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.
This commit is contained in:
+63
-10
@@ -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 #<ref>), or update an already-installed bundle by name
|
||||
/// Install assets from a remote git repository (a URL or <owner>/<repo> shorthand, optionally suffixed with #<ref>), 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<String>,
|
||||
/// Git host used to expand <owner>/<repo> shorthand values passed to --install
|
||||
/// Git host used to expand <owner>/<repo> 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<String>,
|
||||
/// Reinstall bundled assets, overwriting any local changes
|
||||
/// Removed; use --install <GIT_URL> instead
|
||||
#[arg(
|
||||
long,
|
||||
hide = true,
|
||||
value_name = "GIT_URL",
|
||||
help_heading = "Installation & Updates"
|
||||
)]
|
||||
pub install_from: Option<String>,
|
||||
/// 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<AssetCategory>,
|
||||
@@ -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<InstallFilter>,
|
||||
/// 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 #<ref> 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user