feat: add --list-bundles and .list bundles with drift detection
This commit is contained in:
+6
-1
@@ -52,7 +52,8 @@ pub enum McpScopeArg {
|
|||||||
"init_memory", "dry_run", "info", "build_tools", "install",
|
"init_memory", "dry_run", "info", "build_tools", "install",
|
||||||
"install_from", "sync_models", "list_models", "list_roles",
|
"install_from", "sync_models", "list_models", "list_roles",
|
||||||
"list_sessions", "list_agents", "list_rags", "list_macros",
|
"list_sessions", "list_agents", "list_rags", "list_macros",
|
||||||
"list_skills", "skill", "tail_logs", "completions", "update",
|
"list_skills", "list_bundles", "skill", "tail_logs", "completions",
|
||||||
|
"update",
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
group(
|
group(
|
||||||
@@ -175,6 +176,9 @@ pub struct Cli {
|
|||||||
/// List all installed skills
|
/// List all installed skills
|
||||||
#[arg(long, help_heading = "List & Discovery")]
|
#[arg(long, help_heading = "List & Discovery")]
|
||||||
pub list_skills: bool,
|
pub list_skills: bool,
|
||||||
|
/// List installed bundles and their drift status
|
||||||
|
#[arg(long, help_heading = "List & Discovery")]
|
||||||
|
pub list_bundles: bool,
|
||||||
|
|
||||||
/// Reinstall bundled assets, overwriting any local changes
|
/// Reinstall bundled assets, overwriting any local changes
|
||||||
#[arg(
|
#[arg(
|
||||||
@@ -495,6 +499,7 @@ mod tests {
|
|||||||
assert!(parse(&["--list-rags"]).list_rags);
|
assert!(parse(&["--list-rags"]).list_rags);
|
||||||
assert!(parse(&["--list-macros"]).list_macros);
|
assert!(parse(&["--list-macros"]).list_macros);
|
||||||
assert!(parse(&["--list-skills"]).list_skills);
|
assert!(parse(&["--list-skills"]).list_skills);
|
||||||
|
assert!(parse(&["--list-bundles"]).list_bundles);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+215
-1
@@ -158,7 +158,6 @@ impl BundleStore {
|
|||||||
self.bundles.get(name)
|
self.bundles.get(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) fn iter(&self) -> impl Iterator<Item = (&str, &BundleRecord)> {
|
pub(crate) fn iter(&self) -> impl Iterator<Item = (&str, &BundleRecord)> {
|
||||||
self.bundles
|
self.bundles
|
||||||
.iter()
|
.iter()
|
||||||
@@ -410,6 +409,124 @@ pub(crate) fn hash_file(path: &Path) -> Result<String> {
|
|||||||
Ok(hash_bytes(&bytes))
|
Ok(hash_bytes(&bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct DriftSummary {
|
||||||
|
pub(crate) intact: usize,
|
||||||
|
pub(crate) modified: usize,
|
||||||
|
pub(crate) missing: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DriftSummary {
|
||||||
|
pub(crate) fn display(&self) -> String {
|
||||||
|
if self.intact + self.modified + self.missing == 0 {
|
||||||
|
return "-".to_string();
|
||||||
|
}
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if self.intact > 0 {
|
||||||
|
parts.push(format!("{} intact", self.intact));
|
||||||
|
}
|
||||||
|
if self.modified > 0 {
|
||||||
|
parts.push(format!("{} modified locally", self.modified));
|
||||||
|
}
|
||||||
|
if self.missing > 0 {
|
||||||
|
parts.push(format!("{} missing", self.missing));
|
||||||
|
}
|
||||||
|
parts.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct BundleListRow {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) version: String,
|
||||||
|
pub(crate) source: String,
|
||||||
|
pub(crate) git_ref: String,
|
||||||
|
pub(crate) installed_at: String,
|
||||||
|
pub(crate) file_counts: String,
|
||||||
|
pub(crate) drift: DriftSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build one listing row per installed bundle, hashing each owned file under
|
||||||
|
/// `config_dir` against its recorded checksum: a match is intact, a mismatch
|
||||||
|
/// (or unreadable file) counts as locally modified, and an absent file is
|
||||||
|
/// missing. Read-only: the store is never mutated by listing.
|
||||||
|
pub(crate) fn bundle_list_rows(store: &BundleStore, config_dir: &Path) -> Vec<BundleListRow> {
|
||||||
|
store
|
||||||
|
.iter()
|
||||||
|
.map(|(name, record)| {
|
||||||
|
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||||||
|
let mut drift = DriftSummary::default();
|
||||||
|
for file in &record.files {
|
||||||
|
*counts.entry(file.category.as_str()).or_default() += 1;
|
||||||
|
let path = config_dir.join(&file.path);
|
||||||
|
if !path.exists() {
|
||||||
|
drift.missing += 1;
|
||||||
|
} else {
|
||||||
|
match hash_file(&path) {
|
||||||
|
Ok(hash) if hash == file.sha256 => drift.intact += 1,
|
||||||
|
_ => drift.modified += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let file_counts = if counts.is_empty() {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
counts
|
||||||
|
.iter()
|
||||||
|
.map(|(category, count)| format!("{category}: {count}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
};
|
||||||
|
BundleListRow {
|
||||||
|
name: name.to_string(),
|
||||||
|
version: record
|
||||||
|
.version
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| record.commit.chars().take(7).collect()),
|
||||||
|
source: record.source.clone(),
|
||||||
|
git_ref: record.git_ref.clone().unwrap_or_else(|| "-".to_string()),
|
||||||
|
installed_at: record.installed_at.clone(),
|
||||||
|
file_counts,
|
||||||
|
drift,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_installed_bundles() -> Result<()> {
|
||||||
|
let store = BundleStore::load()?;
|
||||||
|
let rows = bundle_list_rows(&store, &paths::config_dir());
|
||||||
|
if rows.is_empty() {
|
||||||
|
println!("No bundles installed. Install one with `coyote --install-from <git-url>`.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut table = super::request_context::asset_table(&[
|
||||||
|
"name",
|
||||||
|
"version",
|
||||||
|
"source",
|
||||||
|
"ref",
|
||||||
|
"installed",
|
||||||
|
"files",
|
||||||
|
"drift",
|
||||||
|
]);
|
||||||
|
for row in rows {
|
||||||
|
table.add_row(vec![
|
||||||
|
row.name.as_str(),
|
||||||
|
&row.version,
|
||||||
|
&row.source,
|
||||||
|
&row.git_ref,
|
||||||
|
&row.installed_at,
|
||||||
|
&row.file_counts,
|
||||||
|
&row.drift.display(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Bundles:");
|
||||||
|
println!("{table}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn sanitize_name_segment(segment: &str) -> String {
|
fn sanitize_name_segment(segment: &str) -> String {
|
||||||
segment
|
segment
|
||||||
.chars()
|
.chars()
|
||||||
@@ -923,4 +1040,101 @@ mod tests {
|
|||||||
}
|
}
|
||||||
result.unwrap();
|
result.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundle_rows_classify_drift_per_file() {
|
||||||
|
let dir = TempStoreDir::new("bundles-list-drift");
|
||||||
|
let mut store = dir.store();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
fs::create_dir_all(dir.0.join("macros")).unwrap();
|
||||||
|
fs::write(dir.0.join("macros/intact.yaml"), "a").unwrap();
|
||||||
|
store
|
||||||
|
.record_file("omc", file_record("macros/intact.yaml", "a"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
fs::create_dir_all(dir.0.join("skills")).unwrap();
|
||||||
|
fs::write(dir.0.join("skills/modified.md"), "changed").unwrap();
|
||||||
|
let mut modified = file_record("skills/modified.md", "original");
|
||||||
|
modified.category = "skills".to_string();
|
||||||
|
store.record_file("omc", modified).unwrap();
|
||||||
|
|
||||||
|
let mut missing = file_record("roles/missing.md", "gone");
|
||||||
|
missing.category = "roles".to_string();
|
||||||
|
store.record_file("omc", missing).unwrap();
|
||||||
|
|
||||||
|
let rows = bundle_list_rows(&store, &dir.0);
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 1);
|
||||||
|
let row = &rows[0];
|
||||||
|
assert_eq!(row.name, "omc");
|
||||||
|
assert_eq!(row.source, "https://github.com/x/omc");
|
||||||
|
assert_eq!(
|
||||||
|
row.drift,
|
||||||
|
DriftSummary {
|
||||||
|
intact: 1,
|
||||||
|
modified: 1,
|
||||||
|
missing: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(row.file_counts, "macros: 1, roles: 1, skills: 1");
|
||||||
|
assert_eq!(
|
||||||
|
row.drift.display(),
|
||||||
|
"1 intact, 1 modified locally, 1 missing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundle_rows_fall_back_to_the_short_commit_when_unversioned() {
|
||||||
|
let dir = TempStoreDir::new("bundles-list-fallback");
|
||||||
|
let mut store = dir.store();
|
||||||
|
store
|
||||||
|
.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123def456"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rows = bundle_list_rows(&store, &dir.0);
|
||||||
|
|
||||||
|
assert_eq!(rows[0].version, "abc123d");
|
||||||
|
assert_eq!(rows[0].git_ref, "-");
|
||||||
|
assert_eq!(rows[0].file_counts, "-");
|
||||||
|
assert_eq!(rows[0].drift, DriftSummary::default());
|
||||||
|
assert_eq!(rows[0].drift.display(), "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundle_rows_show_manifest_version_and_pinned_ref() {
|
||||||
|
let dir = TempStoreDir::new("bundles-list-versioned");
|
||||||
|
let mut store = dir.store();
|
||||||
|
store
|
||||||
|
.upsert_bundle(
|
||||||
|
"omc",
|
||||||
|
InstallMetadata {
|
||||||
|
source: "git@github.com:x/omc.git".to_string(),
|
||||||
|
git_ref: Some("v1.4.0".to_string()),
|
||||||
|
commit: "abc123def456".to_string(),
|
||||||
|
version: Some("1.4.0".to_string()),
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rows = bundle_list_rows(&store, &dir.0);
|
||||||
|
|
||||||
|
assert_eq!(rows[0].version, "1.4.0");
|
||||||
|
assert_eq!(rows[0].git_ref, "v1.4.0");
|
||||||
|
assert_eq!(rows[0].source, "git@github.com:x/omc.git");
|
||||||
|
assert!(!rows[0].installed_at.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundle_rows_are_empty_for_an_empty_store() {
|
||||||
|
let dir = TempStoreDir::new("bundles-list-empty");
|
||||||
|
|
||||||
|
let rows = bundle_list_rows(&dir.store(), &dir.0);
|
||||||
|
|
||||||
|
assert!(rows.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub use self::agent::{
|
|||||||
pub use self::app_config::AppConfig;
|
pub use self::app_config::AppConfig;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::app_state::AppState;
|
pub use self::app_state::AppState;
|
||||||
|
pub use self::bundles::list_installed_bundles;
|
||||||
pub use self::input::Input;
|
pub use self::input::Input;
|
||||||
pub use self::install_remote::{install_remote, install_remote_from_repl_args};
|
pub use self::install_remote::{install_remote, install_remote_from_repl_args};
|
||||||
pub use self::macro_policy::{
|
pub use self::macro_policy::{
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ fn print_asset_names(kind: &str, names: &[String]) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn asset_table(header: &[&str]) -> Table {
|
pub(crate) fn asset_table(header: &[&str]) -> Table {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table.load_preset(UTF8_FULL);
|
table.load_preset(UTF8_FULL);
|
||||||
table.set_content_arrangement(ContentArrangement::Dynamic);
|
table.set_content_arrangement(ContentArrangement::Dynamic);
|
||||||
@@ -2786,8 +2786,9 @@ impl RequestContext {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
"bundles" => super::bundles::list_installed_bundles(),
|
||||||
_ => bail!(
|
_ => bail!(
|
||||||
"Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers"
|
"Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers, bundles"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3327,6 +3328,7 @@ impl RequestContext {
|
|||||||
"skills",
|
"skills",
|
||||||
"tools",
|
"tools",
|
||||||
"mcp-servers",
|
"mcp-servers",
|
||||||
|
"bundles",
|
||||||
]),
|
]),
|
||||||
".vault" => {
|
".vault" => {
|
||||||
let mut values = vec!["add", "get", "update", "delete", "list"];
|
let mut values = vec!["add", "get", "update", "delete", "list"];
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ async fn main() -> Result<()> {
|
|||||||
|| cli.list_rags
|
|| cli.list_rags
|
||||||
|| cli.list_macros
|
|| cli.list_macros
|
||||||
|| cli.list_skills
|
|| cli.list_skills
|
||||||
|
|| cli.list_bundles
|
||||||
|| cli.list_sessions;
|
|| cli.list_sessions;
|
||||||
let vault_flags = cli.add_secret.is_some()
|
let vault_flags = cli.add_secret.is_some()
|
||||||
|| cli.get_secret.is_some()
|
|| cli.get_secret.is_some()
|
||||||
@@ -309,6 +310,9 @@ async fn run(
|
|||||||
println!("{skills}");
|
println!("{skills}");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if cli.list_bundles {
|
||||||
|
return config::list_installed_bundles();
|
||||||
|
}
|
||||||
let skills = cli.skills();
|
let skills = cli.skills();
|
||||||
if skills.len() == 1 {
|
if skills.len() == 1 {
|
||||||
let name = &skills[0];
|
let name = &skills[0];
|
||||||
|
|||||||
+2
-2
@@ -307,7 +307,7 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 60]> = LazyLock::new(|| {
|
|||||||
),
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".list",
|
".list",
|
||||||
"List roles, sessions, agents, RAGs, macros, skills, tools, or MCP servers",
|
"List roles, sessions, agents, RAGs, macros, skills, tools, MCP servers, or bundles",
|
||||||
AssertState::pass(),
|
AssertState::pass(),
|
||||||
),
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
@@ -1208,7 +1208,7 @@ pub async fn run_repl_command(
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!(
|
println!(
|
||||||
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers>"
|
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers|bundles>"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user