Compare commits

...

20 Commits

Author SHA1 Message Date
YeonGyu-Kim
abdbf61acf fix(#788): skills show not-found emitted duplicate JSON error envelope; use exit(1) instead of Err propagation 2026-05-27 09:36:11 +09:00
YeonGyu-Kim
113145a42a fix(#787): --resume with directory path returns session_path_is_directory kind + hint; wire fallback_hint_for_error_kind into both resume error emission sites 2026-05-27 09:06:28 +09:00
YeonGyu-Kim
22b423b651 fix(#786): dump-manifests --manifests-dir missing-value errors now return typed missing_flag_value kind + non-null hint 2026-05-27 08:39:11 +09:00
YeonGyu-Kim
87f4334728 fix(#785): add unknown_subcommand classifier arm for unknown subcommand: prose prefix 2026-05-27 08:36:41 +09:00
YeonGyu-Kim
e628b4bb68 fix(#784): export --output missing-value and extra-positional errors now return typed error_kind + non-null hint 2026-05-27 08:07:32 +09:00
YeonGyu-Kim
81fe0ccbb7 fix(#783): init JSON envelope now includes hint and already_initialized fields for orchestrator parity 2026-05-27 08:04:15 +09:00
YeonGyu-Kim
32c9276fdb fix(#782): acp unsupported invocation now returns non-null hint with newline-delimited remediation text 2026-05-27 07:37:26 +09:00
YeonGyu-Kim
16c1117af6 fix(#781): sub-classify api_auth_error/api_rate_limit_error from api_http_error; add fallback_hint_for_error_kind for hint-less API errors 2026-05-27 07:34:57 +09:00
YeonGyu-Kim
d9844cfe8d fix(#780): classifier arm ordering bug — legacy_session_no_workspace_binding and no_managed_sessions shadowed by generic session_load_failed arm 2026-05-27 05:34:49 +09:00
YeonGyu-Kim
364e7909f4 fix(#779): resumed /skills invocation returns interactive_only error_kind + non-null hint 2026-05-27 05:09:07 +09:00
YeonGyu-Kim
fded4f6b11 fix(#778): doctor check JSON objects now include hint field with stable remediation text for warn/fail checks 2026-05-27 05:07:02 +09:00
YeonGyu-Kim
e02030364d fix(#777): resumed /plugins mutations return interactive_only error_kind + non-null hint instead of unknown+null 2026-05-27 04:44:06 +09:00
YeonGyu-Kim
2684737d9e fix(#776): resume command errors now return typed error_kind + non-null hint (invalid_history_count, session action errors) 2026-05-27 04:39:43 +09:00
YeonGyu-Kim
028998d040 test(#775): integration tests for #769-#771 interactive-only guards and #774 hint fields; fix stale classifier unit test string 2026-05-27 04:03:52 +09:00
YeonGyu-Kim
c760a49c47 fix(#774): agents/plugins/mcp unknown-subcommand errors now include non-null hint 2026-05-27 03:37:00 +09:00
YeonGyu-Kim
727a1ea4a3 fix(#773): config --output-format json now surfaces deprecation warnings in warnings[] array instead of only stderr text 2026-05-27 03:05:14 +09:00
YeonGyu-Kim
212f0b2ad4 fix(#772): slash command aliases now resolve to canonical forms in interactive_only guidance 2026-05-27 02:37:17 +09:00
YeonGyu-Kim
bf212b986d fix(#771): init rejects extra args; usage/stats/fork return interactive_only instead of credential check 2026-05-27 02:33:55 +09:00
YeonGyu-Kim
3a1d88386c fix(#770): cost/clear/memory/ultraplan/model with args now return interactive_only instead of falling to credential check 2026-05-27 02:10:41 +09:00
YeonGyu-Kim
9e1be05634 fix(#769): claw session <arg> now returns interactive_only instead of falling to credential check 2026-05-27 02:05:14 +09:00
5 changed files with 1179 additions and 45 deletions

View File

@@ -7703,3 +7703,43 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
767. **`claw session bogus --output-format json` ignores JSON flag and falls through to credential check** — dogfooded 2026-05-27 on `d29a8e21`. `claw --output-format json session bogus` dispatches to the full interactive REPL runtime instead of rejecting `bogus` as an unknown session subcommand. Output is `error_kind:"missing_credentials"` rather than `error_kind:"unknown_session_subcommand"`. Root cause: `session` arg parser has no unknown-subcommand guard before dispatch; `bogus` is silently accepted as a session ID / switch target and reaches the credential-check gate. Fix needed: validate known session subcommands (`list`, `exists`, `switch`, `fork`, `delete`) before dispatch, return structured `unknown_session_subcommand` error for unrecognized tokens. [SCOPE: claw-code] Source: Jobdori probe on `d29a8e21`, 2026-05-27.
768. **`claw --resume latest compact` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `89735dbd` (gaebal-gajae pinpoint against `d29a8e21`, revised ID after #766/#767 landed). Resume trailing-arg validator emitted single-line `"--resume trailing arguments must be slash commands"` with no typed prefix and no `\n` hint. Fix: (1) changed error to `"invalid_resume_argument: \`{token}\` is not a slash command.\nUsage: claw --resume <session-id|latest> /<slash-command>"` so `split_error_hint()` extracts the hint; (2) added `invalid_resume_argument` classifier arm; (3) unit test assertion + integration test `resume_non_slash_trailing_arg_has_typed_error_kind_and_hint_768` added. 34 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae + Jobdori probe on `89735dbd`, 2026-05-27.
769. **`claw session bogus` fell through to credential check instead of interactive-only guidance** — dogfooded 2026-05-27 on `b778d4e3` (tracked as #767). `claw session <anything>` with more than one token bypassed `parse_single_word_command_alias` (which only fires for `rest.len()==1`) and had no match arm in `parse_args`, so `rest.join(" ")` became a prompt literal dispatched to `CliAction::Prompt`, hitting `missing_credentials` at the gate. Fix: added `"session"` match arm that emits `interactive_only:` error with `\n`-delimited hint referencing `--resume SESSION.jsonl /session` and REPL usage. Integration test `session_with_unknown_subcommand_returns_interactive_only_not_credentials_767` asserts `error_kind:interactive_only` + non-null hint for `bogus`, `nuke`, `delete-all`. 35 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori probe on `b778d4e3`, 2026-05-27.
770. **`claw cost/clear/memory/ultraplan/model` with trailing args fell to credential check** — dogfooded 2026-05-27 on `9e1be056`. Same fallthrough gap as #767/#769: these slash-only verbs had no multi-arg match arms, so `claw cost breakdown`, `claw clear --force`, `claw memory reset`, `claw ultraplan bogus`, `claw model opus extra` all became `CliAction::Prompt` literals, hitting `missing_credentials` at the gate. Fix: added `"cost"`, `"clear"`, `"memory"`, `"ultraplan"`, `"model" if rest.len() > 1` match arms, each returning `interactive_only:` + `\n`-delimited hint. Integration test `slash_only_verbs_with_args_return_interactive_only_not_credentials_770` asserts all five cases. 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori sweep on `9e1be056`, 2026-05-27.
771. **`init extraarg` silently succeeded; `usage`/`stats`/`fork` with args fell to credential check** — dogfooded 2026-05-27 on `3a1d8838`. Two distinct gaps: (1) `claw init extraarg` returned `status:ok` with trailing positional ignored — `"init"` arm always returned `Ok(CliAction::Init)` regardless of `rest[1..]`; (2) `claw usage extra`, `claw stats extra`, `claw fork newbranch` had no match arms and fell to `CliAction::Prompt` + credential gate. Fixes: (1) added extra-arg check in `"init"` arm — rejects with `unexpected_extra_args:` prefix + `\n` usage hint; (2) added `"usage"`, `"stats"`, `"fork"` interactive-only arms. All four now return correct `error_kind` + non-null hint. 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori sweep on `3a1d8838`, 2026-05-27.
772. **Slash command aliases bypassed `bare_slash_command_guidance` lookup** — dogfooded 2026-05-27 on `bf212b98`. `bare_slash_command_guidance()` only checked `spec.name == command_name`, not `spec.aliases`, so `claw yes`, `claw no`, `claw y`, `claw n`, `claw skill`, `claw cwd` all fell through (either to typo suggestions or `missing_credentials`). Should have returned `interactive_only:` guidance referencing the canonical form. Fix: (1) lookup changed to `spec.name == command_name || spec.aliases.contains(&command_name)`; (2) capture `canonical_name = slash_command.name`; (3) guidance strings updated to reference canonical form in remediation (e.g., `claw yes → /approve`, `claw n → /deny`, `claw skill → /skills`). 36 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint on `bf212b98`, 2026-05-27.
773. **Config deprecation warnings only emitted as unstructured stderr text in `--output-format json` mode** — dogfooded 2026-05-27 on `212f0b2a`. `emit_config_warning_once()` always wrote to stderr regardless of output format, causing JSON-mode callers to receive an unexpected `warning: ...` text line on stderr before the JSON object. Callers had to implement ad-hoc stripping. Fix: added `ConfigLoader::load_collecting_warnings()` method that returns `(RuntimeConfig, Vec<String>)` so callers can surface warnings structurally; `render_config_json()` now uses this and includes a `warnings: []` array in the config JSON envelope. Existing `load()` path unchanged (still emits to stderr for text-mode callers). 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori startup-friction probe on `212f0b2a`, 2026-05-27.
774. **`claw agents bogus`, `claw plugins bogus`, `claw mcp bogus` returned `hint: null`** — dogfooded 2026-05-27 on `727a1ea4`. Three "unknown subcommand" envelopes had `error_kind` correctly set but `hint: null`: (1) `unknown_agents_subcommand` — both text and JSON handler emitted single-line error with inline remediation after `.`, no `\n`; (2) `unknown_plugins_action` — same, period-delimited remediation; (3) `unknown_mcp_action``render_mcp_usage_json` never included a `hint` field at all. Fixes: (1)+(2) added `\n` before remediation suffix in `commands/src/lib.rs`; (3) added `hint` field to `render_mcp_usage_json` pointing at supported actions. All three now return non-null `hint`. 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori envelope-consistency probe on `727a1ea4`, 2026-05-27.
775. **Missing integration tests for #769-#771 interactive-only guards and #774 hint fields** — dogfooded 2026-05-27 on `c760a49c`. Fixes #769-#771 (session/cost/clear/memory/ultraplan/model/usage/stats/fork interactive-only guards) and #774 (agents/plugins/mcp unknown-subcommand hints) had no integration tests — a regression in any of those 10+ match arms would go undetected. Also: classify_error_kind unit test for `unknown_agents_subcommand` used the old single-line format string, not the `\n`-delimited format emitted after #774. Fixed: (1) updated unit test string to match new `\n`-delimited emission; (2) added `agents_plugins_mcp_unknown_subcommand_have_hint_774` asserting `error_kind` + non-null `hint` for all three; (3) added `interactive_only_guard_batch_769_to_771` asserting `interactive_only` + non-null `hint` for 10 cases. 38 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori test-coverage sweep on `c760a49c`, 2026-05-27.
776. **Resume-mode JSON errors had opaque `error_kind:"resume_command_error"` + `hint:null`** — dogfooded 2026-05-27 on `028998d0` (pinpoint identified by Gaebal-gajae). `run_resume_command` returned errors (e.g. from `parse_history_count`) with hardcoded `error_kind:"resume_command_error"` and the full error string in `error` with no hint extraction. Wrappers had to regex prose instead of switching on typed fields. Three co-located gaps fixed: (1) `resume_session` JSON error path now applies `classify_error_kind` + `split_error_hint` so errors get specific `error_kind` (e.g. `invalid_history_count`) and non-null `hint`; (2) `parse_history_count` errors now use `invalid_history_count:` prefix + `\n` usage hint; (3) `/session exists|delete|switch|fork` missing-arg and unsupported-action errors now use `\n`-delimited format with `unsupported_resumed_command:` prefix. Existing test updated to match new error message format. 38 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `028998d0`, 2026-05-27.
777. **Resumed `/plugins install|enable|disable|uninstall|update` returned opaque error_kind instead of interactive_only** — dogfooded 2026-05-27 on `2684737d` (pinpoint by Gaebal-gajae). The mutation arm in `run_resume_command` returned a bare single-line error; after #776 it was classified/split by the caller but fell to `error_kind:"unknown"` + `hint:null` because there was no `interactive_only:` prefix. Orchestrators had no stable signal to distinguish "command rejected — switch to REPL" from a transient error. Fix: each mutation verb now returns `interactive_only: /plugins {action} requires a live session...\n...hint...` so the caller emits `error_kind:"interactive_only"` + non-null hint pointing at REPL or direct CLI. Integration test `resume_plugin_mutations_are_typed_interactive_only_777` covers all 5 mutation verbs. 39 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `2684737d`, 2026-05-27.
778. **`claw doctor --output-format json` check objects had no `hint` field — all warn/fail remediation was buried in `details_prose`** — dogfooded 2026-05-27 on `e0203036`. Automation had to parse prose strings to find remediation text instead of reading a stable `hint` field. `DiagnosticCheck.json_value()` never emitted a `hint` field. Fix: added `hint: Option<String>` field to `DiagnosticCheck`, added `with_hint()` builder, populated for all warn/fail cases (auth: set env var; config: fix JSON syntax; workspace: git init; boot_preflight: install missing binaries; sandbox: expected on non-Linux). Empty hint string collapses to `null` (ok checks). 39 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori doctor-envelope probe on `e0203036`, 2026-05-27.
779. **Resumed `/skills <skill>` invocation returned bare prose → `error_kind:"unknown"` + `hint:null` after #776** — dogfooded 2026-05-27 on `fded4f6b` (pinpoint by Gaebal-gajae). Sibling of #777: the `/skills` invoke-dispatch guard emitted a single-line prose error identical in structure to the pre-#777 plugins mutation guard. After #776's classify/split it fell to `unknown+null` because no `interactive_only:` prefix was present. Fix: replaced with `interactive_only: /skills {skill_name} invocation requires a live session.\n...hint...` format. Integration test `resume_skills_invocation_is_typed_interactive_only_779` added. 40 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `fded4f6b`, 2026-05-27.
780. **`classify_error_kind` arm ordering bug: `"failed to restore session: legacy session is missing workspace binding: ..."` classified as `session_load_failed` instead of `legacy_session_no_workspace_binding`** — dogfooded 2026-05-27 on `364e7909`. The full error message from `resume_session` prepends `"failed to restore session: "` before `"legacy session is missing workspace binding: ..."`. The `contains("failed to restore session")` arm at line 278 matched first, returning `session_load_failed`; the more specific `legacy_session_no_workspace_binding` arm at line 282 was never reached. Same shadowing existed for `no_managed_sessions`. Fix: reordered the three arms — specific cases (`no_managed_sessions`, `legacy_session_no_workspace_binding`) before the generic `session_load_failed` catch-all. Unit test updated to assert corrected discriminants, plus new assertion covering the full prefixed message that exposed the bug. 40 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori classifier-ordering probe on `364e7909`, 2026-05-27.
781. **`api_http_error` was a single bucket for all HTTP errors; 401 auth and 429 rate-limit returned `hint:null` with no distinction** — dogfooded 2026-05-27 on `d9844cfe`. `classify_error_kind` had a single `api_http_error` arm for all API failures. 401 Unauthorized and 429 rate-limit errors emitted `error_kind:"api_http_error"` + `hint:null`, making it impossible for automation to distinguish auth misconfiguration from transient rate-limiting. Fixes: (1) added `api_auth_error` sub-classifier arm for 401/Unauthorized/authentication_error messages; (2) added `api_rate_limit_error` arm for 429/rate_limit messages; (3) added `fallback_hint_for_error_kind()` that derives a stable hint from the error kind when `split_error_hint` returns `None` (API layer never emits `\n`-delimited hints); (4) main JSON error emission path now calls `fallback_hint_for_error_kind` as fallback. Auth errors now return `api_auth_error` + env-var hint; rate-limit returns `api_rate_limit_error` + retry hint. Unit tests updated. 40 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori API error opacity probe on `d9844cfe`, 2026-05-27.
782. **`claw acp start` returned `error_kind:"unsupported_acp_invocation"` + `hint:null` — remediation text was on same line** — dogfooded 2026-05-27 on `16c1117a` (pinpoint by Gaebal-gajae). The error message `"unsupported ACP invocation. Use `claw acp`, `claw acp serve`, `claw --acp`, or `claw -acp`."` had no `\n` delimiter, so `split_error_hint` returned `hint:null`. Automation could tell ACP was unsupported but could not read the remediation structurally. Fix: inserted a `\n` before the remediation text: `"unsupported ACP invocation. Use ... claw -acp.\nACP/Zed editor integration is currently a discoverability alias only; ..."`. Integration test `acp_unsupported_invocation_has_hint_782` added. 41 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `16c1117a`, 2026-05-27.
783. **`claw --output-format json init` success envelope was missing `hint` field; idempotent re-init was not structurally detectable** — dogfooded 2026-05-27 on `32c9276f`. The init JSON envelope had no `hint` field (absent, not null), and no field to distinguish a fresh init from a re-init without checking `created.len() == 0`. Orchestrators had to inspect `created` array length to detect idempotent behavior. Fix: (1) added `hint` field to init JSON envelope — fresh path points at `CLAUDE.md + doctor`; idempotent path says "already initialised, run doctor"; (2) added `already_initialized: bool` field — `true` when `created` and `updated` are both empty (all artifacts skipped). Both test cases (fresh + re-init) covered by `init_json_envelope_has_hint_and_already_initialized_783`. 42 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori init-envelope probe on `32c9276f`, 2026-05-27.
784. **`claw export` had two opaque arg-error paths returning `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `81fe0ccb` (pinpoint by Gaebal-gajae). `claw export --output` (missing flag value) emitted plain `"missing value for --output"` with no typed prefix; `claw export a.md b.md` (extra positional) emitted plain `"unexpected export argument: second.md"`. Both classified as `unknown+null`. Fix: (1) `--output` missing-value error now uses `missing_flag_value:` prefix + `\n` usage hint; (2) extra positional now uses `unexpected_extra_args:` prefix + `\n` usage hint; (3) classifier `unexpected_extra_args` arm extended to match both `starts_with("unexpected extra arguments")` (prose form, #766) and `starts_with("unexpected_extra_args:")` (typed prefix form, #784). Integration test `export_arg_errors_have_typed_kind_and_hint_784` covers both paths. 43 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `81fe0ccb`, 2026-05-27.
785. **`claw dump` (typo/near-miss for dump-manifests) returned `error_kind:"unknown"` — no classifier arm for `"unknown subcommand:"` prose prefix** — dogfooded 2026-05-27 on `e628b4bb`. Any unknown top-level subcommand that triggers the suggestion path emitted `"unknown subcommand: <x>.\nDid you mean <y>"` but `classify_error_kind` had no arm for that prefix; all fell to the `"unknown"` catch-all. The hint was non-null (the suggestion text was extracted by `split_error_hint`) but `error_kind` was undifferentiated. Fix: added `starts_with("unknown subcommand:")``"unknown_subcommand"` arm. Unit test assertion + integration test `unknown_subcommand_returns_typed_kind_785` using `claw dump` as the trigger. 44 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori subcommand-classifier probe on `e628b4bb`, 2026-05-27.
786. **`claw dump-manifests --manifests-dir` (missing value) and `--manifests-dir=` (empty) both returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `87f43347` (pinpoint by Gaebal-gajae). Both missing `--manifests-dir` branches in `parse_dump_manifests_args` emitted plain `"--manifests-dir requires a path"` with no typed prefix; `classify_error_kind` had no matching arm so they fell to `"unknown"`. Fix: both branches now use `missing_flag_value:` prefix + `\n` usage hint. Integration test `dump_manifests_missing_dir_has_typed_kind_and_hint_786` covers both cases. 45 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `87f43347`, 2026-05-27.
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.

View File

@@ -2342,7 +2342,7 @@ pub fn handle_plugins_slash_command(
reload_runtime: false,
}),
Some(other) => Err(PluginError::CommandFailed(format!(
"unknown_plugins_action: '{other}' is not a supported /plugins action. Use list, show, install, enable, disable, uninstall, or update."
"unknown_plugins_action: '{other}' is not a supported /plugins action.\nUse: list, show, install, enable, disable, uninstall, or update."
))),
}
}
@@ -2406,7 +2406,7 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
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}. Supported: list, show, help"),
format!("unknown agents subcommand: {args}.\nSupported: list, show, help"),
)),
}
}
@@ -2477,7 +2477,7 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
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}. Supported: list, show, help"),
format!("unknown agents subcommand: {args}.\nSupported: list, show, help"),
)),
}
}
@@ -4173,12 +4173,20 @@ fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
} else {
Value::Null
};
// #774: add hint field so unknown_mcp_action errors have non-null hint parity
// with agents/plugins unknown-subcommand envelopes.
let hint: Value = if unexpected.is_some() {
json!("Use: list, show <server>, or help")
} else {
Value::Null
};
json!({
"kind": "mcp",
"action": "help",
"ok": unexpected.is_none(),
"status": if unexpected.is_some() { "error" } else { "ok" },
"error_kind": error_kind,
"hint": hint,
"usage": {
"slash_command": "/mcp [list|show <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]",

View File

@@ -346,6 +346,70 @@ impl ConfigLoader {
feature_config,
})
}
/// Like [`load`] but also returns the list of validation warnings collected during
/// loading, without emitting them to stderr. Callers that want to surface warnings
/// through a structured channel (e.g. the JSON config envelope) should use this.
/// #773: enables JSON-mode callers to include `warnings` in their output envelope
/// instead of receiving unstructured text on stderr.
pub fn load_collecting_warnings(&self) -> Result<(RuntimeConfig, Vec<String>), ConfigError> {
let mut merged = BTreeMap::new();
let mut loaded_entries = Vec::new();
let mut mcp_servers = BTreeMap::new();
let mut all_warnings: Vec<String> = Vec::new();
for entry in self.discover() {
crate::config_validate::check_unsupported_format(&entry.path)?;
let Some(parsed) = read_optional_json_object(&entry.path)? else {
continue;
};
let validation = crate::config_validate::validate_config_file(
&parsed.object,
&parsed.source,
&entry.path,
);
if !validation.is_ok() {
let first_error = &validation.errors[0];
return Err(ConfigError::Parse(first_error.to_string()));
}
all_warnings.extend(validation.warnings.iter().map(|w| w.to_string()));
validate_optional_hooks_config(&parsed.object, &entry.path)?;
merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)?;
deep_merge_objects(&mut merged, &parsed.object);
loaded_entries.push(entry);
}
// Still emit to stderr for non-JSON callers that go through the normal load() path;
// here we just *also* return them so callers can surface them structurally.
for warning in &all_warnings {
emit_config_warning_once(warning);
}
let merged_value = JsonValue::Object(merged.clone());
let feature_config = RuntimeFeatureConfig {
hooks: parse_optional_hooks_config(&merged_value)?,
plugins: parse_optional_plugin_config(&merged_value)?,
mcp: McpConfigCollection {
servers: mcp_servers,
},
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
model: parse_optional_model(&merged_value),
aliases: parse_optional_aliases(&merged_value)?,
permission_mode: parse_optional_permission_mode(&merged_value)?,
permission_rules: parse_optional_permission_rules(&merged_value)?,
sandbox: parse_optional_sandbox_config(&merged_value)?,
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
};
let config = RuntimeConfig {
merged,
loaded_entries,
feature_config,
};
Ok((config, all_warnings))
}
}
impl RuntimeConfig {

View File

@@ -222,7 +222,9 @@ fn main() {
// fields and add the stable status/error_kind/action contract used
// by non-interactive command guards.
let kind = classify_error_kind(&message);
let (short_reason, hint) = split_error_hint(&message);
let (short_reason, inline_hint) = split_error_hint(&message);
// #781: fall back to a kind-derived hint when the message has no \n-delimited hint
let hint = inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from));
eprintln!(
"{}",
serde_json::json!({
@@ -274,12 +276,18 @@ fn classify_error_kind(message: &str) -> &'static str {
"missing_worker_state"
} else if message.contains("session not found") {
"session_not_found"
} else if message.contains("failed to restore session") {
"session_load_failed"
} else if message.contains("no managed sessions found") {
"no_managed_sessions"
} else if message.contains("legacy session is missing workspace binding") {
// #780: must precede the generic "failed to restore session" arm — the full
// error message is "failed to restore session: legacy session is missing workspace
// binding: ...", so the specific arm must be checked first.
"legacy_session_no_workspace_binding"
} else if message.contains("Is a directory") || message.contains("os error 21") {
// #787: --resume given a directory path instead of a .jsonl file
"session_path_is_directory"
} else if message.contains("failed to restore session") {
"session_load_failed"
} else if message.contains("unsupported ACP invocation") {
"unsupported_acp_invocation"
} else if message.contains("unsupported skills action") {
@@ -298,6 +306,20 @@ fn classify_error_kind(message: &str) -> &'static str {
"unsupported_resumed_command"
} else if message.contains("confirmation required") {
"confirmation_required"
} else if (message.contains("api failed") || message.contains("api returned"))
&& (message.contains("401")
|| message.contains("Unauthorized")
|| message.contains("authentication_error"))
{
// #781: sub-classify auth failures so wrappers can distinguish from rate-limit / server errors
"api_auth_error"
} else if (message.contains("api failed") || message.contains("api returned"))
&& (message.contains("429")
|| message.contains("rate_limit")
|| message.contains("rate limit"))
{
// #781: sub-classify rate-limit failures
"api_rate_limit_error"
} else if message.contains("api failed") || message.contains("api returned") {
"api_http_error"
} else if message.contains("mcpServers") {
@@ -323,13 +345,21 @@ fn classify_error_kind(message: &str) -> &'static str {
"unsupported_config_section"
} else if message.contains("unknown_plugins_action") {
"unknown_plugins_action"
} else if message.starts_with("invalid_history_count:") || message.contains("invalid count") {
"invalid_history_count"
} else if message.starts_with("missing_prompt:") {
"missing_prompt"
} else if message.contains("has been removed.") {
// #765: removed subcommands (login, logout) — hint contains migration guidance
"removed_subcommand"
} else if message.starts_with("unexpected extra arguments") {
} else if message.starts_with("unknown subcommand:") {
// #785: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?)
"unknown_subcommand"
} else if message.starts_with("unexpected extra arguments")
|| message.starts_with("unexpected_extra_args:")
{
// #766: extra positionals after commands that take no arguments (e.g. claw diff)
// #784: export extra-positional errors use the typed prefix form
"unexpected_extra_args"
} else if message.starts_with("invalid_resume_argument:") {
// #768: --resume trailing arg is not a slash command
@@ -360,6 +390,31 @@ fn split_error_hint(message: &str) -> (String, Option<String>) {
}
}
/// #781: derive a stable fallback hint from a classified error kind when the error
/// message itself has no `\n`-delimited hint. Returns `None` for kinds where the
/// message is self-explanatory or no canonical remediation exists.
fn fallback_hint_for_error_kind(kind: &str) -> Option<&'static str> {
match kind {
"api_auth_error" => {
Some("Check that ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN is set and valid.")
}
"api_rate_limit_error" => {
Some("You have hit the API rate limit. Wait and retry, or reduce request frequency.")
}
"missing_credentials" => {
Some("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN before running claw.")
}
// #787: session load failures have no \n-delimited hint from the OS error path
"session_load_failed" => Some(
"Pass a path to a .jsonl session file, not a directory. Managed sessions live in .claw/sessions/.",
),
"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.",
),
_ => None,
}
}
/// Read piped stdin content when stdin is not a terminal.
///
/// Returns `None` when stdin is attached to a terminal (interactive REPL use),
@@ -1166,6 +1221,50 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]"
.to_string(),
),
// #767: `claw session bogus` bypassed parse_single_word_command_alias (rest.len()>1),
// had no match arm, and fell to CliAction::Prompt — reaching the credential gate
// instead of a structured error. Mirror the guard on `permissions`.
"session" => {
let action_hint = rest.get(1).map_or(String::new(), |a| format!(" (got: `{a}`)" ));
Err(format!(
"interactive_only: `claw session` is a slash command{action_hint}.\nUse `claw --resume SESSION.jsonl /session <action>` or start `claw` and run `/session [list|exists|switch|fork|delete]`."
))
}
// #770: same fallthrough gap as #767 — these slash commands had no multi-arg match arm
// and fell to CliAction::Prompt reaching the credential gate when called with args.
"cost" => Err(
"interactive_only: `claw cost` is a slash command.\nUse `claw --resume SESSION.jsonl /cost` or start `claw` and run `/cost`."
.to_string(),
),
"clear" => Err(
"interactive_only: `claw clear` is a slash command.\nUse `claw --resume SESSION.jsonl /clear [--confirm]` or start `claw` and run `/clear`."
.to_string(),
),
"memory" => Err(
"interactive_only: `claw memory` is a slash command.\nStart `claw` and run `/memory` inside the REPL."
.to_string(),
),
"ultraplan" => Err(
"interactive_only: `claw ultraplan` is a slash command.\nStart `claw` and run `/ultraplan` inside the REPL."
.to_string(),
),
"model" if rest.len() > 1 => Err(
"interactive_only: `claw model` is a slash command.\nStart `claw` and run `/model [model-name]` inside the REPL."
.to_string(),
),
// #771: usage/stats/fork are slash-only verbs with no multi-arg match arms
"usage" => Err(
"interactive_only: `claw usage` is a slash command.\nUse `claw --resume SESSION.jsonl /usage` or start `claw` and run `/usage`."
.to_string(),
),
"stats" => Err(
"interactive_only: `claw stats` is a slash command.\nUse `claw --resume SESSION.jsonl /stats` or start `claw` and run `/stats`."
.to_string(),
),
"fork" => Err(
"interactive_only: `claw fork` is a slash command.\nStart `claw` and run `/session fork [branch-name]` inside the REPL."
.to_string(),
),
"skills" => {
let args = join_optional_args(&rest[1..]);
if let Some(action) = args.as_deref() {
@@ -1197,7 +1296,16 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"system-prompt" => parse_system_prompt_args(&rest[1..], model, output_format),
"acp" => parse_acp_args(&rest[1..], output_format),
"login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())),
"init" => Ok(CliAction::Init { output_format }),
"init" => {
// #771: extra positional args to `init` were silently ignored — now rejected
if rest.len() > 1 {
let extra = rest[1..].join(" ");
return Err(format!(
"unexpected extra arguments after `claw init`: {extra}\nUsage: claw init [--cwd <dir>] [--date <date>] [--session <session-id>]"
));
}
Ok(CliAction::Init { output_format })
}
"export" => parse_export_args(&rest[1..], output_format),
"prompt" => {
let prompt = rest[1..].join(" ");
@@ -1466,17 +1574,20 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
}
let slash_command = slash_command_specs()
.iter()
.find(|spec| spec.name == command_name)?;
// #772: check both spec.name and spec.aliases for command-line invocations
.find(|spec| spec.name == command_name || spec.aliases.contains(&command_name))?;
let canonical_name = slash_command.name;
// #745: newline before remediation text so split_error_hint populates hint field
let guidance = if slash_command.resume_supported {
format!(
"`claw {command_name}` is a slash command.\nUse `claw --resume SESSION.jsonl /{command_name}` or start `claw` and run `/{command_name}`."
"`claw {command_name}` is a slash command.\nUse `claw --resume SESSION.jsonl /{canonical_name}` or start `claw` and run `/{canonical_name}`."
)
} else {
format!(
"`claw {command_name}` is a slash command.\nStart `claw` and run `/{command_name}` inside the REPL."
"`claw {command_name}` is a slash command.\nStart `claw` and run `/{canonical_name}` inside the REPL."
)
};
// #772: help text still mentions the alias, but the remediation shows canonical form
Some(guidance)
}
@@ -1498,7 +1609,7 @@ fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result<Cli
[] => Ok(CliAction::Acp { output_format }),
[subcommand] if subcommand == "serve" => Ok(CliAction::Acp { output_format }),
_ => Err(String::from(
"unsupported ACP invocation. Use `claw acp`, `claw acp serve`, `claw --acp`, or `claw -acp`.",
"unsupported ACP invocation. Use `claw acp`, `claw acp serve`, `claw --acp`, or `claw -acp`.\nACP/Zed editor integration is currently a discoverability alias only; a real daemon and JSON-RPC endpoint are in ROADMAP tracking.",
)),
}
}
@@ -2018,7 +2129,7 @@ fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result<
"--output" | "-o" => {
let value = args
.get(index + 1)
.ok_or_else(|| format!("missing value for {}", args[index]))?;
.ok_or_else(|| format!("missing_flag_value: missing value for {}.\nUsage: claw export [PATH] [--session SESSION] [--output PATH]", args[index]))?;
output_path = Some(PathBuf::from(value));
index += 2;
}
@@ -2034,7 +2145,8 @@ fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result<
index += 1;
}
other => {
return Err(format!("unexpected export argument: {other}"));
// #784: use typed prefix so classify_error_kind returns unexpected_extra_args
return Err(format!("unexpected_extra_args: unexpected export argument: {other}.\nUsage: claw export [PATH] [--session SESSION] [--output PATH]"));
}
}
}
@@ -2057,14 +2169,15 @@ fn parse_dump_manifests_args(
if arg == "--manifests-dir" {
let value = args
.get(index + 1)
.ok_or_else(|| String::from("--manifests-dir requires a path"))?;
.ok_or_else(|| String::from("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir <path> [--output-format json]"))?;
manifests_dir = Some(PathBuf::from(value));
index += 2;
continue;
}
if let Some(value) = arg.strip_prefix("--manifests-dir=") {
if value.is_empty() {
return Err(String::from("--manifests-dir requires a path"));
// #786: empty --manifests-dir= is also a missing value
return Err(String::from("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir <path> [--output-format json]"));
}
manifests_dir = Some(PathBuf::from(value));
index += 1;
@@ -2154,6 +2267,9 @@ struct DiagnosticCheck {
summary: String,
details: Vec<String>,
data: Map<String, Value>,
/// #778: stable remediation hint for warn/fail checks so automation can read
/// a structured field instead of parsing details_prose.
hint: Option<String>,
}
impl DiagnosticCheck {
@@ -2164,6 +2280,7 @@ impl DiagnosticCheck {
summary: summary.into(),
details: Vec::new(),
data: Map::new(),
hint: None,
}
}
@@ -2177,6 +2294,14 @@ impl DiagnosticCheck {
self
}
fn with_hint(mut self, hint: impl Into<String>) -> Self {
let h = hint.into();
if !h.is_empty() {
self.hint = Some(h);
}
self
}
fn json_value(&self) -> Value {
// Derive a stable snake_case id from the check name for machine-readable keying (#704).
let id = self
@@ -2239,6 +2364,14 @@ impl DiagnosticCheck {
),
),
]);
// #778: include hint field so automation can read remediation without parsing prose
value.insert(
"hint".to_string(),
self.hint
.as_deref()
.map(|h| Value::String(h.to_string()))
.unwrap_or(Value::Null),
);
value.extend(self.data.clone());
Value::Object(value)
}
@@ -2538,6 +2671,7 @@ fn check_auth_health() -> DiagnosticCheck {
"Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed"
.to_string(),
])
.with_hint("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN env var. The saved OAuth token is no longer accepted.")
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
@@ -2566,6 +2700,7 @@ fn check_auth_health() -> DiagnosticCheck {
},
)
.with_details(vec![env_details])
.with_hint(if !any_auth_present { "Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN to authenticate." } else { "" })
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
@@ -2579,6 +2714,7 @@ fn check_auth_health() -> DiagnosticCheck {
DiagnosticLevel::Fail,
format!("failed to inspect legacy saved credentials: {error}"),
)
.with_hint("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN env var to authenticate.")
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
@@ -2670,6 +2806,7 @@ fn check_config_health(
.map(|path| format!("Discovered file {path}"))
.collect()
})
.with_hint("Fix the JSON syntax error in the listed config file, then rerun `claw doctor`.")
.with_data(Map::from_iter([
("discovered_files".to_string(), json!(discovered_paths)),
(
@@ -2733,6 +2870,13 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
"current directory is not inside a git project".to_string()
},
)
.with_hint(if !in_repo {
"Run `git init` to initialise a repository, or `cd` into a git project."
} else if stale_base_warning.is_some() {
"Rebase or merge to bring the branch up to date with its base."
} else {
""
})
.with_details(vec![
format!("Cwd {}", context.cwd.display()),
format!(
@@ -2872,6 +3016,16 @@ fn check_boot_preflight_health(context: &StatusContext) -> DiagnosticCheck {
preflight.summary(),
)
.with_details(details)
.with_hint(
// #778: stable remediation hint for automation
if !preflight.repo_exists || !preflight.worktree_exists {
"Ensure you are inside a git worktree (`git init` or `git worktree add`)."
} else if !missing_binaries.is_empty() {
"Install the listed missing required binaries."
} else {
""
},
)
.with_data(Map::from_iter([(
"boot_preflight".to_string(),
preflight.json_value(),
@@ -2906,6 +3060,16 @@ fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck {
},
)
.with_details(details)
.with_hint(
// #778: stable remediation hint — sandbox degraded on non-Linux hosts is expected, not an error
if degraded && !status.supported {
"Sandbox namespace isolation requires Linux with `unshare`. On macOS/non-Linux hosts this warning is expected and can be ignored. Filesystem isolation is still active."
} else if degraded {
"Check that the `unshare` binary is available and the process has the required capabilities."
} else {
""
},
)
.with_data(Map::from_iter([
("enabled".to_string(), json!(status.enabled)),
("active".to_string(), json!(status.active)),
@@ -3173,7 +3337,10 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
// #77: classify session load errors for downstream consumers
let full_message = format!("failed to restore session: {error}");
let kind = classify_error_kind(&full_message);
let (short_reason, hint) = split_error_hint(&full_message);
let (short_reason, inline_hint) = split_error_hint(&full_message);
// #787: fall back to kind-derived hint when message has no \n delimiter
let hint =
inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from));
eprintln!(
"{}",
serde_json::json!({
@@ -3314,14 +3481,23 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
}
Err(error) => {
if output_format == CliOutputFormat::Json {
// #776: classify + split so wrappers get typed fields instead of
// hardcoded "resume_command_error" + prose in the error field
let full_error = error.to_string();
let error_kind = classify_error_kind(&full_error);
let (short_reason, inline_hint) = split_error_hint(&full_error);
// #787: fall back to kind-derived hint when error has no \n delimiter
let hint = inline_hint
.or_else(|| fallback_hint_for_error_kind(error_kind).map(String::from));
eprintln!(
"{}",
serde_json::json!({
"kind": "resume_command_error",
"kind": error_kind,
"action": "resume",
"status": "error",
"error_kind": "resume_command_error",
"error": error.to_string(),
"error_kind": error_kind,
"error": short_reason,
"hint": hint,
"exit_code": 2,
"command": raw_command,
})
@@ -4397,9 +4573,12 @@ fn run_resume_command(
}
SlashCommand::Skills { args } => {
if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) {
return Err(
"resumed /skills invocations are interactive-only; start `claw` and run `/skills <skill>` in the REPL".into(),
);
// #779: use interactive_only: prefix + \n hint so #776 classify/split emits
// error_kind:interactive_only + non-null hint instead of unknown+null.
let skill_name = args.as_deref().unwrap_or("<skill>");
return Err(format!(
"interactive_only: /skills {skill_name} invocation requires a live session.\nStart `claw` and run `/skills {skill_name}` inside the REPL, or use `claw -p <prompt>` with skill context."
).into());
}
let cwd = env::current_dir()?;
Ok(ResumeCommandOutcome {
@@ -4411,11 +4590,13 @@ fn run_resume_command(
SlashCommand::Plugins { action, target } => {
// Only list is supported in resume mode (no runtime to reload)
match action.as_deref() {
Some("install") | Some("uninstall") | Some("enable") | Some("disable")
| Some("update") => {
return Err(
"resumed /plugins mutations are interactive-only; start `claw` and run `/plugins` in the REPL".into(),
);
Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => {
// #777: use interactive_only: prefix + \n hint so #776's classify/split
// emits error_kind:interactive_only + non-null hint instead of unknown+null.
// Orchestrators can now detect this and switch to a live REPL instead of retrying.
return Err(format!(
"interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command."
).into());
}
_ => {}
}
@@ -6152,12 +6333,10 @@ impl LiveCli {
let is_help_action = result.get("action").and_then(|v| v.as_str()) == Some("help");
println!("{}", serde_json::to_string_pretty(&result)?);
if is_error && !is_help_action {
return Err(result
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("skills command failed")
.to_string()
.into());
// #788: the error JSON is already emitted above; returning Err here
// would cause the top-level handler to emit a second error envelope.
// Exit directly to signal failure without a duplicate envelope.
std::process::exit(1);
}
}
}
@@ -6791,7 +6970,7 @@ fn run_resumed_session_command(
}
Some("exists") => {
let Some(target) = target else {
return Err("/session exists requires a session id".into());
return Err("/session exists requires a session id.\nUsage: claw --resume <session> /session exists <session-id>".into());
};
let value = session_exists_json(target, &session.session_id)?;
let exists = value
@@ -6810,7 +6989,7 @@ fn run_resumed_session_command(
}
Some("delete") => {
let Some(target) = target else {
return Err("/session delete requires a session id".into());
return Err("/session delete requires a session id.\nUsage: claw --resume <session> /session delete <session-id> --force".into());
};
Ok(ResumeCommandOutcome {
session: session.clone(),
@@ -6827,7 +7006,7 @@ fn run_resumed_session_command(
}
Some("delete-force") => {
let Some(target) = target else {
return Err("/session delete requires a session id".into());
return Err("/session delete requires a session id.\nUsage: claw --resume <session> /session delete <session-id> --force".into());
};
let handle = resolve_session_reference(target)?;
if handle.id == session.session_id || handle.path == session_path {
@@ -6855,8 +7034,8 @@ fn run_resumed_session_command(
})),
})
}
Some("switch" | "fork") => Err("unsupported resumed slash command".into()),
Some(other) => Err(format!("unsupported resumed /session action: {other}").into()),
Some("switch" | "fork") => Err("unsupported_resumed_command: /session switch and /session fork require an interactive REPL.\nUsage: claw (then /session switch <id>) or claw --resume <session>".into()),
Some(other) => Err(format!("unsupported_resumed_command: /session {other} is not supported in resume mode.\nSupported: list, exists, delete").into()),
}
}
@@ -7818,7 +7997,9 @@ fn render_config_json(
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover();
let runtime_config = loader.load()?;
// #773: use load_collecting_warnings so deprecation warnings are surfaced in the
// JSON envelope instead of only as unstructured stderr text.
let (runtime_config, config_warnings) = loader.load_collecting_warnings()?;
let loaded_paths: Vec<_> = runtime_config
.loaded_entries()
@@ -7846,6 +8027,11 @@ fn render_config_json(
})
.collect();
let warnings_json: Vec<serde_json::Value> = config_warnings
.iter()
.map(|w| serde_json::Value::String(w.clone()))
.collect();
let base = serde_json::json!({
"kind": "config",
"action": if section.is_some() { "show" } else { "list" },
@@ -7854,6 +8040,9 @@ fn render_config_json(
"loaded_files": loaded_paths.len(),
"merged_keys": runtime_config.merged().len(),
"files": files,
// #773: deprecation warnings surfaced structurally so JSON-mode callers
// don't need to strip unstructured text from stderr
"warnings": warnings_json,
});
if let Some(section) = section {
@@ -8012,15 +8201,26 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso
// Derive top-level status: "ok" when all artifacts succeeded (created or
// skipped = idempotent); no failure path exists today so always "ok".
let status = "ok";
// #783: already_initialized lets orchestrators detect the idempotent case
// without checking created.len() == 0; hint gives a stable next-action pointer.
let already_initialized = report.artifacts_with_status(InitStatus::Created).is_empty()
&& report.artifacts_with_status(InitStatus::Updated).is_empty();
let hint = if already_initialized {
"Workspace already initialised. Run `claw doctor` to verify health, or edit CLAUDE.md to customise guidance."
} else {
"Review and tailor CLAUDE.md to your project, then run `claw doctor` to verify the workspace."
};
json!({
"kind": "init",
"action": "init",
"status": status,
"already_initialized": already_initialized,
"project_path": report.project_root.display().to_string(),
"created": report.artifacts_with_status(InitStatus::Created),
"updated": report.artifacts_with_status(InitStatus::Updated),
"skipped": report.artifacts_with_status(InitStatus::Skipped),
"artifacts": report.artifact_json_entries(),
"hint": hint,
"next_step": crate::init::InitReport::NEXT_STEP,
"message": message,
})
@@ -8347,11 +8547,12 @@ fn parse_history_count(raw: Option<&str>) -> Result<usize, String> {
let Some(raw) = raw else {
return Ok(DEFAULT_HISTORY_LIMIT);
};
// #776: use \n-delimited format so split_error_hint extracts hint into JSON envelopes
let parsed: usize = raw
.parse()
.map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?;
.map_err(|_| format!("invalid_history_count: '{raw}' is not a positive integer.\nUsage: /history [count] (default: {DEFAULT_HISTORY_LIMIT})"))?;
if parsed == 0 {
return Err("history: count must be greater than 0.".to_string());
return Err(format!("invalid_history_count: count must be greater than 0.\nUsage: /history [count] (default: {DEFAULT_HISTORY_LIMIT})"));
}
Ok(parsed)
}
@@ -12811,14 +13012,31 @@ mod tests {
classify_error_kind("session not found: abc123"),
"session_not_found"
);
// #780: "no managed sessions found" is more specific than generic "failed to restore"
// session_load_failed; the reordered classifier now correctly returns no_managed_sessions.
assert_eq!(
classify_error_kind("failed to restore session: no managed sessions found"),
"no_managed_sessions"
);
// Bare session load failures that aren't no_managed_sessions or legacy_binding still map here
assert_eq!(
classify_error_kind("failed to restore session: file not found"),
"session_load_failed"
);
// #787: directory-as-session-path gets its own kind (precedes generic session_load_failed)
assert_eq!(
classify_error_kind("failed to restore session: Is a directory (os error 21)"),
"session_path_is_directory"
);
assert_eq!(
classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"),
"cli_parse"
);
// #785: unknown top-level subcommand (typo or unrecognised command)
assert_eq!(
classify_error_kind("unknown subcommand: dump.\nDid you mean dump-manifests"),
"unknown_subcommand"
);
assert_eq!(
classify_error_kind("unsupported ACP invocation. Use `claw acp`."),
"unsupported_acp_invocation"
@@ -12865,6 +13083,12 @@ mod tests {
classify_error_kind("legacy session is missing workspace binding"),
"legacy_session_no_workspace_binding"
);
// #780: full error string produced by resume_session includes the
// "failed to restore session: " prefix — the specific arm must win.
assert_eq!(
classify_error_kind("failed to restore session: legacy session is missing workspace binding: /path/to/session.jsonl"),
"legacy_session_no_workspace_binding"
);
assert_eq!(
classify_error_kind("unsupported skills action: bogus. Supported actions: list"),
"unsupported_skills_action"
@@ -12887,8 +13111,19 @@ mod tests {
classify_error_kind("confirmation required before running destructive operation"),
"confirmation_required"
);
// #781: 429 and 401 now sub-classify; generic 5xx/other still api_http_error
assert_eq!(
classify_error_kind("api returned unexpected status 429"),
"api_rate_limit_error"
);
assert_eq!(
classify_error_kind(
"api returned 401 Unauthorized (authentication_error): invalid x-api-key"
),
"api_auth_error"
);
assert_eq!(
classify_error_kind("api returned 500 Internal Server Error"),
"api_http_error"
);
assert_eq!(
@@ -12899,8 +13134,9 @@ mod tests {
classify_error_kind("slash command /compact is interactive-only"),
"interactive_only"
);
// #774: agents now uses \n-delimited format — update test string to match real emission
assert_eq!(
classify_error_kind("unknown agents subcommand: bogus. Supported: list, show, help"),
classify_error_kind("unknown agents subcommand: bogus.\nSupported: list, show, help"),
"unknown_agents_subcommand"
);
assert_eq!(
@@ -15081,8 +15317,9 @@ UU conflicted.rs",
let parsed = parse_history_count(raw);
// then
assert!(parsed.is_err());
assert!(parsed.unwrap_err().contains("invalid count 'abc'"));
// #776: updated to match new invalid_history_count: prefix format
let err = parsed.expect_err("non-numeric count should fail");
assert!(err.contains("invalid_history_count:") && err.contains("'abc'"));
}
#[test]

View File

@@ -1980,3 +1980,788 @@ fn resume_non_slash_trailing_arg_has_typed_error_kind_and_hint_768() {
"hint must reference slash-command usage, got: {hint:?}"
);
}
#[test]
fn session_with_unknown_subcommand_returns_interactive_only_not_credentials_767() {
// #767: `claw session bogus` bypassed all guards and fell through to
// CliAction::Prompt, reaching the credential-check gate and returning
// error_kind:"missing_credentials" instead of a structured routing error.
// Fix: explicit "session" match arm returns interactive_only guidance.
let root = unique_temp_dir("session-unknown-767");
fs::create_dir_all(&root).expect("temp dir should exist");
for sub in &["bogus", "nuke", "delete-all"] {
let output = run_claw(&root, &["--output-format", "json", "session", sub], &[]);
assert!(
!output.status.success(),
"claw session {sub} should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| panic!("claw session {sub} stderr should contain JSON"));
let parsed: serde_json::Value =
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
assert_eq!(
parsed["error_kind"], "interactive_only",
"claw session {sub} must return error_kind:interactive_only (#767), not missing_credentials"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw session {sub} must return non-null hint (#767)"
);
assert!(
hint.contains("/session") || hint.contains("--resume"),
"hint must reference /session usage, got: {hint:?}"
);
}
}
#[test]
fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() {
// #770: `claw cost breakdown`, `claw clear --force`, `claw memory reset`,
// `claw ultraplan bogus`, `claw model opus extra` all fell through to
// CliAction::Prompt and reached the credential gate, returning
// error_kind:"missing_credentials". These are all slash-only commands;
// any multi-token invocation should return interactive_only guidance.
let root = unique_temp_dir("slash-verbs-770");
fs::create_dir_all(&root).expect("temp dir should exist");
let cases: &[&[&str]] = &[
&["cost", "breakdown"],
&["clear", "--force"],
&["memory", "reset"],
&["ultraplan", "bogus"],
&["model", "opus", "extra"],
];
for args in cases {
let full_args: Vec<&str> = std::iter::once("--output-format")
.chain(std::iter::once("json"))
.chain(args.iter().copied())
.collect();
let output = run_claw(&root, &full_args, &[]);
assert!(
!output.status.success(),
"claw {} should exit non-zero",
args.join(" ")
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| {
panic!(
"claw {} stderr should contain JSON, got: {stderr}",
args.join(" ")
)
});
let parsed: serde_json::Value =
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
assert_eq!(
parsed["error_kind"],
"interactive_only",
"claw {} must return error_kind:interactive_only (#770), not missing_credentials",
args.join(" ")
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw {} must return non-null hint (#770)",
args.join(" ")
);
}
}
#[test]
fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
// #774: `claw agents bogus`, `claw plugins bogus`, `claw mcp bogus` returned
// hint:null despite having correct error_kind. Fixed by adding \n delimiter
// to error strings in commands/src/lib.rs and explicit hint in mcp JSON envelope.
let root = unique_temp_dir("unknown-subcommands-774");
fs::create_dir_all(&root).expect("temp dir should exist");
// agents bogus
{
let output = run_claw(&root, &["--output-format", "json", "agents", "bogus"], &[]);
assert!(!output.status.success(), "agents bogus should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("agents bogus should emit JSON error");
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(parsed["error_kind"], "unknown_agents_subcommand");
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"agents bogus hint must be non-null (#774)"
);
assert!(
hint.contains("list") || hint.contains("show") || hint.contains("help"),
"agents bogus hint must mention supported actions, got: {hint:?}"
);
}
// plugins bogus
{
let output = run_claw(&root, &["--output-format", "json", "plugins", "bogus"], &[]);
assert!(!output.status.success(), "plugins bogus should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("plugins bogus should emit JSON error");
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(parsed["error_kind"], "unknown_plugins_action");
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"plugins bogus hint must be non-null (#774)"
);
}
// mcp bogus
{
let output = run_claw(&root, &["--output-format", "json", "mcp", "bogus"], &[]);
assert!(!output.status.success(), "mcp bogus should fail");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_str = if stdout.trim().starts_with('{') {
stdout.to_string()
} else {
stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or("")
.to_string()
};
let parsed: serde_json::Value =
serde_json::from_str(json_str.trim()).expect("mcp bogus should emit JSON");
assert_eq!(parsed["error_kind"], "unknown_mcp_action");
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(!hint.is_empty(), "mcp bogus hint must be non-null (#774)");
}
}
#[test]
fn interactive_only_guard_batch_769_to_771() {
// #769-#771: a sweep of slash-only verbs with args that previously fell to
// CliAction::Prompt hitting the credential gate. All must return
// error_kind:interactive_only (not missing_credentials) with non-null hint.
let root = unique_temp_dir("interactive-only-batch-769-771");
fs::create_dir_all(&root).expect("temp dir should exist");
// Need a git repo for some subcommands
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let cases: &[&[&str]] = &[
// #769: session with unknown subcommand
&["session", "bogus"],
&["session", "nuke"],
// #770: slash-only verbs with trailing args
&["cost", "breakdown"],
&["clear", "--force"],
&["memory", "reset"],
&["ultraplan", "bogus"],
&["model", "opus", "extra"],
// #771: usage/stats/fork
&["usage", "extra"],
&["stats", "extra"],
&["fork", "newbranch"],
];
for args in cases {
let full_args: Vec<&str> = std::iter::once("--output-format")
.chain(std::iter::once("json"))
.chain(args.iter().copied())
.collect();
let output = run_claw(&root, &full_args, &[]);
assert!(
!output.status.success(),
"claw {} should exit non-zero",
args.join(" ")
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| {
panic!(
"claw {} should emit JSON, got stderr: {stderr}",
args.join(" ")
)
});
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(
parsed["error_kind"],
"interactive_only",
"claw {} must return interactive_only, got {:?}",
args.join(" "),
parsed["error_kind"]
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw {} must have non-null hint",
args.join(" ")
);
}
}
#[test]
fn resume_plugin_mutations_are_typed_interactive_only_777() {
// #777: `/plugins install|enable|disable|uninstall|update` in resume mode returned
// a generic single-line error; after #776's classify/split it fell to
// error_kind:"unknown" + hint:null because there was no interactive_only: prefix.
// Fix: each mutation arm now returns "interactive_only: ... \n..." so the caller
// gets error_kind:interactive_only + non-null hint pointing at live REPL.
let root = unique_temp_dir("resume-plugin-mutations-777");
fs::create_dir_all(&root).expect("temp dir should exist");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// Create a minimal session file so we get past session load and into command dispatch
let session_file = write_session_fixture(&root, "resume-plugin-777", None);
for mutation in &["install", "enable", "disable", "uninstall", "update"] {
let cmd = format!("/plugins {mutation} my-plugin");
let output = run_claw(
&root,
&[
"--resume",
session_file.to_str().unwrap(),
"--output-format",
"json",
&cmd,
],
&[],
);
assert!(
!output.status.success(),
"/plugins {mutation} in resume mode should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| {
panic!("/plugins {mutation} should emit JSON error, got stderr: {stderr}")
});
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(
parsed["error_kind"], "interactive_only",
"/plugins {mutation} must return interactive_only, got {:?}",
parsed["error_kind"]
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"/plugins {mutation} must have non-null hint (#777)"
);
assert!(
hint.contains("claw") || hint.contains("REPL") || hint.contains("plugins"),
"/plugins {mutation} hint must reference live session or CLI, got: {hint:?}"
);
}
}
#[test]
fn resume_skills_invocation_is_typed_interactive_only_779() {
// #779: `/skills <skill>` invocation in resume mode returned bare prose;
// after #776 classify/split it fell to error_kind:"unknown" + hint:null.
// Fix: use interactive_only: prefix + \n hint so callers get typed fields.
let root = unique_temp_dir("resume-skills-invocation-779");
fs::create_dir_all(&root).expect("temp dir should exist");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let session_file = write_session_fixture(&root, "resume-skills-779", None);
// A non-empty skills arg that would classify as Invoke
let output = run_claw(
&root,
&[
"--resume",
session_file.to_str().unwrap(),
"--output-format",
"json",
"/skills my-skill",
],
&[],
);
assert!(
!output.status.success(),
"/skills <skill> in resume mode should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| {
panic!("/skills invocation should emit JSON error, got stderr: {stderr}")
});
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(
parsed["error_kind"], "interactive_only",
"resumed /skills invocation must return interactive_only, got {:?}",
parsed["error_kind"]
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"resumed /skills invocation must have non-null hint (#779)"
);
assert!(
hint.contains("claw") || hint.contains("REPL") || hint.contains("skills"),
"hint must reference live session or CLI, got: {hint:?}"
);
}
#[test]
fn acp_unsupported_invocation_has_hint_782() {
// #782: `claw acp start` returned error_kind:unsupported_acp_invocation but hint:null
// because the remediation text was on the same line as the error message.
// Fix: add \n-delimited hint so split_error_hint extracts it.
let root = unique_temp_dir("acp-unsupported-782");
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", "acp", "start"], &[]);
assert!(!output.status.success(), "acp start should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("should emit JSON error");
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(
parsed["error_kind"], "unsupported_acp_invocation",
"unsupported ACP invocation should be classified correctly"
);
let hint = parsed["hint"]
.as_str()
.expect("hint must be non-null (#782)");
assert!(!hint.is_empty(), "hint must not be empty");
assert!(
hint.contains("discoverability") || hint.contains("ROADMAP"),
"hint should explain the discoverability-only status, got: {hint:?}"
);
}
#[test]
fn init_json_envelope_has_hint_and_already_initialized_783() {
// #783: claw --output-format json init was missing the hint field entirely.
// Also added already_initialized: bool so orchestrators can detect the idempotent
// case without checking created.len() == 0.
let root = unique_temp_dir("init-hint-783");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// Fresh init — already_initialized should be false, hint should mention CLAUDE.md
let output = run_claw(&root, &["--output-format", "json", "init"], &[]);
assert!(output.status.success(), "init should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let raw = if stdout.trim_start().starts_with('{') {
&*stdout
} else {
&*stderr
};
let parsed: serde_json::Value = serde_json::from_str(raw.trim()).unwrap_or_else(|_| {
// multi-line JSON; find the whole block
serde_json::from_str(raw).expect("should emit valid JSON")
});
assert_eq!(parsed["status"], "ok", "init should succeed");
assert!(
parsed.get("already_initialized").is_some(),
"init JSON must include already_initialized field (#783)"
);
assert_eq!(
parsed["already_initialized"], false,
"first init: already_initialized must be false"
);
let hint = parsed["hint"]
.as_str()
.expect("hint must be present and non-null (#783)");
assert!(!hint.is_empty(), "hint must not be empty");
assert!(
hint.contains("CLAUDE.md") || hint.contains("doctor"),
"fresh-init hint should mention CLAUDE.md or doctor, got: {hint:?}"
);
// Idempotent re-init — already_initialized should be true
let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]);
assert!(output2.status.success(), "re-init should succeed");
let stdout2 = String::from_utf8_lossy(&output2.stdout);
let stderr2 = String::from_utf8_lossy(&output2.stderr);
let raw2 = if stdout2.trim_start().starts_with('{') {
&*stdout2
} else {
&*stderr2
};
let parsed2: serde_json::Value = serde_json::from_str(raw2.trim())
.or_else(|_| serde_json::from_str(raw2))
.expect("re-init should emit valid JSON");
assert_eq!(
parsed2["already_initialized"], true,
"re-init: already_initialized must be true"
);
let hint2 = parsed2["hint"]
.as_str()
.expect("hint must be present on re-init");
assert!(
hint2.contains("already") || hint2.contains("doctor"),
"re-init hint should acknowledge workspace exists, got: {hint2:?}"
);
}
#[test]
fn export_arg_errors_have_typed_kind_and_hint_784() {
// #784: `claw export --output` (missing flag value) returned error_kind:"unknown" + hint:null.
// `claw export a.md b.md` (extra positional) also returned unknown+null.
// Both export arg errors now use typed prefixes + usage hint.
let root = unique_temp_dir("export-arg-errors-784");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// Missing --output value
let out1 = run_claw(
&root,
&["--output-format", "json", "export", "--output"],
&[],
);
assert!(!out1.status.success(), "--output with no value should fail");
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("missing --output should emit JSON error");
assert_eq!(
j1["error_kind"], "missing_flag_value",
"missing --output value should be missing_flag_value, got {:?}",
j1["error_kind"]
);
let h1 = j1["hint"]
.as_str()
.expect("missing_flag_value must have hint (#784)");
assert!(
!h1.is_empty() && h1.contains("export"),
"hint must reference export usage, got: {h1:?}"
);
// Extra positional argument
let out2 = run_claw(
&root,
&["--output-format", "json", "export", "first.md", "second.md"],
&[],
);
assert!(!out2.status.success(), "extra positional should fail");
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("extra positional should emit JSON error");
assert_eq!(
j2["error_kind"], "unexpected_extra_args",
"extra positional should be unexpected_extra_args, got {:?}",
j2["error_kind"]
);
let h2 = j2["hint"]
.as_str()
.expect("unexpected_extra_args must have hint (#784)");
assert!(
!h2.is_empty() && h2.contains("export"),
"hint must reference export usage, got: {h2:?}"
);
}
#[test]
fn unknown_subcommand_returns_typed_kind_785() {
// #785: `claw dump` (a near-miss for dump-manifests) returned error_kind:"unknown"
// because the classifier had no arm for "unknown subcommand:" prose prefix.
// Fix: added "unknown_subcommand" arm in classify_error_kind.
let root = unique_temp_dir("unknown-subcommand-785");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// "dump" is close enough to "dump-manifests" to trigger the typo suggestion path
let output = run_claw(&root, &["--output-format", "json", "dump"], &[]);
assert!(!output.status.success(), "unknown subcommand should fail");
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("unknown subcommand should emit JSON error");
assert_eq!(
j["error_kind"], "unknown_subcommand",
"unknown subcommand should return unknown_subcommand kind, got {:?}",
j["error_kind"]
);
// hint should point at the suggestion and/or --help
let hint = j["hint"].as_str().unwrap_or("");
assert!(
hint.contains("dump-manifests") || hint.contains("--help") || hint.contains("claw"),
"hint should reference the suggested subcommand or help, got: {hint:?}"
);
}
#[test]
fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
// #786: `claw dump-manifests --manifests-dir` (no value) and `--manifests-dir=` (empty)
// both emitted plain "--manifests-dir requires a path" with error_kind:"unknown" + hint:null.
// Fix: use missing_flag_value: prefix + \n usage hint.
let root = unique_temp_dir("dump-manifests-missing-dir-786");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// Case 1: --manifests-dir with no following value (next arg is --output-format)
let out1 = run_claw(
&root,
&[
"--output-format",
"json",
"dump-manifests",
"--manifests-dir",
"--output-format",
"json",
],
&[],
);
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("missing --manifests-dir value should emit JSON error");
assert_eq!(
j1["error_kind"], "missing_flag_value",
"missing --manifests-dir value should be missing_flag_value, got {:?}",
j1["error_kind"]
);
let h1 = j1["hint"]
.as_str()
.expect("missing_flag_value must have hint (#786)");
assert!(
h1.contains("dump-manifests") || h1.contains("manifests-dir"),
"hint should reference dump-manifests usage, got: {h1:?}"
);
// Case 2: --manifests-dir= with empty value
let out2 = run_claw(
&root,
&[
"--output-format",
"json",
"dump-manifests",
"--manifests-dir=",
],
&[],
);
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("empty --manifests-dir= should emit JSON error");
assert_eq!(
j2["error_kind"], "missing_flag_value",
"empty --manifests-dir= should be missing_flag_value, got {:?}",
j2["error_kind"]
);
let h2 = j2["hint"]
.as_str()
.expect("missing_flag_value must have hint (#786)");
assert!(!h2.is_empty(), "hint must not be empty");
}
#[test]
fn resume_directory_path_returns_typed_kind_and_hint_787() {
// #787: `claw --resume /tmp` (directory instead of .jsonl file) returned
// error_kind:"session_load_failed" + hint:null. The OS error "Is a directory (os error 21)"
// had no \n delimiter so split_error_hint returned None, and the resume error path
// didn't call fallback_hint_for_error_kind.
// Fix: (1) added session_path_is_directory classifier arm for os error 21;
// (2) wired fallback_hint_for_error_kind into both resume error emission sites.
let root = unique_temp_dir("resume-dir-787");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// Pass the root directory itself as the session path
let output = run_claw(
&root,
&[
"--output-format",
"json",
"--resume",
root.to_str().unwrap(),
"/status",
],
&[],
);
assert!(
!output.status.success(),
"resume with directory should fail"
);
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("resume with directory should emit JSON error");
assert_eq!(
j["error_kind"], "session_path_is_directory",
"directory resume path should return session_path_is_directory, got {:?}",
j["error_kind"]
);
let hint = j["hint"]
.as_str()
.expect("session_path_is_directory must have hint (#787)");
assert!(
hint.contains(".jsonl") || hint.contains("session") || hint.contains("file"),
"hint should explain expected path format, got: {hint:?}"
);
}
#[test]
fn skills_show_not_found_emits_single_json_object_788() {
// #788: `claw --output-format json skills show no-such-skill` emitted TWO JSON objects:
// one from the skills handler (action:"show", status:"error") and a second from the
// top-level error handler (action:"abort"). The skills handler returned Err() after
// printing its JSON, which caused the ? propagation to trigger a duplicate envelope.
// Fix: exit(1) directly after the skills JSON is emitted instead of returning Err.
let root = unique_temp_dir("skills-show-double-emit-788");
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",
"no-such-skill-xyz",
],
&[],
);
assert!(!output.status.success(), "skills show unknown should fail");
// Skills handler emits JSON to stdout; the duplicate was on stderr from the main error path.
// After fix: stdout has 1 JSON object, stderr has none (no duplicate).
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// Count JSON objects in stdout — must be exactly 1
let json_objects: Vec<serde_json::Value> = {
let mut objects = Vec::new();
let mut remaining = stdout.trim();
while !remaining.is_empty() {
match serde_json::from_str::<serde_json::Value>(remaining) {
Ok(v) => {
objects.push(v);
break;
}
Err(_) => {
// Try finding a complete JSON object
if let Some(pos) = remaining.find('{') {
remaining = &remaining[pos..];
let mut depth = 0i32;
let mut end = 0;
for (i, c) in remaining.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = i + 1;
break;
}
}
_ => {}
}
}
if end > 0 {
if let Ok(v) = serde_json::from_str(&remaining[..end]) {
objects.push(v);
remaining = remaining[end..].trim_start();
} else {
break;
}
} else {
break;
}
} else {
break;
}
}
}
}
objects
};
assert_eq!(
json_objects.len(),
1,
"skills show not-found must emit exactly 1 JSON object on stdout, got {}. stdout: {} stderr: {}",
json_objects.len(),
stdout,
stderr
);
// Verify stderr has no duplicate error JSON (the pre-#788 bug was a second abort envelope here)
let stderr_has_json = stderr.lines().any(|l| l.trim_start().starts_with('{'));
assert!(
!stderr_has_json,
"stderr must have no duplicate JSON error envelope, got: {stderr}"
);
assert_eq!(
json_objects[0]["error_kind"], "skill_not_found",
"single JSON object must have skill_not_found error_kind"
);
assert_eq!(json_objects[0]["status"], "error");
}