Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 69 additions & 12 deletions crates/skilld-command/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -80,14 +80,15 @@ enum Command {
#[arg(value_name = "SOURCE")]
source: Option<String>,
#[arg(
short = 'g',
long,
long_help = "Install to your account-level Agent targets. The default is the current project."
)]
global: bool,
#[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<String>,
#[arg(
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -815,10 +819,7 @@ fn dispatch<H: Host>(
"install the skilld-maintained Skill with --global",
));
}
let targets = agents
.iter()
.map(|agent| AgentTargetId::parse(agent).map_err(CommandError::domain))
.collect::<Result<Vec<_>, _>>()?;
let targets = parse_agent_targets(&agents).map_err(CommandError::domain)?;
let mode = mode
.as_deref()
.map(InstallMode::parse)
Expand Down Expand Up @@ -1064,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,
}
}
}
Expand Down Expand Up @@ -1126,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,
Expand Down Expand Up @@ -1194,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)?;
Expand All @@ -1207,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)
}
Expand Down Expand Up @@ -2935,6 +2960,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"),
}
}

Expand Down Expand Up @@ -2963,6 +3003,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"),
}
}

Expand All @@ -2982,6 +3029,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.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(),
AgentTargetId::Zed => roots.config_home.join("zed").exists(),
}
}

Expand All @@ -2990,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),
}
}

Expand Down
143 changes: 143 additions & 0 deletions crates/skilld-command/tests/agent_targets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -132,6 +144,134 @@ 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"),
home.join(".openclaw"),
home.join(".hermes"),
home.join(".kiro"),
));

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 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::<Vec<_>>();
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();
Expand All @@ -145,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 {
Expand Down
Loading