feat: rename install flags and unify .install dispatch
--install now takes a git URL or an installed bundle name: categories are redirected to the new --install-builtins, installed names become implicit updates, and source-shaped values install remotely. The old --install-from keeps its exact behavior as a hidden deprecated alias. The REPL's .install gains the same unified dispatch while keeping .install <category> and .install remote <url> back-compat.
This commit is contained in:
@@ -542,7 +542,7 @@ pub fn list_installed_bundles() -> Result<()> {
|
||||
let store = BundleStore::load()?;
|
||||
let rows = bundle_list_rows(&store, &paths::config_dir());
|
||||
if rows.is_empty() {
|
||||
println!("No bundles installed. Install one with `coyote --install-from <git-url>`.");
|
||||
println!("No bundles installed. Install one with `coyote --install <git-url>`.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::bundles::{
|
||||
BundleStore, FileAction, FileRecord, InstallMetadata, McpAction, McpServerRecord, hash_bytes,
|
||||
hash_file,
|
||||
};
|
||||
use crate::config::{InstallFilter, paths};
|
||||
use crate::config::{AssetCategory, InstallFilter, paths};
|
||||
#[cfg(not(windows))]
|
||||
use crate::function::Language;
|
||||
use crate::mcp::{McpServer, McpServersConfig};
|
||||
@@ -10,6 +10,7 @@ use crate::utils;
|
||||
use crate::utils::IS_STDOUT_TERMINAL;
|
||||
use crate::vault::{Vault, create_vault_password_file, interpolate_secrets};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use clap::ValueEnum;
|
||||
use indexmap::IndexMap;
|
||||
use indoc::formatdoc;
|
||||
use inquire::{Confirm, Select};
|
||||
@@ -80,6 +81,14 @@ pub fn install_remote_from_repl_args(args: &str) -> Result<()> {
|
||||
)
|
||||
})?;
|
||||
|
||||
let (filter, force) = parse_repl_install_flags(".install remote", iter)?;
|
||||
install_remote(&url, filter, force)
|
||||
}
|
||||
|
||||
fn parse_repl_install_flags(
|
||||
command: &str,
|
||||
mut iter: impl Iterator<Item = String>,
|
||||
) -> Result<(Option<InstallFilter>, bool)> {
|
||||
let mut filter: Option<InstallFilter> = None;
|
||||
let mut force = false;
|
||||
|
||||
@@ -98,11 +107,117 @@ pub fn install_remote_from_repl_args(args: &str) -> Result<()> {
|
||||
s if s.starts_with("--filter=") => {
|
||||
filter = Some(parse_filter(&s["--filter=".len()..])?);
|
||||
}
|
||||
other => bail!("Unexpected argument to '.install remote': {other}"),
|
||||
other => bail!("Unexpected argument to '{command}': {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
install_remote(&url, filter, force)
|
||||
Ok((filter, force))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum InstallTarget {
|
||||
Category(AssetCategory),
|
||||
InstalledBundle,
|
||||
RemoteSource,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn classify_install_target(value: &str, installed_names: &[String]) -> InstallTarget {
|
||||
if let Some(category) = AssetCategory::parse(value) {
|
||||
return InstallTarget::Category(category);
|
||||
}
|
||||
let name = strip_ref_suffix(value);
|
||||
if installed_names.iter().any(|installed| installed == name) {
|
||||
return InstallTarget::InstalledBundle;
|
||||
}
|
||||
if looks_like_remote_source(value) {
|
||||
return InstallTarget::RemoteSource;
|
||||
}
|
||||
InstallTarget::Unknown
|
||||
}
|
||||
|
||||
/// A value is treated as a source when it is a URL, an scp-style
|
||||
/// `[user@]host:path`, or an explicit local path. Bare names never are: they
|
||||
/// must match an installed bundle.
|
||||
fn looks_like_remote_source(value: &str) -> bool {
|
||||
if value.contains("://")
|
||||
|| value.starts_with("./")
|
||||
|| value.starts_with("../")
|
||||
|| value.starts_with('/')
|
||||
|| value.starts_with('~')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
match value.split_once(':') {
|
||||
Some((host, _)) => {
|
||||
!host.is_empty() && !host.contains('/') && !host.chars().any(char::is_whitespace)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified entry point behind `--install` and the REPL's `.install <value>`:
|
||||
/// asset categories are redirected to `--install-builtins`, installed bundle
|
||||
/// names become updates, and anything shaped like a source is installed.
|
||||
pub fn install_or_update(value: &str, filter: Option<InstallFilter>, force: bool) -> Result<()> {
|
||||
let store = BundleStore::load()?;
|
||||
let installed: Vec<String> = store
|
||||
.bundle_names()
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
|
||||
match classify_install_target(value, &installed) {
|
||||
InstallTarget::Category(category) => {
|
||||
let name = category
|
||||
.to_possible_value()
|
||||
.map_or_else(|| value.to_string(), |v| v.get_name().to_string());
|
||||
let update_hint = if installed.iter().any(|installed| installed == value) {
|
||||
format!(" To update the installed bundle '{value}', use `--update-bundle {value}`.")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
bail!(
|
||||
"'{value}' is an asset category, not a bundle; did you mean \
|
||||
`--install-builtins {name}`? (categories are reinstalled from \
|
||||
the assets built into coyote, not from a remote){update_hint}"
|
||||
);
|
||||
}
|
||||
InstallTarget::InstalledBundle => {
|
||||
if filter.is_some() || force {
|
||||
bail!("--filter/--install-force only apply to remote installs, not bundle updates");
|
||||
}
|
||||
update_bundle(value)
|
||||
}
|
||||
InstallTarget::RemoteSource => install_remote(value, filter, force),
|
||||
InstallTarget::Unknown => {
|
||||
let hint = "a remote source must be a git URL, an scp-style host:path, \
|
||||
or an explicit local path (./dir, /abs, ~)";
|
||||
if installed.is_empty() {
|
||||
bail!("no bundle named '{value}' is installed; none are installed ({hint})");
|
||||
}
|
||||
bail!(
|
||||
"no bundle named '{value}' is installed; installed bundles: {} ({hint})",
|
||||
installed.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_or_update_from_repl_args(args: &str) -> Result<()> {
|
||||
let tokens = shell_words::split(args)
|
||||
.with_context(|| format!("failed to parse '.install' args: {args}"))?;
|
||||
|
||||
let mut iter = tokens.into_iter();
|
||||
let value = iter.next().with_context(|| {
|
||||
format!(
|
||||
"Usage: .install <git-url|installed-bundle> [--filter <{}>] [--force]",
|
||||
InstallFilter::NAMES.join("|")
|
||||
)
|
||||
})?;
|
||||
|
||||
let (filter, force) = parse_repl_install_flags(".install", iter)?;
|
||||
install_or_update(&value, filter, force)
|
||||
}
|
||||
|
||||
/// Update an installed bundle from its recorded source. `spec` is the bundle
|
||||
@@ -3461,6 +3576,160 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&src_root);
|
||||
}
|
||||
|
||||
fn owned_names(names: &[&str]) -> Vec<String> {
|
||||
names.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_urls_and_paths_as_remote_sources() {
|
||||
for value in [
|
||||
"https://github.com/x/y",
|
||||
"git@github.com:x/y.git",
|
||||
"./bundle-dir",
|
||||
"../up",
|
||||
"/abs/path",
|
||||
"~/home-rel",
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_install_target(value, &[]),
|
||||
InstallTarget::RemoteSource,
|
||||
"value: {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_categories_win_over_installed_names() {
|
||||
assert_eq!(
|
||||
classify_install_target("agents", &owned_names(&["agents"])),
|
||||
InstallTarget::Category(AssetCategory::Agents)
|
||||
);
|
||||
assert_eq!(
|
||||
classify_install_target("mcp_config", &[]),
|
||||
InstallTarget::Category(AssetCategory::McpConfig)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_installed_names_with_optional_ref() {
|
||||
let installed = owned_names(&["omc"]);
|
||||
assert_eq!(
|
||||
classify_install_target("omc", &installed),
|
||||
InstallTarget::InstalledBundle
|
||||
);
|
||||
assert_eq!(
|
||||
classify_install_target("omc#v2", &installed),
|
||||
InstallTarget::InstalledBundle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_bare_names_without_a_match_as_unknown() {
|
||||
assert_eq!(classify_install_target("omc", &[]), InstallTarget::Unknown);
|
||||
assert_eq!(
|
||||
classify_install_target("not-a-bundle", &owned_names(&["omc"])),
|
||||
InstallTarget::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn install_or_update_redirects_categories_to_install_builtins() {
|
||||
let _guard = TestVaultConfigGuard::new("iou-category");
|
||||
|
||||
let err = install_or_update("agents", None, false).unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("--install-builtins agents"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn install_or_update_unknown_name_lists_installed_bundles() {
|
||||
let _guard = TestVaultConfigGuard::new("iou-unknown");
|
||||
|
||||
let err = install_or_update("not-a-bundle", None, false).unwrap_err();
|
||||
assert!(err.to_string().contains("none are installed"), "got: {err}");
|
||||
|
||||
let src_root = fresh_temp_dir("iou-unknown-src-");
|
||||
let repo = src_root.join("bundle");
|
||||
write_src(&repo, BUNDLE_MANIFEST_FILE, "name: known-bundle\n");
|
||||
write_src(&repo, "macros/m.yaml", "a: 1\n");
|
||||
init_bundle_repo(&repo);
|
||||
install_remote(repo.to_str().unwrap(), None, false).unwrap();
|
||||
|
||||
let err = install_or_update("not-a-bundle", None, false).unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("no bundle named 'not-a-bundle'"),
|
||||
"got: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("installed bundles: known-bundle"),
|
||||
"got: {err}"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&src_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn install_or_update_category_error_hints_update_for_shadowed_bundle() {
|
||||
let _guard = TestVaultConfigGuard::new("iou-shadow");
|
||||
|
||||
let mut store = BundleStore::load().unwrap();
|
||||
store
|
||||
.upsert_bundle(
|
||||
"agents",
|
||||
InstallMetadata {
|
||||
source: "https://github.com/x/agents".to_string(),
|
||||
git_ref: None,
|
||||
commit: "abc123".to_string(),
|
||||
version: None,
|
||||
description: None,
|
||||
homepage: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = install_or_update("agents", None, false).unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("--install-builtins agents"),
|
||||
"got: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.to_string().contains("--update-bundle agents"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn install_or_update_rejects_remote_flags_for_updates() {
|
||||
let _guard = TestVaultConfigGuard::new("iou-flags");
|
||||
let src_root = fresh_temp_dir("iou-flags-src-");
|
||||
let repo = src_root.join("bundle");
|
||||
write_src(&repo, BUNDLE_MANIFEST_FILE, "name: flag-bundle\n");
|
||||
write_src(&repo, "macros/m.yaml", "a: 1\n");
|
||||
init_bundle_repo(&repo);
|
||||
install_remote(repo.to_str().unwrap(), None, false).unwrap();
|
||||
|
||||
let err = install_or_update("flag-bundle", Some(InstallFilter::Macros), false).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("only apply to remote installs"),
|
||||
"got: {err}"
|
||||
);
|
||||
|
||||
let err = install_or_update("flag-bundle", None, true).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("only apply to remote installs"),
|
||||
"got: {err}"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&src_root);
|
||||
}
|
||||
|
||||
fn owned_file_record(path: &str, contents: &str) -> FileRecord {
|
||||
FileRecord {
|
||||
path: path.to_string(),
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@ 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, uninstall_bundle, update_bundle,
|
||||
install_or_update, install_or_update_from_repl_args, install_remote,
|
||||
install_remote_from_repl_args, uninstall_bundle, update_bundle,
|
||||
};
|
||||
pub use self::macro_policy::{
|
||||
MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro,
|
||||
|
||||
@@ -3264,6 +3264,17 @@ impl RequestContext {
|
||||
let mut values: Vec<String> =
|
||||
AssetCategory::NAMES.iter().map(|s| s.to_string()).collect();
|
||||
values.push("remote".to_string());
|
||||
values.extend(
|
||||
BundleStore::load()
|
||||
.map(|store| {
|
||||
store
|
||||
.bundle_names()
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
super::map_completion_values(values)
|
||||
}
|
||||
".uninstall" => {
|
||||
@@ -4979,6 +4990,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn repl_complete_install_offers_categories_remote_and_bundles() {
|
||||
let _guard = TestConfigDirGuard::new();
|
||||
let mut store = BundleStore::load().unwrap();
|
||||
store
|
||||
.upsert_bundle(
|
||||
"omc",
|
||||
crate::config::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", "remote", "omc"] {
|
||||
assert!(
|
||||
values.iter().any(|(name, _)| name == expected),
|
||||
"missing '{expected}'; got: {values:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn exit_agent_clears_all_agent_state() {
|
||||
|
||||
Reference in New Issue
Block a user