From 0e8a449ea961e48121a925de765d92db0dfe590c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 26 May 2026 21:27:39 +0900 Subject: [PATCH] fix+test(#755): -p consumes exactly one token; flags after prompt text now parse normally --- ROADMAP.md | 2 + rust/crates/rusty-claude-cli/src/main.rs | 68 ++++++++++++++----- .../tests/output_format_contract.rs | 63 +++++++++++++++++ 3 files changed, 116 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 93ce6cec..bc746614 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7675,3 +7675,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 753. **`claw --output-format json -p` (no prompt arg) returned `error_kind:"unknown"` and `hint: null`** — parity gap with #750/#751 which fixed the explicit `prompt` verb. Identified by gaebal-gajae on `ddc71b56`. Fix: same `missing_prompt:` prefix + newline usage hint as #750. Regression guard: `short_p_flag_no_arg_json_error_kind_753` asserting nonzero exit, `error_kind:"missing_prompt"`, non-empty hint mentioning `claw -p` or `claw prompt`. Source: gaebal-gajae dogfood on `ddc71b56`, 2026-05-26. 754. **`missing_credentials` JSON envelope always had `hint: null` even when a contextual hint was available** — dogfooded 2026-05-26 on `e9327135`. `ApiError::Display` for `MissingCredentials` appended the hint via ` — hint: {hint}` (inline, no `\n`), so `split_error_hint()` could not extract it and left the JSON `hint` field null. Fix: change delimiter from ` — hint: ` to `\n` in `api/src/error.rs` Display impl; update two tests in `api/src/error.rs` and `api/src/providers/mod.rs` to assert newline separator. Source: Jobdori dogfood on `e9327135`, 2026-05-26. + +755. **`claw -p hello --model sonnet` swallowed `--model sonnet` into the prompt string** — gaebal-gajae pinpoint on `e9327135` (#117 revival). `-p` used `args[index+1..].join(" ")`, consuming all remaining tokens as prompt. Fix: capture exactly one token via `args.get(index+1)`, reject flag-like tokens (`starts_with('-')`) as `missing_prompt`, support `--` sentinel for literal flag-text, then `continue` the flag loop so `--model`/`--output-format`/etc. parse normally. Dispatch via `short_p_prompt` after full flag scan. Regression guard: `short_p_flag_swallows_no_flags_755` asserts `--output-format json` is parsed (not swallowed) and `--model` as prompt-arg is rejected. Source: gaebal-gajae dogfood on `e9327135`, 2026-05-26. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index ddae2664..120d4498 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -742,6 +742,9 @@ fn parse_args(args: &[String]) -> Result { let mut base_commit: Option = None; let mut reasoning_effort: Option = None; let mut allow_broad_cwd = false; + // #755: -p prompt text captured as single token; remaining args continue + // flag parsing. None until `-p ` is seen. + let mut short_p_prompt: Option = None; let mut rest: Vec = Vec::new(); let mut index = 0; @@ -858,24 +861,40 @@ fn parse_args(args: &[String]) -> Result { index += 1; } "-p" => { - // Claw Code compat: -p "prompt" = one-shot prompt - let prompt = args[index + 1..].join(" "); - if prompt.trim().is_empty() { - // #753: same missing_prompt shape as claw prompt (no arg) fix in #750 - return Err("missing_prompt: -p requires a prompt string.\nUsage: claw -p or claw prompt ".to_string()); + // Claw Code compat: -p "prompt" = one-shot prompt. + // #755: consume exactly one token so subsequent flags like + // --model/--output-format are parsed normally instead of + // being swallowed into the prompt string (#117). + let next = args.get(index + 1).map(|s| s.as_str()); + match next { + None | Some("") => { + return Err("missing_prompt: -p requires a prompt string.\nUsage: claw -p or claw prompt ".to_string()); + } + Some(tok) if tok.starts_with('-') && tok != "--" => { + // Looks like a flag, not a prompt. Reject so the user + // knows to quote the literal text or use `--`. + return Err(format!( + "missing_prompt: -p requires a prompt string before flags; got `{tok}`.\nUsage: claw -p --model sonnet or claw -p -- {tok} (literal)" + )); + } + Some(tok) => { + // `--` sentinel: skip it and take the token after as literal + let (prompt_text, skip) = if tok == "--" { + match args.get(index + 2) { + Some(t) => (t.as_str(), 3usize), + None => return Err("missing_prompt: -p -- requires a prompt string after `--`.\nUsage: claw -p -- ".to_string()), + } + } else { + (tok, 2usize) + }; + if prompt_text.trim().is_empty() { + return Err("missing_prompt: -p requires a non-empty prompt string.\nUsage: claw -p or claw prompt ".to_string()); + } + short_p_prompt = Some(prompt_text.to_string()); + index += skip; + continue; + } } - return Ok(CliAction::Prompt { - prompt, - model: resolve_model_alias_with_config(&model), - output_format, - allowed_tools: normalize_allowed_tools(&allowed_tool_values)?, - permission_mode: permission_mode_override - .unwrap_or_else(default_permission_mode), - compact, - base_commit: base_commit.clone(), - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }); } "--print" => { // Claw Code compat: --print makes output non-interactive @@ -965,6 +984,21 @@ fn parse_args(args: &[String]) -> Result { let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; + // #755: -p consumed exactly one token; dispatch now that all flags are parsed + if let Some(prompt) = short_p_prompt { + return Ok(CliAction::Prompt { + prompt, + model: resolve_model_alias_with_config(&model), + output_format, + allowed_tools, + permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), + compact, + base_commit, + reasoning_effort, + 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 diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 8d4d762f..87b11d8a 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -1504,6 +1504,69 @@ fn prompt_no_arg_json_error_kind_750() { ); } +#[test] +fn short_p_flag_swallows_no_flags_755() { + // #755: `claw -p hello --output-format json` must parse --output-format json + // as a flag rather than swallowing it as part of the prompt. Before #755, + // args[index+1..].join(" ") consumed all remaining tokens into the prompt. + // After #755, -p consumes exactly one token and remaining flags are parsed. + // We verify by checking that the envelope IS JSON (meaning --output-format json + // was interpreted as a flag, not literal prompt text). + use std::process::Command; + let root = unique_temp_dir("short-p-flags"); + fs::create_dir_all(&root).expect("temp dir"); + let bin = env!("CARGO_BIN_EXE_claw"); + + // -p hello --output-format json: with no credentials, should fail with + // missing_credentials (not missing_prompt), proving --output-format json was parsed. + let output = Command::new(bin) + .current_dir(&root) + .args(["-p", "hello", "--output-format", "json"]) + .env_remove("ANTHROPIC_API_KEY") + .env_remove("ANTHROPIC_AUTH_TOKEN") + .output() + .expect("claw -p should run"); + assert!( + !output.status.success(), + "claw -p hello --output-format json must exit non-zero (no credentials)" + ); + let raw = String::from_utf8_lossy(&output.stderr) + .lines() + .filter(|l| l.starts_with('{')) + .collect::>() + .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}") + }); + assert_eq!( + parsed["error_kind"], "missing_credentials", + "flags after -p prompt text must be parsed normally (#755); got: {parsed}" + ); + + // Also verify -p --model bogus is rejected as missing_prompt (flag-as-prompt guard) + let output2 = Command::new(bin) + .current_dir(&root) + .args(["--output-format", "json", "-p", "--model", "sonnet"]) + .output() + .expect("claw -p flag-as-prompt should run"); + let raw2 = String::from_utf8_lossy(&output2.stderr) + .lines() + .filter(|l| l.starts_with('{')) + .collect::>() + .join(""); + let parsed2: serde_json::Value = serde_json::from_str(&raw2) + .unwrap_or_else(|_| panic!("claw -p --model must emit JSON; got: {raw2}")); + assert_eq!( + parsed2["error_kind"], "missing_prompt", + "flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}" + ); + assert!( + parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()), + "missing_prompt hint must be non-empty (#755)" + ); +} + #[test] fn short_p_flag_no_arg_json_error_kind_753() { // #753: `claw --output-format json -p` (no prompt) must emit error_kind:"missing_prompt"