feat: add --list-bundles and .list bundles with drift detection

This commit is contained in:
2026-08-24 11:15:12 -06:00
parent 88acf2362f
commit 2790a823b0
6 changed files with 232 additions and 6 deletions
+6 -1
View File
@@ -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]
+215 -1
View File
@@ -158,7 +158,6 @@ impl BundleStore {
self.bundles.get(name)
}
#[allow(dead_code)]
pub(crate) fn iter(&self) -> impl Iterator<Item = (&str, &BundleRecord)> {
self.bundles
.iter()
@@ -410,6 +409,124 @@ pub(crate) fn hash_file(path: &Path) -> Result<String> {
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 {
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());
}
}
+1
View File
@@ -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::{
+4 -2
View File
@@ -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"];
+4
View File
@@ -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];
+2 -2
View File
@@ -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 <roles|sessions|agents|rags|macros|skills|tools|mcp-servers>"
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers|bundles>"
)
}
},