Compare commits

...

18 Commits

Author SHA1 Message Date
Yeachan-Heo
0197fa2020 fix(#3129): handle trailing json format for diff errors
Keep malformed diff invocations with trailing JSON format flags on the parser error path and lock the contract with focused output-format regressions.

Constraint: Do not touch tracked .omx state files.

Rejected: Repeating direct binary smoke loops | local auth/provider configuration intercepts those invocations and obscures parser behavior.

Confidence: high

Scope-risk: narrow

Tested: git diff --check; cargo fmt --check; cargo test -p rusty-claude-cli diff_extra_args_have_typed_error_kind_and_hint_766 --test output_format_contract; cargo test -p rusty-claude-cli diff_trailing_json_after_malformed_args_is_bounded_json_3129 --test output_format_contract; cargo test -p rusty-claude-cli diff_non_git_dir_has_error_kind_and_hint_801 --test output_format_contract
2026-05-27 12:36:22 +00:00
YeonGyu-Kim
2c3c0f60e7 fix(#804): agents/skills show <name> <extra> in text mode returned wrong error instead of unexpected_extra_args 2026-05-27 20:05:39 +09:00
YeonGyu-Kim
bad1b97f8e fix(#803): agents/skills/plugins list --flag in text mode silently returned empty success 2026-05-27 19:38:31 +09:00
YeonGyu-Kim
fcebf64468 fix(#802): four resume-mode and broad-cwd error envelopes now include hint field 2026-05-27 19:04:15 +09:00
YeonGyu-Kim
53953a8157 fix(#801): diff non-git-dir error envelope now includes error_kind, hint, and message fields 2026-05-27 18:34:58 +09:00
YeonGyu-Kim
1201dc60ef docs(roadmap): add deferred entries #798-#800 (plugins extra-arg, empty-prompt, classifier coverage) 2026-05-27 18:21:35 +09:00
Bellman
23d7761e50 docs(roadmap): add #786 installed binary provenance gap (#3126) 2026-05-27 18:21:02 +09:00
YeonGyu-Kim
6ee67d6c61 test: add unit test coverage for invalid_history_count and unknown_option classifier arms
Two classifier arms had no corresponding assert_eq! in
test_classify_error_kind_returns_correct_discriminants: invalid_history_count
(both prefix and contains paths) and unknown_option (#790). Now 49/39 = full
coverage of all classify_error_kind return values.
2026-05-27 18:05:33 +09:00
YeonGyu-Kim
efb1542a39 fix: empty-prompt error now returns non-null hint via newline-delimited usage string
claw '' and claw '   ' returned empty_prompt + hint:null because the
error message had no newline delimiter. Added usage hint. 61 CLI
contract tests pass.
2026-05-27 16:34:37 +09:00
YeonGyu-Kim
bff370003b fix: plugins extra-arg errors now return non-null hint via newline-delimited usage string
Parity with #791 (config extra-arg fix). The plugins arg parser emitted
'unexpected extra arguments after claw plugins show ...' with no newline
delimiter, so split_error_hint returned None. Added usage hint after newline.
60 CLI contract tests pass.
2026-05-27 15:04:03 +09:00
YeonGyu-Kim
9976585f87 fix(#796): agents/skills show <name> <extra> returned wrong not-found instead of unexpected_extra_args 2026-05-27 14:07:04 +09:00
YeonGyu-Kim
18b4cee5fd fix(#795): skill_not_found and unsupported_skills_action now return non-null hints via fallback table 2026-05-27 13:34:09 +09:00
YeonGyu-Kim
491f179a03 fix(#794): plugins install not-found path returns typed plugin_source_not_found instead of unknown+null 2026-05-27 13:08:14 +09:00
YeonGyu-Kim
57a57ef771 fix(#793): plugins list --flag silent success + uninstall not-found hint:null 2026-05-27 12:34:35 +09:00
YeonGyu-Kim
abfa2e4cf7 fix(#792): agents/skills list --flag silently returned empty success; now returns unknown_option error 2026-05-27 11:39:44 +09:00
YeonGyu-Kim
93a159dca5 fix(#791): config extra-arg errors now return non-null hint via \n-delimited usage string 2026-05-27 11:04:50 +09:00
YeonGyu-Kim
9968a27e92 fix(#790): system-prompt unknown-option errors now return typed unknown_option kind + non-null hint 2026-05-27 10:36:12 +09:00
YeonGyu-Kim
e4c3c1aa80 fix(#789): agents show and plugins show not-found now exit 1; parity with skills (#788) and mcp (#68) 2026-05-27 10:07:51 +09:00
4 changed files with 1030 additions and 33 deletions

View File

@@ -7743,3 +7743,34 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
787. **`claw --resume /tmp` (directory path) returned `error_kind:"session_load_failed"` + `hint:null`; resume error emission sites didn't apply `fallback_hint_for_error_kind`** — dogfooded 2026-05-27 on `22b423b6`. Two gaps: (1) the OS error `"Is a directory (os error 21)"` had no classifier arm, falling to generic `session_load_failed`; (2) both resume error emission paths (session load at line 3338, command execution at line 3484) called `split_error_hint` but not `fallback_hint_for_error_kind`, so API-layer errors with no `\n` always got `hint:null`. Fix: added `session_path_is_directory` classifier arm for `"Is a directory"` / `"os error 21"` messages; added `fallback_hint_for_error_kind` fallback to both resume error sites; added `session_path_is_directory` and `session_load_failed` to `fallback_hint_for_error_kind`. Unit test + integration test `resume_directory_path_returns_typed_kind_and_hint_787`. 46 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori resume-path probe on `22b423b6`, 2026-05-27.
788. **`claw --output-format json skills show <not-found>` emitted two JSON objects — one from the skills handler, one duplicate from the top-level error path** — dogfooded 2026-05-27 on `113145a4`. `print_skills` in JSON mode called `println!` to emit the `skill_not_found` error envelope, then returned `Err(...)`. The `?` propagation triggered the top-level error handler which emitted a second `action:"abort"` JSON envelope on stderr. Callers reading both stdout and stderr got two JSON objects with the same `error_kind` but different `action` fields — the first was the authoritative response, the second was a duplicate. Fix: replaced `return Err(...)` with `std::process::exit(1)` after the skills error JSON is emitted, mirroring the existing `is_help_action` guard pattern. Integration test `skills_show_not_found_emits_single_json_object_788` asserts exactly 1 JSON object on stdout and no JSON on stderr. 47 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori skills double-emission probe on `113145a4`, 2026-05-27.
789. **`claw --output-format json agents show <not-found>` and `plugins show <not-found>` both returned exit 0 despite `status:"error"` in the JSON** — dogfooded 2026-05-27 on `abdbf61a`. Skills was fixed in #788 (exit 1 via process::exit). Agents and plugins had the identical gap: `print_agents` had no error check at all (just println + Ok(())); `print_plugins`'s not-found branch used `return Ok(())`. MCP was already fixed in an earlier cycle (#68). Fix: added `is_error` check in `print_agents` JSON path (exit 1 when status=="error"); changed plugins not-found branch from `return Ok(())` to `std::process::exit(1)`. Existing `inventory_commands_emit_structured_json_when_requested` test updated to use `run_claw` directly for the not-found case. Two new tests added: `agents_show_not_found_exits_nonzero_789`, `plugins_show_not_found_exits_nonzero_789`. 49 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori exit-code consistency probe on `abdbf61a`, 2026-05-27.
790. **`claw --output-format json system-prompt <unknown-option>` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `e4c3c1aa`. The unknown-option branch in `parse_print_system_prompt_args` emitted plain `"unknown system-prompt option: {other}"` for all unrecognised options except `--json` (which appended a `\n`-delimited suggestion). All non-`--json` cases fell to `unknown+null`. Fix: replaced bare format string with `unknown_option: ... \n<usage hint>` format for all unknown options; `--json` special case preserves its `--output-format json` suggestion in the hint prefix. Integration test `system_prompt_unknown_option_returns_typed_kind_790` covers both paths. 50 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori system-prompt option probe on `e4c3c1aa`, 2026-05-27.
791. **`claw config show <extra>` and `claw config set <extra1> <extra2>` returned `unexpected_extra_args` + `hint:null`** — dogfooded 2026-05-27 on `9968a27e`. The config arg parser emitted `"unexpected extra arguments after `claw config {}`: {}"` with no `\n` delimiter, so `split_error_hint` returned `None` and `fallback_hint_for_error_kind("unexpected_extra_args")` also returns `None`. Fix: appended `\nUsage: claw config [env|hooks|model|plugins|mcp|settings]` to the error format string. Integration test `config_extra_args_have_non_null_hint_791` covers both paths. 51 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori config-arg probe on `9968a27e`, 2026-05-27.
792. **`claw agents list --bogus-flag` and `claw skills list --bogus-flag` silently returned `status:"ok" count:0` instead of an error** — dogfooded 2026-05-27 on `93a159dc`. The `list <filter>` arm in both handlers treated flag-shaped tokens (`--something`) as name substring filters. Since no agents/skills have `--bogus` in their name, result was empty success list — a false positive that masks typos and unknown flags. Fix: added flag-prefix guard at the top of both `list <args>` arms in `commands/src/lib.rs`; detected filter tokens starting with `-` return `unknown_option` + usage hint. Two new integration tests `agents_list_flag_shaped_filter_returns_unknown_option_792`, `skills_list_flag_shaped_filter_returns_unknown_option_792`. 53 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori agents/skills list probe on `93a159dc`, 2026-05-27.
793. **`claw plugins list --bogus-flag` silent empty success + `plugins uninstall <not-found>` had `hint:null`** — dogfooded 2026-05-27 on `abfa2e4c`. Two gaps: (1) `plugins list` filter branch in `print_plugins` treated `--bogus-flag` as an id substring filter, found no matches, returned `status:"ok"` empty list — same false-positive as #792 for agents/skills. (2) `plugins uninstall no-such` propagated `plugin_not_found` error via `?` with no `\n` delimiter; `plugin_not_found` was missing from `fallback_hint_for_error_kind` table. Fix: (1) added flag-prefix guard in `print_plugins` `is_list_action` branch (detects tokens starting with `-`, returns `unknown_option` + usage hint, exits 1); (2) added `"plugin_not_found"``"Run 'claw plugins list' to see installed plugins."` to fallback table. Two new tests `plugins_list_flag_shaped_filter_returns_unknown_option_793`, `plugins_uninstall_not_found_has_hint_793`. 55 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori plugins lifecycle probe on `abfa2e4c`, 2026-05-27.
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.
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. **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 the installed `/home/bellman/.cargo/bin/claw` binary in a clean `ultraworkers/claw-code` checkout. `claw version --output-format json` returned `{"kind":"version","version":"0.1.0","git_sha":null,"target":null,"build_date":"2026-03-31"...}` while `claw status --output-format json` only reported workspace state (`git_branch`, clean/dirty counts) and did not provide any executable-vs-workspace provenance comparison. This is a clawability gap in event/log opacity and stale-binary confusion: an operator can run `doctor/status/version` successfully but still cannot prove which commit the installed CLI came from, whether it matches `origin/main`, or whether the observed behavior is from a stale packaged binary. **Required fix shape:** (a) embed build git SHA/target/build provenance in installed/release binaries whenever the source tree is available; (b) when provenance is missing, emit a typed `binary_provenance.status:"unknown"` rather than only `git_sha:null`; (c) have `status`/`doctor` include a redaction-safe comparison between executable provenance and workspace HEAD when running inside a git checkout; (d) add regression/packaging coverage proving release/local install paths preserve or explicitly classify provenance. **Why this matters:** dogfood reports and automation need to distinguish current-source failures from stale or unknown binary lineage before opening/rebasing/closing PRs. Source: gaebal-gajae live dogfood on 2026-05-27; active repo checkout had open PR #3124 DIRTY with no checks and PR #3125 CLEAN, but the installed binary itself could not identify its source revision.
798. **`claw plugins show <name> <extra-arg>` returned `unexpected_extra_args` + `hint:null`** — dogfooded 2026-05-27 on `9976585f`. The plugins arg parser at the top level emitted `"unexpected extra arguments after 'claw plugins show ...': ..."` with no `\n` delimiter (parity gap with #791 config fix). Fix: appended `\nUsage: claw plugins [list|show <id>|...]` to the error format string. Integration test `plugins_extra_args_have_non_null_hint_797`. Committed as `bff37000`. 60 CLI contract tests pass. [SCOPE: claw-code]
799. **`claw --output-format json ""` and `claw " "` returned `empty_prompt` + `hint:null`** — dogfooded 2026-05-27 on `bff37000`. The empty-prompt guard at the fallthrough path emitted `"empty prompt: provide a subcommand..."` with no `\n` delimiter. Fix: added `\n` + usage hint. Integration test `empty_prompt_has_non_null_hint_798`. Committed as `efb1542a`. 61 CLI contract tests pass. [SCOPE: claw-code]
800. **`classify_error_kind` unit test coverage gap: `invalid_history_count` and `unknown_option` arms had zero assertions** — found 2026-05-27 on `efb1542a`. Audit of 39 distinct classifier return values vs 37 unit test assertions revealed 2 untested arms. Fix: added 3 `assert_eq!` covering both arms (invalid_history_count prefix + contains paths, unknown_option prefix). Committed as `6ee67d6c`. All 39 return values now have unit test coverage. [SCOPE: claw-code]
801. **`claw --output-format json diff` in a non-git directory was missing `error_kind`, `hint`, and `message` fields** — dogfooded 2026-05-27 on `1201dc60`. The diff handler's no-git-repo JSON branch constructed a custom object with only `status:"error"` + `result:"no_git_repo"` + `detail`, violating the error envelope contract that every error has `error_kind` + `hint`. Fix: added `error_kind: "no_git_repo"`, `hint: "Run git init..."`, and `message` fields. Integration test `diff_non_git_dir_has_error_kind_and_hint_801`. 62 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori non-git-dir probe on `1201dc60`, 2026-05-27.
802. **Four `status:"error"` JSON sites in resume-mode and broad-cwd handlers were missing `hint` field** — found 2026-05-27 on `53953a81` via source audit of all `"status": "error"` sites in main.rs. The resume `unsupported_command` (L3433), `unsupported_resumed_command` (L3455), `cli_parse` (L3474), and `broad_cwd` (L4838) handlers all emitted JSON error envelopes with `error_kind` but no `hint` field. Fix: added contextual `hint` string to all four sites. Source audit now shows 0 `status:"error"` JSON objects missing `hint` across entire main.rs. 62 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori source-level audit of all error JSON sites, 2026-05-27.
803. **`claw agents list --bogus`, `skills list --bogus`, and `plugins list --bogus` in text mode silently returned empty success** — dogfooded 2026-05-27 on `fcebf644`. The JSON-mode flag guards added in #792/#793 only covered the JSON branch; the text-mode path through `handle_agents_slash_command`, `handle_skills_slash_command`, and `print_plugins` still passed flag-shaped tokens as substring filters. Fix: added flag-prefix guards to all three text-mode list handlers (agents and skills in `commands/src/lib.rs`, plugins in `main.rs print_plugins`). Also removed the now-redundant JSON-only guard from print_plugins (the early guard catches both modes). Updated `plugins_list_flag_shaped_filter_returns_unknown_option_793` test to check stderr. 62 CLI contract tests pass. [SCOPE: claw-code]
804. **`claw agents show <name> <extra>` and `claw skills show <name> <extra>` in text mode returned wrong `agent_not_found`/silent empty instead of catching extra args** — dogfooded 2026-05-27 on `bad1b97f`. Parity gap with JSON-mode fix #796: the text-mode show handlers in `commands/src/lib.rs` still used single-split `split_once(' ')` without checking for spaces in the extracted name. Fix: added `contains(' ')` guard to both text-mode show arms; extra tokens now return `unexpected extra arguments` with usage hint. 62 CLI contract tests pass. [SCOPE: claw-code]

View File

@@ -2365,6 +2365,13 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
// #803: reject flag-shaped tokens in text mode too (JSON guard was added in #792)
if filter.starts_with('-') {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown option for `agents list`: {filter}\nUsage: claw agents list [<filter>]\nFilters are name substrings, not flags."),
));
}
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
let filtered: Vec<_> = agents
@@ -2383,22 +2390,30 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
let name = args
let name_raw = args
.split_once(' ')
.map(|(_, name)| name)
.unwrap_or_default()
.trim()
.to_lowercase();
// #804: detect extra positional args (parity with JSON-mode fix #796)
if name_raw.contains(' ') {
let extra = name_raw.split_once(' ').map(|(_, e)| e).unwrap_or("");
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unexpected extra arguments after agent name\nUsage: claw agents show <name>\nUnexpected extra: '{extra}'"),
));
}
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
let matched: Vec<_> = agents
.into_iter()
.filter(|a| a.name.to_lowercase() == name)
.filter(|a| a.name.to_lowercase() == name_raw)
.collect();
if matched.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("agent not found: {name}"),
format!("agent not found: {name_raw}"),
));
}
Ok(render_agents_report(&matched))
@@ -2429,6 +2444,18 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
// #792: unknown flags (--something) silently became filter strings, returning
// empty success list instead of an error. Detect and reject flag-shaped tokens.
if filter.starts_with('-') {
return Ok(serde_json::json!({
"kind": "agents",
"action": "list",
"status": "error",
"error_kind": "unknown_option",
"unexpected": filter,
"hint": "Usage: claw agents list [<filter>]\nFilters are name substrings, not flags.",
}));
}
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
let filtered: Vec<_> = agents
@@ -2447,12 +2474,29 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
let name = args
let name_raw = args
.split_once(' ')
.map(|(_, name)| name)
.unwrap_or_default()
.trim()
.to_lowercase();
// #796: extra positional args after the name (e.g. `agents show foo extra`)
// produced a confusing agent_not_found for "foo extra" instead of flagging
// the unexpected extra argument.
let (name, extra) = name_raw
.split_once(' ')
.map(|(n, e)| (n.to_string(), Some(e.to_string())))
.unwrap_or_else(|| (name_raw.clone(), None));
if let Some(extra_token) = extra {
return Ok(serde_json::json!({
"kind": "agents",
"action": "show",
"status": "error",
"error_kind": "unexpected_extra_args",
"unexpected": extra_token,
"hint": format!("Usage: claw agents show <name>\nUnexpected extra: '{extra_token}'"),
}));
}
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
let matched: Vec<_> = agents
@@ -2517,6 +2561,13 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
// #803: reject flag-shaped tokens in text mode too (JSON guard was added in #792)
if filter.starts_with('-') {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown option for `skills list`: {filter}\nUsage: claw skills list [<filter>]\nFilters are name substrings, not flags."),
));
}
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let filtered: Vec<_> = skills
@@ -2535,17 +2586,25 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
let name = args
let name_raw = args
.split_once(' ')
.map(|(_, name)| name)
.unwrap_or_default()
.trim()
.to_lowercase();
// #804: detect extra positional args (parity with JSON-mode fix #796)
if name_raw.contains(' ') {
let extra = name_raw.split_once(' ').map(|(_, e)| e).unwrap_or("");
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unexpected extra arguments after skill name\nUsage: claw skills show <name>\nUnexpected extra: '{extra}'"),
));
}
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let matched: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase() == name)
.filter(|s| s.name.to_lowercase() == name_raw)
.collect();
Ok(render_skills_report(&matched))
}
@@ -2582,6 +2641,18 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
// #792: flag-shaped tokens silently became filter strings, returning
// empty success list instead of an error. Detect and reject them.
if filter.starts_with('-') {
return Ok(serde_json::json!({
"kind": "skills",
"action": "list",
"status": "error",
"error_kind": "unknown_option",
"unexpected": filter,
"hint": "Usage: claw skills list [<filter>]\nFilters are name substrings, not flags.",
}));
}
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let filtered: Vec<_> = skills
@@ -2600,12 +2671,29 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
let name = args
let name_raw = args
.split_once(' ')
.map(|(_, name)| name)
.unwrap_or_default()
.trim()
.to_lowercase();
// #796: extra positional args after the name (e.g. `skills show foo extra`)
// produced a confusing skill_not_found for "foo extra" instead of flagging
// the unexpected extra argument.
let (name, extra) = name_raw
.split_once(' ')
.map(|(n, e)| (n.to_string(), Some(e.to_string())))
.unwrap_or_else(|| (name_raw.clone(), None));
if let Some(extra_token) = extra {
return Ok(json!({
"kind": "skills",
"action": "show",
"status": "error",
"error_kind": "unexpected_extra_args",
"unexpected": extra_token,
"hint": format!("Usage: claw skills show <name>\nUnexpected extra: '{extra_token}'"),
}));
}
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let matched: Vec<_> = skills

View File

@@ -337,6 +337,9 @@ fn classify_error_kind(message: &str) -> &'static str {
"agent_not_found"
} else if message.contains("is not installed") {
"plugin_not_found"
} else if message.contains("plugin source") && message.contains("was not found") {
// #794: `plugins install /nonexistent/path` → "plugin source ... was not found"
"plugin_source_not_found"
} else if (message.contains("skill source") && message.contains("not found"))
|| message.starts_with("skill '")
{
@@ -411,6 +414,21 @@ fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> {
"session_path_is_directory" => Some(
"--resume expects a .jsonl session file path, not a directory. Run `claw --output-format json /session list` to list managed sessions.",
),
// #793: plugins uninstall/enable/disable of non-existing plugin propagates through
// the ? operator with no \n delimiter, so split_error_hint returns None.
"plugin_not_found" => Some("Run `claw plugins list` to see installed plugins."),
// #794: plugins install with a path that doesn't exist
"plugin_source_not_found" => Some(
"Check that the path or URL is correct. Use a local directory or a valid registry id.",
),
// #795: skills install/show of a non-existing skill path or name
"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
"unsupported_skills_action" => Some(
"Supported: list, install <path>, show <name>, help. Run `claw skills help` for details.",
),
_ => None,
}
}
@@ -1167,8 +1185,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let action = tail.first().cloned();
let target = tail.get(1).cloned();
if tail.len() > 2 {
// #797: append \n usage hint so split_error_hint extracts it (parity with #791 config fix)
return Err(format!(
"unexpected extra arguments after `claw {} {}`: {}",
"unexpected extra arguments after `claw {} {}`: {}\nUsage: claw plugins [list|show <id>|install <id>|enable <id>|disable <id>|uninstall <id>|update <id>|help]",
rest[0],
tail[..2].join(" "),
tail[2..].join(" ")
@@ -1190,8 +1209,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let tail = &rest[1..];
let section = tail.first().cloned();
if tail.len() > 1 {
// #791: append \n hint so split_error_hint extracts it and hint is non-null
return Err(format!(
"unexpected extra arguments after `claw config {}`: {}",
"unexpected extra arguments after `claw config {}`: {}\nUsage: claw config [env|hooks|model|plugins|mcp|settings]",
tail[0],
tail[1..].join(" ")
));
@@ -1205,10 +1225,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// `git diff`). No session needed to inspect the working tree.
"diff" => {
if rest.len() > 1 {
return Err(format!(
"unexpected extra arguments after `claw diff`: {}\nUsage: claw diff",
rest[1..].join(" ")
));
// #3129: keep malformed `diff ... --output-format json` on the
// parser/error path, not the prompt/TUI fallback. The newline
// before Usage is part of the JSON hint contract.
return Err(unexpected_diff_args_error(&rest[1..]));
}
Ok(CliAction::Diff { output_format })
}
@@ -1358,8 +1378,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// an empty prompt when credentials are present).
let joined = rest.join(" ");
if joined.trim().is_empty() {
// #798: add \n hint so split_error_hint extracts it (was empty_prompt + null)
return Err(
"empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string"
"empty prompt: provide a subcommand or a non-empty prompt string.\nUsage: claw <subcommand> or claw -p <prompt>. Run `claw --help` for the full list."
.to_string(),
);
}
@@ -1604,6 +1625,13 @@ fn removed_auth_surface_error(command_name: &str) -> String {
)
}
fn unexpected_diff_args_error(extra: &[String]) -> String {
format!(
"unexpected extra arguments after `claw diff`: {}\nUsage: claw diff",
extra.join(" ")
)
}
fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
match args {
[] => Ok(CliAction::Acp { output_format }),
@@ -2091,11 +2119,16 @@ fn parse_system_prompt_args(
}
other => {
// #152: hint `--output-format json` when user types `--json`.
let mut msg = format!("unknown system-prompt option: {other}");
if other == "--json" {
msg.push_str("\nDid you mean `--output-format json`?");
}
return Err(msg);
// #790: use unknown_option: prefix + \n hint so classify_error_kind returns
// unknown_option and split_error_hint extracts the remediation text.
let hint = if other == "--json" {
"Did you mean `--output-format json`? Usage: claw system-prompt [--cwd <dir>] [--date <YYYY-MM-DD>] [--output-format text|json]".to_string()
} else {
"Usage: claw system-prompt [--cwd <dir>] [--date <YYYY-MM-DD>] [--output-format text|json]".to_string()
};
return Err(format!(
"unknown_option: unknown system-prompt option: {other}.\n{hint}"
));
}
}
}
@@ -3407,6 +3440,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
"status": "error",
"error_kind": "unsupported_command",
"error": format!("/{cmd_root} is not yet implemented in this build"),
"hint": "This command is not available in the current build. Update claw or use a different command.",
"exit_code": 2,
"command": raw_command,
})
@@ -3429,6 +3463,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
"status": "error",
"error_kind": "unsupported_resumed_command",
"error": format!("unsupported resumed command: {raw_command}"),
"hint": "This command cannot be used with --resume. Use it in an interactive REPL session instead.",
"exit_code": 2,
"command": raw_command,
})
@@ -3448,6 +3483,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
"status": "error",
"error_kind": "cli_parse",
"error": error.to_string(),
"hint": "Run `claw --help` for usage.",
"exit_code": 2,
"command": raw_command,
})
@@ -4812,6 +4848,7 @@ fn enforce_broad_cwd_policy(
"status": "error",
"error_kind": "broad_cwd",
"error": message,
"hint": "Change to a more specific project directory, or use --cwd to set the workspace root.",
"exit_code": 1,
})
);
@@ -6281,10 +6318,17 @@ impl LiveCli {
let cwd = env::current_dir()?;
match output_format {
CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)?
),
CliOutputFormat::Json => {
let value = handle_agents_slash_command_json(args, &cwd)?;
// #789: parity with print_mcp/#788 print_skills — exit 1 when envelope
// reports an error so automation can rely on exit code instead of
// parsing the JSON status field.
let is_error = value.get("status").and_then(|v| v.as_str()) == Some("error");
println!("{}", serde_json::to_string_pretty(&value)?);
if is_error {
std::process::exit(1);
}
}
}
Ok(())
}
@@ -6349,6 +6393,17 @@ impl LiveCli {
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
// #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes.
// Previously the guard was JSON-only (#793); text mode silently returned empty success.
if action.as_deref() == Some("list") {
if let Some(filter) = target.as_deref() {
if filter.starts_with('-') {
return Err(format!(
"unknown option for `claw plugins list`: {filter}\nUsage: claw plugins list [<filter>]\nFilters are id substrings, not flags."
).into());
}
}
}
let payload = plugins_command_payload_for(&cwd, action, target)?;
match output_format {
CliOutputFormat::Text => println!("{}", payload.message),
@@ -6394,6 +6449,20 @@ impl LiveCli {
}
} else if is_list_action {
if let Some(filter) = target {
// #793: flag-shaped tokens silently became substring filters on
// plugins list, returning empty success instead of an error.
if filter.starts_with('-') {
let obj = json!({
"kind": "plugin",
"action": "list",
"status": "error",
"error_kind": "unknown_option",
"unexpected": filter,
"hint": "Usage: claw plugins list [<filter>]\nFilters are id substrings, not flags.",
});
println!("{}", serde_json::to_string_pretty(&obj)?);
std::process::exit(1);
}
let needle = filter.to_lowercase();
payload
.plugins
@@ -6428,7 +6497,8 @@ impl LiveCli {
"hint": "Run `claw plugins list` to see available plugins.",
});
println!("{}", serde_json::to_string_pretty(&obj)?);
return Ok(());
// #789: exit 1 on not-found so automation can rely on exit code
std::process::exit(1);
}
}
}
@@ -8283,11 +8353,15 @@ fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::er
.map(|o| o.status.success())
.unwrap_or(false);
if !in_git_repo {
// #801: add error_kind, hint, message fields for envelope parity with other error paths
return Ok(serde_json::json!({
"kind": "diff",
"action": "diff",
"status": "error",
"error_kind": "no_git_repo",
"result": "no_git_repo",
"message": format!("{} is not inside a git project", cwd.display()),
"hint": "Run `git init` to create a repository, or change to a directory that is inside a git project.",
"working_directory": cwd.display().to_string(),
"detail": format!("{} is not inside a git project", cwd.display()),
}));
@@ -13147,6 +13221,11 @@ mod tests {
classify_error_kind("my-plugin is not installed"),
"plugin_not_found"
);
// #794: plugins install with missing source path
assert_eq!(
classify_error_kind("plugin source `/nonexistent/path` was not found"),
"plugin_source_not_found"
);
assert_eq!(
classify_error_kind("skill source /path/to/skill not found"),
"skill_not_found"
@@ -13206,6 +13285,20 @@ mod tests {
),
"invalid_resume_argument"
);
// coverage: invalid_history_count arm
assert_eq!(
classify_error_kind("invalid_history_count: abc is not a valid count"),
"invalid_history_count"
);
assert_eq!(
classify_error_kind("something invalid count something"),
"invalid_history_count"
);
// coverage: unknown_option arm (#790)
assert_eq!(
classify_error_kind("unknown_option: unknown system-prompt option: --foo."),
"unknown_option"
);
}
#[test]

View File

@@ -203,7 +203,9 @@ fn inventory_commands_emit_structured_json_when_requested() {
isolated_codex.to_str().expect("utf8 codex home"),
),
];
let agents_show_missing = assert_json_command_with_env(
// #789: agents show not-found now exits 1 (parity with skills #788);
// use run_claw directly instead of assert_json_command_with_env which checks success.
let agents_show_out = run_claw(
&root,
&[
"--output-format",
@@ -214,6 +216,12 @@ fn inventory_commands_emit_structured_json_when_requested() {
],
&agents_show_env,
);
assert!(
!agents_show_out.status.success(),
"agents show not-found must exit non-zero"
);
let agents_show_missing: serde_json::Value =
serde_json::from_slice(&agents_show_out.stdout).expect("agents show stdout should be json");
assert_eq!(agents_show_missing["kind"], "agents", "agents show kind");
assert_eq!(agents_show_missing["action"], "show", "agents show action");
assert_eq!(
@@ -1908,36 +1916,95 @@ fn login_logout_removed_subcommands_have_error_kind_and_hint_765() {
fn diff_extra_args_have_typed_error_kind_and_hint_766() {
// #766: `claw diff --bogus` returned error_kind:"unknown" + hint:null.
// `diff` takes no arguments; extra args were unclassified with no remediation.
let root = unique_temp_dir("diff-extra-args-766");
let root = git_temp_dir("diff-extra-args-766");
assert_diff_unexpected_extra_args_json(
&root,
&["--output-format", "json", "diff", "--bogus"],
"claw diff --bogus",
);
}
#[test]
fn diff_trailing_json_after_malformed_args_is_bounded_json_3129() {
// #3129: when --output-format json appeared after malformed `diff` args,
// the parser fell through to the interactive/prompt path and emitted zero
// JSON stdout. These forms must fail before any provider or TUI path starts.
let root = git_temp_dir("diff-trailing-json-3129");
for (args, label) in [
(
&["diff", "--bogus-flag", "--output-format", "json"][..],
"claw diff --bogus-flag --output-format json",
),
(
&["diff", "does-not-exist", "--output-format", "json"][..],
"claw diff does-not-exist --output-format json",
),
(
&[
"diff",
"--cached",
"--bogus-flag",
"--output-format",
"json",
][..],
"claw diff --cached --bogus-flag --output-format json",
),
] {
assert_diff_unexpected_extra_args_json(&root, args, label);
}
}
fn git_temp_dir(prefix: &str) -> PathBuf {
let root = unique_temp_dir(prefix);
fs::create_dir_all(&root).expect("temp dir should exist");
// Need a git repo for diff to parse past arg validation
// Need a git repo so `diff` reaches argument validation before git checks.
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
.expect("git init should launch");
root
}
let output = run_claw(&root, &["--output-format", "json", "diff", "--bogus"], &[]);
fn assert_diff_unexpected_extra_args_json(root: &Path, args: &[&str], label: &str) {
let output = run_claw(root, args, &[]);
assert!(
!output.status.success(),
"claw diff --bogus should exit non-zero"
"{label} should exit non-zero; stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stdout.is_empty(),
"{label} should not enter the spinner/prompt path; stdout:\n{}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("stderr should contain a JSON error envelope");
.unwrap_or_else(|| {
panic!("{label} stderr should contain a JSON error envelope; stderr:\n{stderr}")
});
let parsed: serde_json::Value =
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
assert_eq!(
parsed["error_kind"], "unexpected_extra_args",
"claw diff --bogus must return error_kind:unexpected_extra_args (#766)"
"{label} must return error_kind:unexpected_extra_args"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw diff --bogus must return non-null hint (#766), got: {hint:?}"
"{label} must return non-null hint, got: {hint:?}"
);
assert!(
parsed["message"]
.as_str()
.is_some_and(|message| !message.is_empty()),
"{label} must return non-empty message"
);
}
@@ -2765,3 +2832,721 @@ fn skills_show_not_found_emits_single_json_object_788() {
);
assert_eq!(json_objects[0]["status"], "error");
}
#[test]
fn agents_show_not_found_exits_nonzero_789() {
// #789: `claw --output-format json agents show <not-found>` returned exit 0 despite
// emitting status:"error". print_agents had no error check — just println + Ok(()).
// Skills was fixed in #788 (exit 1 via process::exit); agents/plugins had the same gap.
let root = unique_temp_dir("agents-show-exit-789");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(
&root,
&[
"--output-format",
"json",
"agents",
"show",
"no-such-agent-xyz-789",
],
&[],
);
assert!(
!output.status.success(),
"agents show not-found must exit non-zero (#789), got exit 0"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("agents show should emit valid JSON");
assert_eq!(j["error_kind"], "agent_not_found");
assert_eq!(j["status"], "error");
}
#[test]
fn plugins_show_not_found_exits_nonzero_789() {
// #789: same as agents — `claw --output-format json plugins show <not-found>` exited 0
// despite status:"error". The not-found branch used `return Ok(())` instead of exit(1).
let root = unique_temp_dir("plugins-show-exit-789");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(
&root,
&[
"--output-format",
"json",
"plugins",
"show",
"no-such-plugin-xyz-789",
],
&[],
);
assert!(
!output.status.success(),
"plugins show not-found must exit non-zero (#789), got exit 0"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("plugins show should emit valid JSON");
assert_eq!(j["error_kind"], "plugin_not_found");
assert_eq!(j["status"], "error");
}
#[test]
fn system_prompt_unknown_option_returns_typed_kind_790() {
// #790: `claw --output-format json system-prompt bogus` returned error_kind:"unknown" + hint:null.
// The unknown-option branch emitted plain "unknown system-prompt option: bogus" with no typed
// prefix. Fix: use unknown_option: prefix + \n usage hint.
let root = unique_temp_dir("system-prompt-unknown-opt-790");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// Generic unknown option
let out1 = run_claw(
&root,
&["--output-format", "json", "system-prompt", "bogus"],
&[],
);
assert!(!out1.status.success());
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let j1: serde_json::Value = stderr1
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("unknown option should emit JSON error");
assert_eq!(
j1["error_kind"], "unknown_option",
"system-prompt unknown option should be unknown_option, got {:?}",
j1["error_kind"]
);
let h1 = j1["hint"]
.as_str()
.expect("unknown_option must have hint (#790)");
assert!(
h1.contains("system-prompt") || h1.contains("claw"),
"hint should reference system-prompt usage, got: {h1:?}"
);
// Special --json case: hint should mention --output-format json
let out2 = run_claw(
&root,
&["--output-format", "json", "system-prompt", "--json"],
&[],
);
assert!(!out2.status.success());
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let j2: serde_json::Value = stderr2
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("--json flag should emit JSON error");
assert_eq!(j2["error_kind"], "unknown_option");
let h2 = j2["hint"]
.as_str()
.expect("--json case must have hint (#790)");
assert!(
h2.contains("output-format") || h2.contains("json"),
"hint for --json should suggest --output-format json, got: {h2:?}"
);
}
#[test]
fn config_extra_args_have_non_null_hint_791() {
// #791: `claw config show bogus-key` and `claw config set a b` returned
// error_kind:"unexpected_extra_args" + hint:null because the error message
// "unexpected extra arguments after `claw config ...`: ..." had no \n delimiter.
// Fix: appended \n + usage hint to the format string.
let root = unique_temp_dir("config-extra-args-791");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// config show with extra positional arg
let out1 = run_claw(
&root,
&["--output-format", "json", "config", "show", "bogus-key"],
&[],
);
assert!(!out1.status.success());
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let j1: serde_json::Value = stderr1
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("config show extra arg should emit JSON error");
assert_eq!(
j1["error_kind"], "unexpected_extra_args",
"config show extra arg should be unexpected_extra_args, got {:?}",
j1["error_kind"]
);
let h1 = j1["hint"]
.as_str()
.expect("unexpected_extra_args must have hint (#791)");
assert!(
h1.contains("config") || h1.contains("claw"),
"hint should reference config usage, got: {h1:?}"
);
// config set with extra positionals
let out2 = run_claw(
&root,
&[
"--output-format",
"json",
"config",
"set",
"bogus-section.key",
"value",
],
&[],
);
assert!(!out2.status.success());
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let j2: serde_json::Value = stderr2
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("config set extra arg should emit JSON error");
assert_eq!(j2["error_kind"], "unexpected_extra_args");
assert!(
j2["hint"].as_str().is_some_and(|h| !h.is_empty()),
"config set extra arg must have non-null hint (#791)"
);
}
#[test]
fn agents_list_flag_shaped_filter_returns_unknown_option_792() {
// #792: `claw --output-format json agents list --bogus-flag` silently returned
// status:"ok" count:0 instead of an error. The list filter arm in
// handle_agents_slash_command_json treated "--bogus-flag" as a name substring
// filter (no agents match), producing a false-positive empty success result.
// Fix: detect filter tokens starting with "-" and return unknown_option + hint.
let root = unique_temp_dir("agents-list-flag-792");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(
&root,
&[
"--output-format",
"json",
"agents",
"list",
"--unknown-flag",
],
&[],
);
assert!(
!output.status.success(),
"agents list --unknown-flag must exit non-zero (#792)"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = serde_json::from_str(stdout.trim())
.expect("agents list flag-filter should emit valid JSON");
assert_eq!(
j["error_kind"], "unknown_option",
"agents list flag-shaped filter must return unknown_option, got {:?}",
j["error_kind"]
);
assert_eq!(j["status"], "error");
let h = j["hint"]
.as_str()
.expect("unknown_option must have hint (#792)");
assert!(
h.contains("claw agents list") || h.contains("filter"),
"hint should reference correct usage, got: {h:?}"
);
}
#[test]
fn skills_list_flag_shaped_filter_returns_unknown_option_792() {
// #792: same gap as agents — `claw skills list --bogus-flag` returned success
// with empty list instead of unknown_option error.
let root = unique_temp_dir("skills-list-flag-792");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(
&root,
&[
"--output-format",
"json",
"skills",
"list",
"--unknown-flag",
],
&[],
);
assert!(
!output.status.success(),
"skills list --unknown-flag must exit non-zero (#792)"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value = serde_json::from_str(stdout.trim())
.expect("skills list flag-filter should emit valid JSON");
assert_eq!(
j["error_kind"], "unknown_option",
"skills list flag-shaped filter must return unknown_option, got {:?}",
j["error_kind"]
);
assert_eq!(j["status"], "error");
assert!(
j["hint"]
.as_str()
.is_some_and(|h| h.contains("claw skills list") || h.contains("filter")),
"hint should reference correct usage (#792)"
);
}
#[test]
fn plugins_list_flag_shaped_filter_returns_unknown_option_793() {
// #793: `claw plugins list --bogus-flag` silently returned status:"ok" with empty
// plugins list instead of an error. The list filter branch in print_plugins treated
// "--bogus-flag" as an id substring filter and found no matches, producing a false-positive.
// Fix: added flag-prefix guard; filter tokens starting with "-" now return unknown_option.
let root = unique_temp_dir("plugins-list-flag-793");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(
&root,
&[
"--output-format",
"json",
"plugins",
"list",
"--unknown-flag",
],
&[],
);
assert!(
!output.status.success(),
"plugins list --unknown-flag must exit non-zero (#793)"
);
// #803: the early flag guard now returns Err before the JSON branch,
// so the error envelope goes to stderr via the main error handler.
let stderr = String::from_utf8_lossy(&output.stderr);
let j: serde_json::Value = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("plugins list flag-filter should emit valid JSON on stderr");
assert!(
j["error_kind"] == "unknown_option" || j["error_kind"] == "cli_parse",
"plugins list flag-shaped filter must return typed error, got {:?}",
j["error_kind"]
);
assert_eq!(j["status"], "error");
let h = j["hint"]
.as_str()
.expect("error must have hint (#793/#803)");
assert!(
h.contains("plugins list") || h.contains("filter") || h.contains("claw"),
"hint should reference plugins list usage, got: {h:?}"
);
}
#[test]
fn plugins_uninstall_not_found_has_hint_793() {
// #793: `claw plugins uninstall no-such-plugin` returned plugin_not_found + hint:null.
// The error propagated from plugins_command_payload_for via ? with no \n delimiter;
// split_error_hint returned None and plugin_not_found wasn't in the fallback table.
// Fix: added "plugin_not_found" entry to fallback_hint_for_error_kind().
let root = unique_temp_dir("plugins-uninstall-793");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(
&root,
&[
"--output-format",
"json",
"plugins",
"uninstall",
"no-such-xyz-793",
],
&[],
);
assert!(
!output.status.success(),
"plugins uninstall not-found must exit non-zero (#793)"
);
// Error envelope goes to stderr (propagated via ? to main error handler)
let stderr = String::from_utf8_lossy(&output.stderr);
let j: serde_json::Value = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("plugins uninstall not-found should emit JSON error envelope");
assert_eq!(j["error_kind"], "plugin_not_found");
let h = j["hint"]
.as_str()
.expect("plugin_not_found must have non-null hint (#793)");
assert!(
h.contains("plugins list") || h.contains("claw plugins"),
"hint should reference plugins list, got: {h:?}"
);
}
#[test]
fn plugins_install_not_found_path_returns_typed_kind_794() {
// #794: `claw plugins install /nonexistent/path` returned error_kind:"unknown" + hint:null.
// The message "plugin source ... was not found" had no classifier arm; fell to "unknown".
// Fix: added "plugin_source_not_found" classifier arm + fallback hint table entry.
let root = unique_temp_dir("plugins-install-794");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(
&root,
&[
"--output-format",
"json",
"plugins",
"install",
"/nonexistent-path-xyz-794",
],
&[],
);
assert!(
!output.status.success(),
"plugins install not-found-path must exit non-zero (#794)"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let j: serde_json::Value = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("plugins install not-found should emit JSON error envelope");
assert_eq!(
j["error_kind"], "plugin_source_not_found",
"plugins install not-found should be plugin_source_not_found, got {:?}",
j["error_kind"]
);
let h = j["hint"]
.as_str()
.expect("plugin_source_not_found must have non-null hint (#794)");
assert!(!h.is_empty(), "hint must be non-empty");
}
#[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");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// skills install with nonexistent local path
let out1 = run_claw(
&root,
&[
"--output-format",
"json",
"skills",
"install",
"/nonexistent-xyz-795",
],
&[],
);
assert!(
!out1.status.success(),
"skills install not-found must exit non-zero (#795)"
);
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let j1: serde_json::Value = stderr1
.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"]
.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:?}"
);
// skills uninstall (unsupported action)
let out2 = run_claw(
&root,
&[
"--output-format",
"json",
"skills",
"uninstall",
"some-skill",
],
&[],
);
assert!(
!out2.status.success(),
"skills uninstall must exit non-zero (#795)"
);
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let j2: serde_json::Value = stderr2
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("skills uninstall should emit JSON error");
assert_eq!(
j2["error_kind"], "unsupported_skills_action",
"skills uninstall should be unsupported_skills_action, got {:?}",
j2["error_kind"]
);
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");
}
#[test]
fn agents_show_extra_positional_arg_returns_unexpected_extra_796() {
// #796: `claw agents show <name> <extra>` treated the full "name extra" as a single
// agent name, producing agent_not_found for "name extra" instead of flagging the
// unexpected extra argument. Fix: detect space-containing "name" and return
// unexpected_extra_args with usage hint.
let root = unique_temp_dir("agents-show-extra-796");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(
&root,
&[
"--output-format",
"json",
"agents",
"show",
"some-agent",
"--extra-flag",
],
&[],
);
assert!(
!output.status.success(),
"agents show with extra arg must exit non-zero (#796)"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("agents show extra arg should emit valid JSON");
assert_eq!(
j["error_kind"], "unexpected_extra_args",
"agents show extra arg should return unexpected_extra_args, got {:?}",
j["error_kind"]
);
let h = j["hint"]
.as_str()
.expect("unexpected_extra_args must have hint (#796)");
assert!(
h.contains("claw agents show") || h.contains("Usage"),
"hint should reference usage, got: {h:?}"
);
}
#[test]
fn skills_show_extra_positional_arg_returns_unexpected_extra_796() {
// #796: same gap as agents — `claw skills show <name> <extra>` treated "name extra"
// as a single skill name → skill_not_found. Fix: detect space-containing name.
let root = unique_temp_dir("skills-show-extra-796");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(
&root,
&[
"--output-format",
"json",
"skills",
"show",
"some-skill",
"--extra-flag",
],
&[],
);
assert!(
!output.status.success(),
"skills show with extra arg must exit non-zero (#796)"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("skills show extra arg should emit valid JSON");
assert_eq!(
j["error_kind"], "unexpected_extra_args",
"skills show extra arg should return unexpected_extra_args, got {:?}",
j["error_kind"]
);
assert!(
j["hint"]
.as_str()
.is_some_and(|h| h.contains("claw skills show") || h.contains("Usage")),
"hint should reference usage (#796)"
);
}
#[test]
fn plugins_extra_args_have_non_null_hint_797() {
// #797: `claw plugins show <name> <extra>` returned unexpected_extra_args + hint:null.
// The plugins arg parser at the top level emitted "unexpected extra arguments after
// `claw plugins show ...`: ..." with no \n delimiter. Parity with #791 config fix.
let root = unique_temp_dir("plugins-extra-args-797");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(
&root,
&[
"--output-format",
"json",
"plugins",
"show",
"some-plugin",
"extra-arg",
],
&[],
);
assert!(
!output.status.success(),
"plugins show with extra arg must exit non-zero (#797)"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let j: serde_json::Value = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("plugins extra arg should emit JSON error");
assert_eq!(j["error_kind"], "unexpected_extra_args");
let h = j["hint"]
.as_str()
.expect("unexpected_extra_args must have non-null hint (#797)");
assert!(
h.contains("plugins") || h.contains("Usage"),
"hint should reference plugins usage, got: {h:?}"
);
}
#[test]
fn empty_prompt_has_non_null_hint_798() {
// #798: `claw --output-format json ""` returned empty_prompt + hint:null.
// The error message "empty prompt: provide a subcommand..." had no \n delimiter.
let root = unique_temp_dir("empty-prompt-798");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(&root, &["--output-format", "json", ""], &[]);
assert!(
!output.status.success(),
"empty prompt must exit non-zero (#798)"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let j: serde_json::Value = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("empty prompt should emit JSON error envelope");
assert_eq!(j["error_kind"], "empty_prompt");
let h = j["hint"]
.as_str()
.expect("empty_prompt must have non-null hint (#798)");
assert!(
h.contains("claw") || h.contains("Usage"),
"hint should reference usage, got: {h:?}"
);
}
#[test]
fn diff_non_git_dir_has_error_kind_and_hint_801() {
// #801: `claw --output-format json diff` in a non-git directory returned
// status:"error" + result:"no_git_repo" but had no error_kind, hint, or
// message fields — violating the error envelope contract. Fix: added all
// three fields to the no_git_repo JSON branch.
let root = unique_temp_dir("diff-nongit-801");
fs::create_dir_all(&root).expect("temp dir");
// Intentionally NOT running git init
let output = run_claw(&root, &["--output-format", "json", "diff"], &[]);
// diff non-git may exit 0 (custom JSON handler) — check envelope content
let stdout = String::from_utf8_lossy(&output.stdout);
let j: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("diff non-git should emit valid JSON");
assert_eq!(j["status"], "error");
assert_eq!(j["result"], "no_git_repo");
assert_eq!(
j["error_kind"], "no_git_repo",
"diff non-git must have error_kind (#801), got {:?}",
j["error_kind"]
);
let h = j["hint"]
.as_str()
.expect("diff non-git must have non-null hint (#801)");
assert!(
h.contains("git init") || h.contains("git"),
"hint should suggest git init, got: {h:?}"
);
assert!(
j["message"].as_str().is_some(),
"diff non-git must have message field (#801)"
);
}