Merge pull request #17 from Dark-Alex-17/feat/bundle-provenance

feat: bundle manifest, provenance, and lifecycle for shared configurations
This commit is contained in:
Alex Clarke
2026-08-24 11:23:25 -06:00
committed by GitHub
10 changed files with 5254 additions and 117 deletions
+1 -1
View File
@@ -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.
* [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.
+173 -13
View File
@@ -50,9 +50,10 @@ 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_builtins", "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", "update_bundle", "uninstall",
])
),
group(
@@ -175,34 +176,79 @@ 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
/// 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 = "CATEGORY",
value_enum,
value_name = "GIT_URL|OWNER/REPO|NAME",
conflicts_with_all = ["install_builtins", "update_bundle", "uninstall"],
help_heading = "Installation & Updates"
)]
pub install: Option<AssetCategory>,
/// Install assets from a remote git repository (URL may be suffixed with #<ref>)
#[arg(long, value_name = "GIT_URL", help_heading = "Installation & Updates")]
pub install_from: Option<String>,
/// Restrict --install-from to a single asset category
pub install: Option<String>,
/// 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 for a category (asks before overwriting your local changes)
#[arg(
long,
value_name = "CATEGORY",
value_enum,
requires = "install_from",
conflicts_with_all = ["update_bundle", "uninstall"],
help_heading = "Installation & Updates"
)]
pub install_builtins: Option<AssetCategory>,
/// Restrict a remote install to a single asset category
#[arg(
long,
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-from)
/// Overwrite all conflicts without prompting (remote installs only)
#[arg(
long,
requires = "install_from",
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",
group = "yes_scope",
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",
group = "yes_scope",
help_heading = "Installation & Updates"
)]
pub uninstall: Option<String>,
/// 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,
/// Sync models updates
#[arg(long, help_heading = "Installation & Updates")]
pub sync_models: bool,
@@ -495,6 +541,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]
@@ -503,6 +550,119 @@ 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_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_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());
}
#[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_builtins_conflicts_with_install() {
assert!(
Cli::try_parse_from(["coyote", "--install-builtins", "agents", "--install", "x"])
.is_err()
);
}
#[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());
assert_eq!(
parse(&["--install", "https://github.com/x/y", "--filter", "agents"]).filter,
Some(InstallFilter::Agents)
);
}
#[test]
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 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
.as_deref(),
Some("git.x.com")
);
}
#[test]
fn help_shows_install_builtins() {
use clap::CommandFactory;
let help = Cli::command().render_long_help().to_string();
assert!(help.contains("--install-builtins"));
}
#[test]
fn parse_multiple_skill_flags_preserves_order() {
assert_eq!(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -1,6 +1,7 @@
mod agent;
mod app_config;
mod app_state;
mod bundles;
mod input;
mod install_remote;
pub(crate) mod instructions;
@@ -29,8 +30,11 @@ 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::install_remote::{
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,
};
@@ -155,6 +159,8 @@ 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 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";
+8 -4
View File
@@ -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),
+100 -5
View File
@@ -1,3 +1,4 @@
use super::bundles::BundleStore;
use super::rag_cache::{RagCache, RagKey};
use super::session::Session;
use super::skill::{SKILL_SCAFFOLD, Skill};
@@ -10,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};
@@ -35,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,
@@ -58,6 +60,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<String> {
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,
@@ -112,7 +130,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 +2804,9 @@ impl RequestContext {
}
Ok(())
}
"bundles" => 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"
),
}
}
@@ -3261,9 +3280,18 @@ impl RequestContext {
".install" => {
let mut values: Vec<String> =
AssetCategory::NAMES.iter().map(|s| s.to_string()).collect();
values.push("remote".to_string());
values.extend(installed_bundle_names());
super::map_completion_values(values)
}
".uninstall" => {
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();
let mut values: Vec<(String, Option<String>)> = policy
@@ -3327,6 +3355,7 @@ impl RequestContext {
"skills",
"tools",
"mcp-servers",
"bundles",
]),
".vault" => {
let mut values = vec!["add", "get", "update", "delete", "list"];
@@ -3469,17 +3498,22 @@ 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(
InstallFilter::NAMES.iter().map(|s| s.to_string()).collect(),
);
} else if prev == "--git-host" {
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)
});
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 {
@@ -3488,6 +3522,9 @@ impl RequestContext {
if !has_force {
available.push("--force");
}
if !has_git_host {
available.push("--git-host");
}
values = super::map_completion_values(available);
}
@@ -4936,6 +4973,64 @@ 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",
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 repl_complete_install_offers_categories_and_bundles() {
let _guard = TestConfigDirGuard::new();
let mut store = BundleStore::load().unwrap();
store
.upsert_bundle(
"omc",
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", "omc"] {
assert!(
values.iter().any(|(name, _)| name == expected),
"missing '{expected}'; got: {values:?}"
);
}
}
#[test]
#[serial]
fn exit_agent_clears_all_agent_state() {
+12 -4
View File
@@ -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 = || -> io::Result<()> {
use std::io::Write;
let mut file = 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 {
@@ -484,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(", "));
+20 -3
View File
@@ -127,14 +127,31 @@ 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 {
if let Some(category) = cli.install_builtins {
return config::install_assets(category);
}
if let Some(url) = cli.install_from.as_deref() {
return config::install_remote(url, cli.filter, cli.install_force);
if let Some(value) = cli.install.as_deref() {
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() {
return config::update_bundle(spec, cli.yes);
}
if let Some(name) = cli.uninstall.as_deref() {
return config::uninstall_bundle(name, cli.yes);
}
if let Some(client_arg) = &cli.authenticate {
+102 -26
View File
@@ -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()),
@@ -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(
@@ -317,7 +317,12 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 60]> = LazyLock::new(|| {
),
ReplCommand::new(
".install",
"Reinstall bundled assets, or install assets from a remote git repo (.install remote <url>)",
"Reinstall bundled assets, install a bundle from a git repo, or update an installed bundle",
AssertState::pass(),
),
ReplCommand::new(
".uninstall",
"Uninstall an installed bundle (delete its owned files and MCP entries)",
AssertState::pass(),
),
ReplCommand::new(
@@ -865,27 +870,17 @@ 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 <git-url>",
AssetCategory::NAMES.join("|")
),
".install" => match parse_repl_install(args) {
ReplInstallDispatch::Builtins(category) => config::install_assets(category)?,
ReplInstallDispatch::Unified(value) => {
config::install_or_update_from_repl_args(value)?;
}
}
ReplInstallDispatch::Usage => println!(
"Usage: .install <{}> | .install <git-url|owner/repo|installed-bundle> \
[--git-host <host>] [--filter <cat>] [--force]",
AssetCategory::NAMES.join("|")
),
},
".update" => {
if ctx.macro_flag {
bail!("Cannot perform this operation because you are in a macro")
@@ -1202,13 +1197,17 @@ pub async fn run_repl_command(
println!("Usage: .delete <role|session|rag|macro|skill|agent-data>")
}
},
".uninstall" => match parse_repl_uninstall(args) {
Some((name, assume_yes)) => config::uninstall_bundle(&name, assume_yes)?,
None => println!("Usage: .uninstall <bundle-name> [--yes]"),
},
".list" => match args {
Some(args) => {
ctx.list_assets(args.trim())?;
}
_ => {
println!(
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers>"
"Usage: .list <roles|sessions|agents|rags|macros|skills|tools|mcp-servers|bundles>"
)
}
},
@@ -1570,6 +1569,45 @@ fn unknown_command() -> Result<()> {
bail!(r#"Unknown command. Type ".help" for additional help."#);
}
#[derive(Debug, PartialEq)]
enum ReplInstallDispatch<'a> {
Builtins(AssetCategory),
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(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 => ReplInstallDispatch::Unified(trimmed),
}
}
_ => ReplInstallDispatch::Usage,
}
}
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()
@@ -1791,8 +1829,46 @@ 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]
fn parse_repl_install_routes_categories_to_builtins() {
assert_eq!(
parse_repl_install(Some("agents")),
ReplInstallDispatch::Builtins(AssetCategory::Agents)
);
}
#[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 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]