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.
|
* [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
|
* [Installation](#install): Install Coyote
|
||||||
* [Getting Started](#getting-started): Get started with Coyote by doing first-run setup steps.
|
* [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.
|
* [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.
|
* [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.
|
* [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",
|
"model", "prompt", "role", "session", "agent", "rag", "rebuild_rag",
|
||||||
"macro_name", "execute", "code", "file", "no_stream", "no_memory",
|
"macro_name", "execute", "code", "file", "no_stream", "no_memory",
|
||||||
"init_memory", "dry_run", "info", "build_tools", "install",
|
"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_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(
|
group(
|
||||||
@@ -175,34 +176,79 @@ pub struct Cli {
|
|||||||
/// List all installed skills
|
/// List all installed skills
|
||||||
#[arg(long, help_heading = "List & Discovery")]
|
#[arg(long, help_heading = "List & Discovery")]
|
||||||
pub list_skills: bool,
|
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(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "CATEGORY",
|
value_name = "GIT_URL|OWNER/REPO|NAME",
|
||||||
value_enum,
|
conflicts_with_all = ["install_builtins", "update_bundle", "uninstall"],
|
||||||
help_heading = "Installation & Updates"
|
help_heading = "Installation & Updates"
|
||||||
)]
|
)]
|
||||||
pub install: Option<AssetCategory>,
|
pub install: Option<String>,
|
||||||
/// Install assets from a remote git repository (URL may be suffixed with #<ref>)
|
/// 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 = "GIT_URL", help_heading = "Installation & Updates")]
|
#[arg(
|
||||||
pub install_from: Option<String>,
|
long,
|
||||||
/// Restrict --install-from to a single asset category
|
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(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
value_name = "CATEGORY",
|
value_name = "CATEGORY",
|
||||||
value_enum,
|
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"
|
help_heading = "Installation & Updates"
|
||||||
)]
|
)]
|
||||||
pub filter: Option<InstallFilter>,
|
pub filter: Option<InstallFilter>,
|
||||||
/// Overwrite all conflicts without prompting (used with --install-from)
|
/// Overwrite all conflicts without prompting (remote installs only)
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
requires = "install_from",
|
requires = "install",
|
||||||
|
conflicts_with_all = ["install_builtins", "update_bundle", "uninstall"],
|
||||||
help_heading = "Installation & Updates"
|
help_heading = "Installation & Updates"
|
||||||
)]
|
)]
|
||||||
pub install_force: bool,
|
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
|
/// Sync models updates
|
||||||
#[arg(long, help_heading = "Installation & Updates")]
|
#[arg(long, help_heading = "Installation & Updates")]
|
||||||
pub sync_models: bool,
|
pub sync_models: bool,
|
||||||
@@ -495,6 +541,7 @@ mod tests {
|
|||||||
assert!(parse(&["--list-rags"]).list_rags);
|
assert!(parse(&["--list-rags"]).list_rags);
|
||||||
assert!(parse(&["--list-macros"]).list_macros);
|
assert!(parse(&["--list-macros"]).list_macros);
|
||||||
assert!(parse(&["--list-skills"]).list_skills);
|
assert!(parse(&["--list-skills"]).list_skills);
|
||||||
|
assert!(parse(&["--list-bundles"]).list_bundles);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -503,6 +550,119 @@ mod tests {
|
|||||||
assert!(parse(&[]).skill.is_empty());
|
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]
|
#[test]
|
||||||
fn parse_multiple_skill_flags_preserves_order() {
|
fn parse_multiple_skill_flags_preserves_order() {
|
||||||
assert_eq!(
|
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 agent;
|
||||||
mod app_config;
|
mod app_config;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
|
mod bundles;
|
||||||
mod input;
|
mod input;
|
||||||
mod install_remote;
|
mod install_remote;
|
||||||
pub(crate) mod instructions;
|
pub(crate) mod instructions;
|
||||||
@@ -29,8 +30,11 @@ pub use self::agent::{
|
|||||||
pub use self::app_config::AppConfig;
|
pub use self::app_config::AppConfig;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::app_state::AppState;
|
pub use self::app_state::AppState;
|
||||||
|
pub use self::bundles::list_installed_bundles;
|
||||||
pub use self::input::Input;
|
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::{
|
pub use self::macro_policy::{
|
||||||
MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro,
|
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_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 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";
|
||||||
|
|||||||
+8
-4
@@ -2,10 +2,10 @@ use super::role::Role;
|
|||||||
use super::{
|
use super::{
|
||||||
AGENT_GRAPH_FILE_NAME, AGENTS_DIR_NAME, BASH_PROMPT_UTILS_FILE_NAME, CONFIG_FILE_NAME,
|
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,
|
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,
|
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, INSTALLED_BUNDLES_FILE_NAME,
|
||||||
MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride, RAGS_DIR_NAME, ROLES_DIR_NAME,
|
MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride,
|
||||||
SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME, SBX_MIXIN_KITS_DIR_NAME,
|
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
|
||||||
SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
|
||||||
};
|
};
|
||||||
use crate::client::ProviderModels;
|
use crate::client::ProviderModels;
|
||||||
use crate::config::REPL_HISTORY_DIR_NAME;
|
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 {
|
pub fn roles_dir() -> PathBuf {
|
||||||
match env::var(get_env_name("roles_dir")) {
|
match env::var(get_env_name("roles_dir")) {
|
||||||
Ok(value) => PathBuf::from(value),
|
Ok(value) => PathBuf::from(value),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::bundles::BundleStore;
|
||||||
use super::rag_cache::{RagCache, RagKey};
|
use super::rag_cache::{RagCache, RagKey};
|
||||||
use super::session::Session;
|
use super::session::Session;
|
||||||
use super::skill::{SKILL_SCAFFOLD, Skill};
|
use super::skill::{SKILL_SCAFFOLD, Skill};
|
||||||
@@ -10,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};
|
||||||
@@ -35,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,
|
||||||
@@ -58,6 +60,22 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{env, fs};
|
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 struct AutoContinueConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub max_continues: usize,
|
pub max_continues: usize,
|
||||||
@@ -112,7 +130,7 @@ fn print_asset_names(kind: &str, names: &[String]) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn asset_table(header: &[&str]) -> Table {
|
pub(crate) fn asset_table(header: &[&str]) -> Table {
|
||||||
let mut table = Table::new();
|
let mut table = Table::new();
|
||||||
table.load_preset(UTF8_FULL);
|
table.load_preset(UTF8_FULL);
|
||||||
table.set_content_arrangement(ContentArrangement::Dynamic);
|
table.set_content_arrangement(ContentArrangement::Dynamic);
|
||||||
@@ -2786,8 +2804,9 @@ impl RequestContext {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
"bundles" => bundles::list_installed_bundles(),
|
||||||
_ => bail!(
|
_ => 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" => {
|
".install" => {
|
||||||
let mut values: Vec<String> =
|
let mut values: Vec<String> =
|
||||||
AssetCategory::NAMES.iter().map(|s| s.to_string()).collect();
|
AssetCategory::NAMES.iter().map(|s| s.to_string()).collect();
|
||||||
values.push("remote".to_string());
|
values.extend(installed_bundle_names());
|
||||||
super::map_completion_values(values)
|
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" => {
|
".macro" => {
|
||||||
let policy = self.macro_policy();
|
let policy = self.macro_policy();
|
||||||
let mut values: Vec<(String, Option<String>)> = policy
|
let mut values: Vec<(String, Option<String>)> = policy
|
||||||
@@ -3327,6 +3355,7 @@ impl RequestContext {
|
|||||||
"skills",
|
"skills",
|
||||||
"tools",
|
"tools",
|
||||||
"mcp-servers",
|
"mcp-servers",
|
||||||
|
"bundles",
|
||||||
]),
|
]),
|
||||||
".vault" => {
|
".vault" => {
|
||||||
let mut values = vec!["add", "get", "update", "delete", "list"];
|
let mut values = vec!["add", "get", "update", "delete", "list"];
|
||||||
@@ -3469,17 +3498,22 @@ impl RequestContext {
|
|||||||
values = complete_skills_with_descriptions(paths::list_skills());
|
values = complete_skills_with_descriptions(paths::list_skills());
|
||||||
} else if cmd == ".skill" && args.first() == Some(&"unload") && args.len() == 2 {
|
} else if cmd == ".skill" && args.first() == Some(&"unload") && args.len() == 2 {
|
||||||
values = complete_skills_with_descriptions(self.skill_registry.loaded_names());
|
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("");
|
let prev = args.get(args.len() - 2).copied().unwrap_or("");
|
||||||
if prev == "--filter" {
|
if prev == "--filter" {
|
||||||
values = super::map_completion_values(
|
values = super::map_completion_values(
|
||||||
InstallFilter::NAMES.iter().map(|s| s.to_string()).collect(),
|
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 {
|
} 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)
|
||||||
});
|
});
|
||||||
let has_force = args.contains(&"--force");
|
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![];
|
let mut available: Vec<&str> = vec![];
|
||||||
|
|
||||||
if !has_filter {
|
if !has_filter {
|
||||||
@@ -3488,6 +3522,9 @@ impl RequestContext {
|
|||||||
if !has_force {
|
if !has_force {
|
||||||
available.push("--force");
|
available.push("--force");
|
||||||
}
|
}
|
||||||
|
if !has_git_host {
|
||||||
|
available.push("--git-host");
|
||||||
|
}
|
||||||
|
|
||||||
values = super::map_completion_values(available);
|
values = super::map_completion_values(available);
|
||||||
}
|
}
|
||||||
@@ -4936,6 +4973,64 @@ mod tests {
|
|||||||
assert!(!ctx.maybe_autoname_session());
|
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]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn exit_agent_clears_all_agent_state() {
|
fn exit_agent_clears_all_agent_state() {
|
||||||
|
|||||||
+12
-4
@@ -165,7 +165,16 @@ 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)
|
||||||
));
|
));
|
||||||
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)]
|
#[cfg(unix)]
|
||||||
if let Some(mode) = mode {
|
if let Some(mode) = mode {
|
||||||
@@ -484,9 +493,8 @@ impl Functions {
|
|||||||
|
|
||||||
let serialized =
|
let serialized =
|
||||||
serde_json::to_string_pretty(&merged).context("failed to serialize merged mcp.json")?;
|
serde_json::to_string_pretty(&merged).context("failed to serialize merged mcp.json")?;
|
||||||
let tmp = file_path.with_extension("json.tmp");
|
write_file_atomic(&file_path, &serialized, None)
|
||||||
fs::write(&tmp, &serialized).context("failed to write temporary mcp.json")?;
|
.context("failed to write merged mcp.json")?;
|
||||||
fs::rename(&tmp, &file_path).context("failed to finalize mcp.json")?;
|
|
||||||
|
|
||||||
if !added.is_empty() {
|
if !added.is_empty() {
|
||||||
println!(" + new MCP servers: {}", added.join(", "));
|
println!(" + new MCP servers: {}", added.join(", "));
|
||||||
|
|||||||
+20
-3
@@ -127,14 +127,31 @@ async fn main() -> Result<()> {
|
|||||||
return sandbox::launch(name.clone(), cli.fresh);
|
return sandbox::launch(name.clone(), cli.fresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cli.list_bundles {
|
||||||
|
return config::list_installed_bundles();
|
||||||
|
}
|
||||||
|
|
||||||
install_builtins()?;
|
install_builtins()?;
|
||||||
|
|
||||||
if let Some(category) = cli.install {
|
if let Some(category) = cli.install_builtins {
|
||||||
return config::install_assets(category);
|
return config::install_assets(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(url) = cli.install_from.as_deref() {
|
if let Some(value) = cli.install.as_deref() {
|
||||||
return config::install_remote(url, cli.filter, cli.install_force);
|
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 {
|
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."
|
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(".help", "Show this help guide", AssertState::pass()),
|
||||||
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
ReplCommand::new(".info", "Show system info", AssertState::pass()),
|
||||||
@@ -307,7 +307,7 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 60]> = LazyLock::new(|| {
|
|||||||
),
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".list",
|
".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(),
|
AssertState::pass(),
|
||||||
),
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
@@ -317,7 +317,12 @@ static REPL_COMMANDS: LazyLock<[ReplCommand; 60]> = LazyLock::new(|| {
|
|||||||
),
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
".install",
|
".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(),
|
AssertState::pass(),
|
||||||
),
|
),
|
||||||
ReplCommand::new(
|
ReplCommand::new(
|
||||||
@@ -865,27 +870,17 @@ pub async fn run_repl_command(
|
|||||||
replay::render(app.as_ref(), &compressed, &active)?;
|
replay::render(app.as_ref(), &compressed, &active)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
".install" => {
|
".install" => match parse_repl_install(args) {
|
||||||
let trimmed = args.map(str::trim).unwrap_or("");
|
ReplInstallDispatch::Builtins(category) => config::install_assets(category)?,
|
||||||
let mut parts = trimmed.splitn(2, char::is_whitespace);
|
ReplInstallDispatch::Unified(value) => {
|
||||||
match parts.next() {
|
config::install_or_update_from_repl_args(value)?;
|
||||||
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("|")
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
ReplInstallDispatch::Usage => println!(
|
||||||
|
"Usage: .install <{}> | .install <git-url|owner/repo|installed-bundle> \
|
||||||
|
[--git-host <host>] [--filter <cat>] [--force]",
|
||||||
|
AssetCategory::NAMES.join("|")
|
||||||
|
),
|
||||||
|
},
|
||||||
".update" => {
|
".update" => {
|
||||||
if ctx.macro_flag {
|
if ctx.macro_flag {
|
||||||
bail!("Cannot perform this operation because you are in a macro")
|
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>")
|
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 {
|
".list" => match args {
|
||||||
Some(args) => {
|
Some(args) => {
|
||||||
ctx.list_assets(args.trim())?;
|
ctx.list_assets(args.trim())?;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!(
|
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."#);
|
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> {
|
pub fn builtin_command_names() -> Vec<&'static str> {
|
||||||
let mut names: Vec<&'static str> = REPL_COMMANDS
|
let mut names: Vec<&'static str> = REPL_COMMANDS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1791,8 +1829,46 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_commands_has_60_entries() {
|
fn repl_commands_has_61_entries() {
|
||||||
assert_eq!(REPL_COMMANDS.len(), 60);
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user