mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-06 01:42:47 -04:00
Compare commits
20 Commits
b778d4e3d4
...
abdbf61acf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abdbf61acf | ||
|
|
113145a42a | ||
|
|
22b423b651 | ||
|
|
87f4334728 | ||
|
|
e628b4bb68 | ||
|
|
81fe0ccbb7 | ||
|
|
32c9276fdb | ||
|
|
16c1117af6 | ||
|
|
d9844cfe8d | ||
|
|
364e7909f4 | ||
|
|
fded4f6b11 | ||
|
|
e02030364d | ||
|
|
2684737d9e | ||
|
|
028998d040 | ||
|
|
c760a49c47 | ||
|
|
727a1ea4a3 | ||
|
|
212f0b2ad4 | ||
|
|
bf212b986d | ||
|
|
3a1d88386c | ||
|
|
9e1be05634 |
40
ROADMAP.md
40
ROADMAP.md
@@ -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.
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user