From b5bead90280f4c05cfe78513978cfb2aa481d440 Mon Sep 17 00:00:00 2001 From: bellman Date: Thu, 4 Jun 2026 13:25:15 +0900 Subject: [PATCH] fix: recover CLI parser CI Generated with https://github.com/Yeachan-Heo/gajae-code Co-authored-by: Gajae Code --- ROADMAP.md | 2 +- USAGE.md | 6 ++ rust/README.md | 1 + rust/crates/rusty-claude-cli/src/main.rs | 125 +++++++++++++++++++++-- 4 files changed, 122 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 96589fe7..a4d00348 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6377,7 +6377,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 433. **DONE — `--output-format` selection is typed, case-insensitive, env-configurable, and auditable** — fixed 2026-06-04 in `fix: type output format selection`. `CliOutputFormat::parse` now accepts `text`/`json` in any casing, `CLAW_OUTPUT_FORMAT` seeds the default output format when no CLI output-format flag is present, explicit flags override the env default, and repeated flags emit `warning: --output-format specified multiple times; using last value '...'`. `status --output-format json` exposes `format_source`, `format_raw`, and `format_overridden`; invalid values return typed `invalid_output_format` JSON with `value`, `expected:["text","json"]`, and a recovery hint instead of `kind:"unknown"`. Top-level help documents `CLAW_OUTPUT_FORMAT`, `CLAW_LOG`, and `RUST_LOG`, and doctor system JSON surfaces those env values. Regression coverage: `output_format_flags_and_env_have_typed_contract_433` and `classify_error_kind_returns_correct_discriminants`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused output-format and classifier tests, `scripts/roadmap-check-ids.sh`, `git diff --check`, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, and `cargo build --manifest-path rust/Cargo.toml --workspace --locked`. -434. **POSIX `--` end-of-flags separator is not recognized — `claw -- "-prompt-with-dash"` returns `{"error":"unknown option: --","hint":"Did you mean -V?","kind":"cli_parse"}` instead of treating subsequent args as positional; shorthand prompt mode cannot accept dash-prefixed prompts at all** — dogfooded 2026-05-11 by Jobdori on `0e5f6958` in response to Clawhip pinpoint nudge at `1503298142286905484`. Reproduction: `claw -- "-prompt-with-dash" --output-format json` returns `{"error":"unknown option: --","hint":"Did you mean -V?\nRun \`claw --help\` for usage.","kind":"cli_parse"}`. The POSIX/GNU CLI convention — universally honored by cargo, git, npm, gh, kubectl, grep, ls, find, etc. — is that `--` terminates flag parsing and treats everything after it as positional arguments. claw rejects `--` itself as an unknown flag. **Sibling misleading-suggestion bug (recurring from #429):** the `cli_parse` hint suggests `Did you mean -V?` for `--`. `-V` is the version flag; `--` is the end-of-flags separator. They have no semantic relationship; the auto-complete is matching on prefix-character similarity only. **Sibling shorthand-prompt limitation:** `claw "-just a prompt" --output-format json` returns `{"error":"unknown option: -just a prompt","kind":"cli_parse"}` and `claw "--bogus-flag-like" --output-format json` returns the same. The shorthand non-interactive prompt mode (documented as `claw [--model MODEL] [--output-format text|json] TEXT`) cannot accept any TEXT that starts with `-` or `--`, even when the entire string is shell-quoted as a single token. Users must use the explicit `prompt` verb (`claw prompt "-prompt-with-dash"` works) to escape this, but the explicit verb is documented as alternative not required. **Required fix shape:** (a) accept POSIX `--` as the end-of-flags marker globally — every arg after `--` is positional; (b) shorthand prompt mode must distinguish "this looks like a flag" from "this is a quoted positional that happens to start with `-`" by looking at whether the token matches any registered flag name (`-h`, `-V`, `--help`, `--version`, etc.) — strings that don't match any flag should be treated as prompt text; (c) fix the "Did you mean" hint algorithm to filter by semantic category (don't suggest `-V` for `--`, suggest "use \`--\` to terminate flag parsing" if the user types just `--`); (d) regression test: `claw -- "-foo"` reaches the runtime with prompt=`-foo`; `claw "-not-a-flag"` is treated as shorthand prompt when no registered flag matches; canonical `--` is recognized. **Why this matters:** POSIX `--` is the universal mechanism for passing arbitrary text (filenames starting with `-`, prompts containing flag-like syntax, log lines, etc.) to a CLI. Failing on `--` makes claw fundamentally unergonomic in shell pipelines (`echo "-q for quiet" | xargs claw` fails). The shorthand-prompt limitation forces users to remember the `prompt` verb specifically when their prompt happens to start with `-`. Cross-references #422 (unknown subcommand fallthrough), #423 (stdin not consumed by prompt), #429 ("Did you mean --acp" misleading suggestion). Source: Jobdori live dogfood, `0e5f6958`, 2026-05-11. +434. **DONE — POSIX `--` and dash-prefixed shorthand prompts stay on the prompt path** — fixed 2026-06-04 in CI recovery after `41678eb` turned Rust CI red. Global argument parsing now treats `--` as an end-of-flags separator, stops JSON-output pre-scans before the separator, and forwards every following token as positional prompt text. Shorthand prompt mode accepts dash-prefixed text that is not a registered or near-miss CLI flag (`-not-a-flag`, `--bogus-flag-like literal`) while still rejecting real typo-like options such as `--resum` with the `--resume` suggestion. Direct `/status` invocation again remains REPL/session-only so the existing parser contract is restored. Help, USAGE, and rust README document the `claw -- "-prompt-with-dash"` form. Regression coverage: `parses_dash_prefixed_prompt_text_434`, plus rerun CI-red parser tests `parses_bare_prompt_and_json_output_flag` and `parses_direct_agents_mcp_and_skills_slash_commands`. Verification: `cargo fmt --manifest-path rust/Cargo.toml --all -- --check`, focused parser tests, `scripts/roadmap-check-ids.sh`, `git diff --check -- USAGE.md rust/README.md rust/crates/rusty-claude-cli/src/main.rs ROADMAP.md`, docs source-of-truth/release-readiness/unit helper checks, `cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --no-run`, `cargo build --manifest-path rust/Cargo.toml --workspace --locked`, and `cargo clippy --manifest-path rust/Cargo.toml --workspace`. Local `cargo test --manifest-path rust/Cargo.toml --workspace` still hits the pre-existing Darwin-only `runtime::worker_boot::startup_preflight_warns_when_git_metadata_is_not_writable` permission assertion after all CLI parser tests pass; the red GitHub jobs were parser failures on head `41678eb`. 435. **`claw --resume latest` on a fresh workspace exit code is 0 in text mode but 1 in JSON mode (text mode lies about success); sibling: failed `--resume` creates the `.claw/sessions//` directory tree as a filesystem side effect of the failure** — dogfooded 2026-05-11 by Jobdori on `e29010ed` in response to Clawhip pinpoint nudge at `1503305692566655096`. Reproduction (fresh empty dir, no `.claw/`, no sessions): `claw --resume latest` (text mode) prints `failed to restore session: no managed sessions found in .claw/sessions/0ead448127a2de44/` and exits **0**. Same invocation with `--output-format json` correctly exits **1** with `kind:"session_load_failed"`. Exit-code parity broken on the same input depending on format flag. **Sibling filesystem-side-effect bug:** after the failed `--resume latest` on a fresh empty workspace, the directory `.claw/sessions/0ead448127a2de44/` (the workspace-fingerprint partition) is created on disk despite the operation failing. The user did not opt into creating workspace metadata — they asked to resume an existing session, the resume failed, and now there's a partition directory hanging around. The fingerprint directory ought to be created lazily on first successful session save, not as a side effect of every resume attempt. **Three sibling findings in the same probe:** (a) **`claw --compact` alone (no other args) drops into the interactive REPL with the ANSI welcome banner** — `--compact` is documented as a modifier that strips tool call details in text mode for piping (`--compact ... useful for piping`), not as a verb that activates the REPL. Running `claw --compact` with no positional should be a no-op or an error explaining the flag needs a subcommand or prompt; entering the REPL is the wrong default. (b) **`claw --compact "hello"` (shorthand prompt) returns `{"error":"unknown subcommand: hello.","hint":"Did you mean help","kind":"unknown"}` — `--compact` disables shorthand prompt mode entirely**, treating the positional as a subcommand instead of as prompt text. Users must use the explicit `prompt` verb (`claw --compact prompt "hello"`) which contradicts the `claw [flags] TEXT` usage line in `--help`. (c) `kind:"unknown"` again for the unknown-subcommand error in --compact path — same catch-all bucket bug appearing for the 11th time across pinpoints. **Required fix shape:** (a) exit code 1 for all `failed_to_restore` / `session_load_failed` text-mode failures; text mode should print to stderr and exit non-zero, not print to stdout and exit 0; (b) defer `.claw/sessions//` creation to first successful save; failed `--resume` must not leave filesystem droppings; (c) `claw --compact` alone (no positional, no subcommand, stdin is TTY) should emit `kind:"missing_argument"` with `argument:"prompt or subcommand"` rather than activating the REPL; (d) `--compact` must be transparent to shorthand prompt mode parsing — `claw --compact "hello"` is equivalent to `claw --compact prompt "hello"`, both should reach the prompt path; (e) emit typed `kind:"unknown_subcommand"` not `kind:"unknown"` for fallthrough cases. **Why this matters:** scripts that gate on `$?` after `claw --resume latest` see success on text mode and failure on JSON mode — the same operation, two outcomes. The filesystem side effect pollutes a user's worktree with workspace partitions they didn't ask for, and CI pipelines that snapshot `.claw/` size silently grow on every failed `--resume`. Cross-references #422 (exit-code parity across error envelopes), #423 (`kind:"unknown"` for `missing_argument`), #434 (shorthand prompt limitations). Source: Jobdori live dogfood, `e29010ed`, 2026-05-11. diff --git a/USAGE.md b/USAGE.md index ef0e8f14..a760cef9 100644 --- a/USAGE.md +++ b/USAGE.md @@ -99,6 +99,12 @@ cd rust ./target/debug/claw "explain rust/crates/runtime/src/lib.rs" ``` +Use the POSIX `--` end-of-flags separator when the shorthand prompt itself begins with `-` or `--`: + +```bash +./target/debug/claw -- "-summarize this dash-prefixed text" +``` + ### JSON output for scripting ```bash diff --git a/rust/README.md b/rust/README.md index bae62e05..b7b64236 100644 --- a/rust/README.md +++ b/rust/README.md @@ -148,6 +148,7 @@ Top-level commands: `claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`. `--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs. +Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options. `claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory. The command surface is moving quickly. For the canonical live help text, run: diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 676fd8ea..feca3970 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -301,6 +301,17 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[ "-p", ]; +fn is_registered_cli_flag_token(value: &str) -> bool { + let flag = value.split_once('=').map_or(value, |(flag, _)| flag); + CLI_OPTION_SUGGESTIONS.contains(&flag) +} + +fn should_reject_unknown_option_like(value: &str) -> bool { + is_registered_cli_flag_token(value) + || (value.starts_with("--") + && suggest_closest_term(value, CLI_OPTION_SUGGESTIONS).is_some()) +} + type AllowedToolSet = BTreeSet; type RuntimePluginStateBuildOutput = ( Option>>, @@ -1328,6 +1339,7 @@ fn current_output_format_selection() -> OutputFormatSelection { fn cli_has_output_format_flag(args: &[String]) -> bool { args.iter() + .take_while(|arg| arg.as_str() != "--") .any(|arg| arg == "--output-format" || arg.starts_with("--output-format=")) } @@ -1336,6 +1348,9 @@ fn raw_args_request_json_output(args: &[String]) -> bool { let mut index = 0; while index < args.len() { let arg = &args[index]; + if arg == "--" { + break; + } if arg == "--output-format" { if let Some(value) = args.get(index + 1) { values.push(value.as_str()); @@ -1433,6 +1448,7 @@ fn parse_args(args: &[String]) -> Result { // flag parsing. None until `-p ` is seen. let mut short_p_prompt: Option = None; let mut rest: Vec = Vec::new(); + let mut positional_after_separator = false; let mut index = 0; while index < args.len() { @@ -1548,6 +1564,11 @@ fn parse_args(args: &[String]) -> Result { allow_broad_cwd = true; index += 1; } + "--" => { + positional_after_separator = true; + rest.extend(args[index + 1..].iter().cloned()); + break; + } "-p" => { // Claw Code compat: -p "prompt" = one-shot prompt. // #755: consume exactly one token so subsequent flags like @@ -1629,7 +1650,11 @@ fn parse_args(args: &[String]) -> Result { index += 1; } other if rest.is_empty() && other.starts_with('-') => { - return Err(format_unknown_option(other)) + if should_reject_unknown_option_like(other) { + return Err(format_unknown_option(other)); + } + rest.push(other.to_string()); + index += 1; } other => { rest.push(other.to_string()); @@ -1704,6 +1729,21 @@ fn parse_args(args: &[String]) -> Result { }); } + if positional_after_separator && !rest.is_empty() { + let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); + return Ok(CliAction::Prompt { + prompt: rest.join(" "), + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }); + } + if rest.is_empty() { let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); // When stdin is not a terminal (pipe/redirect) and no prompt is given on the @@ -2042,9 +2082,7 @@ Usage: claw prompt or echo '' | claw prompt".to_string()); allow_broad_cwd, ), other => { - if looks_like_subcommand_typo(other) - && (rest.len() == 1 || output_format == CliOutputFormat::Json) - { + if !other.starts_with('-') && looks_like_subcommand_typo(other) && rest.len() == 1 { // #825/#826: emit command_not_found before provider startup for // command-shaped tokens that do not match known subcommands. // Text-mode multi-word prompt shorthand remains available, but @@ -2393,13 +2431,10 @@ fn parse_direct_slash_cli_action( let raw = rest.join(" "); match SlashCommand::parse(&raw) { Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }), - Ok(Some(SlashCommand::Status)) => Ok(CliAction::Status { - model, - model_flag_raw: None, - permission_mode, - output_format, - allowed_tools, - }), + Ok(Some(SlashCommand::Status)) => Err( + "interactive_only: /status requires a live session.\nStart `claw` and run it there, or use `claw --resume SESSION.jsonl /status` / `claw --resume latest /status`." + .to_string(), + ), Ok(Some(SlashCommand::Sandbox)) => Ok(CliAction::Sandbox { output_format }), Ok(Some(SlashCommand::Diff)) => Ok(CliAction::Diff { output_format }), Ok(Some(SlashCommand::Version)) => Ok(CliAction::Version { output_format }), @@ -2482,6 +2517,9 @@ fn parse_direct_slash_cli_action( } fn format_unknown_option(option: &str) -> String { + if option == "--" { + return "end_of_flags: `--` terminates flag parsing. Pass literal prompt text after it, for example `claw -- \"-literal prompt\"`.\nRun `claw --help` for usage.".to_string(); + } let mut message = format!("unknown option: {option}"); if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) { message.push_str("\nDid you mean "); @@ -12643,6 +12681,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { " claw [--model MODEL] [--output-format text|json] TEXT" )?; writeln!(out, " Shorthand non-interactive prompt mode")?; + writeln!( + out, + " Use `--` before TEXT when the prompt itself starts with '-' or '--'" + )?; writeln!( out, " claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]" @@ -13414,6 +13456,67 @@ mod tests { ); } + #[test] + fn parses_dash_prefixed_prompt_text_434() { + let _guard = env_lock(); + std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); + + assert_eq!( + parse_args(&["--".to_string(), "-prompt-with-dash".to_string()]) + .expect("-- should terminate flag parsing"), + CliAction::Prompt { + prompt: "-prompt-with-dash".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::WorkspaceWrite, + compact: false, + base_commit: None, + reasoning_effort: None, + allow_broad_cwd: false, + } + ); + + assert_eq!( + parse_args(&["-not-a-flag".to_string()]) + .expect("unknown dash-prefixed shorthand prompt should parse as prompt text"), + CliAction::Prompt { + prompt: "-not-a-flag".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::WorkspaceWrite, + compact: false, + base_commit: None, + reasoning_effort: None, + allow_broad_cwd: false, + } + ); + + assert_eq!( + parse_args(&["--bogus-flag-like".to_string(), "literal".to_string()]) + .expect("unknown double-dash text should stay eligible for prompt shorthand"), + CliAction::Prompt { + prompt: "--bogus-flag-like literal".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::WorkspaceWrite, + compact: false, + base_commit: None, + reasoning_effort: None, + allow_broad_cwd: false, + } + ); + + assert!(parse_args(&["--".to_string()]).is_ok()); + + let error = parse_args(&["--resum".to_string()]) + .expect_err("nearby real flags should still be rejected as unknown options"); + assert!(error.contains("unknown option: --resum")); + assert!(error.contains("Did you mean --resume?")); + } + #[test] fn parses_compact_flag_for_prompt_mode() { // given a bare prompt invocation that includes the --compact flag