refactor: Refactored some bundle const locations

This commit is contained in:
2026-08-24 11:15:13 -06:00
parent 6b5535956d
commit 30c1637dff
5 changed files with 105 additions and 16 deletions
+32 -2
View File
@@ -1,7 +1,7 @@
use super::install_remote::{ use super::install_remote::{
canonical_source_url, owner_qualifier, repo_name_slug, validate_bundle_name, 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::config::AssetCategory;
use crate::function::write_file_atomic; use crate::function::write_file_atomic;
use crate::utils::IS_STDOUT_TERMINAL; use crate::utils::IS_STDOUT_TERMINAL;
@@ -137,6 +137,7 @@ impl BundleStore {
bundles: BTreeMap::new(), bundles: BTreeMap::new(),
}); });
} }
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?; .with_context(|| format!("failed to read {}", path.display()))?;
let contents: StoreContents = serde_yaml::from_str(&content).with_context(|| { let contents: StoreContents = serde_yaml::from_str(&content).with_context(|| {
@@ -147,6 +148,7 @@ impl BundleStore {
path.display() path.display()
) )
})?; })?;
if contents.version > STORE_VERSION { if contents.version > STORE_VERSION {
bail!( bail!(
"{} has store version {}, but this coyote build supports up to \ "{} has store version {}, but this coyote build supports up to \
@@ -155,6 +157,7 @@ impl BundleStore {
contents.version contents.version
); );
} }
Ok(Self { Ok(Self {
path, path,
bundles: contents.bundles, bundles: contents.bundles,
@@ -167,10 +170,12 @@ impl BundleStore {
bundles: &self.bundles, bundles: &self.bundles,
}) })
.context("failed to serialize the installed-bundles store")?; .context("failed to serialize the installed-bundles store")?;
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?; .with_context(|| format!("failed to create directory {}", parent.display()))?;
} }
write_file_atomic(&self.path, &content, None) write_file_atomic(&self.path, &content, None)
.with_context(|| format!("failed to write {}", self.path.display())) .with_context(|| format!("failed to write {}", self.path.display()))
} }
@@ -244,12 +249,14 @@ impl BundleStore {
} }
None => "is reserved for an asset category".to_string(), None => "is reserved for an asset category".to_string(),
}; };
if base.contains('/') { if base.contains('/') {
bail!( bail!(
"bundle name '{base}' {reason} and cannot be qualified further; \ "bundle name '{base}' {reason} and cannot be qualified further; \
uninstall it or pick a different manifest name" uninstall it or pick a different manifest name"
); );
} }
let owner = owner_qualifier(url) let owner = owner_qualifier(url)
.map(|owner| sanitize_name_segment(&owner)) .map(|owner| sanitize_name_segment(&owner))
.filter(|owner| !owner.is_empty()); .filter(|owner| !owner.is_empty());
@@ -260,6 +267,7 @@ impl BundleStore {
); );
}; };
let qualified = format!("{owner}/{base}"); let qualified = format!("{owner}/{base}");
if let Some(source) = self.source_of_other_bundle(&qualified, &canonical) { if let Some(source) = self.source_of_other_bundle(&qualified, &canonical) {
bail!( bail!(
"bundle name '{base}' {reason}, and '{qualified}' is already \ "bundle name '{base}' {reason}, and '{qualified}' is already \
@@ -267,6 +275,7 @@ impl BundleStore {
uninstall one or pick a different manifest name" uninstall one or pick a different manifest name"
); );
} }
if let Some(other_source) = &collision if let Some(other_source) = &collision
&& manifest_name.is_some() && manifest_name.is_some()
&& *IS_STDOUT_TERMINAL && *IS_STDOUT_TERMINAL
@@ -357,6 +366,7 @@ impl BundleStore {
); );
} }
} }
self.save() self.save()
} }
@@ -366,7 +376,9 @@ impl BundleStore {
.bundles .bundles
.get_mut(name) .get_mut(name)
.expect("bundle existence checked above"); .expect("bundle existence checked above");
record.updated_at = Some(Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)); record.updated_at = Some(Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true));
self.save() self.save()
} }
@@ -384,8 +396,10 @@ impl BundleStore {
.bundles .bundles
.get_mut(name) .get_mut(name)
.expect("bundle existence checked above"); .expect("bundle existence checked above");
record.commit = commit.to_string(); record.commit = commit.to_string();
record.version = version; record.version = version;
self.save() self.save()
} }
@@ -395,7 +409,9 @@ impl BundleStore {
.bundles .bundles
.get_mut(bundle) .get_mut(bundle)
.expect("bundle existence checked above"); .expect("bundle existence checked above");
record.files.retain(|owned| owned.path != path); record.files.retain(|owned| owned.path != path);
self.save() self.save()
} }
@@ -405,9 +421,11 @@ impl BundleStore {
.bundles .bundles
.get_mut(bundle) .get_mut(bundle)
.expect("bundle existence checked above"); .expect("bundle existence checked above");
record record
.mcp_servers .mcp_servers
.retain(|owned| owned.effective_key() != effective_key); .retain(|owned| owned.effective_key() != effective_key);
self.save() self.save()
} }
@@ -431,8 +449,10 @@ impl BundleStore {
.bundles .bundles
.get_mut(bundle) .get_mut(bundle)
.expect("bundle existence checked above"); .expect("bundle existence checked above");
record.files.retain(|owned| owned.path != file.path); record.files.retain(|owned| owned.path != file.path);
record.files.push(file); record.files.push(file);
self.save() self.save()
} }
@@ -457,22 +477,27 @@ impl BundleStore {
if owned.effective_key() != key { if owned.effective_key() != key {
return true; return true;
} }
if owned.action == McpAction::Replaced { if owned.action == McpAction::Replaced {
prior_user_origin = true; prior_user_origin = true;
} }
false false
}); });
previously_owned |= record.mcp_servers.len() != before; previously_owned |= record.mcp_servers.len() != before;
} }
if previously_owned && !prior_user_origin && entry.action == McpAction::Replaced { if previously_owned && !prior_user_origin && entry.action == McpAction::Replaced {
entry.action = McpAction::Transferred; entry.action = McpAction::Transferred;
} }
self.bundles self.bundles
.get_mut(bundle) .get_mut(bundle)
.expect("bundle existence checked above") .expect("bundle existence checked above")
.mcp_servers .mcp_servers
.push(entry); .push(entry);
} }
self.save() self.save()
} }
@@ -487,6 +512,7 @@ impl BundleStore {
} }
); );
} }
Ok(()) Ok(())
} }
@@ -523,15 +549,19 @@ impl DriftSummary {
return "-".to_string(); return "-".to_string();
} }
let mut parts = Vec::new(); let mut parts = Vec::new();
if self.intact > 0 { if self.intact > 0 {
parts.push(format!("{} intact", self.intact)); parts.push(format!("{} intact", self.intact));
} }
if self.modified > 0 { if self.modified > 0 {
parts.push(format!("{} modified locally", self.modified)); parts.push(format!("{} modified locally", self.modified));
} }
if self.missing > 0 { if self.missing > 0 {
parts.push(format!("{} missing", self.missing)); parts.push(format!("{} missing", self.missing));
} }
parts.join(", ") parts.join(", ")
} }
} }
@@ -600,7 +630,7 @@ pub fn list_installed_bundles() -> Result<()> {
return Ok(()); return Ok(());
} }
let mut table = super::request_context::asset_table(&[ let mut table = request_context::asset_table(&[
"name", "name",
"version", "version",
"source", "source",
+63 -5
View File
@@ -2,7 +2,7 @@ use super::bundles::{
BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes, BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes,
hash_file, hash_file,
}; };
use crate::config::{AssetCategory, InstallFilter, paths}; use crate::config::{AssetCategory, BUNDLE_MANIFEST_FILE, InstallFilter, paths};
#[cfg(not(windows))] #[cfg(not(windows))]
use crate::function::Language; use crate::function::Language;
use crate::mcp::{McpServer, McpServersConfig}; use crate::mcp::{McpServer, McpServersConfig};
@@ -17,8 +17,8 @@ use inquire::{Confirm, Select};
use serde::Deserialize; use serde::Deserialize;
use std::collections::{BTreeSet, HashMap, HashSet}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::ffi::{OsStr, OsString}; use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::{fs, iter};
pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool) -> Result<()> { pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool) -> Result<()> {
let (url, reference) = parse_url_with_ref(git_url)?; 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) { if let Some(category) = AssetCategory::parse(value) {
return InstallTarget::Category(category); return InstallTarget::Category(category);
} }
let name = strip_ref_suffix(value); let name = strip_ref_suffix(value);
if installed_names.iter().any(|installed| installed == name) { if installed_names.iter().any(|installed| installed == name) {
return InstallTarget::InstalledBundle; return InstallTarget::InstalledBundle;
} }
if looks_like_remote_source(value) { if looks_like_remote_source(value) {
return InstallTarget::RemoteSource; return InstallTarget::RemoteSource;
} }
if is_repo_shorthand(value) { if is_repo_shorthand(value) {
return InstallTarget::Shorthand; return InstallTarget::Shorthand;
} }
InstallTarget::Unknown InstallTarget::Unknown
} }
@@ -145,6 +150,7 @@ fn looks_like_remote_source(value: &str) -> bool {
{ {
return true; return true;
} }
match value.split_once(':') { match value.split_once(':') {
Some((host, _)) => { Some((host, _)) => {
!host.is_empty() && !host.contains('/') && !host.chars().any(char::is_whitespace) !host.is_empty() && !host.contains('/') && !host.chars().any(char::is_whitespace)
@@ -165,6 +171,7 @@ fn is_repo_shorthand(value: &str) -> bool {
{ {
return false; return false;
} }
let mut segments = path.split('/'); let mut segments = path.split('/');
segments.clone().count() >= 2 && segments.all(|segment| !segment.is_empty()) 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<String>
.or_else(|| raw.strip_prefix("http://")) .or_else(|| raw.strip_prefix("http://"))
.unwrap_or(raw) .unwrap_or(raw)
.trim_matches('/'); .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"); bail!("invalid --git-host '{raw}': expected a bare host like git.somedomain.com");
} }
Ok(format!("https://{host}/{value}")) Ok(format!("https://{host}/{value}"))
} }
@@ -195,6 +204,7 @@ pub fn install_or_update(
'{value}' is not one" '{value}' is not one"
); );
} }
let url = expand_repo_shorthand(value, Some(host))?; let url = expand_repo_shorthand(value, Some(host))?;
println!("Resolved '{value}' to '{url}'"); println!("Resolved '{value}' to '{url}'");
return install_remote(&url, filter, force); 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 <owner>/<repo> shorthand \ let hint = "a remote source must be a git URL, an <owner>/<repo> shorthand \
(expanded against --git-host, default github.com), an scp-style \ (expanded against --git-host, default github.com), an scp-style \
host:path, or an explicit local path (./dir, /abs, ~)"; host:path, or an explicit local path (./dir, /abs, ~)";
if installed.is_empty() { if installed.is_empty() {
bail!("no bundle named '{value}' is installed; none are installed ({hint})"); bail!("no bundle named '{value}' is installed; none are installed ({hint})");
} }
bail!( bail!(
"no bundle named '{value}' is installed; installed bundles: {} ({hint})", "no bundle named '{value}' is installed; installed bundles: {} ({hint})",
installed.join(", ") installed.join(", ")
@@ -372,13 +384,16 @@ fn reclassify_owned_unmodified(
if planned.kind != PlannedKind::Conflict { if planned.kind != PlannedKind::Conflict {
continue; continue;
} }
let Some(recorded) = owned.get(provenance_path(&planned.dst).as_str()) else { let Some(recorded) = owned.get(provenance_path(&planned.dst).as_str()) else {
continue; continue;
}; };
if hash_file(&planned.dst)? == *recorded { if hash_file(&planned.dst)? == *recorded {
planned.kind = PlannedKind::Refresh; planned.kind = PlannedKind::Refresh;
} }
} }
Ok(plan) Ok(plan)
} }
@@ -398,6 +413,7 @@ fn owned_unmodified_mcp_keys(
.with_context(|| format!("failed to read local mcp.json at {}", local_path.display()))?; .with_context(|| format!("failed to read local mcp.json at {}", local_path.display()))?;
let config: McpServersConfig = serde_json::from_str(&content) let config: McpServersConfig = serde_json::from_str(&content)
.with_context(|| format!("failed to parse local mcp.json at {}", local_path.display()))?; .with_context(|| format!("failed to parse local mcp.json at {}", local_path.display()))?;
for server in &record.mcp_servers { for server in &record.mcp_servers {
let Some(recorded_hash) = server.sha256.as_deref() else { let Some(recorded_hash) = server.sha256.as_deref() else {
continue; continue;
@@ -408,10 +424,12 @@ fn owned_unmodified_mcp_keys(
}; };
let serialized = serde_json::to_string(entry) let serialized = serde_json::to_string(entry)
.with_context(|| format!("failed to serialize MCP server '{key}'"))?; .with_context(|| format!("failed to serialize MCP server '{key}'"))?;
if hash_bytes(serialized.as_bytes()) == recorded_hash { if hash_bytes(serialized.as_bytes()) == recorded_hash {
keys.insert(key.to_string()); keys.insert(key.to_string());
} }
} }
Ok(keys) Ok(keys)
} }
@@ -453,15 +471,18 @@ fn handle_obsolete_files(
eprintln!("skipping suspicious recorded path {path}; keeping its record"); eprintln!("skipping suspicious recorded path {path}; keeping its record");
continue; continue;
} }
let full = config_dir.join(&path); let full = config_dir.join(&path);
if !full.exists() { if !full.exists() {
println!("dropped record for obsolete file {path} (already absent locally)"); println!("dropped record for obsolete file {path} (already absent locally)");
store.remove_file_record(bundle, &path)?; store.remove_file_record(bundle, &path)?;
continue; continue;
} }
let action = resolve_obsolete(&path, &mut sticky)?; let action = resolve_obsolete(&path, &mut sticky)?;
apply_obsolete_action(store, bundle, &path, &full, &config_dir, action)?; apply_obsolete_action(store, bundle, &path, &full, &config_dir, action)?;
} }
Ok(()) Ok(())
} }
@@ -469,6 +490,7 @@ fn resolve_obsolete(path: &str, sticky: &mut Option<ObsoleteAction>) -> Result<O
if let Some(action) = *sticky { if let Some(action) = *sticky {
return Ok(action); return Ok(action);
} }
if !*IS_STDOUT_TERMINAL { if !*IS_STDOUT_TERMINAL {
return Ok(ObsoleteAction::Keep); return Ok(ObsoleteAction::Keep);
} }
@@ -513,6 +535,7 @@ fn apply_obsolete_action(
println!("deleted obsolete file {path}"); println!("deleted obsolete file {path}");
} }
} }
Ok(()) Ok(())
} }
@@ -548,6 +571,7 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> {
if installed.is_empty() { if installed.is_empty() {
bail!("no bundle named '{spec}' is installed; none are installed"); bail!("no bundle named '{spec}' is installed; none are installed");
} }
bail!( bail!(
"no bundle named '{spec}' is installed; installed bundles: {}", "no bundle named '{spec}' is installed; installed bundles: {}",
installed.join(", ") installed.join(", ")
@@ -587,12 +611,14 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> {
&paths::config_dir(), &paths::config_dir(),
assume_yes, assume_yes,
)?; )?;
if files.tools_seen { if files.tools_seen {
println!( println!(
"Note: compiled tool binaries remain in {} until the next --build-tools prune.", "Note: compiled tool binaries remain in {} until the next --build-tools prune.",
paths::functions_bin_dir().display() paths::functions_bin_dir().display()
); );
} }
let mcp = uninstall_mcp_entries( let mcp = uninstall_mcp_entries(
&mut store, &mut store,
&name, &name,
@@ -605,6 +631,7 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> {
.get(&name) .get(&name)
.map(|record| record.files.is_empty() && record.mcp_servers.is_empty()) .map(|record| record.files.is_empty() && record.mcp_servers.is_empty())
.unwrap_or(true); .unwrap_or(true);
if empty { if empty {
store.remove_bundle(&name)?; store.remove_bundle(&name)?;
println!("\nUninstalled bundle '{name}' and removed its record."); 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." items, so re-running --uninstall offers them again."
); );
} }
println!( println!(
" files: deleted={} kept={} missing={} failed={}", " files: deleted={} kept={} missing={} failed={}",
files.deleted, files.kept, files.missing, files.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.removed.len(),
mcp.kept.len() mcp.kept.len()
); );
if !mcp.removed.is_empty() { if !mcp.removed.is_empty() {
println!(" - removed servers: {}", mcp.removed.join(", ")); println!(" - removed servers: {}", mcp.removed.join(", "));
} }
if !mcp.kept.is_empty() { if !mcp.kept.is_empty() {
println!(" = kept servers: {}", mcp.kept.join(", ")); println!(" = kept servers: {}", mcp.kept.join(", "));
} }
if !mcp.secrets.is_empty() { if !mcp.secrets.is_empty() {
println!( println!(
" ~ vault secrets referenced by this bundle's servers \ " ~ 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(", ") mcp.secrets.join(", ")
); );
} }
Ok(()) Ok(())
} }
@@ -645,6 +677,7 @@ fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result<Option<
if !is_repo_shorthand(spec) { if !is_repo_shorthand(spec) {
return Ok(None); return Ok(None);
} }
let needle = format!("/{}", canonical_source_url(spec)); let needle = format!("/{}", canonical_source_url(spec));
let candidates: Vec<(String, String)> = store let candidates: Vec<(String, String)> = store
.iter() .iter()
@@ -660,6 +693,7 @@ fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result<Option<
.iter() .iter()
.map(|(name, source)| format!("{name} ({source})")) .map(|(name, source)| format!("{name} ({source})"))
.collect(); .collect();
if !*IS_STDOUT_TERMINAL { if !*IS_STDOUT_TERMINAL {
bail!( bail!(
"'{spec}' matches multiple installed bundles: {}; re-run with the \ "'{spec}' matches multiple installed bundles: {}; re-run with the \
@@ -667,16 +701,19 @@ fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result<Option<
described.join(", ") described.join(", ")
); );
} }
let picked = Select::new( let picked = Select::new(
&format!("Multiple bundles match '{spec}'; which one should be uninstalled?"), &format!("Multiple bundles match '{spec}'; which one should be uninstalled?"),
described.clone(), described.clone(),
) )
.prompt() .prompt()
.with_context(|| "failed to read uninstall selection")?; .with_context(|| "failed to read uninstall selection")?;
let index = described let index = described
.iter() .iter()
.position(|option| option == &picked) .position(|option| option == &picked)
.expect("selection came from the presented options"); .expect("selection came from the presented options");
Ok(Some(candidates[index].0.clone())) Ok(Some(candidates[index].0.clone()))
} }
} }
@@ -711,6 +748,7 @@ fn uninstall_owned_files(
summary.failed += 1; summary.failed += 1;
continue; continue;
} }
let full = config_dir.join(Path::new(&file.path)); let full = config_dir.join(Path::new(&file.path));
if !full.exists() { if !full.exists() {
println!( println!(
@@ -721,11 +759,14 @@ fn uninstall_owned_files(
summary.missing += 1; summary.missing += 1;
continue; continue;
} }
summary.tools_seen |= file.category == "functions/tools"; summary.tools_seen |= file.category == "functions/tools";
if matches!(hash_file(&full), Ok(hash) if hash == file.sha256) { if matches!(hash_file(&full), Ok(hash) if hash == file.sha256) {
delete_owned_file(store, bundle, &file.path, &full, config_dir, &mut summary)?; delete_owned_file(store, bundle, &file.path, &full, config_dir, &mut summary)?;
continue; continue;
} }
let prompt = format!("File {} was modified locally after install", file.path); let prompt = format!("File {} was modified locally after install", file.path);
let action = resolve_uninstall_action(&prompt, assume_yes, &mut sticky)?; let action = resolve_uninstall_action(&prompt, assume_yes, &mut sticky)?;
apply_uninstall_file_action( apply_uninstall_file_action(
@@ -738,6 +779,7 @@ fn uninstall_owned_files(
&mut summary, &mut summary,
)?; )?;
} }
Ok(summary) Ok(summary)
} }
@@ -759,6 +801,7 @@ fn apply_uninstall_file_action(
delete_owned_file(store, bundle, path, full, config_dir, summary)?; delete_owned_file(store, bundle, path, full, config_dir, summary)?;
} }
} }
Ok(()) Ok(())
} }
@@ -797,9 +840,11 @@ fn prune_empty_dirs(full: &Path, config_dir: &Path) {
if dir == config_dir || !dir.starts_with(config_dir) { if dir == config_dir || !dir.starts_with(config_dir) {
break; break;
} }
if fs::remove_dir(dir).is_err() { if fs::remove_dir(dir).is_err() {
break; break;
} }
current = dir.parent(); current = dir.parent();
} }
} }
@@ -812,9 +857,11 @@ fn resolve_uninstall_action(
if assume_yes || !*IS_STDOUT_TERMINAL { if assume_yes || !*IS_STDOUT_TERMINAL {
return Ok(ObsoleteAction::Keep); return Ok(ObsoleteAction::Keep);
} }
if let Some(action) = *sticky { if let Some(action) = *sticky {
return Ok(action); return Ok(action);
} }
let choice = Select::new(prompt, vec!["keep", "delete", "keep-all", "delete-all"]) let choice = Select::new(prompt, vec!["keep", "delete", "keep-all", "delete-all"])
.prompt() .prompt()
.with_context(|| "failed to read uninstall choice")?; .with_context(|| "failed to read uninstall choice")?;
@@ -847,6 +894,7 @@ fn uninstall_mcp_entries(
if servers.is_empty() { if servers.is_empty() {
return Ok(summary); return Ok(summary);
} }
let mut config = if mcp_path.exists() { let mut config = if mcp_path.exists() {
let content = fs::read_to_string(mcp_path) let content = fs::read_to_string(mcp_path)
.with_context(|| format!("failed to read {}", mcp_path.display()))?; .with_context(|| format!("failed to read {}", mcp_path.display()))?;
@@ -887,6 +935,7 @@ fn uninstall_mcp_entries(
summary.kept.push(key); summary.kept.push(key);
continue; continue;
} }
let Some(entry) = config.as_ref().and_then(|cfg| cfg.mcp_servers.get(&key)) else { let Some(entry) = config.as_ref().and_then(|cfg| cfg.mcp_servers.get(&key)) else {
println!("dropped record for absent server '{key}'"); println!("dropped record for absent server '{key}'");
released.push(key); released.push(key);
@@ -934,6 +983,7 @@ fn uninstall_mcp_entries(
for key in &released { for key in &released {
store.remove_mcp_record(bundle, key)?; store.remove_mcp_record(bundle, key)?;
} }
Ok(summary) Ok(summary)
} }
@@ -1155,8 +1205,6 @@ fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> {
Ok(layout) Ok(layout)
} }
const BUNDLE_MANIFEST_FILE: &str = "coyote-bundle.yaml";
#[derive(Debug, Clone, PartialEq, Deserialize)] #[derive(Debug, Clone, PartialEq, Deserialize)]
pub(crate) struct BundleManifest { pub(crate) struct BundleManifest {
pub(crate) name: String, pub(crate) name: String,
@@ -1196,6 +1244,7 @@ fn register_bundle(
homepage: manifest.and_then(|m| m.homepage.clone()), homepage: manifest.and_then(|m| m.homepage.clone()),
}, },
)?; )?;
Ok(resolved.name) Ok(resolved.name)
} }
@@ -1204,12 +1253,14 @@ fn parse_bundle_manifest(root: &Path) -> Result<Option<BundleManifest>> {
if !path.is_file() { if !path.is_file() {
return Ok(None); return Ok(None);
} }
let content = fs::read_to_string(&path) let content = fs::read_to_string(&path)
.with_context(|| format!("failed to read bundle manifest at {}", path.display()))?; .with_context(|| format!("failed to read bundle manifest at {}", path.display()))?;
let manifest: BundleManifest = serde_yaml::from_str(&content) let manifest: BundleManifest = serde_yaml::from_str(&content)
.with_context(|| format!("invalid bundle manifest at {}", path.display()))?; .with_context(|| format!("invalid bundle manifest at {}", path.display()))?;
validate_bundle_name(&manifest.name) validate_bundle_name(&manifest.name)
.with_context(|| format!("invalid bundle name in manifest at {}", path.display()))?; .with_context(|| format!("invalid bundle name in manifest at {}", path.display()))?;
Ok(Some(manifest)) Ok(Some(manifest))
} }
@@ -1224,7 +1275,8 @@ pub(crate) fn validate_bundle_name(name: &str) -> Result<()> {
(as the owner qualifier separator)" (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() { if part.is_empty() {
bail!("Invalid bundle name '{name}': name segments cannot be 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(()) Ok(())
} }
@@ -1285,6 +1338,7 @@ pub(crate) fn owner_qualifier(url: &str) -> Option<String> {
if segments.len() >= 2 { if segments.len() >= 2 {
return Some(segments[segments.len() - 2].to_string()); return Some(segments[segments.len() - 2].to_string());
} }
let sanitized = sanitize_host(&host); let sanitized = sanitize_host(&host);
(!sanitized.is_empty()).then_some(sanitized) (!sanitized.is_empty()).then_some(sanitized)
} }
@@ -1595,6 +1649,7 @@ fn read_full(file: &mut fs::File, buf: &mut [u8]) -> std::io::Result<usize> {
} }
filled += n; filled += n;
} }
Ok(filled) Ok(filled)
} }
@@ -1619,6 +1674,7 @@ fn print_plan_summary(plan: &InstallPlan) {
if refresh > 0 { if refresh > 0 {
line.push_str(&format!(" refresh={refresh}")); line.push_str(&format!(" refresh={refresh}"));
} }
println!("{line}"); 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(), sha256: report.entry_hashes.get(renamed_to).cloned(),
}), }),
); );
if entries.is_empty() { if entries.is_empty() {
return Ok(()); return Ok(());
} }
store.record_mcp_servers(bundle, entries) store.record_mcp_servers(bundle, entries)
} }
+1
View File
@@ -160,6 +160,7 @@ const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml"; const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
pub(crate) const VAULT_DATA_FILE_NAME: &str = "vault.yml"; pub(crate) const VAULT_DATA_FILE_NAME: &str = "vault.yml";
const INSTALLED_BUNDLES_FILE_NAME: &str = "installed-bundles.yaml"; 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 SBX_MIXIN_KITS_DIR_NAME: &str = "sbx-mixin-kits";
const GIT_DIR_NAME: &str = ".git"; const GIT_DIR_NAME: &str = ".git";
const GITIGNORE_FILE_NAME: &str = ".gitignore"; const GITIGNORE_FILE_NAME: &str = ".gitignore";
+7 -7
View File
@@ -11,7 +11,7 @@ use super::{
Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, MacroAllowlistLevel, Input, InstallFilter, LEFT_PROMPT, LastMessage, MESSAGES_FILE_NAME, MacroAllowlistLevel,
MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, RIGHT_PROMPT, ResolvedMacro, Role, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, RIGHT_PROMPT, ResolvedMacro, Role,
RoleLike, SESSIONS_DIR_NAME, SUMMARIZATION_PROMPT, SUMMARY_CONTEXT_PROMPT, StateFlags, 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, list_agents_with_descriptions, memory, paths,
}; };
use super::{MessageContentToolCalls, prompts}; use super::{MessageContentToolCalls, prompts};
@@ -36,6 +36,7 @@ use crate::utils::{
}; };
use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL}; use comfy_table::{ContentArrangement, Table, presets::UTF8_FULL};
use super::install_remote::DEFAULT_GIT_HOST;
use super::instructions; use super::instructions;
use super::memory::{ use super::memory::{
DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory, DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory,
@@ -2803,7 +2804,7 @@ impl RequestContext {
} }
Ok(()) Ok(())
} }
"bundles" => super::bundles::list_installed_bundles(), "bundles" => bundles::list_installed_bundles(),
_ => bail!( _ => bail!(
"Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers, bundles" "Unknown kind '{kind}'. Valid kinds: roles, sessions, agents, rags, macros, skills, tools, mcp-servers, bundles"
), ),
@@ -3288,6 +3289,7 @@ impl RequestContext {
"--yes".to_string(), "--yes".to_string(),
Some("Skip the uninstall confirmation".to_string()), Some("Skip the uninstall confirmation".to_string()),
)); ));
values values
} }
".macro" => { ".macro" => {
@@ -3503,9 +3505,7 @@ impl RequestContext {
InstallFilter::NAMES.iter().map(|s| s.to_string()).collect(), InstallFilter::NAMES.iter().map(|s| s.to_string()).collect(),
); );
} else if prev == "--git-host" { } else if prev == "--git-host" {
values = super::map_completion_values(vec![ values = super::map_completion_values(vec![DEFAULT_GIT_HOST.to_string()]);
super::install_remote::DEFAULT_GIT_HOST.to_string(),
]);
} else { } else {
let has_filter = args.iter().enumerate().any(|(i, a)| { let has_filter = args.iter().enumerate().any(|(i, a)| {
a.starts_with("--filter=") || (*a == "--filter" && i < args.len() - 1) a.starts_with("--filter=") || (*a == "--filter" && i < args.len() - 1)
@@ -4981,7 +4981,7 @@ mod tests {
store store
.upsert_bundle( .upsert_bundle(
"omc", "omc",
crate::config::bundles::InstallMetadata { bundles::InstallMetadata {
source: "https://github.com/x/omc".to_string(), source: "https://github.com/x/omc".to_string(),
git_ref: None, git_ref: None,
commit: "abc123".to_string(), commit: "abc123".to_string(),
@@ -5009,7 +5009,7 @@ mod tests {
store store
.upsert_bundle( .upsert_bundle(
"omc", "omc",
crate::config::bundles::InstallMetadata { bundles::InstallMetadata {
source: "https://github.com/x/omc".to_string(), source: "https://github.com/x/omc".to_string(),
git_ref: None, git_ref: None,
commit: "abc123".to_string(), commit: "abc123".to_string(),
+2 -2
View File
@@ -165,9 +165,9 @@ pub(crate) fn write_file_atomic(
std::process::id(), std::process::id(),
TMP_COUNTER.fetch_add(1, Ordering::Relaxed) TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
)); ));
let write_synced = || -> std::io::Result<()> { let write_synced = || -> io::Result<()> {
use std::io::Write; use std::io::Write;
let mut file = fs::File::create(&tmp)?; let mut file = File::create(&tmp)?;
file.write_all(content.as_bytes())?; file.write_all(content.as_bytes())?;
file.sync_all() file.sync_all()
}; };