feat: added remote install and install support for skills

This commit is contained in:
2026-06-01 11:58:35 -06:00
parent 75a6a5e145
commit 3359c62429
3 changed files with 81 additions and 7 deletions
+71 -5
View File
@@ -24,7 +24,7 @@ pub fn install_remote(git_url: &str, filter: Option<InstallFilter>, force: bool)
if layout.is_empty() { if layout.is_empty() {
println!( println!(
"No recognized assets found in {git_url}. Expected one or more of: \ "No recognized assets found in {git_url}. Expected one or more of: \
agents/, roles/, macros/, functions/tools/, functions/mcp.json" agents/, roles/, skills/, macros/, functions/tools/, functions/mcp.json"
); );
return Ok(()); return Ok(());
} }
@@ -193,6 +193,7 @@ fn run_git(args: Vec<OsString>) -> Result<()> {
struct RemoteLayout { struct RemoteLayout {
agents: Option<PathBuf>, agents: Option<PathBuf>,
roles: Option<PathBuf>, roles: Option<PathBuf>,
skills: Option<PathBuf>,
macros: Option<PathBuf>, macros: Option<PathBuf>,
functions_tools: Option<PathBuf>, functions_tools: Option<PathBuf>,
mcp_json: Option<PathBuf>, mcp_json: Option<PathBuf>,
@@ -202,6 +203,7 @@ impl RemoteLayout {
fn is_empty(&self) -> bool { fn is_empty(&self) -> bool {
self.agents.is_none() self.agents.is_none()
&& self.roles.is_none() && self.roles.is_none()
&& self.skills.is_none()
&& self.macros.is_none() && self.macros.is_none()
&& self.functions_tools.is_none() && self.functions_tools.is_none()
&& self.mcp_json.is_none() && self.mcp_json.is_none()
@@ -215,20 +217,29 @@ fn scan_remote_layout(root: &Path) -> Result<RemoteLayout> {
if agents.is_dir() { if agents.is_dir() {
layout.agents = Some(agents); layout.agents = Some(agents);
} }
let roles = root.join("roles"); let roles = root.join("roles");
if roles.is_dir() { if roles.is_dir() {
layout.roles = Some(roles); layout.roles = Some(roles);
} }
let skills = root.join("skills");
if skills.is_dir() {
layout.skills = Some(skills);
}
let macros = root.join("macros"); let macros = root.join("macros");
if macros.is_dir() { if macros.is_dir() {
layout.macros = Some(macros); layout.macros = Some(macros);
} }
let functions = root.join("functions"); let functions = root.join("functions");
if functions.is_dir() { if functions.is_dir() {
let tools = functions.join("tools"); let tools = functions.join("tools");
if tools.is_dir() { if tools.is_dir() {
layout.functions_tools = Some(tools); layout.functions_tools = Some(tools);
} }
let mcp = functions.join("mcp.json"); let mcp = functions.join("mcp.json");
if mcp.is_file() { if mcp.is_file() {
layout.mcp_json = Some(mcp); layout.mcp_json = Some(mcp);
@@ -251,6 +262,10 @@ fn apply_filter(mut layout: RemoteLayout, filter: Option<InstallFilter>) -> Remo
roles: layout.roles.take(), roles: layout.roles.take(),
..RemoteLayout::default() ..RemoteLayout::default()
}, },
InstallFilter::Skills => RemoteLayout {
skills: layout.skills.take(),
..RemoteLayout::default()
},
InstallFilter::Macros => RemoteLayout { InstallFilter::Macros => RemoteLayout {
macros: layout.macros.take(), macros: layout.macros.take(),
..RemoteLayout::default() ..RemoteLayout::default()
@@ -308,6 +323,7 @@ fn walk_files_inner(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
enum TopCategory { enum TopCategory {
Agents, Agents,
Roles, Roles,
Skills,
Macros, Macros,
FunctionsTools, FunctionsTools,
} }
@@ -317,6 +333,7 @@ impl TopCategory {
match self { match self {
TopCategory::Agents => "agents", TopCategory::Agents => "agents",
TopCategory::Roles => "roles", TopCategory::Roles => "roles",
TopCategory::Skills => "skills",
TopCategory::Macros => "macros", TopCategory::Macros => "macros",
TopCategory::FunctionsTools => "functions/tools", TopCategory::FunctionsTools => "functions/tools",
} }
@@ -356,6 +373,11 @@ fn plan_changes(layout: &RemoteLayout) -> Result<InstallPlan> {
if let Some(src_dir) = &layout.roles { if let Some(src_dir) = &layout.roles {
plan_dir_into(src_dir, &paths::roles_dir(), TopCategory::Roles, &mut files)?; plan_dir_into(src_dir, &paths::roles_dir(), TopCategory::Roles, &mut files)?;
} }
if let Some(src_dir) = &layout.skills {
plan_dir_into(src_dir, &paths::skills_dir(), TopCategory::Skills, &mut files)?;
}
if let Some(src_dir) = &layout.macros { if let Some(src_dir) = &layout.macros {
plan_dir_into( plan_dir_into(
src_dir, src_dir,
@@ -457,6 +479,7 @@ fn print_plan_summary(plan: &InstallPlan) {
for cat in [ for cat in [
TopCategory::Agents, TopCategory::Agents,
TopCategory::Roles, TopCategory::Roles,
TopCategory::Skills,
TopCategory::Macros, TopCategory::Macros,
TopCategory::FunctionsTools, TopCategory::FunctionsTools,
] { ] {
@@ -982,6 +1005,7 @@ mod tests {
let l = RemoteLayout { let l = RemoteLayout {
agents: Some(PathBuf::from("a")), agents: Some(PathBuf::from("a")),
roles: Some(PathBuf::from("r")), roles: Some(PathBuf::from("r")),
skills: Some(PathBuf::from("s")),
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")),
@@ -989,8 +1013,8 @@ mod tests {
let out = apply_filter(l, None); let out = apply_filter(l, None);
assert!(out.agents.is_some() && out.roles.is_some() && out.macros.is_some()); assert!(out.agents.is_some() && out.roles.is_some() && out.skills.is_some());
assert!(out.functions_tools.is_some() && out.mcp_json.is_some()); assert!(out.macros.is_some() && out.functions_tools.is_some() && out.mcp_json.is_some());
} }
#[test] #[test]
@@ -998,6 +1022,7 @@ mod tests {
let l = RemoteLayout { let l = RemoteLayout {
agents: Some(PathBuf::from("a")), agents: Some(PathBuf::from("a")),
roles: None, roles: None,
skills: Some(PathBuf::from("s")),
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")),
@@ -1006,6 +1031,7 @@ mod tests {
let out = apply_filter(l, Some(InstallFilter::Functions)); let out = apply_filter(l, Some(InstallFilter::Functions));
assert!(out.agents.is_none()); assert!(out.agents.is_none());
assert!(out.skills.is_none());
assert_eq!(out.functions_tools, Some(PathBuf::from("f"))); assert_eq!(out.functions_tools, Some(PathBuf::from("f")));
assert!(out.mcp_json.is_none()); assert!(out.mcp_json.is_none());
} }
@@ -1015,6 +1041,7 @@ mod tests {
let l = RemoteLayout { let l = RemoteLayout {
agents: Some(PathBuf::from("a")), agents: Some(PathBuf::from("a")),
roles: None, roles: None,
skills: Some(PathBuf::from("s")),
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")),
@@ -1022,7 +1049,7 @@ mod tests {
let out = apply_filter(l, Some(InstallFilter::McpConfig)); let out = apply_filter(l, Some(InstallFilter::McpConfig));
assert!(out.agents.is_none() && out.functions_tools.is_none()); assert!(out.agents.is_none() && out.skills.is_none() && out.functions_tools.is_none());
assert_eq!(out.mcp_json, Some(PathBuf::from("j"))); assert_eq!(out.mcp_json, Some(PathBuf::from("j")));
} }
@@ -1031,6 +1058,7 @@ mod tests {
let l = RemoteLayout { let l = RemoteLayout {
agents: Some(PathBuf::from("a")), agents: Some(PathBuf::from("a")),
roles: Some(PathBuf::from("r")), roles: Some(PathBuf::from("r")),
skills: Some(PathBuf::from("s")),
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")),
@@ -1039,7 +1067,25 @@ mod tests {
let out = apply_filter(l, Some(InstallFilter::Roles)); let out = apply_filter(l, Some(InstallFilter::Roles));
assert_eq!(out.roles, Some(PathBuf::from("r"))); assert_eq!(out.roles, Some(PathBuf::from("r")));
assert!(out.agents.is_none() && out.macros.is_none()); assert!(out.agents.is_none() && out.skills.is_none() && out.macros.is_none());
assert!(out.functions_tools.is_none() && out.mcp_json.is_none());
}
#[test]
fn apply_filter_skills_keeps_only_skills() {
let l = RemoteLayout {
agents: Some(PathBuf::from("a")),
roles: Some(PathBuf::from("r")),
skills: Some(PathBuf::from("s")),
macros: Some(PathBuf::from("m")),
functions_tools: Some(PathBuf::from("f")),
mcp_json: Some(PathBuf::from("j")),
};
let out = apply_filter(l, Some(InstallFilter::Skills));
assert_eq!(out.skills, Some(PathBuf::from("s")));
assert!(out.agents.is_none() && out.roles.is_none() && out.macros.is_none());
assert!(out.functions_tools.is_none() && out.mcp_json.is_none()); assert!(out.functions_tools.is_none() && out.mcp_json.is_none());
} }
@@ -1084,8 +1130,10 @@ mod tests {
#[test] #[test]
fn scan_remote_layout_finds_known_subdirs() { fn scan_remote_layout_finds_known_subdirs() {
let root = fresh_temp_dir("scan-test-"); let root = fresh_temp_dir("scan-test-");
fs::create_dir_all(root.join("agents/sample")).unwrap(); fs::create_dir_all(root.join("agents/sample")).unwrap();
fs::create_dir_all(root.join("roles")).unwrap(); fs::create_dir_all(root.join("roles")).unwrap();
fs::create_dir_all(root.join("skills")).unwrap();
fs::create_dir_all(root.join("macros")).unwrap(); fs::create_dir_all(root.join("macros")).unwrap();
fs::create_dir_all(root.join("functions/tools")).unwrap(); fs::create_dir_all(root.join("functions/tools")).unwrap();
touch(&root.join("functions/mcp.json")); touch(&root.join("functions/mcp.json"));
@@ -1094,12 +1142,30 @@ mod tests {
let layout = scan_remote_layout(&root).unwrap(); let layout = scan_remote_layout(&root).unwrap();
assert!(layout.agents.is_some()); assert!(layout.agents.is_some());
assert!(layout.roles.is_some()); assert!(layout.roles.is_some());
assert!(layout.skills.is_some());
assert!(layout.macros.is_some()); assert!(layout.macros.is_some());
assert!(layout.functions_tools.is_some()); assert!(layout.functions_tools.is_some());
assert!(layout.mcp_json.is_some()); assert!(layout.mcp_json.is_some());
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
} }
#[test]
fn scan_remote_layout_finds_skills_only() {
let root = fresh_temp_dir("scan-skills-only-");
fs::create_dir_all(root.join("skills/git-master")).unwrap();
touch(&root.join("skills/git-master/SKILL.md"));
let layout = scan_remote_layout(&root).unwrap();
assert!(layout.skills.is_some());
assert!(layout.agents.is_none());
assert!(layout.roles.is_none());
assert!(layout.macros.is_none());
assert!(layout.functions_tools.is_none());
assert!(layout.mcp_json.is_none());
let _ = fs::remove_dir_all(&root);
}
#[test] #[test]
fn scan_remote_layout_ignores_unrelated_files() { fn scan_remote_layout_ignores_unrelated_files() {
let root = fresh_temp_dir("scan-unrelated-"); let root = fresh_temp_dir("scan-unrelated-");
+10 -1
View File
@@ -287,6 +287,7 @@ impl AssetCategory {
pub enum InstallFilter { pub enum InstallFilter {
Agents, Agents,
Roles, Roles,
Skills,
Macros, Macros,
Functions, Functions,
#[value(name = "mcp_config")] #[value(name = "mcp_config")]
@@ -294,12 +295,20 @@ pub enum InstallFilter {
} }
impl InstallFilter { impl InstallFilter {
pub const NAMES: [&'static str; 5] = ["agents", "roles", "macros", "functions", "mcp_config"]; pub const NAMES: [&'static str; 6] = [
"agents",
"roles",
"skills",
"macros",
"functions",
"mcp_config",
];
pub fn parse(name: &str) -> Option<Self> { pub fn parse(name: &str) -> Option<Self> {
match name { match name {
"agents" => Some(Self::Agents), "agents" => Some(Self::Agents),
"roles" => Some(Self::Roles), "roles" => Some(Self::Roles),
"skills" => Some(Self::Skills),
"macros" => Some(Self::Macros), "macros" => Some(Self::Macros),
"functions" => Some(Self::Functions), "functions" => Some(Self::Functions),
"mcp_config" => Some(Self::McpConfig), "mcp_config" => Some(Self::McpConfig),
-1
View File
@@ -65,7 +65,6 @@ pub fn role_file(name: &str) -> PathBuf {
roles_dir().join(format!("{name}.md")) roles_dir().join(format!("{name}.md"))
} }
#[allow(dead_code)]
pub fn skills_dir() -> PathBuf { pub fn skills_dir() -> PathBuf {
match env::var(get_env_name("skills_dir")) { match env::var(get_env_name("skills_dir")) {
Ok(value) => PathBuf::from(value), Ok(value) => PathBuf::from(value),