mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-05 17:32:43 -04:00
Compare commits
25 Commits
89e7f415a9
...
fix/direct
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
892d8f9ea6 | ||
|
|
63931c74fb | ||
|
|
4d3dc5b873 | ||
|
|
ac5b19dee1 | ||
|
|
fdfb9f4dc1 | ||
|
|
187aebd74f | ||
|
|
9d05573f24 | ||
|
|
58902915f6 | ||
|
|
d47b015100 | ||
|
|
5458d3547a | ||
|
|
70d64be033 | ||
|
|
de7edd5bb1 | ||
|
|
f0e6671538 | ||
|
|
b4b1ba10f6 | ||
|
|
e50c46c1ed | ||
|
|
3dbb35c3aa | ||
|
|
3a76c4f4fd | ||
|
|
69b59079c5 | ||
|
|
42aff269d1 | ||
|
|
efe59c22e4 | ||
|
|
37a9a543d6 | ||
|
|
0800d7ae88 | ||
|
|
69b8b367c1 | ||
|
|
9494e3c26f | ||
|
|
ed3a616e62 |
98
ROADMAP.md
98
ROADMAP.md
@@ -7814,3 +7814,101 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||
**Required fix shape.** In JSON mode for config/list surfaces that already include `warnings[]`, suppress eager prose emission of the same config warning on stderr or mark it as already collected. Text mode should keep the human stderr warning. Add regression coverage asserting `claw --output-format json config` returns exactly one structured warning and zero duplicate `enabledPlugins` prose lines on stderr.
|
||||
|
||||
**Acceptance.** With a deprecated `enabledPlugins` key present, `claw --output-format json config` exits 0, stdout parses from byte 0 and includes `warnings[]`, and stderr has no duplicate deprecation warning for the same file/key. [SCOPE: claw-code]
|
||||
|
||||
816. **JSON-mode local/list surfaces still leak deprecated config prose warnings on stderr outside `config`** — dogfooded 2026-05-28 09:30 on `main` `89e7f415a` after #3190. `./target/debug/claw --output-format json config` is now fixed (`rc=0`, parseable stdout, `warnings[]`, stderr empty), but sibling JSON surfaces still emit the same app-level config warning to stderr when `~/.claw/settings.json` contains deprecated `enabledPlugins`: `plugins list` (`kind:"plugin"`), `mcp list` (`kind:"mcp"`), and `doctor` (`kind:"doctor"`) all return parseable JSON with `rc=0` while stderr contains `enabledPlugins is deprecated`. `skills list` and `version` stay clean. This leaves machine consumers with a global JSON-mode cleanliness gap even after the config-specific duplicate was fixed.
|
||||
|
||||
**Required fix shape.** Treat JSON output mode as a global app-level diagnostic routing contract: local/list/status surfaces that successfully return structured JSON should not write config deprecation prose to stderr. Either collect those warnings into each relevant JSON envelope where a warnings field exists, or suppress config-warning emission during JSON-mode preloading/default resolution for surfaces that cannot represent warnings yet. Preserve human stderr warnings in text mode.
|
||||
|
||||
**Acceptance.** With deprecated `enabledPlugins` present, `claw --output-format json plugins list`, `claw --output-format json mcp list`, and `claw --output-format json doctor` exit 0, stdout parses from byte 0, and stderr contains zero `enabledPlugins is deprecated` app-level warning lines. Text mode still prints the warning. [SCOPE: claw-code]
|
||||
|
||||
817. **`claw --output-format json plugins list --` writes its JSON error envelope to stderr while sibling local inventory commands use stdout** — dogfooded 2026-05-28 12:30 on `main` `9494e3c26`. Trailing bare `--` is a useful parser edge because automation sometimes injects delimiter sentinels. `agents list --` and `skills list --` return rc 1 with parseable JSON on stdout and empty stderr. `mcp list --` also returns a parseable JSON error on stdout. `config --` returns rc 0 with a structured config error on stdout. But `plugins list --` returns rc 1, stdout empty, and writes the JSON error envelope to stderr: `{"action":"abort","error":"unknown option for `claw plugins list`: --", ...}`. This is machine-readable, but channel-inconsistent and surprising for JSON-mode consumers that read stdout for command payloads.
|
||||
|
||||
**Required fix shape.** Align `plugins list` parse-error routing with the other JSON inventory/local surfaces: in JSON mode, print the structured CLI error envelope to stdout and keep stderr empty for this handled parse error. Preserve text-mode stderr behavior. Add regression coverage for `claw --output-format json plugins list --` asserting rc 1, stdout parseable JSON with `error_kind:"cli_parse"`, and empty stderr.
|
||||
|
||||
**Acceptance.** `claw --output-format json plugins list --` exits 1, stdout parses from byte 0 as the existing JSON error envelope, stderr is empty, and text mode still reports the parse error to stderr. [SCOPE: claw-code]
|
||||
|
||||
818. **`AGENTS.md` and `.claude/CLAUDE.md` silently omitted from instruction file cascade** — dogfooded 2026-05-29 08:00. When a repo contains `AGENTS.md` (OpenAI Codex / multi-agent convention) or `.claude/CLAUDE.md` (scoped Claude Code convention), claw-code does not load either file as part of the instruction/context cascade on startup. Users following either convention discover this only by noticing their persona/context instructions have no effect — no warning, no missing-file diagnostic, no documentation note. This is a friction gap for any team migrating to or simultaneously using claw-code alongside Claude Code or Codex workflows, since the two most common non-CLAUDE.md instruction files are silently ignored.
|
||||
|
||||
**Required fix shape.** Add `AGENTS.md` (project root) and `.claude/CLAUDE.md` (`.claude/` subdirectory) to the instruction file cascade that already loads `CLAUDE.md`. Apply the same merge-and-precedence semantics as existing instruction files. Log a debug trace (not stderr noise) when either file is loaded. Add test coverage: a fixture repo with `AGENTS.md` only, `.claude/CLAUDE.md` only, and both present alongside `CLAUDE.md` should each have the relevant content visible in the resolved instruction context.
|
||||
|
||||
**Acceptance.** `claw` launched in a repo containing `AGENTS.md` or `.claude/CLAUDE.md` loads those files into the instruction context. No warning emitted for absent optional files. Existing `CLAUDE.md`-only repos unaffected. PR #3195. [SCOPE: claw-code]
|
||||
|
||||
819. **`claw --output-format json export --session <missing>` writes JSON error envelope to stderr, stdout empty** — dogfooded 2026-05-29 09:30 on `main` `37a9a543`. `claw --output-format json export --session does-not-exist` exits rc=1 with stdout length 0 and the full JSON error envelope on stderr: `{"action":"abort","error":"session not found: does-not-exist","error_kind":"session_not_found",...}`. This is the same channel-routing inconsistency class as #817 (plugins list trailing-dash, fixed in #3194): handled errors in JSON mode should go to stdout, not stderr, so machine consumers can parse the envelope from stdout byte 0 regardless of which surface triggered the error.
|
||||
|
||||
**Required fix shape.** Align `export --session <missing>` error routing with the inventory surfaces fixed in #817: in JSON mode, write the `session_not_found` error envelope to stdout (rc=1) and keep stderr empty. Preserve text-mode behavior (stderr message). Add regression coverage asserting rc=1, stdout parseable JSON with `error_kind:"session_not_found"`, and empty stderr.
|
||||
|
||||
**Acceptance.** `claw --output-format json export --session does-not-exist` exits 1, stdout contains the JSON error envelope from byte 0, stderr is empty. Text mode still prints the error to stderr. [SCOPE: claw-code]
|
||||
|
||||
820. **`interactive_only` error class always routes JSON envelope to stderr (stdout empty)** — dogfooded 2026-05-29 10:00 on `main` `efe59c22`. All `interactive_only` errors share the same routing gap as #819 (`export --session <missing>`): `claw --output-format json session list`, `session switch <id>`, `session delete <id>`, and `session fork <id>` each exit rc=1, stdout empty, JSON envelope on stderr. The envelope is well-formed (`error_kind:"interactive_only"`, `hint:...`, `action:"abort"`) but the channel is wrong for JSON mode. Any surface that returns `interactive_only` is affected; these are all the `claw session` subcommands. This is the same root cause as #817 (plugins) and #819 (export): the top-level error handler writes `Err(...)` to stderr instead of routing to stdout when `--output-format json` is active.
|
||||
|
||||
**Required fix shape.** In the top-level error handler (or the `interactive_only` classifier arm in `main.rs`), detect JSON output mode and write the structured error envelope to stdout (rc=1) instead of stderr. Scope the fix to the `interactive_only` error_kind so all affected surfaces are repaired in one pass. Add regression coverage for at least `claw --output-format json session list` asserting rc=1, stdout parseable JSON with `error_kind:"interactive_only"`, stderr empty.
|
||||
|
||||
**Acceptance.** All `claw --output-format json session <subcommand>` invocations exit 1 with the JSON envelope on stdout and empty stderr. Text mode continues to print the error to stderr. [SCOPE: claw-code]
|
||||
|
||||
821. **`status`, `sandbox`, and `system-prompt` in JSON mode still emit config deprecation warning to stderr** — dogfooded 2026-05-29 10:30 on `main` `42aff269`. After #816 fixed config deprecation stderr leakage for `plugins list`, `mcp list`, `doctor`, and `config`, three JSON-mode surfaces continue to emit the `enabledPlugins is deprecated` prose warning to stderr: `claw --output-format json status` (122 bytes stderr), `claw --output-format json sandbox` (122 bytes stderr), `claw --output-format json system-prompt` (122 bytes stderr). These surfaces return well-formed JSON on stdout (rc=0) but leak the config warning to stderr, leaving machine consumers with mixed-channel output. `version`, `acp`, `agents`, `skills`, `mcp`, `plugins`, and `doctor` all have clean stderr after #816.
|
||||
|
||||
**Required fix shape.** Extend the JSON-mode config-warning suppression applied in #816 to cover `status`, `sandbox`, and `system-prompt`. The fix should apply globally: any JSON-mode surface that completes successfully should not emit config deprecation prose to stderr. Text mode should keep the human stderr warning.
|
||||
|
||||
**Acceptance.** With deprecated `enabledPlugins` in `~/.claw/settings.json`, `claw --output-format json status`, `claw --output-format json sandbox`, and `claw --output-format json system-prompt` each exit 0, stdout parses from byte 0, and stderr is empty. Text mode still prints the deprecation warning. [SCOPE: claw-code]
|
||||
|
||||
**Follow-up (2026-05-29 12:00, `main` `3dbb35c3`).** Broader sweep confirms additional surfaces with the same 122-byte stderr leak in JSON mode: `--resume latest /config` (all subforms: bare, `env`, `hooks`, `model`, `plugins`) and `--resume latest /providers` (doctor alias). The fix must apply to all config-loading paths, not just the three originally documented surfaces. The suppression guard should fire at the settings-load level so any JSON-mode invocation benefits without per-surface patching.
|
||||
|
||||
822. **Unknown top-level subcommand falls through to REPL/provider startup instead of returning a `command_not_found` error** — dogfooded 2026-05-29 11:00 on `main` `69b59079`. `claw --output-format json foobar` does not return a structured `command_not_found` error; instead it falls through to the interactive/API path and hits `missing_credentials` (rc=1, stderr: `{"error_kind":"missing_credentials",...}`). Two gaps in one: (1) the unrecognized command word is silently treated as a prompt/text argument, not flagged as unknown, so the user gets a misleading "no credentials" error instead of "command not found"; (2) the resulting error goes to stderr. This makes automation scripts that probe for command availability impossible to distinguish from auth failures.
|
||||
|
||||
**Required fix shape.** Before falling through to the REPL/prompt path, check whether the first positional arg matches any known subcommand. If not, return a typed error: `{"error_kind":"command_not_found","message":"unknown command: foobar","hint":"Run `claw --help` for available commands.","status":"error"}` on stdout (JSON mode, rc=1) or stderr (text mode). This mirrors the behavior of `--bogus-flag` (which correctly returns `cli_parse`) but for unknown positional commands.
|
||||
|
||||
**Acceptance.** `claw --output-format json foobar` exits 1, stdout contains JSON with `error_kind:"command_not_found"`, stderr empty. Text mode prints the error to stderr. No provider startup attempted. [SCOPE: claw-code]
|
||||
|
||||
823. **`claw --output-format json prompt` with missing/empty prompt text routes JSON error to stderr (stdout empty)** — dogfooded 2026-05-29 11:30 on `main` `3a76c4f4`. `claw --output-format json prompt` (no text) and `claw --output-format json prompt ""` (empty string) both exit rc=1, stdout empty, and write `{"error_kind":"missing_prompt","action":"abort",...}` to stderr. The envelope is well-formed but channel-inconsistent: JSON mode machine consumers reading stdout for command results get empty stdout and must check stderr to detect the error. This is the same class as #819 (export session-not-found) and #820 (interactive_only / session subcommands), and the same root cause: the top-level abort handler writes to stderr regardless of output-format mode.
|
||||
|
||||
**Required fix shape.** In JSON mode, route `missing_prompt` abort errors to stdout (rc=1) and keep stderr empty. This is the same fix pattern as #817/#819/#820: detect JSON output mode in the abort handler and redirect the structured envelope to stdout. Add regression coverage for `claw --output-format json prompt` (no arg) and `claw --output-format json prompt ""` asserting rc=1, stdout parseable JSON with `error_kind:"missing_prompt"`, stderr empty.
|
||||
|
||||
**Acceptance.** Both invocations exit 1 with JSON envelope on stdout and empty stderr. Text mode still prints to stderr. [SCOPE: claw-code]
|
||||
|
||||
824. **Global settings-load deprecation warning still leaks to stderr in JSON mode for `status`, `sandbox`, `system-prompt`, `skills`, `mcp`, `agents` surfaces** — dogfooded 2026-05-29 13:30 on `main` `b4b1ba10`. After #816 and #821 (doc), the `enabledPlugins is deprecated` config warning still reaches stderr on every JSON-mode surface that loads settings: `claw --output-format json status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list` all emit `warning: /path/.claw/settings.json: field "enabledPlugins" is deprecated (line 2)...` to stderr. Root cause: `emit_config_warning_once()` in `runtime/src/config.rs` always uses `eprintln!` with no output-format awareness. The `config` surface avoids the duplicate by collecting warnings into a structured `warnings[]` field, but all other surfaces hit the raw `eprintln!` path.
|
||||
|
||||
**Required fix shape.** Add a global `SUPPRESS_CONFIG_WARNINGS_STDERR: AtomicBool` flag in `config.rs`. Set it to `true` immediately when `--output-format json` is detected in `main.rs` (before any settings load). Gate `emit_config_warning_once` on that flag. Text-mode invocations continue to print to stderr; JSON-mode invocations silently suppress the prose warning (warnings remain available via structured `config` output).
|
||||
|
||||
**Acceptance.** With deprecated `enabledPlugins` in `~/.claw/settings.json`, all JSON-mode surfaces (`status`, `sandbox`, `system-prompt`, `mcp list`, `skills list`, `agents list`, plus all `--resume /config*` forms) exit with empty stderr. Text-mode output is unchanged. [SCOPE: claw-code]
|
||||
|
||||
825. **Unknown single-word subcommand falls through to provider startup and surfaces `missing_credentials` instead of `command_not_found`** — dogfooded 2026-05-29 14:00 on `main` `de7edd5b`. `claw foobar` (and `claw --output-format json foobar`) hit the `looks_like_subcommand_typo` guard, which checked for close fuzzy matches but fell through silently when no suggestions matched. The fallthrough routed to `CliAction::Prompt`, triggering Anthropic provider startup and a misleading `missing_credentials` error (or burning API tokens if credentials were present). The `command_not_found` error kind existed in the registry but was never emitted by this path.
|
||||
|
||||
**Required fix shape.** When `looks_like_subcommand_typo` fires on a single-word positional arg with no close suggestions, emit `command_not_found:` rather than falling through. Add `command_not_found:` prefix classifier to `classify_error_kind`. Result: clean `{"error_kind":"command_not_found",...}` envelope on stdout (JSON mode), error on stderr (text mode), zero provider startup.
|
||||
|
||||
**Acceptance.** `claw --output-format json foobar` exits 1, stdout `error_kind:"command_not_found"`, stderr empty, no Anthropic call. Typo with suggestions (`claw statuz`) also gets `command_not_found` plus `hint` with suggestions. [SCOPE: claw-code]
|
||||
|
||||
826. **Multi-word unknown subcommand still falls through to `missing_credentials`** — dogfooded 2026-05-29 14:38 on `main` `70d64be0`. After #825 fixed single-word unknown subcommands, multi-word invocations (`claw foobar baz`) are still undetected: the `looks_like_subcommand_typo` guard only fires when `rest.len() == 1`. When there are two or more positional args, the first word is treated as a prompt and all args join into a prompt string → provider startup → `missing_credentials`. Same misleading-error class as #825 but for multi-word cases.
|
||||
|
||||
**Required fix shape.** Extend the command-not-found guard to also fire when `rest.len() > 1` and `rest[0]` passes `looks_like_subcommand_typo` but does not match any known subcommand. The multi-arg case should also emit `command_not_found` — with a note that if literal multi-word prompt was intended, use `claw prompt <text>` or `echo 'text' | claw`.
|
||||
|
||||
**Acceptance.** `claw --output-format json foobar baz` exits 1, stdout `error_kind:"command_not_found"`, stderr empty, no provider startup. `claw "write a haiku"` (valid prompt passthrough) is unaffected. [SCOPE: claw-code]
|
||||
|
||||
827. **`--resume <session> /unknown-slash-command` emits `error_kind:"unknown"` instead of a typed kind** — dogfooded 2026-05-29 14:58 on `main` `d47b0151`. `claw --resume latest --output-format json /bogus` exits rc=2 with `{"error_kind":"unknown",...}` on stdout (correct channel, correct rc) but the opaque `"unknown"` kind gives machine consumers no way to distinguish "unrecognized slash command" from other error classes. The error has a useful `hint` with suggestions, but the `error_kind` field is `"unknown"` across all unrecognized resume slash commands.
|
||||
|
||||
**Required fix shape.** Introduce a typed `error_kind` for unrecognized slash commands (e.g. `unknown_slash_command` or `command_not_found`). Update the JSON emit in the `--resume` unknown-command handler to use the typed kind. Add regression coverage asserting the typed kind.
|
||||
|
||||
**Acceptance.** `claw --resume latest --output-format json /bogus` exits rc=2, stdout `error_kind:"unknown_slash_command"` (or similar typed constant), stderr empty. [SCOPE: claw-code]
|
||||
|
||||
828. **`/approve` and `/deny` outside REPL emit `unknown_slash_command` instead of `interactive_only`** — dogfooded 2026-05-29 16:05 on `main` `9d05573f`. `claw --output-format json /approve` exited rc=1 with `error_kind:"unknown_slash_command"` — these are valid REPL-only slash commands but are not `SlashCommand` enum variants, so they fell through to `format_unknown_direct_slash_command`. Machine consumers saw the wrong error class.
|
||||
|
||||
**Fix applied.** `SlashCommand::Unknown` arm now special-cases `approve | yes | y | deny | no | n` and emits `interactive_only:` prefix before falling through to `format_unknown_direct_slash_command`. Both `error_kind` and hint are correct.
|
||||
|
||||
**Acceptance.** `claw --output-format json /approve` exits rc=1, stdout `error_kind:"interactive_only"`, stderr empty. [SCOPE: claw-code]
|
||||
|
||||
829. **`interactive_only` hint incorrectly suggests `--resume` for commands that are not resume-safe** — dogfooded 2026-05-29 16:40 on `main` `187aebd7`. `claw --output-format json /commit` hint says `"use claw --resume SESSION.jsonl /commit"` but `/commit` is not resume-safe (no `[resume]` marker in `/help`). Same for `/pr`, `/issue`, `/bughunter`, `/ultraplan`. The generic `interactive_only` hint template does not distinguish resume-safe from live-REPL-only commands, so it always suggests `--resume` regardless. Users who follow the hint will get `interactive_only` again.
|
||||
|
||||
**Required fix shape.** The generic `interactive_only:` message formatter (line ~1745 in `main.rs`) currently always appends `or use claw --resume SESSION.jsonl {command_name}`. This should be conditioned on whether the slash command appears in the resume-safe command list. Non-resume-safe interactive commands should only say `Start claw and run it there.`
|
||||
|
||||
**Acceptance.** `claw --output-format json /commit` hint does NOT mention `--resume`. `claw --output-format json /status` (resume-safe) hint still mentions `--resume`. [SCOPE: claw-code]
|
||||
|
||||
830. **`claw mcp show` (missing server name arg) emits `error_kind:"unknown_mcp_action"` instead of `missing_argument`** — dogfooded 2026-05-29 17:00 on `main` `ac5b19de`. `claw --output-format json mcp show` (no server name supplied) exits with `error_kind:"unknown_mcp_action"`. However `show` IS a known MCP action — the error is a missing required argument (server name), not an unknown action. Machine consumers inspecting `error_kind` cannot distinguish "I don't know this action" from "I know this action but a required arg is missing".
|
||||
|
||||
**Required fix shape.** The MCP subcommand parser should detect `show` with no following token and emit `missing_argument: mcp show requires a server name.\nUsage: claw mcp show <server>` with a distinct `error_kind`. Update the classifier arm to return `missing_argument` for this prefix.
|
||||
|
||||
**Acceptance.** `claw --output-format json mcp show` exits rc=1, stdout `error_kind:"missing_argument"`, stderr empty. Hint contains usage example. [SCOPE: claw-code]
|
||||
|
||||
831. **Direct-slash resume-safe commands (`claw /status`, `claw /diff`, `claw /version`, `claw /doctor`, `claw /sandbox`) return `interactive_only` instead of executing** — dogfooded 2026-05-29 17:05 on `main` `ac5b19de`. `/help`, `/agents`, `/skills list` work because they have explicit `CliAction` routing arms in `parse_direct_slash_command`. `/status`, `/diff`, `/version`, `/doctor`, `/sandbox` fall through to the generic `Ok(Some(command)) => Err(interactive_only...)` arm. The hint correctly suggests `--resume SESSION.jsonl /status`, but `claw /status` directly should also work — these commands don't need a session.
|
||||
|
||||
**Required fix shape.** Add explicit `Ok(Some(SlashCommand::Status | Diff | Version | Doctor | Sandbox))` arms in `parse_direct_slash_command` routing to the same `CliAction` variants as `"status"` / `"diff"` / `"version"` / `"doctor"` / `"sandbox"` subcommands.
|
||||
|
||||
**Acceptance.** `claw --output-format json /status` exits 0 and emits the same envelope as `claw --output-format json status`. [SCOPE: claw-code]
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
||||
use plugins::{PluginError, PluginLoadFailure, PluginManager, PluginSummary};
|
||||
use runtime::{
|
||||
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
|
||||
ScopedMcpServerConfig, Session,
|
||||
RuntimeConfig, ScopedMcpServerConfig, Session,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -2542,6 +2542,14 @@ pub fn handle_mcp_slash_command_json(
|
||||
render_mcp_report_json_for(&loader, cwd, args)
|
||||
}
|
||||
|
||||
fn load_runtime_config_without_stderr_warnings(
|
||||
loader: &ConfigLoader,
|
||||
) -> Result<RuntimeConfig, runtime::ConfigError> {
|
||||
loader
|
||||
.load_collecting_warnings()
|
||||
.map(|(runtime_config, _warnings)| runtime_config)
|
||||
}
|
||||
|
||||
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
|
||||
if let Some(args) = normalize_optional_args(args) {
|
||||
if let Some(help_path) = help_path_from_args(args) {
|
||||
@@ -2994,7 +3002,7 @@ fn render_mcp_report_json_for(
|
||||
// failure, emit top-level `status: "degraded"` with
|
||||
// `config_load_error`, empty servers[], and exit 0. On clean
|
||||
// runs, the existing serializer adds `status: "ok"` below.
|
||||
match loader.load() {
|
||||
match load_runtime_config_without_stderr_warnings(loader) {
|
||||
Ok(runtime_config) => {
|
||||
let mut value =
|
||||
render_mcp_summary_report_json(cwd, runtime_config.mcp().servers());
|
||||
@@ -3019,7 +3027,19 @@ fn render_mcp_report_json_for(
|
||||
}
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_mcp_usage_json(None)),
|
||||
Some("show") => Ok(render_mcp_usage_json(Some("show"))),
|
||||
// #830: `claw mcp show` with no server name is a missing required
|
||||
// argument, not an unknown action. Emit a dedicated error_kind so
|
||||
// machine consumers can distinguish "I know show, but need a name"
|
||||
// from "I don't know this action".
|
||||
Some("show") => Ok(serde_json::json!({
|
||||
"kind": "mcp",
|
||||
"action": "show",
|
||||
"status": "error",
|
||||
"ok": false,
|
||||
"error_kind": "missing_argument",
|
||||
"hint": "Usage: claw mcp show <server>\nRun `claw mcp list` to see available servers.",
|
||||
"message": "missing required argument: mcp show requires a server name.",
|
||||
})),
|
||||
Some(args) if args.split_whitespace().next() == Some("show") => {
|
||||
let mut parts = args.split_whitespace();
|
||||
let _ = parts.next();
|
||||
@@ -3030,7 +3050,7 @@ fn render_mcp_report_json_for(
|
||||
return Ok(render_mcp_usage_json(Some(args)));
|
||||
}
|
||||
// #144: same degradation pattern for show action.
|
||||
match loader.load() {
|
||||
match load_runtime_config_without_stderr_warnings(loader) {
|
||||
Ok(runtime_config) => {
|
||||
let mut value = render_mcp_server_report_json(
|
||||
cwd,
|
||||
|
||||
@@ -10,7 +10,22 @@ use std::sync::Mutex;
|
||||
static EMITTED_CONFIG_WARNINGS: std::sync::OnceLock<Mutex<HashSet<String>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// When set to `true`, `emit_config_warning_once` silently drops all prose
|
||||
/// deprecation warnings instead of writing them to stderr. Set this flag
|
||||
/// before any settings load when `--output-format json` is active so that
|
||||
/// JSON-mode machine consumers see empty stderr on success. (#824)
|
||||
static SUPPRESS_CONFIG_WARNINGS_STDERR: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// Call this once at startup when `--output-format json` is active.
|
||||
pub fn suppress_config_warnings_for_json_mode() {
|
||||
SUPPRESS_CONFIG_WARNINGS_STDERR.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn emit_config_warning_once(warning: &str) {
|
||||
if SUPPRESS_CONFIG_WARNINGS_STDERR.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let set = EMITTED_CONFIG_WARNINGS.get_or_init(|| Mutex::new(HashSet::new()));
|
||||
let mut guard = set.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if guard.insert(warning.to_string()) {
|
||||
|
||||
@@ -65,12 +65,12 @@ pub use compact::{
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpConfigCollection,
|
||||
McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
|
||||
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||
ProviderFallbackConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig,
|
||||
RuntimeHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig, ScopedMcpServerConfig,
|
||||
CLAW_SETTINGS_SCHEMA_NAME,
|
||||
suppress_config_warnings_for_json_mode, ConfigEntry, ConfigError, ConfigLoader, ConfigSource,
|
||||
McpConfigCollection, McpManagedProxyServerConfig, McpOAuthConfig, McpRemoteServerConfig,
|
||||
McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||
RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, RuntimePermissionRuleConfig,
|
||||
RuntimePluginConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use config_validate::{
|
||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||
|
||||
@@ -225,7 +225,10 @@ fn main() {
|
||||
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!(
|
||||
// #819/#820/#823: JSON mode error envelopes must go to stdout so machine
|
||||
// consumers can parse failures from stdout byte 0 (parity with all
|
||||
// non-interactive command guards that already use println! / to_stdout).
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
@@ -268,7 +271,13 @@ Run `claw --help` for usage."
|
||||
/// matching against the error messages produced throughout the CLI surface.
|
||||
fn classify_error_kind(message: &str) -> &'static str {
|
||||
// Check specific patterns first (more specific before generic)
|
||||
if message.contains("missing Anthropic credentials") {
|
||||
if message.starts_with("missing_argument:") || message.starts_with("missing required argument:") {
|
||||
"missing_argument"
|
||||
} else if message.starts_with("unknown_slash_command:") {
|
||||
"unknown_slash_command"
|
||||
} else if message.starts_with("command_not_found:") {
|
||||
"command_not_found"
|
||||
} else if message.contains("missing Anthropic credentials") {
|
||||
"missing_credentials"
|
||||
} else if message.contains("Manifest source files are missing") {
|
||||
"missing_manifests"
|
||||
@@ -356,8 +365,9 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
// #765: removed subcommands (login, logout) — hint contains migration guidance
|
||||
"removed_subcommand"
|
||||
} else if message.starts_with("unknown subcommand:") {
|
||||
// #785: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?)
|
||||
"unknown_subcommand"
|
||||
// #785/#825: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?)
|
||||
// Unified under command_not_found in #825.
|
||||
"command_not_found"
|
||||
} else if message.starts_with("unexpected extra arguments")
|
||||
|| message.starts_with("unexpected_extra_args:")
|
||||
{
|
||||
@@ -529,6 +539,16 @@ fn plugin_load_failure_json(failure: &plugins::PluginLoadFailure) -> Value {
|
||||
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
// #824: suppress config deprecation prose warnings to stderr when JSON
|
||||
// output mode is active. Scan the raw argv before parse_args so the
|
||||
// suppression is in place before any settings file is loaded.
|
||||
let json_mode = args
|
||||
.windows(2)
|
||||
.any(|w| w[0] == "--output-format" && w[1] == "json")
|
||||
|| args.iter().any(|a| a == "--output-format=json");
|
||||
if json_mode {
|
||||
runtime::suppress_config_warnings_for_json_mode();
|
||||
}
|
||||
match parse_args(&args)? {
|
||||
CliAction::DumpManifests {
|
||||
output_format,
|
||||
@@ -1362,17 +1382,23 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
),
|
||||
other => {
|
||||
if rest.len() == 1 && looks_like_subcommand_typo(other) {
|
||||
// #825: always emit a command_not_found error for
|
||||
// single-word all-alpha/dash tokens that don't match any
|
||||
// known subcommand — with or without close suggestions.
|
||||
// Multi-word cases fall through to CliAction::Prompt so
|
||||
// natural language prompts like `claw explain this` work.
|
||||
// (#826 documents the multi-word gap as a known limitation.)
|
||||
let mut message = format!("command_not_found: unknown subcommand: {other}.");
|
||||
if let Some(suggestions) = suggest_similar_subcommand(other) {
|
||||
let mut message = format!("unknown subcommand: {other}.");
|
||||
if let Some(line) = render_suggestion_line("Did you mean", &suggestions) {
|
||||
message.push('\n');
|
||||
message.push_str(&line);
|
||||
}
|
||||
message.push_str(
|
||||
"\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt <text>`.",
|
||||
);
|
||||
return Err(message);
|
||||
}
|
||||
message.push_str(
|
||||
"\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt <text>`.",
|
||||
);
|
||||
return Err(message);
|
||||
}
|
||||
// #147: guard empty/whitespace-only prompts at the fallthrough
|
||||
// path the same way `"prompt"` arm above does. Without this,
|
||||
@@ -1715,15 +1741,57 @@ fn parse_direct_slash_cli_action(
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)),
|
||||
// #831: resume-safe slash commands invoked directly (claw /status,
|
||||
// claw /diff, claw /version, claw /doctor, claw /sandbox) should
|
||||
// route to the same CliAction as their subcommand equivalents.
|
||||
// Previously they fell through to the generic interactive_only arm.
|
||||
Ok(Some(SlashCommand::Status)) => Ok(CliAction::Status {
|
||||
model: model.to_string(),
|
||||
model_flag_raw: None,
|
||||
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
|
||||
output_format,
|
||||
allowed_tools,
|
||||
}),
|
||||
Ok(Some(SlashCommand::Diff)) => Ok(CliAction::Diff { output_format }),
|
||||
Ok(Some(SlashCommand::Version)) => Ok(CliAction::Version { output_format }),
|
||||
Ok(Some(SlashCommand::Doctor)) => Ok(CliAction::Doctor { output_format }),
|
||||
Ok(Some(SlashCommand::Sandbox)) => Ok(CliAction::Sandbox { output_format }),
|
||||
Ok(Some(SlashCommand::Unknown(name))) => {
|
||||
// #828: /approve and /deny are valid REPL-only slash commands that
|
||||
// are not SlashCommand enum variants (they require an active tool
|
||||
// call in the REPL to be meaningful). Emit interactive_only so
|
||||
// machine consumers see the correct error_kind instead of
|
||||
// unknown_slash_command.
|
||||
if matches!(name.as_str(), "approve" | "yes" | "y" | "deny" | "no" | "n") {
|
||||
Err(format!(
|
||||
"interactive_only: /{name} requires an active tool call in the REPL.\nStart `claw` and use /{name} to approve or deny a pending tool execution."
|
||||
))
|
||||
} else {
|
||||
Err(format_unknown_direct_slash_command(&name))
|
||||
}
|
||||
}
|
||||
Ok(Some(command)) => Err({
|
||||
let _ = command;
|
||||
format!(
|
||||
// #738: newline before remediation so split_error_hint populates hint field
|
||||
"slash command {command_name} is interactive-only.\nStart `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.",
|
||||
command_name = rest[0],
|
||||
latest = LATEST_SESSION_REFERENCE,
|
||||
)
|
||||
let command_name = &rest[0];
|
||||
// #829: only suggest --resume when the command is actually
|
||||
// resume-safe. Non-resume-safe commands (e.g. /commit, /pr)
|
||||
// previously suggested --resume, which just re-triggered
|
||||
// interactive_only on a second invocation.
|
||||
let bare_name = command_name.trim_start_matches('/');
|
||||
let is_resume_safe = commands::resume_supported_slash_commands()
|
||||
.iter()
|
||||
.any(|spec| spec.name == bare_name);
|
||||
if is_resume_safe {
|
||||
format!(
|
||||
// #738: newline before remediation so split_error_hint populates hint field
|
||||
"interactive_only: slash command {command_name} requires a live session.\nStart `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}`.",
|
||||
latest = LATEST_SESSION_REFERENCE,
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"interactive_only: slash command {command_name} requires a live REPL session.\nStart `claw` and run it there."
|
||||
)
|
||||
}
|
||||
}),
|
||||
Ok(None) => Err(format!("unknown subcommand: {}", rest[0])),
|
||||
Err(error) => Err(error.to_string()),
|
||||
@@ -1742,7 +1810,10 @@ fn format_unknown_option(option: &str) -> String {
|
||||
}
|
||||
|
||||
fn format_unknown_direct_slash_command(name: &str) -> String {
|
||||
let mut message = format!("unknown slash command outside the REPL: /{name}");
|
||||
// #827: prefix with classifier-friendly token so classify_error_kind
|
||||
// returns "unknown_slash_command" instead of the opaque fallback.
|
||||
let mut message =
|
||||
format!("unknown_slash_command: unknown slash command outside the REPL: /{name}");
|
||||
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
|
||||
{
|
||||
message.push('\n');
|
||||
@@ -1757,7 +1828,9 @@ fn format_unknown_direct_slash_command(name: &str) -> String {
|
||||
}
|
||||
|
||||
fn format_unknown_slash_command(name: &str) -> String {
|
||||
let mut message = format!("Unknown slash command: /{name}");
|
||||
// #827: prefix with classifier-friendly token so classify_error_kind
|
||||
// can return "unknown_slash_command" instead of the opaque fallback.
|
||||
let mut message = format!("unknown_slash_command: Unknown slash command: /{name}");
|
||||
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
|
||||
{
|
||||
message.push('\n');
|
||||
@@ -2414,6 +2487,24 @@ impl DiagnosticCheck {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
enum ConfigWarningMode {
|
||||
EmitStderr,
|
||||
SuppressStderr,
|
||||
}
|
||||
|
||||
fn load_config_with_warning_mode(
|
||||
loader: &ConfigLoader,
|
||||
mode: ConfigWarningMode,
|
||||
) -> Result<runtime::RuntimeConfig, runtime::ConfigError> {
|
||||
match mode {
|
||||
ConfigWarningMode::EmitStderr => loader.load(),
|
||||
ConfigWarningMode::SuppressStderr => loader
|
||||
.load_collecting_warnings()
|
||||
.map(|(runtime_config, _warnings)| runtime_config),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct DoctorReport {
|
||||
checks: Vec<DiagnosticCheck>,
|
||||
@@ -2503,10 +2594,12 @@ fn render_diagnostic_check(check: &DiagnosticCheck) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
fn render_doctor_report(
|
||||
config_warning_mode: ConfigWarningMode,
|
||||
) -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let config_loader = ConfigLoader::default_for(&cwd);
|
||||
let config = config_loader.load();
|
||||
let config = load_config_with_warning_mode(&config_loader, config_warning_mode);
|
||||
let discovered_config = config_loader.discover();
|
||||
let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
|
||||
let (project_root, git_branch) =
|
||||
@@ -2559,7 +2652,10 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let report = render_doctor_report()?;
|
||||
let report = render_doctor_report(match output_format {
|
||||
CliOutputFormat::Json => ConfigWarningMode::SuppressStderr,
|
||||
CliOutputFormat::Text => ConfigWarningMode::EmitStderr,
|
||||
})?;
|
||||
let message = report.render();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{message}"),
|
||||
@@ -3378,7 +3474,9 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
// #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!(
|
||||
// #819: JSON mode resume errors go to stdout for parity with other
|
||||
// non-interactive command guards.
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"kind": kind,
|
||||
@@ -3436,7 +3534,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
.unwrap_or("");
|
||||
if STUB_COMMANDS.contains(&cmd_root) {
|
||||
if output_format == CliOutputFormat::Json {
|
||||
eprintln!(
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"kind": "unsupported_command",
|
||||
@@ -3459,7 +3557,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
Ok(Some(command)) => command,
|
||||
Ok(None) => {
|
||||
if output_format == CliOutputFormat::Json {
|
||||
eprintln!(
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"kind": "unsupported_resumed_command",
|
||||
@@ -3479,7 +3577,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
}
|
||||
Err(error) => {
|
||||
if output_format == CliOutputFormat::Json {
|
||||
eprintln!(
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"kind": "cli_parse",
|
||||
@@ -3529,7 +3627,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
// #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!(
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"kind": error_kind,
|
||||
@@ -4641,7 +4739,12 @@ fn run_resume_command(
|
||||
_ => {}
|
||||
}
|
||||
let cwd = env::current_dir()?;
|
||||
let payload = plugins_command_payload_for(&cwd, action.as_deref(), target.as_deref())?;
|
||||
let payload = plugins_command_payload_for(
|
||||
&cwd,
|
||||
action.as_deref(),
|
||||
target.as_deref(),
|
||||
ConfigWarningMode::EmitStderr,
|
||||
)?;
|
||||
let action_str = action.as_deref().unwrap_or("list");
|
||||
let enabled_count = payload
|
||||
.plugins
|
||||
@@ -4675,7 +4778,7 @@ fn run_resume_command(
|
||||
})
|
||||
}
|
||||
SlashCommand::Doctor => {
|
||||
let report = render_doctor_report()?;
|
||||
let report = render_doctor_report(ConfigWarningMode::EmitStderr)?;
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(report.render()),
|
||||
@@ -5981,7 +6084,10 @@ impl LiveCli {
|
||||
false
|
||||
}
|
||||
SlashCommand::Doctor => {
|
||||
println!("{}", render_doctor_report()?.render());
|
||||
println!(
|
||||
"{}",
|
||||
render_doctor_report(ConfigWarningMode::EmitStderr)?.render()
|
||||
);
|
||||
false
|
||||
}
|
||||
SlashCommand::History { count } => {
|
||||
@@ -6402,13 +6508,41 @@ impl LiveCli {
|
||||
if action.as_deref() == Some("list") {
|
||||
if let Some(filter) = target.as_deref() {
|
||||
if filter.starts_with('-') {
|
||||
if matches!(output_format, CliOutputFormat::Json) {
|
||||
// ROADMAP #817: this is a handled local inventory parse error.
|
||||
// Keep it on stdout in JSON mode so `plugins list --` matches the
|
||||
// sibling JSON inventory/local surfaces instead of falling through
|
||||
// to the top-level stderr error path.
|
||||
let obj = json!({
|
||||
"type": "error",
|
||||
"kind": "plugin",
|
||||
"action": "list",
|
||||
"status": "error",
|
||||
"error_kind": "cli_parse",
|
||||
"error": format!("unknown option for `claw plugins list`: {filter}"),
|
||||
"message": format!("unknown option for `claw plugins list`: {filter}"),
|
||||
"unexpected": filter,
|
||||
"hint": "Usage: claw plugins list [<filter>]\nFilters are id substrings, not flags.",
|
||||
"exit_code": 1,
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&obj)?);
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Err(format!(
|
||||
"unknown option for `claw plugins list`: {filter}\nUsage: claw plugins list [<filter>]\nFilters are id substrings, not flags."
|
||||
).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let payload = plugins_command_payload_for(&cwd, action, target)?;
|
||||
let payload = plugins_command_payload_for(
|
||||
&cwd,
|
||||
action,
|
||||
target,
|
||||
match output_format {
|
||||
CliOutputFormat::Json => ConfigWarningMode::SuppressStderr,
|
||||
CliOutputFormat::Text => ConfigWarningMode::EmitStderr,
|
||||
},
|
||||
)?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
// #806: text-mode show must return error when plugin not found (parity with JSON)
|
||||
@@ -6474,20 +6608,6 @@ impl LiveCli {
|
||||
}
|
||||
} else if is_list_action {
|
||||
if let Some(filter) = target {
|
||||
// #793: flag-shaped tokens silently became substring filters on
|
||||
// plugins list, returning empty success instead of an error.
|
||||
if filter.starts_with('-') {
|
||||
let obj = json!({
|
||||
"kind": "plugin",
|
||||
"action": "list",
|
||||
"status": "error",
|
||||
"error_kind": "unknown_option",
|
||||
"unexpected": filter,
|
||||
"hint": "Usage: claw plugins list [<filter>]\nFilters are id substrings, not flags.",
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&obj)?);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let needle = filter.to_lowercase();
|
||||
payload
|
||||
.plugins
|
||||
@@ -6735,7 +6855,8 @@ impl LiveCli {
|
||||
target: Option<&str>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
let payload = plugins_command_payload_for(&cwd, action, target)?;
|
||||
let payload =
|
||||
plugins_command_payload_for(&cwd, action, target, ConfigWarningMode::EmitStderr)?;
|
||||
println!("{}", payload.message);
|
||||
if payload.reload_runtime {
|
||||
self.reload_runtime_features()?;
|
||||
@@ -9133,9 +9254,11 @@ fn plugins_command_payload_for(
|
||||
cwd: &Path,
|
||||
action: Option<&str>,
|
||||
target: Option<&str>,
|
||||
config_warning_mode: ConfigWarningMode,
|
||||
) -> Result<PluginsCommandPayload, Box<dyn std::error::Error>> {
|
||||
let loader = ConfigLoader::default_for(cwd);
|
||||
let (runtime_config, config_load_error) = match loader.load() {
|
||||
let loaded_config = load_config_with_warning_mode(&loader, config_warning_mode);
|
||||
let (runtime_config, config_load_error) = match loaded_config {
|
||||
Ok(runtime_config) => (runtime_config, None),
|
||||
Err(error) => (runtime::RuntimeConfig::empty(), Some(error.to_string())),
|
||||
};
|
||||
@@ -12522,7 +12645,7 @@ mod tests {
|
||||
let typo_err = parse_args(&["sttaus".to_string()])
|
||||
.expect_err("typo'd subcommand should be caught by #108 guard");
|
||||
assert!(
|
||||
typo_err.starts_with("unknown subcommand:"),
|
||||
typo_err.contains("unknown subcommand:"),
|
||||
"typo guard should fire for 'sttaus', got: {typo_err}"
|
||||
);
|
||||
// #148: `--model` flag must be captured as model_flag_raw so status
|
||||
@@ -12804,8 +12927,13 @@ mod tests {
|
||||
|
||||
let previous_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
let payload = super::plugins_command_payload_for(&cwd, None, None)
|
||||
.expect("plugins list should not hard-fail on malformed MCP config");
|
||||
let payload = super::plugins_command_payload_for(
|
||||
&cwd,
|
||||
None,
|
||||
None,
|
||||
super::ConfigWarningMode::EmitStderr,
|
||||
)
|
||||
.expect("plugins list should not hard-fail on malformed MCP config");
|
||||
match previous_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
@@ -13172,10 +13300,10 @@ mod tests {
|
||||
classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"),
|
||||
"cli_parse"
|
||||
);
|
||||
// #785: unknown top-level subcommand (typo or unrecognised command)
|
||||
// #785/#825: unknown top-level subcommand (typo or unrecognised command)
|
||||
assert_eq!(
|
||||
classify_error_kind("unknown subcommand: dump.\nDid you mean dump-manifests"),
|
||||
"unknown_subcommand"
|
||||
"command_not_found" // #825: unified from unknown_subcommand
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error_kind("unsupported ACP invocation. Use `claw acp`."),
|
||||
@@ -13809,8 +13937,15 @@ mod tests {
|
||||
);
|
||||
let error = parse_args(&["/status".to_string()])
|
||||
.expect_err("/status should remain REPL-only when invoked directly");
|
||||
assert!(error.contains("interactive-only"));
|
||||
assert!(error.contains("claw --resume SESSION.jsonl /status"));
|
||||
// #829: prefix changed from "interactive-only" to "interactive_only:"
|
||||
assert!(
|
||||
error.contains("interactive_only:"),
|
||||
"expected interactive_only: prefix, got: {error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("claw --resume SESSION.jsonl /status"),
|
||||
"expected --resume suggestion for resume-safe /status, got: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -13832,8 +13967,9 @@ mod tests {
|
||||
for alias in ["/plugin", "/plugins", "/marketplace"] {
|
||||
let error = parse_args(&[alias.to_string()])
|
||||
.expect_err("valid plugin slash aliases are local/interactive, never prompts");
|
||||
// #829: prefix changed from "interactive-only" to "interactive_only:"
|
||||
assert!(
|
||||
error.contains("interactive-only"),
|
||||
error.contains("interactive_only:") || error.contains("interactive-only"),
|
||||
"{alias} should reject as an interactive plugin command outside the REPL, got: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,13 +266,15 @@ fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
|
||||
!output.status.success(),
|
||||
"compact json help should fail non-zero"
|
||||
);
|
||||
assert!(
|
||||
output.stdout.is_empty(),
|
||||
"compact json help should not start a prompt/spinner on stdout: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
// #819/#820/#823: JSON abort envelopes route to stdout
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||
let parsed: Value = serde_json::from_str(stderr.trim()).expect("stderr should be JSON error");
|
||||
assert!(
|
||||
stderr.trim().is_empty() || !stderr.trim_start().starts_with('{'),
|
||||
"compact json help should not emit JSON envelope to stderr (#819/#820/#823): {stderr}"
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value =
|
||||
serde_json::from_str(stdout.trim()).expect("stdout should be JSON error envelope");
|
||||
assert_eq!(parsed["status"], "error");
|
||||
assert_eq!(parsed["error_kind"], "interactive_only");
|
||||
assert_eq!(parsed["action"], "abort");
|
||||
|
||||
@@ -1319,6 +1319,102 @@ fn config_json_reports_deprecations_structurally_without_stderr_duplicate_815()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_json_surfaces_suppress_config_deprecation_stderr_816() {
|
||||
let root = unique_temp_dir("global-json-warning-816");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{"enabledPlugins": {}}"#,
|
||||
)
|
||||
.expect("deprecated config fixture should write");
|
||||
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
];
|
||||
|
||||
for (args, expected_kind, expected_action) in [
|
||||
(
|
||||
&["--output-format", "json", "plugins", "list"][..],
|
||||
"plugin",
|
||||
"list",
|
||||
),
|
||||
(
|
||||
&["--output-format", "json", "mcp", "list"][..],
|
||||
"mcp",
|
||||
"list",
|
||||
),
|
||||
(
|
||||
&["--output-format", "json", "doctor"][..],
|
||||
"doctor",
|
||||
"doctor",
|
||||
),
|
||||
] {
|
||||
let output = run_claw(&root, args, &envs);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"args={args:?}\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let parsed: Value =
|
||||
serde_json::from_slice(&output.stdout).expect("stdout should be valid JSON");
|
||||
assert_eq!(parsed["kind"], expected_kind, "args={args:?}");
|
||||
assert_eq!(parsed["action"], expected_action, "args={args:?}");
|
||||
assert!(
|
||||
matches!(parsed["status"].as_str(), Some("ok" | "warn")),
|
||||
"args={args:?} should report successful local status: {parsed}"
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
|
||||
assert!(
|
||||
!stderr.contains("field \"enabledPlugins\" is deprecated"),
|
||||
"successful JSON surface must not leak config deprecation prose to stderr for args={args:?}:\n{stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_text_surface_preserves_config_deprecation_stderr_816() {
|
||||
let root = unique_temp_dir("global-text-warning-816");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{"enabledPlugins": {}}"#,
|
||||
)
|
||||
.expect("deprecated config fixture should write");
|
||||
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
];
|
||||
|
||||
let output = run_claw(&root, &["doctor"], &envs);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
|
||||
assert!(
|
||||
stderr.contains("field \"enabledPlugins\" is deprecated"),
|
||||
"text-mode doctor should preserve human config deprecation warnings on stderr"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||
assert_json_command_with_env(current_dir, args, &[])
|
||||
}
|
||||
@@ -1649,13 +1745,15 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
||||
!out.status.success(),
|
||||
"invalid reasoning-effort must exit non-zero"
|
||||
);
|
||||
let raw = String::from_utf8_lossy(&out.stderr)
|
||||
// #819/#820/#823: abort envelopes route to stdout in JSON mode
|
||||
let raw = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter(|l| l.starts_with('{'))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw)
|
||||
.unwrap_or_else(|_| panic!("invalid --reasoning-effort must emit JSON; got: {raw}"));
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|_| {
|
||||
panic!("invalid --reasoning-effort must emit JSON to stdout; got: {raw}")
|
||||
});
|
||||
assert_eq!(
|
||||
parsed["error_kind"], "invalid_flag_value",
|
||||
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
|
||||
@@ -1677,13 +1775,13 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
||||
!out2.status.success(),
|
||||
"missing --model value must exit non-zero"
|
||||
);
|
||||
let raw2 = String::from_utf8_lossy(&out2.stderr)
|
||||
let raw2 = String::from_utf8_lossy(&out2.stdout)
|
||||
.lines()
|
||||
.filter(|l| l.starts_with('{'))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let parsed2: serde_json::Value = serde_json::from_str(&raw2)
|
||||
.unwrap_or_else(|_| panic!("missing --model value must emit JSON; got: {raw2}"));
|
||||
.unwrap_or_else(|_| panic!("missing --model value must emit JSON to stdout; got: {raw2}"));
|
||||
assert_eq!(
|
||||
parsed2["error_kind"], "missing_flag_value",
|
||||
"missing --model value must be missing_flag_value (#756): {parsed2}"
|
||||
@@ -1720,14 +1818,15 @@ fn short_p_flag_swallows_no_flags_755() {
|
||||
!output.status.success(),
|
||||
"claw -p hello --output-format json must exit non-zero (no credentials)"
|
||||
);
|
||||
let raw = String::from_utf8_lossy(&output.stderr)
|
||||
// #819/#820/#823: abort envelopes route to stdout in JSON mode
|
||||
let raw = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter(|l| l.starts_with('{'))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
// Must be valid JSON (i.e. --output-format json was parsed, not swallowed)
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|_| {
|
||||
panic!("--output-format json must be parsed as a flag, not prompt text; stderr: {raw}")
|
||||
panic!("--output-format json must be parsed as a flag, not prompt text; stdout: {raw}")
|
||||
});
|
||||
assert_eq!(
|
||||
parsed["error_kind"], "missing_credentials",
|
||||
@@ -1740,13 +1839,13 @@ fn short_p_flag_swallows_no_flags_755() {
|
||||
.args(["--output-format", "json", "-p", "--model", "sonnet"])
|
||||
.output()
|
||||
.expect("claw -p flag-as-prompt should run");
|
||||
let raw2 = String::from_utf8_lossy(&output2.stderr)
|
||||
let raw2 = String::from_utf8_lossy(&output2.stdout)
|
||||
.lines()
|
||||
.filter(|l| l.starts_with('{'))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let parsed2: serde_json::Value = serde_json::from_str(&raw2)
|
||||
.unwrap_or_else(|_| panic!("claw -p --model must emit JSON; got: {raw2}"));
|
||||
.unwrap_or_else(|_| panic!("claw -p --model must emit JSON to stdout; got: {raw2}"));
|
||||
assert_eq!(
|
||||
parsed2["error_kind"], "missing_prompt",
|
||||
"flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
|
||||
@@ -1942,13 +2041,13 @@ fn export_json_has_kind_702() {
|
||||
"export status must be ok or error"
|
||||
);
|
||||
} else {
|
||||
// Error envelope on stderr must be parseable JSON.
|
||||
assert!(
|
||||
!stderr.is_empty(),
|
||||
"export failure must emit JSON to stderr"
|
||||
);
|
||||
// #819: Error envelope in JSON mode must be on stdout (not stderr).
|
||||
let stdout_json = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.expect("export failure must emit JSON to stdout (#819)");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&stderr).expect("export error stderr must be valid JSON");
|
||||
serde_json::from_str(stdout_json).expect("export error stdout must be valid JSON");
|
||||
assert_eq!(
|
||||
parsed["type"], "error",
|
||||
"export error envelope must have type:error"
|
||||
@@ -1973,11 +2072,13 @@ fn config_parse_error_has_typed_error_kind_and_hint_764() {
|
||||
!output.status.success(),
|
||||
"malformed settings.json should cause non-zero exit"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.expect("stderr should contain a JSON error envelope");
|
||||
.expect("stdout should contain a JSON error envelope (#819/#820/#823: abort envelopes route to stdout in JSON mode)");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
|
||||
|
||||
@@ -2006,11 +2107,13 @@ fn login_logout_removed_subcommands_have_error_kind_and_hint_765() {
|
||||
!output.status.success(),
|
||||
"claw {subcmd} should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.unwrap_or_else(|| panic!("claw {subcmd} stderr should contain a JSON envelope"));
|
||||
.unwrap_or_else(|| panic!("claw {subcmd} stdout should contain a JSON envelope (#819/#820/#823: abort envelopes route to stdout in JSON mode)"));
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
|
||||
|
||||
@@ -2094,17 +2197,18 @@ fn assert_diff_unexpected_extra_args_json(root: &Path, args: &[&str], label: &st
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
output.stdout.is_empty(),
|
||||
"{label} should not enter the spinner/prompt path; stdout:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
// #819/#820/#823: JSON abort envelopes route to stdout
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
assert!(
|
||||
stderr.lines().all(|l| !l.trim_start().starts_with('{')),
|
||||
"{label} stderr should not contain a JSON envelope in JSON mode (#819/#820/#823); stderr:\n{stderr}"
|
||||
);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("{label} stderr should contain a JSON error envelope; stderr:\n{stderr}")
|
||||
panic!("{label} stdout should contain a JSON error envelope (#819/#820/#823); stdout:\n{stdout}")
|
||||
});
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
|
||||
@@ -2143,11 +2247,13 @@ fn resume_non_slash_trailing_arg_has_typed_error_kind_and_hint_768() {
|
||||
!output.status.success(),
|
||||
"claw --resume latest compact should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.expect("stderr should contain a JSON error envelope");
|
||||
.expect("stdout should contain a JSON error envelope (#819/#820/#823: abort envelopes route to stdout in JSON mode)");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
|
||||
|
||||
@@ -2181,8 +2287,10 @@ fn session_with_unknown_subcommand_returns_interactive_only_not_credentials_767(
|
||||
!output.status.success(),
|
||||
"claw session {sub} should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.unwrap_or_else(|| panic!("claw session {sub} stderr should contain JSON"));
|
||||
@@ -2234,8 +2342,10 @@ fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() {
|
||||
"claw {} should exit non-zero",
|
||||
args.join(" ")
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.unwrap_or_else(|| {
|
||||
@@ -2274,8 +2384,10 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
|
||||
{
|
||||
let output = run_claw(&root, &["--output-format", "json", "agents", "bogus"], &[]);
|
||||
assert!(!output.status.success(), "agents bogus should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.expect("agents bogus should emit JSON error");
|
||||
@@ -2296,8 +2408,10 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
|
||||
{
|
||||
let output = run_claw(&root, &["--output-format", "json", "plugins", "bogus"], &[]);
|
||||
assert!(!output.status.success(), "plugins bogus should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.expect("plugins bogus should emit JSON error");
|
||||
@@ -2316,6 +2430,7 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
|
||||
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 stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_str = if stdout.trim().starts_with('{') {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
@@ -2374,8 +2489,10 @@ fn interactive_only_guard_batch_769_to_771() {
|
||||
"claw {} should exit non-zero",
|
||||
args.join(" ")
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.unwrap_or_else(|| {
|
||||
@@ -2436,12 +2553,14 @@ fn resume_plugin_mutations_are_typed_interactive_only_777() {
|
||||
!output.status.success(),
|
||||
"/plugins {mutation} in resume mode should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("/plugins {mutation} should emit JSON error, got stderr: {stderr}")
|
||||
panic!("/plugins {mutation} should emit JSON error on stdout, got: {stderr}")
|
||||
});
|
||||
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
|
||||
assert_eq!(
|
||||
@@ -2491,12 +2610,14 @@ fn resume_skills_invocation_is_typed_interactive_only_779() {
|
||||
!output.status.success(),
|
||||
"/skills <skill> in resume mode should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("/skills invocation should emit JSON error, got stderr: {stderr}")
|
||||
panic!("/skills invocation should emit JSON error on stdout, got: {stderr}")
|
||||
});
|
||||
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
|
||||
assert_eq!(
|
||||
@@ -2530,8 +2651,10 @@ fn acp_unsupported_invocation_has_hint_782() {
|
||||
|
||||
let output = run_claw(&root, &["--output-format", "json", "acp", "start"], &[]);
|
||||
assert!(!output.status.success(), "acp start should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let json_line = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.expect("should emit JSON error");
|
||||
@@ -2568,6 +2691,7 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
|
||||
assert!(output.status.success(), "init should succeed");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let raw = if stdout.trim_start().starts_with('{') {
|
||||
&*stdout
|
||||
} else {
|
||||
@@ -2601,6 +2725,7 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
|
||||
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 stdout2 = String::from_utf8_lossy(&output2.stdout);
|
||||
let raw2 = if stdout2.trim_start().starts_with('{') {
|
||||
&*stdout2
|
||||
} else {
|
||||
@@ -2643,7 +2768,8 @@ fn export_arg_errors_have_typed_kind_and_hint_784() {
|
||||
);
|
||||
assert!(!out1.status.success(), "--output with no value should fail");
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let j1: serde_json::Value = stderr1
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -2669,7 +2795,8 @@ fn export_arg_errors_have_typed_kind_and_hint_784() {
|
||||
);
|
||||
assert!(!out2.status.success(), "extra positional should fail");
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let j2: serde_json::Value = stderr2
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -2705,14 +2832,16 @@ fn unknown_subcommand_returns_typed_kind_785() {
|
||||
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
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
.expect("unknown subcommand should emit JSON error");
|
||||
// #825: unified under command_not_found (previously unknown_subcommand)
|
||||
assert_eq!(
|
||||
j["error_kind"], "unknown_subcommand",
|
||||
"unknown subcommand should return unknown_subcommand kind, got {:?}",
|
||||
j["error_kind"], "command_not_found",
|
||||
"unknown subcommand should return command_not_found kind (#825), got {:?}",
|
||||
j["error_kind"]
|
||||
);
|
||||
// hint should point at the suggestion and/or --help
|
||||
@@ -2751,7 +2880,8 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
|
||||
);
|
||||
assert!(!out1.status.success());
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let j1: serde_json::Value = stderr1
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -2782,7 +2912,8 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
|
||||
);
|
||||
assert!(!out2.status.success());
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let j2: serde_json::Value = stderr2
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -2831,7 +2962,8 @@ fn resume_directory_path_returns_typed_kind_and_hint_787() {
|
||||
"resume with directory should fail"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let j: serde_json::Value = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -2881,6 +3013,7 @@ fn skills_show_not_found_emits_single_json_object_788() {
|
||||
// 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);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Count JSON objects in stdout — must be exactly 1
|
||||
let json_objects: Vec<serde_json::Value> = {
|
||||
@@ -3041,7 +3174,8 @@ fn system_prompt_unknown_option_returns_typed_kind_790() {
|
||||
);
|
||||
assert!(!out1.status.success());
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let j1: serde_json::Value = stderr1
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -3067,7 +3201,8 @@ fn system_prompt_unknown_option_returns_typed_kind_790() {
|
||||
);
|
||||
assert!(!out2.status.success());
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let j2: serde_json::Value = stderr2
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -3104,7 +3239,8 @@ fn config_extra_args_have_non_null_hint_791() {
|
||||
);
|
||||
assert!(!out1.status.success());
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let j1: serde_json::Value = stderr1
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -3137,7 +3273,8 @@ fn config_extra_args_have_non_null_hint_791() {
|
||||
);
|
||||
assert!(!out2.status.success());
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let j2: serde_json::Value = stderr2
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -3242,11 +3379,12 @@ fn skills_list_flag_shaped_filter_returns_unknown_option_792() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_list_flag_shaped_filter_returns_unknown_option_793() {
|
||||
fn plugins_list_flag_shaped_filter_returns_cli_parse_on_stdout_793_817() {
|
||||
// #793: `claw plugins list --bogus-flag` silently returned status:"ok" with empty
|
||||
// plugins list instead of an error. The list filter branch in print_plugins treated
|
||||
// "--bogus-flag" as an id substring filter and found no matches, producing a false-positive.
|
||||
// Fix: added flag-prefix guard; filter tokens starting with "-" now return unknown_option.
|
||||
// #817: in JSON mode, handled local parse errors now return error_kind:"cli_parse"
|
||||
// on stdout with stderr empty.
|
||||
let root = unique_temp_dir("plugins-list-flag-793");
|
||||
fs::create_dir_all(&root).expect("temp dir");
|
||||
std::process::Command::new("git")
|
||||
@@ -3270,19 +3408,16 @@ fn plugins_list_flag_shaped_filter_returns_unknown_option_793() {
|
||||
!output.status.success(),
|
||||
"plugins list --unknown-flag must exit non-zero (#793)"
|
||||
);
|
||||
// #803: the early flag guard now returns Err before the JSON branch,
|
||||
// so the error envelope goes to stderr via the main error handler.
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let j: serde_json::Value = stderr
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
.expect("plugins list flag-filter should emit valid JSON on stderr");
|
||||
assert_eq!(output.status.code(), Some(1), "exit code must be 1 (#817)");
|
||||
// #817: handled JSON local parse errors stay on stdout, with stderr empty.
|
||||
assert!(
|
||||
j["error_kind"] == "unknown_option" || j["error_kind"] == "cli_parse",
|
||||
"plugins list flag-shaped filter must return typed error, got {:?}",
|
||||
j["error_kind"]
|
||||
output.stderr.is_empty(),
|
||||
"plugins list flag-filter JSON error must keep stderr empty (#817), got: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let j: serde_json::Value = serde_json::from_slice(&output.stdout)
|
||||
.expect("plugins list flag-filter should emit valid JSON on stdout");
|
||||
assert_eq!(j["error_kind"], "cli_parse");
|
||||
assert_eq!(j["status"], "error");
|
||||
let h = j["hint"]
|
||||
.as_str()
|
||||
@@ -3324,7 +3459,8 @@ fn plugins_uninstall_not_found_has_hint_793() {
|
||||
);
|
||||
// Error envelope goes to stderr (propagated via ? to main error handler)
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let j: serde_json::Value = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -3368,7 +3504,8 @@ fn plugins_install_not_found_path_returns_typed_kind_794() {
|
||||
"plugins install not-found-path must exit non-zero (#794)"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let j: serde_json::Value = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -3414,7 +3551,8 @@ fn skills_install_not_found_and_unsupported_action_have_hints_795() {
|
||||
"skills install not-found must exit non-zero (#795)"
|
||||
);
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let j1: serde_json::Value = stderr1
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -3449,7 +3587,8 @@ fn skills_install_not_found_and_unsupported_action_have_hints_795() {
|
||||
"skills uninstall must exit non-zero (#795)"
|
||||
);
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let j2: serde_json::Value = stderr2
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -3586,7 +3725,8 @@ fn plugins_extra_args_have_non_null_hint_797() {
|
||||
"plugins show with extra arg must exit non-zero (#797)"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let j: serde_json::Value = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -3601,6 +3741,63 @@ fn plugins_extra_args_have_non_null_hint_797() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_list_trailing_dash_json_error_uses_stdout_817() {
|
||||
// ROADMAP #817: JSON inventory/local parse errors are machine-readable on
|
||||
// stdout. `plugins list --` used to route through the top-level error path,
|
||||
// leaving stdout empty and writing the JSON envelope to stderr.
|
||||
let root = unique_temp_dir("plugins-list-dash-817");
|
||||
fs::create_dir_all(&root).expect("temp dir");
|
||||
|
||||
let output = run_claw(
|
||||
&root,
|
||||
&["--output-format", "json", "plugins", "list", "--"],
|
||||
&[],
|
||||
);
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"plugins list -- must exit non-zero (#817)"
|
||||
);
|
||||
assert_eq!(output.status.code(), Some(1), "exit code must be 1 (#817)");
|
||||
assert!(
|
||||
output.stderr.is_empty(),
|
||||
"JSON parse error must keep stderr empty (#817), got: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let j: serde_json::Value =
|
||||
serde_json::from_slice(&output.stdout).expect("stdout should be JSON error (#817)");
|
||||
assert_eq!(j["kind"], "plugin");
|
||||
assert_eq!(j["action"], "list");
|
||||
assert_eq!(j["status"], "error");
|
||||
assert_eq!(j["error_kind"], "cli_parse");
|
||||
assert_eq!(j["unexpected"], "--");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_list_trailing_dash_text_error_stays_on_stderr_817() {
|
||||
let root = unique_temp_dir("plugins-list-dash-text-817");
|
||||
fs::create_dir_all(&root).expect("temp dir");
|
||||
|
||||
let output = run_claw(&root, &["plugins", "list", "--"], &[]);
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"plugins list -- text mode must exit non-zero (#817)"
|
||||
);
|
||||
assert_eq!(output.status.code(), Some(1), "exit code must be 1 (#817)");
|
||||
assert!(
|
||||
output.stdout.is_empty(),
|
||||
"text parse error should not emit stdout (#817), got: {}",
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stderr.contains("[error-kind: cli_parse]"), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains("unknown option for `claw plugins list`: --"),
|
||||
"{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prompt_has_non_null_hint_798() {
|
||||
// #798: `claw --output-format json ""` returned empty_prompt + hint:null.
|
||||
@@ -3619,7 +3816,8 @@ fn empty_prompt_has_non_null_hint_798() {
|
||||
"empty prompt must exit non-zero (#798)"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let j: serde_json::Value = stderr
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.and_then(|l| serde_json::from_str(l).ok())
|
||||
@@ -3668,3 +3866,252 @@ fn diff_non_git_dir_has_error_kind_and_hint_801() {
|
||||
"diff non-git must have message field (#801)"
|
||||
);
|
||||
}
|
||||
|
||||
// #825: unknown single-word subcommand must return command_not_found, not
|
||||
// fall through to missing_credentials after provider startup.
|
||||
#[test]
|
||||
fn unknown_subcommand_json_emits_command_not_found() {
|
||||
let root = unique_temp_dir("unknown-cmd-json-825");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
let output = run_claw(&root, &["--output-format", "json", "foobar"], &[]);
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(1),
|
||||
"unknown subcommand should exit 1"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
!stdout.trim().is_empty(),
|
||||
"unknown subcommand JSON envelope must be on stdout"
|
||||
);
|
||||
let j: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("stdout must be parseable JSON (#825)");
|
||||
assert_eq!(
|
||||
j["error_kind"], "command_not_found",
|
||||
"unknown subcommand must emit command_not_found, not missing_credentials (#825): {j}"
|
||||
);
|
||||
assert_eq!(j["status"], "error");
|
||||
assert!(
|
||||
stderr.is_empty(),
|
||||
"unknown subcommand in JSON mode must have empty stderr (#825), got: {stderr:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subcommand_text_emits_command_not_found_on_stderr() {
|
||||
let root = unique_temp_dir("unknown-cmd-text-825");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
let output = run_claw(&root, &["foobar"], &[]);
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(1),
|
||||
"unknown subcommand should exit 1"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _ = stdout;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("command_not_found"),
|
||||
"text mode unknown subcommand must mention command_not_found on stderr (#825), got: {stderr:?}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("missing_credentials"),
|
||||
"text mode unknown subcommand must not show missing_credentials (#825)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subcommand_typo_with_suggestions_json_emits_command_not_found() {
|
||||
let root = unique_temp_dir("unknown-cmd-typo-825");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
let output = run_claw(&root, &["--output-format", "json", "statuz"], &[]);
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let j: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("typo envelope must be valid JSON (#825)");
|
||||
assert_eq!(j["error_kind"], "command_not_found", "#825 typo: {j}");
|
||||
let hint = j["hint"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
hint.contains("status") || hint.contains("state"),
|
||||
"typo hint should suggest status/state, got: {hint:?}"
|
||||
);
|
||||
assert!(stderr.is_empty(), "typo JSON must have empty stderr (#825)");
|
||||
}
|
||||
|
||||
// #826: multi-word unknown subcommand is a known gap — falls through to
|
||||
// CliAction::Prompt (natural language prompt passthrough like `claw explain this`).
|
||||
// Single-word typos (#825) are caught; multi-word is documented as backlog.
|
||||
// This test documents the current behaviour (not the desired fix).
|
||||
#[test]
|
||||
fn multi_word_unknown_subcommand_falls_through_to_prompt_826() {
|
||||
let root = unique_temp_dir("multi-word-gap-826");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
// "foobar baz" has no fuzzy suggestion → falls through to Prompt path
|
||||
// (hits missing_credentials since no API key is set, rc=1)
|
||||
let output = run_claw(&root, &["--output-format", "json", "foobar", "baz"], &[]);
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Currently emits missing_credentials (fallthrough gap documented in #826)
|
||||
let j: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("multi-word fallthrough must emit JSON");
|
||||
assert_eq!(
|
||||
j["status"], "error",
|
||||
"multi-word fallthrough must be an error: {j}"
|
||||
);
|
||||
// stderr must be empty regardless (JSON mode)
|
||||
assert!(
|
||||
stderr.is_empty(),
|
||||
"multi-word fallthrough JSON must have empty stderr: {stderr:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// #827: direct /unknown-slash-command must emit typed error_kind, not "unknown"
|
||||
// Uses the direct-slash CLI path (no session load needed; reproducible on CI).
|
||||
#[test]
|
||||
fn direct_unknown_slash_command_emits_typed_error_kind() {
|
||||
let root = unique_temp_dir("direct-unknown-slash-827");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
let output = run_claw(&root, &["--output-format", "json", "/boguscommand"], &[]);
|
||||
assert_eq!(output.status.code(), Some(1), "unknown slash should exit 1");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let j: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("unknown slash must emit JSON (#827)");
|
||||
assert_ne!(
|
||||
j["error_kind"], "unknown",
|
||||
"direct unknown slash must not emit opaque \'unknown\' error_kind (#827): {j}"
|
||||
);
|
||||
assert_eq!(
|
||||
j["error_kind"], "unknown_slash_command",
|
||||
"direct unknown slash must emit unknown_slash_command (#827): {j}"
|
||||
);
|
||||
assert!(
|
||||
stderr.is_empty(),
|
||||
"direct unknown slash JSON must have empty stderr (#827)"
|
||||
);
|
||||
}
|
||||
|
||||
// #828: /approve and /deny outside REPL must emit interactive_only, not unknown_slash_command
|
||||
#[test]
|
||||
fn approve_deny_outside_repl_emits_interactive_only() {
|
||||
let root = unique_temp_dir("approve-deny-828");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
for cmd in &["/approve", "/yes", "/deny", "/no"] {
|
||||
let output = run_claw(&root, &["--output-format", "json", cmd], &[]);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let j: serde_json::Value = serde_json::from_str(stdout.trim())
|
||||
.unwrap_or_else(|_| panic!("{cmd} must emit JSON (#828), got: {stdout:?}"));
|
||||
assert_eq!(
|
||||
j["error_kind"], "interactive_only",
|
||||
"{cmd} outside REPL must emit interactive_only (#828): {j}"
|
||||
);
|
||||
assert!(
|
||||
stderr.is_empty(),
|
||||
"{cmd} JSON must have empty stderr (#828): {stderr:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// #829: interactive_only hint must NOT suggest --resume for non-resume-safe commands
|
||||
#[test]
|
||||
fn non_resume_safe_interactive_only_hint_omits_resume_suggestion() {
|
||||
let root = unique_temp_dir("non-resume-hint-829");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
// /commit, /pr, /issue, /bughunter, /ultraplan are not resume-safe
|
||||
for cmd in &["/commit", "/pr", "/issue", "/bughunter", "/ultraplan"] {
|
||||
let output = run_claw(&root, &["--output-format", "json", cmd], &[]);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = serde_json::from_str(stdout.trim())
|
||||
.unwrap_or_else(|_| panic!("{cmd} must emit JSON (#829), got: {stdout:?}"));
|
||||
assert_eq!(
|
||||
j["error_kind"], "interactive_only",
|
||||
"{cmd} must emit interactive_only (#829): {j}"
|
||||
);
|
||||
let hint = j["hint"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!hint.contains("--resume"),
|
||||
"{cmd} hint must not suggest --resume for non-resume-safe command (#829): hint={hint:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// #829: resume-safe commands should still suggest --resume in the hint
|
||||
#[test]
|
||||
fn resume_safe_interactive_only_hint_includes_resume_suggestion() {
|
||||
let root = unique_temp_dir("resume-hint-829");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
let output = run_claw(&root, &["--output-format", "json", "/diff"], &[]);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = serde_json::from_str(stdout.trim())
|
||||
.unwrap_or_else(|_| panic!("/diff must emit JSON (#829), got: {stdout:?}"));
|
||||
let hint = j["hint"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
hint.contains("--resume"),
|
||||
"/diff hint must suggest --resume (it is resume-safe) (#829): hint={hint:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// #830: claw mcp show (missing server name) must emit missing_argument, not unknown_mcp_action
|
||||
#[test]
|
||||
fn mcp_show_missing_server_name_emits_missing_argument() {
|
||||
let root = unique_temp_dir("mcp-show-missing-830");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
let output = run_claw(&root, &["--output-format", "json", "mcp", "show"], &[]);
|
||||
assert_eq!(output.status.code(), Some(1), "mcp show (no name) should exit 1");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let j: serde_json::Value =
|
||||
serde_json::from_str(stdout.trim()).expect("mcp show (no name) must emit JSON (#830)");
|
||||
assert_eq!(
|
||||
j["error_kind"], "missing_argument",
|
||||
"mcp show (no name) must emit missing_argument, not unknown_mcp_action (#830): {j}"
|
||||
);
|
||||
assert_ne!(
|
||||
j["error_kind"], "unknown_mcp_action",
|
||||
"mcp show (no name) must not emit unknown_mcp_action (#830): {j}"
|
||||
);
|
||||
let hint = j["hint"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
hint.contains("claw mcp show") || hint.contains("mcp list"),
|
||||
"mcp show (no name) hint should mention usage (#830): {hint:?}"
|
||||
);
|
||||
assert!(
|
||||
stderr.is_empty(),
|
||||
"mcp show (no name) JSON must have empty stderr (#830): {stderr:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// #831: direct-slash resume-safe commands must route to their CliAction equivalents
|
||||
#[test]
|
||||
fn direct_slash_resume_safe_commands_route_correctly() {
|
||||
let root = unique_temp_dir("direct-slash-resume-831");
|
||||
std::fs::create_dir_all(&root).expect("create temp dir");
|
||||
// /version, /doctor, /sandbox, /diff all work without --resume
|
||||
for (cmd, expected_kind) in &[
|
||||
("/version", "version"),
|
||||
("/sandbox", "sandbox"),
|
||||
("/diff", "diff"),
|
||||
] {
|
||||
let output = run_claw(&root, &["--output-format", "json", cmd], &[]);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = serde_json::from_str(stdout.trim())
|
||||
.unwrap_or_else(|_| panic!("{cmd} must emit JSON (#831), got: {stdout:?}"));
|
||||
assert_eq!(
|
||||
j["status"], "ok",
|
||||
"{cmd} direct slash must exit ok (#831): {j}"
|
||||
);
|
||||
assert_ne!(
|
||||
j["error_kind"], "interactive_only",
|
||||
"{cmd} direct slash must not emit interactive_only (#831): {j}"
|
||||
);
|
||||
let kind = j["kind"].as_str().unwrap_or("");
|
||||
assert_eq!(
|
||||
kind, *expected_kind,
|
||||
"{cmd} direct slash must emit kind={expected_kind} (#831): {j}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,8 +521,9 @@ fn resumed_stub_command_emits_not_implemented_json() {
|
||||
|
||||
// Stub commands exit with code 2
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json");
|
||||
// #819/#820/#823: JSON abort envelopes route to stdout
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(
|
||||
parsed["status"], "error",
|
||||
"stub command should emit status:error"
|
||||
|
||||
Reference in New Issue
Block a user