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() };