feat: parse bundle manifests and capture resolved SHAs for remote installs

This commit is contained in:
2026-08-24 11:15:12 -06:00
parent 5f23e2403f
commit b21699b749
+542 -10
View File
@@ -9,6 +9,7 @@ use anyhow::{Context, Result, anyhow, bail};
use indexmap::IndexMap; use indexmap::IndexMap;
use indoc::formatdoc; use indoc::formatdoc;
use inquire::{Confirm, Select}; use inquire::{Confirm, Select};
use serde::Deserialize;
use std::ffi::{OsStr, OsString}; use std::ffi::{OsStr, OsString};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -18,7 +19,8 @@ pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool)
let temp = clone_to_temp(&url, reference.as_deref())?; let temp = clone_to_temp(&url, reference.as_deref())?;
println!("Cloned {git_url} to {}", temp.path().display()); println!("Cloned {git_url} to {}", temp.path().display());
let layout = scan_remote_layout(temp.path())?; let mut layout = scan_remote_layout(temp.path())?;
layout.head_sha = Some(temp.head_sha().to_string());
let layout = apply_filter(layout, filter); let layout = apply_filter(layout, filter);
if layout.is_empty() { if layout.is_empty() {
@@ -121,12 +123,17 @@ fn parse_url_with_ref(input: &str) -> Result<(String, Option<String>)> {
struct TempRepoDir { struct TempRepoDir {
path: PathBuf, path: PathBuf,
head_sha: String,
} }
impl TempRepoDir { impl TempRepoDir {
fn path(&self) -> &Path { fn path(&self) -> &Path {
&self.path &self.path
} }
fn head_sha(&self) -> &str {
&self.head_sha
}
} }
impl Drop for TempRepoDir { impl Drop for TempRepoDir {
@@ -170,7 +177,17 @@ fn clone_to_temp(url: &str, reference: Option<&str>) -> Result<TempRepoDir> {
} }
} }
Ok(TempRepoDir { path: dest }) let head_sha = run_git_capture(vec![
"-C".into(),
dest.as_os_str().into(),
"rev-parse".into(),
"HEAD".into(),
])?;
Ok(TempRepoDir {
path: dest,
head_sha,
})
} }
fn run_git(args: Vec<OsString>) -> Result<()> { fn run_git(args: Vec<OsString>) -> Result<()> {
@@ -189,7 +206,24 @@ fn run_git(args: Vec<OsString>) -> Result<()> {
Ok(()) Ok(())
} }
#[derive(Default)] fn run_git_capture(args: Vec<OsString>) -> Result<String> {
let output = duct::cmd("git", &args)
.stdout_capture()
.stderr_capture()
.unchecked()
.run()
.context("failed to spawn git (is it installed and on PATH?)")?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git failed: {}", format!("{stdout} {stderr}").trim());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[derive(Debug, Default)]
struct RemoteLayout { struct RemoteLayout {
agents: Option<PathBuf>, agents: Option<PathBuf>,
roles: Option<PathBuf>, roles: Option<PathBuf>,
@@ -197,6 +231,8 @@ struct RemoteLayout {
macros: Option<PathBuf>, macros: Option<PathBuf>,
functions_tools: Option<PathBuf>, functions_tools: Option<PathBuf>,
mcp_json: Option<PathBuf>, mcp_json: Option<PathBuf>,
manifest: Option<BundleManifest>,
head_sha: Option<String>,
} }
impl RemoteLayout { impl RemoteLayout {
@@ -211,7 +247,10 @@ impl RemoteLayout {
} }
fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> { fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> {
let mut layout = RemoteLayout::default(); let mut layout = RemoteLayout {
manifest: parse_bundle_manifest(root)?,
..RemoteLayout::default()
};
let agents = root.join("agents"); let agents = root.join("agents");
if agents.is_dir() { if agents.is_dir() {
@@ -249,34 +288,178 @@ fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> {
Ok(layout) Ok(layout)
} }
const BUNDLE_MANIFEST_FILE: &str = "coyote-bundle.yaml";
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub(crate) struct BundleManifest {
pub(crate) name: String,
#[allow(dead_code)]
pub(crate) version: Option<String>,
#[allow(dead_code)]
pub(crate) description: Option<String>,
#[allow(dead_code)]
pub(crate) homepage: Option<String>,
}
fn parse_bundle_manifest(root: &Path) -> Result<Option<BundleManifest>> {
let path = root.join(BUNDLE_MANIFEST_FILE);
if !path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(&path)
.with_context(|| format!("failed to read bundle manifest at {}", path.display()))?;
let manifest: BundleManifest = serde_yaml::from_str(&content)
.with_context(|| format!("invalid bundle manifest at {}", path.display()))?;
validate_bundle_name(&manifest.name)
.with_context(|| format!("invalid bundle name in manifest at {}", path.display()))?;
Ok(Some(manifest))
}
pub(crate) fn validate_bundle_name(name: &str) -> Result<()> {
let (owner, base) = match name.split_once('/') {
Some((owner, base)) => (Some(owner), base),
None => (None, name),
};
if base.contains('/') {
bail!(
"Invalid bundle name '{name}': at most one '/' is allowed \
(as the owner qualifier separator)"
);
}
for part in owner.into_iter().chain(std::iter::once(base)) {
if part.is_empty() {
bail!("Invalid bundle name '{name}': name segments cannot be empty");
}
if !part
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
bail!(
"Invalid bundle name '{name}': only letters, digits, '-', and '_' are allowed \
(plus a single '/' separating an owner qualifier)"
);
}
}
Ok(())
}
fn strip_ref_suffix(url: &str) -> &str {
match url.rsplit_once('#') {
Some((base, _)) if !base.is_empty() => base,
_ => url,
}
}
fn split_host_and_path(url: &str) -> (String, String) {
let url = strip_ref_suffix(url);
if let Some((_, rest)) = url.split_once("://") {
let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
(host.to_string(), path.trim_matches('/').to_string())
} else if let Some((prefix, path)) = url.split_once(':')
&& !prefix.contains('/')
{
let host = prefix.rsplit_once('@').map_or(prefix, |(_, h)| h);
(host.to_string(), path.trim_matches('/').to_string())
} else {
(String::new(), url.trim_end_matches('/').to_string())
}
}
fn strip_git_suffix(segment: &str) -> &str {
if segment.len() > 4 && segment.to_ascii_lowercase().ends_with(".git") {
&segment[..segment.len() - 4]
} else {
segment
}
}
#[allow(dead_code)]
pub(crate) fn repo_name_slug(url: &str) -> String {
let (_, path) = split_host_and_path(url);
let last = path.rsplit('/').next().unwrap_or("");
strip_git_suffix(last).to_string()
}
#[allow(dead_code)]
pub(crate) fn owner_qualifier(url: &str) -> Option<String> {
let (host, path) = split_host_and_path(url);
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() >= 2 {
return Some(segments[segments.len() - 2].to_string());
}
let sanitized = sanitize_host(&host);
(!sanitized.is_empty()).then_some(sanitized)
}
fn sanitize_host(host: &str) -> String {
host.to_ascii_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
#[allow(dead_code)]
pub(crate) fn canonical_source_url(url: &str) -> String {
let (host, path) = split_host_and_path(url);
let mut path = path.to_ascii_lowercase();
if let Some(stripped) = path.strip_suffix(".git")
&& !stripped.is_empty()
&& !stripped.ends_with('/')
{
path = stripped.to_string();
}
let host = host.to_ascii_lowercase();
if host.is_empty() {
path
} else if path.is_empty() {
host
} else {
format!("{host}/{path}")
}
}
fn apply_filter(mut layout: RemoteLayout, filter: Option<InstallFilter>) -> RemoteLayout { fn apply_filter(mut layout: RemoteLayout, filter: Option<InstallFilter>) -> RemoteLayout {
let Some(filter) = filter else { let Some(filter) = filter else {
return layout; return layout;
}; };
let base = RemoteLayout {
manifest: layout.manifest.take(),
head_sha: layout.head_sha.take(),
..RemoteLayout::default()
};
match filter { match filter {
InstallFilter::Agents => RemoteLayout { InstallFilter::Agents => RemoteLayout {
agents: layout.agents.take(), agents: layout.agents.take(),
..RemoteLayout::default() ..base
}, },
InstallFilter::Roles => RemoteLayout { InstallFilter::Roles => RemoteLayout {
roles: layout.roles.take(), roles: layout.roles.take(),
..RemoteLayout::default() ..base
}, },
InstallFilter::Skills => RemoteLayout { InstallFilter::Skills => RemoteLayout {
skills: layout.skills.take(), skills: layout.skills.take(),
..RemoteLayout::default() ..base
}, },
InstallFilter::Macros => RemoteLayout { InstallFilter::Macros => RemoteLayout {
macros: layout.macros.take(), macros: layout.macros.take(),
..RemoteLayout::default() ..base
}, },
InstallFilter::Functions => RemoteLayout { InstallFilter::Functions => RemoteLayout {
functions_tools: layout.functions_tools.take(), functions_tools: layout.functions_tools.take(),
..RemoteLayout::default() ..base
}, },
InstallFilter::McpConfig => RemoteLayout { InstallFilter::McpConfig => RemoteLayout {
mcp_json: layout.mcp_json.take(), mcp_json: layout.mcp_json.take(),
..RemoteLayout::default() ..base
}, },
} }
} }
@@ -1104,6 +1287,7 @@ mod tests {
macros: Some(PathBuf::from("m")), macros: Some(PathBuf::from("m")),
functions_tools: Some(PathBuf::from("f")), functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")), mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
}; };
let out = apply_filter(l, None); let out = apply_filter(l, None);
@@ -1121,6 +1305,7 @@ mod tests {
macros: None, macros: None,
functions_tools: Some(PathBuf::from("f")), functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")), mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
}; };
let out = apply_filter(l, Some(InstallFilter::Functions)); let out = apply_filter(l, Some(InstallFilter::Functions));
@@ -1140,6 +1325,7 @@ mod tests {
macros: None, macros: None,
functions_tools: Some(PathBuf::from("f")), functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")), mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
}; };
let out = apply_filter(l, Some(InstallFilter::McpConfig)); let out = apply_filter(l, Some(InstallFilter::McpConfig));
@@ -1157,6 +1343,7 @@ mod tests {
macros: Some(PathBuf::from("m")), macros: Some(PathBuf::from("m")),
functions_tools: Some(PathBuf::from("f")), functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")), mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
}; };
let out = apply_filter(l, Some(InstallFilter::Roles)); let out = apply_filter(l, Some(InstallFilter::Roles));
@@ -1175,6 +1362,7 @@ mod tests {
macros: Some(PathBuf::from("m")), macros: Some(PathBuf::from("m")),
functions_tools: Some(PathBuf::from("f")), functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")), mcp_json: Some(PathBuf::from("j")),
..RemoteLayout::default()
}; };
let out = apply_filter(l, Some(InstallFilter::Skills)); let out = apply_filter(l, Some(InstallFilter::Skills));
@@ -1493,4 +1681,348 @@ mod tests {
assert!(handle_missing_secrets(&missing).is_ok()); assert!(handle_missing_secrets(&missing).is_ok());
} }
fn write_manifest(root: &Path, yaml: &str) {
fs::write(root.join(BUNDLE_MANIFEST_FILE), yaml).unwrap();
}
#[test]
fn scan_remote_layout_without_manifest_has_none() {
let root = fresh_temp_dir("scan-no-manifest-");
fs::create_dir_all(root.join("macros")).unwrap();
let layout = scan_remote_layout(&root).unwrap();
assert_eq!(layout.manifest, None);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_parses_full_manifest_and_ignores_unknown_fields() {
let root = fresh_temp_dir("scan-manifest-full-");
fs::create_dir_all(root.join("macros")).unwrap();
write_manifest(
&root,
"name: oh-my-coyote\n\
version: \"1.4.0\"\n\
description: Opinionated roles and macros\n\
homepage: https://github.com/x/oh-my-coyote\n\
future_field: ignored\n",
);
let manifest = scan_remote_layout(&root).unwrap().manifest.unwrap();
assert_eq!(manifest.name, "oh-my-coyote");
assert_eq!(manifest.version.as_deref(), Some("1.4.0"));
assert_eq!(
manifest.description.as_deref(),
Some("Opinionated roles and macros")
);
assert_eq!(
manifest.homepage.as_deref(),
Some("https://github.com/x/oh-my-coyote")
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_parses_name_only_manifest() {
let root = fresh_temp_dir("scan-manifest-minimal-");
write_manifest(&root, "name: minimal\n");
let manifest = scan_remote_layout(&root).unwrap().manifest.unwrap();
assert_eq!(manifest.name, "minimal");
assert_eq!(manifest.version, None);
assert_eq!(manifest.description, None);
assert_eq!(manifest.homepage, None);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_fails_on_malformed_manifest() {
let root = fresh_temp_dir("scan-manifest-malformed-");
write_manifest(&root, "name: [unclosed\n");
let err = scan_remote_layout(&root).unwrap_err();
assert!(
format!("{err:#}").contains("invalid bundle manifest"),
"got: {err:#}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_fails_on_manifest_missing_name() {
let root = fresh_temp_dir("scan-manifest-no-name-");
write_manifest(&root, "version: \"1.0\"\n");
let err = scan_remote_layout(&root).unwrap_err();
assert!(
format!("{err:#}").contains("invalid bundle manifest"),
"got: {err:#}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scan_remote_layout_fails_on_invalid_manifest_name() {
let root = fresh_temp_dir("scan-manifest-bad-name-");
write_manifest(&root, "name: not a valid name\n");
let err = scan_remote_layout(&root).unwrap_err();
assert!(
format!("{err:#}").contains("Invalid bundle name"),
"got: {err:#}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn apply_filter_carries_manifest_and_head_sha_through() {
let l = RemoteLayout {
macros: Some(PathBuf::from("m")),
manifest: Some(BundleManifest {
name: "bundle".to_string(),
version: None,
description: None,
homepage: None,
}),
head_sha: Some("abc123".to_string()),
..RemoteLayout::default()
};
let out = apply_filter(l, Some(InstallFilter::Macros));
assert_eq!(out.macros, Some(PathBuf::from("m")));
assert_eq!(out.manifest.unwrap().name, "bundle");
assert_eq!(out.head_sha.as_deref(), Some("abc123"));
}
#[test]
fn validate_bundle_name_accepts_simple_names() {
assert!(validate_bundle_name("oh-my-coyote").is_ok());
assert!(validate_bundle_name("under_score").is_ok());
assert!(validate_bundle_name("Abc123").is_ok());
}
#[test]
fn validate_bundle_name_accepts_owner_qualified_form() {
assert!(validate_bundle_name("x/oh-my-coyote").is_ok());
}
#[test]
fn validate_bundle_name_rejects_empty() {
assert!(validate_bundle_name("").is_err());
}
#[test]
fn validate_bundle_name_rejects_multiple_slashes() {
assert!(validate_bundle_name("a/b/c").is_err());
}
#[test]
fn validate_bundle_name_rejects_empty_segments() {
assert!(validate_bundle_name("/repo").is_err());
assert!(validate_bundle_name("owner/").is_err());
assert!(validate_bundle_name("/").is_err());
}
#[test]
fn validate_bundle_name_rejects_disallowed_characters() {
assert!(validate_bundle_name("bad name").is_err());
assert!(validate_bundle_name("dot.name").is_err());
assert!(validate_bundle_name("owner/bad!base").is_err());
}
#[test]
fn repo_name_slug_from_https_url() {
assert_eq!(
repo_name_slug("https://github.com/x/oh-my-coyote"),
"oh-my-coyote"
);
}
#[test]
fn repo_name_slug_strips_git_suffix() {
assert_eq!(
repo_name_slug("https://github.com/x/oh-my-coyote.git"),
"oh-my-coyote"
);
}
#[test]
fn repo_name_slug_from_scp_style_url() {
assert_eq!(
repo_name_slug("git@github.com:x/oh-my-coyote.git"),
"oh-my-coyote"
);
}
#[test]
fn repo_name_slug_ignores_ref_suffix() {
assert_eq!(
repo_name_slug("https://github.com/x/repo.git#release/v2"),
"repo"
);
}
#[test]
fn repo_name_slug_ignores_trailing_slash() {
assert_eq!(repo_name_slug("https://github.com/x/repo/"), "repo");
}
#[test]
fn owner_qualifier_from_https_url() {
assert_eq!(
owner_qualifier("https://github.com/x/repo.git").as_deref(),
Some("x")
);
}
#[test]
fn owner_qualifier_from_scp_style_url() {
assert_eq!(
owner_qualifier("git@github.com:x/repo.git").as_deref(),
Some("x")
);
}
#[test]
fn owner_qualifier_falls_back_to_sanitized_host() {
assert_eq!(
owner_qualifier("https://example.com/repo.git").as_deref(),
Some("example-com")
);
}
#[test]
fn owner_qualifier_scp_without_owner_falls_back_to_host() {
assert_eq!(
owner_qualifier("git@host.example.com:repo.git").as_deref(),
Some("host-example-com")
);
}
#[test]
fn owner_qualifier_none_without_owner_or_host() {
assert_eq!(owner_qualifier("repo"), None);
}
#[test]
fn canonical_source_url_treats_equivalent_forms_identically() {
let canonical = canonical_source_url("https://github.com/x/r");
assert_eq!(canonical, "github.com/x/r");
assert_eq!(
canonical_source_url("https://github.com/x/r.git"),
canonical
);
assert_eq!(canonical_source_url("git@github.com:x/r.git"), canonical);
}
#[test]
fn canonical_source_url_lowercases_host_and_path() {
assert_eq!(
canonical_source_url("https://GitHub.COM/X/R.git"),
"github.com/x/r"
);
}
#[test]
fn canonical_source_url_ignores_ref_suffix() {
assert_eq!(
canonical_source_url("https://github.com/x/r.git#main"),
"github.com/x/r"
);
}
#[test]
fn canonical_source_url_strips_userinfo() {
assert_eq!(
canonical_source_url("https://user@github.com/x/r.git"),
"github.com/x/r"
);
}
fn git_in(dir: &Path, args: &[&str]) {
let mut full: Vec<OsString> = vec!["-C".into(), dir.as_os_str().into()];
full.extend(args.iter().map(OsString::from));
run_git(full).unwrap();
}
fn commit_file(dir: &Path, name: &str, content: &str) -> String {
fs::write(dir.join(name), content).unwrap();
git_in(dir, &["add", "."]);
git_in(
dir,
&[
"-c",
"user.email=coyote-test@localhost",
"-c",
"user.name=coyote-test",
"-c",
"commit.gpgsign=false",
"commit",
"-q",
"-m",
name,
],
);
run_git_capture(vec![
"-C".into(),
dir.as_os_str().into(),
"rev-parse".into(),
"HEAD".into(),
])
.unwrap()
}
fn init_git_repo(dir: &Path) -> String {
run_git(vec!["init".into(), "-q".into(), dir.as_os_str().into()]).unwrap();
commit_file(dir, "seed.txt", "one")
}
#[test]
fn clone_to_temp_captures_resolved_head_sha() {
let repo = fresh_temp_dir("clone-sha-");
let sha = init_git_repo(&repo);
let temp = clone_to_temp(repo.to_str().unwrap(), None).unwrap();
assert_eq!(temp.head_sha(), sha);
assert_eq!(temp.head_sha().len(), 40);
assert!(temp.head_sha().chars().all(|c| c.is_ascii_hexdigit()));
let _ = fs::remove_dir_all(&repo);
}
#[test]
fn clone_to_temp_respects_sha_ref_pinning() {
let repo = fresh_temp_dir("clone-pin-sha-");
let first = init_git_repo(&repo);
let second = commit_file(&repo, "next.txt", "two");
assert_ne!(first, second);
let temp = clone_to_temp(repo.to_str().unwrap(), Some(&first)).unwrap();
assert_eq!(temp.head_sha(), first);
let _ = fs::remove_dir_all(&repo);
}
#[test]
fn clone_to_temp_respects_branch_ref_pinning() {
let repo = fresh_temp_dir("clone-pin-branch-");
let first = init_git_repo(&repo);
git_in(&repo, &["branch", "pinned"]);
let second = commit_file(&repo, "next.txt", "two");
assert_ne!(first, second);
let temp = clone_to_temp(repo.to_str().unwrap(), Some("pinned")).unwrap();
assert_eq!(temp.head_sha(), first);
let _ = fs::remove_dir_all(&repo);
}
} }