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:
@@ -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
@@ -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
+3342
-60
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user