mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-27 05:15:00 +08:00
feat: #147 — reject empty / whitespace-only prompts at CLI fallthrough
## Problem
The `"prompt"` subcommand arm enforced `if prompt.trim().is_empty()`
and returned a specific error. The fallthrough `other` arm in the same
match block — which routes any unrecognized first positional arg to
`CliAction::Prompt` — had no such guard. Result:
$ claw ""
error: missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN ...
$ claw " "
error: missing Anthropic credentials; ...
$ claw "" ""
error: missing Anthropic credentials; ...
$ claw --output-format json ""
{"error":"missing Anthropic credentials; ...","type":"error"}
An empty prompt should never reach the credentials check. Worse: with
valid credentials, the literal empty string gets sent to Claude as a
user prompt, either burning tokens for nothing or triggering a model-
side refusal. Same prompt-misdelivery family as #145.
## Root cause
In `parse_subcommand()`, the final `other =>` arm in the top-level
match only guards against typos (#108 guard via `looks_like_subcommand_typo`)
and then unconditionally builds `CliAction::Prompt { prompt: rest.join(" ") }`.
An empty/whitespace-only join passes through.
## Changes
### rust/crates/rusty-claude-cli/src/main.rs
Added the same `if joined.trim().is_empty()` guard already used in the
`"prompt"` arm to the fallthrough path. Error message distinguishes it
from the `prompt` subcommand path:
empty prompt: provide a subcommand (run `claw --help`) or a
non-empty prompt string
Runs AFTER the typo guard (so `claw sttaus` still suggests `status`)
and BEFORE CliAction::Prompt construction (so no network call ever
happens for empty inputs).
### Regression tests
Added 4 assertions in the existing parse_args test:
- parse_args([""]) → Err("empty prompt: ...")
- parse_args([" "]) → Err("empty prompt: ...")
- parse_args(["", ""]) → Err("empty prompt: ...")
- parse_args(["sttaus"]) → Err("unknown subcommand: ...") [verifies #108 typo guard still takes precedence]
### ROADMAP.md
Added Pinpoint #147 documenting the gap, verification, root cause,
fix shape, and acceptance. Joins the prompt-misdelivery cluster
alongside #145.
## Live verification
$ claw ""
error: empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string
$ claw " "
error: empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string
$ claw --output-format json ""
{"error":"empty prompt: provide a subcommand ...","type":"error"}
$ claw prompt "" # unchanged: subcommand-specific error preserved
error: prompt subcommand requires a prompt string
$ claw hello # unchanged: typo guard still fires
error: unknown subcommand: hello.
Did you mean help
$ claw "real prompt here" # unchanged: real prompts still reach API
error: api returned 401 Unauthorized (with dummy key, as expected)
All empty/whitespace-only paths exit 1. No network call. No misleading
credentials error.
## Tests
- rusty-claude-cli bin: 177 tests pass (4 new assertions)
- Full workspace green except pre-existing resume_latest flake (unrelated)
Closes ROADMAP #147.
This commit is contained in:
@@ -824,8 +824,21 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
return Err(message);
|
||||
}
|
||||
}
|
||||
// #147: guard empty/whitespace-only prompts at the fallthrough
|
||||
// path the same way `"prompt"` arm above does. Without this,
|
||||
// `claw ""`, `claw " "`, and `claw "" ""` silently route to
|
||||
// the Anthropic call and surface a misleading
|
||||
// `missing Anthropic credentials` error (or burn API tokens on
|
||||
// an empty prompt when credentials are present).
|
||||
let joined = rest.join(" ");
|
||||
if joined.trim().is_empty() {
|
||||
return Err(
|
||||
"empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(CliAction::Prompt {
|
||||
prompt: rest.join(" "),
|
||||
prompt: joined,
|
||||
model,
|
||||
output_format,
|
||||
allowed_tools,
|
||||
@@ -9743,6 +9756,36 @@ mod tests {
|
||||
output_format: CliOutputFormat::Json,
|
||||
}
|
||||
);
|
||||
// #147: empty / whitespace-only positional args must be rejected
|
||||
// with a specific error instead of falling through to the prompt
|
||||
// path (where they surface a misleading "missing Anthropic
|
||||
// credentials" error or burn API tokens on an empty prompt).
|
||||
let empty_err = parse_args(&["".to_string()])
|
||||
.expect_err("empty positional arg should be rejected");
|
||||
assert!(
|
||||
empty_err.starts_with("empty prompt:"),
|
||||
"empty-arg error should be specific, got: {empty_err}"
|
||||
);
|
||||
let whitespace_err = parse_args(&[" ".to_string()])
|
||||
.expect_err("whitespace-only positional arg should be rejected");
|
||||
assert!(
|
||||
whitespace_err.starts_with("empty prompt:"),
|
||||
"whitespace-only error should be specific, got: {whitespace_err}"
|
||||
);
|
||||
let multi_empty_err = parse_args(&["".to_string(), "".to_string()])
|
||||
.expect_err("multiple empty positional args should be rejected");
|
||||
assert!(
|
||||
multi_empty_err.starts_with("empty prompt:"),
|
||||
"multi-empty error should be specific, got: {multi_empty_err}"
|
||||
);
|
||||
// Typo guard from #108 must still take precedence for non-empty
|
||||
// single-word non-prompt-looking inputs.
|
||||
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 guard should fire for 'sttaus', got: {typo_err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user