Refactor CLI parser to strict fail-closed architecture

This commit is contained in:
Marc 2026-05-21 14:02:31 -05:00 committed by MagersCode
parent 63a5a87471
commit a5e6ca70a6
1 changed files with 287 additions and 207 deletions

View File

@ -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,
} }
); );