From b21699b7496949fe87d71f4a9b0812eeb04383c0 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 13:01:54 -0600 Subject: [PATCH 01/20] feat: parse bundle manifests and capture resolved SHAs for remote installs --- src/config/install_remote.rs | 552 ++++++++++++++++++++++++++++++++++- 1 file changed, 542 insertions(+), 10 deletions(-) diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index f5a8eaf..2ae5950 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -9,6 +9,7 @@ use anyhow::{Context, Result, anyhow, bail}; use indexmap::IndexMap; use indoc::formatdoc; use inquire::{Confirm, Select}; +use serde::Deserialize; use std::ffi::{OsStr, OsString}; use std::fs; use std::path::{Path, PathBuf}; @@ -18,7 +19,8 @@ pub fn install_remote(git_url: &str, filter: Option, force: bool) let temp = clone_to_temp(&url, reference.as_deref())?; println!("Cloned {git_url} to {}", temp.path().display()); - let layout = scan_remote_layout(temp.path())?; + let mut layout = scan_remote_layout(temp.path())?; + layout.head_sha = Some(temp.head_sha().to_string()); let layout = apply_filter(layout, filter); if layout.is_empty() { @@ -121,12 +123,17 @@ fn parse_url_with_ref(input: &str) -> Result<(String, Option)> { struct TempRepoDir { path: PathBuf, + head_sha: String, } impl TempRepoDir { fn path(&self) -> &Path { &self.path } + + fn head_sha(&self) -> &str { + &self.head_sha + } } impl Drop for TempRepoDir { @@ -170,7 +177,17 @@ fn clone_to_temp(url: &str, reference: Option<&str>) -> Result { } } - Ok(TempRepoDir { path: dest }) + let head_sha = run_git_capture(vec![ + "-C".into(), + dest.as_os_str().into(), + "rev-parse".into(), + "HEAD".into(), + ])?; + + Ok(TempRepoDir { + path: dest, + head_sha, + }) } fn run_git(args: Vec) -> Result<()> { @@ -189,7 +206,24 @@ fn run_git(args: Vec) -> Result<()> { Ok(()) } -#[derive(Default)] +fn run_git_capture(args: Vec) -> Result { + let output = duct::cmd("git", &args) + .stdout_capture() + .stderr_capture() + .unchecked() + .run() + .context("failed to spawn git (is it installed and on PATH?)")?; + + if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("git failed: {}", format!("{stdout} {stderr}").trim()); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[derive(Debug, Default)] struct RemoteLayout { agents: Option, roles: Option, @@ -197,6 +231,8 @@ struct RemoteLayout { macros: Option, functions_tools: Option, mcp_json: Option, + manifest: Option, + head_sha: Option, } impl RemoteLayout { @@ -211,7 +247,10 @@ impl RemoteLayout { } fn scan_remote_layout(root: &Path) -> Result { - let mut layout = RemoteLayout::default(); + let mut layout = RemoteLayout { + manifest: parse_bundle_manifest(root)?, + ..RemoteLayout::default() + }; let agents = root.join("agents"); if agents.is_dir() { @@ -249,34 +288,178 @@ fn scan_remote_layout(root: &Path) -> Result { Ok(layout) } +const BUNDLE_MANIFEST_FILE: &str = "coyote-bundle.yaml"; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct BundleManifest { + pub(crate) name: String, + #[allow(dead_code)] + pub(crate) version: Option, + #[allow(dead_code)] + pub(crate) description: Option, + #[allow(dead_code)] + pub(crate) homepage: Option, +} + +fn parse_bundle_manifest(root: &Path) -> Result> { + let path = root.join(BUNDLE_MANIFEST_FILE); + if !path.is_file() { + return Ok(None); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("failed to read bundle manifest at {}", path.display()))?; + let manifest: BundleManifest = serde_yaml::from_str(&content) + .with_context(|| format!("invalid bundle manifest at {}", path.display()))?; + validate_bundle_name(&manifest.name) + .with_context(|| format!("invalid bundle name in manifest at {}", path.display()))?; + Ok(Some(manifest)) +} + +pub(crate) fn validate_bundle_name(name: &str) -> Result<()> { + let (owner, base) = match name.split_once('/') { + Some((owner, base)) => (Some(owner), base), + None => (None, name), + }; + if base.contains('/') { + bail!( + "Invalid bundle name '{name}': at most one '/' is allowed \ + (as the owner qualifier separator)" + ); + } + for part in owner.into_iter().chain(std::iter::once(base)) { + if part.is_empty() { + bail!("Invalid bundle name '{name}': name segments cannot be empty"); + } + if !part + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + bail!( + "Invalid bundle name '{name}': only letters, digits, '-', and '_' are allowed \ + (plus a single '/' separating an owner qualifier)" + ); + } + } + Ok(()) +} + +fn strip_ref_suffix(url: &str) -> &str { + match url.rsplit_once('#') { + Some((base, _)) if !base.is_empty() => base, + _ => url, + } +} + +fn split_host_and_path(url: &str) -> (String, String) { + let url = strip_ref_suffix(url); + if let Some((_, rest)) = url.split_once("://") { + let (authority, path) = rest.split_once('/').unwrap_or((rest, "")); + let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + (host.to_string(), path.trim_matches('/').to_string()) + } else if let Some((prefix, path)) = url.split_once(':') + && !prefix.contains('/') + { + let host = prefix.rsplit_once('@').map_or(prefix, |(_, h)| h); + (host.to_string(), path.trim_matches('/').to_string()) + } else { + (String::new(), url.trim_end_matches('/').to_string()) + } +} + +fn strip_git_suffix(segment: &str) -> &str { + if segment.len() > 4 && segment.to_ascii_lowercase().ends_with(".git") { + &segment[..segment.len() - 4] + } else { + segment + } +} + +#[allow(dead_code)] +pub(crate) fn repo_name_slug(url: &str) -> String { + let (_, path) = split_host_and_path(url); + let last = path.rsplit('/').next().unwrap_or(""); + strip_git_suffix(last).to_string() +} + +#[allow(dead_code)] +pub(crate) fn owner_qualifier(url: &str) -> Option { + let (host, path) = split_host_and_path(url); + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + if segments.len() >= 2 { + return Some(segments[segments.len() - 2].to_string()); + } + let sanitized = sanitize_host(&host); + (!sanitized.is_empty()).then_some(sanitized) +} + +fn sanitize_host(host: &str) -> String { + host.to_ascii_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string() +} + +#[allow(dead_code)] +pub(crate) fn canonical_source_url(url: &str) -> String { + let (host, path) = split_host_and_path(url); + let mut path = path.to_ascii_lowercase(); + if let Some(stripped) = path.strip_suffix(".git") + && !stripped.is_empty() + && !stripped.ends_with('/') + { + path = stripped.to_string(); + } + let host = host.to_ascii_lowercase(); + if host.is_empty() { + path + } else if path.is_empty() { + host + } else { + format!("{host}/{path}") + } +} + fn apply_filter(mut layout: RemoteLayout, filter: Option) -> RemoteLayout { let Some(filter) = filter else { return layout; }; + let base = RemoteLayout { + manifest: layout.manifest.take(), + head_sha: layout.head_sha.take(), + ..RemoteLayout::default() + }; match filter { InstallFilter::Agents => RemoteLayout { agents: layout.agents.take(), - ..RemoteLayout::default() + ..base }, InstallFilter::Roles => RemoteLayout { roles: layout.roles.take(), - ..RemoteLayout::default() + ..base }, InstallFilter::Skills => RemoteLayout { skills: layout.skills.take(), - ..RemoteLayout::default() + ..base }, InstallFilter::Macros => RemoteLayout { macros: layout.macros.take(), - ..RemoteLayout::default() + ..base }, InstallFilter::Functions => RemoteLayout { functions_tools: layout.functions_tools.take(), - ..RemoteLayout::default() + ..base }, InstallFilter::McpConfig => RemoteLayout { mcp_json: layout.mcp_json.take(), - ..RemoteLayout::default() + ..base }, } } @@ -1104,6 +1287,7 @@ mod tests { macros: Some(PathBuf::from("m")), functions_tools: Some(PathBuf::from("f")), mcp_json: Some(PathBuf::from("j")), + ..RemoteLayout::default() }; let out = apply_filter(l, None); @@ -1121,6 +1305,7 @@ mod tests { macros: None, functions_tools: Some(PathBuf::from("f")), mcp_json: Some(PathBuf::from("j")), + ..RemoteLayout::default() }; let out = apply_filter(l, Some(InstallFilter::Functions)); @@ -1140,6 +1325,7 @@ mod tests { macros: None, functions_tools: Some(PathBuf::from("f")), mcp_json: Some(PathBuf::from("j")), + ..RemoteLayout::default() }; let out = apply_filter(l, Some(InstallFilter::McpConfig)); @@ -1157,6 +1343,7 @@ mod tests { macros: Some(PathBuf::from("m")), functions_tools: Some(PathBuf::from("f")), mcp_json: Some(PathBuf::from("j")), + ..RemoteLayout::default() }; let out = apply_filter(l, Some(InstallFilter::Roles)); @@ -1175,6 +1362,7 @@ mod tests { macros: Some(PathBuf::from("m")), functions_tools: Some(PathBuf::from("f")), mcp_json: Some(PathBuf::from("j")), + ..RemoteLayout::default() }; let out = apply_filter(l, Some(InstallFilter::Skills)); @@ -1493,4 +1681,348 @@ mod tests { assert!(handle_missing_secrets(&missing).is_ok()); } + + fn write_manifest(root: &Path, yaml: &str) { + fs::write(root.join(BUNDLE_MANIFEST_FILE), yaml).unwrap(); + } + + #[test] + fn scan_remote_layout_without_manifest_has_none() { + let root = fresh_temp_dir("scan-no-manifest-"); + fs::create_dir_all(root.join("macros")).unwrap(); + + let layout = scan_remote_layout(&root).unwrap(); + + assert_eq!(layout.manifest, None); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn scan_remote_layout_parses_full_manifest_and_ignores_unknown_fields() { + let root = fresh_temp_dir("scan-manifest-full-"); + fs::create_dir_all(root.join("macros")).unwrap(); + write_manifest( + &root, + "name: oh-my-coyote\n\ + version: \"1.4.0\"\n\ + description: Opinionated roles and macros\n\ + homepage: https://github.com/x/oh-my-coyote\n\ + future_field: ignored\n", + ); + + let manifest = scan_remote_layout(&root).unwrap().manifest.unwrap(); + + assert_eq!(manifest.name, "oh-my-coyote"); + assert_eq!(manifest.version.as_deref(), Some("1.4.0")); + assert_eq!( + manifest.description.as_deref(), + Some("Opinionated roles and macros") + ); + assert_eq!( + manifest.homepage.as_deref(), + Some("https://github.com/x/oh-my-coyote") + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn scan_remote_layout_parses_name_only_manifest() { + let root = fresh_temp_dir("scan-manifest-minimal-"); + write_manifest(&root, "name: minimal\n"); + + let manifest = scan_remote_layout(&root).unwrap().manifest.unwrap(); + + assert_eq!(manifest.name, "minimal"); + assert_eq!(manifest.version, None); + assert_eq!(manifest.description, None); + assert_eq!(manifest.homepage, None); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn scan_remote_layout_fails_on_malformed_manifest() { + let root = fresh_temp_dir("scan-manifest-malformed-"); + write_manifest(&root, "name: [unclosed\n"); + + let err = scan_remote_layout(&root).unwrap_err(); + + assert!( + format!("{err:#}").contains("invalid bundle manifest"), + "got: {err:#}" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn scan_remote_layout_fails_on_manifest_missing_name() { + let root = fresh_temp_dir("scan-manifest-no-name-"); + write_manifest(&root, "version: \"1.0\"\n"); + + let err = scan_remote_layout(&root).unwrap_err(); + + assert!( + format!("{err:#}").contains("invalid bundle manifest"), + "got: {err:#}" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn scan_remote_layout_fails_on_invalid_manifest_name() { + let root = fresh_temp_dir("scan-manifest-bad-name-"); + write_manifest(&root, "name: not a valid name\n"); + + let err = scan_remote_layout(&root).unwrap_err(); + + assert!( + format!("{err:#}").contains("Invalid bundle name"), + "got: {err:#}" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn apply_filter_carries_manifest_and_head_sha_through() { + let l = RemoteLayout { + macros: Some(PathBuf::from("m")), + manifest: Some(BundleManifest { + name: "bundle".to_string(), + version: None, + description: None, + homepage: None, + }), + head_sha: Some("abc123".to_string()), + ..RemoteLayout::default() + }; + + let out = apply_filter(l, Some(InstallFilter::Macros)); + + assert_eq!(out.macros, Some(PathBuf::from("m"))); + assert_eq!(out.manifest.unwrap().name, "bundle"); + assert_eq!(out.head_sha.as_deref(), Some("abc123")); + } + + #[test] + fn validate_bundle_name_accepts_simple_names() { + assert!(validate_bundle_name("oh-my-coyote").is_ok()); + assert!(validate_bundle_name("under_score").is_ok()); + assert!(validate_bundle_name("Abc123").is_ok()); + } + + #[test] + fn validate_bundle_name_accepts_owner_qualified_form() { + assert!(validate_bundle_name("x/oh-my-coyote").is_ok()); + } + + #[test] + fn validate_bundle_name_rejects_empty() { + assert!(validate_bundle_name("").is_err()); + } + + #[test] + fn validate_bundle_name_rejects_multiple_slashes() { + assert!(validate_bundle_name("a/b/c").is_err()); + } + + #[test] + fn validate_bundle_name_rejects_empty_segments() { + assert!(validate_bundle_name("/repo").is_err()); + assert!(validate_bundle_name("owner/").is_err()); + assert!(validate_bundle_name("/").is_err()); + } + + #[test] + fn validate_bundle_name_rejects_disallowed_characters() { + assert!(validate_bundle_name("bad name").is_err()); + assert!(validate_bundle_name("dot.name").is_err()); + assert!(validate_bundle_name("owner/bad!base").is_err()); + } + + #[test] + fn repo_name_slug_from_https_url() { + assert_eq!( + repo_name_slug("https://github.com/x/oh-my-coyote"), + "oh-my-coyote" + ); + } + + #[test] + fn repo_name_slug_strips_git_suffix() { + assert_eq!( + repo_name_slug("https://github.com/x/oh-my-coyote.git"), + "oh-my-coyote" + ); + } + + #[test] + fn repo_name_slug_from_scp_style_url() { + assert_eq!( + repo_name_slug("git@github.com:x/oh-my-coyote.git"), + "oh-my-coyote" + ); + } + + #[test] + fn repo_name_slug_ignores_ref_suffix() { + assert_eq!( + repo_name_slug("https://github.com/x/repo.git#release/v2"), + "repo" + ); + } + + #[test] + fn repo_name_slug_ignores_trailing_slash() { + assert_eq!(repo_name_slug("https://github.com/x/repo/"), "repo"); + } + + #[test] + fn owner_qualifier_from_https_url() { + assert_eq!( + owner_qualifier("https://github.com/x/repo.git").as_deref(), + Some("x") + ); + } + + #[test] + fn owner_qualifier_from_scp_style_url() { + assert_eq!( + owner_qualifier("git@github.com:x/repo.git").as_deref(), + Some("x") + ); + } + + #[test] + fn owner_qualifier_falls_back_to_sanitized_host() { + assert_eq!( + owner_qualifier("https://example.com/repo.git").as_deref(), + Some("example-com") + ); + } + + #[test] + fn owner_qualifier_scp_without_owner_falls_back_to_host() { + assert_eq!( + owner_qualifier("git@host.example.com:repo.git").as_deref(), + Some("host-example-com") + ); + } + + #[test] + fn owner_qualifier_none_without_owner_or_host() { + assert_eq!(owner_qualifier("repo"), None); + } + + #[test] + fn canonical_source_url_treats_equivalent_forms_identically() { + let canonical = canonical_source_url("https://github.com/x/r"); + + assert_eq!(canonical, "github.com/x/r"); + assert_eq!( + canonical_source_url("https://github.com/x/r.git"), + canonical + ); + assert_eq!(canonical_source_url("git@github.com:x/r.git"), canonical); + } + + #[test] + fn canonical_source_url_lowercases_host_and_path() { + assert_eq!( + canonical_source_url("https://GitHub.COM/X/R.git"), + "github.com/x/r" + ); + } + + #[test] + fn canonical_source_url_ignores_ref_suffix() { + assert_eq!( + canonical_source_url("https://github.com/x/r.git#main"), + "github.com/x/r" + ); + } + + #[test] + fn canonical_source_url_strips_userinfo() { + assert_eq!( + canonical_source_url("https://user@github.com/x/r.git"), + "github.com/x/r" + ); + } + + fn git_in(dir: &Path, args: &[&str]) { + let mut full: Vec = vec!["-C".into(), dir.as_os_str().into()]; + full.extend(args.iter().map(OsString::from)); + run_git(full).unwrap(); + } + + fn commit_file(dir: &Path, name: &str, content: &str) -> String { + fs::write(dir.join(name), content).unwrap(); + git_in(dir, &["add", "."]); + git_in( + dir, + &[ + "-c", + "user.email=coyote-test@localhost", + "-c", + "user.name=coyote-test", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + name, + ], + ); + run_git_capture(vec![ + "-C".into(), + dir.as_os_str().into(), + "rev-parse".into(), + "HEAD".into(), + ]) + .unwrap() + } + + fn init_git_repo(dir: &Path) -> String { + run_git(vec!["init".into(), "-q".into(), dir.as_os_str().into()]).unwrap(); + commit_file(dir, "seed.txt", "one") + } + + #[test] + fn clone_to_temp_captures_resolved_head_sha() { + let repo = fresh_temp_dir("clone-sha-"); + let sha = init_git_repo(&repo); + + let temp = clone_to_temp(repo.to_str().unwrap(), None).unwrap(); + + assert_eq!(temp.head_sha(), sha); + assert_eq!(temp.head_sha().len(), 40); + assert!(temp.head_sha().chars().all(|c| c.is_ascii_hexdigit())); + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn clone_to_temp_respects_sha_ref_pinning() { + let repo = fresh_temp_dir("clone-pin-sha-"); + let first = init_git_repo(&repo); + let second = commit_file(&repo, "next.txt", "two"); + assert_ne!(first, second); + + let temp = clone_to_temp(repo.to_str().unwrap(), Some(&first)).unwrap(); + + assert_eq!(temp.head_sha(), first); + let _ = fs::remove_dir_all(&repo); + } + + #[test] + fn clone_to_temp_respects_branch_ref_pinning() { + let repo = fresh_temp_dir("clone-pin-branch-"); + let first = init_git_repo(&repo); + git_in(&repo, &["branch", "pinned"]); + let second = commit_file(&repo, "next.txt", "two"); + assert_ne!(first, second); + + let temp = clone_to_temp(repo.to_str().unwrap(), Some("pinned")).unwrap(); + + assert_eq!(temp.head_sha(), first); + let _ = fs::remove_dir_all(&repo); + } } From bfcc762ec95419a5da27dbb7e34baf7395b7d19d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 13:15:47 -0600 Subject: [PATCH 02/20] feat: add bundle provenance store --- src/config/bundles.rs | 903 +++++++++++++++++++++++++++++++++++ src/config/install_remote.rs | 3 - src/config/mod.rs | 3 + src/config/paths.rs | 12 +- 4 files changed, 914 insertions(+), 7 deletions(-) create mode 100644 src/config/bundles.rs diff --git a/src/config/bundles.rs b/src/config/bundles.rs new file mode 100644 index 0000000..255e85f --- /dev/null +++ b/src/config/bundles.rs @@ -0,0 +1,903 @@ +use super::install_remote::{ + canonical_source_url, owner_qualifier, repo_name_slug, validate_bundle_name, +}; +use super::paths; +use crate::function::write_file_atomic; + +use anyhow::{Context, Result, bail}; +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum FileAction { + New, + Replaced, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum McpAction { + Added, + Replaced, + Renamed, + Transferred, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct FileRecord { + /// Relative to the config dir. + pub(crate) path: String, + pub(crate) category: String, + /// Content hash at install time; a later mismatch means the user modified the file. + pub(crate) sha256: String, + pub(crate) action: FileAction, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct McpServerRecord { + pub(crate) name: String, + pub(crate) action: McpAction, + pub(crate) renamed_to: Option, +} + +impl McpServerRecord { + /// The key this entry actually occupies in mcp.json (the rename target, if any). + pub(crate) fn effective_key(&self) -> &str { + self.renamed_to.as_deref().unwrap_or(&self.name) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct BundleRecord { + pub(crate) source: String, + #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")] + pub(crate) git_ref: Option, + pub(crate) commit: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) version: Option, + pub(crate) installed_at: String, + #[serde(default)] + pub(crate) files: Vec, + #[serde(default)] + pub(crate) mcp_servers: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct InstallMetadata { + pub(crate) source: String, + pub(crate) git_ref: Option, + pub(crate) commit: String, + pub(crate) version: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ResolvedBundleName { + pub(crate) name: String, + /// The unqualified name this install asked for, when it had to be owner-qualified. + pub(crate) qualified_from: Option, + /// The record key this source was previously tracked under, when it changed. + pub(crate) migrated_from: Option, + /// Source URL of the different-source bundle that already holds the unqualified name. + pub(crate) same_name_other_source: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct StoreContents { + #[serde(default)] + bundles: BTreeMap, +} + +#[derive(Serialize)] +struct StoreContentsRef<'a> { + bundles: &'a BTreeMap, +} + +#[derive(Debug)] +pub(crate) struct BundleStore { + path: PathBuf, + bundles: BTreeMap, +} + +impl BundleStore { + pub(crate) fn load() -> Result { + Self::load_from(paths::installed_bundles_file()) + } + + /// A corrupt store is an error, never an empty store: treating it as empty + /// would let a reinstall re-acquire ownership over files the user may have + /// modified since. + pub(crate) fn load_from(path: PathBuf) -> Result { + if !path.exists() { + return Ok(Self { + path, + bundles: BTreeMap::new(), + }); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let contents: StoreContents = serde_yaml::from_str(&content).with_context(|| { + format!( + "failed to parse {}; refusing to treat it as empty. \ + Fix or remove the file to continue", + path.display() + ) + })?; + Ok(Self { + path, + bundles: contents.bundles, + }) + } + + pub(crate) fn save(&self) -> Result<()> { + let content = serde_yaml::to_string(&StoreContentsRef { + bundles: &self.bundles, + }) + .context("failed to serialize the installed-bundles store")?; + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create directory {}", parent.display()))?; + } + write_file_atomic(&self.path, &content, None) + .with_context(|| format!("failed to write {}", self.path.display())) + } + + pub(crate) fn get(&self, name: &str) -> Option<&BundleRecord> { + self.bundles.get(name) + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.bundles + .iter() + .map(|(name, record)| (name.as_str(), record)) + } + + pub(crate) fn bundle_names(&self) -> Vec<&str> { + self.bundles.keys().map(String::as_str).collect() + } + + /// Look up the record installed from `url`, comparing canonical source URLs + /// so https/scp/`.git` spellings of the same remote all match. + pub(crate) fn find_by_source(&self, url: &str) -> Option<(&str, &BundleRecord)> { + let canonical = canonical_source_url(url); + self.bundles + .iter() + .find(|(_, record)| canonical_source_url(&record.source) == canonical) + .map(|(name, record)| (name.as_str(), record)) + } + + /// Decide the record key for an install from `url`, matching by canonical + /// source URL first and name second. If the URL is already tracked under a + /// different key (manifest name added, renamed, or removed since install), + /// the existing record is migrated to the new key — the same URL never gets + /// a second record. A name held by a different-source bundle is + /// owner-qualified instead. Both cases print a notice. + pub(crate) fn resolve_bundle_name( + &mut self, + url: &str, + manifest_name: Option<&str>, + ) -> Result { + let canonical = canonical_source_url(url); + let existing_key = self + .bundles + .iter() + .find(|(_, record)| canonical_source_url(&record.source) == canonical) + .map(|(name, _)| name.clone()); + + let base = match manifest_name { + Some(name) => { + validate_bundle_name(name)?; + name.to_string() + } + None => { + let slug = sanitize_name_segment(&repo_name_slug(url)); + if slug.is_empty() { + bail!( + "cannot derive a bundle name from '{url}'; \ + add a coyote-bundle.yaml manifest with a name" + ); + } + slug + } + }; + + let mut resolved = ResolvedBundleName { + name: base.clone(), + qualified_from: None, + migrated_from: None, + same_name_other_source: None, + }; + + if let Some(other_source) = self.source_of_other_bundle(&base, &canonical) { + if base.contains('/') { + bail!( + "bundle name '{base}' is already used by an install from \ + '{other_source}' and cannot be qualified further; \ + uninstall it or pick a different manifest name" + ); + } + let owner = owner_qualifier(url) + .map(|owner| sanitize_name_segment(&owner)) + .filter(|owner| !owner.is_empty()); + let Some(owner) = owner else { + bail!( + "bundle name '{base}' is already used by an install from \ + '{other_source}', and no owner qualifier can be derived from '{url}'" + ); + }; + let qualified = format!("{owner}/{base}"); + if let Some(source) = self.source_of_other_bundle(&qualified, &canonical) { + bail!( + "bundle names '{base}' and '{qualified}' are both used by installs \ + from other sources ('{other_source}', '{source}'); \ + uninstall one or pick a different manifest name" + ); + } + resolved.name = qualified; + resolved.qualified_from = Some(base); + resolved.same_name_other_source = Some(other_source); + } + + let already_recorded = existing_key.as_deref() == Some(resolved.name.as_str()); + if let Some(old_key) = existing_key + && !already_recorded + { + let record = self + .bundles + .remove(&old_key) + .expect("existing_key was found in the map"); + self.bundles.insert(resolved.name.clone(), record); + println!( + "Bundle '{old_key}' from {url} is now tracked as '{}'.", + resolved.name + ); + resolved.migrated_from = Some(old_key); + self.save()?; + } + + if let (Some(from), false) = (&resolved.qualified_from, already_recorded) { + let other = resolved + .same_name_other_source + .as_deref() + .unwrap_or_default(); + println!( + "Bundle name '{from}' is already used by an install from '{other}'; \ + tracking this install as '{}'.", + resolved.name + ); + } + + Ok(resolved) + } + + /// Create or update the record's metadata and persist it. Repeated installs + /// of the same bundle (e.g. with different filters) merge into one record: + /// metadata is refreshed, files accumulate, and the original install time + /// is kept. + pub(crate) fn upsert_bundle(&mut self, name: &str, metadata: InstallMetadata) -> Result<()> { + match self.bundles.get_mut(name) { + Some(record) => { + record.source = metadata.source; + record.git_ref = metadata.git_ref; + record.commit = metadata.commit; + record.version = metadata.version; + } + None => { + self.bundles.insert( + name.to_string(), + BundleRecord { + source: metadata.source, + git_ref: metadata.git_ref, + commit: metadata.commit, + version: metadata.version, + installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + files: Vec::new(), + mcp_servers: Vec::new(), + }, + ); + } + } + self.save() + } + + /// Record one written file and persist immediately, so an install aborted + /// partway through still has provenance for everything already on disk. + /// A path owned by another bundle transfers to `bundle`. + pub(crate) fn record_file(&mut self, bundle: &str, file: FileRecord) -> Result<()> { + self.ensure_bundle_exists(bundle)?; + for (name, record) in self.bundles.iter_mut() { + if name != bundle { + record.files.retain(|owned| owned.path != file.path); + } + } + let record = self + .bundles + .get_mut(bundle) + .expect("bundle existence checked above"); + record.files.retain(|owned| owned.path != file.path); + record.files.push(file); + self.save() + } + + /// Record the mcp.json entries an install wrote, in one persisted flush. + /// An entry whose key another bundle owns transfers to `bundle`: the old + /// owner drops it, and a `replaced` action is upgraded to `transferred` + /// (removable at uninstall — plain `replaced` marks a pre-existing user + /// entry that uninstall must never delete). + pub(crate) fn record_mcp_servers( + &mut self, + bundle: &str, + entries: Vec, + ) -> Result<()> { + self.ensure_bundle_exists(bundle)?; + for mut entry in entries { + let key = entry.effective_key().to_string(); + let mut previously_owned = false; + for (name, record) in self.bundles.iter_mut() { + if name == bundle { + continue; + } + let before = record.mcp_servers.len(); + record + .mcp_servers + .retain(|owned| owned.effective_key() != key); + previously_owned |= record.mcp_servers.len() != before; + } + if previously_owned && entry.action == McpAction::Replaced { + entry.action = McpAction::Transferred; + } + let record = self + .bundles + .get_mut(bundle) + .expect("bundle existence checked above"); + record + .mcp_servers + .retain(|owned| owned.effective_key() != key); + record.mcp_servers.push(entry); + } + self.save() + } + + fn ensure_bundle_exists(&self, bundle: &str) -> Result<()> { + if !self.bundles.contains_key(bundle) { + bail!( + "no installed bundle named '{bundle}' (installed: {})", + if self.bundles.is_empty() { + "none".to_string() + } else { + self.bundle_names().join(", ") + } + ); + } + Ok(()) + } + + fn source_of_other_bundle(&self, name: &str, canonical: &str) -> Option { + self.bundles + .get(name) + .filter(|record| canonical_source_url(&record.source) != canonical) + .map(|record| record.source.clone()) + } +} + +pub(crate) fn hash_bytes(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +pub(crate) fn hash_file(path: &Path) -> Result { + let bytes = + fs::read(path).with_context(|| format!("failed to read {} for hashing", path.display()))?; + Ok(hash_bytes(&bytes)) +} + +fn sanitize_name_segment(segment: &str) -> String { + segment + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::{get_env_name, temp_file}; + use serial_test::serial; + use std::env; + use std::ffi::OsString; + + struct TempStoreDir(PathBuf); + + impl TempStoreDir { + fn new(label: &str) -> Self { + let dir = temp_file(label, ""); + fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn store_path(&self) -> PathBuf { + self.0.join("installed-bundles.yaml") + } + + fn store(&self) -> BundleStore { + BundleStore::load_from(self.store_path()).unwrap() + } + } + + impl Drop for TempStoreDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn metadata(source: &str, commit: &str) -> InstallMetadata { + InstallMetadata { + source: source.to_string(), + git_ref: None, + commit: commit.to_string(), + version: None, + } + } + + fn file_record(path: &str, contents: &str) -> FileRecord { + FileRecord { + path: path.to_string(), + category: "macros".to_string(), + sha256: hash_bytes(contents.as_bytes()), + action: FileAction::New, + } + } + + fn mcp_record(name: &str, action: McpAction, renamed_to: Option<&str>) -> McpServerRecord { + McpServerRecord { + name: name.to_string(), + action, + renamed_to: renamed_to.map(str::to_string), + } + } + + #[test] + fn load_missing_file_yields_empty_store() { + let dir = TempStoreDir::new("bundles-empty"); + + let store = dir.store(); + + assert!(store.bundle_names().is_empty()); + } + + #[test] + fn corrupt_store_fails_closed() { + let dir = TempStoreDir::new("bundles-corrupt"); + fs::write(dir.store_path(), "bundles:\n - this is not a map\n").unwrap(); + + let result = BundleStore::load_from(dir.store_path()); + + let message = format!("{:#}", result.unwrap_err()); + assert!( + message.contains("refusing to treat it as empty"), + "{message}" + ); + } + + #[test] + fn save_and_reload_roundtrip() { + let dir = TempStoreDir::new("bundles-roundtrip"); + let mut store = dir.store(); + store + .upsert_bundle( + "omc", + InstallMetadata { + source: "https://github.com/x/omc".to_string(), + git_ref: Some("main".to_string()), + commit: "abc123".to_string(), + version: Some("1.4.0".to_string()), + }, + ) + .unwrap(); + store + .record_file("omc", file_record("macros/a.yaml", "a")) + .unwrap(); + store + .record_mcp_servers("omc", vec![mcp_record("srv", McpAction::Added, None)]) + .unwrap(); + + let raw = fs::read_to_string(dir.store_path()).unwrap(); + let reloaded = dir.store(); + + assert!(raw.contains("ref: main"), "{raw}"); + assert!(!raw.contains("git_ref"), "{raw}"); + assert!(raw.contains("action: added"), "{raw}"); + let record = reloaded.get("omc").unwrap(); + assert_eq!(record.git_ref.as_deref(), Some("main")); + assert_eq!(record.version.as_deref(), Some("1.4.0")); + assert_eq!(record.files.len(), 1); + assert_eq!(record.mcp_servers.len(), 1); + } + + #[test] + fn files_recorded_before_an_abort_survive_it() { + let dir = TempStoreDir::new("bundles-abort"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + + store + .record_file("omc", file_record("macros/a.yaml", "a")) + .unwrap(); + store + .record_file("omc", file_record("skills/b.md", "b")) + .unwrap(); + drop(store); + + let reloaded = dir.store(); + let paths: Vec<&str> = reloaded + .get("omc") + .unwrap() + .files + .iter() + .map(|f| f.path.as_str()) + .collect(); + assert_eq!(paths, vec!["macros/a.yaml", "skills/b.md"]); + } + + #[test] + fn recording_the_same_path_twice_updates_in_place() { + let dir = TempStoreDir::new("bundles-dedupe"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + + store + .record_file("omc", file_record("macros/a.yaml", "v1")) + .unwrap(); + let mut updated = file_record("macros/a.yaml", "v2"); + updated.action = FileAction::Replaced; + store.record_file("omc", updated).unwrap(); + + let record = store.get("omc").unwrap(); + assert_eq!(record.files.len(), 1); + assert_eq!(record.files[0].sha256, hash_bytes(b"v2")); + assert_eq!(record.files[0].action, FileAction::Replaced); + } + + #[test] + fn overwritten_file_transfers_ownership() { + let dir = TempStoreDir::new("bundles-transfer"); + let mut store = dir.store(); + store + .upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123")) + .unwrap(); + store + .upsert_bundle("beta", metadata("https://github.com/b/beta", "def456")) + .unwrap(); + store + .record_file("alpha", file_record("macros/shared.yaml", "a")) + .unwrap(); + + let mut taken = file_record("macros/shared.yaml", "b"); + taken.action = FileAction::Replaced; + store.record_file("beta", taken).unwrap(); + + let reloaded = dir.store(); + assert!(reloaded.get("alpha").unwrap().files.is_empty()); + let beta_files = &reloaded.get("beta").unwrap().files; + assert_eq!(beta_files.len(), 1); + assert_eq!(beta_files[0].path, "macros/shared.yaml"); + } + + #[test] + fn overwritten_mcp_entry_transfers_ownership_as_transferred() { + let dir = TempStoreDir::new("bundles-mcp-transfer"); + let mut store = dir.store(); + store + .upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123")) + .unwrap(); + store + .upsert_bundle("beta", metadata("https://github.com/b/beta", "def456")) + .unwrap(); + store + .record_mcp_servers("alpha", vec![mcp_record("srv", McpAction::Added, None)]) + .unwrap(); + + store + .record_mcp_servers("beta", vec![mcp_record("srv", McpAction::Replaced, None)]) + .unwrap(); + + let reloaded = dir.store(); + assert!(reloaded.get("alpha").unwrap().mcp_servers.is_empty()); + let beta_servers = &reloaded.get("beta").unwrap().mcp_servers; + assert_eq!(beta_servers.len(), 1); + assert_eq!(beta_servers[0].action, McpAction::Transferred); + } + + #[test] + fn mcp_replacement_of_unowned_entry_stays_replaced() { + let dir = TempStoreDir::new("bundles-mcp-replaced"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + + store + .record_mcp_servers( + "omc", + vec![mcp_record("user-srv", McpAction::Replaced, None)], + ) + .unwrap(); + + assert_eq!( + store.get("omc").unwrap().mcp_servers[0].action, + McpAction::Replaced + ); + } + + #[test] + fn mcp_transfer_matches_renamed_entries_by_effective_key() { + let dir = TempStoreDir::new("bundles-mcp-renamed"); + let mut store = dir.store(); + store + .upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123")) + .unwrap(); + store + .upsert_bundle("beta", metadata("https://github.com/b/beta", "def456")) + .unwrap(); + store + .record_mcp_servers( + "alpha", + vec![mcp_record("srv", McpAction::Renamed, Some("srv-remote"))], + ) + .unwrap(); + + store + .record_mcp_servers( + "beta", + vec![mcp_record("srv-remote", McpAction::Replaced, None)], + ) + .unwrap(); + + let reloaded = dir.store(); + assert!(reloaded.get("alpha").unwrap().mcp_servers.is_empty()); + assert_eq!( + reloaded.get("beta").unwrap().mcp_servers[0].action, + McpAction::Transferred + ); + } + + #[test] + fn resolve_same_url_same_name_is_an_update() { + let dir = TempStoreDir::new("bundles-resolve-update"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + + let resolved = store + .resolve_bundle_name("git@github.com:X/omc.git", None) + .unwrap(); + + assert_eq!(resolved.name, "omc"); + assert_eq!(resolved.migrated_from, None); + assert_eq!(resolved.qualified_from, None); + } + + #[test] + fn resolve_migrates_record_when_identity_changes() { + let dir = TempStoreDir::new("bundles-migrate"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + store + .record_file("omc", file_record("macros/a.yaml", "a")) + .unwrap(); + + let resolved = store + .resolve_bundle_name("git@github.com:x/omc.git", Some("oh-my-coyote")) + .unwrap(); + + assert_eq!(resolved.name, "oh-my-coyote"); + assert_eq!(resolved.migrated_from.as_deref(), Some("omc")); + let reloaded = dir.store(); + assert!(reloaded.get("omc").is_none()); + assert_eq!(reloaded.get("oh-my-coyote").unwrap().files.len(), 1); + } + + #[test] + fn resolve_qualifies_colliding_name_from_https_source() { + let dir = TempStoreDir::new("bundles-qualify-https"); + let mut store = dir.store(); + store + .upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123")) + .unwrap(); + + let resolved = store + .resolve_bundle_name("https://gitlab.com/b/repo.git", None) + .unwrap(); + + assert_eq!(resolved.name, "b/repo"); + assert_eq!(resolved.qualified_from.as_deref(), Some("repo")); + assert_eq!( + resolved.same_name_other_source.as_deref(), + Some("https://github.com/a/repo") + ); + } + + #[test] + fn resolve_qualifies_colliding_name_from_scp_source() { + let dir = TempStoreDir::new("bundles-qualify-scp"); + let mut store = dir.store(); + store + .upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123")) + .unwrap(); + + let resolved = store + .resolve_bundle_name("git@bitbucket.org:c/repo.git", None) + .unwrap(); + + assert_eq!(resolved.name, "c/repo"); + assert_eq!(resolved.qualified_from.as_deref(), Some("repo")); + } + + #[test] + fn resolve_of_already_qualified_record_is_stable() { + let dir = TempStoreDir::new("bundles-qualify-stable"); + let mut store = dir.store(); + store + .upsert_bundle("repo", metadata("https://github.com/a/repo", "abc123")) + .unwrap(); + store + .upsert_bundle("b/repo", metadata("https://gitlab.com/b/repo", "def456")) + .unwrap(); + + let resolved = store + .resolve_bundle_name("https://gitlab.com/b/repo.git", None) + .unwrap(); + + assert_eq!(resolved.name, "b/repo"); + assert_eq!(resolved.migrated_from, None); + } + + #[test] + fn resolve_sanitizes_derived_names() { + let dir = TempStoreDir::new("bundles-sanitize"); + let mut store = dir.store(); + + let resolved = store + .resolve_bundle_name("https://github.com/vercel/next.js.git", None) + .unwrap(); + + assert_eq!(resolved.name, "next-js"); + } + + #[test] + fn resolve_rejects_invalid_manifest_names() { + let dir = TempStoreDir::new("bundles-invalid-name"); + let mut store = dir.store(); + + let result = store.resolve_bundle_name("https://github.com/x/repo", Some("a/b/c")); + + assert!(result.is_err()); + } + + #[test] + fn upsert_merges_metadata_and_preserves_files_and_install_time() { + let dir = TempStoreDir::new("bundles-merge"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + store + .record_file("omc", file_record("macros/a.yaml", "a")) + .unwrap(); + let installed_at = store.get("omc").unwrap().installed_at.clone(); + + store + .upsert_bundle("omc", metadata("https://github.com/x/omc.git", "def456")) + .unwrap(); + + let record = store.get("omc").unwrap(); + assert_eq!(record.commit, "def456"); + assert_eq!(record.source, "https://github.com/x/omc.git"); + assert_eq!(record.installed_at, installed_at); + assert_eq!(record.files.len(), 1); + } + + #[test] + fn recording_against_unknown_bundle_fails() { + let dir = TempStoreDir::new("bundles-unknown"); + let mut store = dir.store(); + + let result = store.record_file("ghost", file_record("macros/a.yaml", "a")); + + assert!(result.unwrap_err().to_string().contains("ghost")); + } + + #[cfg(unix)] + #[test] + fn failed_save_preserves_the_existing_store() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempStoreDir::new("bundles-atomic"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + let before = fs::read_to_string(dir.store_path()).unwrap(); + + fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o555)).unwrap(); + let probe = dir.0.join(".write-probe"); + if fs::write(&probe, "x").is_ok() { + // A privileged user bypasses permission bits; the failure path + // cannot be provoked this way. + let _ = fs::remove_file(&probe); + fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o755)).unwrap(); + return; + } + + let result = store.record_file("omc", file_record("macros/a.yaml", "a")); + fs::set_permissions(&dir.0, fs::Permissions::from_mode(0o755)).unwrap(); + + assert!(result.is_err()); + assert_eq!(fs::read_to_string(dir.store_path()).unwrap(), before); + let reloaded = dir.store(); + assert!(reloaded.get("omc").unwrap().files.is_empty()); + } + + #[test] + fn hash_helpers_are_stable() { + let dir = TempStoreDir::new("bundles-hash"); + let path = dir.0.join("artifact.yaml"); + fs::write(&path, "hello").unwrap(); + + assert_eq!( + hash_bytes(b"hello"), + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + assert_eq!(hash_file(&path).unwrap(), hash_bytes(b"hello")); + assert_ne!(hash_bytes(b"hello"), hash_bytes(b"hello!")); + } + + #[test] + #[serial] + fn default_store_path_follows_the_config_dir() { + let dir = TempStoreDir::new("bundles-env"); + let key = get_env_name("config_dir"); + let previous: Option = env::var_os(&key); + unsafe { + env::set_var(&key, &dir.0); + } + + let result = (|| -> Result<()> { + let mut store = BundleStore::load()?; + assert!(store.bundle_names().is_empty()); + store.upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123"))?; + let reloaded = BundleStore::load()?; + assert!(reloaded.get("omc").is_some()); + assert!(dir.store_path().is_file()); + Ok(()) + })(); + + unsafe { + match &previous { + Some(value) => env::set_var(&key, value), + None => env::remove_var(&key), + } + } + result.unwrap(); + } +} diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 2ae5950..5dc3464 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -374,14 +374,12 @@ fn strip_git_suffix(segment: &str) -> &str { } } -#[allow(dead_code)] pub(crate) fn repo_name_slug(url: &str) -> String { let (_, path) = split_host_and_path(url); let last = path.rsplit('/').next().unwrap_or(""); strip_git_suffix(last).to_string() } -#[allow(dead_code)] pub(crate) fn owner_qualifier(url: &str) -> Option { let (host, path) = split_host_and_path(url); let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); @@ -407,7 +405,6 @@ fn sanitize_host(host: &str) -> String { .to_string() } -#[allow(dead_code)] pub(crate) fn canonical_source_url(url: &str) -> String { let (host, path) = split_host_and_path(url); let mut path = path.to_ascii_lowercase(); diff --git a/src/config/mod.rs b/src/config/mod.rs index ea90e76..5029016 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,8 @@ mod agent; mod app_config; mod app_state; +#[allow(dead_code)] +mod bundles; mod input; mod install_remote; pub(crate) mod instructions; @@ -155,6 +157,7 @@ const SBX_KIT_DIR_NAME: &str = "sbx-kit"; const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; pub(crate) const VAULT_DATA_FILE_NAME: &str = "vault.yml"; +const INSTALLED_BUNDLES_FILE_NAME: &str = "installed-bundles.yaml"; const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits"; const GIT_DIR_NAME: &str = ".git"; const GITIGNORE_FILE_NAME: &str = ".gitignore"; diff --git a/src/config/paths.rs b/src/config/paths.rs index 6a0ecfe..0e2154e 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -2,10 +2,10 @@ use super::role::Role; use super::{ AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME, ENV_FILE_NAME, FUNCTIONS_BIN_DIR_NAME, FUNCTIONS_DIR_NAME, GLOBAL_TOOLS_DIR_NAME, - GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, MACROS_DIR_NAME, MCP_FILE_NAME, - MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME, - SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME, - SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, + GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, INSTALLED_BUNDLES_FILE_NAME, + MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, + RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, + SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME, }; use crate::client::ProviderModels; use crate::config::REPL_HISTORY_DIR_NAME; @@ -169,6 +169,10 @@ pub fn config_file() -> PathBuf { } } +pub fn installed_bundles_file() -> PathBuf { + local_dir(INSTALLED_BUNDLES_FILE_NAME) +} + pub fn roles_dir() -> PathBuf { match env::var(get_env_name("roles_dir")) { Ok(value) => PathBuf::from(value), From 88acf2362f5616d29ea98f6039c16db47bf0af8b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 13:31:33 -0600 Subject: [PATCH 03/20] feat: record bundle provenance when installing from remote repos --- src/config/bundles.rs | 23 ++ src/config/install_remote.rs | 440 ++++++++++++++++++++++++++++++++++- src/config/mod.rs | 1 - 3 files changed, 453 insertions(+), 11 deletions(-) diff --git a/src/config/bundles.rs b/src/config/bundles.rs index 255e85f..a898007 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -60,6 +60,10 @@ pub(crate) struct BundleRecord { pub(crate) commit: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) homepage: Option, pub(crate) installed_at: String, #[serde(default)] pub(crate) files: Vec, @@ -73,6 +77,8 @@ pub(crate) struct InstallMetadata { pub(crate) git_ref: Option, pub(crate) commit: String, pub(crate) version: Option, + pub(crate) description: Option, + pub(crate) homepage: Option, } #[derive(Debug, Clone, PartialEq)] @@ -81,6 +87,7 @@ pub(crate) struct ResolvedBundleName { /// The unqualified name this install asked for, when it had to be owner-qualified. pub(crate) qualified_from: Option, /// The record key this source was previously tracked under, when it changed. + #[allow(dead_code)] pub(crate) migrated_from: Option, /// Source URL of the different-source bundle that already holds the unqualified name. pub(crate) same_name_other_source: Option, @@ -146,10 +153,12 @@ impl BundleStore { .with_context(|| format!("failed to write {}", self.path.display())) } + #[allow(dead_code)] pub(crate) fn get(&self, name: &str) -> Option<&BundleRecord> { self.bundles.get(name) } + #[allow(dead_code)] pub(crate) fn iter(&self) -> impl Iterator { self.bundles .iter() @@ -162,6 +171,7 @@ impl BundleStore { /// Look up the record installed from `url`, comparing canonical source URLs /// so https/scp/`.git` spellings of the same remote all match. + #[allow(dead_code)] pub(crate) fn find_by_source(&self, url: &str) -> Option<(&str, &BundleRecord)> { let canonical = canonical_source_url(url); self.bundles @@ -285,6 +295,8 @@ impl BundleStore { record.git_ref = metadata.git_ref; record.commit = metadata.commit; record.version = metadata.version; + record.description = metadata.description; + record.homepage = metadata.homepage; } None => { self.bundles.insert( @@ -294,6 +306,8 @@ impl BundleStore { git_ref: metadata.git_ref, commit: metadata.commit, version: metadata.version, + description: metadata.description, + homepage: metadata.homepage, installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), files: Vec::new(), mcp_servers: Vec::new(), @@ -449,6 +463,8 @@ mod tests { git_ref: None, commit: commit.to_string(), version: None, + description: None, + homepage: None, } } @@ -504,6 +520,8 @@ mod tests { git_ref: Some("main".to_string()), commit: "abc123".to_string(), version: Some("1.4.0".to_string()), + description: Some("Opinionated roles and macros".to_string()), + homepage: Some("https://github.com/x/omc".to_string()), }, ) .unwrap(); @@ -523,6 +541,11 @@ mod tests { let record = reloaded.get("omc").unwrap(); assert_eq!(record.git_ref.as_deref(), Some("main")); assert_eq!(record.version.as_deref(), Some("1.4.0")); + assert_eq!( + record.description.as_deref(), + Some("Opinionated roles and macros") + ); + assert_eq!(record.homepage.as_deref(), Some("https://github.com/x/omc")); assert_eq!(record.files.len(), 1); assert_eq!(record.mcp_servers.len(), 1); } diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 5dc3464..913ffd5 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -1,3 +1,6 @@ +use super::bundles::{ + BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_file, +}; use crate::config::{InstallFilter, paths}; #[cfg(not(windows))] use crate::function::Language; @@ -31,16 +34,31 @@ pub fn install_remote(git_url: &str, filter: Option, force: bool) return Ok(()); } + let mut store = BundleStore::load()?; + let bundle = register_bundle( + &mut store, + &url, + reference.as_deref(), + layout.manifest.as_ref(), + temp.head_sha(), + )?; + let plan = plan_changes(&layout)?; if !plan.files.is_empty() { print_plan_summary(&plan); - apply_plan(&plan, force)?; + let sticky = if force { + StickyMode::ReplaceAll + } else { + StickyMode::None + }; + apply_plan(&plan, sticky, &mut store, &bundle)?; } if let Some((remote_mcp, local_mcp)) = &plan.mcp_json { let local = local_mcp.exists().then_some(local_mcp.as_path()); let report = merge_mcp_json(local, remote_mcp, local_mcp, force)?; + record_mcp_merge(&mut store, &bundle, &report)?; print_mcp_merge_report(&report); handle_missing_secrets(&report.missing_secrets)?; } @@ -293,14 +311,38 @@ const BUNDLE_MANIFEST_FILE: &str = "coyote-bundle.yaml"; #[derive(Debug, Clone, PartialEq, Deserialize)] pub(crate) struct BundleManifest { pub(crate) name: String, - #[allow(dead_code)] pub(crate) version: Option, - #[allow(dead_code)] pub(crate) description: Option, - #[allow(dead_code)] pub(crate) homepage: Option, } +/// Resolve the bundle's identity for this install and create or refresh its +/// provenance record. Returns the record key subsequent recording uses. +fn register_bundle( + store: &mut BundleStore, + url: &str, + git_ref: Option<&str>, + manifest: Option<&BundleManifest>, + commit: &str, +) -> Result { + let resolved = store.resolve_bundle_name(url, manifest.map(|m| m.name.as_str()))?; + let version = manifest + .and_then(|m| m.version.clone()) + .unwrap_or_else(|| commit.chars().take(7).collect()); + store.upsert_bundle( + &resolved.name, + InstallMetadata { + source: url.to_string(), + git_ref: git_ref.map(str::to_string), + commit: commit.to_string(), + version: Some(version), + description: manifest.and_then(|m| m.description.clone()), + homepage: manifest.and_then(|m| m.homepage.clone()), + }, + )?; + Ok(resolved.name) +} + fn parse_bundle_manifest(root: &Path) -> Result> { let path = root.join(BUNDLE_MANIFEST_FILE); if !path.is_file() { @@ -719,6 +761,7 @@ enum ConflictAction { Replace, } +#[derive(Debug)] struct ApplyReport { new_count: usize, identical_count: usize, @@ -726,23 +769,25 @@ struct ApplyReport { kept_count: usize, } -fn apply_plan(plan: &InstallPlan, force: bool) -> Result { +fn apply_plan( + plan: &InstallPlan, + initial_mode: StickyMode, + store: &mut BundleStore, + bundle: &str, +) -> Result { let mut report = ApplyReport { new_count: 0, identical_count: 0, replaced_count: 0, kept_count: 0, }; - let mut sticky = if force { - StickyMode::ReplaceAll - } else { - StickyMode::None - }; + let mut sticky = initial_mode; for planned in &plan.files { match planned.kind { PlannedKind::New => { write_file(&planned.src, &planned.dst)?; + record_written_file(store, bundle, planned, FileAction::New)?; report.new_count += 1; } PlannedKind::Identical => { @@ -752,6 +797,7 @@ fn apply_plan(plan: &InstallPlan, force: bool) -> Result { ConflictAction::Keep => report.kept_count += 1, ConflictAction::Replace => { write_file(&planned.src, &planned.dst)?; + record_written_file(store, bundle, planned, FileAction::Replaced)?; report.replaced_count += 1; } }, @@ -766,6 +812,67 @@ fn apply_plan(plan: &InstallPlan, force: bool) -> Result { Ok(report) } +/// Provenance is recorded per written file, not at the end of the loop: a +/// conflict prompt can abort the install after earlier files already landed +/// on disk, and those must not become untracked orphans. Files the user kept +/// (or that were identical) are never recorded — ownership means "this +/// content exists because of this bundle" — so a kept file that another +/// bundle's record already owns stays with that owner. +fn record_written_file( + store: &mut BundleStore, + bundle: &str, + planned: &PlannedFile, + action: FileAction, +) -> Result<()> { + store.record_file( + bundle, + FileRecord { + path: provenance_path(&planned.dst), + category: planned.top_category.label().to_string(), + sha256: hash_file(&planned.dst)?, + action, + }, + ) +} + +fn provenance_path(dst: &Path) -> String { + dst.strip_prefix(paths::config_dir()) + .unwrap_or(dst) + .to_string_lossy() + .into_owned() +} + +/// Record the mcp.json entries the merge actually wrote, in one flush right +/// after the merged file itself. Entries the merge kept local are deliberately +/// absent: an entry another bundle already owns stays with that owner. +fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeReport) -> Result<()> { + let mut entries: Vec = Vec::new(); + entries.extend(report.added.iter().map(|name| McpServerRecord { + name: name.clone(), + action: McpAction::Added, + renamed_to: None, + })); + entries.extend(report.replaced.iter().map(|name| McpServerRecord { + name: name.clone(), + action: McpAction::Replaced, + renamed_to: None, + })); + entries.extend( + report + .renamed + .iter() + .map(|(name, renamed_to)| McpServerRecord { + name: name.clone(), + action: McpAction::Renamed, + renamed_to: Some(renamed_to.clone()), + }), + ); + if entries.is_empty() { + return Ok(()); + } + store.record_mcp_servers(bundle, entries) +} + fn resolve_conflict(planned: &PlannedFile, sticky: &mut StickyMode) -> Result { match *sticky { StickyMode::KeepAll => return Ok(ConflictAction::Keep), @@ -2022,4 +2129,317 @@ mod tests { assert_eq!(temp.head_sha(), first); let _ = fs::remove_dir_all(&repo); } + + fn write_src(root: &Path, rel: &str, content: &str) { + let path = root.join(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, content).unwrap(); + } + + fn init_bundle_repo(dir: &Path) -> String { + run_git(vec!["init".into(), "-q".into(), dir.as_os_str().into()]).unwrap(); + commit_file(dir, ".seed", "seed") + } + + fn test_metadata(source: &str) -> InstallMetadata { + InstallMetadata { + source: source.to_string(), + git_ref: None, + commit: "abc123".to_string(), + version: None, + description: None, + homepage: None, + } + } + + #[test] + #[serial] + fn install_remote_records_files_mcp_entries_and_metadata() { + use crate::config::bundles::hash_bytes; + + let _guard = TestVaultConfigGuard::new("prov-full"); + let src_root = fresh_temp_dir("prov-full-src-"); + let repo = src_root.join("my-bundle"); + write_src( + &repo, + BUNDLE_MANIFEST_FILE, + "name: my-bundle\n\ + version: \"2.0\"\n\ + description: Test bundle\n\ + homepage: https://example.com/my-bundle\n", + ); + write_src(&repo, "macros/hello.yaml", "name: hello\n"); + write_src( + &repo, + "functions/mcp.json", + r#"{"mcpServers": {"srv": {"type": "stdio", "command": "echo"}}}"#, + ); + let sha = init_bundle_repo(&repo); + + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + + let store = BundleStore::load().unwrap(); + let record = store.get("my-bundle").unwrap(); + assert_eq!(record.source, repo.to_str().unwrap()); + assert_eq!(record.git_ref, None); + assert_eq!(record.commit, sha); + assert_eq!(record.version.as_deref(), Some("2.0")); + assert_eq!(record.description.as_deref(), Some("Test bundle")); + assert_eq!( + record.homepage.as_deref(), + Some("https://example.com/my-bundle") + ); + assert_eq!(record.files.len(), 1); + assert_eq!(record.files[0].path, "macros/hello.yaml"); + assert_eq!(record.files[0].category, "macros"); + assert_eq!(record.files[0].action, FileAction::New); + assert_eq!(record.files[0].sha256, hash_bytes(b"name: hello\n")); + assert_eq!(record.mcp_servers.len(), 1); + assert_eq!(record.mcp_servers[0].name, "srv"); + assert_eq!(record.mcp_servers[0].action, McpAction::Added); + assert_eq!(record.mcp_servers[0].renamed_to, None); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn install_remote_without_manifest_uses_repo_slug_and_short_sha_version() { + let _guard = TestVaultConfigGuard::new("prov-slug"); + let src_root = fresh_temp_dir("prov-slug-src-"); + let repo = src_root.join("plain.bundle"); + write_src(&repo, "macros/m.yaml", "a: 1\n"); + let sha = init_bundle_repo(&repo); + + install_remote(&format!("{}#{sha}", repo.display()), None, false).unwrap(); + + let store = BundleStore::load().unwrap(); + let record = store.get("plain-bundle").unwrap(); + assert_eq!(record.git_ref.as_deref(), Some(sha.as_str())); + assert_eq!(record.commit, sha); + assert_eq!(record.version.as_deref(), Some(&sha[..7])); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn apply_plan_records_files_written_before_a_mid_run_abort() { + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping apply_plan_records_files_written_before_a_mid_run_abort: requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("prov-abort"); + let src_dir = fresh_temp_dir("prov-abort-src-"); + let src_new = src_dir.join("new.yaml"); + let src_conflict = src_dir.join("conflict.yaml"); + fs::write(&src_new, "new content").unwrap(); + fs::write(&src_conflict, "remote content").unwrap(); + let dst_new = paths::macros_dir().join("new.yaml"); + let dst_conflict = paths::macros_dir().join("conflict.yaml"); + fs::create_dir_all(paths::macros_dir()).unwrap(); + fs::write(&dst_conflict, "local content").unwrap(); + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle("aborty", test_metadata("https://github.com/x/aborty")) + .unwrap(); + let plan = InstallPlan { + files: vec![ + PlannedFile { + src: src_new, + dst: dst_new.clone(), + kind: PlannedKind::New, + top_category: TopCategory::Macros, + }, + PlannedFile { + src: src_conflict, + dst: dst_conflict.clone(), + kind: PlannedKind::Conflict, + top_category: TopCategory::Macros, + }, + ], + mcp_json: None, + }; + + let err = apply_plan(&plan, StickyMode::None, &mut store, "aborty").unwrap_err(); + + assert!( + err.to_string().contains("Refusing to overwrite"), + "got: {err}" + ); + assert_eq!(fs::read_to_string(&dst_new).unwrap(), "new content"); + assert_eq!(fs::read_to_string(&dst_conflict).unwrap(), "local content"); + let reloaded = BundleStore::load().unwrap(); + let files = &reloaded.get("aborty").unwrap().files; + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "macros/new.yaml"); + assert_eq!(files[0].action, FileAction::New); + let _ = fs::remove_dir_all(&src_dir); + } + + #[test] + fn apply_plan_keep_all_leaves_prior_owner_intact() { + let dir = fresh_temp_dir("prov-keep-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("alpha", test_metadata("https://github.com/a/alpha")) + .unwrap(); + store + .upsert_bundle("beta", test_metadata("https://github.com/b/beta")) + .unwrap(); + let dst = dir.join("macros/owned.yaml"); + write_src(&dir, "macros/owned.yaml", "alpha content"); + store + .record_file( + "alpha", + FileRecord { + path: "macros/owned.yaml".to_string(), + category: "macros".to_string(), + sha256: hash_file(&dst).unwrap(), + action: FileAction::New, + }, + ) + .unwrap(); + let src = dir.join("beta-src/owned.yaml"); + write_src(&dir, "beta-src/owned.yaml", "beta content"); + let plan = InstallPlan { + files: vec![PlannedFile { + src, + dst: dst.clone(), + kind: PlannedKind::Conflict, + top_category: TopCategory::Macros, + }], + mcp_json: None, + }; + + apply_plan(&plan, StickyMode::KeepAll, &mut store, "beta").unwrap(); + + assert_eq!(fs::read_to_string(&dst).unwrap(), "alpha content"); + let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + assert_eq!(reloaded.get("alpha").unwrap().files.len(), 1); + assert!(reloaded.get("beta").unwrap().files.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn second_install_transfers_file_and_mcp_ownership() { + use crate::config::bundles::hash_bytes; + + let _guard = TestVaultConfigGuard::new("prov-transfer"); + let src_root = fresh_temp_dir("prov-transfer-src-"); + let alpha = src_root.join("alpha"); + write_src(&alpha, "macros/shared.yaml", "from: alpha\n"); + write_src( + &alpha, + "functions/mcp.json", + r#"{"mcpServers": {"srv": {"type": "stdio", "command": "alpha"}}}"#, + ); + init_bundle_repo(&alpha); + let beta = src_root.join("beta"); + write_src(&beta, "macros/shared.yaml", "from: beta\n"); + write_src( + &beta, + "functions/mcp.json", + r#"{"mcpServers": {"srv": {"type": "stdio", "command": "beta"}}}"#, + ); + init_bundle_repo(&beta); + + install_remote(alpha.to_str().unwrap(), None, false).unwrap(); + install_remote(beta.to_str().unwrap(), None, true).unwrap(); + + let store = BundleStore::load().unwrap(); + let alpha_record = store.get("alpha").unwrap(); + assert!(alpha_record.files.is_empty()); + assert!(alpha_record.mcp_servers.is_empty()); + let beta_record = store.get("beta").unwrap(); + assert_eq!(beta_record.files.len(), 1); + assert_eq!(beta_record.files[0].path, "macros/shared.yaml"); + assert_eq!(beta_record.files[0].action, FileAction::Replaced); + assert_eq!(beta_record.files[0].sha256, hash_bytes(b"from: beta\n")); + assert_eq!(beta_record.mcp_servers.len(), 1); + assert_eq!(beta_record.mcp_servers[0].name, "srv"); + assert_eq!(beta_record.mcp_servers[0].action, McpAction::Transferred); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn filtered_installs_merge_into_one_record() { + let _guard = TestVaultConfigGuard::new("prov-filter"); + let src_root = fresh_temp_dir("prov-filter-src-"); + let repo = src_root.join("combo"); + write_src(&repo, "macros/m.yaml", "a: 1\n"); + write_src(&repo, "skills/myskill/SKILL.md", "# skill\n"); + let first_sha = init_bundle_repo(&repo); + + install_remote(repo.to_str().unwrap(), Some(InstallFilter::Macros), false).unwrap(); + let second_sha = commit_file(&repo, "extra.txt", "two"); + install_remote(repo.to_str().unwrap(), Some(InstallFilter::Skills), false).unwrap(); + + let store = BundleStore::load().unwrap(); + assert_eq!(store.bundle_names(), vec!["combo"]); + let record = store.get("combo").unwrap(); + assert_ne!(first_sha, second_sha); + assert_eq!(record.commit, second_sha); + let mut owned: Vec<&str> = record.files.iter().map(|f| f.path.as_str()).collect(); + owned.sort(); + assert_eq!(owned, vec!["macros/m.yaml", "skills/myskill/SKILL.md"]); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + fn record_mcp_merge_ignores_kept_local_entries() { + let dir = fresh_temp_dir("prov-mcp-keep-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("alpha", test_metadata("https://github.com/a/alpha")) + .unwrap(); + store + .upsert_bundle("beta", test_metadata("https://github.com/b/beta")) + .unwrap(); + store + .record_mcp_servers( + "alpha", + vec![McpServerRecord { + name: "srv".to_string(), + action: McpAction::Added, + renamed_to: None, + }], + ) + .unwrap(); + let report = McpMergeReport { + added: Vec::new(), + kept_local: vec!["srv".to_string()], + replaced: Vec::new(), + renamed: Vec::new(), + final_path: dir.join("mcp.json"), + missing_secrets: Vec::new(), + }; + + record_mcp_merge(&mut store, "beta", &report).unwrap(); + + assert_eq!(store.get("alpha").unwrap().mcp_servers.len(), 1); + assert!(store.get("beta").unwrap().mcp_servers.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn builtin_asset_install_writes_no_provenance() { + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping builtin_asset_install_writes_no_provenance: requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("prov-builtin"); + + crate::config::install_assets(crate::config::AssetCategory::Macros).unwrap(); + + assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some()); + assert!(!paths::installed_bundles_file().exists()); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 5029016..ca9de0e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,7 +1,6 @@ mod agent; mod app_config; mod app_state; -#[allow(dead_code)] mod bundles; mod input; mod install_remote; From 2790a823b0a8af90c11f7d0221ce36d581eb02f3 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 14:16:12 -0600 Subject: [PATCH 04/20] feat: add --list-bundles and .list bundles with drift detection --- src/cli/mod.rs | 7 +- src/config/bundles.rs | 216 +++++++++++++++++++++++++++++++++- src/config/mod.rs | 1 + src/config/request_context.rs | 6 +- src/main.rs | 4 + src/repl/mod.rs | 4 +- 6 files changed, 232 insertions(+), 6 deletions(-) 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 " ) } }, From 0a806da8d22af45de9b2146a9fa8aa1861221d58 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 15:00:19 -0600 Subject: [PATCH 05/20] feat: add --update-bundle with provenance-aware conflict handling Updates re-clone a bundle's recorded source (honoring a recorded commit pin unless a # override moves it), silently refresh files the bundle owns that the user never modified, and fall back to the normal conflict prompts for modified or unowned files. Files the remote no longer ships are offered for deletion (kept by default non-interactively, staying owned). The record is refreshed with the new commit, version, and metadata, and stamped with an updated_at timestamp on success. --- src/cli/mod.rs | 13 +- src/config/bundles.rs | 93 ++++- src/config/install_remote.rs | 662 ++++++++++++++++++++++++++++++++++- src/config/mod.rs | 2 +- src/main.rs | 4 + 5 files changed, 762 insertions(+), 12 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2fd2e58..2061395 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -53,7 +53,7 @@ pub enum McpScopeArg { "install_from", "sync_models", "list_models", "list_roles", "list_sessions", "list_agents", "list_rags", "list_macros", "list_skills", "list_bundles", "skill", "tail_logs", "completions", - "update", + "update", "update_bundle", ]) ), group( @@ -207,6 +207,9 @@ pub struct Cli { help_heading = "Installation & Updates" )] pub install_force: bool, + /// Update an installed bundle from its recorded source (NAME may be suffixed with # to move a pin) + #[arg(long, value_name = "NAME", help_heading = "Installation & Updates")] + pub update_bundle: Option, /// Sync models updates #[arg(long, help_heading = "Installation & Updates")] pub sync_models: bool, @@ -508,6 +511,14 @@ mod tests { assert!(parse(&[]).skill.is_empty()); } + #[test] + fn parse_update_bundle_flag_takes_name() { + assert_eq!( + parse(&["--update-bundle", "foo"]).update_bundle.as_deref(), + Some("foo") + ); + } + #[test] fn parse_multiple_skill_flags_preserves_order() { assert_eq!( diff --git a/src/config/bundles.rs b/src/config/bundles.rs index 5be258e..54eaad2 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -65,6 +65,8 @@ pub(crate) struct BundleRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) homepage: Option, pub(crate) installed_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) updated_at: Option, #[serde(default)] pub(crate) files: Vec, #[serde(default)] @@ -153,7 +155,6 @@ impl BundleStore { .with_context(|| format!("failed to write {}", self.path.display())) } - #[allow(dead_code)] pub(crate) fn get(&self, name: &str) -> Option<&BundleRecord> { self.bundles.get(name) } @@ -308,6 +309,7 @@ impl BundleStore { description: metadata.description, homepage: metadata.homepage, installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + updated_at: None, files: Vec::new(), mcp_servers: Vec::new(), }, @@ -317,6 +319,29 @@ impl BundleStore { self.save() } + /// Stamp the record with the time of its most recent update from source. + pub(crate) fn mark_updated(&mut self, name: &str) -> Result<()> { + self.ensure_bundle_exists(name)?; + let record = self + .bundles + .get_mut(name) + .expect("bundle existence checked above"); + record.updated_at = Some(Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)); + self.save() + } + + /// Drop one path from a bundle's owned files and persist. Used when the + /// bundle no longer ships the file and it is gone (or deleted) locally. + pub(crate) fn remove_file_record(&mut self, bundle: &str, path: &str) -> Result<()> { + self.ensure_bundle_exists(bundle)?; + let record = self + .bundles + .get_mut(bundle) + .expect("bundle existence checked above"); + record.files.retain(|owned| owned.path != path); + self.save() + } + /// Record one written file and persist immediately, so an install aborted /// partway through still has provenance for everything already on disk. /// A path owned by another bundle transfers to `bundle`. @@ -967,6 +992,72 @@ mod tests { assert!(result.unwrap_err().to_string().contains("ghost")); } + #[test] + fn mark_updated_stamps_and_round_trips() { + let dir = TempStoreDir::new("bundles-mark-updated"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + let raw_before = fs::read_to_string(dir.store_path()).unwrap(); + assert!(!raw_before.contains("updated_at"), "{raw_before}"); + assert_eq!(store.get("omc").unwrap().updated_at, None); + + store.mark_updated("omc").unwrap(); + + let raw_after = fs::read_to_string(dir.store_path()).unwrap(); + assert!(raw_after.contains("updated_at"), "{raw_after}"); + let reloaded = dir.store(); + assert!(reloaded.get("omc").unwrap().updated_at.is_some()); + } + + #[test] + fn mark_updated_unknown_bundle_fails() { + let dir = TempStoreDir::new("bundles-mark-unknown"); + let mut store = dir.store(); + + let result = store.mark_updated("ghost"); + + assert!(result.unwrap_err().to_string().contains("ghost")); + } + + #[test] + fn remove_file_record_drops_only_the_named_path() { + let dir = TempStoreDir::new("bundles-remove-file"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + store + .record_file("omc", file_record("macros/a.yaml", "a")) + .unwrap(); + store + .record_file("omc", file_record("macros/b.yaml", "b")) + .unwrap(); + + store.remove_file_record("omc", "macros/a.yaml").unwrap(); + + let reloaded = dir.store(); + let paths: Vec<&str> = reloaded + .get("omc") + .unwrap() + .files + .iter() + .map(|f| f.path.as_str()) + .collect(); + assert_eq!(paths, vec!["macros/b.yaml"]); + } + + #[test] + fn remove_file_record_unknown_bundle_fails() { + let dir = TempStoreDir::new("bundles-remove-unknown"); + let mut store = dir.store(); + + let result = store.remove_file_record("ghost", "macros/a.yaml"); + + assert!(result.unwrap_err().to_string().contains("ghost")); + } + #[cfg(unix)] #[test] fn failed_save_preserves_the_existing_store() { diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 913ffd5..6ccdd39 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -13,6 +13,7 @@ use indexmap::IndexMap; use indoc::formatdoc; use inquire::{Confirm, Select}; use serde::Deserialize; +use std::collections::{HashMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::fs; use std::path::{Path, PathBuf}; @@ -103,6 +104,208 @@ pub fn install_remote_from_repl_args(args: &str) -> Result<()> { install_remote(&url, filter, force) } +/// Update an installed bundle from its recorded source. `spec` is the bundle +/// name, optionally suffixed with `#` to move a pinned ref. The whole +/// remote is always processed — including categories a filtered install +/// excluded — because filtered installs merge into a single record and an +/// update brings that record in line with everything the remote now ships. +pub fn update_bundle(spec: &str) -> Result<()> { + let (name, ref_override) = parse_url_with_ref(spec)?; + + let mut store = BundleStore::load()?; + let Some(record) = store.get(&name) else { + let installed = store.bundle_names(); + if installed.is_empty() { + bail!("no bundle named '{name}' is installed; none are installed"); + } + bail!( + "no bundle named '{name}' is installed; installed bundles: {}", + installed.join(", ") + ); + }; + let source = record.source.clone(); + let recorded_ref = record.git_ref.clone(); + + let has_override = ref_override.is_some(); + let effective_ref = ref_override.or(recorded_ref); + if !has_override + && let Some(pinned) = effective_ref.as_deref() + && is_commit_sha(pinned) + { + println!("Bundle '{name}' is pinned to commit {pinned}; pass # to move the pin."); + } + + let temp = clone_to_temp(&source, effective_ref.as_deref())?; + println!("Cloned {source} to {}", temp.path().display()); + + let mut layout = scan_remote_layout(temp.path())?; + layout.head_sha = Some(temp.head_sha().to_string()); + if layout.is_empty() { + println!( + "The source for '{name}' no longer contains recognized assets; \ + leaving installed files and the bundle record untouched." + ); + return Ok(()); + } + + let bundle = register_bundle( + &mut store, + &source, + effective_ref.as_deref(), + layout.manifest.as_ref(), + temp.head_sha(), + )?; + + let plan = plan_changes(&layout)?; + let plan = reclassify_owned_unmodified(plan, &store, &bundle)?; + + if !plan.files.is_empty() { + print_plan_summary(&plan); + apply_plan(&plan, StickyMode::None, &mut store, &bundle)?; + } + + handle_obsolete_files(&mut store, &bundle, &plan)?; + + if let Some((remote_mcp, local_mcp)) = &plan.mcp_json { + let local = local_mcp.exists().then_some(local_mcp.as_path()); + let report = merge_mcp_json(local, remote_mcp, local_mcp, false)?; + record_mcp_merge(&mut store, &bundle, &report)?; + print_mcp_merge_report(&report); + handle_missing_secrets(&report.missing_secrets)?; + } + + store.mark_updated(&bundle)?; + + Ok(()) +} + +/// A conflict on a file this bundle owns whose on-disk content still matches +/// the recorded hash is not a real conflict: the bundle wrote that content and +/// the user never touched it, so an update refreshes it without prompting. +/// Files the user modified — or that another bundle owns — keep the normal +/// conflict semantics. +fn reclassify_owned_unmodified( + mut plan: InstallPlan, + store: &BundleStore, + bundle: &str, +) -> Result { + let owned: HashMap<&str, &str> = store + .get(bundle) + .map(|record| { + record + .files + .iter() + .map(|file| (file.path.as_str(), file.sha256.as_str())) + .collect() + }) + .unwrap_or_default(); + + for planned in &mut plan.files { + if planned.kind != PlannedKind::Conflict { + continue; + } + let Some(recorded) = owned.get(provenance_path(&planned.dst).as_str()) else { + continue; + }; + if hash_file(&planned.dst)? == *recorded { + planned.kind = PlannedKind::Refresh; + } + } + Ok(plan) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ObsoleteAction { + Keep, + Delete, +} + +/// Reconcile owned files the remote no longer ships. A file already gone from +/// disk just drops out of the record; a file still present is kept by default +/// (the user may rely on it) and only deleted on explicit confirmation. Kept +/// files stay in the record, so a later uninstall still offers to remove them. +fn handle_obsolete_files(store: &mut BundleStore, bundle: &str, plan: &InstallPlan) -> Result<()> { + let planned: HashSet = plan + .files + .iter() + .map(|planned| provenance_path(&planned.dst)) + .collect(); + let obsolete: Vec = store + .get(bundle) + .map(|record| { + record + .files + .iter() + .map(|file| file.path.clone()) + .filter(|path| !planned.contains(path)) + .collect() + }) + .unwrap_or_default(); + + let config_dir = paths::config_dir(); + let mut sticky: Option = None; + for path in obsolete { + let full = config_dir.join(&path); + if !full.exists() { + println!("dropped record for obsolete file {path} (already absent locally)"); + store.remove_file_record(bundle, &path)?; + continue; + } + let action = resolve_obsolete(&path, &mut sticky)?; + apply_obsolete_action(store, bundle, &path, &full, action)?; + } + Ok(()) +} + +fn resolve_obsolete(path: &str, sticky: &mut Option) -> Result { + if let Some(action) = *sticky { + return Ok(action); + } + if !*IS_STDOUT_TERMINAL { + return Ok(ObsoleteAction::Keep); + } + + let prompt = format!("Obsolete file {path} is no longer shipped by the bundle"); + let choice = Select::new(&prompt, vec!["keep", "delete", "keep-all", "delete-all"]) + .prompt() + .with_context(|| "failed to read obsolete-file choice")?; + + match choice { + "keep" => Ok(ObsoleteAction::Keep), + "delete" => Ok(ObsoleteAction::Delete), + "keep-all" => { + *sticky = Some(ObsoleteAction::Keep); + Ok(ObsoleteAction::Keep) + } + "delete-all" => { + *sticky = Some(ObsoleteAction::Delete); + Ok(ObsoleteAction::Delete) + } + _ => unreachable!("inquire::Select returned an unexpected option"), + } +} + +fn apply_obsolete_action( + store: &mut BundleStore, + bundle: &str, + path: &str, + full: &Path, + action: ObsoleteAction, +) -> Result<()> { + match action { + ObsoleteAction::Keep => { + println!("kept obsolete file {path} (no longer shipped by the bundle)"); + } + ObsoleteAction::Delete => { + fs::remove_file(full) + .with_context(|| format!("failed to delete obsolete file {}", full.display()))?; + store.remove_file_record(bundle, path)?; + println!("deleted obsolete file {path}"); + } + } + Ok(()) +} + fn parse_filter(name: &str) -> Result { InstallFilter::parse(name).with_context(|| { format!( @@ -160,13 +363,17 @@ impl Drop for TempRepoDir { } } +fn is_commit_sha(reference: &str) -> bool { + reference.len() >= 4 + && reference.len() <= 40 + && reference.chars().all(|c| c.is_ascii_hexdigit()) +} + fn clone_to_temp(url: &str, reference: Option<&str>) -> Result { let dest = utils::temp_file("coyote-remote-install-", ""); let dest_arg: OsString = dest.as_os_str().into(); - let is_sha = reference - .map(|r| r.len() >= 4 && r.len() <= 40 && r.chars().all(|c| c.is_ascii_hexdigit())) - .unwrap_or(false); + let is_sha = reference.is_some_and(is_commit_sha); match reference { Some(r) if !is_sha => { @@ -567,6 +774,9 @@ enum PlannedKind { New, Identical, Conflict, + /// A conflict downgraded because this bundle owns the file and the local + /// content still matches the recorded hash; applied without prompting. + Refresh, } struct PlannedFile { @@ -733,11 +943,16 @@ fn print_plan_summary(plan: &InstallPlan) { let new_ = count_kind(plan, cat, PlannedKind::New); let identical = count_kind(plan, cat, PlannedKind::Identical); let conflict = count_kind(plan, cat, PlannedKind::Conflict); - if new_ + identical + conflict > 0 { - println!( + let refresh = count_kind(plan, cat, PlannedKind::Refresh); + if new_ + identical + conflict + refresh > 0 { + let mut line = format!( " {:<16} new={new_} identical={identical} conflict={conflict}", cat.label() ); + if refresh > 0 { + line.push_str(&format!(" refresh={refresh}")); + } + println!("{line}"); } } } @@ -766,6 +981,7 @@ struct ApplyReport { new_count: usize, identical_count: usize, replaced_count: usize, + refreshed_count: usize, kept_count: usize, } @@ -779,6 +995,7 @@ fn apply_plan( new_count: 0, identical_count: 0, replaced_count: 0, + refreshed_count: 0, kept_count: 0, }; let mut sticky = initial_mode; @@ -793,6 +1010,11 @@ fn apply_plan( PlannedKind::Identical => { report.identical_count += 1; } + PlannedKind::Refresh => { + write_file(&planned.src, &planned.dst)?; + record_written_file(store, bundle, planned, FileAction::Replaced)?; + report.refreshed_count += 1; + } PlannedKind::Conflict => match resolve_conflict(planned, &mut sticky)? { ConflictAction::Keep => report.kept_count += 1, ConflictAction::Replace => { @@ -804,10 +1026,21 @@ fn apply_plan( } } - println!( - "\nInstalled: {} new, {} replaced, {} kept, {} identical.", - report.new_count, report.replaced_count, report.kept_count, report.identical_count - ); + if report.refreshed_count > 0 { + println!( + "\nInstalled: {} new, {} refreshed, {} replaced, {} kept, {} identical.", + report.new_count, + report.refreshed_count, + report.replaced_count, + report.kept_count, + report.identical_count + ); + } else { + println!( + "\nInstalled: {} new, {} replaced, {} kept, {} identical.", + report.new_count, report.replaced_count, report.kept_count, report.identical_count + ); + } Ok(report) } @@ -2442,4 +2675,415 @@ mod tests { assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some()); assert!(!paths::installed_bundles_file().exists()); } + + #[test] + #[serial] + fn update_silently_refreshes_owned_unmodified_files() { + use crate::config::bundles::hash_bytes; + + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping update_silently_refreshes_owned_unmodified_files: requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("upd-refresh"); + let src_root = fresh_temp_dir("upd-refresh-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: refresh-bundle\n"); + write_src(&repo, "macros/hello.yaml", "v1\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + commit_file(&repo, "macros/hello.yaml", "v2\n"); + + update_bundle("refresh-bundle").unwrap(); + + let installed = paths::macros_dir().join("hello.yaml"); + assert_eq!(fs::read_to_string(&installed).unwrap(), "v2\n"); + let store = BundleStore::load().unwrap(); + let files = &store.get("refresh-bundle").unwrap().files; + assert_eq!(files.len(), 1); + assert_eq!(files[0].sha256, hash_bytes(b"v2\n")); + assert_eq!(files[0].action, FileAction::Replaced); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + fn apply_plan_keep_all_preserves_local_edit_and_stale_record() { + let dir = fresh_temp_dir("upd-keep-modified-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let dst = dir.join("macros/owned.yaml"); + write_src(&dir, "macros/owned.yaml", "installed content"); + let recorded_sha = hash_file(&dst).unwrap(); + store + .record_file( + "omc", + FileRecord { + path: provenance_path(&dst), + category: "macros".to_string(), + sha256: recorded_sha.clone(), + action: FileAction::New, + }, + ) + .unwrap(); + fs::write(&dst, "local edit").unwrap(); + let src = dir.join("upstream/owned.yaml"); + write_src(&dir, "upstream/owned.yaml", "upstream content"); + let plan = InstallPlan { + files: vec![PlannedFile { + src, + dst: dst.clone(), + kind: PlannedKind::Conflict, + top_category: TopCategory::Macros, + }], + mcp_json: None, + }; + + apply_plan(&plan, StickyMode::KeepAll, &mut store, "omc").unwrap(); + + assert_eq!(fs::read_to_string(&dst).unwrap(), "local edit"); + let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + let files = &reloaded.get("omc").unwrap().files; + assert_eq!(files.len(), 1); + assert_eq!(files[0].sha256, recorded_sha); + assert_ne!(files[0].sha256, hash_file(&dst).unwrap()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn apply_plan_replace_all_takes_upstream_and_rerecords_hash() { + use crate::config::bundles::hash_bytes; + + let dir = fresh_temp_dir("upd-replace-modified-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let dst = dir.join("macros/owned.yaml"); + write_src(&dir, "macros/owned.yaml", "installed content"); + store + .record_file( + "omc", + FileRecord { + path: provenance_path(&dst), + category: "macros".to_string(), + sha256: hash_file(&dst).unwrap(), + action: FileAction::New, + }, + ) + .unwrap(); + fs::write(&dst, "local edit").unwrap(); + let src = dir.join("upstream/owned.yaml"); + write_src(&dir, "upstream/owned.yaml", "upstream content"); + let plan = InstallPlan { + files: vec![PlannedFile { + src, + dst: dst.clone(), + kind: PlannedKind::Conflict, + top_category: TopCategory::Macros, + }], + mcp_json: None, + }; + + apply_plan(&plan, StickyMode::ReplaceAll, &mut store, "omc").unwrap(); + + assert_eq!(fs::read_to_string(&dst).unwrap(), "upstream content"); + let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + let files = &reloaded.get("omc").unwrap().files; + assert_eq!(files.len(), 1); + assert_eq!(files[0].sha256, hash_bytes(b"upstream content")); + assert_eq!(files[0].action, FileAction::Replaced); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn update_bails_non_interactively_on_unowned_conflict() { + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping update_bails_non_interactively_on_unowned_conflict: requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("upd-unowned"); + let src_root = fresh_temp_dir("upd-unowned-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: unowned-bundle\n"); + write_src(&repo, "macros/a.yaml", "a\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + commit_file(&repo, "macros/user.yaml", "upstream\n"); + fs::create_dir_all(paths::macros_dir()).unwrap(); + fs::write(paths::macros_dir().join("user.yaml"), "local\n").unwrap(); + + let err = update_bundle("unowned-bundle").unwrap_err(); + + assert!( + err.to_string().contains("Refusing to overwrite"), + "got: {err}" + ); + assert_eq!( + fs::read_to_string(paths::macros_dir().join("user.yaml")).unwrap(), + "local\n" + ); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + fn apply_plan_replace_all_transfers_ownership_between_bundles() { + let dir = fresh_temp_dir("upd-transfer-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("alpha", test_metadata("https://github.com/a/alpha")) + .unwrap(); + store + .upsert_bundle("beta", test_metadata("https://github.com/b/beta")) + .unwrap(); + let dst = dir.join("macros/shared.yaml"); + write_src(&dir, "macros/shared.yaml", "alpha content"); + store + .record_file( + "alpha", + FileRecord { + path: provenance_path(&dst), + category: "macros".to_string(), + sha256: hash_file(&dst).unwrap(), + action: FileAction::New, + }, + ) + .unwrap(); + let src = dir.join("beta-src/shared.yaml"); + write_src(&dir, "beta-src/shared.yaml", "beta content"); + let plan = InstallPlan { + files: vec![PlannedFile { + src, + dst: dst.clone(), + kind: PlannedKind::Conflict, + top_category: TopCategory::Macros, + }], + mcp_json: None, + }; + + apply_plan(&plan, StickyMode::ReplaceAll, &mut store, "beta").unwrap(); + + assert_eq!(fs::read_to_string(&dst).unwrap(), "beta content"); + let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + assert!(reloaded.get("alpha").unwrap().files.is_empty()); + let beta_files = &reloaded.get("beta").unwrap().files; + assert_eq!(beta_files.len(), 1); + assert_eq!(beta_files[0].path, provenance_path(&dst)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn update_keeps_obsolete_files_non_interactively() { + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping update_keeps_obsolete_files_non_interactively: requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("upd-obsolete-keep"); + let src_root = fresh_temp_dir("upd-obsolete-keep-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: obs-keep\n"); + write_src(&repo, "macros/keep.yaml", "k\n"); + write_src(&repo, "macros/gone.yaml", "g\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + fs::remove_file(repo.join("macros/gone.yaml")).unwrap(); + commit_file(&repo, "macros/keep.yaml", "k2\n"); + + update_bundle("obs-keep").unwrap(); + + assert_eq!( + fs::read_to_string(paths::macros_dir().join("gone.yaml")).unwrap(), + "g\n" + ); + let store = BundleStore::load().unwrap(); + let record = store.get("obs-keep").unwrap(); + assert!(record.files.iter().any(|f| f.path == "macros/gone.yaml")); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + fn apply_obsolete_delete_removes_file_and_record() { + let dir = fresh_temp_dir("upd-obsolete-delete-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let dst = dir.join("macros/gone.yaml"); + write_src(&dir, "macros/gone.yaml", "g"); + let path = provenance_path(&dst); + store + .record_file( + "omc", + FileRecord { + path: path.clone(), + category: "macros".to_string(), + sha256: hash_file(&dst).unwrap(), + action: FileAction::New, + }, + ) + .unwrap(); + + apply_obsolete_action(&mut store, "omc", &path, &dst, ObsoleteAction::Delete).unwrap(); + + assert!(!dst.exists()); + let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + assert!(reloaded.get("omc").unwrap().files.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn handle_obsolete_drops_records_for_missing_files() { + let dir = fresh_temp_dir("upd-obsolete-missing-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let ghost = dir.join("macros/ghost.yaml"); + store + .record_file( + "omc", + FileRecord { + path: provenance_path(&ghost), + category: "macros".to_string(), + sha256: "0".repeat(64), + action: FileAction::New, + }, + ) + .unwrap(); + let plan = InstallPlan { + files: Vec::new(), + mcp_json: None, + }; + + handle_obsolete_files(&mut store, "omc", &plan).unwrap(); + + assert!(store.get("omc").unwrap().files.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn update_refreshes_record_metadata_and_stamps_updated_at() { + let _guard = TestVaultConfigGuard::new("upd-meta"); + let src_root = fresh_temp_dir("upd-meta-src-"); + let repo = src_root.join("bundle"); + write_src( + &repo, + BUNDLE_MANIFEST_FILE, + "name: meta-bundle\n\ + version: \"1.0\"\n\ + description: Old\n\ + homepage: https://example.com/old\n", + ); + write_src(&repo, "macros/m.yaml", "a: 1\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + let installed_at = BundleStore::load() + .unwrap() + .get("meta-bundle") + .unwrap() + .installed_at + .clone(); + let new_sha = commit_file( + &repo, + BUNDLE_MANIFEST_FILE, + "name: meta-bundle\n\ + version: \"2.0\"\n\ + description: New\n\ + homepage: https://example.com/new\n", + ); + + update_bundle("meta-bundle").unwrap(); + + let store = BundleStore::load().unwrap(); + let record = store.get("meta-bundle").unwrap(); + assert_eq!(record.commit, new_sha); + assert_eq!(record.version.as_deref(), Some("2.0")); + assert_eq!(record.description.as_deref(), Some("New")); + assert_eq!(record.homepage.as_deref(), Some("https://example.com/new")); + assert!(record.updated_at.is_some()); + assert_eq!(record.installed_at, installed_at); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn update_unknown_bundle_lists_installed_names() { + let _guard = TestVaultConfigGuard::new("upd-unknown-name"); + + let err = update_bundle("nope").unwrap_err(); + assert!(err.to_string().contains("none are installed"), "got: {err}"); + + let src_root = fresh_temp_dir("upd-unknown-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: known-bundle\n"); + write_src(&repo, "macros/m.yaml", "a: 1\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + + let err = update_bundle("nope").unwrap_err(); + + assert!( + err.to_string().contains("installed bundles: known-bundle"), + "got: {err}" + ); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn update_honors_recorded_sha_pin() { + let _guard = TestVaultConfigGuard::new("upd-pin"); + let src_root = fresh_temp_dir("upd-pin-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: pin-bundle\n"); + write_src(&repo, "macros/one.yaml", "1\n"); + let pinned = init_bundle_repo(&repo); + install_remote(&format!("{}#{pinned}", repo.display()), None, false).unwrap(); + let newer = commit_file(&repo, "macros/two.yaml", "2\n"); + assert_ne!(pinned, newer); + + update_bundle("pin-bundle").unwrap(); + + let store = BundleStore::load().unwrap(); + let record = store.get("pin-bundle").unwrap(); + assert_eq!(record.commit, pinned); + assert_eq!(record.git_ref.as_deref(), Some(pinned.as_str())); + assert!(!paths::macros_dir().join("two.yaml").exists()); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn update_ref_override_moves_the_pin() { + let _guard = TestVaultConfigGuard::new("upd-move-pin"); + let src_root = fresh_temp_dir("upd-move-pin-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: move-bundle\n"); + write_src(&repo, "macros/one.yaml", "1\n"); + let pinned = init_bundle_repo(&repo); + install_remote(&format!("{}#{pinned}", repo.display()), None, false).unwrap(); + let newer = commit_file(&repo, "macros/two.yaml", "2\n"); + + update_bundle(&format!("move-bundle#{newer}")).unwrap(); + + let store = BundleStore::load().unwrap(); + let record = store.get("move-bundle").unwrap(); + assert_eq!(record.commit, newer); + assert_eq!(record.git_ref.as_deref(), Some(newer.as_str())); + assert_eq!( + fs::read_to_string(paths::macros_dir().join("two.yaml")).unwrap(), + "2\n" + ); + let _ = fs::remove_dir_all(&src_root); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index c13c297..21c8e96 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -32,7 +32,7 @@ pub use self::app_config::AppConfig; 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::install_remote::{install_remote, install_remote_from_repl_args, update_bundle}; pub use self::macro_policy::{ MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro, }; diff --git a/src/main.rs b/src/main.rs index 4ecc548..d95dd75 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,6 +138,10 @@ async fn main() -> Result<()> { return config::install_remote(url, cli.filter, cli.install_force); } + if let Some(spec) = cli.update_bundle.as_deref() { + return config::update_bundle(spec); + } + if let Some(client_arg) = &cli.authenticate { let cfg = Config::load_with_interpolation(true).await?; let app_config = AppConfig::from_config(cfg)?; From 0e5d85f2ff01c8fdf0e2a9bbc42edf3b7fdaed84 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 15:45:22 -0600 Subject: [PATCH 06/20] feat: add --uninstall and .uninstall for installed bundles --- src/cli/mod.rs | 23 +- src/config/bundles.rs | 127 +++++- src/config/install_remote.rs | 835 +++++++++++++++++++++++++++++++++- src/config/mod.rs | 4 +- src/config/request_context.rs | 41 ++ src/main.rs | 4 + src/repl/mod.rs | 17 +- 7 files changed, 1029 insertions(+), 22 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2061395..2716ecc 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -53,7 +53,7 @@ pub enum McpScopeArg { "install_from", "sync_models", "list_models", "list_roles", "list_sessions", "list_agents", "list_rags", "list_macros", "list_skills", "list_bundles", "skill", "tail_logs", "completions", - "update", "update_bundle", + "update", "update_bundle", "uninstall", ]) ), group( @@ -210,6 +210,12 @@ pub struct Cli { /// Update an installed bundle from its recorded source (NAME may be suffixed with # to move a pin) #[arg(long, value_name = "NAME", help_heading = "Installation & Updates")] pub update_bundle: Option, + /// Uninstall a bundle: delete its owned files and remove its mcp.json entries + #[arg(long, value_name = "NAME", help_heading = "Installation & Updates")] + pub uninstall: Option, + /// Skip uninstall confirmation prompts (locally modified items are still kept) + #[arg(long, requires = "uninstall", help_heading = "Installation & Updates")] + pub yes: bool, /// Sync models updates #[arg(long, help_heading = "Installation & Updates")] pub sync_models: bool, @@ -519,6 +525,21 @@ mod tests { ); } + #[test] + fn parse_uninstall_flag_takes_name() { + assert_eq!( + parse(&["--uninstall", "foo"]).uninstall.as_deref(), + Some("foo") + ); + assert!(!parse(&["--uninstall", "foo"]).yes); + } + + #[test] + fn parse_yes_flag_requires_uninstall() { + assert!(parse(&["--uninstall", "foo", "--yes"]).yes); + assert!(Cli::try_parse_from(["coyote", "--yes"]).is_err()); + } + #[test] fn parse_multiple_skill_flags_preserves_order() { assert_eq!( diff --git a/src/config/bundles.rs b/src/config/bundles.rs index 54eaad2..0c6d1b8 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -43,6 +43,10 @@ pub(crate) struct McpServerRecord { pub(crate) name: String, pub(crate) action: McpAction, pub(crate) renamed_to: Option, + /// Hash of the mcp.json entry as written; a later mismatch means the user + /// modified it. Absent on records made before entry hashing existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) sha256: Option, } impl McpServerRecord { @@ -171,7 +175,6 @@ impl BundleStore { /// Look up the record installed from `url`, comparing canonical source URLs /// so https/scp/`.git` spellings of the same remote all match. - #[allow(dead_code)] pub(crate) fn find_by_source(&self, url: &str) -> Option<(&str, &BundleRecord)> { let canonical = canonical_source_url(url); self.bundles @@ -342,6 +345,28 @@ impl BundleStore { self.save() } + /// Drop one mcp.json entry from a bundle's owned servers, matched by the + /// key it occupies in mcp.json, and persist. + pub(crate) fn remove_mcp_record(&mut self, bundle: &str, effective_key: &str) -> Result<()> { + self.ensure_bundle_exists(bundle)?; + let record = self + .bundles + .get_mut(bundle) + .expect("bundle existence checked above"); + record + .mcp_servers + .retain(|owned| owned.effective_key() != effective_key); + self.save() + } + + /// Remove a bundle's record entirely and persist. Used once an uninstall + /// has released everything the record owned. + pub(crate) fn remove_bundle(&mut self, name: &str) -> Result<()> { + self.ensure_bundle_exists(name)?; + self.bundles.remove(name); + self.save() + } + /// Record one written file and persist immediately, so an install aborted /// partway through still has provenance for everything already on disk. /// A path owned by another bundle transfers to `bundle`. @@ -362,10 +387,11 @@ impl BundleStore { } /// Record the mcp.json entries an install wrote, in one persisted flush. - /// An entry whose key another bundle owns transfers to `bundle`: the old - /// owner drops it, and a `replaced` action is upgraded to `transferred` - /// (removable at uninstall — plain `replaced` marks a pre-existing user - /// entry that uninstall must never delete). + /// An entry whose key any bundle already owns — including `bundle` itself + /// on an update — transfers to `bundle`: the old owner drops it, and a + /// `replaced` action is upgraded to `transferred` (removable at uninstall + /// — plain `replaced` marks a pre-existing user entry that uninstall must + /// never delete). pub(crate) fn record_mcp_servers( &mut self, bundle: &str, @@ -375,10 +401,7 @@ impl BundleStore { for mut entry in entries { let key = entry.effective_key().to_string(); let mut previously_owned = false; - for (name, record) in self.bundles.iter_mut() { - if name == bundle { - continue; - } + for record in self.bundles.values_mut() { let before = record.mcp_servers.len(); record .mcp_servers @@ -388,14 +411,11 @@ impl BundleStore { if previously_owned && entry.action == McpAction::Replaced { entry.action = McpAction::Transferred; } - let record = self - .bundles + self.bundles .get_mut(bundle) - .expect("bundle existence checked above"); - record + .expect("bundle existence checked above") .mcp_servers - .retain(|owned| owned.effective_key() != key); - record.mcp_servers.push(entry); + .push(entry); } self.save() } @@ -624,6 +644,7 @@ mod tests { name: name.to_string(), action, renamed_to: renamed_to.map(str::to_string), + sha256: None, } } @@ -811,6 +832,27 @@ mod tests { ); } + #[test] + fn mcp_rerecord_of_own_entry_upgrades_replaced_to_transferred() { + let dir = TempStoreDir::new("bundles-mcp-self-update"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + store + .record_mcp_servers("omc", vec![mcp_record("srv", McpAction::Added, None)]) + .unwrap(); + + let mut updated = mcp_record("srv", McpAction::Replaced, None); + updated.sha256 = Some("deadbeef".to_string()); + store.record_mcp_servers("omc", vec![updated]).unwrap(); + + let servers = &store.get("omc").unwrap().mcp_servers; + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].action, McpAction::Transferred); + assert_eq!(servers[0].sha256.as_deref(), Some("deadbeef")); + } + #[test] fn mcp_transfer_matches_renamed_entries_by_effective_key() { let dir = TempStoreDir::new("bundles-mcp-renamed"); @@ -1058,6 +1100,61 @@ mod tests { assert!(result.unwrap_err().to_string().contains("ghost")); } + #[test] + fn remove_mcp_record_drops_only_the_named_effective_key() { + let dir = TempStoreDir::new("bundles-remove-mcp"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + store + .record_mcp_servers( + "omc", + vec![ + mcp_record("srv", McpAction::Renamed, Some("srv-remote")), + mcp_record("other", McpAction::Added, None), + ], + ) + .unwrap(); + + store.remove_mcp_record("omc", "srv-remote").unwrap(); + + let reloaded = dir.store(); + let keys: Vec<&str> = reloaded + .get("omc") + .unwrap() + .mcp_servers + .iter() + .map(|s| s.effective_key()) + .collect(); + assert_eq!(keys, vec!["other"]); + } + + #[test] + fn remove_bundle_deletes_the_record_and_persists() { + let dir = TempStoreDir::new("bundles-remove-bundle"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + + store.remove_bundle("omc").unwrap(); + + let reloaded = dir.store(); + assert!(reloaded.get("omc").is_none()); + assert!(reloaded.bundle_names().is_empty()); + } + + #[test] + fn remove_bundle_unknown_name_fails() { + let dir = TempStoreDir::new("bundles-remove-bundle-unknown"); + let mut store = dir.store(); + + let result = store.remove_bundle("ghost"); + + assert!(result.unwrap_err().to_string().contains("ghost")); + } + #[cfg(unix)] #[test] fn failed_save_preserves_the_existing_store() { diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 6ccdd39..1eb28c8 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -1,5 +1,6 @@ use super::bundles::{ - BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_file, + BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes, + hash_file, }; use crate::config::{InstallFilter, paths}; #[cfg(not(windows))] @@ -16,7 +17,7 @@ use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; pub fn install_remote(git_url: &str, filter: Option, force: bool) -> Result<()> { let (url, reference) = parse_url_with_ref(git_url)?; @@ -306,6 +307,365 @@ fn apply_obsolete_action( Ok(()) } +#[derive(Debug, Default, PartialEq, Eq)] +struct UninstallFileSummary { + deleted: usize, + kept: usize, + missing: usize, + failed: usize, + /// Set when a functions/tools file was found on disk; its compiled binary + /// lingers in the functions bin dir until the next --build-tools prune. + tools_seen: bool, +} + +#[derive(Debug, Default)] +struct UninstallMcpSummary { + removed: Vec, + kept: Vec, +} + +/// Uninstall a bundle: delete the files it owns, remove its mcp.json entries, +/// and drop its store record once nothing is left. `spec` is the bundle name +/// or its source URL. Locally modified items are kept unless explicitly +/// confirmed at a prompt; kept and failed items stay in the record, so a +/// re-run offers them again. +pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { + let mut store = BundleStore::load()?; + let name = match store.get(spec) { + Some(_) => spec.to_string(), + None => match store.find_by_source(spec) { + Some((name, _)) => name.to_string(), + None => { + let installed = store.bundle_names(); + if installed.is_empty() { + bail!("no bundle named '{spec}' is installed; none are installed"); + } + bail!( + "no bundle named '{spec}' is installed; installed bundles: {}", + installed.join(", ") + ); + } + }, + }; + let record = store.get(&name).expect("resolved above").clone(); + + if !assume_yes { + if !*IS_STDOUT_TERMINAL { + bail!( + "refusing to uninstall bundle '{name}' non-interactively; \ + re-run with --yes to confirm" + ); + } + println!( + "Bundle '{name}' owns {} file(s) and {} mcp.json server(s).", + record.files.len(), + record.mcp_servers.len() + ); + let proceed = Confirm::new(&format!("Uninstall bundle '{name}'?")) + .with_default(false) + .prompt() + .with_context(|| "failed to read uninstall confirmation")?; + if !proceed { + println!("Uninstall of '{name}' aborted; nothing was changed."); + return Ok(()); + } + } + + let files = uninstall_owned_files( + &mut store, + &name, + &record.files, + &paths::config_dir(), + assume_yes, + )?; + if files.tools_seen { + println!( + "Note: compiled tool binaries remain in {} until the next --build-tools prune.", + paths::functions_bin_dir().display() + ); + } + let mcp = uninstall_mcp_entries( + &mut store, + &name, + &record.mcp_servers, + &paths::mcp_config_file(), + assume_yes, + )?; + + let empty = store + .get(&name) + .map(|record| record.files.is_empty() && record.mcp_servers.is_empty()) + .unwrap_or(true); + if empty { + store.remove_bundle(&name)?; + println!("\nUninstalled bundle '{name}' and removed its record."); + } else { + println!( + "\nBundle '{name}' partially uninstalled; its record keeps the remaining \ + items, so re-running --uninstall offers them again." + ); + } + println!( + " files: deleted={} kept={} missing={} failed={}", + files.deleted, files.kept, files.missing, files.failed + ); + println!( + " mcp servers: removed={} kept={}", + mcp.removed.len(), + mcp.kept.len() + ); + if !mcp.removed.is_empty() { + println!(" - removed servers: {}", mcp.removed.join(", ")); + } + if !mcp.kept.is_empty() { + println!(" = kept servers: {}", mcp.kept.join(", ")); + } + Ok(()) +} + +/// Process the files a bundle owns under `config_dir`. Intact files (content +/// still matches the recorded hash) are deleted outright; missing files just +/// drop out of the record; modified files are kept unless confirmed for +/// deletion. A failed deletion keeps the record so a re-run can retry, and +/// never aborts the remaining files. A recorded path that could escape +/// `config_dir` (absolute, or containing anything but plain components) is +/// never touched; its record survives. +fn uninstall_owned_files( + store: &mut BundleStore, + bundle: &str, + files: &[FileRecord], + config_dir: &Path, + assume_yes: bool, +) -> Result { + let mut summary = UninstallFileSummary::default(); + let mut sticky: Option = None; + for file in files { + let recorded = Path::new(&file.path); + if recorded.is_absolute() + || !recorded + .components() + .all(|c| matches!(c, Component::Normal(_))) + { + eprintln!( + "skipping suspicious recorded path {}; keeping its record", + file.path + ); + summary.failed += 1; + continue; + } + let full = config_dir.join(recorded); + if !full.exists() { + println!( + "dropped record for missing file {} (already absent locally)", + file.path + ); + store.remove_file_record(bundle, &file.path)?; + summary.missing += 1; + continue; + } + summary.tools_seen |= file.category == "functions/tools"; + if matches!(hash_file(&full), Ok(hash) if hash == file.sha256) { + delete_owned_file(store, bundle, &file.path, &full, config_dir, &mut summary)?; + continue; + } + let prompt = format!("File {} was modified locally after install", file.path); + let action = resolve_uninstall_action(&prompt, assume_yes, &mut sticky)?; + apply_uninstall_file_action( + store, + bundle, + &file.path, + &full, + config_dir, + action, + &mut summary, + )?; + } + Ok(summary) +} + +fn apply_uninstall_file_action( + store: &mut BundleStore, + bundle: &str, + path: &str, + full: &Path, + config_dir: &Path, + action: ObsoleteAction, + summary: &mut UninstallFileSummary, +) -> Result<()> { + match action { + ObsoleteAction::Keep => { + println!("kept modified file {path}"); + summary.kept += 1; + } + ObsoleteAction::Delete => { + delete_owned_file(store, bundle, path, full, config_dir, summary)?; + } + } + Ok(()) +} + +fn delete_owned_file( + store: &mut BundleStore, + bundle: &str, + path: &str, + full: &Path, + config_dir: &Path, + summary: &mut UninstallFileSummary, +) -> Result<()> { + match fs::remove_file(full) { + Ok(()) => { + store.remove_file_record(bundle, path)?; + println!("deleted {path}"); + summary.deleted += 1; + prune_empty_dirs(full, config_dir); + } + Err(err) => { + eprintln!( + "failed to delete {}: {err}; keeping its record", + full.display() + ); + summary.failed += 1; + } + } + Ok(()) +} + +/// Remove now-empty ancestors of a deleted file, climbing strictly below +/// `config_dir`. `fs::remove_dir` fails on a non-empty directory, which is +/// the stop condition. +fn prune_empty_dirs(full: &Path, config_dir: &Path) { + let mut current = full.parent(); + while let Some(dir) = current { + if dir == config_dir || !dir.starts_with(config_dir) { + break; + } + if fs::remove_dir(dir).is_err() { + break; + } + current = dir.parent(); + } +} + +fn resolve_uninstall_action( + prompt: &str, + assume_yes: bool, + sticky: &mut Option, +) -> Result { + if assume_yes || !*IS_STDOUT_TERMINAL { + return Ok(ObsoleteAction::Keep); + } + if let Some(action) = *sticky { + return Ok(action); + } + let choice = Select::new(prompt, vec!["keep", "delete", "keep-all", "delete-all"]) + .prompt() + .with_context(|| "failed to read uninstall choice")?; + match choice { + "keep" => Ok(ObsoleteAction::Keep), + "delete" => Ok(ObsoleteAction::Delete), + "keep-all" => { + *sticky = Some(ObsoleteAction::Keep); + Ok(ObsoleteAction::Keep) + } + "delete-all" => { + *sticky = Some(ObsoleteAction::Delete); + Ok(ObsoleteAction::Delete) + } + _ => unreachable!("inquire::Select returned an unexpected option"), + } +} + +/// Remove a bundle's entries from mcp.json. An entry is removed outright only +/// when its current content still matches the recorded hash; a modified (or +/// legacy, hash-less) entry is kept unless confirmed for deletion, and a +/// pre-existing server the bundle replaced is never removed. Keys the bundle +/// does not own are never touched. mcp.json is written before any ownership +/// record is dropped, so a failed write leaves every record intact for a +/// retry, while a crash after it leaves stale records the absent-server +/// branch cleans up on re-run. +fn uninstall_mcp_entries( + store: &mut BundleStore, + bundle: &str, + servers: &[McpServerRecord], + mcp_path: &Path, + assume_yes: bool, +) -> Result { + let mut summary = UninstallMcpSummary::default(); + if servers.is_empty() { + return Ok(summary); + } + let mut config = if mcp_path.exists() { + let content = fs::read_to_string(mcp_path) + .with_context(|| format!("failed to read {}", mcp_path.display()))?; + Some( + serde_json::from_str::(&content) + .with_context(|| format!("failed to parse {}", mcp_path.display()))?, + ) + } else { + None + }; + + let mut changed = false; + let mut released = Vec::new(); + let mut sticky: Option = None; + for server in servers { + let key = server.effective_key().to_string(); + if server.action == McpAction::Replaced { + println!("kept pre-existing server '{key}'"); + released.push(key.clone()); + summary.kept.push(key); + continue; + } + let Some(entry) = config.as_ref().and_then(|cfg| cfg.mcp_servers.get(&key)) else { + println!("dropped record for absent server '{key}'"); + released.push(key); + continue; + }; + let serialized = serde_json::to_string(entry) + .with_context(|| format!("failed to serialize MCP server '{key}'"))?; + let intact = server.sha256.as_deref() == Some(hash_bytes(serialized.as_bytes()).as_str()); + let action = if intact { + ObsoleteAction::Delete + } else { + let reason = if server.sha256.is_none() { + "predates entry hashing" + } else { + "was modified locally after install" + }; + let prompt = format!("MCP server '{key}' {reason}"); + resolve_uninstall_action(&prompt, assume_yes, &mut sticky)? + }; + match action { + ObsoleteAction::Keep => { + println!("kept server '{key}'"); + summary.kept.push(key); + } + ObsoleteAction::Delete => { + config + .as_mut() + .expect("entry was found in the config above") + .mcp_servers + .shift_remove(&key); + changed = true; + println!("removed server '{key}'"); + released.push(key.clone()); + summary.removed.push(key); + } + } + } + + if changed { + let config = config.expect("changed implies a parsed config"); + let serialized = serde_json::to_string_pretty(&config) + .context("failed to serialize mcp.json after uninstall")?; + write_atomically(mcp_path, &serialized)?; + } + for key in &released { + store.remove_mcp_record(bundle, key)?; + } + Ok(summary) +} + fn parse_filter(name: &str) -> Result { InstallFilter::parse(name).with_context(|| { format!( @@ -1084,11 +1444,13 @@ fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeRepo name: name.clone(), action: McpAction::Added, renamed_to: None, + sha256: report.entry_hashes.get(name).cloned(), })); entries.extend(report.replaced.iter().map(|name| McpServerRecord { name: name.clone(), action: McpAction::Replaced, renamed_to: None, + sha256: report.entry_hashes.get(name).cloned(), })); entries.extend( report @@ -1098,6 +1460,7 @@ fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeRepo name: name.clone(), action: McpAction::Renamed, renamed_to: Some(renamed_to.clone()), + sha256: report.entry_hashes.get(renamed_to).cloned(), }), ); if entries.is_empty() { @@ -1186,6 +1549,9 @@ struct McpMergeReport { kept_local: Vec, replaced: Vec, renamed: Vec<(String, String)>, + /// Hash of each entry this merge wrote, by its final key in mcp.json, so + /// uninstall can tell the entry apart from a later local edit. + entry_hashes: HashMap, final_path: PathBuf, missing_secrets: Vec, } @@ -1226,6 +1592,7 @@ fn merge_mcp_json( kept_local: Vec::new(), replaced: Vec::new(), renamed: Vec::new(), + entry_hashes: HashMap::new(), final_path: final_path.clone(), missing_secrets: Vec::new(), }; @@ -1265,6 +1632,11 @@ fn merge_mcp_json( spec.validate(key).with_context(|| { format!("MCP server '{key}' failed validation; refusing to write merged mcp.json") })?; + let serialized = serde_json::to_string(spec) + .with_context(|| format!("failed to serialize MCP server '{key}'"))?; + report + .entry_hashes + .insert(key.clone(), hash_bytes(serialized.as_bytes())); } let serialized = @@ -2640,6 +3012,7 @@ mod tests { name: "srv".to_string(), action: McpAction::Added, renamed_to: None, + sha256: None, }], ) .unwrap(); @@ -2648,6 +3021,7 @@ mod tests { kept_local: vec!["srv".to_string()], replaced: Vec::new(), renamed: Vec::new(), + entry_hashes: HashMap::new(), final_path: dir.join("mcp.json"), missing_secrets: Vec::new(), }; @@ -3086,4 +3460,461 @@ mod tests { ); let _ = fs::remove_dir_all(&src_root); } + + fn owned_file_record(path: &str, contents: &str) -> FileRecord { + FileRecord { + path: path.to_string(), + category: "macros".to_string(), + sha256: hash_bytes(contents.as_bytes()), + action: FileAction::New, + } + } + + fn mcp_server_record(name: &str, action: McpAction, sha256: Option) -> McpServerRecord { + McpServerRecord { + name: name.to_string(), + action, + renamed_to: None, + sha256, + } + } + + #[test] + #[serial] + fn uninstall_deletes_intact_files_and_removes_the_bundle_record() { + let _guard = TestVaultConfigGuard::new("uninst-intact"); + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle("gone", test_metadata("https://github.com/x/gone")) + .unwrap(); + let dst = paths::macros_dir().join("hello.yaml"); + fs::create_dir_all(paths::macros_dir()).unwrap(); + fs::write(&dst, "hi\n").unwrap(); + store + .record_file("gone", owned_file_record("macros/hello.yaml", "hi\n")) + .unwrap(); + + uninstall_bundle("gone", true).unwrap(); + + assert!(!dst.exists()); + assert!( + !paths::macros_dir().exists(), + "emptied macros dir should be pruned" + ); + assert!(BundleStore::load().unwrap().get("gone").is_none()); + } + + #[test] + #[serial] + fn uninstall_keeps_modified_files_and_retains_the_record() { + let _guard = TestVaultConfigGuard::new("uninst-modified"); + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle("mods", test_metadata("https://github.com/x/mods")) + .unwrap(); + let dst = paths::macros_dir().join("edited.yaml"); + fs::create_dir_all(paths::macros_dir()).unwrap(); + fs::write(&dst, "user edit\n").unwrap(); + store + .record_file( + "mods", + owned_file_record("macros/edited.yaml", "original\n"), + ) + .unwrap(); + + uninstall_bundle("mods", true).unwrap(); + + assert_eq!(fs::read_to_string(&dst).unwrap(), "user edit\n"); + let store = BundleStore::load().unwrap(); + let record = store.get("mods").unwrap(); + assert_eq!(record.files.len(), 1); + assert_eq!(record.files[0].path, "macros/edited.yaml"); + } + + #[test] + fn apply_uninstall_delete_removes_file_record_and_empty_dirs() { + let dir = fresh_temp_dir("uninst-apply-delete-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let dst = dir.join("macros/gone.yaml"); + write_src(&dir, "macros/gone.yaml", "user edit"); + store + .record_file("omc", owned_file_record("macros/gone.yaml", "original")) + .unwrap(); + let mut summary = UninstallFileSummary::default(); + + apply_uninstall_file_action( + &mut store, + "omc", + "macros/gone.yaml", + &dst, + &dir, + ObsoleteAction::Delete, + &mut summary, + ) + .unwrap(); + + assert!(!dst.exists()); + assert!(!dir.join("macros").exists()); + assert_eq!(summary.deleted, 1); + assert!(store.get("omc").unwrap().files.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn uninstall_files_drop_records_for_missing_files() { + let dir = fresh_temp_dir("uninst-missing-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + store + .record_file("omc", owned_file_record("macros/ghost.yaml", "gone")) + .unwrap(); + let files = store.get("omc").unwrap().files.clone(); + + let summary = uninstall_owned_files(&mut store, "omc", &files, &dir, true).unwrap(); + + assert_eq!( + summary, + UninstallFileSummary { + missing: 1, + ..UninstallFileSummary::default() + } + ); + assert!(store.get("omc").unwrap().files.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn uninstall_files_never_touch_unowned_files() { + let dir = fresh_temp_dir("uninst-unowned-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + write_src(&dir, "macros/owned.yaml", "a"); + write_src(&dir, "macros/user.yaml", "mine"); + store + .record_file("omc", owned_file_record("macros/owned.yaml", "a")) + .unwrap(); + let files = store.get("omc").unwrap().files.clone(); + + let summary = uninstall_owned_files(&mut store, "omc", &files, &dir, true).unwrap(); + + assert_eq!(summary.deleted, 1); + assert!(!dir.join("macros/owned.yaml").exists()); + assert_eq!( + fs::read_to_string(dir.join("macros/user.yaml")).unwrap(), + "mine" + ); + let _ = fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn uninstall_files_keep_records_when_deletion_fails_and_continue() { + use std::os::unix::fs::PermissionsExt; + + struct RestorePerms(PathBuf); + impl Drop for RestorePerms { + fn drop(&mut self) { + let _ = fs::set_permissions(&self.0, fs::Permissions::from_mode(0o755)); + } + } + + let dir = fresh_temp_dir("uninst-fail-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + write_src(&dir, "locked/stuck.yaml", "a"); + write_src(&dir, "macros/ok.yaml", "b"); + store + .record_file("omc", owned_file_record("locked/stuck.yaml", "a")) + .unwrap(); + store + .record_file("omc", owned_file_record("macros/ok.yaml", "b")) + .unwrap(); + let locked = dir.join("locked"); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o555)).unwrap(); + let restore = RestorePerms(locked.clone()); + let probe = locked.join(".write-probe"); + if fs::write(&probe, "x").is_ok() { + // A privileged user bypasses permission bits; the failure path + // cannot be provoked this way. + let _ = fs::remove_file(&probe); + drop(restore); + let _ = fs::remove_dir_all(&dir); + return; + } + let files = store.get("omc").unwrap().files.clone(); + + let summary = uninstall_owned_files(&mut store, "omc", &files, &dir, true).unwrap(); + drop(restore); + + assert_eq!(summary.failed, 1); + assert_eq!(summary.deleted, 1); + assert!(dir.join("locked/stuck.yaml").exists()); + assert!(!dir.join("macros/ok.yaml").exists()); + let paths: Vec<&str> = store + .get("omc") + .unwrap() + .files + .iter() + .map(|f| f.path.as_str()) + .collect(); + assert_eq!(paths, vec!["locked/stuck.yaml"]); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn uninstall_files_skip_suspicious_recorded_paths() { + let dir = fresh_temp_dir("uninst-suspicious-"); + let config_dir = dir.join("config"); + fs::create_dir_all(&config_dir).unwrap(); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + write_src(&dir, "evil.yaml", "outside"); + let abs_victim = dir.join("abs.yaml"); + fs::write(&abs_victim, "outside").unwrap(); + store + .record_file("omc", owned_file_record("../evil.yaml", "outside")) + .unwrap(); + store + .record_file( + "omc", + owned_file_record(abs_victim.to_str().unwrap(), "outside"), + ) + .unwrap(); + let files = store.get("omc").unwrap().files.clone(); + + let summary = uninstall_owned_files(&mut store, "omc", &files, &config_dir, true).unwrap(); + + assert_eq!(summary.failed, 2); + assert_eq!(summary.deleted, 0); + assert!(dir.join("evil.yaml").exists()); + assert!(abs_victim.exists()); + assert_eq!(store.get("omc").unwrap().files.len(), 2); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn uninstall_mcp_keeps_replaced_entries_and_drops_the_record() { + let dir = fresh_temp_dir("uninst-mcp-replaced-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + store + .record_mcp_servers( + "omc", + vec![mcp_server_record("srv", McpAction::Replaced, None)], + ) + .unwrap(); + let mcp = dir.join("mcp.json"); + write_mcp( + &mcp, + r#"{"mcpServers": {"srv": {"type": "stdio", "command": "echo"}}}"#, + ); + let servers = store.get("omc").unwrap().mcp_servers.clone(); + + let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap(); + + assert_eq!(summary.kept, vec!["srv"]); + assert!(summary.removed.is_empty()); + let written: McpServersConfig = + serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); + assert!(written.mcp_servers.contains_key("srv")); + assert!(store.get("omc").unwrap().mcp_servers.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn uninstall_mcp_removes_intact_entries_and_leaves_unowned_keys() { + let dir = fresh_temp_dir("uninst-mcp-intact-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let mcp = dir.join("mcp.json"); + write_mcp( + &mcp, + r#"{"mcpServers": { + "srv": {"type": "stdio", "command": "echo"}, + "user-srv": {"type": "stdio", "command": "mine"} + }}"#, + ); + let parsed: McpServersConfig = + serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); + let hash = hash_bytes( + serde_json::to_string(parsed.mcp_servers.get("srv").unwrap()) + .unwrap() + .as_bytes(), + ); + store + .record_mcp_servers( + "omc", + vec![mcp_server_record("srv", McpAction::Added, Some(hash))], + ) + .unwrap(); + let servers = store.get("omc").unwrap().mcp_servers.clone(); + + let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap(); + + assert_eq!(summary.removed, vec!["srv"]); + assert!(summary.kept.is_empty()); + let written: McpServersConfig = + serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); + assert!(!written.mcp_servers.contains_key("srv")); + assert!(written.mcp_servers.contains_key("user-srv")); + assert!(store.get("omc").unwrap().mcp_servers.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn uninstall_mcp_keeps_modified_entries_and_their_records() { + let dir = fresh_temp_dir("uninst-mcp-modified-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let mcp = dir.join("mcp.json"); + write_mcp( + &mcp, + r#"{"mcpServers": {"srv": {"type": "stdio", "command": "edited"}}}"#, + ); + store + .record_mcp_servers( + "omc", + vec![mcp_server_record( + "srv", + McpAction::Added, + Some("0".repeat(64)), + )], + ) + .unwrap(); + let servers = store.get("omc").unwrap().mcp_servers.clone(); + + let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap(); + + assert_eq!(summary.kept, vec!["srv"]); + assert!(summary.removed.is_empty()); + let written: McpServersConfig = + serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); + assert!(written.mcp_servers.contains_key("srv")); + assert_eq!(store.get("omc").unwrap().mcp_servers.len(), 1); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn uninstall_mcp_keeps_legacy_records_without_a_hash() { + let dir = fresh_temp_dir("uninst-mcp-legacy-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let mcp = dir.join("mcp.json"); + write_mcp( + &mcp, + r#"{"mcpServers": {"srv": {"type": "stdio", "command": "echo"}}}"#, + ); + store + .record_mcp_servers( + "omc", + vec![mcp_server_record("srv", McpAction::Added, None)], + ) + .unwrap(); + let servers = store.get("omc").unwrap().mcp_servers.clone(); + + let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap(); + + assert_eq!(summary.kept, vec!["srv"]); + let written: McpServersConfig = + serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); + assert!(written.mcp_servers.contains_key("srv")); + assert_eq!(store.get("omc").unwrap().mcp_servers.len(), 1); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn uninstall_unknown_name_lists_installed_and_url_spelling_resolves() { + let _guard = TestVaultConfigGuard::new("uninst-unknown"); + + let err = uninstall_bundle("nope", true).unwrap_err(); + assert!(err.to_string().contains("none are installed"), "got: {err}"); + + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let err = uninstall_bundle("nope", true).unwrap_err(); + assert!( + err.to_string().contains("installed bundles: omc"), + "got: {err}" + ); + + uninstall_bundle("git@github.com:x/omc.git", true).unwrap(); + + assert!(BundleStore::load().unwrap().get("omc").is_none()); + } + + #[test] + #[serial] + fn uninstall_refuses_non_interactive_without_yes() { + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping uninstall_refuses_non_interactive_without_yes: requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("uninst-non-tty"); + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + + let err = uninstall_bundle("omc", false).unwrap_err(); + + assert!(err.to_string().contains("--yes"), "got: {err}"); + assert!(BundleStore::load().unwrap().get("omc").is_some()); + } + + #[test] + #[serial] + fn record_mcp_merge_stores_entry_hashes() { + let _guard = TestVaultConfigGuard::new("uninst-merge-hash"); + let dir = fresh_temp_dir("uninst-merge-hash-"); + let remote = dir.join("remote.json"); + let target = dir.join("target.json"); + write_mcp(&remote, FIXTURE_REMOTE); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + + let report = merge_mcp_json(None, &remote, &target, false).unwrap(); + record_mcp_merge(&mut store, "omc", &report).unwrap(); + + let written: McpServersConfig = + serde_json::from_str(&fs::read_to_string(&target).unwrap()).unwrap(); + let expected = hash_bytes( + serde_json::to_string(written.mcp_servers.get("alpha").unwrap()) + .unwrap() + .as_bytes(), + ); + let record = store.get("omc").unwrap(); + let alpha = record + .mcp_servers + .iter() + .find(|s| s.name == "alpha") + .unwrap(); + assert_eq!(alpha.sha256.as_deref(), Some(expected.as_str())); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 21c8e96..e1d822e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -32,7 +32,9 @@ pub use self::app_config::AppConfig; 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, update_bundle}; +pub use self::install_remote::{ + install_remote, install_remote_from_repl_args, uninstall_bundle, update_bundle, +}; pub use self::macro_policy::{ MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro, }; diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 8b8b7b8..9416dce 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -1,3 +1,4 @@ +use super::bundles::BundleStore; use super::rag_cache::{RagCache, RagKey}; use super::session::Session; use super::skill::{SKILL_SCAFFOLD, Skill}; @@ -3265,6 +3266,18 @@ impl RequestContext { values.push("remote".to_string()); super::map_completion_values(values) } + ".uninstall" => { + let values = BundleStore::load() + .map(|store| { + store + .bundle_names() + .into_iter() + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + super::map_completion_values(values) + } ".macro" => { let policy = self.macro_policy(); let mut values: Vec<(String, Option)> = policy @@ -4938,6 +4951,34 @@ mod tests { assert!(!ctx.maybe_autoname_session()); } + #[test] + #[serial] + fn repl_complete_uninstall_offers_installed_bundle_names() { + let _guard = TestConfigDirGuard::new(); + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle( + "omc", + crate::config::bundles::InstallMetadata { + source: "https://github.com/x/omc".to_string(), + git_ref: None, + commit: "abc123".to_string(), + version: None, + description: None, + homepage: None, + }, + ) + .unwrap(); + let ctx = create_test_ctx(); + + let values = ctx.repl_complete(".uninstall", &[""], ""); + + assert!( + values.iter().any(|(name, _)| name == "omc"), + "got: {values:?}" + ); + } + #[test] #[serial] fn exit_agent_clears_all_agent_state() { diff --git a/src/main.rs b/src/main.rs index d95dd75..377e211 100644 --- a/src/main.rs +++ b/src/main.rs @@ -142,6 +142,10 @@ async fn main() -> Result<()> { return config::update_bundle(spec); } + if let Some(name) = cli.uninstall.as_deref() { + return config::uninstall_bundle(name, cli.yes); + } + if let Some(client_arg) = &cli.authenticate { let cfg = Config::load_with_interpolation(true).await?; let app_config = AppConfig::from_config(cfg)?; diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 0d63c30..dfc209d 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -53,7 +53,7 @@ pub const DEFAULT_CONTINUATION_PROMPT: &str = indoc! {" 4. Continue with the next pending item now. Call tools immediately." }; -static REPL_COMMANDS: LazyLock<[ReplCommand; 60]> = LazyLock::new(|| { +static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| { [ ReplCommand::new(".help", "Show this help guide", AssertState::pass()), ReplCommand::new(".info", "Show system info", AssertState::pass()), @@ -320,6 +320,11 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 60]> = LazyLock::new(|| { "Reinstall bundled assets, or install assets from a remote git repo (.install remote )", AssertState::pass(), ), + ReplCommand::new( + ".uninstall", + "Uninstall an installed bundle (delete its owned files and MCP entries)", + AssertState::pass(), + ), ReplCommand::new( ".update", "Update Coyote to the latest release (or a specified version)", @@ -1202,6 +1207,12 @@ pub async fn run_repl_command( println!("Usage: .delete ") } }, + ".uninstall" => match args { + Some(args) => { + config::uninstall_bundle(args.trim(), false)?; + } + _ => println!("Usage: .uninstall "), + }, ".list" => match args { Some(args) => { ctx.list_assets(args.trim())?; @@ -1791,8 +1802,8 @@ mod tests { } #[test] - fn repl_commands_has_60_entries() { - assert_eq!(REPL_COMMANDS.len(), 60); + fn repl_commands_has_61_entries() { + assert_eq!(REPL_COMMANDS.len(), 61); } #[test] From bca85a4017c1d30ca24ab909bda59d99d508e338 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 16:06:14 -0600 Subject: [PATCH 07/20] feat: rename install flags and unify .install dispatch --install now takes a git URL or an installed bundle name: categories are redirected to the new --install-builtins, installed names become implicit updates, and source-shaped values install remotely. The old --install-from keeps its exact behavior as a hidden deprecated alias. The REPL's .install gains the same unified dispatch while keeping .install and .install remote back-compat. --- src/cli/mod.rs | 120 ++++++++++++++- src/config/bundles.rs | 2 +- src/config/install_remote.rs | 275 +++++++++++++++++++++++++++++++++- src/config/mod.rs | 3 +- src/config/request_context.rs | 41 +++++ src/main.rs | 9 +- src/repl/mod.rs | 86 ++++++++--- 7 files changed, 502 insertions(+), 34 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2716ecc..0c87db5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -50,7 +50,7 @@ pub enum McpScopeArg { "model", "prompt", "role", "session", "agent", "rag", "rebuild_rag", "macro_name", "execute", "code", "file", "no_stream", "no_memory", "init_memory", "dry_run", "info", "build_tools", "install", - "install_from", "sync_models", "list_models", "list_roles", + "install_from", "install_builtins", "sync_models", "list_models", "list_roles", "list_sessions", "list_agents", "list_rags", "list_macros", "list_skills", "list_bundles", "skill", "tail_logs", "completions", "update", "update_bundle", "uninstall", @@ -61,6 +61,11 @@ pub enum McpScopeArg { .args(["mcp_add", "mcp_remove", "mcp_list", "mcp_get"]) .multiple(false) ), + group( + ArgGroup::new("remote-install") + .args(["install", "install_from"]) + .multiple(false) + ), )] pub struct Cli { /// Input text @@ -180,30 +185,43 @@ pub struct Cli { #[arg(long, help_heading = "List & Discovery")] pub list_bundles: bool, + /// Install assets from a remote git repository (URL may be suffixed with #), or update an already-installed bundle by name + #[arg( + long, + value_name = "GIT_URL|NAME", + help_heading = "Installation & Updates" + )] + pub install: Option, /// Reinstall bundled assets, overwriting any local changes #[arg( long, value_name = "CATEGORY", value_enum, + conflicts_with_all = ["install", "install_from"], help_heading = "Installation & Updates" )] - pub install: Option, + pub install_builtins: Option, /// Install assets from a remote git repository (URL may be suffixed with #) - #[arg(long, value_name = "GIT_URL", help_heading = "Installation & Updates")] + #[arg( + long, + value_name = "GIT_URL", + hide = true, + help_heading = "Installation & Updates" + )] pub install_from: Option, - /// Restrict --install-from to a single asset category + /// Restrict a remote install to a single asset category #[arg( long, value_name = "CATEGORY", value_enum, - requires = "install_from", + requires = "remote-install", help_heading = "Installation & Updates" )] pub filter: Option, - /// Overwrite all conflicts without prompting (used with --install-from) + /// Overwrite all conflicts without prompting (used with --install) #[arg( long, - requires = "install_from", + requires = "remote-install", help_heading = "Installation & Updates" )] pub install_force: bool, @@ -540,6 +558,94 @@ mod tests { assert!(Cli::try_parse_from(["coyote", "--yes"]).is_err()); } + #[test] + fn parse_install_flag_takes_url_or_name() { + assert_eq!( + parse(&["--install", "https://github.com/x/y"]) + .install + .as_deref(), + Some("https://github.com/x/y") + ); + } + + #[test] + fn parse_install_builtins_flag_takes_category() { + assert_eq!( + parse(&["--install-builtins", "agents"]).install_builtins, + Some(AssetCategory::Agents) + ); + assert_eq!( + parse(&["--install-builtins", "mcp_config"]).install_builtins, + Some(AssetCategory::McpConfig) + ); + } + + #[test] + fn parse_install_from_flag_still_works() { + assert_eq!( + parse(&["--install-from", "https://github.com/x/y"]) + .install_from + .as_deref(), + Some("https://github.com/x/y") + ); + } + + #[test] + fn parse_install_conflicts_with_install_from() { + assert!(Cli::try_parse_from(["coyote", "--install", "x", "--install-from", "y"]).is_err()); + } + + #[test] + fn parse_install_builtins_conflicts_with_remote_install_flags() { + assert!( + Cli::try_parse_from(["coyote", "--install-builtins", "agents", "--install", "x"]) + .is_err() + ); + assert!( + Cli::try_parse_from([ + "coyote", + "--install-builtins", + "agents", + "--install-from", + "y" + ]) + .is_err() + ); + } + + #[test] + fn parse_filter_requires_a_remote_install_flag() { + assert!(Cli::try_parse_from(["coyote", "--filter", "agents"]).is_err()); + assert_eq!( + parse(&["--install", "https://github.com/x/y", "--filter", "agents"]).filter, + Some(InstallFilter::Agents) + ); + assert_eq!( + parse(&[ + "--install-from", + "https://github.com/x/y", + "--filter", + "agents" + ]) + .filter, + Some(InstallFilter::Agents) + ); + } + + #[test] + fn parse_install_force_requires_a_remote_install_flag() { + assert!(Cli::try_parse_from(["coyote", "--install-force"]).is_err()); + assert!(parse(&["--install", "https://github.com/x/y", "--install-force"]).install_force); + } + + #[test] + fn help_hides_install_from_and_shows_install_builtins() { + use clap::CommandFactory; + let help = Cli::command().render_long_help().to_string(); + assert!(!help.contains("--install-from"), "help: {help}"); + assert!(help.contains("--install-builtins")); + } + #[test] fn parse_multiple_skill_flags_preserves_order() { assert_eq!( diff --git a/src/config/bundles.rs b/src/config/bundles.rs index 0c6d1b8..fc3b211 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -542,7 +542,7 @@ 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 `."); + println!("No bundles installed. Install one with `coyote --install `."); return Ok(()); } diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 1eb28c8..9bf6a84 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -2,7 +2,7 @@ use super::bundles::{ BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes, hash_file, }; -use crate::config::{InstallFilter, paths}; +use crate::config::{AssetCategory, InstallFilter, paths}; #[cfg(not(windows))] use crate::function::Language; use crate::mcp::{McpServer, McpServersConfig}; @@ -10,6 +10,7 @@ use crate::utils; use crate::utils::IS_STDOUT_TERMINAL; use crate::vault::{Vault, create_vault_password_file, interpolate_secrets}; use anyhow::{Context, Result, anyhow, bail}; +use clap::ValueEnum; use indexmap::IndexMap; use indoc::formatdoc; use inquire::{Confirm, Select}; @@ -80,6 +81,14 @@ pub fn install_remote_from_repl_args(args: &str) -> Result<()> { ) })?; + let (filter, force) = parse_repl_install_flags(".install remote", iter)?; + install_remote(&url, filter, force) +} + +fn parse_repl_install_flags( + command: &str, + mut iter: impl Iterator, +) -> Result<(Option, bool)> { let mut filter: Option = None; let mut force = false; @@ -98,11 +107,117 @@ pub fn install_remote_from_repl_args(args: &str) -> Result<()> { s if s.starts_with("--filter=") => { filter = Some(parse_filter(&s["--filter=".len()..])?); } - other => bail!("Unexpected argument to '.install remote': {other}"), + other => bail!("Unexpected argument to '{command}': {other}"), } } - install_remote(&url, filter, force) + Ok((filter, force)) +} + +#[derive(Debug, Clone, PartialEq)] +enum InstallTarget { + Category(AssetCategory), + InstalledBundle, + RemoteSource, + Unknown, +} + +fn classify_install_target(value: &str, installed_names: &[String]) -> InstallTarget { + if let Some(category) = AssetCategory::parse(value) { + return InstallTarget::Category(category); + } + let name = strip_ref_suffix(value); + if installed_names.iter().any(|installed| installed == name) { + return InstallTarget::InstalledBundle; + } + if looks_like_remote_source(value) { + return InstallTarget::RemoteSource; + } + InstallTarget::Unknown +} + +/// A value is treated as a source when it is a URL, an scp-style +/// `[user@]host:path`, or an explicit local path. Bare names never are: they +/// must match an installed bundle. +fn looks_like_remote_source(value: &str) -> bool { + if value.contains("://") + || value.starts_with("./") + || value.starts_with("../") + || value.starts_with('/') + || value.starts_with('~') + { + return true; + } + match value.split_once(':') { + Some((host, _)) => { + !host.is_empty() && !host.contains('/') && !host.chars().any(char::is_whitespace) + } + None => false, + } +} + +/// Unified entry point behind `--install` and the REPL's `.install `: +/// asset categories are redirected to `--install-builtins`, installed bundle +/// names become updates, and anything shaped like a source is installed. +pub fn install_or_update(value: &str, filter: Option, force: bool) -> Result<()> { + let store = BundleStore::load()?; + let installed: Vec = store + .bundle_names() + .into_iter() + .map(str::to_string) + .collect(); + + match classify_install_target(value, &installed) { + InstallTarget::Category(category) => { + let name = category + .to_possible_value() + .map_or_else(|| value.to_string(), |v| v.get_name().to_string()); + let update_hint = if installed.iter().any(|installed| installed == value) { + format!(" To update the installed bundle '{value}', use `--update-bundle {value}`.") + } else { + String::new() + }; + bail!( + "'{value}' is an asset category, not a bundle; did you mean \ + `--install-builtins {name}`? (categories are reinstalled from \ + the assets built into coyote, not from a remote){update_hint}" + ); + } + InstallTarget::InstalledBundle => { + if filter.is_some() || force { + bail!("--filter/--install-force only apply to remote installs, not bundle updates"); + } + update_bundle(value) + } + InstallTarget::RemoteSource => install_remote(value, filter, force), + InstallTarget::Unknown => { + let hint = "a remote source must be a git URL, an scp-style host:path, \ + or an explicit local path (./dir, /abs, ~)"; + if installed.is_empty() { + bail!("no bundle named '{value}' is installed; none are installed ({hint})"); + } + bail!( + "no bundle named '{value}' is installed; installed bundles: {} ({hint})", + installed.join(", ") + ) + } + } +} + +pub fn install_or_update_from_repl_args(args: &str) -> Result<()> { + let tokens = shell_words::split(args) + .with_context(|| format!("failed to parse '.install' args: {args}"))?; + + let mut iter = tokens.into_iter(); + let value = iter.next().with_context(|| { + format!( + "Usage: .install [--filter <{}>] [--force]", + InstallFilter::NAMES.join("|") + ) + })?; + + let (filter, force) = parse_repl_install_flags(".install", iter)?; + install_or_update(&value, filter, force) } /// Update an installed bundle from its recorded source. `spec` is the bundle @@ -3461,6 +3576,160 @@ mod tests { let _ = fs::remove_dir_all(&src_root); } + fn owned_names(names: &[&str]) -> Vec { + names.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn classify_urls_and_paths_as_remote_sources() { + for value in [ + "https://github.com/x/y", + "git@github.com:x/y.git", + "./bundle-dir", + "../up", + "/abs/path", + "~/home-rel", + ] { + assert_eq!( + classify_install_target(value, &[]), + InstallTarget::RemoteSource, + "value: {value}" + ); + } + } + + #[test] + fn classify_categories_win_over_installed_names() { + assert_eq!( + classify_install_target("agents", &owned_names(&["agents"])), + InstallTarget::Category(AssetCategory::Agents) + ); + assert_eq!( + classify_install_target("mcp_config", &[]), + InstallTarget::Category(AssetCategory::McpConfig) + ); + } + + #[test] + fn classify_installed_names_with_optional_ref() { + let installed = owned_names(&["omc"]); + assert_eq!( + classify_install_target("omc", &installed), + InstallTarget::InstalledBundle + ); + assert_eq!( + classify_install_target("omc#v2", &installed), + InstallTarget::InstalledBundle + ); + } + + #[test] + fn classify_bare_names_without_a_match_as_unknown() { + assert_eq!(classify_install_target("omc", &[]), InstallTarget::Unknown); + assert_eq!( + classify_install_target("not-a-bundle", &owned_names(&["omc"])), + InstallTarget::Unknown + ); + } + + #[test] + #[serial] + fn install_or_update_redirects_categories_to_install_builtins() { + let _guard = TestVaultConfigGuard::new("iou-category"); + + let err = install_or_update("agents", None, false).unwrap_err(); + + assert!( + err.to_string().contains("--install-builtins agents"), + "got: {err}" + ); + } + + #[test] + #[serial] + fn install_or_update_unknown_name_lists_installed_bundles() { + let _guard = TestVaultConfigGuard::new("iou-unknown"); + + let err = install_or_update("not-a-bundle", None, false).unwrap_err(); + assert!(err.to_string().contains("none are installed"), "got: {err}"); + + let src_root = fresh_temp_dir("iou-unknown-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: known-bundle\n"); + write_src(&repo, "macros/m.yaml", "a: 1\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + + let err = install_or_update("not-a-bundle", None, false).unwrap_err(); + + assert!( + err.to_string().contains("no bundle named 'not-a-bundle'"), + "got: {err}" + ); + assert!( + err.to_string().contains("installed bundles: known-bundle"), + "got: {err}" + ); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn install_or_update_category_error_hints_update_for_shadowed_bundle() { + let _guard = TestVaultConfigGuard::new("iou-shadow"); + + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle( + "agents", + InstallMetadata { + source: "https://github.com/x/agents".to_string(), + git_ref: None, + commit: "abc123".to_string(), + version: None, + description: None, + homepage: None, + }, + ) + .unwrap(); + + let err = install_or_update("agents", None, false).unwrap_err(); + + assert!( + err.to_string().contains("--install-builtins agents"), + "got: {err}" + ); + assert!( + err.to_string().contains("--update-bundle agents"), + "got: {err}" + ); + } + + #[test] + #[serial] + fn install_or_update_rejects_remote_flags_for_updates() { + let _guard = TestVaultConfigGuard::new("iou-flags"); + let src_root = fresh_temp_dir("iou-flags-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: flag-bundle\n"); + write_src(&repo, "macros/m.yaml", "a: 1\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + + let err = install_or_update("flag-bundle", Some(InstallFilter::Macros), false).unwrap_err(); + assert!( + err.to_string().contains("only apply to remote installs"), + "got: {err}" + ); + + let err = install_or_update("flag-bundle", None, true).unwrap_err(); + assert!( + err.to_string().contains("only apply to remote installs"), + "got: {err}" + ); + let _ = fs::remove_dir_all(&src_root); + } + fn owned_file_record(path: &str, contents: &str) -> FileRecord { FileRecord { path: path.to_string(), diff --git a/src/config/mod.rs b/src/config/mod.rs index e1d822e..a8ceb60 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -33,7 +33,8 @@ 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, uninstall_bundle, update_bundle, + install_or_update, install_or_update_from_repl_args, install_remote, + install_remote_from_repl_args, uninstall_bundle, update_bundle, }; pub use self::macro_policy::{ MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro, diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 9416dce..3b54b5d 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -3264,6 +3264,17 @@ impl RequestContext { let mut values: Vec = AssetCategory::NAMES.iter().map(|s| s.to_string()).collect(); values.push("remote".to_string()); + values.extend( + BundleStore::load() + .map(|store| { + store + .bundle_names() + .into_iter() + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(), + ); super::map_completion_values(values) } ".uninstall" => { @@ -4979,6 +4990,36 @@ mod tests { ); } + #[test] + #[serial] + fn repl_complete_install_offers_categories_remote_and_bundles() { + let _guard = TestConfigDirGuard::new(); + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle( + "omc", + crate::config::bundles::InstallMetadata { + source: "https://github.com/x/omc".to_string(), + git_ref: None, + commit: "abc123".to_string(), + version: None, + description: None, + homepage: None, + }, + ) + .unwrap(); + let ctx = create_test_ctx(); + + let values = ctx.repl_complete(".install", &[""], ""); + + for expected in ["agents", "remote", "omc"] { + assert!( + values.iter().any(|(name, _)| name == expected), + "missing '{expected}'; got: {values:?}" + ); + } + } + #[test] #[serial] fn exit_agent_clears_all_agent_state() { diff --git a/src/main.rs b/src/main.rs index 377e211..eb36f2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -130,11 +130,18 @@ async fn main() -> Result<()> { install_builtins()?; - if let Some(category) = cli.install { + if let Some(category) = cli.install_builtins { return config::install_assets(category); } + if let Some(value) = cli.install.as_deref() { + return config::install_or_update(value, cli.filter, cli.install_force); + } + if let Some(url) = cli.install_from.as_deref() { + eprintln!( + "warning: --install-from is deprecated and will be removed in a future release; use --install " + ); return config::install_remote(url, cli.filter, cli.install_force); } diff --git a/src/repl/mod.rs b/src/repl/mod.rs index dfc209d..f67bfde 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -317,7 +317,7 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 61]> = LazyLock::new(|| { ), ReplCommand::new( ".install", - "Reinstall bundled assets, or install assets from a remote git repo (.install remote )", + "Reinstall bundled assets, install a bundle from a git repo, or update an installed bundle", AssertState::pass(), ), ReplCommand::new( @@ -870,27 +870,20 @@ pub async fn run_repl_command( replay::render(app.as_ref(), &compressed, &active)?; } } - ".install" => { - let trimmed = args.map(str::trim).unwrap_or(""); - let mut parts = trimmed.splitn(2, char::is_whitespace); - match parts.next() { - Some("remote") => { - let rest = parts.next().unwrap_or("").trim(); - config::install_remote_from_repl_args(rest)?; - } - Some(name) if !name.is_empty() => match AssetCategory::parse(name) { - Some(category) => config::install_assets(category)?, - None => println!( - "Unknown asset category '{name}'. Valid categories: {}", - AssetCategory::NAMES.join(", ") - ), - }, - _ => println!( - "Usage: .install <{}> | .install remote ", - AssetCategory::NAMES.join("|") - ), + ".install" => match parse_repl_install(args) { + ReplInstallDispatch::Builtins(category) => config::install_assets(category)?, + ReplInstallDispatch::Remote(rest) => { + config::install_remote_from_repl_args(rest)?; } - } + ReplInstallDispatch::Unified(value) => { + config::install_or_update_from_repl_args(value)?; + } + ReplInstallDispatch::Usage => println!( + "Usage: .install <{}> | .install | \ + .install remote [--filter ] [--force]", + AssetCategory::NAMES.join("|") + ), + }, ".update" => { if ctx.macro_flag { bail!("Cannot perform this operation because you are in a macro") @@ -1581,6 +1574,27 @@ fn unknown_command() -> Result<()> { bail!(r#"Unknown command. Type ".help" for additional help."#); } +#[derive(Debug, PartialEq)] +enum ReplInstallDispatch<'a> { + Builtins(AssetCategory), + Remote(&'a str), + Unified(&'a str), + Usage, +} + +fn parse_repl_install(args: Option<&str>) -> ReplInstallDispatch<'_> { + let trimmed = args.map(str::trim).unwrap_or(""); + let mut parts = trimmed.splitn(2, char::is_whitespace); + match parts.next() { + Some("remote") => ReplInstallDispatch::Remote(parts.next().unwrap_or("").trim()), + Some(name) if !name.is_empty() => match AssetCategory::parse(name) { + Some(category) => ReplInstallDispatch::Builtins(category), + None => ReplInstallDispatch::Unified(trimmed), + }, + _ => ReplInstallDispatch::Usage, + } +} + pub fn builtin_command_names() -> Vec<&'static str> { let mut names: Vec<&'static str> = REPL_COMMANDS .iter() @@ -1806,6 +1820,36 @@ mod tests { assert_eq!(REPL_COMMANDS.len(), 61); } + #[test] + fn parse_repl_install_keeps_category_and_remote_back_compat() { + assert_eq!( + parse_repl_install(Some("agents")), + ReplInstallDispatch::Builtins(AssetCategory::Agents) + ); + assert_eq!( + parse_repl_install(Some("remote https://x --force")), + ReplInstallDispatch::Remote("https://x --force") + ); + } + + #[test] + fn parse_repl_install_routes_other_values_to_unified_dispatch() { + assert_eq!( + parse_repl_install(Some("https://github.com/x/y")), + ReplInstallDispatch::Unified("https://github.com/x/y") + ); + assert_eq!( + parse_repl_install(Some("my-bundle")), + ReplInstallDispatch::Unified("my-bundle") + ); + } + + #[test] + fn parse_repl_install_empty_args_ask_for_usage() { + assert_eq!(parse_repl_install(None), ReplInstallDispatch::Usage); + assert_eq!(parse_repl_install(Some(" ")), ReplInstallDispatch::Usage); + } + #[test] fn builtin_command_names_are_sorted_deduped_first_words_without_dots() { let names = builtin_command_names(); From 89df8ec1cae91be5ae7e9e44da24d03754d9ecaf Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 17:44:39 -0600 Subject: [PATCH 08/20] docs: document bundle lifecycle and manifest for sharing configurations --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b9b00df..0ea2b86 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,18 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g * [AIChat Migration Guide](https://github.com/Dark-Alex-17/coyote/wiki/AIChat-Migration): Coming from AIChat? Follow the migration guide to get started. * [Installation](#install): Install Coyote * [Getting Started](#getting-started): Get started with Coyote by doing first-run setup steps. -* [Sharing Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Sharing-Configurations): Install bundles of agents, roles, macros, tools, and MCP servers from any git repo, and share your own. +* [Sharing Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Sharing-Configurations): Install bundles of agents, roles, macros, tools, and MCP servers from any git repo, and share your own. Bundles are Coyote's equivalent of plugins in other CLI agents. + * Manage the full bundle lifecycle from the CLI: `coyote --install ` installs a bundle from any git repository (suffix the URL with `#` to pin a branch, tag, or commit; passing an installed bundle's name updates it instead), `--list-bundles` lists installed bundles and their drift status, `--update-bundle ` updates a bundle from its recorded source (suffix `#` to move a pin), and `--uninstall ` deletes a bundle's owned files and removes its `mcp.json` entries (add `--yes` to skip confirmation prompts; locally modified items are still kept). `--filter ` restricts a remote install to a single asset category, and `--install-force` overwrites all conflicts without prompting. + * The same lifecycle is available in the REPL: `.install `, `.list bundles`, and `.uninstall `. + * Bundle authors can optionally add a `coyote-bundle.yaml` manifest to the repository root to give the bundle a stable identity. The manifest is identity-only — the bundle's contents are always discovered by scanning the repository, never declared. Without a manifest, the bundle is named after the repository. + ```yaml + name: oh-my-coyote # required; the bundle's identity + version: "1.4.0" # optional; informational + description: Opinionated roles, macros, and skills for Coyote + homepage: https://github.com/example/oh-my-coyote # optional + ``` + * `coyote --install-builtins ` reinstalls Coyote's bundled assets for a category, overwriting any local changes (built-in assets are not bundles). + * The old `--install-from ` flag still works but is deprecated; use `--install` instead. * [REPL](https://github.com/Dark-Alex-17/coyote/wiki/REPL): Interactive Read-Eval-Print Loop for conversational interactions with LLMs and Coyote. * [Custom REPL Prompt](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt): Customize the REPL prompt to provide useful contextual information. * [Vault](https://github.com/Dark-Alex-17/coyote/wiki/Vault): Securely store and manage sensitive information such as API keys and credentials. From 9541a094d832c14f0744d974e352146e0096630f Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 17:58:54 -0600 Subject: [PATCH 09/20] fix: make bundle provenance portable to Windows Provenance records stored OS-native path separators, making installed-bundles.yaml non-portable; slug derivation treated a Windows drive letter as an scp host and swallowed the whole path into one sanitized segment. Store paths are now always forward-slashed and backslashes normalize before URL parsing. Test fixture repos commit a '* -text' .gitattributes so clone-side autocrlf cannot rewrite content assertions. --- src/config/install_remote.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 9bf6a84..b747f3d 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -1076,6 +1076,7 @@ fn strip_ref_suffix(url: &str) -> &str { fn split_host_and_path(url: &str) -> (String, String) { let url = strip_ref_suffix(url); + let url = &url.replace('\\', "/"); if let Some((_, rest)) = url.split_once("://") { let (authority, path) = rest.split_once('/').unwrap_or((rest, "")); let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); @@ -1544,10 +1545,8 @@ fn record_written_file( } fn provenance_path(dst: &Path) -> String { - dst.strip_prefix(paths::config_dir()) - .unwrap_or(dst) - .to_string_lossy() - .into_owned() + let rel = dst.strip_prefix(paths::config_dir()).unwrap_or(dst); + rel.to_string_lossy().replace('\\', "/") } /// Record the mcp.json entries the merge actually wrote, in one flush right @@ -2807,6 +2806,7 @@ mod tests { fn init_git_repo(dir: &Path) -> String { run_git(vec!["init".into(), "-q".into(), dir.as_os_str().into()]).unwrap(); + fs::write(dir.join(".gitattributes"), "* -text\n").unwrap(); commit_file(dir, "seed.txt", "one") } @@ -2860,6 +2860,7 @@ mod tests { fn init_bundle_repo(dir: &Path) -> String { run_git(vec!["init".into(), "-q".into(), dir.as_os_str().into()]).unwrap(); + fs::write(dir.join(".gitattributes"), "* -text\n").unwrap(); commit_file(dir, ".seed", "seed") } From 4987d850f95256d9f8f18da0dd457edfb32d1309 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 18:15:16 -0600 Subject: [PATCH 10/20] feat!: remove the deprecated --install-from flag and .install remote form --install is the single entry point for remote installs and updates; the unified .install dispatch likewise replaces .install remote. Flag completion for .install now applies to the unified form. --- README.md | 1 - src/cli/mod.rs | 69 ++++++----------------------------- src/config/install_remote.rs | 16 -------- src/config/mod.rs | 3 +- src/config/request_context.rs | 7 ++-- src/main.rs | 7 ---- src/repl/mod.rs | 15 ++------ 7 files changed, 19 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 0ea2b86..afdab70 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,6 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g homepage: https://github.com/example/oh-my-coyote # optional ``` * `coyote --install-builtins ` reinstalls Coyote's bundled assets for a category, overwriting any local changes (built-in assets are not bundles). - * The old `--install-from ` flag still works but is deprecated; use `--install` instead. * [REPL](https://github.com/Dark-Alex-17/coyote/wiki/REPL): Interactive Read-Eval-Print Loop for conversational interactions with LLMs and Coyote. * [Custom REPL Prompt](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt): Customize the REPL prompt to provide useful contextual information. * [Vault](https://github.com/Dark-Alex-17/coyote/wiki/Vault): Securely store and manage sensitive information such as API keys and credentials. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0c87db5..45c1b0c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -50,7 +50,7 @@ pub enum McpScopeArg { "model", "prompt", "role", "session", "agent", "rag", "rebuild_rag", "macro_name", "execute", "code", "file", "no_stream", "no_memory", "init_memory", "dry_run", "info", "build_tools", "install", - "install_from", "install_builtins", "sync_models", "list_models", "list_roles", + "install_builtins", "sync_models", "list_models", "list_roles", "list_sessions", "list_agents", "list_rags", "list_macros", "list_skills", "list_bundles", "skill", "tail_logs", "completions", "update", "update_bundle", "uninstall", @@ -61,11 +61,6 @@ pub enum McpScopeArg { .args(["mcp_add", "mcp_remove", "mcp_list", "mcp_get"]) .multiple(false) ), - group( - ArgGroup::new("remote-install") - .args(["install", "install_from"]) - .multiple(false) - ), )] pub struct Cli { /// Input text @@ -197,33 +192,21 @@ pub struct Cli { long, value_name = "CATEGORY", value_enum, - conflicts_with_all = ["install", "install_from"], + conflicts_with_all = ["install"], help_heading = "Installation & Updates" )] pub install_builtins: Option, - /// Install assets from a remote git repository (URL may be suffixed with #) - #[arg( - long, - value_name = "GIT_URL", - hide = true, - help_heading = "Installation & Updates" - )] - pub install_from: Option, /// Restrict a remote install to a single asset category #[arg( long, value_name = "CATEGORY", value_enum, - requires = "remote-install", + requires = "install", help_heading = "Installation & Updates" )] pub filter: Option, /// Overwrite all conflicts without prompting (used with --install) - #[arg( - long, - requires = "remote-install", - help_heading = "Installation & Updates" - )] + #[arg(long, requires = "install", help_heading = "Installation & Updates")] pub install_force: bool, /// Update an installed bundle from its recorded source (NAME may be suffixed with # to move a pin) #[arg(long, value_name = "NAME", help_heading = "Installation & Updates")] @@ -581,65 +564,37 @@ mod tests { } #[test] - fn parse_install_from_flag_still_works() { - assert_eq!( - parse(&["--install-from", "https://github.com/x/y"]) - .install_from - .as_deref(), - Some("https://github.com/x/y") - ); + fn parse_install_from_is_no_longer_a_flag() { + let cli = parse(&["--install-from", "https://github.com/x/y"]); + assert!(cli.install.is_none()); + assert_eq!(cli.text, vec!["--install-from", "https://github.com/x/y"]); } #[test] - fn parse_install_conflicts_with_install_from() { - assert!(Cli::try_parse_from(["coyote", "--install", "x", "--install-from", "y"]).is_err()); - } - - #[test] - fn parse_install_builtins_conflicts_with_remote_install_flags() { + fn parse_install_builtins_conflicts_with_install() { assert!( Cli::try_parse_from(["coyote", "--install-builtins", "agents", "--install", "x"]) .is_err() ); - assert!( - Cli::try_parse_from([ - "coyote", - "--install-builtins", - "agents", - "--install-from", - "y" - ]) - .is_err() - ); } #[test] - fn parse_filter_requires_a_remote_install_flag() { + fn parse_filter_requires_install() { assert!(Cli::try_parse_from(["coyote", "--filter", "agents"]).is_err()); assert_eq!( parse(&["--install", "https://github.com/x/y", "--filter", "agents"]).filter, Some(InstallFilter::Agents) ); - assert_eq!( - parse(&[ - "--install-from", - "https://github.com/x/y", - "--filter", - "agents" - ]) - .filter, - Some(InstallFilter::Agents) - ); } #[test] - fn parse_install_force_requires_a_remote_install_flag() { + fn parse_install_force_requires_install() { assert!(Cli::try_parse_from(["coyote", "--install-force"]).is_err()); assert!(parse(&["--install", "https://github.com/x/y", "--install-force"]).install_force); } #[test] - fn help_hides_install_from_and_shows_install_builtins() { + fn help_omits_install_from_and_shows_install_builtins() { use clap::CommandFactory; let help = Cli::command().render_long_help().to_string(); assert!(!help.contains("--install-from"), "help: {help}"); diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index b747f3d..18df557 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -69,22 +69,6 @@ pub fn install_remote(git_url: &str, filter: Option, force: bool) Ok(()) } -pub fn install_remote_from_repl_args(args: &str) -> Result<()> { - let tokens = shell_words::split(args) - .with_context(|| format!("failed to parse '.install remote' args: {args}"))?; - - let mut iter = tokens.into_iter(); - let url = iter.next().with_context(|| { - format!( - "Usage: .install remote [--filter <{}>] [--force]", - InstallFilter::NAMES.join("|") - ) - })?; - - let (filter, force) = parse_repl_install_flags(".install remote", iter)?; - install_remote(&url, filter, force) -} - fn parse_repl_install_flags( command: &str, mut iter: impl Iterator, diff --git a/src/config/mod.rs b/src/config/mod.rs index a8ceb60..d3cf255 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -33,8 +33,7 @@ pub use self::app_state::AppState; pub use self::bundles::list_installed_bundles; pub use self::input::Input; pub use self::install_remote::{ - install_or_update, install_or_update_from_repl_args, install_remote, - install_remote_from_repl_args, uninstall_bundle, update_bundle, + install_or_update, install_or_update_from_repl_args, uninstall_bundle, update_bundle, }; pub use self::macro_policy::{ MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro, diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 3b54b5d..c2e2489 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -3263,7 +3263,6 @@ impl RequestContext { ".install" => { let mut values: Vec = AssetCategory::NAMES.iter().map(|s| s.to_string()).collect(); - values.push("remote".to_string()); values.extend( BundleStore::load() .map(|store| { @@ -3495,7 +3494,7 @@ impl RequestContext { values = complete_skills_with_descriptions(paths::list_skills()); } else if cmd == ".skill" && args.first() == Some(&"unload") && args.len() == 2 { values = complete_skills_with_descriptions(self.skill_registry.loaded_names()); - } else if cmd == ".install" && args.first() == Some(&"remote") && args.len() >= 2 { + } else if cmd == ".install" && args.len() >= 2 { let prev = args.get(args.len() - 2).copied().unwrap_or(""); if prev == "--filter" { values = super::map_completion_values( @@ -4992,7 +4991,7 @@ mod tests { #[test] #[serial] - fn repl_complete_install_offers_categories_remote_and_bundles() { + fn repl_complete_install_offers_categories_and_bundles() { let _guard = TestConfigDirGuard::new(); let mut store = BundleStore::load().unwrap(); store @@ -5012,7 +5011,7 @@ mod tests { let values = ctx.repl_complete(".install", &[""], ""); - for expected in ["agents", "remote", "omc"] { + for expected in ["agents", "omc"] { assert!( values.iter().any(|(name, _)| name == expected), "missing '{expected}'; got: {values:?}" diff --git a/src/main.rs b/src/main.rs index eb36f2d..402e896 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,13 +138,6 @@ async fn main() -> Result<()> { return config::install_or_update(value, cli.filter, cli.install_force); } - if let Some(url) = cli.install_from.as_deref() { - eprintln!( - "warning: --install-from is deprecated and will be removed in a future release; use --install " - ); - return config::install_remote(url, cli.filter, cli.install_force); - } - if let Some(spec) = cli.update_bundle.as_deref() { return config::update_bundle(spec); } diff --git a/src/repl/mod.rs b/src/repl/mod.rs index f67bfde..ce25aa9 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -872,15 +872,12 @@ pub async fn run_repl_command( } ".install" => match parse_repl_install(args) { ReplInstallDispatch::Builtins(category) => config::install_assets(category)?, - ReplInstallDispatch::Remote(rest) => { - config::install_remote_from_repl_args(rest)?; - } ReplInstallDispatch::Unified(value) => { config::install_or_update_from_repl_args(value)?; } ReplInstallDispatch::Usage => println!( - "Usage: .install <{}> | .install | \ - .install remote [--filter ] [--force]", + "Usage: .install <{}> | .install \ + [--filter ] [--force]", AssetCategory::NAMES.join("|") ), }, @@ -1577,7 +1574,6 @@ fn unknown_command() -> Result<()> { #[derive(Debug, PartialEq)] enum ReplInstallDispatch<'a> { Builtins(AssetCategory), - Remote(&'a str), Unified(&'a str), Usage, } @@ -1586,7 +1582,6 @@ fn parse_repl_install(args: Option<&str>) -> ReplInstallDispatch<'_> { let trimmed = args.map(str::trim).unwrap_or(""); let mut parts = trimmed.splitn(2, char::is_whitespace); match parts.next() { - Some("remote") => ReplInstallDispatch::Remote(parts.next().unwrap_or("").trim()), Some(name) if !name.is_empty() => match AssetCategory::parse(name) { Some(category) => ReplInstallDispatch::Builtins(category), None => ReplInstallDispatch::Unified(trimmed), @@ -1821,15 +1816,11 @@ mod tests { } #[test] - fn parse_repl_install_keeps_category_and_remote_back_compat() { + fn parse_repl_install_routes_categories_to_builtins() { assert_eq!( parse_repl_install(Some("agents")), ReplInstallDispatch::Builtins(AssetCategory::Agents) ); - assert_eq!( - parse_repl_install(Some("remote https://x --force")), - ReplInstallDispatch::Remote("https://x --force") - ); } #[test] From 2d1bf372d8cec68b60fe58af5fda6c5dbd235edd Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 18:37:44 -0600 Subject: [PATCH 11/20] style: strip narration comments from bundle provenance code Function docs that restated behavior already evident from names, signatures, and code are removed; only comments carrying invariants the code cannot express remain. --- src/config/bundles.rs | 41 ++++++---------------- src/config/install_remote.rs | 68 +++++++++--------------------------- 2 files changed, 27 insertions(+), 82 deletions(-) diff --git a/src/config/bundles.rs b/src/config/bundles.rs index fc3b211..fe5b2c9 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -173,8 +173,6 @@ impl BundleStore { self.bundles.keys().map(String::as_str).collect() } - /// Look up the record installed from `url`, comparing canonical source URLs - /// so https/scp/`.git` spellings of the same remote all match. pub(crate) fn find_by_source(&self, url: &str) -> Option<(&str, &BundleRecord)> { let canonical = canonical_source_url(url); self.bundles @@ -183,12 +181,9 @@ impl BundleStore { .map(|(name, record)| (name.as_str(), record)) } - /// Decide the record key for an install from `url`, matching by canonical - /// source URL first and name second. If the URL is already tracked under a - /// different key (manifest name added, renamed, or removed since install), - /// the existing record is migrated to the new key — the same URL never gets - /// a second record. A name held by a different-source bundle is - /// owner-qualified instead. Both cases print a notice. + /// A URL already tracked under a different key migrates to the new key — + /// the same URL never gets a second record. A name held by a + /// different-source bundle is owner-qualified instead. pub(crate) fn resolve_bundle_name( &mut self, url: &str, @@ -287,10 +282,6 @@ impl BundleStore { Ok(resolved) } - /// Create or update the record's metadata and persist it. Repeated installs - /// of the same bundle (e.g. with different filters) merge into one record: - /// metadata is refreshed, files accumulate, and the original install time - /// is kept. pub(crate) fn upsert_bundle(&mut self, name: &str, metadata: InstallMetadata) -> Result<()> { match self.bundles.get_mut(name) { Some(record) => { @@ -322,7 +313,6 @@ impl BundleStore { self.save() } - /// Stamp the record with the time of its most recent update from source. pub(crate) fn mark_updated(&mut self, name: &str) -> Result<()> { self.ensure_bundle_exists(name)?; let record = self @@ -333,8 +323,6 @@ impl BundleStore { self.save() } - /// Drop one path from a bundle's owned files and persist. Used when the - /// bundle no longer ships the file and it is gone (or deleted) locally. pub(crate) fn remove_file_record(&mut self, bundle: &str, path: &str) -> Result<()> { self.ensure_bundle_exists(bundle)?; let record = self @@ -345,8 +333,6 @@ impl BundleStore { self.save() } - /// Drop one mcp.json entry from a bundle's owned servers, matched by the - /// key it occupies in mcp.json, and persist. pub(crate) fn remove_mcp_record(&mut self, bundle: &str, effective_key: &str) -> Result<()> { self.ensure_bundle_exists(bundle)?; let record = self @@ -359,16 +345,14 @@ impl BundleStore { self.save() } - /// Remove a bundle's record entirely and persist. Used once an uninstall - /// has released everything the record owned. pub(crate) fn remove_bundle(&mut self, name: &str) -> Result<()> { self.ensure_bundle_exists(name)?; self.bundles.remove(name); self.save() } - /// Record one written file and persist immediately, so an install aborted - /// partway through still has provenance for everything already on disk. + /// Persists per call, so an install aborted partway through still has + /// provenance for everything already on disk. /// A path owned by another bundle transfers to `bundle`. pub(crate) fn record_file(&mut self, bundle: &str, file: FileRecord) -> Result<()> { self.ensure_bundle_exists(bundle)?; @@ -386,12 +370,9 @@ impl BundleStore { self.save() } - /// Record the mcp.json entries an install wrote, in one persisted flush. - /// An entry whose key any bundle already owns — including `bundle` itself - /// on an update — transfers to `bundle`: the old owner drops it, and a - /// `replaced` action is upgraded to `transferred` (removable at uninstall - /// — plain `replaced` marks a pre-existing user entry that uninstall must - /// never delete). + /// An entry whose key any bundle already owns transfers to `bundle`, and + /// its `replaced` action upgrades to `transferred` — plain `replaced` + /// marks a pre-existing user entry that uninstall must never delete. pub(crate) fn record_mcp_servers( &mut self, bundle: &str, @@ -491,10 +472,8 @@ pub(crate) struct BundleListRow { 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. +/// An unreadable file counts as locally modified: it exists but its integrity +/// cannot be verified. pub(crate) fn bundle_list_rows(store: &BundleStore, config_dir: &Path) -> Vec { store .iter() diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 18df557..9253126 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -120,9 +120,6 @@ fn classify_install_target(value: &str, installed_names: &[String]) -> InstallTa InstallTarget::Unknown } -/// A value is treated as a source when it is a URL, an scp-style -/// `[user@]host:path`, or an explicit local path. Bare names never are: they -/// must match an installed bundle. fn looks_like_remote_source(value: &str) -> bool { if value.contains("://") || value.starts_with("./") @@ -140,9 +137,6 @@ fn looks_like_remote_source(value: &str) -> bool { } } -/// Unified entry point behind `--install` and the REPL's `.install `: -/// asset categories are redirected to `--install-builtins`, installed bundle -/// names become updates, and anything shaped like a source is installed. pub fn install_or_update(value: &str, filter: Option, force: bool) -> Result<()> { let store = BundleStore::load()?; let installed: Vec = store @@ -204,11 +198,8 @@ pub fn install_or_update_from_repl_args(args: &str) -> Result<()> { install_or_update(&value, filter, force) } -/// Update an installed bundle from its recorded source. `spec` is the bundle -/// name, optionally suffixed with `#` to move a pinned ref. The whole -/// remote is always processed — including categories a filtered install -/// excluded — because filtered installs merge into a single record and an -/// update brings that record in line with everything the remote now ships. +/// The whole remote is always processed — including categories a filtered +/// install excluded — because filtered installs merge into a single record. pub fn update_bundle(spec: &str) -> Result<()> { let (name, ref_override) = parse_url_with_ref(spec)?; @@ -279,11 +270,6 @@ pub fn update_bundle(spec: &str) -> Result<()> { Ok(()) } -/// A conflict on a file this bundle owns whose on-disk content still matches -/// the recorded hash is not a real conflict: the bundle wrote that content and -/// the user never touched it, so an update refreshes it without prompting. -/// Files the user modified — or that another bundle owns — keep the normal -/// conflict semantics. fn reclassify_owned_unmodified( mut plan: InstallPlan, store: &BundleStore, @@ -320,10 +306,8 @@ enum ObsoleteAction { Delete, } -/// Reconcile owned files the remote no longer ships. A file already gone from -/// disk just drops out of the record; a file still present is kept by default -/// (the user may rely on it) and only deleted on explicit confirmation. Kept -/// files stay in the record, so a later uninstall still offers to remove them. +/// Kept files stay in the record, so a later uninstall still offers to +/// remove them. fn handle_obsolete_files(store: &mut BundleStore, bundle: &str, plan: &InstallPlan) -> Result<()> { let planned: HashSet = plan .files @@ -423,11 +407,7 @@ struct UninstallMcpSummary { kept: Vec, } -/// Uninstall a bundle: delete the files it owns, remove its mcp.json entries, -/// and drop its store record once nothing is left. `spec` is the bundle name -/// or its source URL. Locally modified items are kept unless explicitly -/// confirmed at a prompt; kept and failed items stay in the record, so a -/// re-run offers them again. +/// Kept and failed items stay in the record, so a re-run offers them again. pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { let mut store = BundleStore::load()?; let name = match store.get(spec) { @@ -522,13 +502,8 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { Ok(()) } -/// Process the files a bundle owns under `config_dir`. Intact files (content -/// still matches the recorded hash) are deleted outright; missing files just -/// drop out of the record; modified files are kept unless confirmed for -/// deletion. A failed deletion keeps the record so a re-run can retry, and -/// never aborts the remaining files. A recorded path that could escape -/// `config_dir` (absolute, or containing anything but plain components) is -/// never touched; its record survives. +/// A recorded path that could escape `config_dir` (absolute, or containing +/// anything but plain components) is never touched; its record survives. fn uninstall_owned_files( store: &mut BundleStore, bundle: &str, @@ -674,14 +649,9 @@ fn resolve_uninstall_action( } } -/// Remove a bundle's entries from mcp.json. An entry is removed outright only -/// when its current content still matches the recorded hash; a modified (or -/// legacy, hash-less) entry is kept unless confirmed for deletion, and a -/// pre-existing server the bundle replaced is never removed. Keys the bundle -/// does not own are never touched. mcp.json is written before any ownership -/// record is dropped, so a failed write leaves every record intact for a -/// retry, while a crash after it leaves stale records the absent-server -/// branch cleans up on re-run. +/// mcp.json is written before any ownership record is dropped: a failed write +/// leaves every record intact for a retry, while a crash after it leaves +/// stale records the absent-server branch cleans up on re-run. fn uninstall_mcp_entries( store: &mut BundleStore, bundle: &str, @@ -982,8 +952,8 @@ pub(crate) struct BundleManifest { pub(crate) homepage: Option, } -/// Resolve the bundle's identity for this install and create or refresh its -/// provenance record. Returns the record key subsequent recording uses. +/// Returns the record key — possibly migrated or owner-qualified — that all +/// subsequent recording must use in place of the requested name. fn register_bundle( store: &mut BundleStore, url: &str, @@ -1505,12 +1475,9 @@ fn apply_plan( Ok(report) } -/// Provenance is recorded per written file, not at the end of the loop: a -/// conflict prompt can abort the install after earlier files already landed -/// on disk, and those must not become untracked orphans. Files the user kept -/// (or that were identical) are never recorded — ownership means "this -/// content exists because of this bundle" — so a kept file that another -/// bundle's record already owns stays with that owner. +/// Kept and identical files are never recorded — ownership means "this +/// content exists because of this bundle" — so a file another bundle's +/// record already owns stays with that owner. fn record_written_file( store: &mut BundleStore, bundle: &str, @@ -1533,9 +1500,8 @@ fn provenance_path(dst: &Path) -> String { rel.to_string_lossy().replace('\\', "/") } -/// Record the mcp.json entries the merge actually wrote, in one flush right -/// after the merged file itself. Entries the merge kept local are deliberately -/// absent: an entry another bundle already owns stays with that owner. +/// Entries the merge kept local are deliberately absent: an entry another +/// bundle already owns stays with that owner. fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeReport) -> Result<()> { let mut entries: Vec = Vec::new(); entries.extend(report.added.iter().map(|name| McpServerRecord { From 53ccbda97cca9c426eb0618cdd00e277161f4588 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 19:01:24 -0600 Subject: [PATCH 12/20] feat: expand owner/repo shorthand for --install with a --git-host flag --install someuser/repo expands to https://github.com/someuser/repo; --git-host overrides the default host and forces source interpretation even when the value matches an installed bundle name. Two or more path segments are accepted so nested GitLab-style groups work, and #ref pinning applies to shorthand values. --uninstall resolves owner/repo against recorded sources: a single match uninstalls, multiple matches prompt an interactive selection showing each bundle's source, and non-interactive runs bail instead of guessing. --- src/cli/mod.rs | 19 ++ src/config/install_remote.rs | 315 +++++++++++++++++++++++++++++++--- src/config/request_context.rs | 8 + src/main.rs | 7 +- src/repl/mod.rs | 4 +- 5 files changed, 327 insertions(+), 26 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 45c1b0c..91d66e2 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -187,6 +187,14 @@ pub struct Cli { help_heading = "Installation & Updates" )] pub install: Option, + /// Git host used to expand / shorthand values passed to --install + #[arg( + long, + value_name = "HOST", + requires = "install", + help_heading = "Installation & Updates" + )] + pub git_host: Option, /// Reinstall bundled assets, overwriting any local changes #[arg( long, @@ -593,6 +601,17 @@ mod tests { assert!(parse(&["--install", "https://github.com/x/y", "--install-force"]).install_force); } + #[test] + fn parse_git_host_requires_install() { + assert!(Cli::try_parse_from(["coyote", "--git-host", "git.x.com"]).is_err()); + assert_eq!( + parse(&["--install", "someuser/omc", "--git-host", "git.x.com"]) + .git_host + .as_deref(), + Some("git.x.com") + ); + } + #[test] fn help_omits_install_from_and_shows_install_builtins() { use clap::CommandFactory; diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 9253126..21348b6 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -72,9 +72,10 @@ pub fn install_remote(git_url: &str, filter: Option, force: bool) fn parse_repl_install_flags( command: &str, mut iter: impl Iterator, -) -> Result<(Option, bool)> { +) -> Result<(Option, bool, Option)> { let mut filter: Option = None; let mut force = false; + let mut git_host: Option = None; while let Some(tok) = iter.next() { match tok.as_str() { @@ -91,11 +92,20 @@ fn parse_repl_install_flags( s if s.starts_with("--filter=") => { filter = Some(parse_filter(&s["--filter=".len()..])?); } + "--git-host" => { + let val = iter + .next() + .with_context(|| "--git-host requires a value (e.g. git.somedomain.com)")?; + git_host = Some(val); + } + s if s.starts_with("--git-host=") => { + git_host = Some(s["--git-host=".len()..].to_string()); + } other => bail!("Unexpected argument to '{command}': {other}"), } } - Ok((filter, force)) + Ok((filter, force, git_host)) } #[derive(Debug, Clone, PartialEq)] @@ -103,6 +113,7 @@ enum InstallTarget { Category(AssetCategory), InstalledBundle, RemoteSource, + Shorthand, Unknown, } @@ -117,6 +128,9 @@ fn classify_install_target(value: &str, installed_names: &[String]) -> InstallTa if looks_like_remote_source(value) { return InstallTarget::RemoteSource; } + if is_repo_shorthand(value) { + return InstallTarget::Shorthand; + } InstallTarget::Unknown } @@ -137,7 +151,53 @@ fn looks_like_remote_source(value: &str) -> bool { } } -pub fn install_or_update(value: &str, filter: Option, force: bool) -> Result<()> { +const DEFAULT_GIT_HOST: &str = "github.com"; + +fn is_repo_shorthand(value: &str) -> bool { + let path = strip_ref_suffix(value); + if path.contains("://") + || path.contains(':') + || path.contains('\\') + || path.contains(char::is_whitespace) + || path.starts_with(['/', '~', '.', '-']) + { + return false; + } + let mut segments = path.split('/'); + segments.clone().count() >= 2 && segments.all(|segment| !segment.is_empty()) +} + +fn expand_repo_shorthand(value: &str, git_host: Option<&str>) -> Result { + let raw = git_host.unwrap_or(DEFAULT_GIT_HOST); + let host = raw + .strip_prefix("https://") + .or_else(|| raw.strip_prefix("http://")) + .unwrap_or(raw) + .trim_matches('/'); + if host.is_empty() || host.contains('/') || host.chars().any(char::is_whitespace) { + bail!("invalid --git-host '{raw}': expected a bare host like git.somedomain.com"); + } + Ok(format!("https://{host}/{value}")) +} + +pub fn install_or_update( + value: &str, + git_host: Option<&str>, + filter: Option, + force: bool, +) -> Result<()> { + if let Some(host) = git_host { + if !is_repo_shorthand(value) { + bail!( + "--git-host only applies to / shorthand values; \ + '{value}' is not one" + ); + } + let url = expand_repo_shorthand(value, Some(host))?; + println!("Resolved '{value}' to '{url}'"); + return install_remote(&url, filter, force); + } + let store = BundleStore::load()?; let installed: Vec = store .bundle_names() @@ -168,9 +228,15 @@ pub fn install_or_update(value: &str, filter: Option, force: bool update_bundle(value) } InstallTarget::RemoteSource => install_remote(value, filter, force), + InstallTarget::Shorthand => { + let url = expand_repo_shorthand(value, None)?; + println!("Resolved '{value}' to '{url}'"); + install_remote(&url, filter, force) + } InstallTarget::Unknown => { - let hint = "a remote source must be a git URL, an scp-style host:path, \ - or an explicit local path (./dir, /abs, ~)"; + let hint = "a remote source must be a git URL, an / shorthand \ + (expanded against --git-host, default github.com), an scp-style \ + host:path, or an explicit local path (./dir, /abs, ~)"; if installed.is_empty() { bail!("no bundle named '{value}' is installed; none are installed ({hint})"); } @@ -189,13 +255,14 @@ pub fn install_or_update_from_repl_args(args: &str) -> Result<()> { let mut iter = tokens.into_iter(); let value = iter.next().with_context(|| { format!( - "Usage: .install [--filter <{}>] [--force]", + "Usage: .install \ + [--git-host ] [--filter <{}>] [--force]", InstallFilter::NAMES.join("|") ) })?; - let (filter, force) = parse_repl_install_flags(".install", iter)?; - install_or_update(&value, filter, force) + let (filter, force, git_host) = parse_repl_install_flags(".install", iter)?; + install_or_update(&value, git_host.as_deref(), filter, force) } /// The whole remote is always processed — including categories a filtered @@ -414,16 +481,19 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { Some(_) => spec.to_string(), None => match store.find_by_source(spec) { Some((name, _)) => name.to_string(), - None => { - let installed = store.bundle_names(); - if installed.is_empty() { - bail!("no bundle named '{spec}' is installed; none are installed"); + None => match select_uninstall_candidate(&store, spec)? { + Some(name) => name, + None => { + let installed = store.bundle_names(); + if installed.is_empty() { + bail!("no bundle named '{spec}' is installed; none are installed"); + } + bail!( + "no bundle named '{spec}' is installed; installed bundles: {}", + installed.join(", ") + ); } - bail!( - "no bundle named '{spec}' is installed; installed bundles: {}", - installed.join(", ") - ); - } + }, }, }; let record = store.get(&name).expect("resolved above").clone(); @@ -502,6 +572,49 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { Ok(()) } +/// Never auto-picks between multiple matches: ambiguity is resolved by an +/// interactive prompt, and non-interactive runs bail even under --yes. +fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result> { + if !is_repo_shorthand(spec) { + return Ok(None); + } + let needle = format!("/{}", canonical_source_url(spec)); + let candidates: Vec<(String, String)> = store + .iter() + .filter(|(_, record)| canonical_source_url(&record.source).ends_with(&needle)) + .map(|(name, record)| (name.to_string(), record.source.clone())) + .collect(); + + match candidates.as_slice() { + [] => Ok(None), + [(name, _)] => Ok(Some(name.clone())), + _ => { + let described: Vec = candidates + .iter() + .map(|(name, source)| format!("{name} — {source}")) + .collect(); + if !*IS_STDOUT_TERMINAL { + bail!( + "'{spec}' matches multiple installed bundles: {}; re-run with the \ + exact bundle name or source URL", + described.join(", ") + ); + } + let picked = Select::new( + &format!("Multiple bundles match '{spec}'; which one should be uninstalled?"), + described.clone(), + ) + .prompt() + .with_context(|| "failed to read uninstall selection")?; + let index = described + .iter() + .position(|option| option == &picked) + .expect("selection came from the presented options"); + Ok(Some(candidates[index].0.clone())) + } + } +} + /// A recorded path that could escape `config_dir` (absolute, or containing /// anything but plain components) is never touched; its record survives. fn uninstall_owned_files( @@ -3588,7 +3701,7 @@ mod tests { fn install_or_update_redirects_categories_to_install_builtins() { let _guard = TestVaultConfigGuard::new("iou-category"); - let err = install_or_update("agents", None, false).unwrap_err(); + let err = install_or_update("agents", None, None, false).unwrap_err(); assert!( err.to_string().contains("--install-builtins agents"), @@ -3601,7 +3714,7 @@ mod tests { fn install_or_update_unknown_name_lists_installed_bundles() { let _guard = TestVaultConfigGuard::new("iou-unknown"); - let err = install_or_update("not-a-bundle", None, false).unwrap_err(); + let err = install_or_update("not-a-bundle", None, None, false).unwrap_err(); assert!(err.to_string().contains("none are installed"), "got: {err}"); let src_root = fresh_temp_dir("iou-unknown-src-"); @@ -3611,7 +3724,7 @@ mod tests { init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); - let err = install_or_update("not-a-bundle", None, false).unwrap_err(); + let err = install_or_update("not-a-bundle", None, None, false).unwrap_err(); assert!( err.to_string().contains("no bundle named 'not-a-bundle'"), @@ -3644,7 +3757,7 @@ mod tests { ) .unwrap(); - let err = install_or_update("agents", None, false).unwrap_err(); + let err = install_or_update("agents", None, None, false).unwrap_err(); assert!( err.to_string().contains("--install-builtins agents"), @@ -3667,13 +3780,14 @@ mod tests { init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); - let err = install_or_update("flag-bundle", Some(InstallFilter::Macros), false).unwrap_err(); + let err = + install_or_update("flag-bundle", None, Some(InstallFilter::Macros), false).unwrap_err(); assert!( err.to_string().contains("only apply to remote installs"), "got: {err}" ); - let err = install_or_update("flag-bundle", None, true).unwrap_err(); + let err = install_or_update("flag-bundle", None, None, true).unwrap_err(); assert!( err.to_string().contains("only apply to remote installs"), "got: {err}" @@ -3681,6 +3795,161 @@ mod tests { let _ = fs::remove_dir_all(&src_root); } + #[test] + fn shorthand_accepts_owner_repo_and_deeper_paths() { + assert!(is_repo_shorthand("someuser/oh-my-coyote")); + assert!(is_repo_shorthand("group/subgroup/repo")); + assert!(is_repo_shorthand("someuser/repo#v2")); + } + + #[test] + fn shorthand_rejects_urls_paths_and_bare_names() { + assert!(!is_repo_shorthand("https://github.com/x/y")); + assert!(!is_repo_shorthand("git@github.com:x/y.git")); + assert!(!is_repo_shorthand("./local/dir")); + assert!(!is_repo_shorthand("/abs/path")); + assert!(!is_repo_shorthand("~/home/path")); + assert!(!is_repo_shorthand("-flag/like")); + assert!(!is_repo_shorthand("bare-name")); + assert!(!is_repo_shorthand("a//b")); + assert!(!is_repo_shorthand("a/b/")); + assert!(!is_repo_shorthand("a\\b")); + assert!(!is_repo_shorthand("a b/c")); + } + + #[test] + fn shorthand_expands_against_default_and_custom_hosts() { + assert_eq!( + expand_repo_shorthand("someuser/omc", None).unwrap(), + "https://github.com/someuser/omc" + ); + assert_eq!( + expand_repo_shorthand("someuser/omc#v2", Some("git.somedomain.com")).unwrap(), + "https://git.somedomain.com/someuser/omc#v2" + ); + assert_eq!( + expand_repo_shorthand("a/b", Some("https://git.x.com/")).unwrap(), + "https://git.x.com/a/b" + ); + assert!(expand_repo_shorthand("a/b", Some("bad/host")).is_err()); + assert!(expand_repo_shorthand("a/b", Some("")).is_err()); + } + + #[test] + fn classify_prefers_installed_names_over_shorthand() { + assert_eq!( + classify_install_target("someuser/omc", &[]), + InstallTarget::Shorthand + ); + assert_eq!( + classify_install_target("someuser/omc", &["someuser/omc".to_string()]), + InstallTarget::InstalledBundle + ); + } + + #[test] + fn install_or_update_git_host_rejects_non_shorthand_values() { + let err = install_or_update("https://github.com/x/y", Some("git.x.com"), None, false) + .unwrap_err(); + assert!( + err.to_string().contains("--git-host only applies"), + "got: {err}" + ); + + let err = install_or_update("bare-name", Some("git.x.com"), None, false).unwrap_err(); + assert!( + err.to_string().contains("--git-host only applies"), + "got: {err}" + ); + } + + #[test] + fn repl_install_flags_parse_git_host() { + let (filter, force, host) = parse_repl_install_flags( + ".install", + vec!["--git-host".to_string(), "git.x.com".to_string()].into_iter(), + ) + .unwrap(); + assert_eq!(host.as_deref(), Some("git.x.com")); + assert!(filter.is_none() && !force); + + let (_, force, host) = parse_repl_install_flags( + ".install", + vec!["--git-host=git.y.com".to_string(), "--force".to_string()].into_iter(), + ) + .unwrap(); + assert_eq!(host.as_deref(), Some("git.y.com")); + assert!(force); + } + + #[test] + #[serial] + fn uninstall_shorthand_resolves_a_single_source_match() { + let _guard = TestVaultConfigGuard::new("uninst-short-one"); + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/someuser/omc")) + .unwrap(); + drop(store); + + uninstall_bundle("someuser/omc", true).unwrap(); + + let store = BundleStore::load().unwrap(); + assert!(store.get("omc").is_none()); + } + + #[test] + #[serial] + fn uninstall_shorthand_with_multiple_matches_bails_non_interactively() { + let _guard = TestVaultConfigGuard::new("uninst-short-multi"); + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/someuser/omc")) + .unwrap(); + store + .upsert_bundle( + "omc-fork", + test_metadata("https://git.somedomain.com/someuser/omc"), + ) + .unwrap(); + drop(store); + + let err = uninstall_bundle("someuser/omc", true).unwrap_err(); + + assert!( + err.to_string() + .contains("matches multiple installed bundles"), + "got: {err}" + ); + assert!(err.to_string().contains("omc-fork"), "got: {err}"); + let store = BundleStore::load().unwrap(); + assert!(store.get("omc").is_some()); + assert!(store.get("omc-fork").is_some()); + } + + #[test] + #[serial] + fn uninstall_exact_name_wins_over_shorthand_source_matches() { + let _guard = TestVaultConfigGuard::new("uninst-short-name"); + let mut store = BundleStore::load().unwrap(); + store + .upsert_bundle( + "someuser/omc", + test_metadata("https://git.somedomain.com/other/repo"), + ) + .unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/someuser/omc")) + .unwrap(); + drop(store); + + uninstall_bundle("someuser/omc", true).unwrap(); + + let store = BundleStore::load().unwrap(); + assert!(store.get("someuser/omc").is_none()); + assert!(store.get("omc").is_some()); + } + fn owned_file_record(path: &str, contents: &str) -> FileRecord { FileRecord { path: path.to_string(), diff --git a/src/config/request_context.rs b/src/config/request_context.rs index c2e2489..e255ec9 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -3500,11 +3500,16 @@ impl RequestContext { values = super::map_completion_values( InstallFilter::NAMES.iter().map(|s| s.to_string()).collect(), ); + } else if prev == "--git-host" { + values = super::map_completion_values(vec!["github.com".to_string()]); } else { let has_filter = args.iter().enumerate().any(|(i, a)| { a.starts_with("--filter=") || (*a == "--filter" && i < args.len() - 1) }); let has_force = args.contains(&"--force"); + let has_git_host = args.iter().enumerate().any(|(i, a)| { + a.starts_with("--git-host=") || (*a == "--git-host" && i < args.len() - 1) + }); let mut available: Vec<&str> = vec![]; if !has_filter { @@ -3513,6 +3518,9 @@ impl RequestContext { if !has_force { available.push("--force"); } + if !has_git_host { + available.push("--git-host"); + } values = super::map_completion_values(available); } diff --git a/src/main.rs b/src/main.rs index 402e896..eb2b06d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -135,7 +135,12 @@ async fn main() -> Result<()> { } if let Some(value) = cli.install.as_deref() { - return config::install_or_update(value, cli.filter, cli.install_force); + return config::install_or_update( + value, + cli.git_host.as_deref(), + cli.filter, + cli.install_force, + ); } if let Some(spec) = cli.update_bundle.as_deref() { diff --git a/src/repl/mod.rs b/src/repl/mod.rs index ce25aa9..4e48529 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -876,8 +876,8 @@ pub async fn run_repl_command( config::install_or_update_from_repl_args(value)?; } ReplInstallDispatch::Usage => println!( - "Usage: .install <{}> | .install \ - [--filter ] [--force]", + "Usage: .install <{}> | .install \ + [--git-host ] [--filter ] [--force]", AssetCategory::NAMES.join("|") ), }, From 84b90bfe26598748ab5c4d7352f6575e1e980153 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 19:14:15 -0600 Subject: [PATCH 13/20] style: remove em-dashes from comments and the uninstall selector --- src/config/bundles.rs | 4 ++-- src/config/install_remote.rs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/config/bundles.rs b/src/config/bundles.rs index fe5b2c9..7405761 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -181,7 +181,7 @@ impl BundleStore { .map(|(name, record)| (name.as_str(), record)) } - /// A URL already tracked under a different key migrates to the new key — + /// A URL already tracked under a different key migrates to the new key, so /// the same URL never gets a second record. A name held by a /// different-source bundle is owner-qualified instead. pub(crate) fn resolve_bundle_name( @@ -371,7 +371,7 @@ impl BundleStore { } /// An entry whose key any bundle already owns transfers to `bundle`, and - /// its `replaced` action upgrades to `transferred` — plain `replaced` + /// its `replaced` action upgrades to `transferred`; plain `replaced` /// marks a pre-existing user entry that uninstall must never delete. pub(crate) fn record_mcp_servers( &mut self, diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 21348b6..fd6a912 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -265,8 +265,8 @@ pub fn install_or_update_from_repl_args(args: &str) -> Result<()> { install_or_update(&value, git_host.as_deref(), filter, force) } -/// The whole remote is always processed — including categories a filtered -/// install excluded — because filtered installs merge into a single record. +/// The whole remote is always processed, including categories a filtered +/// install excluded, because filtered installs merge into a single record. pub fn update_bundle(spec: &str) -> Result<()> { let (name, ref_override) = parse_url_with_ref(spec)?; @@ -591,7 +591,7 @@ fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result { let described: Vec = candidates .iter() - .map(|(name, source)| format!("{name} — {source}")) + .map(|(name, source)| format!("{name} ({source})")) .collect(); if !*IS_STDOUT_TERMINAL { bail!( @@ -1065,7 +1065,7 @@ pub(crate) struct BundleManifest { pub(crate) homepage: Option, } -/// Returns the record key — possibly migrated or owner-qualified — that all +/// Returns the record key, possibly migrated or owner-qualified, that all /// subsequent recording must use in place of the requested name. fn register_bundle( store: &mut BundleStore, @@ -1588,8 +1588,8 @@ fn apply_plan( Ok(report) } -/// Kept and identical files are never recorded — ownership means "this -/// content exists because of this bundle" — so a file another bundle's +/// Kept and identical files are never recorded, because ownership means +/// "this content exists because of this bundle": a file another bundle's /// record already owns stays with that owner. fn record_written_file( store: &mut BundleStore, From 1136b1385b48be6599f66f49c1395198c250b32b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 19:22:41 -0600 Subject: [PATCH 14/20] docs: removed extra fluff on bundles from the main README --- README.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/README.md b/README.md index afdab70..02f2901 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,6 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g * [Installation](#install): Install Coyote * [Getting Started](#getting-started): Get started with Coyote by doing first-run setup steps. * [Sharing Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Sharing-Configurations): Install bundles of agents, roles, macros, tools, and MCP servers from any git repo, and share your own. Bundles are Coyote's equivalent of plugins in other CLI agents. - * Manage the full bundle lifecycle from the CLI: `coyote --install ` installs a bundle from any git repository (suffix the URL with `#` to pin a branch, tag, or commit; passing an installed bundle's name updates it instead), `--list-bundles` lists installed bundles and their drift status, `--update-bundle ` updates a bundle from its recorded source (suffix `#` to move a pin), and `--uninstall ` deletes a bundle's owned files and removes its `mcp.json` entries (add `--yes` to skip confirmation prompts; locally modified items are still kept). `--filter ` restricts a remote install to a single asset category, and `--install-force` overwrites all conflicts without prompting. - * The same lifecycle is available in the REPL: `.install `, `.list bundles`, and `.uninstall `. - * Bundle authors can optionally add a `coyote-bundle.yaml` manifest to the repository root to give the bundle a stable identity. The manifest is identity-only — the bundle's contents are always discovered by scanning the repository, never declared. Without a manifest, the bundle is named after the repository. - ```yaml - name: oh-my-coyote # required; the bundle's identity - version: "1.4.0" # optional; informational - description: Opinionated roles, macros, and skills for Coyote - homepage: https://github.com/example/oh-my-coyote # optional - ``` - * `coyote --install-builtins ` reinstalls Coyote's bundled assets for a category, overwriting any local changes (built-in assets are not bundles). * [REPL](https://github.com/Dark-Alex-17/coyote/wiki/REPL): Interactive Read-Eval-Print Loop for conversational interactions with LLMs and Coyote. * [Custom REPL Prompt](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt): Customize the REPL prompt to provide useful contextual information. * [Vault](https://github.com/Dark-Alex-17/coyote/wiki/Vault): Securely store and manage sensitive information such as API keys and credentials. From 4324d551d6be3442359b3542d18df3eca422c1de Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 19:39:06 -0600 Subject: [PATCH 15/20] fix: reserve category names, confirm fork-name collisions, report secrets on uninstall Bundle names that collide with an asset category (agents, roles, skills, macros, functions, mcp_config) are now owner-qualified at install time, whether derived from the repo or declared by a manifest, so no bundle can shadow a category by name. A manifest name that collides with a bundle from a different source now prompts for confirmation interactively (a fork or typo-squat is the likely cause); declining aborts before anything is written, and non-interactive runs keep the deterministic owner-qualification. Uninstall summaries now list the vault secrets the bundle's MCP servers reference, noting they are installed by the bundle but not removed. Also removes the dead ResolvedBundleName.migrated_from field. --- src/config/bundles.rs | 83 ++++++++++++++++++++++++++---------- src/config/install_remote.rs | 70 +++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 24 deletions(-) diff --git a/src/config/bundles.rs b/src/config/bundles.rs index 7405761..aefd4f2 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -2,10 +2,13 @@ use super::install_remote::{ canonical_source_url, owner_qualifier, repo_name_slug, validate_bundle_name, }; use super::paths; +use crate::config::AssetCategory; use crate::function::write_file_atomic; +use crate::utils::IS_STDOUT_TERMINAL; use anyhow::{Context, Result, bail}; use chrono::{SecondsFormat, Utc}; +use inquire::Confirm; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; @@ -92,9 +95,6 @@ pub(crate) struct ResolvedBundleName { pub(crate) name: String, /// The unqualified name this install asked for, when it had to be owner-qualified. pub(crate) qualified_from: Option, - /// The record key this source was previously tracked under, when it changed. - #[allow(dead_code)] - pub(crate) migrated_from: Option, /// Source URL of the different-source bundle that already holds the unqualified name. pub(crate) same_name_other_source: Option, } @@ -216,15 +216,21 @@ impl BundleStore { let mut resolved = ResolvedBundleName { name: base.clone(), qualified_from: None, - migrated_from: None, same_name_other_source: None, }; - if let Some(other_source) = self.source_of_other_bundle(&base, &canonical) { + let reserved = AssetCategory::parse(&base).is_some(); + let collision = self.source_of_other_bundle(&base, &canonical); + if reserved || collision.is_some() { + let reason = match &collision { + Some(other_source) => { + format!("is already used by an install from '{other_source}'") + } + None => "is reserved for an asset category".to_string(), + }; if base.contains('/') { bail!( - "bundle name '{base}' is already used by an install from \ - '{other_source}' and cannot be qualified further; \ + "bundle name '{base}' {reason} and cannot be qualified further; \ uninstall it or pick a different manifest name" ); } @@ -233,21 +239,37 @@ impl BundleStore { .filter(|owner| !owner.is_empty()); let Some(owner) = owner else { bail!( - "bundle name '{base}' is already used by an install from \ - '{other_source}', and no owner qualifier can be derived from '{url}'" + "bundle name '{base}' {reason}, and no owner qualifier \ + can be derived from '{url}'" ); }; let qualified = format!("{owner}/{base}"); if let Some(source) = self.source_of_other_bundle(&qualified, &canonical) { bail!( - "bundle names '{base}' and '{qualified}' are both used by installs \ - from other sources ('{other_source}', '{source}'); \ + "bundle name '{base}' {reason}, and '{qualified}' is already \ + used by an install from '{source}'; \ uninstall one or pick a different manifest name" ); } + if let Some(other_source) = &collision + && manifest_name.is_some() + && *IS_STDOUT_TERMINAL + { + let proceed = Confirm::new(&format!( + "Bundle name '{base}' is already used by an install from \ + '{other_source}' (a fork or typo-squat?). Track this install \ + as '{qualified}'?" + )) + .with_default(false) + .prompt() + .with_context(|| "failed to read bundle name confirmation")?; + if !proceed { + bail!("install aborted: bundle name '{base}' {reason}"); + } + } resolved.name = qualified; resolved.qualified_from = Some(base); - resolved.same_name_other_source = Some(other_source); + resolved.same_name_other_source = collision; } let already_recorded = existing_key.as_deref() == Some(resolved.name.as_str()); @@ -263,18 +285,16 @@ impl BundleStore { "Bundle '{old_key}' from {url} is now tracked as '{}'.", resolved.name ); - resolved.migrated_from = Some(old_key); self.save()?; } if let (Some(from), false) = (&resolved.qualified_from, already_recorded) { - let other = resolved - .same_name_other_source - .as_deref() - .unwrap_or_default(); + let detail = match resolved.same_name_other_source.as_deref() { + Some(other) => format!("is already used by an install from '{other}'"), + None => "is reserved for an asset category".to_string(), + }; println!( - "Bundle name '{from}' is already used by an install from '{other}'; \ - tracking this install as '{}'.", + "Bundle name '{from}' {detail}; tracking this install as '{}'.", resolved.name ); } @@ -877,7 +897,6 @@ mod tests { .unwrap(); assert_eq!(resolved.name, "omc"); - assert_eq!(resolved.migrated_from, None); assert_eq!(resolved.qualified_from, None); } @@ -897,7 +916,6 @@ mod tests { .unwrap(); assert_eq!(resolved.name, "oh-my-coyote"); - assert_eq!(resolved.migrated_from.as_deref(), Some("omc")); let reloaded = dir.store(); assert!(reloaded.get("omc").is_none()); assert_eq!(reloaded.get("oh-my-coyote").unwrap().files.len(), 1); @@ -955,7 +973,7 @@ mod tests { .unwrap(); assert_eq!(resolved.name, "b/repo"); - assert_eq!(resolved.migrated_from, None); + assert_eq!(resolved.qualified_from.as_deref(), Some("repo")); } #[test] @@ -970,6 +988,27 @@ mod tests { assert_eq!(resolved.name, "next-js"); } + #[test] + fn resolve_reserves_asset_category_names() { + let dir = TempStoreDir::new("bundles-reserved"); + let mut store = dir.store(); + + let resolved = store + .resolve_bundle_name("https://github.com/x/agents", None) + .unwrap(); + + assert_eq!(resolved.name, "x/agents"); + assert_eq!(resolved.qualified_from.as_deref(), Some("agents")); + assert!(resolved.same_name_other_source.is_none()); + + let resolved = store + .resolve_bundle_name("https://github.com/y/repo", Some("mcp_config")) + .unwrap(); + + assert_eq!(resolved.name, "y/mcp_config"); + assert_eq!(resolved.qualified_from.as_deref(), Some("mcp_config")); + } + #[test] fn resolve_rejects_invalid_manifest_names() { let dir = TempStoreDir::new("bundles-invalid-name"); diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index fd6a912..65e9b10 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -8,14 +8,14 @@ use crate::function::Language; use crate::mcp::{McpServer, McpServersConfig}; use crate::utils; use crate::utils::IS_STDOUT_TERMINAL; -use crate::vault::{Vault, create_vault_password_file, interpolate_secrets}; +use crate::vault::{SECRET_RE, Vault, create_vault_password_file, interpolate_secrets}; use anyhow::{Context, Result, anyhow, bail}; use clap::ValueEnum; use indexmap::IndexMap; use indoc::formatdoc; use inquire::{Confirm, Select}; use serde::Deserialize; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::fs; use std::path::{Component, Path, PathBuf}; @@ -472,6 +472,7 @@ struct UninstallFileSummary { struct UninstallMcpSummary { removed: Vec, kept: Vec, + secrets: Vec, } /// Kept and failed items stay in the record, so a re-run offers them again. @@ -569,6 +570,13 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { if !mcp.kept.is_empty() { println!(" = kept servers: {}", mcp.kept.join(", ")); } + if !mcp.secrets.is_empty() { + println!( + " ~ vault secrets referenced by this bundle's servers \ + (installed by this bundle, not removed): {}", + mcp.secrets.join(", ") + ); + } Ok(()) } @@ -787,6 +795,24 @@ fn uninstall_mcp_entries( None }; + let mut secret_names: BTreeSet = BTreeSet::new(); + for server in servers { + if let Some(entry) = config + .as_ref() + .and_then(|cfg| cfg.mcp_servers.get(server.effective_key())) + && let Ok(serialized) = serde_json::to_string(entry) + { + for capture in SECRET_RE.captures_iter(&serialized) { + if let Ok(capture) = capture + && let Some(name) = capture.get(1) + { + secret_names.insert(name.as_str().to_string()); + } + } + } + } + summary.secrets = secret_names.into_iter().collect(); + let mut changed = false; let mut released = Vec::new(); let mut sticky: Option = None; @@ -4265,6 +4291,46 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn uninstall_mcp_reports_referenced_secrets_without_removing_them() { + let dir = fresh_temp_dir("uninst-mcp-secrets-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let mcp = dir.join("mcp.json"); + write_mcp( + &mcp, + r#"{"mcpServers": { + "srv": { + "type": "stdio", + "command": "echo", + "env": {"TOKEN": "{{OMC_TOKEN}}", "ORG": "{{OMC_ORG}}"} + } + }}"#, + ); + let parsed: McpServersConfig = + serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap(); + let hash = hash_bytes( + serde_json::to_string(parsed.mcp_servers.get("srv").unwrap()) + .unwrap() + .as_bytes(), + ); + store + .record_mcp_servers( + "omc", + vec![mcp_server_record("srv", McpAction::Added, Some(hash))], + ) + .unwrap(); + let servers = store.get("omc").unwrap().mcp_servers.clone(); + + let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap(); + + assert_eq!(summary.removed, vec!["srv"]); + assert_eq!(summary.secrets, vec!["OMC_ORG", "OMC_TOKEN"]); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn uninstall_mcp_keeps_modified_entries_and_their_records() { let dir = fresh_temp_dir("uninst-mcp-modified-"); From 80b082423c6eff98fc2dd231c448c088945c72d6 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 20:07:57 -0600 Subject: [PATCH 16/20] fix: address code review findings on the bundle lifecycle The user-origin marker on replaced mcp.json entries is now sticky: re-records and cross-bundle transfers only upgrade replaced to transferred when the prior record proves bundle origin, so updating a bundle can no longer make uninstall delete a key the user had before the bundle replaced it. Canonical source URLs lowercase only the host, since self-hosted forges treat repository paths as case-sensitive and collapsing distinct repos misdirects updates and uninstalls. git clone invocations pass '--' before the URL so a crafted source cannot be parsed as a git flag. Lifecycle flags (--install, --install-builtins, --update-bundle, --uninstall) and their companions now conflict explicitly instead of first-match dispatch silently dropping actions. --install-from returns as a hidden tombstone that errors with the replacement instead of feeding the flag to the LLM as prompt text. --list-bundles dispatches before config load so a pure read no longer boots MCP servers. write_file_atomic fsyncs before the rename so a crash cannot persist a truncated store. REPL: .uninstall accepts --yes, .install rejects trailing tokens after a category, and .install remote gets a migration hint. Plus polish: host validation rejects '#' and '?', renamed_to no longer serializes null, derived names get a debug assert against the validator, completions share DEFAULT_GIT_HOST, README mentions skills. --- README.md | 2 +- src/cli/mod.rs | 73 +++++++++++++++++++++++++---- src/config/bundles.rs | 86 ++++++++++++++++++++++++++++++++--- src/config/install_remote.rs | 36 ++++++++++----- src/config/request_context.rs | 4 +- src/function/mod.rs | 11 ++++- src/main.rs | 12 +++-- src/repl/mod.rs | 59 ++++++++++++++++++++---- 8 files changed, 239 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 02f2901..330b01e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g * [AIChat Migration Guide](https://github.com/Dark-Alex-17/coyote/wiki/AIChat-Migration): Coming from AIChat? Follow the migration guide to get started. * [Installation](#install): Install Coyote * [Getting Started](#getting-started): Get started with Coyote by doing first-run setup steps. -* [Sharing Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Sharing-Configurations): Install bundles of agents, roles, macros, tools, and MCP servers from any git repo, and share your own. Bundles are Coyote's equivalent of plugins in other CLI agents. +* [Sharing Configurations](https://github.com/Dark-Alex-17/coyote/wiki/Sharing-Configurations): Install bundles of agents, roles, skills, macros, tools, and MCP servers from any git repo, and share your own. Bundles are Coyote's equivalent of plugins in other CLI agents. * [REPL](https://github.com/Dark-Alex-17/coyote/wiki/REPL): Interactive Read-Eval-Print Loop for conversational interactions with LLMs and Coyote. * [Custom REPL Prompt](https://github.com/Dark-Alex-17/coyote/wiki/REPL-Prompt): Customize the REPL prompt to provide useful contextual information. * [Vault](https://github.com/Dark-Alex-17/coyote/wiki/Vault): Securely store and manage sensitive information such as API keys and credentials. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 91d66e2..6cc9c67 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -180,27 +180,37 @@ pub struct Cli { #[arg(long, help_heading = "List & Discovery")] pub list_bundles: bool, - /// Install assets from a remote git repository (URL may be suffixed with #), or update an already-installed bundle by name + /// Install assets from a remote git repository (a URL or / shorthand, optionally suffixed with #), or update an already-installed bundle by name #[arg( long, - value_name = "GIT_URL|NAME", + value_name = "GIT_URL|OWNER/REPO|NAME", + conflicts_with_all = ["install_builtins", "update_bundle", "uninstall"], help_heading = "Installation & Updates" )] pub install: Option, - /// Git host used to expand / shorthand values passed to --install + /// Git host used to expand / shorthand values passed to --install (also forces the value to be treated as a source when it matches an installed bundle name) #[arg( long, value_name = "HOST", requires = "install", + conflicts_with_all = ["install_builtins", "update_bundle", "uninstall"], help_heading = "Installation & Updates" )] pub git_host: Option, - /// Reinstall bundled assets, overwriting any local changes + /// Removed; use --install instead + #[arg( + long, + hide = true, + value_name = "GIT_URL", + help_heading = "Installation & Updates" + )] + pub install_from: Option, + /// Reinstall bundled assets for a category (asks before overwriting your local changes) #[arg( long, value_name = "CATEGORY", value_enum, - conflicts_with_all = ["install"], + conflicts_with_all = ["update_bundle", "uninstall"], help_heading = "Installation & Updates" )] pub install_builtins: Option, @@ -210,20 +220,36 @@ pub struct Cli { value_name = "CATEGORY", value_enum, requires = "install", + conflicts_with_all = ["install_builtins", "update_bundle", "uninstall"], help_heading = "Installation & Updates" )] pub filter: Option, /// Overwrite all conflicts without prompting (used with --install) - #[arg(long, requires = "install", help_heading = "Installation & Updates")] + #[arg( + long, + requires = "install", + conflicts_with_all = ["install_builtins", "update_bundle", "uninstall"], + help_heading = "Installation & Updates" + )] pub install_force: bool, /// Update an installed bundle from its recorded source (NAME may be suffixed with # to move a pin) - #[arg(long, value_name = "NAME", help_heading = "Installation & Updates")] + #[arg( + long, + value_name = "NAME", + conflicts_with_all = ["uninstall"], + help_heading = "Installation & Updates" + )] pub update_bundle: Option, /// Uninstall a bundle: delete its owned files and remove its mcp.json entries #[arg(long, value_name = "NAME", help_heading = "Installation & Updates")] pub uninstall: Option, /// Skip uninstall confirmation prompts (locally modified items are still kept) - #[arg(long, requires = "uninstall", help_heading = "Installation & Updates")] + #[arg( + long, + requires = "uninstall", + conflicts_with_all = ["install", "install_builtins", "update_bundle"], + help_heading = "Installation & Updates" + )] pub yes: bool, /// Sync models updates #[arg(long, help_heading = "Installation & Updates")] @@ -572,10 +598,11 @@ mod tests { } #[test] - fn parse_install_from_is_no_longer_a_flag() { + fn parse_install_from_is_a_tombstone() { let cli = parse(&["--install-from", "https://github.com/x/y"]); assert!(cli.install.is_none()); - assert_eq!(cli.text, vec!["--install-from", "https://github.com/x/y"]); + assert_eq!(cli.install_from.as_deref(), Some("https://github.com/x/y")); + assert!(cli.text.is_empty()); } #[test] @@ -586,6 +613,28 @@ mod tests { ); } + #[test] + fn parse_lifecycle_flags_are_mutually_exclusive() { + assert!(Cli::try_parse_from(["coyote", "--install", "x", "--uninstall", "y"]).is_err()); + assert!( + Cli::try_parse_from(["coyote", "--update-bundle", "x", "--uninstall", "y"]).is_err() + ); + assert!( + Cli::try_parse_from(["coyote", "--install-builtins", "agents", "--uninstall", "y"]) + .is_err() + ); + assert!(Cli::try_parse_from(["coyote", "--install", "x", "--update-bundle", "y"]).is_err()); + } + + #[test] + fn parse_companion_flags_conflict_with_other_lifecycle_actions() { + assert!( + Cli::try_parse_from(["coyote", "--update-bundle", "x", "--filter", "agents"]).is_err() + ); + assert!(Cli::try_parse_from(["coyote", "--uninstall", "x", "--install-force"]).is_err()); + assert!(Cli::try_parse_from(["coyote", "--install", "x", "--yes"]).is_err()); + } + #[test] fn parse_filter_requires_install() { assert!(Cli::try_parse_from(["coyote", "--filter", "agents"]).is_err()); @@ -604,6 +653,10 @@ mod tests { #[test] fn parse_git_host_requires_install() { assert!(Cli::try_parse_from(["coyote", "--git-host", "git.x.com"]).is_err()); + assert!( + Cli::try_parse_from(["coyote", "--git-host", "gitlab.com", "--update-bundle", "x"]) + .is_err() + ); assert_eq!( parse(&["--install", "someuser/omc", "--git-host", "git.x.com"]) .git_host diff --git a/src/config/bundles.rs b/src/config/bundles.rs index aefd4f2..da51ebd 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -45,6 +45,7 @@ pub(crate) struct FileRecord { pub(crate) struct McpServerRecord { pub(crate) name: String, pub(crate) action: McpAction, + #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) renamed_to: Option, /// Hash of the mcp.json entry as written; a later mismatch means the user /// modified it. Absent on records made before entry hashing existed. @@ -299,6 +300,10 @@ impl BundleStore { ); } + debug_assert!( + validate_bundle_name(&resolved.name).is_ok(), + "derived bundle names must satisfy validate_bundle_name" + ); Ok(resolved) } @@ -391,8 +396,10 @@ impl BundleStore { } /// An entry whose key any bundle already owns transfers to `bundle`, and - /// its `replaced` action upgrades to `transferred`; plain `replaced` - /// marks a pre-existing user entry that uninstall must never delete. + /// its `replaced` action upgrades to `transferred` only when the prior + /// record proves bundle origin. A prior `replaced` record marks a + /// pre-existing user entry that uninstall must never delete, and that + /// marker survives re-records and cross-bundle transfers. pub(crate) fn record_mcp_servers( &mut self, bundle: &str, @@ -402,14 +409,21 @@ impl BundleStore { for mut entry in entries { let key = entry.effective_key().to_string(); let mut previously_owned = false; + let mut prior_user_origin = false; for record in self.bundles.values_mut() { let before = record.mcp_servers.len(); - record - .mcp_servers - .retain(|owned| owned.effective_key() != key); + record.mcp_servers.retain(|owned| { + if owned.effective_key() != key { + return true; + } + if owned.action == McpAction::Replaced { + prior_user_origin = true; + } + false + }); previously_owned |= record.mcp_servers.len() != before; } - if previously_owned && entry.action == McpAction::Replaced { + if previously_owned && !prior_user_origin && entry.action == McpAction::Replaced { entry.action = McpAction::Transferred; } self.bundles @@ -852,6 +866,64 @@ mod tests { assert_eq!(servers[0].sha256.as_deref(), Some("deadbeef")); } + #[test] + fn mcp_rerecord_of_own_replaced_entry_stays_replaced() { + let dir = TempStoreDir::new("bundles-mcp-sticky-self"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + store + .record_mcp_servers( + "omc", + vec![mcp_record("user-srv", McpAction::Replaced, None)], + ) + .unwrap(); + + store + .record_mcp_servers( + "omc", + vec![mcp_record("user-srv", McpAction::Replaced, None)], + ) + .unwrap(); + + assert_eq!( + store.get("omc").unwrap().mcp_servers[0].action, + McpAction::Replaced + ); + } + + #[test] + fn mcp_transfer_of_replaced_entry_keeps_user_origin_marker() { + let dir = TempStoreDir::new("bundles-mcp-sticky-transfer"); + let mut store = dir.store(); + store + .upsert_bundle("alpha", metadata("https://github.com/a/alpha", "abc123")) + .unwrap(); + store + .upsert_bundle("beta", metadata("https://github.com/b/beta", "def456")) + .unwrap(); + store + .record_mcp_servers( + "alpha", + vec![mcp_record("user-srv", McpAction::Replaced, None)], + ) + .unwrap(); + + store + .record_mcp_servers( + "beta", + vec![mcp_record("user-srv", McpAction::Replaced, None)], + ) + .unwrap(); + + assert!(store.get("alpha").unwrap().mcp_servers.is_empty()); + assert_eq!( + store.get("beta").unwrap().mcp_servers[0].action, + McpAction::Replaced + ); + } + #[test] fn mcp_transfer_matches_renamed_entries_by_effective_key() { let dir = TempStoreDir::new("bundles-mcp-renamed"); @@ -893,7 +965,7 @@ mod tests { .unwrap(); let resolved = store - .resolve_bundle_name("git@github.com:X/omc.git", None) + .resolve_bundle_name("git@github.com:x/omc.git", None) .unwrap(); assert_eq!(resolved.name, "omc"); diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 65e9b10..8529fa6 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -151,7 +151,7 @@ fn looks_like_remote_source(value: &str) -> bool { } } -const DEFAULT_GIT_HOST: &str = "github.com"; +pub(crate) const DEFAULT_GIT_HOST: &str = "github.com"; fn is_repo_shorthand(value: &str) -> bool { let path = strip_ref_suffix(value); @@ -174,7 +174,7 @@ fn expand_repo_shorthand(value: &str, git_host: Option<&str>) -> Result .or_else(|| raw.strip_prefix("http://")) .unwrap_or(raw) .trim_matches('/'); - if host.is_empty() || host.contains('/') || host.chars().any(char::is_whitespace) { + if host.is_empty() || host.contains(['/', '#', '?']) || host.chars().any(char::is_whitespace) { bail!("invalid --git-host '{raw}': expected a bare host like git.somedomain.com"); } Ok(format!("https://{host}/{value}")) @@ -951,12 +951,18 @@ fn clone_to_temp(url: &str, reference: Option<&str>) -> Result { "1".into(), "--branch".into(), r.into(), + "--".into(), url.into(), dest_arg, ])?; } Some(r) => { - run_git(vec!["clone".into(), url.into(), dest_arg.clone()])?; + run_git(vec![ + "clone".into(), + "--".into(), + url.into(), + dest_arg.clone(), + ])?; run_git(vec!["-C".into(), dest_arg, "checkout".into(), r.into()])?; } None => { @@ -964,6 +970,7 @@ fn clone_to_temp(url: &str, reference: Option<&str>) -> Result { "clone".into(), "--depth".into(), "1".into(), + "--".into(), url.into(), dest_arg, ])?; @@ -1223,14 +1230,17 @@ fn sanitize_host(host: &str) -> String { .to_string() } +/// The host compares case-insensitively but the path keeps its case: many +/// self-hosted forges treat repository paths as case-sensitive, and collapsing +/// distinct repos into one record misdirects updates and uninstalls. pub(crate) fn canonical_source_url(url: &str) -> String { let (host, path) = split_host_and_path(url); - let mut path = path.to_ascii_lowercase(); - if let Some(stripped) = path.strip_suffix(".git") - && !stripped.is_empty() - && !stripped.ends_with('/') - { - path = stripped.to_string(); + let mut path = path; + if path.to_ascii_lowercase().ends_with(".git") { + let stripped = &path[..path.len() - 4]; + if !stripped.is_empty() && !stripped.ends_with('/') { + path.truncate(path.len() - 4); + } } let host = host.to_ascii_lowercase(); if host.is_empty() { @@ -2837,10 +2847,14 @@ mod tests { } #[test] - fn canonical_source_url_lowercases_host_and_path() { + fn canonical_source_url_lowercases_host_but_not_path() { assert_eq!( canonical_source_url("https://GitHub.COM/X/R.git"), - "github.com/x/r" + "github.com/X/R" + ); + assert_ne!( + canonical_source_url("https://gitlab.example.com/team/Repo"), + canonical_source_url("https://gitlab.example.com/team/repo") ); } diff --git a/src/config/request_context.rs b/src/config/request_context.rs index e255ec9..0b75021 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -3501,7 +3501,9 @@ impl RequestContext { InstallFilter::NAMES.iter().map(|s| s.to_string()).collect(), ); } else if prev == "--git-host" { - values = super::map_completion_values(vec!["github.com".to_string()]); + values = super::map_completion_values(vec![ + super::install_remote::DEFAULT_GIT_HOST.to_string(), + ]); } else { let has_filter = args.iter().enumerate().any(|(i, a)| { a.starts_with("--filter=") || (*a == "--filter" && i < args.len() - 1) diff --git a/src/function/mod.rs b/src/function/mod.rs index fc8d7cb..7c0b81e 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -165,7 +165,16 @@ pub(crate) fn write_file_atomic( std::process::id(), TMP_COUNTER.fetch_add(1, Ordering::Relaxed) )); - fs::write(&tmp, content)?; + let write_synced = || -> std::io::Result<()> { + use std::io::Write; + let mut file = fs::File::create(&tmp)?; + file.write_all(content.as_bytes())?; + file.sync_all() + }; + if let Err(err) = write_synced() { + let _ = fs::remove_file(&tmp); + return Err(err.into()); + } #[cfg(unix)] if let Some(mode) = mode { diff --git a/src/main.rs b/src/main.rs index eb2b06d..4befadc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,7 +107,6 @@ 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() @@ -128,6 +127,10 @@ async fn main() -> Result<()> { return sandbox::launch(name.clone(), cli.fresh); } + if cli.install_from.is_some() { + bail!("--install-from was removed; use --install instead"); + } + install_builtins()?; if let Some(category) = cli.install_builtins { @@ -151,6 +154,10 @@ async fn main() -> Result<()> { return config::uninstall_bundle(name, cli.yes); } + if cli.list_bundles { + return config::list_installed_bundles(); + } + if let Some(client_arg) = &cli.authenticate { let cfg = Config::load_with_interpolation(true).await?; let app_config = AppConfig::from_config(cfg)?; @@ -323,9 +330,6 @@ 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 4e48529..32f9ef7 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -875,6 +875,9 @@ pub async fn run_repl_command( ReplInstallDispatch::Unified(value) => { config::install_or_update_from_repl_args(value)?; } + ReplInstallDispatch::RemovedRemote => println!( + "'.install remote ' was removed; use '.install ' directly." + ), ReplInstallDispatch::Usage => println!( "Usage: .install <{}> | .install \ [--git-host ] [--filter ] [--force]", @@ -1197,12 +1200,20 @@ pub async fn run_repl_command( println!("Usage: .delete ") } }, - ".uninstall" => match args { - Some(args) => { - config::uninstall_bundle(args.trim(), false)?; + ".uninstall" => { + let mut assume_yes = false; + let mut names = Vec::new(); + for token in args.unwrap_or("").split_whitespace() { + match token { + "--yes" | "-y" => assume_yes = true, + other => names.push(other), + } } - _ => println!("Usage: .uninstall "), - }, + match names.as_slice() { + [name] => config::uninstall_bundle(name, assume_yes)?, + _ => println!("Usage: .uninstall [--yes]"), + } + } ".list" => match args { Some(args) => { ctx.list_assets(args.trim())?; @@ -1575,6 +1586,7 @@ fn unknown_command() -> Result<()> { enum ReplInstallDispatch<'a> { Builtins(AssetCategory), Unified(&'a str), + RemovedRemote, Usage, } @@ -1582,10 +1594,15 @@ fn parse_repl_install(args: Option<&str>) -> ReplInstallDispatch<'_> { let trimmed = args.map(str::trim).unwrap_or(""); let mut parts = trimmed.splitn(2, char::is_whitespace); match parts.next() { - Some(name) if !name.is_empty() => match AssetCategory::parse(name) { - Some(category) => ReplInstallDispatch::Builtins(category), - None => ReplInstallDispatch::Unified(trimmed), - }, + Some(name) if !name.is_empty() => { + let rest = parts.next().map(str::trim).unwrap_or(""); + match AssetCategory::parse(name) { + Some(category) if rest.is_empty() => ReplInstallDispatch::Builtins(category), + Some(_) => ReplInstallDispatch::Usage, + None if name == "remote" && !rest.is_empty() => ReplInstallDispatch::RemovedRemote, + None => ReplInstallDispatch::Unified(trimmed), + } + } _ => ReplInstallDispatch::Usage, } } @@ -1841,6 +1858,30 @@ mod tests { assert_eq!(parse_repl_install(Some(" ")), ReplInstallDispatch::Usage); } + #[test] + fn parse_repl_install_rejects_extra_tokens_after_a_category() { + assert_eq!( + parse_repl_install(Some("agents --force")), + ReplInstallDispatch::Usage + ); + assert_eq!( + parse_repl_install(Some("agents extra")), + ReplInstallDispatch::Usage + ); + } + + #[test] + fn parse_repl_install_hints_on_removed_remote_form() { + assert_eq!( + parse_repl_install(Some("remote https://github.com/x/y")), + ReplInstallDispatch::RemovedRemote + ); + assert_eq!( + parse_repl_install(Some("remote")), + ReplInstallDispatch::Unified("remote") + ); + } + #[test] fn builtin_command_names_are_sorted_deduped_first_words_without_dots() { let names = builtin_command_names(); From 8f02bf1c33e05fe065d333821a737f7941e67f98 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 20:23:38 -0600 Subject: [PATCH 17/20] refactor!: drop the --install-from tombstone entirely The flag no longer exists in any form; --install is the only spelling. --- src/cli/mod.rs | 19 +------------------ src/main.rs | 4 ---- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6cc9c67..f36e019 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -197,14 +197,6 @@ pub struct Cli { help_heading = "Installation & Updates" )] pub git_host: Option, - /// Removed; use --install instead - #[arg( - long, - hide = true, - value_name = "GIT_URL", - help_heading = "Installation & Updates" - )] - pub install_from: Option, /// Reinstall bundled assets for a category (asks before overwriting your local changes) #[arg( long, @@ -597,14 +589,6 @@ mod tests { ); } - #[test] - fn parse_install_from_is_a_tombstone() { - let cli = parse(&["--install-from", "https://github.com/x/y"]); - assert!(cli.install.is_none()); - assert_eq!(cli.install_from.as_deref(), Some("https://github.com/x/y")); - assert!(cli.text.is_empty()); - } - #[test] fn parse_install_builtins_conflicts_with_install() { assert!( @@ -666,10 +650,9 @@ mod tests { } #[test] - fn help_omits_install_from_and_shows_install_builtins() { + fn help_shows_install_builtins() { use clap::CommandFactory; let help = Cli::command().render_long_help().to_string(); - assert!(!help.contains("--install-from"), "help: {help}"); assert!(help.contains("--install-builtins")); } diff --git a/src/main.rs b/src/main.rs index 4befadc..eebdaf0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,10 +127,6 @@ async fn main() -> Result<()> { return sandbox::launch(name.clone(), cli.fresh); } - if cli.install_from.is_some() { - bail!("--install-from was removed; use --install instead"); - } - install_builtins()?; if let Some(category) = cli.install_builtins { From fdfe4ba0237739d0d592a4c8e0d5019a59043657 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 20:35:12 -0600 Subject: [PATCH 18/20] refactor!: drop the .install remote migration hint 'remote' is no longer special-cased anywhere; the token falls through to the unified .install dispatch like any other value. --- src/repl/mod.rs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 32f9ef7..37cf9b3 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -875,9 +875,6 @@ pub async fn run_repl_command( ReplInstallDispatch::Unified(value) => { config::install_or_update_from_repl_args(value)?; } - ReplInstallDispatch::RemovedRemote => println!( - "'.install remote ' was removed; use '.install ' directly." - ), ReplInstallDispatch::Usage => println!( "Usage: .install <{}> | .install \ [--git-host ] [--filter ] [--force]", @@ -1586,7 +1583,6 @@ fn unknown_command() -> Result<()> { enum ReplInstallDispatch<'a> { Builtins(AssetCategory), Unified(&'a str), - RemovedRemote, Usage, } @@ -1599,7 +1595,6 @@ fn parse_repl_install(args: Option<&str>) -> ReplInstallDispatch<'_> { match AssetCategory::parse(name) { Some(category) if rest.is_empty() => ReplInstallDispatch::Builtins(category), Some(_) => ReplInstallDispatch::Usage, - None if name == "remote" && !rest.is_empty() => ReplInstallDispatch::RemovedRemote, None => ReplInstallDispatch::Unified(trimmed), } } @@ -1870,18 +1865,6 @@ mod tests { ); } - #[test] - fn parse_repl_install_hints_on_removed_remote_form() { - assert_eq!( - parse_repl_install(Some("remote https://github.com/x/y")), - ReplInstallDispatch::RemovedRemote - ); - assert_eq!( - parse_repl_install(Some("remote")), - ReplInstallDispatch::Unified("remote") - ); - } - #[test] fn builtin_command_names_are_sorted_deduped_first_words_without_dots() { let names = builtin_command_names(); From 6b5535956db4187180acc7d989a3048bd40e1bc5 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 21 Aug 2026 20:59:12 -0600 Subject: [PATCH 19/20] fix: harden the bundle lifecycle per code review The path-escape guard that uninstall applies to recorded paths now also covers update's obsolete-file deletion through a shared check, so a tampered store cannot turn either delete site into an arbitrary file removal. Updates gain a working non-interactive path: --yes now applies to --update-bundle (locally modified files, obsolete files, and modified mcp entries are all kept; everything else refreshes), owned mcp entries whose recorded hash still matches the local entry take the remote side without prompting, and the non-TTY conflict bails name the flag that actually works per surface. An update records its new commit and version only after files and mcp entries land, so an aborted update cannot claim content it never wrote. The store gains a version field and rejects stores from newer builds, the corrupt-store error no longer advises the removal that would forfeit ownership tracking, and duplicate records tracking one source abort a rename instead of overwriting a record. Reinstalling from a source URL reclassifies owned unmodified files as silent refreshes just like updates. git runs with GIT_TERMINAL_PROMPT=0 and a null stdin so private or mistyped URLs fail instead of hanging. File comparison fills buffers fully before comparing, deleting an obsolete file prunes emptied directories, mcp.json backfill uses the fsynced atomic writer, --list-bundles no longer triggers builtin backfill, bundle-name completion logs store errors instead of swallowing them and offers --yes, and REPL .uninstall rejects unknown flags. --- src/cli/mod.rs | 21 ++- src/config/bundles.rs | 43 ++++- src/config/install_remote.rs | 325 +++++++++++++++++++++++++++++----- src/config/request_context.rs | 44 ++--- src/function/mod.rs | 5 +- src/main.rs | 10 +- src/repl/mod.rs | 34 ++-- 7 files changed, 384 insertions(+), 98 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f36e019..1114217 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -216,7 +216,7 @@ pub struct Cli { help_heading = "Installation & Updates" )] pub filter: Option, - /// Overwrite all conflicts without prompting (used with --install) + /// Overwrite all conflicts without prompting (remote installs only) #[arg( long, requires = "install", @@ -228,18 +228,24 @@ pub struct Cli { #[arg( long, value_name = "NAME", + group = "yes_scope", conflicts_with_all = ["uninstall"], help_heading = "Installation & Updates" )] pub update_bundle: Option, /// Uninstall a bundle: delete its owned files and remove its mcp.json entries - #[arg(long, value_name = "NAME", help_heading = "Installation & Updates")] - pub uninstall: Option, - /// Skip uninstall confirmation prompts (locally modified items are still kept) #[arg( long, - requires = "uninstall", - conflicts_with_all = ["install", "install_builtins", "update_bundle"], + value_name = "NAME", + group = "yes_scope", + help_heading = "Installation & Updates" + )] + pub uninstall: Option, + /// Proceed without prompts for --uninstall and --update-bundle (locally modified items are always kept) + #[arg( + long, + requires = "yes_scope", + conflicts_with_all = ["install", "install_builtins"], help_heading = "Installation & Updates" )] pub yes: bool, @@ -562,8 +568,9 @@ mod tests { } #[test] - fn parse_yes_flag_requires_uninstall() { + fn parse_yes_flag_requires_uninstall_or_update_bundle() { assert!(parse(&["--uninstall", "foo", "--yes"]).yes); + assert!(parse(&["--update-bundle", "foo", "--yes"]).yes); assert!(Cli::try_parse_from(["coyote", "--yes"]).is_err()); } diff --git a/src/config/bundles.rs b/src/config/bundles.rs index da51ebd..8b1b29c 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -100,14 +100,19 @@ pub(crate) struct ResolvedBundleName { pub(crate) same_name_other_source: Option, } +const STORE_VERSION: u32 = 1; + #[derive(Debug, Default, Deserialize)] struct StoreContents { + #[serde(default)] + version: u32, #[serde(default)] bundles: BTreeMap, } #[derive(Serialize)] struct StoreContentsRef<'a> { + version: u32, bundles: &'a BTreeMap, } @@ -137,10 +142,19 @@ impl BundleStore { let contents: StoreContents = serde_yaml::from_str(&content).with_context(|| { format!( "failed to parse {}; refusing to treat it as empty. \ - Fix or remove the file to continue", + Restore or repair the file to continue; removing it forfeits \ + uninstall tracking for every installed bundle", path.display() ) })?; + if contents.version > STORE_VERSION { + bail!( + "{} has store version {}, but this coyote build supports up to \ + {STORE_VERSION}; update coyote or restore a matching store", + path.display(), + contents.version + ); + } Ok(Self { path, bundles: contents.bundles, @@ -149,6 +163,7 @@ impl BundleStore { pub(crate) fn save(&self) -> Result<()> { let content = serde_yaml::to_string(&StoreContentsRef { + version: STORE_VERSION, bundles: &self.bundles, }) .context("failed to serialize the installed-bundles store")?; @@ -277,6 +292,13 @@ impl BundleStore { if let Some(old_key) = existing_key && !already_recorded { + if self.bundles.contains_key(&resolved.name) { + bail!( + "records '{old_key}' and '{}' both track source '{url}'; \ + uninstall one or repair installed-bundles.yaml before continuing", + resolved.name + ); + } let record = self .bundles .remove(&old_key) @@ -348,6 +370,25 @@ impl BundleStore { self.save() } + /// Written only after an update's files and mcp entries land, so an + /// aborted update cannot leave the record claiming a commit whose content + /// never finished applying. + pub(crate) fn set_bundle_versions( + &mut self, + name: &str, + commit: &str, + version: Option, + ) -> Result<()> { + self.ensure_bundle_exists(name)?; + let record = self + .bundles + .get_mut(name) + .expect("bundle existence checked above"); + record.commit = commit.to_string(); + record.version = version; + self.save() + } + pub(crate) fn remove_file_record(&mut self, bundle: &str, path: &str) -> Result<()> { self.ensure_bundle_exists(bundle)?; let record = self diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 8529fa6..c28568f 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -44,9 +44,11 @@ pub fn install_remote(git_url: &str, filter: Option, force: bool) reference.as_deref(), layout.manifest.as_ref(), temp.head_sha(), + false, )?; let plan = plan_changes(&layout)?; + let plan = reclassify_owned_unmodified(plan, &store, &bundle)?; if !plan.files.is_empty() { print_plan_summary(&plan); @@ -60,7 +62,7 @@ pub fn install_remote(git_url: &str, filter: Option, force: bool) if let Some((remote_mcp, local_mcp)) = &plan.mcp_json { let local = local_mcp.exists().then_some(local_mcp.as_path()); - let report = merge_mcp_json(local, remote_mcp, local_mcp, force)?; + let report = merge_mcp_json(local, remote_mcp, local_mcp, force, &HashSet::new(), false)?; record_mcp_merge(&mut store, &bundle, &report)?; print_mcp_merge_report(&report); handle_missing_secrets(&report.missing_secrets)?; @@ -225,7 +227,7 @@ pub fn install_or_update( if filter.is_some() || force { bail!("--filter/--install-force only apply to remote installs, not bundle updates"); } - update_bundle(value) + update_bundle(value, false) } InstallTarget::RemoteSource => install_remote(value, filter, force), InstallTarget::Shorthand => { @@ -267,7 +269,7 @@ pub fn install_or_update_from_repl_args(args: &str) -> Result<()> { /// The whole remote is always processed, including categories a filtered /// install excluded, because filtered installs merge into a single record. -pub fn update_bundle(spec: &str) -> Result<()> { +pub fn update_bundle(spec: &str, assume_yes: bool) -> Result<()> { let (name, ref_override) = parse_url_with_ref(spec)?; let mut store = BundleStore::load()?; @@ -312,6 +314,7 @@ pub fn update_bundle(spec: &str) -> Result<()> { effective_ref.as_deref(), layout.manifest.as_ref(), temp.head_sha(), + true, )?; let plan = plan_changes(&layout)?; @@ -319,19 +322,31 @@ pub fn update_bundle(spec: &str) -> Result<()> { if !plan.files.is_empty() { print_plan_summary(&plan); - apply_plan(&plan, StickyMode::None, &mut store, &bundle)?; + let sticky = if assume_yes { + StickyMode::KeepAll + } else { + StickyMode::None + }; + apply_plan(&plan, sticky, &mut store, &bundle)?; } - handle_obsolete_files(&mut store, &bundle, &plan)?; + handle_obsolete_files(&mut store, &bundle, &plan, assume_yes)?; if let Some((remote_mcp, local_mcp)) = &plan.mcp_json { let local = local_mcp.exists().then_some(local_mcp.as_path()); - let report = merge_mcp_json(local, remote_mcp, local_mcp, false)?; + let auto_take = owned_unmodified_mcp_keys(&store, &bundle, local)?; + let report = merge_mcp_json(local, remote_mcp, local_mcp, false, &auto_take, assume_yes)?; record_mcp_merge(&mut store, &bundle, &report)?; print_mcp_merge_report(&report); handle_missing_secrets(&report.missing_secrets)?; } + let version = layout + .manifest + .as_ref() + .and_then(|m| m.version.clone()) + .unwrap_or_else(|| temp.head_sha().chars().take(7).collect()); + store.set_bundle_versions(&bundle, temp.head_sha(), Some(version))?; store.mark_updated(&bundle)?; Ok(()) @@ -367,6 +382,39 @@ fn reclassify_owned_unmodified( Ok(plan) } +/// Keys of this bundle's mcp entries whose recorded hash still matches the +/// local entry: the bundle wrote them and the user never touched them, so an +/// upstream change takes the remote side without prompting. +fn owned_unmodified_mcp_keys( + store: &BundleStore, + bundle: &str, + local: Option<&Path>, +) -> Result> { + let mut keys = HashSet::new(); + let (Some(local_path), Some(record)) = (local, store.get(bundle)) else { + return Ok(keys); + }; + let content = fs::read_to_string(local_path) + .with_context(|| format!("failed to read local mcp.json at {}", local_path.display()))?; + let config: McpServersConfig = serde_json::from_str(&content) + .with_context(|| format!("failed to parse local mcp.json at {}", local_path.display()))?; + for server in &record.mcp_servers { + let Some(recorded_hash) = server.sha256.as_deref() else { + continue; + }; + let key = server.effective_key(); + let Some(entry) = config.mcp_servers.get(key) else { + continue; + }; + let serialized = serde_json::to_string(entry) + .with_context(|| format!("failed to serialize MCP server '{key}'"))?; + if hash_bytes(serialized.as_bytes()) == recorded_hash { + keys.insert(key.to_string()); + } + } + Ok(keys) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ObsoleteAction { Keep, @@ -375,7 +423,12 @@ enum ObsoleteAction { /// Kept files stay in the record, so a later uninstall still offers to /// remove them. -fn handle_obsolete_files(store: &mut BundleStore, bundle: &str, plan: &InstallPlan) -> Result<()> { +fn handle_obsolete_files( + store: &mut BundleStore, + bundle: &str, + plan: &InstallPlan, + assume_yes: bool, +) -> Result<()> { let planned: HashSet = plan .files .iter() @@ -394,8 +447,12 @@ fn handle_obsolete_files(store: &mut BundleStore, bundle: &str, plan: &InstallPl .unwrap_or_default(); let config_dir = paths::config_dir(); - let mut sticky: Option = None; + let mut sticky: Option = assume_yes.then_some(ObsoleteAction::Keep); for path in obsolete { + if !is_safe_relative_path(&path) { + eprintln!("skipping suspicious recorded path {path}; keeping its record"); + continue; + } let full = config_dir.join(&path); if !full.exists() { println!("dropped record for obsolete file {path} (already absent locally)"); @@ -403,7 +460,7 @@ fn handle_obsolete_files(store: &mut BundleStore, bundle: &str, plan: &InstallPl continue; } let action = resolve_obsolete(&path, &mut sticky)?; - apply_obsolete_action(store, bundle, &path, &full, action)?; + apply_obsolete_action(store, bundle, &path, &full, &config_dir, action)?; } Ok(()) } @@ -441,6 +498,7 @@ fn apply_obsolete_action( bundle: &str, path: &str, full: &Path, + config_dir: &Path, action: ObsoleteAction, ) -> Result<()> { match action { @@ -451,6 +509,7 @@ fn apply_obsolete_action( fs::remove_file(full) .with_context(|| format!("failed to delete obsolete file {}", full.display()))?; store.remove_file_record(bundle, path)?; + prune_empty_dirs(full, config_dir); println!("deleted obsolete file {path}"); } } @@ -623,8 +682,17 @@ fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result bool { + let recorded = Path::new(path); + !recorded.is_absolute() + && recorded + .components() + .all(|c| matches!(c, Component::Normal(_))) +} + fn uninstall_owned_files( store: &mut BundleStore, bundle: &str, @@ -635,12 +703,7 @@ fn uninstall_owned_files( let mut summary = UninstallFileSummary::default(); let mut sticky: Option = None; for file in files { - let recorded = Path::new(&file.path); - if recorded.is_absolute() - || !recorded - .components() - .all(|c| matches!(c, Component::Normal(_))) - { + if !is_safe_relative_path(&file.path) { eprintln!( "skipping suspicious recorded path {}; keeping its record", file.path @@ -648,7 +711,7 @@ fn uninstall_owned_files( summary.failed += 1; continue; } - let full = config_dir.join(recorded); + let full = config_dir.join(Path::new(&file.path)); if !full.exists() { println!( "dropped record for missing file {} (already absent locally)", @@ -992,6 +1055,8 @@ fn clone_to_temp(url: &str, reference: Option<&str>) -> Result { fn run_git(args: Vec) -> Result<()> { let output = duct::cmd("git", &args) + .env("GIT_TERMINAL_PROMPT", "0") + .stdin_null() .stderr_to_stdout() .stdout_capture() .unchecked() @@ -1008,6 +1073,8 @@ fn run_git(args: Vec) -> Result<()> { fn run_git_capture(args: Vec) -> Result { let output = duct::cmd("git", &args) + .env("GIT_TERMINAL_PROMPT", "0") + .stdin_null() .stdout_capture() .stderr_capture() .unchecked() @@ -1100,24 +1167,31 @@ pub(crate) struct BundleManifest { /// Returns the record key, possibly migrated or owner-qualified, that all /// subsequent recording must use in place of the requested name. +/// An update passes `preserve_versions` so the record keeps claiming the old +/// commit and version until the new content actually lands on disk. fn register_bundle( store: &mut BundleStore, url: &str, git_ref: Option<&str>, manifest: Option<&BundleManifest>, commit: &str, + preserve_versions: bool, ) -> Result { let resolved = store.resolve_bundle_name(url, manifest.map(|m| m.name.as_str()))?; let version = manifest .and_then(|m| m.version.clone()) .unwrap_or_else(|| commit.chars().take(7).collect()); + let (commit, version) = match (preserve_versions, store.get(&resolved.name)) { + (true, Some(existing)) => (existing.commit.clone(), existing.version.clone()), + _ => (commit.to_string(), Some(version)), + }; store.upsert_bundle( &resolved.name, InstallMetadata { source: url.to_string(), git_ref: git_ref.map(str::to_string), - commit: commit.to_string(), - version: Some(version), + commit, + version, description: manifest.and_then(|m| m.description.clone()), homepage: manifest.and_then(|m| m.homepage.clone()), }, @@ -1490,14 +1564,13 @@ fn files_equal(a: &Path, b: &Path) -> Result { } fn files_equal_streaming(a: &Path, b: &Path) -> Result { - use std::io::Read; let mut fa = fs::File::open(a).with_context(|| format!("open {}", a.display()))?; let mut fb = fs::File::open(b).with_context(|| format!("open {}", b.display()))?; let mut buf_a = [0u8; 8192]; let mut buf_b = [0u8; 8192]; loop { - let na = fa.read(&mut buf_a)?; - let nb = fb.read(&mut buf_b)?; + let na = read_full(&mut fa, &mut buf_a)?; + let nb = read_full(&mut fb, &mut buf_b)?; if na != nb { return Ok(false); } @@ -1510,6 +1583,21 @@ fn files_equal_streaming(a: &Path, b: &Path) -> Result { } } +/// `read` may return short counts without EOF; comparing partially filled +/// buffers positionally would misreport identical files as different. +fn read_full(file: &mut fs::File, buf: &mut [u8]) -> std::io::Result { + use std::io::Read; + let mut filled = 0; + while filled < buf.len() { + let n = file.read(&mut buf[filled..])?; + if n == 0 { + break; + } + filled += n; + } + Ok(filled) +} + fn print_plan_summary(plan: &InstallPlan) { println!("Plan:"); for cat in [ @@ -1692,7 +1780,8 @@ fn resolve_conflict(planned: &PlannedFile, sticky: &mut StickyMode) -> Result, + assume_yes: bool, ) -> Result { let remote_content = fs::read_to_string(remote) .with_context(|| format!("failed to read remote mcp.json at {}", remote.display()))?; @@ -1816,7 +1907,12 @@ fn merge_mcp_json( if local_server == &remote_server { continue; } - match resolve_mcp_conflict(&name, force)? { + let action = if auto_take.contains(&name) { + McpConflictAction::TakeRemote + } else { + resolve_mcp_conflict(&name, force, assume_yes)? + }; + match action { McpConflictAction::KeepLocal => report.kept_local.push(name), McpConflictAction::TakeRemote => { merged.mcp_servers.insert(name.clone(), remote_server); @@ -1882,14 +1978,17 @@ fn merge_mcp_json( Ok(report) } -fn resolve_mcp_conflict(name: &str, force: bool) -> Result { +fn resolve_mcp_conflict(name: &str, force: bool, assume_yes: bool) -> Result { if force { return Ok(McpConflictAction::TakeRemote); } + if assume_yes { + return Ok(McpConflictAction::KeepLocal); + } if !*IS_STDOUT_TERMINAL { bail!( "MCP server '{name}' already exists locally. Refusing to merge non-interactively. \ - Re-run with --install-force or in a terminal." + Re-run in a terminal, with --install-force (installs), or with --yes (updates)." ); } let rename_label = format!("rename remote as \"{name}-remote\""); @@ -2461,7 +2560,7 @@ mod tests { let target = dir.join("target.json"); write_mcp(&remote, FIXTURE_REMOTE); - let report = merge_mcp_json(None, &remote, &target, false).unwrap(); + let report = merge_mcp_json(None, &remote, &target, false, &HashSet::new(), false).unwrap(); assert_eq!(report.added, vec!["alpha", "beta"]); assert!(report.kept_local.is_empty()); @@ -2484,7 +2583,15 @@ mod tests { ); write_mcp(&remote, FIXTURE_REMOTE); - let report = merge_mcp_json(Some(&target), &remote, &target, true).unwrap(); + let report = merge_mcp_json( + Some(&target), + &remote, + &target, + true, + &HashSet::new(), + false, + ) + .unwrap(); assert_eq!(report.added, vec!["beta"]); assert_eq!(report.replaced, vec!["alpha"]); @@ -2513,7 +2620,15 @@ mod tests { ); write_mcp(&remote, FIXTURE_REMOTE); - let err = merge_mcp_json(Some(&target), &remote, &target, false).unwrap_err(); + let err = merge_mcp_json( + Some(&target), + &remote, + &target, + false, + &HashSet::new(), + false, + ) + .unwrap_err(); assert!( err.to_string() @@ -2530,7 +2645,8 @@ mod tests { let target = dir.join("target.json"); write_mcp(&remote, r#"{"mcpServers": {"broken": {"type": "stdio"}}}"#); - let err = merge_mcp_json(None, &remote, &target, false).unwrap_err(); + let err = + merge_mcp_json(None, &remote, &target, false, &HashSet::new(), false).unwrap_err(); assert!( format!("{err:#}").contains("missing a \"command\" field"), @@ -2557,7 +2673,7 @@ mod tests { r#"{"mcpServers": {"x": {"type":"stdio","command":"echo","env":{"K":"{{COYOTE_TEST_MERGE_SECRET}}"}}}}"#, ); - let report = merge_mcp_json(None, &remote, &target, false).unwrap(); + let report = merge_mcp_json(None, &remote, &target, false, &HashSet::new(), false).unwrap(); assert_eq!(report.missing_secrets, vec!["COYOTE_TEST_MERGE_SECRET"]); let _ = fs::remove_dir_all(&dir); @@ -2572,10 +2688,18 @@ mod tests { let target = dir.join("target.json"); write_mcp(&remote, FIXTURE_REMOTE); - merge_mcp_json(None, &remote, &target, false).unwrap(); + merge_mcp_json(None, &remote, &target, false, &HashSet::new(), false).unwrap(); let after_first = fs::read(&target).unwrap(); - let report = merge_mcp_json(Some(&target), &remote, &target, false).unwrap(); + let report = merge_mcp_json( + Some(&target), + &remote, + &target, + false, + &HashSet::new(), + false, + ) + .unwrap(); assert!(report.added.is_empty(), "got: {:?}", report.added); let after_second = fs::read(&target).unwrap(); @@ -3289,7 +3413,7 @@ mod tests { install_remote(repo.to_str().unwrap(), None, false).unwrap(); commit_file(&repo, "macros/hello.yaml", "v2\n"); - update_bundle("refresh-bundle").unwrap(); + update_bundle("refresh-bundle", false).unwrap(); let installed = paths::macros_dir().join("hello.yaml"); assert_eq!(fs::read_to_string(&installed).unwrap(), "v2\n"); @@ -3301,6 +3425,82 @@ mod tests { let _ = fs::remove_dir_all(&src_root); } + #[test] + #[serial] + fn update_with_yes_keeps_modified_files_and_updates_the_rest() { + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping update_with_yes_keeps_modified_files_and_updates_the_rest: \ + requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("upd-yes"); + let src_root = fresh_temp_dir("upd-yes-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: yes-bundle\n"); + write_src(&repo, "macros/edited.yaml", "v1\n"); + write_src(&repo, "macros/pristine.yaml", "v1\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + fs::write(paths::macros_dir().join("edited.yaml"), "local\n").unwrap(); + commit_file(&repo, "macros/edited.yaml", "v2\n"); + commit_file(&repo, "macros/pristine.yaml", "v2\n"); + + let err = update_bundle("yes-bundle", false).unwrap_err(); + assert!( + format!("{err:#}").contains("Refusing to overwrite"), + "err: {err:#}" + ); + + update_bundle("yes-bundle", true).unwrap(); + + assert_eq!( + fs::read_to_string(paths::macros_dir().join("edited.yaml")).unwrap(), + "local\n" + ); + assert_eq!( + fs::read_to_string(paths::macros_dir().join("pristine.yaml")).unwrap(), + "v2\n" + ); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn update_takes_remote_for_owned_unmodified_mcp_entries() { + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping update_takes_remote_for_owned_unmodified_mcp_entries: \ + requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("upd-mcp-auto"); + let src_root = fresh_temp_dir("upd-mcp-auto-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: mcp-auto-bundle\n"); + write_src( + &repo, + "functions/mcp.json", + r#"{"mcpServers": {"srv": {"type": "stdio", "command": "echo"}}}"#, + ); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + commit_file( + &repo, + "functions/mcp.json", + r#"{"mcpServers": {"srv": {"type": "stdio", "command": "printf"}}}"#, + ); + + update_bundle("mcp-auto-bundle", false).unwrap(); + + let merged = fs::read_to_string(paths::mcp_config_file()).unwrap(); + assert!(merged.contains("printf"), "merged mcp.json: {merged}"); + assert!(!merged.contains("echo"), "merged mcp.json: {merged}"); + let _ = fs::remove_dir_all(&src_root); + } + #[test] fn apply_plan_keep_all_preserves_local_edit_and_stale_record() { let dir = fresh_temp_dir("upd-keep-modified-"); @@ -3412,7 +3612,7 @@ mod tests { fs::create_dir_all(paths::macros_dir()).unwrap(); fs::write(paths::macros_dir().join("user.yaml"), "local\n").unwrap(); - let err = update_bundle("unowned-bundle").unwrap_err(); + let err = update_bundle("unowned-bundle", false).unwrap_err(); assert!( err.to_string().contains("Refusing to overwrite"), @@ -3491,7 +3691,7 @@ mod tests { fs::remove_file(repo.join("macros/gone.yaml")).unwrap(); commit_file(&repo, "macros/keep.yaml", "k2\n"); - update_bundle("obs-keep").unwrap(); + update_bundle("obs-keep", false).unwrap(); assert_eq!( fs::read_to_string(paths::macros_dir().join("gone.yaml")).unwrap(), @@ -3525,7 +3725,8 @@ mod tests { ) .unwrap(); - apply_obsolete_action(&mut store, "omc", &path, &dst, ObsoleteAction::Delete).unwrap(); + apply_obsolete_action(&mut store, "omc", &path, &dst, &dir, ObsoleteAction::Delete) + .unwrap(); assert!(!dst.exists()); let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); @@ -3540,12 +3741,11 @@ mod tests { store .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); - let ghost = dir.join("macros/ghost.yaml"); store .record_file( "omc", FileRecord { - path: provenance_path(&ghost), + path: "macros/ghost-bundle-test.yaml".to_string(), category: "macros".to_string(), sha256: "0".repeat(64), action: FileAction::New, @@ -3557,12 +3757,43 @@ mod tests { mcp_json: None, }; - handle_obsolete_files(&mut store, "omc", &plan).unwrap(); + handle_obsolete_files(&mut store, "omc", &plan, false).unwrap(); assert!(store.get("omc").unwrap().files.is_empty()); let _ = fs::remove_dir_all(&dir); } + #[test] + fn handle_obsolete_never_touches_suspicious_recorded_paths() { + let dir = fresh_temp_dir("upd-obsolete-suspicious-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + for hostile in ["/etc/nonexistent-coyote-test", "../escape.yaml"] { + store + .record_file( + "omc", + FileRecord { + path: hostile.to_string(), + category: "macros".to_string(), + sha256: "0".repeat(64), + action: FileAction::New, + }, + ) + .unwrap(); + } + let plan = InstallPlan { + files: Vec::new(), + mcp_json: None, + }; + + handle_obsolete_files(&mut store, "omc", &plan, false).unwrap(); + + assert_eq!(store.get("omc").unwrap().files.len(), 2); + let _ = fs::remove_dir_all(&dir); + } + #[test] #[serial] fn update_refreshes_record_metadata_and_stamps_updated_at() { @@ -3595,7 +3826,7 @@ mod tests { homepage: https://example.com/new\n", ); - update_bundle("meta-bundle").unwrap(); + update_bundle("meta-bundle", false).unwrap(); let store = BundleStore::load().unwrap(); let record = store.get("meta-bundle").unwrap(); @@ -3613,7 +3844,7 @@ mod tests { fn update_unknown_bundle_lists_installed_names() { let _guard = TestVaultConfigGuard::new("upd-unknown-name"); - let err = update_bundle("nope").unwrap_err(); + let err = update_bundle("nope", false).unwrap_err(); assert!(err.to_string().contains("none are installed"), "got: {err}"); let src_root = fresh_temp_dir("upd-unknown-src-"); @@ -3623,7 +3854,7 @@ mod tests { init_bundle_repo(&repo); install_remote(repo.to_str().unwrap(), None, false).unwrap(); - let err = update_bundle("nope").unwrap_err(); + let err = update_bundle("nope", false).unwrap_err(); assert!( err.to_string().contains("installed bundles: known-bundle"), @@ -3645,7 +3876,7 @@ mod tests { let newer = commit_file(&repo, "macros/two.yaml", "2\n"); assert_ne!(pinned, newer); - update_bundle("pin-bundle").unwrap(); + update_bundle("pin-bundle", false).unwrap(); let store = BundleStore::load().unwrap(); let record = store.get("pin-bundle").unwrap(); @@ -3667,7 +3898,7 @@ mod tests { install_remote(&format!("{}#{pinned}", repo.display()), None, false).unwrap(); let newer = commit_file(&repo, "macros/two.yaml", "2\n"); - update_bundle(&format!("move-bundle#{newer}")).unwrap(); + update_bundle(&format!("move-bundle#{newer}"), false).unwrap(); let store = BundleStore::load().unwrap(); let record = store.get("move-bundle").unwrap(); @@ -4467,7 +4698,7 @@ mod tests { .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) .unwrap(); - let report = merge_mcp_json(None, &remote, &target, false).unwrap(); + let report = merge_mcp_json(None, &remote, &target, false, &HashSet::new(), false).unwrap(); record_mcp_merge(&mut store, "omc", &report).unwrap(); let written: McpServersConfig = diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 0b75021..3987b89 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -59,6 +59,22 @@ use std::sync::Arc; use std::time::Duration; use std::{env, fs}; +/// Completion must degrade rather than break the prompt, but a corrupt store +/// should not vanish silently: the failure is logged before returning empty. +fn installed_bundle_names() -> Vec { + match BundleStore::load() { + Ok(store) => store + .bundle_names() + .into_iter() + .map(str::to_string) + .collect(), + Err(e) => { + warn!("skipping bundle-name completion: {e:#}"); + Vec::new() + } + } +} + pub struct AutoContinueConfig { pub enabled: bool, pub max_continues: usize, @@ -3263,30 +3279,16 @@ impl RequestContext { ".install" => { let mut values: Vec = AssetCategory::NAMES.iter().map(|s| s.to_string()).collect(); - values.extend( - BundleStore::load() - .map(|store| { - store - .bundle_names() - .into_iter() - .map(str::to_string) - .collect::>() - }) - .unwrap_or_default(), - ); + values.extend(installed_bundle_names()); super::map_completion_values(values) } ".uninstall" => { - let values = BundleStore::load() - .map(|store| { - store - .bundle_names() - .into_iter() - .map(str::to_string) - .collect::>() - }) - .unwrap_or_default(); - super::map_completion_values(values) + let mut values = super::map_completion_values(installed_bundle_names()); + values.push(( + "--yes".to_string(), + Some("Skip the uninstall confirmation".to_string()), + )); + values } ".macro" => { let policy = self.macro_policy(); diff --git a/src/function/mod.rs b/src/function/mod.rs index 7c0b81e..1aefeb5 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -493,9 +493,8 @@ impl Functions { let serialized = serde_json::to_string_pretty(&merged).context("failed to serialize merged mcp.json")?; - let tmp = file_path.with_extension("json.tmp"); - fs::write(&tmp, &serialized).context("failed to write temporary mcp.json")?; - fs::rename(&tmp, &file_path).context("failed to finalize mcp.json")?; + write_file_atomic(&file_path, &serialized, None) + .context("failed to write merged mcp.json")?; if !added.is_empty() { println!(" + new MCP servers: {}", added.join(", ")); diff --git a/src/main.rs b/src/main.rs index eebdaf0..3f98954 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,6 +127,10 @@ async fn main() -> Result<()> { return sandbox::launch(name.clone(), cli.fresh); } + if cli.list_bundles { + return config::list_installed_bundles(); + } + install_builtins()?; if let Some(category) = cli.install_builtins { @@ -143,17 +147,13 @@ async fn main() -> Result<()> { } if let Some(spec) = cli.update_bundle.as_deref() { - return config::update_bundle(spec); + return config::update_bundle(spec, cli.yes); } if let Some(name) = cli.uninstall.as_deref() { return config::uninstall_bundle(name, cli.yes); } - if cli.list_bundles { - return config::list_installed_bundles(); - } - if let Some(client_arg) = &cli.authenticate { let cfg = Config::load_with_interpolation(true).await?; let app_config = AppConfig::from_config(cfg)?; diff --git a/src/repl/mod.rs b/src/repl/mod.rs index 37cf9b3..12c68a9 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -1197,20 +1197,10 @@ pub async fn run_repl_command( println!("Usage: .delete ") } }, - ".uninstall" => { - let mut assume_yes = false; - let mut names = Vec::new(); - for token in args.unwrap_or("").split_whitespace() { - match token { - "--yes" | "-y" => assume_yes = true, - other => names.push(other), - } - } - match names.as_slice() { - [name] => config::uninstall_bundle(name, assume_yes)?, - _ => println!("Usage: .uninstall [--yes]"), - } - } + ".uninstall" => match parse_repl_uninstall(args) { + Some((name, assume_yes)) => config::uninstall_bundle(&name, assume_yes)?, + None => println!("Usage: .uninstall [--yes]"), + }, ".list" => match args { Some(args) => { ctx.list_assets(args.trim())?; @@ -1602,6 +1592,22 @@ fn parse_repl_install(args: Option<&str>) -> ReplInstallDispatch<'_> { } } +fn parse_repl_uninstall(args: Option<&str>) -> Option<(String, bool)> { + let mut assume_yes = false; + let mut names = Vec::new(); + for token in args.unwrap_or("").split_whitespace() { + match token { + "--yes" | "-y" => assume_yes = true, + other if other.starts_with('-') => return None, + other => names.push(other), + } + } + match names.as_slice() { + [name] => Some((name.to_string(), assume_yes)), + _ => None, + } +} + pub fn builtin_command_names() -> Vec<&'static str> { let mut names: Vec<&'static str> = REPL_COMMANDS .iter() From 30c1637dfffdf047236719d80b5dc3b39ad5d99d Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 24 Aug 2026 11:14:54 -0600 Subject: [PATCH 20/20] refactor: Refactored some bundle const locations --- src/config/bundles.rs | 34 ++++++++++++++++-- src/config/install_remote.rs | 68 ++++++++++++++++++++++++++++++++--- src/config/mod.rs | 1 + src/config/request_context.rs | 14 ++++---- src/function/mod.rs | 4 +-- 5 files changed, 105 insertions(+), 16 deletions(-) diff --git a/src/config/bundles.rs b/src/config/bundles.rs index 8b1b29c..3499227 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -1,7 +1,7 @@ use super::install_remote::{ canonical_source_url, owner_qualifier, repo_name_slug, validate_bundle_name, }; -use super::paths; +use super::{paths, request_context}; use crate::config::AssetCategory; use crate::function::write_file_atomic; use crate::utils::IS_STDOUT_TERMINAL; @@ -137,6 +137,7 @@ impl BundleStore { bundles: BTreeMap::new(), }); } + let content = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; let contents: StoreContents = serde_yaml::from_str(&content).with_context(|| { @@ -147,6 +148,7 @@ impl BundleStore { path.display() ) })?; + if contents.version > STORE_VERSION { bail!( "{} has store version {}, but this coyote build supports up to \ @@ -155,6 +157,7 @@ impl BundleStore { contents.version ); } + Ok(Self { path, bundles: contents.bundles, @@ -167,10 +170,12 @@ impl BundleStore { bundles: &self.bundles, }) .context("failed to serialize the installed-bundles store")?; + if let Some(parent) = self.path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create directory {}", parent.display()))?; } + write_file_atomic(&self.path, &content, None) .with_context(|| format!("failed to write {}", self.path.display())) } @@ -244,12 +249,14 @@ impl BundleStore { } None => "is reserved for an asset category".to_string(), }; + if base.contains('/') { bail!( "bundle name '{base}' {reason} and cannot be qualified further; \ uninstall it or pick a different manifest name" ); } + let owner = owner_qualifier(url) .map(|owner| sanitize_name_segment(&owner)) .filter(|owner| !owner.is_empty()); @@ -260,6 +267,7 @@ impl BundleStore { ); }; let qualified = format!("{owner}/{base}"); + if let Some(source) = self.source_of_other_bundle(&qualified, &canonical) { bail!( "bundle name '{base}' {reason}, and '{qualified}' is already \ @@ -267,6 +275,7 @@ impl BundleStore { uninstall one or pick a different manifest name" ); } + if let Some(other_source) = &collision && manifest_name.is_some() && *IS_STDOUT_TERMINAL @@ -357,6 +366,7 @@ impl BundleStore { ); } } + self.save() } @@ -366,7 +376,9 @@ impl BundleStore { .bundles .get_mut(name) .expect("bundle existence checked above"); + record.updated_at = Some(Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)); + self.save() } @@ -384,8 +396,10 @@ impl BundleStore { .bundles .get_mut(name) .expect("bundle existence checked above"); + record.commit = commit.to_string(); record.version = version; + self.save() } @@ -395,7 +409,9 @@ impl BundleStore { .bundles .get_mut(bundle) .expect("bundle existence checked above"); + record.files.retain(|owned| owned.path != path); + self.save() } @@ -405,9 +421,11 @@ impl BundleStore { .bundles .get_mut(bundle) .expect("bundle existence checked above"); + record .mcp_servers .retain(|owned| owned.effective_key() != effective_key); + self.save() } @@ -431,8 +449,10 @@ impl BundleStore { .bundles .get_mut(bundle) .expect("bundle existence checked above"); + record.files.retain(|owned| owned.path != file.path); record.files.push(file); + self.save() } @@ -457,22 +477,27 @@ impl BundleStore { if owned.effective_key() != key { return true; } + if owned.action == McpAction::Replaced { prior_user_origin = true; } + false }); previously_owned |= record.mcp_servers.len() != before; } + if previously_owned && !prior_user_origin && entry.action == McpAction::Replaced { entry.action = McpAction::Transferred; } + self.bundles .get_mut(bundle) .expect("bundle existence checked above") .mcp_servers .push(entry); } + self.save() } @@ -487,6 +512,7 @@ impl BundleStore { } ); } + Ok(()) } @@ -523,15 +549,19 @@ impl DriftSummary { 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(", ") } } @@ -600,7 +630,7 @@ pub fn list_installed_bundles() -> Result<()> { return Ok(()); } - let mut table = super::request_context::asset_table(&[ + let mut table = request_context::asset_table(&[ "name", "version", "source", diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index c28568f..c590e9e 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -2,7 +2,7 @@ use super::bundles::{ BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes, hash_file, }; -use crate::config::{AssetCategory, InstallFilter, paths}; +use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths}; #[cfg(not(windows))] use crate::function::Language; use crate::mcp::{McpServer, McpServersConfig}; @@ -17,8 +17,8 @@ use inquire::{Confirm, Select}; use serde::Deserialize; use std::collections::{BTreeSet, HashMap, HashSet}; use std::ffi::{OsStr, OsString}; -use std::fs; use std::path::{Component, Path, PathBuf}; +use std::{fs, iter}; pub fn install_remote(git_url: &str, filter: Option, force: bool) -> Result<()> { let (url, reference) = parse_url_with_ref(git_url)?; @@ -123,16 +123,21 @@ fn classify_install_target(value: &str, installed_names: &[String]) -> InstallTa if let Some(category) = AssetCategory::parse(value) { return InstallTarget::Category(category); } + let name = strip_ref_suffix(value); + if installed_names.iter().any(|installed| installed == name) { return InstallTarget::InstalledBundle; } + if looks_like_remote_source(value) { return InstallTarget::RemoteSource; } + if is_repo_shorthand(value) { return InstallTarget::Shorthand; } + InstallTarget::Unknown } @@ -145,6 +150,7 @@ fn looks_like_remote_source(value: &str) -> bool { { return true; } + match value.split_once(':') { Some((host, _)) => { !host.is_empty() && !host.contains('/') && !host.chars().any(char::is_whitespace) @@ -165,6 +171,7 @@ fn is_repo_shorthand(value: &str) -> bool { { return false; } + let mut segments = path.split('/'); segments.clone().count() >= 2 && segments.all(|segment| !segment.is_empty()) } @@ -176,9 +183,11 @@ fn expand_repo_shorthand(value: &str, git_host: Option<&str>) -> Result .or_else(|| raw.strip_prefix("http://")) .unwrap_or(raw) .trim_matches('/'); + if host.is_empty() || host.contains(['/', '#', '?']) || host.chars().any(char::is_whitespace) { bail!("invalid --git-host '{raw}': expected a bare host like git.somedomain.com"); } + Ok(format!("https://{host}/{value}")) } @@ -195,6 +204,7 @@ pub fn install_or_update( '{value}' is not one" ); } + let url = expand_repo_shorthand(value, Some(host))?; println!("Resolved '{value}' to '{url}'"); return install_remote(&url, filter, force); @@ -239,9 +249,11 @@ pub fn install_or_update( let hint = "a remote source must be a git URL, an / shorthand \ (expanded against --git-host, default github.com), an scp-style \ host:path, or an explicit local path (./dir, /abs, ~)"; + if installed.is_empty() { bail!("no bundle named '{value}' is installed; none are installed ({hint})"); } + bail!( "no bundle named '{value}' is installed; installed bundles: {} ({hint})", installed.join(", ") @@ -372,13 +384,16 @@ fn reclassify_owned_unmodified( if planned.kind != PlannedKind::Conflict { continue; } + let Some(recorded) = owned.get(provenance_path(&planned.dst).as_str()) else { continue; }; + if hash_file(&planned.dst)? == *recorded { planned.kind = PlannedKind::Refresh; } } + Ok(plan) } @@ -398,6 +413,7 @@ fn owned_unmodified_mcp_keys( .with_context(|| format!("failed to read local mcp.json at {}", local_path.display()))?; let config: McpServersConfig = serde_json::from_str(&content) .with_context(|| format!("failed to parse local mcp.json at {}", local_path.display()))?; + for server in &record.mcp_servers { let Some(recorded_hash) = server.sha256.as_deref() else { continue; @@ -408,10 +424,12 @@ fn owned_unmodified_mcp_keys( }; let serialized = serde_json::to_string(entry) .with_context(|| format!("failed to serialize MCP server '{key}'"))?; + if hash_bytes(serialized.as_bytes()) == recorded_hash { keys.insert(key.to_string()); } } + Ok(keys) } @@ -453,15 +471,18 @@ fn handle_obsolete_files( eprintln!("skipping suspicious recorded path {path}; keeping its record"); continue; } + let full = config_dir.join(&path); if !full.exists() { println!("dropped record for obsolete file {path} (already absent locally)"); store.remove_file_record(bundle, &path)?; continue; } + let action = resolve_obsolete(&path, &mut sticky)?; apply_obsolete_action(store, bundle, &path, &full, &config_dir, action)?; } + Ok(()) } @@ -469,6 +490,7 @@ fn resolve_obsolete(path: &str, sticky: &mut Option) -> Result Result<()> { if installed.is_empty() { bail!("no bundle named '{spec}' is installed; none are installed"); } + bail!( "no bundle named '{spec}' is installed; installed bundles: {}", installed.join(", ") @@ -587,12 +611,14 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { &paths::config_dir(), assume_yes, )?; + if files.tools_seen { println!( "Note: compiled tool binaries remain in {} until the next --build-tools prune.", paths::functions_bin_dir().display() ); } + let mcp = uninstall_mcp_entries( &mut store, &name, @@ -605,6 +631,7 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { .get(&name) .map(|record| record.files.is_empty() && record.mcp_servers.is_empty()) .unwrap_or(true); + if empty { store.remove_bundle(&name)?; println!("\nUninstalled bundle '{name}' and removed its record."); @@ -614,6 +641,7 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { items, so re-running --uninstall offers them again." ); } + println!( " files: deleted={} kept={} missing={} failed={}", files.deleted, files.kept, files.missing, files.failed @@ -623,12 +651,15 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { mcp.removed.len(), mcp.kept.len() ); + if !mcp.removed.is_empty() { println!(" - removed servers: {}", mcp.removed.join(", ")); } + if !mcp.kept.is_empty() { println!(" = kept servers: {}", mcp.kept.join(", ")); } + if !mcp.secrets.is_empty() { println!( " ~ vault secrets referenced by this bundle's servers \ @@ -636,6 +667,7 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> { mcp.secrets.join(", ") ); } + Ok(()) } @@ -645,6 +677,7 @@ fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result = store .iter() @@ -660,6 +693,7 @@ fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result Result Result { Ok(layout) } -const BUNDLE_MANIFEST_FILE: &str = "coyote-bundle.yaml"; - #[derive(Debug, Clone, PartialEq, Deserialize)] pub(crate) struct BundleManifest { pub(crate) name: String, @@ -1196,6 +1244,7 @@ fn register_bundle( homepage: manifest.and_then(|m| m.homepage.clone()), }, )?; + Ok(resolved.name) } @@ -1204,12 +1253,14 @@ fn parse_bundle_manifest(root: &Path) -> Result> { if !path.is_file() { return Ok(None); } + let content = fs::read_to_string(&path) .with_context(|| format!("failed to read bundle manifest at {}", path.display()))?; let manifest: BundleManifest = serde_yaml::from_str(&content) .with_context(|| format!("invalid bundle manifest at {}", path.display()))?; validate_bundle_name(&manifest.name) .with_context(|| format!("invalid bundle name in manifest at {}", path.display()))?; + Ok(Some(manifest)) } @@ -1224,7 +1275,8 @@ pub(crate) fn validate_bundle_name(name: &str) -> Result<()> { (as the owner qualifier separator)" ); } - for part in owner.into_iter().chain(std::iter::once(base)) { + + for part in owner.into_iter().chain(iter::once(base)) { if part.is_empty() { bail!("Invalid bundle name '{name}': name segments cannot be empty"); } @@ -1238,6 +1290,7 @@ pub(crate) fn validate_bundle_name(name: &str) -> Result<()> { ); } } + Ok(()) } @@ -1285,6 +1338,7 @@ pub(crate) fn owner_qualifier(url: &str) -> Option { if segments.len() >= 2 { return Some(segments[segments.len() - 2].to_string()); } + let sanitized = sanitize_host(&host); (!sanitized.is_empty()).then_some(sanitized) } @@ -1595,6 +1649,7 @@ fn read_full(file: &mut fs::File, buf: &mut [u8]) -> std::io::Result { } filled += n; } + Ok(filled) } @@ -1619,6 +1674,7 @@ fn print_plan_summary(plan: &InstallPlan) { if refresh > 0 { line.push_str(&format!(" refresh={refresh}")); } + println!("{line}"); } } @@ -1764,9 +1820,11 @@ fn record_mcp_merge(store: &mut BundleStore, bundle: &str, report: &McpMergeRepo sha256: report.entry_hashes.get(renamed_to).cloned(), }), ); + if entries.is_empty() { return Ok(()); } + store.record_mcp_servers(bundle, entries) } diff --git a/src/config/mod.rs b/src/config/mod.rs index d3cf255..762ca5e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -160,6 +160,7 @@ const SBX_KIT_HASH_FILE: &str = "kit.sha256"; const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; pub(crate) const VAULT_DATA_FILE_NAME: &str = "vault.yml"; const INSTALLED_BUNDLES_FILE_NAME: &str = "installed-bundles.yaml"; +const BUNDLE_MANIFEST_FILE: &str = "coyote-bundle.yaml"; const SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits"; const GIT_DIR_NAME: &str = ".git"; const GITIGNORE_FILE_NAME: &str = ".gitignore"; diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 3987b89..2a0a9a8 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -11,7 +11,7 @@ use super::{ Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, RIGHT_PROMPT, ResolvedMacro, Role, RoleLike, SESSIONS_DIR_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags, - TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, + TEMP_ROLE_NAME, TEMP_SESSION_NAME, WorkingMode, bundles, ensure_parent_exists, list_agents_with_descriptions, memory, paths, }; use super::{MessageContentToolCalls, prompts}; @@ -36,6 +36,7 @@ use crate::utils::{ }; use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL}; +use super::install_remote::DEFAULT_GIT_HOST; use super::instructions; use super::memory::{ DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory, @@ -2803,7 +2804,7 @@ impl RequestContext { } Ok(()) } - "bundles" => super::bundles::list_installed_bundles(), + "bundles" => bundles::list_installed_bundles(), _ => bail!( "Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers, bundles" ), @@ -3288,6 +3289,7 @@ impl RequestContext { "--yes".to_string(), Some("Skip the uninstall confirmation".to_string()), )); + values } ".macro" => { @@ -3503,9 +3505,7 @@ impl RequestContext { InstallFilter::NAMES.iter().map(|s| s.to_string()).collect(), ); } else if prev == "--git-host" { - values = super::map_completion_values(vec![ - super::install_remote::DEFAULT_GIT_HOST.to_string(), - ]); + values = super::map_completion_values(vec![DEFAULT_GIT_HOST.to_string()]); } else { let has_filter = args.iter().enumerate().any(|(i, a)| { a.starts_with("--filter=") || (*a == "--filter" && i < args.len() - 1) @@ -4981,7 +4981,7 @@ mod tests { store .upsert_bundle( "omc", - crate::config::bundles::InstallMetadata { + bundles::InstallMetadata { source: "https://github.com/x/omc".to_string(), git_ref: None, commit: "abc123".to_string(), @@ -5009,7 +5009,7 @@ mod tests { store .upsert_bundle( "omc", - crate::config::bundles::InstallMetadata { + bundles::InstallMetadata { source: "https://github.com/x/omc".to_string(), git_ref: None, commit: "abc123".to_string(), diff --git a/src/function/mod.rs b/src/function/mod.rs index 1aefeb5..d17f042 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -165,9 +165,9 @@ pub(crate) fn write_file_atomic( std::process::id(), TMP_COUNTER.fetch_add(1, Ordering::Relaxed) )); - let write_synced = || -> std::io::Result<()> { + let write_synced = || -> io::Result<()> { use std::io::Write; - let mut file = fs::File::create(&tmp)?; + let mut file = File::create(&tmp)?; file.write_all(content.as_bytes())?; file.sync_all() };