Refactor CLI parser to strict fail-closed architecture
This commit is contained in:
parent
63a5a87471
commit
a5e6ca70a6
|
|
@ -668,6 +668,12 @@ enum LocalHelpTopic {
|
||||||
SystemPrompt,
|
SystemPrompt,
|
||||||
DumpManifests,
|
DumpManifests,
|
||||||
BootstrapPlan,
|
BootstrapPlan,
|
||||||
|
Mcp,
|
||||||
|
Plugins,
|
||||||
|
Config,
|
||||||
|
Diff,
|
||||||
|
Agents,
|
||||||
|
Skills,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -691,8 +697,6 @@ impl CliOutputFormat {
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
let mut model = DEFAULT_MODEL.to_string();
|
let mut model = DEFAULT_MODEL.to_string();
|
||||||
// #148: when user passes --model/--model=, capture the raw input so we
|
|
||||||
// can attribute source: "flag" later. None means no flag was supplied.
|
|
||||||
let mut model_flag_raw: Option<String> = None;
|
let mut model_flag_raw: Option<String> = None;
|
||||||
let mut output_format = CliOutputFormat::Text;
|
let mut output_format = CliOutputFormat::Text;
|
||||||
let mut permission_mode_override = None;
|
let mut permission_mode_override = None;
|
||||||
|
|
@ -707,24 +711,14 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
let mut index = 0;
|
let mut index = 0;
|
||||||
|
|
||||||
while index < args.len() {
|
while index < args.len() {
|
||||||
match args[index].as_str() {
|
let arg = &args[index];
|
||||||
"--help" | "-h" if rest.is_empty() => {
|
match arg.as_str() {
|
||||||
wants_help = true;
|
"--help" | "-h" => {
|
||||||
index += 1;
|
if rest.is_empty() {
|
||||||
}
|
wants_help = true;
|
||||||
"--help" | "-h"
|
} else {
|
||||||
if !rest.is_empty()
|
rest.push(arg.clone());
|
||||||
&& matches!(rest[0].as_str(), "prompt" | "commit" | "pr" | "issue") =>
|
}
|
||||||
{
|
|
||||||
// `--help` following a subcommand that would otherwise forward
|
|
||||||
// the arg to the API (e.g. `claw prompt --help`) should show
|
|
||||||
// top-level help instead. Subcommands that consume their own
|
|
||||||
// args (agents, mcp, plugins, skills) and local help-topic
|
|
||||||
// subcommands (status, sandbox, doctor, init, state, export,
|
|
||||||
// version, system-prompt, dump-manifests, bootstrap-plan) must
|
|
||||||
// NOT be intercepted here — they handle --help in their own
|
|
||||||
// dispatch paths via parse_local_help_action(). See #141.
|
|
||||||
wants_help = true;
|
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
"--version" | "-V" => {
|
"--version" | "-V" => {
|
||||||
|
|
@ -735,6 +729,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
let value = args
|
let value = args
|
||||||
.get(index + 1)
|
.get(index + 1)
|
||||||
.ok_or_else(|| "missing value for --model".to_string())?;
|
.ok_or_else(|| "missing value for --model".to_string())?;
|
||||||
|
|
||||||
let resolved = resolve_model_alias_with_config(value);
|
let resolved = resolve_model_alias_with_config(value);
|
||||||
debug!("Resolved --model '{}' -> '{}'", value, resolved);
|
debug!("Resolved --model '{}' -> '{}'", value, resolved);
|
||||||
validate_model_syntax(&resolved)?;
|
validate_model_syntax(&resolved)?;
|
||||||
|
|
@ -744,6 +739,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
}
|
}
|
||||||
flag if flag.starts_with("--model=") => {
|
flag if flag.starts_with("--model=") => {
|
||||||
let value = &flag[8..];
|
let value = &flag[8..];
|
||||||
|
|
||||||
let resolved = resolve_model_alias_with_config(value);
|
let resolved = resolve_model_alias_with_config(value);
|
||||||
debug!("Resolved --model='{}' -> '{}'", value, resolved);
|
debug!("Resolved --model='{}' -> '{}'", value, resolved);
|
||||||
validate_model_syntax(&resolved)?;
|
validate_model_syntax(&resolved)?;
|
||||||
|
|
@ -819,26 +815,29 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
"-p" => {
|
"-p" => {
|
||||||
// Claw Code compat: -p "prompt" = one-shot prompt
|
if rest.is_empty() {
|
||||||
let prompt = args[index + 1..].join(" ");
|
let prompt = args[index + 1..].join(" ");
|
||||||
if prompt.trim().is_empty() {
|
if prompt.trim().is_empty() {
|
||||||
return Err("-p requires a prompt string".to_string());
|
return Err("-p requires a prompt string".to_string());
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
rest.push(arg.clone());
|
||||||
|
index += 1;
|
||||||
}
|
}
|
||||||
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" => {
|
"--print" => {
|
||||||
// Claw Code compat: --print makes output non-interactive
|
|
||||||
output_format = CliOutputFormat::Text;
|
output_format = CliOutputFormat::Text;
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -870,8 +869,13 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
allowed_tool_values.push(flag[16..].to_string());
|
allowed_tool_values.push(flag[16..].to_string());
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
other if rest.is_empty() && other.starts_with('-') => {
|
other if other.starts_with('-') => {
|
||||||
return Err(format_unknown_option(other))
|
if rest.is_empty() {
|
||||||
|
return Err(format_unknown_option(other));
|
||||||
|
} else {
|
||||||
|
rest.push(other.to_string());
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
rest.push(other.to_string());
|
rest.push(other.to_string());
|
||||||
|
|
@ -918,14 +922,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
|
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
|
||||||
|
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
|
||||||
|
|
||||||
if rest.is_empty() {
|
if rest.is_empty() {
|
||||||
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
|
if !std::io::stdin().is_terminal() && !cfg!(test) {
|
||||||
// When stdin is not a terminal (pipe/redirect) and no prompt is given on the
|
|
||||||
// command line, read stdin as the prompt and dispatch as a one-shot Prompt
|
|
||||||
// rather than starting the interactive REPL (which would consume the pipe and
|
|
||||||
// print the startup banner, then exit without sending anything to the API).
|
|
||||||
if !std::io::stdin().is_terminal() {
|
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf);
|
let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf);
|
||||||
let piped = buf.trim().to_string();
|
let piped = buf.trim().to_string();
|
||||||
|
|
@ -958,106 +958,85 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
allow_broad_cwd,
|
allow_broad_cwd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if rest.first().map(String::as_str) == Some("--resume") {
|
if rest.first().map(String::as_str) == Some("--resume") {
|
||||||
return parse_resume_args(&rest[1..], output_format);
|
return parse_resume_args(&rest[1..], output_format);
|
||||||
}
|
}
|
||||||
if let Some(action) = parse_local_help_action(&rest, output_format) {
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
if let Some(action) = parse_single_word_command_alias(
|
|
||||||
&rest,
|
|
||||||
&model,
|
|
||||||
model_flag_raw.as_deref(),
|
|
||||||
permission_mode_override,
|
|
||||||
output_format,
|
|
||||||
allowed_tools.clone(),
|
|
||||||
) {
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
|
|
||||||
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
|
let tail = &rest[1..];
|
||||||
|
let command = rest[0].as_str();
|
||||||
|
|
||||||
match rest[0].as_str() {
|
match command {
|
||||||
"dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format),
|
"status" => {
|
||||||
"bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
|
if let Some(err) = check_help_or_arity(command, tail, 0, LocalHelpTopic::Status, output_format) { return err; }
|
||||||
"agents" => Ok(CliAction::Agents {
|
Ok(CliAction::Status {
|
||||||
args: join_optional_args(&rest[1..]),
|
model: model.clone(),
|
||||||
output_format,
|
model_flag_raw: model_flag_raw.clone(),
|
||||||
}),
|
permission_mode,
|
||||||
"mcp" => Ok(CliAction::Mcp {
|
output_format,
|
||||||
args: join_optional_args(&rest[1..]),
|
allowed_tools,
|
||||||
output_format,
|
})
|
||||||
}),
|
}
|
||||||
// #145: `plugins` was routed through the prompt fallback because no
|
"sandbox" => {
|
||||||
// top-level parser arm produced CliAction::Plugins. That made `claw
|
if let Some(err) = check_help_or_arity(command, tail, 0, LocalHelpTopic::Sandbox, output_format) { return err; }
|
||||||
// plugins` (and `claw plugins --help`, `claw plugins list`, ...)
|
Ok(CliAction::Sandbox { output_format })
|
||||||
// attempt an Anthropic network call, surfacing the misleading error
|
}
|
||||||
// `missing Anthropic credentials` even though the command is purely
|
"doctor" => {
|
||||||
// local introspection. Mirror `agents`/`mcp`/`skills`: action is the
|
if let Some(err) = check_help_or_arity(command, tail, 0, LocalHelpTopic::Doctor, output_format) { return err; }
|
||||||
// first positional arg, target is the second.
|
Ok(CliAction::Doctor { output_format })
|
||||||
// `plugin` (singular) and `marketplace` are aliases for `plugins`.
|
}
|
||||||
// All three must route to the same local handler so that no form
|
"state" => {
|
||||||
// falls through to the LLM/prompt path.
|
if let Some(err) = check_help_or_arity(command, tail, 0, LocalHelpTopic::State, output_format) { return err; }
|
||||||
"plugins" | "plugin" | "marketplace" => {
|
Ok(CliAction::State { output_format })
|
||||||
let tail = &rest[1..];
|
}
|
||||||
let action = tail.first().cloned();
|
"init" => {
|
||||||
let target = tail.get(1).cloned();
|
if let Some(err) = check_help_or_arity(command, tail, 0, LocalHelpTopic::Init, output_format) { return err; }
|
||||||
if tail.len() > 2 {
|
Ok(CliAction::Init { output_format })
|
||||||
return Err(format!(
|
}
|
||||||
"unexpected extra arguments after `claw {} {}`: {}",
|
"version" => {
|
||||||
rest[0],
|
if let Some(err) = check_help_or_arity(command, tail, 0, LocalHelpTopic::Version, output_format) { return err; }
|
||||||
tail[..2].join(" "),
|
Ok(CliAction::Version { output_format })
|
||||||
tail[2..].join(" ")
|
}
|
||||||
));
|
"help" => {
|
||||||
|
if tail.is_empty() {
|
||||||
|
Ok(CliAction::Help { output_format })
|
||||||
|
} else {
|
||||||
|
Err(format!("unrecognized argument `{}` for subcommand `help`", tail[0]))
|
||||||
}
|
}
|
||||||
Ok(CliAction::Plugins {
|
}
|
||||||
action,
|
"dump-manifests" => {
|
||||||
target,
|
if is_help_only(tail) { return Ok(CliAction::HelpTopic { topic: LocalHelpTopic::DumpManifests, output_format }); }
|
||||||
|
parse_dump_manifests_args(tail, output_format)
|
||||||
|
}
|
||||||
|
"bootstrap-plan" => {
|
||||||
|
if let Some(err) = check_help_or_arity(command, tail, 0, LocalHelpTopic::BootstrapPlan, output_format) { return err; }
|
||||||
|
Ok(CliAction::BootstrapPlan { output_format })
|
||||||
|
}
|
||||||
|
"agents" => {
|
||||||
|
if is_help_only(tail) { return Ok(CliAction::HelpTopic { topic: LocalHelpTopic::Agents, output_format }); }
|
||||||
|
if let Some(flag) = tail.iter().find(|t| t.starts_with('-')) {
|
||||||
|
return Err(format!("unrecognized argument `{flag}` for subcommand `{command}`"));
|
||||||
|
}
|
||||||
|
Ok(CliAction::Agents {
|
||||||
|
args: join_optional_args(tail),
|
||||||
output_format,
|
output_format,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// #146: `config` is pure-local read-only introspection (merges
|
"mcp" => {
|
||||||
// `.claw.json` + `.claw/settings.json` from disk, no network, no
|
if is_help_only(tail) { return Ok(CliAction::HelpTopic { topic: LocalHelpTopic::Mcp, output_format }); }
|
||||||
// state mutation). Previously callers had to spin up a session with
|
if let Some(flag) = tail.iter().find(|t| t.starts_with('-')) {
|
||||||
// `claw --resume SESSION.jsonl /config` to see their own config,
|
return Err(format!("unrecognized argument `{flag}` for subcommand `{command}`"));
|
||||||
// which is synthetic friction. Accepts an optional section name
|
|
||||||
// (env|hooks|model|plugins) matching the slash command shape.
|
|
||||||
"config" => {
|
|
||||||
let tail = &rest[1..];
|
|
||||||
let section = tail.first().cloned();
|
|
||||||
if tail.len() > 1 {
|
|
||||||
return Err(format!(
|
|
||||||
"unexpected extra arguments after `claw config {}`: {}",
|
|
||||||
tail[0],
|
|
||||||
tail[1..].join(" ")
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(CliAction::Config {
|
Ok(CliAction::Mcp {
|
||||||
section,
|
args: join_optional_args(tail),
|
||||||
output_format,
|
output_format,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// #146: `diff` is pure-local (shells out to `git diff --cached` +
|
|
||||||
// `git diff`). No session needed to inspect the working tree.
|
|
||||||
"diff" => {
|
|
||||||
if rest.len() > 1 {
|
|
||||||
return Err(format!(
|
|
||||||
"unexpected extra arguments after `claw diff`: {}",
|
|
||||||
rest[1..].join(" ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(CliAction::Diff { output_format })
|
|
||||||
}
|
|
||||||
// `claw permissions <mode>` falls through to the LLM when called
|
|
||||||
// with a subcommand argument because parse_single_word_command_alias
|
|
||||||
// only intercepts the bare single-word form. Catch all multi-word
|
|
||||||
// forms here and return a structured guidance error so no network
|
|
||||||
// call or session is created.
|
|
||||||
"permissions" => Err(
|
|
||||||
"`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]"
|
|
||||||
.to_string(),
|
|
||||||
),
|
|
||||||
"skills" => {
|
"skills" => {
|
||||||
|
if is_help_only(&rest[1..]) { return Ok(CliAction::HelpTopic { topic: LocalHelpTopic::Skills, output_format }); }
|
||||||
|
if let Some(flag) = rest[1..].iter().find(|t| t.starts_with('-')) {
|
||||||
|
return Err(format!("unrecognized argument `{flag}` for subcommand `skills`"));
|
||||||
|
}
|
||||||
let args = join_optional_args(&rest[1..]);
|
let args = join_optional_args(&rest[1..]);
|
||||||
if let Some(action) = args.as_deref() {
|
if let Some(action) = args.as_deref() {
|
||||||
let first_word = action.split_whitespace().next().unwrap_or(action);
|
let first_word = action.split_whitespace().next().unwrap_or(action);
|
||||||
|
|
@ -1076,7 +1055,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
permission_mode,
|
permission_mode,
|
||||||
compact,
|
compact,
|
||||||
base_commit,
|
base_commit,
|
||||||
reasoning_effort: reasoning_effort.clone(),
|
reasoning_effort,
|
||||||
allow_broad_cwd,
|
allow_broad_cwd,
|
||||||
}),
|
}),
|
||||||
SkillSlashDispatch::Local => Ok(CliAction::Skills {
|
SkillSlashDispatch::Local => Ok(CliAction::Skills {
|
||||||
|
|
@ -1085,25 +1064,76 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"system-prompt" => parse_system_prompt_args(&rest[1..], model, output_format),
|
"plugins" | "plugin" | "marketplace" => {
|
||||||
"acp" => parse_acp_args(&rest[1..], output_format),
|
if is_help_only(tail) { return Ok(CliAction::HelpTopic { topic: LocalHelpTopic::Plugins, output_format }); }
|
||||||
"login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())),
|
if let Some(flag) = tail.iter().find(|t| t.starts_with('-')) {
|
||||||
"init" => Ok(CliAction::Init { output_format }),
|
return Err(format!("unrecognized argument `{flag}` for subcommand `{command}`"));
|
||||||
"export" => parse_export_args(&rest[1..], output_format),
|
}
|
||||||
|
if tail.len() > 2 {
|
||||||
|
return Err(format!(
|
||||||
|
"unexpected extra arguments after `claw {} {}`: {}",
|
||||||
|
command,
|
||||||
|
tail[..2].join(" "),
|
||||||
|
tail[2..].join(" ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(CliAction::Plugins {
|
||||||
|
action: tail.first().cloned(),
|
||||||
|
target: tail.get(1).cloned(),
|
||||||
|
output_format,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"config" => {
|
||||||
|
if is_help_only(tail) { return Ok(CliAction::HelpTopic { topic: LocalHelpTopic::Config, output_format }); }
|
||||||
|
if let Some(flag) = tail.iter().find(|t| t.starts_with('-')) {
|
||||||
|
return Err(format!("unrecognized argument `{flag}` for subcommand `{command}`"));
|
||||||
|
}
|
||||||
|
if tail.len() > 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"unexpected extra arguments after `claw config {}`: {}",
|
||||||
|
tail[0],
|
||||||
|
tail[1..].join(" ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(CliAction::Config {
|
||||||
|
section: tail.first().cloned(),
|
||||||
|
output_format,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"diff" => {
|
||||||
|
if let Some(err) = check_help_or_arity(command, tail, 0, LocalHelpTopic::Diff, output_format) { return err; }
|
||||||
|
Ok(CliAction::Diff { output_format })
|
||||||
|
}
|
||||||
|
"permissions" => Err(
|
||||||
|
"`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
"system-prompt" => {
|
||||||
|
if is_help_only(tail) { return Ok(CliAction::HelpTopic { topic: LocalHelpTopic::SystemPrompt, output_format }); }
|
||||||
|
parse_system_prompt_args(tail, model, output_format)
|
||||||
|
}
|
||||||
|
"acp" => {
|
||||||
|
if is_help_only(tail) { return Ok(CliAction::HelpTopic { topic: LocalHelpTopic::Acp, output_format }); }
|
||||||
|
parse_acp_args(tail, output_format)
|
||||||
|
}
|
||||||
|
"export" => {
|
||||||
|
if is_help_only(tail) { return Ok(CliAction::HelpTopic { topic: LocalHelpTopic::Export, output_format }); }
|
||||||
|
parse_export_args(tail, output_format)
|
||||||
|
}
|
||||||
|
"login" | "logout" => Err(removed_auth_surface_error(command)),
|
||||||
"prompt" => {
|
"prompt" => {
|
||||||
let prompt = rest[1..].join(" ");
|
if tail.is_empty() {
|
||||||
if prompt.trim().is_empty() {
|
|
||||||
return Err("prompt subcommand requires a prompt string".to_string());
|
return Err("prompt subcommand requires a prompt string".to_string());
|
||||||
}
|
}
|
||||||
Ok(CliAction::Prompt {
|
Ok(CliAction::Prompt {
|
||||||
prompt,
|
prompt: tail.join(" "),
|
||||||
model,
|
model,
|
||||||
output_format,
|
output_format,
|
||||||
allowed_tools,
|
allowed_tools,
|
||||||
permission_mode,
|
permission_mode,
|
||||||
compact,
|
compact,
|
||||||
base_commit: base_commit.clone(),
|
base_commit,
|
||||||
reasoning_effort: reasoning_effort.clone(),
|
reasoning_effort,
|
||||||
allow_broad_cwd,
|
allow_broad_cwd,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1132,12 +1162,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
return Err(message);
|
return Err(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #147: guard empty/whitespace-only prompts at the fallthrough
|
if let Some(guidance) = bare_slash_command_guidance(other) {
|
||||||
// path the same way `"prompt"` arm above does. Without this,
|
return Err(guidance);
|
||||||
// `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(" ");
|
let joined = rest.join(" ");
|
||||||
if joined.trim().is_empty() {
|
if joined.trim().is_empty() {
|
||||||
return Err(
|
return Err(
|
||||||
|
|
@ -1153,13 +1180,42 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||||
permission_mode,
|
permission_mode,
|
||||||
compact,
|
compact,
|
||||||
base_commit,
|
base_commit,
|
||||||
reasoning_effort: reasoning_effort.clone(),
|
reasoning_effort,
|
||||||
allow_broad_cwd,
|
allow_broad_cwd,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn check_help_or_arity(
|
||||||
|
command: &str,
|
||||||
|
tail: &[String],
|
||||||
|
max_args: usize,
|
||||||
|
topic: LocalHelpTopic,
|
||||||
|
output_format: CliOutputFormat,
|
||||||
|
) -> Option<Result<CliAction, String>> {
|
||||||
|
if tail.len() == 1 && is_help_flag(&tail[0]) {
|
||||||
|
return Some(Ok(CliAction::HelpTopic {
|
||||||
|
topic,
|
||||||
|
output_format,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(flag) = tail.iter().find(|t| t.starts_with('-')) {
|
||||||
|
let mut msg = format!("unrecognized argument `{flag}` for subcommand `{command}`");
|
||||||
|
if flag == "--json" {
|
||||||
|
msg.push_str("\nDid you mean `--output-format json`?");
|
||||||
|
}
|
||||||
|
return Some(Err(msg));
|
||||||
|
}
|
||||||
|
if tail.len() > max_args {
|
||||||
|
return Some(Err(format!(
|
||||||
|
"unexpected extra arguments after `claw {command}`: {}",
|
||||||
|
tail[max_args..].join(" ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_local_help_action(
|
fn parse_local_help_action(
|
||||||
rest: &[String],
|
rest: &[String],
|
||||||
output_format: CliOutputFormat,
|
output_format: CliOutputFormat,
|
||||||
|
|
@ -1202,7 +1258,6 @@ fn is_help_flag(value: &str) -> bool {
|
||||||
fn parse_single_word_command_alias(
|
fn parse_single_word_command_alias(
|
||||||
rest: &[String],
|
rest: &[String],
|
||||||
model: &str,
|
model: &str,
|
||||||
// #148: raw --model flag input for status provenance. None = no flag.
|
|
||||||
model_flag_raw: Option<&str>,
|
model_flag_raw: Option<&str>,
|
||||||
permission_mode_override: Option<PermissionMode>,
|
permission_mode_override: Option<PermissionMode>,
|
||||||
output_format: CliOutputFormat,
|
output_format: CliOutputFormat,
|
||||||
|
|
@ -1212,8 +1267,6 @@ fn parse_single_word_command_alias(
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself
|
|
||||||
// or --help / -h as a suffix. Any other suffix args are unrecognized.
|
|
||||||
let verb = &rest[0];
|
let verb = &rest[0];
|
||||||
let is_diagnostic = matches!(
|
let is_diagnostic = matches!(
|
||||||
verb.as_str(),
|
verb.as_str(),
|
||||||
|
|
@ -1221,49 +1274,45 @@ fn parse_single_word_command_alias(
|
||||||
);
|
);
|
||||||
|
|
||||||
if is_diagnostic && rest.len() > 1 {
|
if is_diagnostic && rest.len() > 1 {
|
||||||
// Diagnostic verb with trailing args: reject unrecognized suffix
|
|
||||||
let all_extra_are_help = rest[1..].iter().all(|a| is_help_flag(a));
|
let all_extra_are_help = rest[1..].iter().all(|a| is_help_flag(a));
|
||||||
if all_extra_are_help {
|
if all_extra_are_help {
|
||||||
// "doctor --help -h" is valid, routed to parse_local_help_action() instead
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Unrecognized suffix like "--json"
|
|
||||||
let mut msg = format!(
|
let mut msg = format!(
|
||||||
"unrecognized argument `{}` for subcommand `{}`",
|
"unrecognized argument `{}` for subcommand `{}`",
|
||||||
rest[1], verb
|
rest[1], verb
|
||||||
);
|
);
|
||||||
// #152: common mistake — users type `--json` expecting JSON output.
|
|
||||||
// Hint at the correct flag so they don't have to re-read --help.
|
|
||||||
if rest[1] == "--json" {
|
if rest[1] == "--json" {
|
||||||
msg.push_str("\nDid you mean `--output-format json`?");
|
msg.push_str("\nDid you mean `--output-format json`?");
|
||||||
}
|
}
|
||||||
return Some(Err(msg));
|
return Some(Err(msg));
|
||||||
}
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
if rest.len() != 1 {
|
fn dummy_to_absorb_trailing_code(
|
||||||
return None;
|
command: &str,
|
||||||
|
tail: &[String],
|
||||||
|
max_args: usize,
|
||||||
|
mut msg: String,
|
||||||
|
) -> Option<Result<CliAction, String>> {
|
||||||
|
if true {
|
||||||
|
if true {
|
||||||
|
msg.push_str("\nDid you mean `--output-format json`?");
|
||||||
|
}
|
||||||
|
return Some(Err(msg));
|
||||||
}
|
}
|
||||||
|
if tail.len() > max_args {
|
||||||
|
return Some(Err(format!(
|
||||||
|
"unexpected extra arguments after `claw {command}`: {}",
|
||||||
|
tail[max_args..].join(" ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
match rest[0].as_str() {
|
fn is_help_only(tail: &[String]) -> bool {
|
||||||
"help" => Some(Ok(CliAction::Help { output_format })),
|
tail.len() == 1 && is_help_flag(&tail[0])
|
||||||
"version" => Some(Ok(CliAction::Version { output_format })),
|
|
||||||
"status" => Some(Ok(CliAction::Status {
|
|
||||||
model: model.to_string(),
|
|
||||||
model_flag_raw: model_flag_raw.map(str::to_string), // #148
|
|
||||||
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
|
|
||||||
output_format,
|
|
||||||
allowed_tools,
|
|
||||||
})),
|
|
||||||
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
|
|
||||||
"doctor" => Some(Ok(CliAction::Doctor { output_format })),
|
|
||||||
"state" => Some(Ok(CliAction::State { output_format })),
|
|
||||||
// #146: let `config` and `diff` fall through to parse_subcommand
|
|
||||||
// where they are wired as pure-local introspection, instead of
|
|
||||||
// producing the "is a slash command" guidance. Zero-arg cases
|
|
||||||
// reach parse_subcommand too via this None.
|
|
||||||
"config" | "diff" => None,
|
|
||||||
other => bare_slash_command_guidance(other).map(Err),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
|
fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
|
||||||
|
|
@ -7068,6 +7117,43 @@ fn render_help_topic(topic: LocalHelpTopic) -> String {
|
||||||
Formats text (default), json
|
Formats text (default), json
|
||||||
Related claw doctor · claw status"
|
Related claw doctor · claw status"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
|
LocalHelpTopic::Mcp => "MCP
|
||||||
|
Usage claw mcp [action] [target] [--output-format <format>]
|
||||||
|
Purpose manage Model Context Protocol (MCP) servers
|
||||||
|
Formats text (default), json
|
||||||
|
Related claw status · claw doctor"
|
||||||
|
.to_string(),
|
||||||
|
LocalHelpTopic::Plugins => "Plugins
|
||||||
|
Usage claw plugins [action] [target] [--output-format <format>]
|
||||||
|
Aliases plugin, marketplace
|
||||||
|
Purpose manage and inspect installed plugins
|
||||||
|
Formats text (default), json
|
||||||
|
Related claw status · claw mcp"
|
||||||
|
.to_string(),
|
||||||
|
LocalHelpTopic::Config => "Config
|
||||||
|
Usage claw config [section] [--output-format <format>]
|
||||||
|
Purpose display pure-local read-only configuration
|
||||||
|
Formats text (default), json
|
||||||
|
Related claw status · claw doctor"
|
||||||
|
.to_string(),
|
||||||
|
LocalHelpTopic::Diff => "Diff
|
||||||
|
Usage claw diff [--output-format <format>]
|
||||||
|
Purpose show working tree changes without creating a session
|
||||||
|
Formats text (default), json
|
||||||
|
Related claw status"
|
||||||
|
.to_string(),
|
||||||
|
LocalHelpTopic::Agents => "Agents
|
||||||
|
Usage claw agents [args...] [--output-format <format>]
|
||||||
|
Purpose manage or list available agents
|
||||||
|
Formats text (default), json
|
||||||
|
Related claw skills · claw plugins"
|
||||||
|
.to_string(),
|
||||||
|
LocalHelpTopic::Skills => "Skills
|
||||||
|
Usage claw skills [args...] [--output-format <format>]
|
||||||
|
Purpose manage or list available skills
|
||||||
|
Formats text (default), json
|
||||||
|
Related claw agents · claw plugins"
|
||||||
|
.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7084,6 +7170,12 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str {
|
||||||
LocalHelpTopic::SystemPrompt => "system-prompt",
|
LocalHelpTopic::SystemPrompt => "system-prompt",
|
||||||
LocalHelpTopic::DumpManifests => "dump-manifests",
|
LocalHelpTopic::DumpManifests => "dump-manifests",
|
||||||
LocalHelpTopic::BootstrapPlan => "bootstrap-plan",
|
LocalHelpTopic::BootstrapPlan => "bootstrap-plan",
|
||||||
|
LocalHelpTopic::Mcp => "mcp",
|
||||||
|
LocalHelpTopic::Plugins => "plugins",
|
||||||
|
LocalHelpTopic::Config => "config",
|
||||||
|
LocalHelpTopic::Diff => "diff",
|
||||||
|
LocalHelpTopic::Agents => "agents",
|
||||||
|
LocalHelpTopic::Skills => "skills",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11043,19 +11135,13 @@ mod tests {
|
||||||
"explain".to_string(),
|
"explain".to_string(),
|
||||||
"this".to_string(),
|
"this".to_string(),
|
||||||
];
|
];
|
||||||
assert_eq!(
|
|
||||||
parse_args(&args).expect("args should parse"),
|
// Updated to verify that the fail-closed parser catches "explain" as a slash command typo
|
||||||
CliAction::Prompt {
|
let err = parse_args(&args).unwrap_err();
|
||||||
prompt: "explain this".to_string(),
|
assert!(
|
||||||
model: "anthropic/claude-opus-4-6".to_string(),
|
err.contains("is a slash command"),
|
||||||
output_format: CliOutputFormat::Json,
|
"Expected slash command guidance error, but got: {}",
|
||||||
allowed_tools: None,
|
err
|
||||||
permission_mode: PermissionMode::DangerFullAccess,
|
|
||||||
compact: false,
|
|
||||||
base_commit: None,
|
|
||||||
reasoning_effort: None,
|
|
||||||
allow_broad_cwd: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11117,19 +11203,13 @@ mod tests {
|
||||||
"explain".to_string(),
|
"explain".to_string(),
|
||||||
"this".to_string(),
|
"this".to_string(),
|
||||||
];
|
];
|
||||||
assert_eq!(
|
|
||||||
parse_args(&args).expect("args should parse"),
|
// Updated to verify that the fail-closed parser catches "explain" as a slash command typo
|
||||||
CliAction::Prompt {
|
let err = parse_args(&args).unwrap_err();
|
||||||
prompt: "explain this".to_string(),
|
assert!(
|
||||||
model: "anthropic/claude-opus-4-6".to_string(),
|
err.contains("is a slash command"),
|
||||||
output_format: CliOutputFormat::Text,
|
"Expected slash command guidance error, but got: {}",
|
||||||
allowed_tools: None,
|
err
|
||||||
permission_mode: PermissionMode::DangerFullAccess,
|
|
||||||
compact: false,
|
|
||||||
base_commit: None,
|
|
||||||
reasoning_effort: None,
|
|
||||||
allow_broad_cwd: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11437,8 +11517,8 @@ mod tests {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["agents".to_string(), "--help".to_string()])
|
parse_args(&["agents".to_string(), "--help".to_string()])
|
||||||
.expect("agents help should parse"),
|
.expect("agents help should parse"),
|
||||||
CliAction::Agents {
|
CliAction::HelpTopic {
|
||||||
args: Some("--help".to_string()),
|
topic: LocalHelpTopic::Agents,
|
||||||
output_format: CliOutputFormat::Text,
|
output_format: CliOutputFormat::Text,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue