diff --git a/ROADMAP.md b/ROADMAP.md index 8d5b09f9..ef7f7825 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7705,3 +7705,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 768. **`claw --resume latest compact` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `89735dbd` (gaebal-gajae pinpoint against `d29a8e21`, revised ID after #766/#767 landed). Resume trailing-arg validator emitted single-line `"--resume trailing arguments must be slash commands"` with no typed prefix and no `\n` hint. Fix: (1) changed error to `"invalid_resume_argument: \`{token}\` is not a slash command.\nUsage: claw --resume /"` so `split_error_hint()` extracts the hint; (2) added `invalid_resume_argument` classifier arm; (3) unit test assertion + integration test `resume_non_slash_trailing_arg_has_typed_error_kind_and_hint_768` added. 34 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae + Jobdori probe on `89735dbd`, 2026-05-27. 769. **`claw session bogus` fell through to credential check instead of interactive-only guidance** — dogfooded 2026-05-27 on `b778d4e3` (tracked as #767). `claw session ` with more than one token bypassed `parse_single_word_command_alias` (which only fires for `rest.len()==1`) and had no match arm in `parse_args`, so `rest.join(" ")` became a prompt literal dispatched to `CliAction::Prompt`, hitting `missing_credentials` at the gate. Fix: added `"session"` match arm that emits `interactive_only:` error with `\n`-delimited hint referencing `--resume SESSION.jsonl /session` and REPL usage. Integration test `session_with_unknown_subcommand_returns_interactive_only_not_credentials_767` asserts `error_kind:interactive_only` + non-null hint for `bogus`, `nuke`, `delete-all`. 35 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori probe on `b778d4e3`, 2026-05-27. + +770. **`claw cost/clear/memory/ultraplan/model` with trailing args fell to credential check** — dogfooded 2026-05-27 on `9e1be056`. Same fallthrough gap as #767/#769: these slash-only verbs had no multi-arg match arms, so `claw cost breakdown`, `claw clear --force`, `claw memory reset`, `claw ultraplan bogus`, `claw model opus extra` all became `CliAction::Prompt` literals, hitting `missing_credentials` at the gate. Fix: added `"cost"`, `"clear"`, `"memory"`, `"ultraplan"`, `"model" if rest.len() > 1` match arms, each returning `interactive_only:` + `\n`-delimited hint. Integration test `slash_only_verbs_with_args_return_interactive_only_not_credentials_770` asserts all five cases. 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori sweep on `9e1be056`, 2026-05-27. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 30fd1ab1..7c5ce197 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1175,6 +1175,28 @@ fn parse_args(args: &[String]) -> Result { "interactive_only: `claw session` is a slash command{action_hint}.\nUse `claw --resume SESSION.jsonl /session ` or start `claw` and run `/session [list|exists|switch|fork|delete]`." )) } + // #770: same fallthrough gap as #767 — these slash commands had no multi-arg match arm + // and fell to CliAction::Prompt reaching the credential gate when called with args. + "cost" => Err( + "interactive_only: `claw cost` is a slash command.\nUse `claw --resume SESSION.jsonl /cost` or start `claw` and run `/cost`." + .to_string(), + ), + "clear" => Err( + "interactive_only: `claw clear` is a slash command.\nUse `claw --resume SESSION.jsonl /clear [--confirm]` or start `claw` and run `/clear`." + .to_string(), + ), + "memory" => Err( + "interactive_only: `claw memory` is a slash command.\nStart `claw` and run `/memory` inside the REPL." + .to_string(), + ), + "ultraplan" => Err( + "interactive_only: `claw ultraplan` is a slash command.\nStart `claw` and run `/ultraplan` inside the REPL." + .to_string(), + ), + "model" if rest.len() > 1 => Err( + "interactive_only: `claw model` is a slash command.\nStart `claw` and run `/model [model-name]` inside the REPL." + .to_string(), + ), "skills" => { let args = join_optional_args(&rest[1..]); if let Some(action) = args.as_deref() { 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 bc4a1598..4e79699f 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -2019,3 +2019,60 @@ fn session_with_unknown_subcommand_returns_interactive_only_not_credentials_767( ); } } + +#[test] +fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() { + // #770: `claw cost breakdown`, `claw clear --force`, `claw memory reset`, + // `claw ultraplan bogus`, `claw model opus extra` all fell through to + // CliAction::Prompt and reached the credential gate, returning + // error_kind:"missing_credentials". These are all slash-only commands; + // any multi-token invocation should return interactive_only guidance. + let root = unique_temp_dir("slash-verbs-770"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let cases: &[&[&str]] = &[ + &["cost", "breakdown"], + &["clear", "--force"], + &["memory", "reset"], + &["ultraplan", "bogus"], + &["model", "opus", "extra"], + ]; + + for args in cases { + let full_args: Vec<&str> = std::iter::once("--output-format") + .chain(std::iter::once("json")) + .chain(args.iter().copied()) + .collect(); + let output = run_claw(&root, &full_args, &[]); + assert!( + !output.status.success(), + "claw {} should exit non-zero", + args.join(" ") + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let json_line = stderr + .lines() + .find(|l| l.trim_start().starts_with('{')) + .unwrap_or_else(|| { + panic!( + "claw {} stderr should contain JSON, got: {stderr}", + args.join(" ") + ) + }); + let parsed: serde_json::Value = + serde_json::from_str(json_line).expect("error envelope should be valid JSON"); + + assert_eq!( + parsed["error_kind"], + "interactive_only", + "claw {} must return error_kind:interactive_only (#770), not missing_credentials", + args.join(" ") + ); + let hint = parsed["hint"].as_str().unwrap_or(""); + assert!( + !hint.is_empty(), + "claw {} must return non-null hint (#770)", + args.join(" ") + ); + } +}