diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 9c4394d..2fd2e58 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -52,7 +52,8 @@ pub enum McpScopeArg { "init_memory", "dry_run", "info", "build_tools", "install", "install_from", "sync_models", "list_models", "list_roles", "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( @@ -175,6 +176,9 @@ pub struct Cli { /// List all installed skills #[arg(long, help_heading = "List & Discovery")] 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 #[arg( @@ -495,6 +499,7 @@ mod tests { assert!(parse(&["--list-rags"]).list_rags); assert!(parse(&["--list-macros"]).list_macros); assert!(parse(&["--list-skills"]).list_skills); + assert!(parse(&["--list-bundles"]).list_bundles); } #[test] diff --git a/src/config/bundles.rs b/src/config/bundles.rs index a898007..5be258e 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -158,7 +158,6 @@ impl BundleStore { self.bundles.get(name) } - #[allow(dead_code)] pub(crate) fn iter(&self) -> impl Iterator { self.bundles .iter() @@ -410,6 +409,124 @@ pub(crate) fn hash_file(path: &Path) -> Result { 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 { + 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::>() + .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 `."); + 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 { segment .chars() @@ -923,4 +1040,101 @@ mod tests { } 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()); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index ca9de0e..c13c297 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -30,6 +30,7 @@ pub use self::agent::{ pub use self::app_config::AppConfig; #[allow(unused_imports)] pub use self::app_state::AppState; +pub use self::bundles::list_installed_bundles; pub use self::input::Input; pub use self::install_remote::{install_remote, install_remote_from_repl_args}; pub use self::macro_policy::{ diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 24597b6..8b8b7b8 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -112,7 +112,7 @@ fn print_asset_names(kind: &str, names: &[String]) -> Result<()> { Ok(()) } -fn asset_table(header: &[&str]) -> Table { +pub(crate) fn asset_table(header: &[&str]) -> Table { let mut table = Table::new(); table.load_preset(UTF8_FULL); table.set_content_arrangement(ContentArrangement::Dynamic); @@ -2786,8 +2786,9 @@ impl RequestContext { } Ok(()) } + "bundles" => super::bundles::list_installed_bundles(), _ => 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", "tools", "mcp-servers", + "bundles", ]), ".vault" => { let mut values = vec!["add", "get", "update", "delete", "list"]; diff --git a/src/main.rs b/src/main.rs index 68be6c7..4ecc548 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,6 +107,7 @@ async fn main() -> Result<()> { || cli.list_rags || cli.list_macros || cli.list_skills + || cli.list_bundles || cli.list_sessions; let vault_flags = cli.add_secret.is_some() || cli.get_secret.is_some() @@ -309,6 +310,9 @@ async fn run( println!("{skills}"); return Ok(()); } + if cli.list_bundles { + return config::list_installed_bundles(); + } let skills = cli.skills(); if skills.len() == 1 { let name = &skills[0]; diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 6f7bbf2..0d63c30 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -307,7 +307,7 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 60]> = LazyLock::new(|| { ), ReplCommand::new( ".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(), ), ReplCommand::new( @@ -1208,7 +1208,7 @@ pub async fn run_repl_command( } _ => { println!( - "Usage: .list " + "Usage: .list " ) } },