From 91b294fa7c83ed2b1069f6d28d999ac4654eebd2 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 1 Sep 2026 15:59:58 +1000 Subject: [PATCH 1/2] feat(cli): add openclaw, hermes, kiro, kilo, droid, trae, zed targets Add seven Agent targets with project and global Skill paths and environment detection signals. Accept `--agent all` for every known target and `-g` as the short form of `--global`. Claude-Session: https://claude.ai/code/session_018T67Ndp8FAjnHWthXABbJW --- README.md | 6 +- crates/skilld-command/src/lib.rs | 50 ++++++++--- crates/skilld-command/tests/agent_targets.rs | 60 +++++++++++++ crates/skilld-command/tests/outdated.rs | 14 +-- crates/skilld-core/src/lib.rs | 3 +- crates/skilld-core/src/target.rs | 72 +++++++++++++++- crates/skilld-core/tests/target.rs | 47 ++++++++++ crates/skilld-native/src/main.rs | 26 +++++- crates/skilld-native/tests/cli.rs | 91 +++++++++++++++++++- tests/fixtures/v3-rust/agent-targets.json | 9 +- 10 files changed, 354 insertions(+), 24 deletions(-) create mode 100644 crates/skilld-core/tests/target.rs diff --git a/README.md b/README.md index 16273afc..ad1ee879 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,14 @@ Install it only when you want your Agent to keep it across sessions: skilld install skilld --global ``` -Use `--agent` when you want an explicit Agent target: +Use `--agent` when you want an explicit Agent target. +Repeat it for several targets. Use `--agent all` for every known target. +`-g` is the short form of `--global`. ```sh skilld install skilld --global --agent codex +skilld install skilld -g --agent kiro --agent zed +skilld install skilld -g --agent all ``` ## Run a Skill without installing it diff --git a/crates/skilld-command/src/lib.rs b/crates/skilld-command/src/lib.rs index 82e6fd4b..c8fbe022 100644 --- a/crates/skilld-command/src/lib.rs +++ b/crates/skilld-command/src/lib.rs @@ -36,7 +36,7 @@ use skilld_core::{ InstallMode, InstallOperation, InstallRequest, InstallScope, InstallSource, LockedSource, NotTrackedReason, RemoteSelector, SourceRef, UpdateFailure, UpdateLatestCommit, UpdateModelError, UpdatePlan, UpdatePlanItem, UpdatePlanV1, UpdateRelation, UpdateRetryAfter, - VERSION, classify_update_comparison, select_target_ids, + VERSION, classify_update_comparison, parse_agent_targets, select_target_ids, }; use skilld_ui::text::is_unsafe_terminal; use skilld_ui::{Detail, Line, Marker, Screen}; @@ -80,6 +80,7 @@ enum Command { #[arg(value_name = "SOURCE")] source: Option, #[arg( + short = 'g', long, long_help = "Install to your account-level Agent targets. The default is the current project." )] @@ -87,7 +88,7 @@ enum Command { #[arg( long = "agent", value_name = "AGENT", - long_help = "Select an Agent target. Repeat --agent to select several.\nValues: claude-code, cursor, windsurf, cline, codex, github-copilot,\n gemini-cli, goose, amp, opencode, roo, antigravity.\nDefault: every Agent target skilld detects. If skilld detects none, it uses agent.targets." + long_help = "Select an Agent target. Repeat --agent to select several.\nValues: claude-code, cursor, windsurf, cline, codex, github-copilot,\n gemini-cli, goose, amp, opencode, roo, antigravity, openclaw,\n hermes, kiro, kilo, droid, trae, zed.\nUse --agent all to select every Agent target.\nDefault: every Agent target skilld detects. If skilld detects none, it uses agent.targets." )] agents: Vec, #[arg( @@ -132,19 +133,22 @@ enum Command { }, /// List installed Skills. List { - #[arg(long)] + /// List Skills in the global scope. + #[arg(short = 'g', long)] global: bool, }, /// View Skill details. View { skill: String, - #[arg(long)] + /// View a Skill in the global scope. + #[arg(short = 'g', long)] global: bool, }, /// Remove an installed Skill. Remove { skill: String, - #[arg(long)] + /// Remove a Skill from the global scope. + #[arg(short = 'g', long)] global: bool, }, /// Update installed Skills. @@ -160,7 +164,7 @@ enum Command { )] interactive: bool, /// Update Skills in the global scope. - #[arg(long)] + #[arg(short = 'g', long)] global: bool, }, /// Verify a Skill source. @@ -815,10 +819,7 @@ fn dispatch( "install the skilld-maintained Skill with --global", )); } - let targets = agents - .iter() - .map(|agent| AgentTargetId::parse(agent).map_err(CommandError::domain)) - .collect::, _>>()?; + let targets = parse_agent_targets(&agents).map_err(CommandError::domain)?; let mode = mode .as_deref() .map(InstallMode::parse) @@ -2935,6 +2936,21 @@ fn detects_environment(agent: AgentTargetId, environment: &DetectionEnvironment) .any(|name| environment.has(name)), AgentTargetId::Roo => environment.has("ROO_SESSION"), AgentTargetId::Antigravity => environment.has("ANTIGRAVITY_CLI_ALIAS"), + AgentTargetId::Openclaw => ["OPENCLAW_SHELL", "OPENCLAW_CLI", "OPENCLAW_STATE_DIR"] + .iter() + .any(|name| environment.has(name)), + AgentTargetId::Hermes => ["HERMES_AGENT", "HERMES_SESSION_ID", "HERMES_HOME"] + .iter() + .any(|name| environment.has(name)), + AgentTargetId::Kiro => ["KIRO_HOME", "AGENT_CONTEXT_OUT"] + .iter() + .any(|name| environment.has(name)), + AgentTargetId::Kilo => ["KILO_RUN_ID", "KILO_PID"] + .iter() + .any(|name| environment.has(name)), + AgentTargetId::Droid => false, + AgentTargetId::Trae => false, + AgentTargetId::Zed => environment.has("ZED_TERM"), } } @@ -2963,6 +2979,13 @@ fn detects_project(agent: AgentTargetId, root: &Path) -> bool { AgentTargetId::Opencode => exists(".opencode"), AgentTargetId::Roo => exists(".roo"), AgentTargetId::Antigravity => exists(".agent"), + AgentTargetId::Openclaw => exists(".openclaw"), + AgentTargetId::Hermes => exists(".hermes"), + AgentTargetId::Kiro => exists(".kiro"), + AgentTargetId::Kilo => exists(".kilo"), + AgentTargetId::Droid => exists(".factory"), + AgentTargetId::Trae => exists(".trae"), + AgentTargetId::Zed => exists(".zed"), } } @@ -2982,6 +3005,13 @@ fn detects_installed(agent: AgentTargetId, roots: &TargetRoots) -> bool { AgentTargetId::Opencode => roots.config_home.join("opencode").exists(), AgentTargetId::Roo => roots.home.join(".roo").exists(), AgentTargetId::Antigravity => roots.home.join(".gemini/antigravity").exists(), + AgentTargetId::Openclaw => roots.home.join(".openclaw").exists(), + AgentTargetId::Hermes => roots.home.join(".hermes").exists(), + AgentTargetId::Kiro => roots.home.join(".kiro").exists(), + AgentTargetId::Kilo => roots.home.join(".kilo").exists(), + AgentTargetId::Droid => roots.home.join(".factory").exists(), + AgentTargetId::Trae => roots.home.join(".trae").exists(), + AgentTargetId::Zed => roots.config_home.join("zed").exists(), } } diff --git a/crates/skilld-command/tests/agent_targets.rs b/crates/skilld-command/tests/agent_targets.rs index 4e1d127d..a1e88a1f 100644 --- a/crates/skilld-command/tests/agent_targets.rs +++ b/crates/skilld-command/tests/agent_targets.rs @@ -38,6 +38,13 @@ fn every_project_signal_selects_the_matching_agent_target() { (AgentTargetId::Opencode, ".opencode", ".opencode/skills"), (AgentTargetId::Roo, ".roo", ".roo/skills"), (AgentTargetId::Antigravity, ".agent", ".agent/skills"), + (AgentTargetId::Openclaw, ".openclaw", "skills"), + (AgentTargetId::Hermes, ".hermes", ".hermes/skills"), + (AgentTargetId::Kiro, ".kiro", ".kiro/skills"), + (AgentTargetId::Kilo, ".kilo", ".kilo/skills"), + (AgentTargetId::Droid, ".factory", ".factory/skills"), + (AgentTargetId::Trae, ".trae", ".trae/skills"), + (AgentTargetId::Zed, ".zed", ".agents/skills"), ]; for (agent, signal, skills_dir) in cases { @@ -105,6 +112,11 @@ fn every_runtime_signal_selects_the_matching_agent_target() { "ANTIGRAVITY_CLI_ALIAS", ".agent/skills", ), + (AgentTargetId::Openclaw, "OPENCLAW_SHELL", "skills"), + (AgentTargetId::Hermes, "HERMES_AGENT", ".hermes/skills"), + (AgentTargetId::Kiro, "AGENT_CONTEXT_OUT", ".kiro/skills"), + (AgentTargetId::Kilo, "KILO_RUN_ID", ".kilo/skills"), + (AgentTargetId::Zed, "ZED_TERM", ".agents/skills"), ]; for (agent, signal, skills_dir) in cases { @@ -132,6 +144,54 @@ fn every_runtime_signal_selects_the_matching_agent_target() { } } +#[test] +fn every_new_agent_target_resolves_its_global_and_project_paths() { + let cases = [ + (AgentTargetId::Openclaw, ".openclaw/skills", "skills"), + (AgentTargetId::Hermes, ".hermes/skills", ".hermes/skills"), + (AgentTargetId::Kiro, ".kiro/skills", ".kiro/skills"), + (AgentTargetId::Kilo, ".kilo/skills", ".kilo/skills"), + (AgentTargetId::Droid, ".factory/skills", ".factory/skills"), + (AgentTargetId::Trae, ".trae/skills", ".trae/skills"), + (AgentTargetId::Zed, ".agents/skills", ".agents/skills"), + ]; + + for (agent, global_dir, project_dir) in cases { + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().join("project"); + let data = temporary.path().join("data"); + let home = temporary.path().join("home"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&home).unwrap(); + let source = source(temporary.path()); + let host = LocalHost::new(project.clone(), data).with_target_roots(TargetRoots::new( + home.clone(), + home.join(".config"), + home.join(".claude"), + )); + + for (scope, root, dir) in [ + (InstallScope::Global, &home, global_dir), + (InstallScope::Project, &project, project_dir), + ] { + host.install_request(InstallRequest { + operation: InstallOperation::Install(InstallSource::Local(source.clone())), + scope, + targets: vec![agent], + mode: None, + }) + .unwrap(); + + assert!( + root.join(dir).join("example/SKILL.md").exists(), + "{} {:?}", + agent.as_str(), + scope + ); + } + } +} + #[test] fn an_existing_global_target_directory_is_detected() { let temporary = tempfile::tempdir().unwrap(); diff --git a/crates/skilld-command/tests/outdated.rs b/crates/skilld-command/tests/outdated.rs index 70dd952e..e01c5289 100644 --- a/crates/skilld-command/tests/outdated.rs +++ b/crates/skilld-command/tests/outdated.rs @@ -564,7 +564,7 @@ fn outdated_all_reports_unmanaged_skills_without_a_match() { assert_eq!(result.exit_code, 0); assert_eq!( String::from_utf8(stdout).unwrap(), - "No Repository match for 1 Skill (private-skill (codex)).\n" + "No Repository match for 1 Skill (private-skill (codex, zed)).\n" ); } @@ -593,7 +593,7 @@ fn outdated_all_surfaces_a_search_failure_and_keeps_scanning() { assert_eq!(result.exit_code, 0); assert_eq!( String::from_utf8(stdout).unwrap(), - "Skill search unavailable for 2 Skills (other-skill (codex), vue-testing (claude-code)): Skill search returned invalid JSON.\n" + "Skill search unavailable for 2 Skills (other-skill (codex, zed), vue-testing (claude-code)): Skill search returned invalid JSON.\n" ); } @@ -623,7 +623,7 @@ fn outdated_search_failures_are_single_line_and_terminal_safe() { assert!(stderr.is_empty()); assert_eq!( String::from_utf8(stdout.clone()).unwrap(), - "Skill search unavailable for 1 Skill (search-failure (codex)): request [31m forged line.\n" + "Skill search unavailable for 1 Skill (search-failure (codex, zed)): request [31m forged line.\n" ); stdout.clear(); let human = run_with_output( @@ -644,7 +644,7 @@ fn outdated_search_failures_are_single_line_and_terminal_safe() { String::from_utf8(stdout).unwrap(), concat!( "⚠ Skill search unavailable: request [31m forged line\n", - " search-failure codex\n" + " search-failure codex, zed\n" ) ); } @@ -832,7 +832,9 @@ fn outdated_all_survives_a_corrupt_global_store() { output.starts_with("Skill store unavailable in global scope: "), "expected a store failure line, got: {output}" ); - assert!(output.contains("No Repository match for 1 Skill (unmanaged-project (amp, codex)).\n")); + assert!( + output.contains("No Repository match for 1 Skill (unmanaged-project (amp, codex, zed)).\n") + ); assert!( !output.contains("hidden-global"), "a scope with an unreadable lockfile must not report its Skills: {output}" @@ -861,7 +863,7 @@ fn outdated_all_groups_agents_sharing_one_directory() { assert_eq!(result.exit_code, 0); let output = String::from_utf8(stdout).unwrap(); assert!( - output.contains("No Repository match for 1 Skill (shared (amp, codex)).\n"), + output.contains("No Repository match for 1 Skill (shared (amp, codex, zed)).\n"), "expected both agents sharing .agents/skills, got: {output}" ); } diff --git a/crates/skilld-core/src/lib.rs b/crates/skilld-core/src/lib.rs index 10d48f12..c02369b9 100644 --- a/crates/skilld-core/src/lib.rs +++ b/crates/skilld-core/src/lib.rs @@ -19,7 +19,8 @@ pub use remote::{ }; use serde::{Deserialize, Serialize}; pub use target::{ - AGENT_TARGETS, AgentTarget, AgentTargetId, GlobalTargetPath, TargetSelection, select_target_ids, + AGENT_TARGETS, ALL_AGENT_TARGETS, AgentTarget, AgentTargetId, GlobalTargetPath, + TargetSelection, parse_agent_targets, select_target_ids, }; pub use update::{ CommitAuthor, CommitHistory, CommitSha, CommitSummary, NotTrackedReason, UpdateFailure, diff --git a/crates/skilld-core/src/target.rs b/crates/skilld-core/src/target.rs index c6993e94..b00c72a1 100644 --- a/crates/skilld-core/src/target.rs +++ b/crates/skilld-core/src/target.rs @@ -19,6 +19,13 @@ pub enum AgentTargetId { Opencode, Roo, Antigravity, + Openclaw, + Hermes, + Kiro, + Kilo, + Droid, + Trae, + Zed, } impl AgentTargetId { @@ -36,6 +43,13 @@ impl AgentTargetId { Self::Opencode => "opencode", Self::Roo => "roo", Self::Antigravity => "antigravity", + Self::Openclaw => "openclaw", + Self::Hermes => "hermes", + Self::Kiro => "kiro", + Self::Kilo => "kilo", + Self::Droid => "droid", + Self::Trae => "trae", + Self::Zed => "zed", } } @@ -69,7 +83,7 @@ pub struct AgentTarget { pub global_skills_dir: GlobalTargetPath, } -pub const AGENT_TARGETS: [AgentTarget; 12] = [ +pub const AGENT_TARGETS: [AgentTarget; 19] = [ AgentTarget { id: AgentTargetId::ClaudeCode, display_name: "Claude Code", @@ -142,8 +156,64 @@ pub const AGENT_TARGETS: [AgentTarget; 12] = [ project_skills_dir: ".agent/skills", global_skills_dir: GlobalTargetPath::Home(".gemini/antigravity/skills"), }, + AgentTarget { + id: AgentTargetId::Openclaw, + display_name: "OpenClaw", + project_skills_dir: "skills", + global_skills_dir: GlobalTargetPath::Home(".openclaw/skills"), + }, + AgentTarget { + id: AgentTargetId::Hermes, + display_name: "Hermes Agent", + project_skills_dir: ".hermes/skills", + global_skills_dir: GlobalTargetPath::Home(".hermes/skills"), + }, + AgentTarget { + id: AgentTargetId::Kiro, + display_name: "Kiro CLI", + project_skills_dir: ".kiro/skills", + global_skills_dir: GlobalTargetPath::Home(".kiro/skills"), + }, + AgentTarget { + id: AgentTargetId::Kilo, + display_name: "Kilo Code", + project_skills_dir: ".kilo/skills", + global_skills_dir: GlobalTargetPath::Home(".kilo/skills"), + }, + AgentTarget { + id: AgentTargetId::Droid, + display_name: "Droid", + project_skills_dir: ".factory/skills", + global_skills_dir: GlobalTargetPath::Home(".factory/skills"), + }, + AgentTarget { + id: AgentTargetId::Trae, + display_name: "Trae", + project_skills_dir: ".trae/skills", + global_skills_dir: GlobalTargetPath::Home(".trae/skills"), + }, + AgentTarget { + id: AgentTargetId::Zed, + display_name: "Zed", + project_skills_dir: ".agents/skills", + global_skills_dir: GlobalTargetPath::Home(".agents/skills"), + }, ]; +/// The `--agent` value that selects every known Agent target. +pub const ALL_AGENT_TARGETS: &str = "all"; + +/// Parse `--agent` values. `all` expands to every known Agent target in registry order. +pub fn parse_agent_targets(values: &[String]) -> Result, DomainError> { + if values.iter().any(|value| value == ALL_AGENT_TARGETS) { + return Ok(AGENT_TARGETS.iter().map(|target| target.id).collect()); + } + values + .iter() + .map(|value| AgentTargetId::parse(value)) + .collect() +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum TargetSelection { Explicit(Vec), diff --git a/crates/skilld-core/tests/target.rs b/crates/skilld-core/tests/target.rs new file mode 100644 index 00000000..7db982ab --- /dev/null +++ b/crates/skilld-core/tests/target.rs @@ -0,0 +1,47 @@ +use skilld_core::{AGENT_TARGETS, AgentTargetId, DomainError, parse_agent_targets}; + +#[test] +fn every_new_agent_target_parses_by_name() { + let cases = [ + ("openclaw", AgentTargetId::Openclaw), + ("hermes", AgentTargetId::Hermes), + ("kiro", AgentTargetId::Kiro), + ("kilo", AgentTargetId::Kilo), + ("droid", AgentTargetId::Droid), + ("trae", AgentTargetId::Trae), + ("zed", AgentTargetId::Zed), + ]; + for (name, expected) in cases { + assert_eq!(AgentTargetId::parse(name), Ok(expected)); + assert_eq!(expected.as_str(), name); + } +} + +#[test] +fn all_expands_to_every_agent_target_in_registry_order() { + let expected = AGENT_TARGETS + .iter() + .map(|target| target.id) + .collect::>(); + + assert_eq!( + parse_agent_targets(&["all".to_owned()]), + Ok(expected.clone()) + ); + assert_eq!( + parse_agent_targets(&["codex".to_owned(), "all".to_owned()]), + Ok(expected) + ); +} + +#[test] +fn named_agent_targets_parse_in_the_given_order() { + assert_eq!( + parse_agent_targets(&["zed".to_owned(), "kiro".to_owned()]), + Ok(vec![AgentTargetId::Zed, AgentTargetId::Kiro]) + ); + assert_eq!( + parse_agent_targets(&["kiro".to_owned(), "nope".to_owned()]), + Err(DomainError::InvalidTarget("nope".to_owned())) + ); +} diff --git a/crates/skilld-native/src/main.rs b/crates/skilld-native/src/main.rs index aa49745a..dc378476 100644 --- a/crates/skilld-native/src/main.rs +++ b/crates/skilld-native/src/main.rs @@ -230,7 +230,7 @@ fn target_roots() -> TargetRoots { } fn detection_environment() -> DetectionEnvironment { - const SIGNALS: [&str; 18] = [ + const SIGNALS: [&str; 29] = [ "CLAUDE_CODE", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", @@ -249,6 +249,17 @@ fn detection_environment() -> DetectionEnvironment { "OPENCODE_SESSION_ID", "ROO_SESSION", "ANTIGRAVITY_CLI_ALIAS", + "OPENCLAW_SHELL", + "OPENCLAW_CLI", + "OPENCLAW_STATE_DIR", + "HERMES_AGENT", + "HERMES_SESSION_ID", + "HERMES_HOME", + "KIRO_HOME", + "AGENT_CONTEXT_OUT", + "KILO_RUN_ID", + "KILO_PID", + "ZED_TERM", ]; DetectionEnvironment::new( SIGNALS @@ -259,7 +270,7 @@ fn detection_environment() -> DetectionEnvironment { } fn active_agent_detected() -> bool { - const SIGNALS: [&str; 17] = [ + const SIGNALS: [&str; 28] = [ "CLAUDE_CODE", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", @@ -277,6 +288,17 @@ fn active_agent_detected() -> bool { "OPENCODE_SESSION_ID", "ROO_SESSION", "ANTIGRAVITY_CLI_ALIAS", + "OPENCLAW_SHELL", + "OPENCLAW_CLI", + "OPENCLAW_STATE_DIR", + "HERMES_AGENT", + "HERMES_SESSION_ID", + "HERMES_HOME", + "KIRO_HOME", + "AGENT_CONTEXT_OUT", + "KILO_RUN_ID", + "KILO_PID", + "ZED_TERM", ]; SIGNALS.iter().any(|name| environment_enabled(name)) } diff --git a/crates/skilld-native/tests/cli.rs b/crates/skilld-native/tests/cli.rs index 1e9726db..1a261d87 100644 --- a/crates/skilld-native/tests/cli.rs +++ b/crates/skilld-native/tests/cli.rs @@ -11,7 +11,7 @@ use std::process::{Command, Output}; #[cfg(unix)] use nix::pty::{Winsize, openpty}; -const DETECTION_SIGNALS: [&str; 18] = [ +const DETECTION_SIGNALS: [&str; 29] = [ "CLAUDE_CODE", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", @@ -30,6 +30,17 @@ const DETECTION_SIGNALS: [&str; 18] = [ "OPENCODE_SESSION_ID", "ROO_SESSION", "ANTIGRAVITY_CLI_ALIAS", + "OPENCLAW_SHELL", + "OPENCLAW_CLI", + "OPENCLAW_STATE_DIR", + "HERMES_AGENT", + "HERMES_SESSION_ID", + "HERMES_HOME", + "KIRO_HOME", + "AGENT_CONTEXT_OUT", + "KILO_RUN_ID", + "KILO_PID", + "ZED_TERM", ]; fn binary() -> PathBuf { @@ -191,7 +202,9 @@ fn install_help_gives_agents_actionable_source_and_target_grammar() { "github:OWNER/REPOSITORY/SKILL_PATH#commit:SHA", "https://github.com/OWNER/REPOSITORY/tree/REF/SKILL_PATH", "Values: claude-code, cursor, windsurf, cline, codex, github-copilot,", - "gemini-cli, goose, amp, opencode, roo, antigravity.", + "gemini-cli, goose, amp, opencode, roo, antigravity, openclaw,", + "hermes, kiro, kilo, droid, trae, zed.", + "Use --agent all to select every Agent target.", "Repeat --agent to select several.", "Default: every Agent target skilld detects.", "If skilld detects none, it uses agent.targets.", @@ -343,6 +356,80 @@ fn empty_interactive_update_restores_the_terminal_before_its_summary() { assert!(restored < summary, "{output:?}"); } +#[test] +fn short_global_flag_installs_to_the_named_global_target() { + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().join("project"); + let data = temporary.path().join("data"); + let home = temporary.path().join("home"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&home).unwrap(); + + let install = run( + &project, + &data, + &home, + &[ + "install", + fixture().to_str().unwrap(), + "-g", + "--agent", + "kiro", + ], + ); + + assert!( + install.status.success(), + "{}", + String::from_utf8_lossy(&install.stderr) + ); + assert!(home.join(".kiro/skills/local-skill/SKILL.md").exists()); + assert!(!project.join(".kiro").exists()); + + let list = run(&project, &data, &home, &["list", "-g"]); + assert_eq!(String::from_utf8(list.stdout).unwrap(), "local-skill\n"); +} + +#[test] +fn agent_all_installs_to_every_known_target() { + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().join("project"); + let data = temporary.path().join("data"); + let home = temporary.path().join("home"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&home).unwrap(); + + let install = run( + &project, + &data, + &home, + &["install", fixture().to_str().unwrap(), "--agent", "all"], + ); + + assert!( + install.status.success(), + "{}", + String::from_utf8_lossy(&install.stderr) + ); + for dir in [ + ".claude/skills", + ".cursor/skills", + ".agents/skills", + ".github/skills", + "skills", + ".hermes/skills", + ".kiro/skills", + ".kilo/skills", + ".factory/skills", + ".trae/skills", + ] { + assert!( + project.join(dir).join("local-skill/SKILL.md").exists(), + "{dir}" + ); + } +} + #[test] fn local_install_list_view_and_remove_use_project_state() { let temporary = tempfile::tempdir().unwrap(); diff --git a/tests/fixtures/v3-rust/agent-targets.json b/tests/fixtures/v3-rust/agent-targets.json index 1e5bcdb7..15c1d5dc 100644 --- a/tests/fixtures/v3-rust/agent-targets.json +++ b/tests/fixtures/v3-rust/agent-targets.json @@ -10,5 +10,12 @@ { "id": "amp", "displayName": "Amp", "projectSkillsDir": ".agents/skills", "globalSkillsDir": "config:agents/skills" }, { "id": "opencode", "displayName": "OpenCode", "projectSkillsDir": ".opencode/skills", "globalSkillsDir": "config:opencode/skills" }, { "id": "roo", "displayName": "Roo Code", "projectSkillsDir": ".roo/skills", "globalSkillsDir": "home:.roo/skills" }, - { "id": "antigravity", "displayName": "Antigravity", "projectSkillsDir": ".agent/skills", "globalSkillsDir": "home:.gemini/antigravity/skills" } + { "id": "antigravity", "displayName": "Antigravity", "projectSkillsDir": ".agent/skills", "globalSkillsDir": "home:.gemini/antigravity/skills" }, + { "id": "openclaw", "displayName": "OpenClaw", "projectSkillsDir": "skills", "globalSkillsDir": "home:.openclaw/skills" }, + { "id": "hermes", "displayName": "Hermes Agent", "projectSkillsDir": ".hermes/skills", "globalSkillsDir": "home:.hermes/skills" }, + { "id": "kiro", "displayName": "Kiro CLI", "projectSkillsDir": ".kiro/skills", "globalSkillsDir": "home:.kiro/skills" }, + { "id": "kilo", "displayName": "Kilo Code", "projectSkillsDir": ".kilo/skills", "globalSkillsDir": "home:.kilo/skills" }, + { "id": "droid", "displayName": "Droid", "projectSkillsDir": ".factory/skills", "globalSkillsDir": "home:.factory/skills" }, + { "id": "trae", "displayName": "Trae", "projectSkillsDir": ".trae/skills", "globalSkillsDir": "home:.trae/skills" }, + { "id": "zed", "displayName": "Zed", "projectSkillsDir": ".agents/skills", "globalSkillsDir": "home:.agents/skills" } ] From 0750ad90baf39313ac12a786fa35d286c6d8d02d Mon Sep 17 00:00:00 2001 From: Harlan GitHub Agent Date: Tue, 1 Sep 2026 17:01:44 +1000 Subject: [PATCH 2/2] fix(cli): honor agent home overrides and stop bare skills dir auto-detecting openclaw --- crates/skilld-command/src/lib.rs | 37 +++++++-- crates/skilld-command/tests/agent_targets.rs | 83 ++++++++++++++++++++ crates/skilld-core/src/target.rs | 36 +++++++-- crates/skilld-core/tests/target.rs | 12 +++ crates/skilld-native/src/main.rs | 18 ++++- crates/skilld-native/tests/cli.rs | 51 ++++++++++++ tests/fixtures/v3-rust/agent-targets.json | 6 +- 7 files changed, 226 insertions(+), 17 deletions(-) diff --git a/crates/skilld-command/src/lib.rs b/crates/skilld-command/src/lib.rs index c8fbe022..c2e1b083 100644 --- a/crates/skilld-command/src/lib.rs +++ b/crates/skilld-command/src/lib.rs @@ -1065,14 +1065,27 @@ pub struct TargetRoots { pub home: PathBuf, pub config_home: PathBuf, pub claude_home: PathBuf, + pub openclaw_home: PathBuf, + pub hermes_home: PathBuf, + pub kiro_home: PathBuf, } impl TargetRoots { - pub fn new(home: PathBuf, config_home: PathBuf, claude_home: PathBuf) -> Self { + pub fn new( + home: PathBuf, + config_home: PathBuf, + claude_home: PathBuf, + openclaw_home: PathBuf, + hermes_home: PathBuf, + kiro_home: PathBuf, + ) -> Self { Self { home, config_home, claude_home, + openclaw_home, + hermes_home, + kiro_home, } } } @@ -1127,6 +1140,9 @@ impl LocalHost { home.clone(), home.join(".config"), home.join(".claude"), + home.join(".openclaw"), + home.join(".hermes"), + home.join(".kiro"), ), detection: DetectionEnvironment::default(), bundled_skill: None, @@ -1195,6 +1211,13 @@ impl LocalHost { GlobalTargetPath::ClaudeHome(path) => { self.target_roots.claude_home.join(path) } + GlobalTargetPath::OpenclawHome(path) => { + self.target_roots.openclaw_home.join(path) + } + GlobalTargetPath::HermesHome(path) => { + self.target_roots.hermes_home.join(path) + } + GlobalTargetPath::KiroHome(path) => self.target_roots.kiro_home.join(path), }, }; let root = absolute(&root)?; @@ -1208,7 +1231,8 @@ impl LocalHost { .iter() .filter(|target| match scope { InstallScope::Project => { - self.project_root.join(target.project_skills_dir).exists() + (target.auto_detects_project_dir() + && self.project_root.join(target.project_skills_dir).exists()) || detects_environment(target.id, &self.detection) || detects_project(target.id, &self.project_root) } @@ -3005,9 +3029,9 @@ fn detects_installed(agent: AgentTargetId, roots: &TargetRoots) -> bool { AgentTargetId::Opencode => roots.config_home.join("opencode").exists(), AgentTargetId::Roo => roots.home.join(".roo").exists(), AgentTargetId::Antigravity => roots.home.join(".gemini/antigravity").exists(), - AgentTargetId::Openclaw => roots.home.join(".openclaw").exists(), - AgentTargetId::Hermes => roots.home.join(".hermes").exists(), - AgentTargetId::Kiro => roots.home.join(".kiro").exists(), + AgentTargetId::Openclaw => roots.openclaw_home.exists(), + AgentTargetId::Hermes => roots.hermes_home.exists(), + AgentTargetId::Kiro => roots.kiro_home.exists(), AgentTargetId::Kilo => roots.home.join(".kilo").exists(), AgentTargetId::Droid => roots.home.join(".factory").exists(), AgentTargetId::Trae => roots.home.join(".trae").exists(), @@ -3020,6 +3044,9 @@ fn global_target_root(path: GlobalTargetPath, roots: &TargetRoots) -> PathBuf { GlobalTargetPath::Home(path) => roots.home.join(path), GlobalTargetPath::ConfigHome(path) => roots.config_home.join(path), GlobalTargetPath::ClaudeHome(path) => roots.claude_home.join(path), + GlobalTargetPath::OpenclawHome(path) => roots.openclaw_home.join(path), + GlobalTargetPath::HermesHome(path) => roots.hermes_home.join(path), + GlobalTargetPath::KiroHome(path) => roots.kiro_home.join(path), } } diff --git a/crates/skilld-command/tests/agent_targets.rs b/crates/skilld-command/tests/agent_targets.rs index a1e88a1f..932a7edf 100644 --- a/crates/skilld-command/tests/agent_targets.rs +++ b/crates/skilld-command/tests/agent_targets.rs @@ -168,6 +168,9 @@ fn every_new_agent_target_resolves_its_global_and_project_paths() { home.clone(), home.join(".config"), home.join(".claude"), + home.join(".openclaw"), + home.join(".hermes"), + home.join(".kiro"), )); for (scope, root, dir) in [ @@ -192,6 +195,83 @@ fn every_new_agent_target_resolves_its_global_and_project_paths() { } } +#[test] +fn a_first_party_skills_directory_alone_does_not_select_openclaw() { + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().join("project"); + let data = temporary.path().join("data"); + fs::create_dir_all(project.join("skills/team-skill")).unwrap(); + fs::write(project.join("skills/team-skill/SKILL.md"), "fixture").unwrap(); + fs::create_dir_all(project.join(".cursor")).unwrap(); + let source = source(temporary.path()); + let host = LocalHost::new(project.clone(), data); + + let names = host + .install_request(InstallRequest { + operation: InstallOperation::Install(InstallSource::Local(source)), + scope: InstallScope::Project, + targets: vec![], + mode: None, + }) + .unwrap(); + + assert_eq!(names, ["example"]); + let skills = fs::read_dir(project.join("skills")) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .collect::>(); + assert_eq!(skills, ["team-skill"]); + let view = host.view("example", InstallScope::Project).unwrap(); + assert_eq!(view.skill.targets[0].agent, AgentTargetId::Cursor); +} + +#[test] +fn overridden_agent_homes_receive_global_installs_and_detection() { + for (agent, home_name) in [ + (AgentTargetId::Openclaw, "openclaw-state"), + (AgentTargetId::Hermes, "hermes-home"), + (AgentTargetId::Kiro, "kiro-home"), + ] { + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().join("project"); + let data = temporary.path().join("data"); + let home = temporary.path().join("home"); + let agent_home = temporary.path().join(home_name); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&agent_home).unwrap(); + let host = LocalHost::new(project, data).with_target_roots(TargetRoots::new( + home.clone(), + home.join(".config"), + home.join(".claude"), + agent_home.clone(), + agent_home.clone(), + agent_home.clone(), + )); + + host.install_request(InstallRequest { + operation: InstallOperation::Install(InstallSource::Local(source(temporary.path()))), + scope: InstallScope::Global, + targets: vec![], + mode: None, + }) + .unwrap(); + + assert!( + agent_home.join("skills/example/SKILL.md").exists(), + "{}", + agent.as_str() + ); + assert!( + !home + .join(format!(".{}/skills/example/SKILL.md", agent.as_str())) + .exists(), + "{}", + agent.as_str() + ); + } +} + #[test] fn an_existing_global_target_directory_is_detected() { let temporary = tempfile::tempdir().unwrap(); @@ -205,6 +285,9 @@ fn an_existing_global_target_directory_is_detected() { home.clone(), home.join(".config"), home.join(".claude"), + home.join(".openclaw"), + home.join(".hermes"), + home.join(".kiro"), )); host.install_request(InstallRequest { diff --git a/crates/skilld-core/src/target.rs b/crates/skilld-core/src/target.rs index b00c72a1..f6aee1fa 100644 --- a/crates/skilld-core/src/target.rs +++ b/crates/skilld-core/src/target.rs @@ -73,6 +73,9 @@ pub enum GlobalTargetPath { Home(&'static str), ConfigHome(&'static str), ClaudeHome(&'static str), + OpenclawHome(&'static str), + HermesHome(&'static str), + KiroHome(&'static str), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -83,6 +86,14 @@ pub struct AgentTarget { pub global_skills_dir: GlobalTargetPath, } +impl AgentTarget { + /// A bare project directory such as `skills` is a common first-party + /// path, so its existence alone must not select this target. + pub fn auto_detects_project_dir(&self) -> bool { + self.project_skills_dir.starts_with('.') + } +} + pub const AGENT_TARGETS: [AgentTarget; 19] = [ AgentTarget { id: AgentTargetId::ClaudeCode, @@ -160,19 +171,19 @@ pub const AGENT_TARGETS: [AgentTarget; 19] = [ id: AgentTargetId::Openclaw, display_name: "OpenClaw", project_skills_dir: "skills", - global_skills_dir: GlobalTargetPath::Home(".openclaw/skills"), + global_skills_dir: GlobalTargetPath::OpenclawHome("skills"), }, AgentTarget { id: AgentTargetId::Hermes, display_name: "Hermes Agent", project_skills_dir: ".hermes/skills", - global_skills_dir: GlobalTargetPath::Home(".hermes/skills"), + global_skills_dir: GlobalTargetPath::HermesHome("skills"), }, AgentTarget { id: AgentTargetId::Kiro, display_name: "Kiro CLI", project_skills_dir: ".kiro/skills", - global_skills_dir: GlobalTargetPath::Home(".kiro/skills"), + global_skills_dir: GlobalTargetPath::KiroHome("skills"), }, AgentTarget { id: AgentTargetId::Kilo, @@ -205,13 +216,19 @@ pub const ALL_AGENT_TARGETS: &str = "all"; /// Parse `--agent` values. `all` expands to every known Agent target in registry order. pub fn parse_agent_targets(values: &[String]) -> Result, DomainError> { - if values.iter().any(|value| value == ALL_AGENT_TARGETS) { + let mut parsed = Vec::with_capacity(values.len()); + let mut expand_all = false; + for value in values { + if value == ALL_AGENT_TARGETS { + expand_all = true; + continue; + } + parsed.push(AgentTargetId::parse(value)?); + } + if expand_all { return Ok(AGENT_TARGETS.iter().map(|target| target.id).collect()); } - values - .iter() - .map(|value| AgentTargetId::parse(value)) - .collect() + Ok(parsed) } #[derive(Clone, Debug, Eq, PartialEq)] @@ -288,6 +305,9 @@ mod tests { GlobalTargetPath::Home(path) => format!("home:{path}"), GlobalTargetPath::ConfigHome(path) => format!("config:{path}"), GlobalTargetPath::ClaudeHome(path) => format!("claude:{path}"), + GlobalTargetPath::OpenclawHome(path) => format!("openclaw:{path}"), + GlobalTargetPath::HermesHome(path) => format!("hermes:{path}"), + GlobalTargetPath::KiroHome(path) => format!("kiro:{path}"), }, }) .collect::>(); diff --git a/crates/skilld-core/tests/target.rs b/crates/skilld-core/tests/target.rs index 7db982ab..9ad89426 100644 --- a/crates/skilld-core/tests/target.rs +++ b/crates/skilld-core/tests/target.rs @@ -34,6 +34,18 @@ fn all_expands_to_every_agent_target_in_registry_order() { ); } +#[test] +fn all_with_an_invalid_value_fails_validation() { + assert_eq!( + parse_agent_targets(&["all".to_owned(), "nope".to_owned()]), + Err(DomainError::InvalidTarget("nope".to_owned())) + ); + assert_eq!( + parse_agent_targets(&["nope".to_owned(), "all".to_owned()]), + Err(DomainError::InvalidTarget("nope".to_owned())) + ); +} + #[test] fn named_agent_targets_parse_in_the_given_order() { assert_eq!( diff --git a/crates/skilld-native/src/main.rs b/crates/skilld-native/src/main.rs index dc378476..c0204b39 100644 --- a/crates/skilld-native/src/main.rs +++ b/crates/skilld-native/src/main.rs @@ -226,7 +226,23 @@ fn target_roots() -> TargetRoots { let claude_home = env::var_os("CLAUDE_CONFIG_DIR") .map(PathBuf::from) .unwrap_or_else(|| home.join(".claude")); - TargetRoots::new(home, config_home, claude_home) + let openclaw_home = env::var_os("OPENCLAW_STATE_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".openclaw")); + let hermes_home = env::var_os("HERMES_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".hermes")); + let kiro_home = env::var_os("KIRO_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".kiro")); + TargetRoots::new( + home, + config_home, + claude_home, + openclaw_home, + hermes_home, + kiro_home, + ) } fn detection_environment() -> DetectionEnvironment { diff --git a/crates/skilld-native/tests/cli.rs b/crates/skilld-native/tests/cli.rs index 1a261d87..fcaabc56 100644 --- a/crates/skilld-native/tests/cli.rs +++ b/crates/skilld-native/tests/cli.rs @@ -430,6 +430,57 @@ fn agent_all_installs_to_every_known_target() { } } +#[test] +fn agent_home_overrides_relocate_the_global_target() { + for (agent, variable, default_directory) in [ + ("hermes", "HERMES_HOME", ".hermes/skills"), + ("kiro", "KIRO_HOME", ".kiro/skills"), + ("openclaw", "OPENCLAW_STATE_DIR", ".openclaw/skills"), + ] { + let temporary = tempfile::tempdir().unwrap(); + let project = temporary.path().join("project"); + let data = temporary.path().join("data"); + let home = temporary.path().join("home"); + let override_home = temporary.path().join(format!("{agent}-home")); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(&home).unwrap(); + + let mut command = Command::new(binary()); + for signal in DETECTION_SIGNALS { + command.env_remove(signal); + } + let install = command + .current_dir(&project) + .env("SKILLD_DATA_DIR", &data) + .env("HOME", &home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env(variable, &override_home) + .args([ + "install", + fixture().to_str().unwrap(), + "-g", + "--agent", + agent, + ]) + .output() + .unwrap(); + + assert!( + install.status.success(), + "{agent}: {}", + String::from_utf8_lossy(&install.stderr) + ); + assert!( + override_home.join("skills/local-skill/SKILL.md").exists(), + "{agent}" + ); + assert!( + !home.join(default_directory).exists(), + "{agent}: default directory was created" + ); + } +} + #[test] fn local_install_list_view_and_remove_use_project_state() { let temporary = tempfile::tempdir().unwrap(); diff --git a/tests/fixtures/v3-rust/agent-targets.json b/tests/fixtures/v3-rust/agent-targets.json index 15c1d5dc..0656ac1f 100644 --- a/tests/fixtures/v3-rust/agent-targets.json +++ b/tests/fixtures/v3-rust/agent-targets.json @@ -11,9 +11,9 @@ { "id": "opencode", "displayName": "OpenCode", "projectSkillsDir": ".opencode/skills", "globalSkillsDir": "config:opencode/skills" }, { "id": "roo", "displayName": "Roo Code", "projectSkillsDir": ".roo/skills", "globalSkillsDir": "home:.roo/skills" }, { "id": "antigravity", "displayName": "Antigravity", "projectSkillsDir": ".agent/skills", "globalSkillsDir": "home:.gemini/antigravity/skills" }, - { "id": "openclaw", "displayName": "OpenClaw", "projectSkillsDir": "skills", "globalSkillsDir": "home:.openclaw/skills" }, - { "id": "hermes", "displayName": "Hermes Agent", "projectSkillsDir": ".hermes/skills", "globalSkillsDir": "home:.hermes/skills" }, - { "id": "kiro", "displayName": "Kiro CLI", "projectSkillsDir": ".kiro/skills", "globalSkillsDir": "home:.kiro/skills" }, + { "id": "openclaw", "displayName": "OpenClaw", "projectSkillsDir": "skills", "globalSkillsDir": "openclaw:skills" }, + { "id": "hermes", "displayName": "Hermes Agent", "projectSkillsDir": ".hermes/skills", "globalSkillsDir": "hermes:skills" }, + { "id": "kiro", "displayName": "Kiro CLI", "projectSkillsDir": ".kiro/skills", "globalSkillsDir": "kiro:skills" }, { "id": "kilo", "displayName": "Kilo Code", "projectSkillsDir": ".kilo/skills", "globalSkillsDir": "home:.kilo/skills" }, { "id": "droid", "displayName": "Droid", "projectSkillsDir": ".factory/skills", "globalSkillsDir": "home:.factory/skills" }, { "id": "trae", "displayName": "Trae", "projectSkillsDir": ".trae/skills", "globalSkillsDir": "home:.trae/skills" },