feat: expand owner/repo shorthand for --install with a --git-host flag

--install someuser/repo expands to https://github.com/someuser/repo;
--git-host overrides the default host and forces source interpretation
even when the value matches an installed bundle name. Two or more path
segments are accepted so nested GitLab-style groups work, and #ref
pinning applies to shorthand values. --uninstall resolves owner/repo
against recorded sources: a single match uninstalls, multiple matches
prompt an interactive selection showing each bundle's source, and
non-interactive runs bail instead of guessing.
This commit is contained in:
2026-08-24 11:15:13 -06:00
parent 2d1bf372d8
commit 53ccbda97c
5 changed files with 327 additions and 26 deletions
+19
View File
@@ -187,6 +187,14 @@ pub struct Cli {
help_heading = "Installation & Updates" help_heading = "Installation & Updates"
)] )]
pub install: Option<String>, pub install: Option<String>,
/// Git host used to expand <owner>/<repo> shorthand values passed to --install
#[arg(
long,
value_name = "HOST",
requires = "install",
help_heading = "Installation & Updates"
)]
pub git_host: Option<String>,
/// Reinstall bundled assets, overwriting any local changes /// Reinstall bundled assets, overwriting any local changes
#[arg( #[arg(
long, long,
@@ -593,6 +601,17 @@ mod tests {
assert!(parse(&["--install", "https://github.com/x/y", "--install-force"]).install_force); 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_eq!(
parse(&["--install", "someuser/omc", "--git-host", "git.x.com"])
.git_host
.as_deref(),
Some("git.x.com")
);
}
#[test] #[test]
fn help_omits_install_from_and_shows_install_builtins() { fn help_omits_install_from_and_shows_install_builtins() {
use clap::CommandFactory; use clap::CommandFactory;
+292 -23
View File
@@ -72,9 +72,10 @@ pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool)
fn parse_repl_install_flags( fn parse_repl_install_flags(
command: &str, command: &str,
mut iter: impl Iterator<Item = String>, mut iter: impl Iterator<Item = String>,
) -> Result<(Option<InstallFilter>, bool)> { ) -> Result<(Option<InstallFilter>, bool, Option<String>)> {
let mut filter: Option<InstallFilter> = None; let mut filter: Option<InstallFilter> = None;
let mut force = false; let mut force = false;
let mut git_host: Option<String> = None;
while let Some(tok) = iter.next() { while let Some(tok) = iter.next() {
match tok.as_str() { match tok.as_str() {
@@ -91,11 +92,20 @@ fn parse_repl_install_flags(
s if s.starts_with("--filter=") => { s if s.starts_with("--filter=") => {
filter = Some(parse_filter(&s["--filter=".len()..])?); filter = Some(parse_filter(&s["--filter=".len()..])?);
} }
"--git-host" => {
let val = iter
.next()
.with_context(|| "--git-host requires a value (e.g. git.somedomain.com)")?;
git_host = Some(val);
}
s if s.starts_with("--git-host=") => {
git_host = Some(s["--git-host=".len()..].to_string());
}
other => bail!("Unexpected argument to '{command}': {other}"), other => bail!("Unexpected argument to '{command}': {other}"),
} }
} }
Ok((filter, force)) Ok((filter, force, git_host))
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -103,6 +113,7 @@ enum InstallTarget {
Category(AssetCategory), Category(AssetCategory),
InstalledBundle, InstalledBundle,
RemoteSource, RemoteSource,
Shorthand,
Unknown, Unknown,
} }
@@ -117,6 +128,9 @@ fn classify_install_target(value: &str, installed_names: &[String]) -> InstallTa
if looks_like_remote_source(value) { if looks_like_remote_source(value) {
return InstallTarget::RemoteSource; return InstallTarget::RemoteSource;
} }
if is_repo_shorthand(value) {
return InstallTarget::Shorthand;
}
InstallTarget::Unknown InstallTarget::Unknown
} }
@@ -137,7 +151,53 @@ fn looks_like_remote_source(value: &str) -> bool {
} }
} }
pub fn install_or_update(value: &str, filter: Option<InstallFilter>, force: bool) -> Result<()> { const DEFAULT_GIT_HOST: &str = "github.com";
fn is_repo_shorthand(value: &str) -> bool {
let path = strip_ref_suffix(value);
if path.contains("://")
|| path.contains(':')
|| path.contains('\\')
|| path.contains(char::is_whitespace)
|| path.starts_with(['/', '~', '.', '-'])
{
return false;
}
let mut segments = path.split('/');
segments.clone().count() >= 2 && segments.all(|segment| !segment.is_empty())
}
fn expand_repo_shorthand(value: &str, git_host: Option<&str>) -> Result<String> {
let raw = git_host.unwrap_or(DEFAULT_GIT_HOST);
let host = raw
.strip_prefix("https://")
.or_else(|| raw.strip_prefix("http://"))
.unwrap_or(raw)
.trim_matches('/');
if host.is_empty() || host.contains('/') || host.chars().any(char::is_whitespace) {
bail!("invalid --git-host '{raw}': expected a bare host like git.somedomain.com");
}
Ok(format!("https://{host}/{value}"))
}
pub fn install_or_update(
value: &str,
git_host: Option<&str>,
filter: Option<InstallFilter>,
force: bool,
) -> Result<()> {
if let Some(host) = git_host {
if !is_repo_shorthand(value) {
bail!(
"--git-host only applies to <owner>/<repo> shorthand values; \
'{value}' is not one"
);
}
let url = expand_repo_shorthand(value, Some(host))?;
println!("Resolved '{value}' to '{url}'");
return install_remote(&url, filter, force);
}
let store = BundleStore::load()?; let store = BundleStore::load()?;
let installed: Vec<String> = store let installed: Vec<String> = store
.bundle_names() .bundle_names()
@@ -168,9 +228,15 @@ pub fn install_or_update(value: &str, filter: Option<InstallFilter>, force: bool
update_bundle(value) update_bundle(value)
} }
InstallTarget::RemoteSource => install_remote(value, filter, force), InstallTarget::RemoteSource => install_remote(value, filter, force),
InstallTarget::Shorthand => {
let url = expand_repo_shorthand(value, None)?;
println!("Resolved '{value}' to '{url}'");
install_remote(&url, filter, force)
}
InstallTarget::Unknown => { InstallTarget::Unknown => {
let hint = "a remote source must be a git URL, an scp-style host:path, \ let hint = "a remote source must be a git URL, an <owner>/<repo> shorthand \
or an explicit local path (./dir, /abs, ~)"; (expanded against --git-host, default github.com), an scp-style \
host:path, or an explicit local path (./dir, /abs, ~)";
if installed.is_empty() { if installed.is_empty() {
bail!("no bundle named '{value}' is installed; none are installed ({hint})"); bail!("no bundle named '{value}' is installed; none are installed ({hint})");
} }
@@ -189,13 +255,14 @@ pub fn install_or_update_from_repl_args(args: &str) -> Result<()> {
let mut iter = tokens.into_iter(); let mut iter = tokens.into_iter();
let value = iter.next().with_context(|| { let value = iter.next().with_context(|| {
format!( format!(
"Usage: .install <git-url|installed-bundle> [--filter <{}>] [--force]", "Usage: .install <git-url|owner/repo|installed-bundle> \
[--git-host <host>] [--filter <{}>] [--force]",
InstallFilter::NAMES.join("|") InstallFilter::NAMES.join("|")
) )
})?; })?;
let (filter, force) = parse_repl_install_flags(".install", iter)?; let (filter, force, git_host) = parse_repl_install_flags(".install", iter)?;
install_or_update(&value, filter, force) install_or_update(&value, git_host.as_deref(), filter, force)
} }
/// The whole remote is always processed — including categories a filtered /// The whole remote is always processed — including categories a filtered
@@ -414,16 +481,19 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> {
Some(_) => spec.to_string(), Some(_) => spec.to_string(),
None => match store.find_by_source(spec) { None => match store.find_by_source(spec) {
Some((name, _)) => name.to_string(), Some((name, _)) => name.to_string(),
None => { None => match select_uninstall_candidate(&store, spec)? {
let installed = store.bundle_names(); Some(name) => name,
if installed.is_empty() { None => {
bail!("no bundle named '{spec}' is installed; none are installed"); let installed = store.bundle_names();
if installed.is_empty() {
bail!("no bundle named '{spec}' is installed; none are installed");
}
bail!(
"no bundle named '{spec}' is installed; installed bundles: {}",
installed.join(", ")
);
} }
bail!( },
"no bundle named '{spec}' is installed; installed bundles: {}",
installed.join(", ")
);
}
}, },
}; };
let record = store.get(&name).expect("resolved above").clone(); let record = store.get(&name).expect("resolved above").clone();
@@ -502,6 +572,49 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> {
Ok(()) Ok(())
} }
/// Never auto-picks between multiple matches: ambiguity is resolved by an
/// interactive prompt, and non-interactive runs bail even under --yes.
fn select_uninstall_candidate(store: &BundleStore, spec: &str) -> Result<Option<String>> {
if !is_repo_shorthand(spec) {
return Ok(None);
}
let needle = format!("/{}", canonical_source_url(spec));
let candidates: Vec<(String, String)> = store
.iter()
.filter(|(_, record)| canonical_source_url(&record.source).ends_with(&needle))
.map(|(name, record)| (name.to_string(), record.source.clone()))
.collect();
match candidates.as_slice() {
[] => Ok(None),
[(name, _)] => Ok(Some(name.clone())),
_ => {
let described: Vec<String> = candidates
.iter()
.map(|(name, source)| format!("{name}{source}"))
.collect();
if !*IS_STDOUT_TERMINAL {
bail!(
"'{spec}' matches multiple installed bundles: {}; re-run with the \
exact bundle name or source URL",
described.join(", ")
);
}
let picked = Select::new(
&format!("Multiple bundles match '{spec}'; which one should be uninstalled?"),
described.clone(),
)
.prompt()
.with_context(|| "failed to read uninstall selection")?;
let index = described
.iter()
.position(|option| option == &picked)
.expect("selection came from the presented options");
Ok(Some(candidates[index].0.clone()))
}
}
}
/// A recorded path that could escape `config_dir` (absolute, or containing /// A recorded path that could escape `config_dir` (absolute, or containing
/// anything but plain components) is never touched; its record survives. /// anything but plain components) is never touched; its record survives.
fn uninstall_owned_files( fn uninstall_owned_files(
@@ -3588,7 +3701,7 @@ mod tests {
fn install_or_update_redirects_categories_to_install_builtins() { fn install_or_update_redirects_categories_to_install_builtins() {
let _guard = TestVaultConfigGuard::new("iou-category"); let _guard = TestVaultConfigGuard::new("iou-category");
let err = install_or_update("agents", None, false).unwrap_err(); let err = install_or_update("agents", None, None, false).unwrap_err();
assert!( assert!(
err.to_string().contains("--install-builtins agents"), err.to_string().contains("--install-builtins agents"),
@@ -3601,7 +3714,7 @@ mod tests {
fn install_or_update_unknown_name_lists_installed_bundles() { fn install_or_update_unknown_name_lists_installed_bundles() {
let _guard = TestVaultConfigGuard::new("iou-unknown"); let _guard = TestVaultConfigGuard::new("iou-unknown");
let err = install_or_update("not-a-bundle", None, false).unwrap_err(); let err = install_or_update("not-a-bundle", None, None, false).unwrap_err();
assert!(err.to_string().contains("none are installed"), "got: {err}"); assert!(err.to_string().contains("none are installed"), "got: {err}");
let src_root = fresh_temp_dir("iou-unknown-src-"); let src_root = fresh_temp_dir("iou-unknown-src-");
@@ -3611,7 +3724,7 @@ mod tests {
init_bundle_repo(&repo); init_bundle_repo(&repo);
install_remote(repo.to_str().unwrap(), None, false).unwrap(); install_remote(repo.to_str().unwrap(), None, false).unwrap();
let err = install_or_update("not-a-bundle", None, false).unwrap_err(); let err = install_or_update("not-a-bundle", None, None, false).unwrap_err();
assert!( assert!(
err.to_string().contains("no bundle named 'not-a-bundle'"), err.to_string().contains("no bundle named 'not-a-bundle'"),
@@ -3644,7 +3757,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let err = install_or_update("agents", None, false).unwrap_err(); let err = install_or_update("agents", None, None, false).unwrap_err();
assert!( assert!(
err.to_string().contains("--install-builtins agents"), err.to_string().contains("--install-builtins agents"),
@@ -3667,13 +3780,14 @@ mod tests {
init_bundle_repo(&repo); init_bundle_repo(&repo);
install_remote(repo.to_str().unwrap(), None, false).unwrap(); install_remote(repo.to_str().unwrap(), None, false).unwrap();
let err = install_or_update("flag-bundle", Some(InstallFilter::Macros), false).unwrap_err(); let err =
install_or_update("flag-bundle", None, Some(InstallFilter::Macros), false).unwrap_err();
assert!( assert!(
err.to_string().contains("only apply to remote installs"), err.to_string().contains("only apply to remote installs"),
"got: {err}" "got: {err}"
); );
let err = install_or_update("flag-bundle", None, true).unwrap_err(); let err = install_or_update("flag-bundle", None, None, true).unwrap_err();
assert!( assert!(
err.to_string().contains("only apply to remote installs"), err.to_string().contains("only apply to remote installs"),
"got: {err}" "got: {err}"
@@ -3681,6 +3795,161 @@ mod tests {
let _ = fs::remove_dir_all(&src_root); let _ = fs::remove_dir_all(&src_root);
} }
#[test]
fn shorthand_accepts_owner_repo_and_deeper_paths() {
assert!(is_repo_shorthand("someuser/oh-my-coyote"));
assert!(is_repo_shorthand("group/subgroup/repo"));
assert!(is_repo_shorthand("someuser/repo#v2"));
}
#[test]
fn shorthand_rejects_urls_paths_and_bare_names() {
assert!(!is_repo_shorthand("https://github.com/x/y"));
assert!(!is_repo_shorthand("git@github.com:x/y.git"));
assert!(!is_repo_shorthand("./local/dir"));
assert!(!is_repo_shorthand("/abs/path"));
assert!(!is_repo_shorthand("~/home/path"));
assert!(!is_repo_shorthand("-flag/like"));
assert!(!is_repo_shorthand("bare-name"));
assert!(!is_repo_shorthand("a//b"));
assert!(!is_repo_shorthand("a/b/"));
assert!(!is_repo_shorthand("a\\b"));
assert!(!is_repo_shorthand("a b/c"));
}
#[test]
fn shorthand_expands_against_default_and_custom_hosts() {
assert_eq!(
expand_repo_shorthand("someuser/omc", None).unwrap(),
"https://github.com/someuser/omc"
);
assert_eq!(
expand_repo_shorthand("someuser/omc#v2", Some("git.somedomain.com")).unwrap(),
"https://git.somedomain.com/someuser/omc#v2"
);
assert_eq!(
expand_repo_shorthand("a/b", Some("https://git.x.com/")).unwrap(),
"https://git.x.com/a/b"
);
assert!(expand_repo_shorthand("a/b", Some("bad/host")).is_err());
assert!(expand_repo_shorthand("a/b", Some("")).is_err());
}
#[test]
fn classify_prefers_installed_names_over_shorthand() {
assert_eq!(
classify_install_target("someuser/omc", &[]),
InstallTarget::Shorthand
);
assert_eq!(
classify_install_target("someuser/omc", &["someuser/omc".to_string()]),
InstallTarget::InstalledBundle
);
}
#[test]
fn install_or_update_git_host_rejects_non_shorthand_values() {
let err = install_or_update("https://github.com/x/y", Some("git.x.com"), None, false)
.unwrap_err();
assert!(
err.to_string().contains("--git-host only applies"),
"got: {err}"
);
let err = install_or_update("bare-name", Some("git.x.com"), None, false).unwrap_err();
assert!(
err.to_string().contains("--git-host only applies"),
"got: {err}"
);
}
#[test]
fn repl_install_flags_parse_git_host() {
let (filter, force, host) = parse_repl_install_flags(
".install",
vec!["--git-host".to_string(), "git.x.com".to_string()].into_iter(),
)
.unwrap();
assert_eq!(host.as_deref(), Some("git.x.com"));
assert!(filter.is_none() && !force);
let (_, force, host) = parse_repl_install_flags(
".install",
vec!["--git-host=git.y.com".to_string(), "--force".to_string()].into_iter(),
)
.unwrap();
assert_eq!(host.as_deref(), Some("git.y.com"));
assert!(force);
}
#[test]
#[serial]
fn uninstall_shorthand_resolves_a_single_source_match() {
let _guard = TestVaultConfigGuard::new("uninst-short-one");
let mut store = BundleStore::load().unwrap();
store
.upsert_bundle("omc", test_metadata("https://github.com/someuser/omc"))
.unwrap();
drop(store);
uninstall_bundle("someuser/omc", true).unwrap();
let store = BundleStore::load().unwrap();
assert!(store.get("omc").is_none());
}
#[test]
#[serial]
fn uninstall_shorthand_with_multiple_matches_bails_non_interactively() {
let _guard = TestVaultConfigGuard::new("uninst-short-multi");
let mut store = BundleStore::load().unwrap();
store
.upsert_bundle("omc", test_metadata("https://github.com/someuser/omc"))
.unwrap();
store
.upsert_bundle(
"omc-fork",
test_metadata("https://git.somedomain.com/someuser/omc"),
)
.unwrap();
drop(store);
let err = uninstall_bundle("someuser/omc", true).unwrap_err();
assert!(
err.to_string()
.contains("matches multiple installed bundles"),
"got: {err}"
);
assert!(err.to_string().contains("omc-fork"), "got: {err}");
let store = BundleStore::load().unwrap();
assert!(store.get("omc").is_some());
assert!(store.get("omc-fork").is_some());
}
#[test]
#[serial]
fn uninstall_exact_name_wins_over_shorthand_source_matches() {
let _guard = TestVaultConfigGuard::new("uninst-short-name");
let mut store = BundleStore::load().unwrap();
store
.upsert_bundle(
"someuser/omc",
test_metadata("https://git.somedomain.com/other/repo"),
)
.unwrap();
store
.upsert_bundle("omc", test_metadata("https://github.com/someuser/omc"))
.unwrap();
drop(store);
uninstall_bundle("someuser/omc", true).unwrap();
let store = BundleStore::load().unwrap();
assert!(store.get("someuser/omc").is_none());
assert!(store.get("omc").is_some());
}
fn owned_file_record(path: &str, contents: &str) -> FileRecord { fn owned_file_record(path: &str, contents: &str) -> FileRecord {
FileRecord { FileRecord {
path: path.to_string(), path: path.to_string(),
+8
View File
@@ -3500,11 +3500,16 @@ impl RequestContext {
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!["github.com".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 {
@@ -3513,6 +3518,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);
} }
+6 -1
View File
@@ -135,7 +135,12 @@ async fn main() -> Result<()> {
} }
if let Some(value) = cli.install.as_deref() { if let Some(value) = cli.install.as_deref() {
return config::install_or_update(value, 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() { if let Some(spec) = cli.update_bundle.as_deref() {
+2 -2
View File
@@ -876,8 +876,8 @@ pub async fn run_repl_command(
config::install_or_update_from_repl_args(value)?; config::install_or_update_from_repl_args(value)?;
} }
ReplInstallDispatch::Usage => println!( ReplInstallDispatch::Usage => println!(
"Usage: .install <{}> | .install <git-url|installed-bundle> \ "Usage: .install <{}> | .install <git-url|owner/repo|installed-bundle> \
[--filter <cat>] [--force]", [--git-host <host>] [--filter <cat>] [--force]",
AssetCategory::NAMES.join("|") AssetCategory::NAMES.join("|")
), ),
}, },