mirror of
https://github.com/instructkr/claude-code.git
synced 2026-06-04 11:36:44 +00:00
fix: keep skills lifecycle local
This commit is contained in:
@@ -6368,7 +6368,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||
430. **DONE — `dump-manifests` emits the self-contained Rust resolver inventory instead of requiring upstream Claude Code TypeScript source files** — fixed 2026-06-03 in `fix: make dump-manifests self-contained`. `claw dump-manifests --output-format json` now succeeds from an installed workspace with `source:"rust-resolver"`, command/tool/agent/skill/bootstrap manifests, no `CLAUDE_CODE_UPSTREAM` hint, and no `src/commands.ts` dependency. Explicit `--manifests-dir` scopes resolver discovery to another directory and missing/not-directory roots emit typed `missing_manifests` JSON. Sibling export diagnostics now validate explicit positional/`--output` paths before session discovery and return typed `invalid_output_path` JSON with `path` and `reason`. Regression coverage: `dump_manifests_defaults_to_rust_resolver_inventory`, `dump_manifests_scopes_explicit_manifest_dir_without_upstream_ts`, `dump_manifests_missing_explicit_dir_has_typed_kind`, `dump_manifests_and_init_emit_json_when_requested`, `local_json_surfaces_have_non_empty_action_contract_714`, and `export_invalid_output_path_reports_typed_json_430`.
|
||||
|
||||
|
||||
431. **`skills uninstall <name>` requires Anthropic credentials despite being a local filesystem operation — `claw skills uninstall nonexistent-skill-xyz --output-format json` returns `kind:"missing_credentials"` instead of resolving locally that the skill doesn't exist** — dogfooded 2026-05-11 by Jobdori on `328fd114` in response to Clawhip pinpoint nudge at `1503275502046023690` (sibling probe to #430). Reproduction (no creds, isolated `CLAW_CONFIG_HOME`): `claw skills uninstall nonexistent-skill-xyz --output-format json` returns `{"error":"missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY...","kind":"missing_credentials"}`. Uninstalling a skill is a pure local filesystem operation: read the skills directory, find the named skill, remove its files. There is no semantic reason to require API credentials. Same class of bug as #357 (`session list` requires creds), #369 (`session help/fork` require creds), and #427 (`resume <bogus-id>` requires creds). **Three sibling findings in same probe:** (a) `claw skills install <bogus-name>` returns `{"error":"No such file or directory (os error 2)","kind":"unknown"}` — leaks raw OS error string with no hint about expected install source format (path vs name vs URL?), and the catch-all `kind:"unknown"` again instead of typed `kind:"skill_install_source_not_found"`. (b) `claw skills install` (no args) returns `action:"help"` with `unexpected:"install"` — but `install` IS a documented subcommand. The handler treats it as "unknown action" instead of "missing required argument". Should emit `kind:"missing_argument"` with `argument:"install_source"`. (c) `claw agents create my-agent` returns `action:"help"` with `unexpected:"create my-agent"` — there is no agent-creation surface at all. Users must hand-craft `.claw/agents/<name>.md` files with no scaffolding command, while `claw init` only creates the top-level `.claw/` skeleton. **Required fix shape:** (a) `skills uninstall <name>` must be local-first: enumerate the local skills dir, return `kind:"skill_not_found"` (with `skills_dir:` and `available_names:[]` fields) for missing, or remove the files and return `kind:"skills"` with `action:"uninstall", removed:<name>` for present skills; (b) `skills install <source>` must distinguish source forms (`path:`, `name:`, `url:`) and emit `kind:"invalid_install_source"` with the parsed-and-failed reason; (c) `skills install` (no args) emits `kind:"missing_argument"` with `argument:"install_source"`; (d) add `claw agents create <name>` (or `claw init agent <name>`) that scaffolds `.claw/agents/<name>.md` with a stub frontmatter; or document explicitly that agents are user-authored only. **Why this matters:** lifecycle commands (`uninstall`, `install`, `create`) are the primary surface for managing claw's extension surface area. If `uninstall` requires API creds, an offline user who fat-fingered an install can't undo it. If `install` returns a raw OS error, automation can't programmatically recover. If `agents create` doesn't exist, agent authoring is undocumented file-touching only. Cross-references #357, #369, #427 (auth-gate-on-local-ops cluster), and #422/#423/#428/#430 (`kind:"unknown"` catch-all cluster). Source: Jobdori live dogfood, `328fd114`, 2026-05-11.
|
||||
431. **DONE — `skills uninstall <name>` resolves locally instead of requiring Anthropic credentials** — fixed 2026-06-03 in `fix: keep skills lifecycle local`. `claw skills uninstall nonexistent-skill-xyz --output-format json` now stays on the local skills lifecycle surface and emits `kind:"skills"`, `action:"uninstall"`, `error_kind:"skill_not_found"`, `skills_dir`, `available_names`, and a hint without provider credentials. `claw skills install` no-arg emits typed `missing_argument` with `argument:"install_source"`; `claw skills install <bogus-name>` emits typed `invalid_install_source` with `source`, `source_kind`, `reason`, and a recovery hint. Installed skill roundtrips remove the installed files through the shared local lifecycle helper. `claw agents create <name>` now scaffolds `.claw/agents/<name>.toml` and lists through the existing TOML agent discovery surface. Regression coverage: `skills_lifecycle_errors_have_typed_local_json_795_431`, `skills_install_uninstall_roundtrip_stays_local_431`, `agents_create_scaffolds_toml_and_lists_locally_431`, local command routing tests, parser discriminant tests, and command help/docs assertions.
|
||||
|
||||
|
||||
432. **`--allowedTools` validator inconsistency: tool name list is half snake_case (`bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, `grep_search`) and half PascalCase (`WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `Sleep`) with three UPPERCASE entries (`REPL`, `LSP`, `MCP`); accepts undocumented CamelCase aliases (`Read`, `Write`, `Edit`) and silently translates them to snake_case; argument parsing consumes the next positional when value is missing** — dogfooded 2026-05-11 by Jobdori on `fad53e2d` in response to Clawhip pinpoint nudge at `1503283046856655029`. Reproduction: `claw --allowedTools status --output-format json` → `{"error":"unsupported tool in --allowedTools: status (expected one of: bash, read_file, write_file, edit_file, glob_search, grep_search, WebFetch, WebSearch, TodoWrite, Skill, Agent, ToolSearch, NotebookEdit, Sleep, SendUserMessage, Config, EnterPlanMode, ExitPlanMode, StructuredOutput, REPL, PowerShell, AskUserQuestion, TaskCreate, RunTaskPacket, TaskGet, TaskList, TaskStop, TaskUpdate, TaskOutput, WorkerCreate, WorkerGet, WorkerObserve, WorkerResolveTrust, WorkerAwaitReady, WorkerSendPrompt, WorkerRestart, WorkerTerminate, WorkerObserveCompletion, TeamCreate, TeamDelete, CronCreate, CronDelete, CronList, LSP, ListMcpResources, ReadMcpResource, McpAuth, RemoteTrigger, MCP, TestingPermission)","kind":"unknown"}`. The `status` subcommand was consumed as the `--allowedTools` value because the flag parser doesn't distinguish missing-value from end-of-flag-args. The error reveals **the supported tool list mixes naming conventions inconsistently within a single error message**: snake_case (`bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, `grep_search`), PascalCase (`WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `Sleep`, `Config`, `PowerShell`, `AskUserQuestion`, `TaskCreate`, `WorkerCreate`, `TeamCreate`, `CronCreate`), UPPERCASE (`REPL`, `LSP`, `MCP`), and CamelCase compounds (`McpAuth`, `RemoteTrigger`). **Hidden alias mapping**: `claw --allowedTools Read,Write,Edit status --output-format json` is accepted and returns `allowed_tools.entries:["edit_file","read_file","write_file"]` — proving the validator has an undocumented CamelCase→snake_case alias map (`Read`→`read_file`, `Write`→`write_file`, `Edit`→`edit_file`) that is not surfaced in the error message. Users who copy-paste tool names from Claude Code documentation work, users who copy from the validator error don't. **Sibling missing-value bug:** `claw --allowedTools status` with `status` as a positional subcommand is interpreted as `--allowedTools=status`, swallowing the subcommand. The flag parser must require a value for `--allowedTools` and emit `kind:"missing_argument"` when followed by a recognized subcommand or `--`-prefixed flag instead of silently treating the next arg as a tool name. **Sibling typed-kind bug:** both errors use `kind:"unknown"` instead of typed `kind:"invalid_tool_name"` / `kind:"missing_argument"` — the catch-all keeps appearing (#422/#423/#424/#428/#430/#431/#432). **Required fix shape:** (a) standardize the canonical tool-name registry on one casing convention (snake_case is most CLI-ergonomic) and update both the registry and all CamelCase aliases; (b) document and expose the alias map (`tool_aliases:{Read:"read_file",...}`) in `claw doctor`/`status` and in the validator error; (c) flag parser must require a value for `--allowedTools` and refuse to consume a recognized subcommand or `-`/`--`-prefixed token as the value, emit `kind:"missing_argument"` with `argument:"--allowedTools"`; (d) emit `kind:"invalid_tool_name"` with `tool_name:` and `available:[]` fields instead of `kind:"unknown"`; (e) regression test that `claw --allowedTools <subcommand>` rejects with `missing_argument`, and that the canonical name list in errors uses the same casing as the alias map. **Why this matters:** `--allowedTools` is the primary surface for restricting claw's tool surface area (security-relevant). Inconsistent naming between the validator error and the alias map means users following the error message guidance pick names that work in some places and fail in others. The missing-value bug silently swallows a subcommand, leading to confusing "unsupported tool: status" errors when the user actually wanted to run `claw status`. Cross-references #94/#97/#101/#106/#115/#123 (permission-rule audit), #428 (default permission_mode), #422/#423/#424/#428/#430/#431 (`kind:"unknown"` catch-all). Source: Jobdori live dogfood, `fad53e2d`, 2026-05-11.
|
||||
@@ -7755,7 +7755,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||
|
||||
794. **`claw plugins install /nonexistent/path` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `57a57ef7`. The error message `"plugin source '/path' was not found"` had no classifier arm, falling to `"unknown"`. Fix: added `plugin_source_not_found` classifier arm (`message.contains("plugin source") && message.contains("was not found")`); added `"plugin_source_not_found"` → `"Check that the path or URL is correct..."` to `fallback_hint_for_error_kind`. Unit test assertion added to `test_classify_error_kind`; integration test `plugins_install_not_found_path_returns_typed_kind_794` added. 56 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori plugins install probe on `57a57ef7`, 2026-05-27.
|
||||
|
||||
795. **`claw skills install /nonexistent` returned `skill_not_found + hint:null` and `claw skills uninstall x` returned `unsupported_skills_action + hint:null`** — dogfooded 2026-05-27 on `491f179a`. Both error kinds were missing from `fallback_hint_for_error_kind` table, so even though classify returned a typed kind, the hint field was always null. Fix: added `"skill_not_found"` → hint suggesting `claw skills list` / `claw skills install`; added `"unsupported_skills_action"` → hint listing supported actions. Integration test `skills_install_not_found_and_unsupported_action_have_hints_795` covers both paths. 57 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori skills lifecycle probe on `491f179a`, 2026-05-27.
|
||||
795. **`claw skills install /nonexistent` returned `skill_not_found + hint:null` and `claw skills uninstall x` returned `unsupported_skills_action + hint:null`** — dogfooded 2026-05-27 on `491f179a`. Both error kinds were missing from `fallback_hint_for_error_kind` table, so even though classify returned a typed kind, the hint field was always null. Fix: added `skill_not_found` and `unsupported_skills_action` fallback hints. ROADMAP #431 later moved the lifecycle surface fully local: install failures now emit typed `invalid_install_source`, uninstall failures emit local `skill_not_found` with `skills_dir` and `available_names`, and the combined regression is covered by `skills_lifecycle_errors_have_typed_local_json_795_431` plus the install/uninstall roundtrip test. [SCOPE: claw-code] Source: Jobdori skills lifecycle probe on `491f179a`, 2026-05-27.
|
||||
|
||||
796. **`claw agents show <name> <extra>` and `claw skills show <name> <extra>` returned confusing `agent_not_found`/`skill_not_found` for the concatenated "name extra" string** — dogfooded 2026-05-27 on `18b4cee5`. `join_optional_args` passes all tokens as a space-joined string; both `show` handlers called `split_once(' ')` to extract the name but did not check if the remainder (after the first split) contained additional tokens. Extra positional args (including `--flags`) became part of the "name", silently mangling the lookup. Fix: added second `split_once(' ')` on the extracted name; if the result has two parts, return `unexpected_extra_args` with a usage hint. Valid single-name lookups are unaffected. Two new integration tests `agents_show_extra_positional_arg_returns_unexpected_extra_796`, `skills_show_extra_positional_arg_returns_unexpected_extra_796`. 59 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori agents/skills show extra-arg probe on `18b4cee5`, 2026-05-27.
|
||||
797. **DONE — Installed `claw version --output-format json` reports `git_sha:null` / `Git SHA unknown`, so dogfood cannot tie the binary under test to a source revision** — dogfooded 2026-05-27 from `#clawcode-building-in-public` using an installed binary in a clean `ultraworkers/claw-code` checkout. The gap was that version/status/doctor did not provide a structured executable-vs-workspace provenance object when build metadata was missing or stale. [SCOPE: claw-code]
|
||||
|
||||
14
USAGE.md
14
USAGE.md
@@ -477,11 +477,12 @@ let client = build_http_client_with(&config).expect("proxy client");
|
||||
|
||||
## Skills
|
||||
|
||||
Use `/skills list` in the interactive REPL or `claw skills --output-format json` from the direct CLI to inspect installed skills. For offline/local installs, install the directory that contains `SKILL.md`, then verify the discovered name before invoking it:
|
||||
Use `/skills list` in the interactive REPL or `claw skills --output-format json` from the direct CLI to inspect installed skills. For offline/local installs, install the directory that contains `SKILL.md`, then verify the discovered name before invoking it. `skills install`, `skills uninstall`, and `agents create` are local filesystem lifecycle commands; they do not require provider credentials.
|
||||
|
||||
```text
|
||||
/skills install /absolute/path/to/my-skill
|
||||
/skills list
|
||||
/skills uninstall my-skill
|
||||
/skills my-skill
|
||||
```
|
||||
|
||||
@@ -494,6 +495,7 @@ cd rust
|
||||
./target/debug/claw status
|
||||
./target/debug/claw sandbox
|
||||
./target/debug/claw agents
|
||||
./target/debug/claw agents create my-agent
|
||||
./target/debug/claw mcp
|
||||
./target/debug/claw skills
|
||||
./target/debug/claw system-prompt --cwd .. --date 2026-04-04
|
||||
@@ -513,6 +515,7 @@ git clone https://github.com/Xquik-dev/tweetclaw
|
||||
cd claw-code/rust
|
||||
./target/debug/claw skills install ../../tweetclaw/skills/tweetclaw
|
||||
./target/debug/claw skills show tweetclaw
|
||||
./target/debug/claw skills uninstall tweetclaw
|
||||
```
|
||||
|
||||
TweetClaw gives `claw` users a local skill guide for OpenClaw/Xquik workflows
|
||||
@@ -520,6 +523,15 @@ such as tweet search, reply search, follower export, monitors, webhooks, and
|
||||
approval-gated posting. Configure any Xquik credentials outside the prompt and
|
||||
avoid pasting API keys into chat.
|
||||
|
||||
## Author a local agent
|
||||
|
||||
`claw agents create <name>` scaffolds a local `.claw/agents/<name>.toml` file for the current workspace. The scaffold is intentionally small so you can edit the description, model, and reasoning effort before listing or invoking agents:
|
||||
|
||||
```bash
|
||||
./target/debug/claw agents create release-checker
|
||||
./target/debug/claw agents list
|
||||
```
|
||||
|
||||
## Session management
|
||||
|
||||
REPL turns are persisted under `.claw/sessions/` in the current workspace.
|
||||
|
||||
@@ -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>]`
|
||||
|
||||
@@ -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