mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-04 13:37:09 +08:00
fix: keep skills lifecycle local
This commit is contained in:
@@ -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,
|
||||
®istry_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(®istry_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 =
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user