fix: keep skills lifecycle local

This commit is contained in:
bellman
2026-06-04 03:58:35 +09:00
parent 4522490bd5
commit 22fdaeae2c
6 changed files with 805 additions and 130 deletions

View File

@@ -100,7 +100,7 @@ Primary artifacts:
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
| Plugin management surfaces | ✅ |
| Skills inventory / install surfaces | ✅ |
| Skills inventory / install / uninstall surfaces | ✅ |
| Machine-readable JSON output across core CLI surfaces | ✅ |
## Model Aliases
@@ -168,8 +168,8 @@ The REPL now exposes a much broader surface than the original minimal shell:
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
Notable claw-first surfaces now available directly in slash form:
- `/skills [list|install <path>|help]`
- `/agents [list|help]`
- `/skills [list|show <name>|install <path>|uninstall <name>|help]`
- `/agents [list|show <name>|create <name>|help]`
- `/mcp [list|show <server>|help]`
- `/doctor`
- `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]`

View File

@@ -239,15 +239,15 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec {
name: "agents",
aliases: &[],
summary: "List configured agents",
argument_hint: Some("[list|help]"),
summary: "List, show, or create configured agents",
argument_hint: Some("[list|show <name>|create <name>|help]"),
resume_supported: true,
},
SlashCommandSpec {
name: "skills",
aliases: &["skill"],
summary: "List, install, or invoke available skills",
argument_hint: Some("[list|install <path>|help|<skill> [args]]"),
summary: "List, install, uninstall, or invoke available skills",
argument_hint: Some("[list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"),
resume_supported: true,
},
SlashCommandSpec {
@@ -1767,13 +1767,25 @@ fn parse_list_or_help_args(
args: Option<String>,
) -> Result<Option<String>, SlashCommandParseError> {
match normalize_optional_args(args.as_deref()) {
None | Some("list" | "help" | "-h" | "--help") => Ok(args),
None
| Some(
"list" | "help" | "-h" | "--help" | "show" | "info" | "describe" | "create",
) => Ok(args),
Some(value)
if value.starts_with("list ")
|| value.starts_with("show ")
|| value.starts_with("info ")
|| value.starts_with("describe ")
|| value.starts_with("create ") =>
{
Ok(args)
}
Some(unexpected) => Err(command_error(
&format!(
"Unexpected arguments for /{command}: {unexpected}. Use /{command}, /{command} list, or /{command} help."
"Unexpected arguments for /{command}: {unexpected}. Use /{command}, /{command} list, /{command} show <name>, /{command} create <name>, or /{command} help."
),
command,
&format!("/{command} [list|help]"),
&format!("/{command} [list|show <name>|create <name>|help]"),
)),
}
}
@@ -1787,14 +1799,6 @@ fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandP
return Ok(Some(args.to_string()));
}
if args == "install" {
return Err(command_error(
"Usage: /skills install <path>",
"skills",
"/skills install <path>",
));
}
if let Some(target) = args.strip_prefix("install").map(str::trim) {
if !target.is_empty() {
return Ok(Some(format!("install {target}")));
@@ -2195,6 +2199,30 @@ struct InstalledSkill {
installed_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct UninstalledSkill {
invocation_name: String,
registry_root: PathBuf,
removed_path: PathBuf,
available_names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SkillUninstallOutcome {
Removed(UninstalledSkill),
Missing {
requested: String,
registry_root: PathBuf,
available_names: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CreatedAgent {
name: String,
path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SkillInstallSource {
Directory { root: PathBuf, prompt_path: PathBuf },
@@ -2422,10 +2450,32 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
}
Ok(render_agents_report(&matched))
}
Some("create") => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing_argument: agents create requires an agent name.\nUsage: claw agents create <name>",
)),
Some(args) if args.starts_with("create ") => {
let mut parts = args.split_whitespace();
let _ = parts.next();
let Some(name) = parts.next() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing_argument: agents create requires an agent name.\nUsage: claw agents create <name>",
));
};
if let Some(extra) = parts.next() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unexpected extra arguments after agent name\nUsage: claw agents create <name>\nUnexpected extra: '{extra}'"),
));
}
let agent = create_agent(name, cwd)?;
Ok(render_agent_create_report(&agent))
}
Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)),
Some(args) => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown agents subcommand: {args}.\nSupported: list, show, help"),
format!("unknown agents subcommand: {args}.\nSupported: list, show, create, help"),
)),
}
}
@@ -2522,10 +2572,32 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}
Ok(render_agents_report_json_with_action(cwd, &matched, "show"))
}
Some("create") => Ok(render_agents_missing_argument_json("create", "agent_name")),
Some(args) if args.starts_with("create ") => {
let mut parts = args.split_whitespace();
let _ = parts.next();
let Some(name) = parts.next() else {
return Ok(render_agents_missing_argument_json("create", "agent_name"));
};
if let Some(extra) = parts.next() {
return Ok(json!({
"kind": "agents",
"action": "create",
"status": "error",
"error_kind": "unexpected_extra_args",
"unexpected": extra,
"hint": format!("Usage: claw agents create <name>\nUnexpected extra: '{extra}'"),
}));
}
match create_agent(name, cwd) {
Ok(agent) => Ok(render_agent_create_report_json(&agent)),
Err(error) => Ok(render_agent_create_error_json(name, &error)),
}
}
Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)),
Some(args) => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown agents subcommand: {args}.\nSupported: list, show, help"),
format!("unknown agents subcommand: {args}.\nSupported: list, show, create, help"),
)),
}
}
@@ -2627,15 +2699,53 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
}
Ok(render_skills_report(&matched))
}
Some("install") => Ok(render_skills_usage(Some("install"))),
Some("install") => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing_argument: skills install requires an install source.\nUsage: claw skills install <path>",
)),
Some(args) if args.starts_with("install ") => {
let target = args["install ".len()..].trim();
if target.is_empty() {
return Ok(render_skills_usage(Some("install")));
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing_argument: skills install requires an install source.\nUsage: claw skills install <path>",
));
}
let install = install_skill(target, cwd)?;
Ok(render_skill_install_report(&install))
}
Some("uninstall" | "remove" | "delete") => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing_argument: skills uninstall requires a skill name.\nUsage: claw skills uninstall <name>",
)),
Some(args)
if args.starts_with("uninstall ")
|| args.starts_with("remove ")
|| args.starts_with("delete ") =>
{
let (_, target) = args.split_once(' ').unwrap_or_default();
let target = target.trim();
if target.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"missing_argument: skills uninstall requires a skill name.\nUsage: claw skills uninstall <name>",
));
}
match uninstall_skill(target)? {
SkillUninstallOutcome::Removed(skill) => Ok(render_skill_uninstall_report(&skill)),
SkillUninstallOutcome::Missing {
requested,
available_names,
..
} => Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"skill '{requested}' not found\nAvailable skills: {}\nRun `claw skills list` to see available skills.",
format_optional_list(&available_names)
),
)),
}
}
Some(args) if is_help_arg(args) => Ok(render_skills_usage(None)),
Some(args) => Ok(render_skills_usage(Some(args))),
}
@@ -2734,14 +2844,58 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}
Ok(render_skills_report_json_with_action(&matched, "show"))
}
Some("install") => Ok(render_skills_usage_json(Some("install"))),
Some("install") => Ok(render_skills_missing_argument_json(
"install",
"install_source",
"Usage: claw skills install <path>",
)),
Some(args) if args.starts_with("install ") => {
let target = args["install ".len()..].trim();
if target.is_empty() {
return Ok(render_skills_usage_json(Some("install")));
return Ok(render_skills_missing_argument_json(
"install",
"install_source",
"Usage: claw skills install <path>",
));
}
match install_skill(target, cwd) {
Ok(install) => Ok(render_skill_install_report_json(&install)),
Err(error) => Ok(render_skill_install_error_json(target, &error)),
}
}
Some("uninstall" | "remove" | "delete") => Ok(render_skills_missing_argument_json(
"uninstall",
"skill_name",
"Usage: claw skills uninstall <name>",
)),
Some(args)
if args.starts_with("uninstall ")
|| args.starts_with("remove ")
|| args.starts_with("delete ") =>
{
let (_, target) = args.split_once(' ').unwrap_or_default();
let target = target.trim();
if target.is_empty() {
return Ok(render_skills_missing_argument_json(
"uninstall",
"skill_name",
"Usage: claw skills uninstall <name>",
));
}
match uninstall_skill(target)? {
SkillUninstallOutcome::Removed(skill) => {
Ok(render_skill_uninstall_report_json(&skill))
}
SkillUninstallOutcome::Missing {
requested,
registry_root,
available_names,
} => Ok(render_skill_uninstall_missing_json(
&requested,
&registry_root,
&available_names,
)),
}
let install = install_skill(target, cwd)?;
Ok(render_skill_install_report_json(&install))
}
Some(args) if is_help_arg(args) => Ok(render_skills_usage_json(None)),
Some(args) => Ok(render_skills_usage_json(Some(args))),
@@ -2751,9 +2905,11 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
#[must_use]
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
match normalize_optional_args(args) {
None | Some("list" | "help" | "-h" | "--help" | "show" | "info" | "describe") => {
SkillSlashDispatch::Local
}
None
| Some(
"list" | "help" | "-h" | "--help" | "show" | "info" | "describe" | "install"
| "uninstall" | "remove" | "delete",
) => SkillSlashDispatch::Local,
Some(args)
if args
.split_whitespace()
@@ -2761,7 +2917,12 @@ pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
{
SkillSlashDispatch::Local
}
Some(args) if args == "install" || args.starts_with("install ") => {
Some(args)
if args.starts_with("install ")
|| args.starts_with("uninstall ")
|| args.starts_with("remove ")
|| args.starts_with("delete ") =>
{
SkillSlashDispatch::Local
}
Some(args)
@@ -2806,7 +2967,7 @@ pub fn resolve_skill_invocation(
message.push_str(&names.join(", "));
}
}
message.push_str("\n Usage: /skills [list|install <path>|help|<skill> [args]]");
message.push_str("\n Usage: /skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]");
return Err(message);
}
}
@@ -3461,6 +3622,103 @@ fn install_skill_into(
})
}
fn uninstall_skill(target: &str) -> std::io::Result<SkillUninstallOutcome> {
let registry_root = default_skill_install_root()?;
let requested = sanitize_skill_invocation_name(target).unwrap_or_else(|| {
target
.trim()
.trim_start_matches('/')
.trim_start_matches('$')
.to_ascii_lowercase()
});
let available_names = installed_skill_names(&registry_root)?;
let matched_name = available_names
.iter()
.find(|name| name.eq_ignore_ascii_case(&requested))
.cloned();
let Some(invocation_name) = matched_name else {
return Ok(SkillUninstallOutcome::Missing {
requested,
registry_root,
available_names,
});
};
let removed_path = registry_root.join(&invocation_name);
if removed_path.is_dir() {
fs::remove_dir_all(&removed_path)?;
} else {
fs::remove_file(&removed_path)?;
}
let available_names = available_names
.into_iter()
.filter(|name| !name.eq_ignore_ascii_case(&invocation_name))
.collect();
Ok(SkillUninstallOutcome::Removed(UninstalledSkill {
invocation_name,
registry_root,
removed_path,
available_names,
}))
}
fn installed_skill_names(registry_root: &Path) -> std::io::Result<Vec<String>> {
let entries = match fs::read_dir(registry_root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error),
};
let mut names = Vec::new();
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() && path.join("SKILL.md").is_file() {
names.push(entry.file_name().to_string_lossy().to_string());
} else if path
.extension()
.is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case("md"))
{
if let Some(stem) = path.file_stem() {
names.push(stem.to_string_lossy().to_string());
}
}
}
names.sort();
Ok(names)
}
fn create_agent(name: &str, cwd: &Path) -> std::io::Result<CreatedAgent> {
let Some(name) = sanitize_skill_invocation_name(name) else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"invalid_agent_name: agent name must contain at least one alphanumeric character",
));
};
let root = cwd.join(".claw").join("agents");
let path = root.join(format!("{name}.toml"));
if path.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"agent_already_exists: agent '{name}' already exists at {}",
path.display()
),
));
}
fs::create_dir_all(&root)?;
fs::write(
&path,
format!(
"name = \"{name}\"\ndescription = \"Describe when to use this agent.\"\nmodel_reasoning_effort = \"medium\"\n"
),
)?;
Ok(CreatedAgent { name, path })
}
fn default_skill_install_root() -> std::io::Result<PathBuf> {
if let Ok(claw_config_home) = env::var("CLAW_CONFIG_HOME") {
return Ok(PathBuf::from(claw_config_home).join("skills"));
@@ -3902,6 +4160,59 @@ fn render_agents_report_json_with_action(
})
}
fn render_agents_missing_argument_json(action: &str, argument: &str) -> Value {
json!({
"kind": "agents",
"action": action,
"status": "error",
"error_kind": "missing_argument",
"argument": argument,
"hint": "Usage: claw agents create <name>",
})
}
fn render_agent_create_report(agent: &CreatedAgent) -> String {
format!(
"Agents\n Result created {}\n Path {}\n Format TOML",
agent.name,
agent.path.display()
)
}
fn render_agent_create_report_json(agent: &CreatedAgent) -> Value {
json!({
"kind": "agents",
"status": "ok",
"action": "create",
"result": "created",
"name": &agent.name,
"path": agent.path.display().to_string(),
"format": "toml",
})
}
fn render_agent_create_error_json(name: &str, error: &std::io::Error) -> Value {
let message = error.to_string();
let error_kind = if message.starts_with("invalid_agent_name:") {
"invalid_agent_name"
} else if message.starts_with("agent_already_exists:")
|| error.kind() == std::io::ErrorKind::AlreadyExists
{
"agent_already_exists"
} else {
"agent_create_failed"
};
json!({
"kind": "agents",
"status": "error",
"action": "create",
"error_kind": error_kind,
"name": name,
"message": message,
"hint": "Use `claw agents create <name>` with a simple alphanumeric, dash, underscore, or dot name.",
})
}
fn agent_detail(agent: &AgentSummary) -> String {
let mut parts = vec![agent.name.clone()];
if let Some(description) = &agent.description {
@@ -4019,6 +4330,102 @@ fn render_skill_install_report_json(skill: &InstalledSkill) -> Value {
})
}
fn render_skills_missing_argument_json(action: &str, argument: &str, hint: &str) -> Value {
json!({
"kind": "skills",
"action": action,
"status": "error",
"error_kind": "missing_argument",
"argument": argument,
"hint": hint,
})
}
fn render_skill_install_error_json(target: &str, error: &std::io::Error) -> Value {
let source_kind = skill_install_source_kind(target);
json!({
"kind": "skills",
"action": "install",
"status": "error",
"error_kind": "invalid_install_source",
"source": target,
"source_kind": source_kind,
"reason": io_error_reason(error),
"message": format!("invalid install source: {error}"),
"hint": match source_kind {
"url" => "Remote skill install is not supported yet; pass a local directory containing SKILL.md or a markdown file.",
"name" => "Skill install expects a local path, not a registry name. Pass a directory containing SKILL.md or a markdown file.",
_ => "Check that the path exists and is a directory containing SKILL.md or a markdown file.",
},
})
}
fn render_skill_uninstall_report(skill: &UninstalledSkill) -> String {
format!(
"Skills\n Result uninstalled {}\n Registry {}\n Removed path {}\n Remaining {}",
skill.invocation_name,
skill.registry_root.display(),
skill.removed_path.display(),
format_optional_list(&skill.available_names)
)
}
fn render_skill_uninstall_report_json(skill: &UninstalledSkill) -> Value {
json!({
"kind": "skills",
"status": "ok",
"action": "uninstall",
"result": "removed",
"removed": &skill.invocation_name,
"skills_dir": skill.registry_root.display().to_string(),
"removed_path": skill.removed_path.display().to_string(),
"available_names": &skill.available_names,
})
}
fn render_skill_uninstall_missing_json(
requested: &str,
registry_root: &Path,
available_names: &[String],
) -> Value {
json!({
"kind": "skills",
"status": "error",
"action": "uninstall",
"error_kind": "skill_not_found",
"requested": requested,
"skills_dir": registry_root.display().to_string(),
"available_names": available_names,
"message": format!("skill '{requested}' not found"),
"hint": "Run `claw skills list` to see available skills.",
})
}
fn skill_install_source_kind(source: &str) -> &'static str {
let trimmed = source.trim();
if trimmed.contains("://") {
"url"
} else if Path::new(trimmed).is_absolute()
|| trimmed.starts_with('.')
|| trimmed.contains('/')
|| trimmed.contains('\\')
{
"path"
} else {
"name"
}
}
fn io_error_reason(error: &std::io::Error) -> &'static str {
match error.kind() {
std::io::ErrorKind::NotFound => "not_found",
std::io::ErrorKind::AlreadyExists => "already_exists",
std::io::ErrorKind::PermissionDenied => "permission_denied",
std::io::ErrorKind::InvalidInput => "invalid",
_ => "io_error",
}
}
fn render_mcp_summary_report(
cwd: &Path,
servers: &BTreeMap<String, ScopedMcpServerConfig>,
@@ -4188,8 +4595,10 @@ fn help_path_from_args(args: &str) -> Option<Vec<&str>> {
fn render_agents_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Agents".to_string(),
" Usage /agents [list|help]".to_string(),
" Direct CLI claw agents".to_string(),
" Usage /agents [list|show <name>|create <name>|help]".to_string(),
" Direct CLI claw agents [list|show <name>|create <name>|help]".to_string(),
" Format TOML files (.toml); create scaffolds .claw/agents/<name>.toml"
.to_string(),
" Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents".to_string(),
];
if let Some(args) = unexpected {
@@ -4205,8 +4614,10 @@ fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
"ok": unexpected.is_none(),
"status": if unexpected.is_some() { "error" } else { "ok" },
"usage": {
"slash_command": "/agents [list|help]",
"direct_cli": "claw agents [list|help]",
"slash_command": "/agents [list|show <name>|create <name>|help]",
"direct_cli": "claw agents [list|show <name>|create <name>|help]",
"format": "toml",
"create": "claw agents create <name>",
"sources": [".claw/agents", "~/.claw/agents", "$CLAW_CONFIG_HOME/agents"],
},
"unexpected": unexpected,
@@ -4216,9 +4627,10 @@ fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
fn render_skills_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Skills".to_string(),
" Usage /skills [list|install <path>|help|<skill> [args]]".to_string(),
" Usage /skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]".to_string(),
" Alias /skill".to_string(),
" Direct CLI claw skills [list|install <path>|help|<skill> [args]]".to_string(),
" Direct CLI claw skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]".to_string(),
" Lifecycle install <path>, uninstall <name>".to_string(),
" Invoke /skills help overview -> $help overview".to_string(),
" Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills".to_string(),
" Sources .claw/skills, .omc/skills, .agents/skills, .codex/skills, .claude/skills, ~/.claw/skills, ~/.omc/skills, ~/.claude/skills/omc-learned, ~/.codex/skills, ~/.claude/skills, legacy /commands".to_string(),
@@ -4236,9 +4648,10 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value {
"ok": unexpected.is_none(),
"status": if unexpected.is_some() { "error" } else { "ok" },
"usage": {
"slash_command": "/skills [list|install <path>|help|<skill> [args]]",
"slash_command": "/skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]",
"aliases": ["/skill"],
"direct_cli": "claw skills [list|install <path>|help|<skill> [args]]",
"direct_cli": "claw skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]",
"lifecycle": ["install <path>", "uninstall <name>"],
"invoke": "/skills help overview -> $help overview",
"install_root": "$CLAW_CONFIG_HOME/skills or ~/.claw/skills",
"sources": [
@@ -5113,16 +5526,17 @@ mod tests {
#[test]
fn rejects_invalid_agents_arguments() {
// given
let agents_input = "/agents show planner";
let agents_input = "/agents frobnicate";
// when
let agents_error = parse_error_message(agents_input);
// then
assert!(agents_error.contains(
"Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
"Unexpected arguments for /agents: frobnicate. Use /agents, /agents list, /agents show <name>, /agents create <name>, or /agents help."
));
assert!(agents_error.contains(" Usage /agents [list|help]"));
assert!(agents_error
.contains(" Usage /agents [list|show <name>|create <name>|help]"));
}
#[test]
@@ -5144,6 +5558,13 @@ mod tests {
"`skills {arg}` must be Local, not Invoke"
);
}
for arg in ["uninstall", "uninstall plan", "remove plan", "delete plan"] {
assert_eq!(
classify_skills_slash_command(Some(arg)),
SkillSlashDispatch::Local,
"`skills {arg}` must be Local, not Invoke"
);
}
// Bare invocable tokens still dispatch to Invoke.
assert_eq!(
classify_skills_slash_command(Some("plan")),
@@ -5171,6 +5592,10 @@ mod tests {
classify_skills_slash_command(Some("install ./skill-pack")),
SkillSlashDispatch::Local
);
assert_eq!(
classify_skills_slash_command(Some("uninstall help")),
SkillSlashDispatch::Local
);
}
#[test]
@@ -5263,8 +5688,10 @@ mod tests {
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
));
assert!(help.contains("aliases: /plugins, /marketplace"));
assert!(help.contains("/agents [list|help]"));
assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
assert!(help.contains("/agents [list|show <name>|create <name>|help]"));
assert!(help.contains(
"/skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"
));
assert!(help.contains("aliases: /skill"));
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
@@ -5609,10 +6036,27 @@ mod tests {
#[test]
fn renders_agents_reports_as_json() {
let _guard = env_guard();
let workspace = temp_dir("agents-json-workspace");
let project_agents = workspace.join(".codex").join("agents");
let user_home = temp_dir("agents-json-home");
let user_agents = user_home.join(".codex").join("agents");
let isolated_home = temp_dir("agents-json-isolated-home");
let config_home = temp_dir("agents-json-config-home");
let codex_home = temp_dir("agents-json-codex-home");
let claude_config = temp_dir("agents-json-claude-config");
fs::create_dir_all(&isolated_home).expect("isolated home");
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&codex_home).expect("codex home");
fs::create_dir_all(&claude_config).expect("claude config");
let original_home = std::env::var_os("HOME");
let original_claw_config_home = std::env::var_os("CLAW_CONFIG_HOME");
let original_codex_home = std::env::var_os("CODEX_HOME");
let original_claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
std::env::set_var("HOME", &isolated_home);
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::set_var("CODEX_HOME", &codex_home);
std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config);
write_agent(
&project_agents,
@@ -5664,7 +6108,10 @@ mod tests {
assert_eq!(help["kind"], "agents");
assert_eq!(help["action"], "help");
assert_eq!(help["status"], "ok");
assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
assert_eq!(
help["usage"]["direct_cli"],
"claw agents [list|show <name>|create <name>|help]"
);
// `show <name>` is now valid. Known agent returns ok with matching entry.
let show_planner = handle_agents_slash_command_json(Some("show planner"), &workspace)
@@ -5686,6 +6133,14 @@ mod tests {
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
restore_env_var("HOME", original_home);
restore_env_var("CLAW_CONFIG_HOME", original_claw_config_home);
restore_env_var("CODEX_HOME", original_codex_home);
restore_env_var("CLAUDE_CONFIG_DIR", original_claude_config_dir);
let _ = fs::remove_dir_all(isolated_home);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(codex_home);
let _ = fs::remove_dir_all(claude_config);
}
#[test]
@@ -5816,7 +6271,7 @@ mod tests {
assert_eq!(help["usage"]["aliases"][0], "/skill");
assert_eq!(
help["usage"]["direct_cli"],
"claw skills [list|install <path>|help|<skill> [args]]"
"claw skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"
);
let _ = fs::remove_dir_all(workspace);
@@ -5829,13 +6284,20 @@ mod tests {
let agents_help =
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
assert!(agents_help.contains("Usage /agents [list|help]"));
assert!(agents_help.contains("Direct CLI claw agents"));
assert!(
agents_help.contains("Usage /agents [list|show <name>|create <name>|help]")
);
assert!(agents_help
.contains("Direct CLI claw agents [list|show <name>|create <name>|help]"));
assert!(agents_help.contains(
"Format TOML files (.toml); create scaffolds .claw/agents/<name>.toml"
));
assert!(agents_help
.contains("Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents"));
// `show <name>` is now valid. For an agent that doesn't exist it returns Err(NotFound).
let agents_show_missing = super::handle_agents_slash_command(Some("show planner"), &cwd);
let agents_show_missing =
super::handle_agents_slash_command(Some("show definitely-missing-agent-431"), &cwd);
assert!(
agents_show_missing.is_err(),
"show of a missing agent should Err"
@@ -5854,9 +6316,11 @@ mod tests {
let skills_help =
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
assert!(skills_help
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
assert!(skills_help.contains(
"Usage /skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"
));
assert!(skills_help.contains("Alias /skill"));
assert!(skills_help.contains("Lifecycle install <path>, uninstall <name>"));
assert!(skills_help.contains("Invoke /skills help overview -> $help overview"));
assert!(skills_help.contains("Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills"));
assert!(skills_help.contains(".omc/skills"));
@@ -5870,15 +6334,17 @@ mod tests {
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
.expect("nested skills help");
assert!(skills_install_help
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
assert!(skills_install_help.contains(
"Usage /skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"
));
assert!(skills_install_help.contains("Alias /skill"));
assert!(skills_install_help.contains("Unexpected install"));
let skills_unknown_help =
super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help");
assert!(skills_unknown_help
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
assert!(skills_unknown_help.contains(
"Usage /skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"
));
assert!(skills_unknown_help.contains("Unexpected show"));
let skills_help_json =

View File

@@ -422,6 +422,8 @@ fn classify_error_kind(message: &str) -> &'static str {
"missing_argument"
} else if message.contains("unsupported skills action") {
"unsupported_skills_action"
} else if message.starts_with("invalid_install_source:") {
"invalid_install_source"
} else if message.starts_with("invalid_cwd:") {
"invalid_cwd"
} else if message.starts_with("invalid_output_path:") {
@@ -567,9 +569,12 @@ fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> {
"skill_not_found" => Some(
"Run `claw skills list` to see available skills, or `claw skills install <path>` to install a new one.",
),
// #795: unsupported action on skills (e.g. /skills uninstall) with no \n hint
// #795/#431: unsupported/invalid skills lifecycle input should include actionable local guidance.
"unsupported_skills_action" => Some(
"Supported: list, install <path>, show <name>, help. Run `claw skills help` for details.",
"Supported: list, show <name>, install <path>, uninstall <name>, help. Run `claw skills help` for details.",
),
"invalid_install_source" => Some(
"Pass a local skill directory containing SKILL.md or a standalone markdown file.",
),
_ => None,
}
@@ -1711,9 +1716,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let args = join_optional_args(&rest[1..]);
if let Some(action) = args.as_deref() {
let first_word = action.split_whitespace().next().unwrap_or(action);
if matches!(first_word, "remove" | "add" | "uninstall" | "delete") {
if matches!(first_word, "add") {
return Err(format!(
"unsupported skills action: {first_word}. Supported actions: list, install <path>, help, or <skill> [args]"
"unsupported skills action: {first_word}. Supported actions: list, show <name>, install <path>, uninstall <name>, help, or <skill> [args]"
));
}
}
@@ -14408,6 +14413,10 @@ mod tests {
classify_error_kind("unsupported skills action: bogus. Supported actions: list"),
"unsupported_skills_action"
);
assert_eq!(
classify_error_kind("invalid_install_source: bogus"),
"invalid_install_source"
);
assert_eq!(
classify_error_kind(
"missing_flag_value: missing value for --model.\nUsage: --model <provider/model>"
@@ -15056,17 +15065,27 @@ mod tests {
#[test]
fn unsupported_skills_actions_return_typed_error_683() {
for action in ["remove", "add", "uninstall", "delete"] {
let error = parse_args(&["skills".to_string(), action.to_string()])
.expect_err(&format!("skills {action} should error"));
assert!(
error.contains("unsupported skills action"),
"skills {action} should contain 'unsupported skills action', got: {error}"
);
let error = parse_args(&["skills".to_string(), "add".to_string()])
.expect_err("skills add should error");
assert!(
error.contains("unsupported skills action"),
"skills add should contain 'unsupported skills action', got: {error}"
);
assert_eq!(
classify_error_kind(&error),
"unsupported_skills_action",
"skills add should classify as unsupported_skills_action, got: {error}"
);
for action in ["remove", "uninstall", "delete"] {
assert_eq!(
classify_error_kind(&error),
"unsupported_skills_action",
"skills {action} should classify as unsupported_skills_action, got: {error}"
parse_args(&["skills".to_string(), action.to_string()])
.expect(&format!("skills {action} should parse")),
CliAction::Skills {
args: Some(action.to_string()),
output_format: CliOutputFormat::Text,
},
"skills {action} should route locally so missing targets are handled without credentials"
);
}
}

View File

@@ -2346,6 +2346,16 @@ fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output
command.output().expect("claw should launch")
}
fn parse_json_stdout(output: &Output, context: &str) -> Value {
serde_json::from_slice(&output.stdout).unwrap_or_else(|_| {
panic!(
"{context} should emit valid stdout JSON; stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
})
}
fn strings(items: &[&str]) -> Vec<String> {
items.iter().map(|item| (*item).to_string()).collect()
}
@@ -4531,86 +4541,254 @@ fn plugins_install_not_found_path_returns_typed_kind_794() {
}
#[test]
fn skills_install_not_found_and_unsupported_action_have_hints_795() {
// #795: `claw skills install /nonexistent` returned skill_not_found + hint:null, and
// `claw skills uninstall x` returned unsupported_skills_action + hint:null. Both error
// kinds were missing from fallback_hint_for_error_kind table. Fix: added both entries.
let root = unique_temp_dir("skills-install-795");
fs::create_dir_all(&root).expect("temp dir");
fn skills_lifecycle_errors_have_typed_local_json_795_431() {
// #431: skills install/uninstall lifecycle paths are local JSON surfaces and must not
// fall through to provider credential checks. #795: every error envelope needs a hint.
let root = unique_temp_dir("skills-lifecycle-431");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&home).expect("home");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
("ANTHROPIC_API_KEY", ""),
("ANTHROPIC_AUTH_TOKEN", ""),
("OPENAI_API_KEY", ""),
];
// skills install with nonexistent local path
let out1 = run_claw(
let missing_arg = run_claw(
&root,
&[
"--output-format",
"json",
"skills",
"install",
"/nonexistent-xyz-795",
],
&[],
&["skills", "install", "--output-format", "json"],
&envs,
);
assert_eq!(missing_arg.status.code(), Some(1));
assert!(
!out1.status.success(),
"skills install not-found must exit non-zero (#795)"
missing_arg.stderr.is_empty(),
"stderr: {}",
String::from_utf8_lossy(&missing_arg.stderr)
);
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let stdout1 = String::from_utf8_lossy(&out1.stdout);
let j1: serde_json::Value = stdout1
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("skills install not-found should emit JSON error");
assert_eq!(
j1["error_kind"], "skill_not_found",
"skills install not-found should be skill_not_found, got {:?}",
j1["error_kind"]
);
let h1 = j1["hint"]
let missing_arg_json = parse_json_stdout(&missing_arg, "skills install missing source");
assert_eq!(missing_arg_json["kind"], "skills");
assert_eq!(missing_arg_json["action"], "install");
assert_eq!(missing_arg_json["error_kind"], "missing_argument");
assert_eq!(missing_arg_json["argument"], "install_source");
assert!(missing_arg_json["hint"]
.as_str()
.expect("skill_not_found must have non-null hint (#795)");
assert!(
h1.contains("skills list") || h1.contains("skills install"),
"hint should reference skills commands, got: {h1:?}"
);
.is_some_and(|hint| !hint.is_empty()));
// skills uninstall (unsupported action)
let out2 = run_claw(
let invalid_source = run_claw(
&root,
&["skills", "install", "bogus-name", "--output-format", "json"],
&envs,
);
assert_eq!(invalid_source.status.code(), Some(1));
assert!(
invalid_source.stderr.is_empty(),
"stderr: {}",
String::from_utf8_lossy(&invalid_source.stderr)
);
let invalid_source_json = parse_json_stdout(&invalid_source, "skills install invalid source");
assert_eq!(invalid_source_json["kind"], "skills");
assert_eq!(invalid_source_json["action"], "install");
assert_eq!(invalid_source_json["error_kind"], "invalid_install_source");
assert_eq!(invalid_source_json["source"], "bogus-name");
assert_eq!(invalid_source_json["source_kind"], "name");
assert_eq!(invalid_source_json["reason"], "not_found");
assert!(invalid_source_json["hint"]
.as_str()
.is_some_and(|hint| { hint.contains("local path") || hint.contains("SKILL.md") }));
let missing_uninstall = run_claw(
&root,
&[
"--output-format",
"json",
"skills",
"uninstall",
"some-skill",
"nonexistent-skill-xyz",
"--output-format",
"json",
],
&[],
&envs,
);
assert_eq!(missing_uninstall.status.code(), Some(1));
assert!(
missing_uninstall.stderr.is_empty(),
"stderr: {}",
String::from_utf8_lossy(&missing_uninstall.stderr)
);
let missing_uninstall_json =
parse_json_stdout(&missing_uninstall, "skills uninstall missing skill");
assert_eq!(missing_uninstall_json["kind"], "skills");
assert_eq!(missing_uninstall_json["action"], "uninstall");
assert_eq!(missing_uninstall_json["error_kind"], "skill_not_found");
assert_eq!(missing_uninstall_json["requested"], "nonexistent-skill-xyz");
assert_eq!(
missing_uninstall_json["skills_dir"],
config_home.join("skills").display().to_string()
);
assert_eq!(
missing_uninstall_json["available_names"]
.as_array()
.expect("available_names")
.len(),
0
);
assert!(missing_uninstall_json["hint"]
.as_str()
.is_some_and(|hint| !hint.is_empty()));
}
#[test]
fn skills_install_uninstall_roundtrip_stays_local_431() {
let root = unique_temp_dir("skills-roundtrip-431");
let config_home = root.join("config-home");
let home = root.join("home");
let source_root = root.join("fixtures");
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&home).expect("home");
write_skill(&source_root, "roundtrip", "Roundtrip skill");
let skill_source = source_root.join("roundtrip");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
("ANTHROPIC_API_KEY", ""),
("ANTHROPIC_AUTH_TOKEN", ""),
("OPENAI_API_KEY", ""),
];
let install = run_claw(
&root,
&[
"skills",
"install",
skill_source.to_str().expect("utf8 skill source"),
"--output-format",
"json",
],
&envs,
);
assert!(
!out2.status.success(),
"skills uninstall must exit non-zero (#795)"
install.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&install.stdout),
String::from_utf8_lossy(&install.stderr)
);
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let stdout2 = String::from_utf8_lossy(&out2.stdout);
let j2: serde_json::Value = stdout2
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("skills uninstall should emit JSON error");
let install_json = parse_json_stdout(&install, "skills install roundtrip");
assert_eq!(install_json["kind"], "skills");
assert_eq!(install_json["action"], "install");
assert_eq!(install_json["status"], "ok");
assert_eq!(install_json["invocation_name"], "roundtrip");
let installed_path = config_home.join("skills").join("roundtrip");
assert_eq!(
j2["error_kind"], "unsupported_skills_action",
"skills uninstall should be unsupported_skills_action, got {:?}",
j2["error_kind"]
install_json["installed_path"],
installed_path.display().to_string()
);
let h2 = j2["hint"]
.as_str()
.expect("unsupported_skills_action must have non-null hint (#795)");
assert!(!h2.is_empty(), "hint must be non-empty");
assert!(installed_path.join("SKILL.md").is_file());
let uninstall = run_claw(
&root,
&[
"skills",
"uninstall",
"roundtrip",
"--output-format",
"json",
],
&envs,
);
assert!(
uninstall.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&uninstall.stdout),
String::from_utf8_lossy(&uninstall.stderr)
);
let uninstall_json = parse_json_stdout(&uninstall, "skills uninstall roundtrip");
assert_eq!(uninstall_json["kind"], "skills");
assert_eq!(uninstall_json["action"], "uninstall");
assert_eq!(uninstall_json["status"], "ok");
assert_eq!(uninstall_json["removed"], "roundtrip");
assert_eq!(
uninstall_json["removed_path"],
installed_path.display().to_string()
);
assert!(
!installed_path.exists(),
"uninstall should remove installed skill files"
);
}
#[test]
fn agents_create_scaffolds_toml_and_lists_locally_431() {
let root = unique_temp_dir("agents-create-431");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&home).expect("home");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
("ANTHROPIC_API_KEY", ""),
("ANTHROPIC_AUTH_TOKEN", ""),
("OPENAI_API_KEY", ""),
];
let create = run_claw(
&root,
&["agents", "create", "my-agent", "--output-format", "json"],
&envs,
);
assert!(
create.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&create.stdout),
String::from_utf8_lossy(&create.stderr)
);
let create_json = parse_json_stdout(&create, "agents create my-agent");
let agent_path = root.join(".claw").join("agents").join("my-agent.toml");
let reported_agent_path = PathBuf::from(
create_json["path"]
.as_str()
.expect("agents create should report path"),
);
assert_eq!(create_json["kind"], "agents");
assert_eq!(create_json["action"], "create");
assert_eq!(create_json["status"], "ok");
assert_eq!(create_json["format"], "toml");
assert_eq!(
reported_agent_path,
fs::canonicalize(&agent_path).expect("canonical agent path")
);
assert!(agent_path.is_file());
let agent_contents = fs::read_to_string(&agent_path).expect("agent scaffold should read");
assert!(agent_contents.contains("name = \"my-agent\""));
let list =
assert_json_command_with_env(&root, &["--output-format", "json", "agents", "list"], &envs);
assert_eq!(list["kind"], "agents");
assert_eq!(list["action"], "list");
assert!(list["agents"]
.as_array()
.expect("agents array")
.iter()
.any(|agent| {
agent["name"] == "my-agent"
&& PathBuf::from(agent["path"].as_str().expect("listed agent path"))
== fs::canonicalize(&agent_path).expect("canonical listed agent path")
}));
}
#[test]