claw-code/rust/crates/rusty-claude-cli/src/args.rs

1228 lines
44 KiB
Rust

use std::env;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use api::ToolDefinition;
use commands::{
classify_skills_slash_command, resolve_skill_invocation, slash_command_specs,
SkillSlashDispatch, SlashCommand,
};
use runtime::{ConfigLoader, PermissionMode, ResolvedPermissionMode};
use serde_json::{json, Value};
use tools::GlobalToolRegistry;
use crate::build_runtime_plugin_state_with_loader;
use crate::constants::*;
use crate::looks_like_slash_command_token;
use crate::model::*;
use crate::normalize_permission_mode;
use crate::resume_command_can_absorb_token;
pub(crate) enum CliAction {
DumpManifests {
output_format: CliOutputFormat,
manifests_dir: Option<PathBuf>,
},
BootstrapPlan {
output_format: CliOutputFormat,
},
Agents {
args: Option<String>,
output_format: CliOutputFormat,
},
Mcp {
args: Option<String>,
output_format: CliOutputFormat,
},
Skills {
args: Option<String>,
output_format: CliOutputFormat,
},
Plugins {
action: Option<String>,
target: Option<String>,
output_format: CliOutputFormat,
},
PrintSystemPrompt {
cwd: PathBuf,
date: String,
output_format: CliOutputFormat,
},
Version {
output_format: CliOutputFormat,
},
ResumeSession {
session_path: PathBuf,
commands: Vec<String>,
output_format: CliOutputFormat,
},
Status {
model: String,
// #148: raw `--model` flag input (pre-alias-resolution), if any.
// None means no flag was supplied; env/config/default fallback is
// resolved inside `print_status_snapshot`.
model_flag_raw: Option<String>,
permission_mode: PermissionMode,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
},
Sandbox {
output_format: CliOutputFormat,
},
Prompt {
prompt: String,
model: String,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
compact: bool,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
},
Doctor {
output_format: CliOutputFormat,
},
Acp {
output_format: CliOutputFormat,
},
State {
output_format: CliOutputFormat,
},
Init {
output_format: CliOutputFormat,
},
// #146: `claw config` and `claw diff` are pure-local read-only
// introspection commands; wire them as standalone CLI subcommands.
Config {
section: Option<String>,
output_format: CliOutputFormat,
},
Diff {
output_format: CliOutputFormat,
},
Export {
session_reference: String,
output_path: Option<PathBuf>,
output_format: CliOutputFormat,
},
Repl {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
},
HelpTopic(LocalHelpTopic),
// prompt-mode formatting is only supported for non-interactive runs
Help {
output_format: CliOutputFormat,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LocalHelpTopic {
Status,
Sandbox,
Doctor,
Acp,
// #141: extend the local-help pattern to every subcommand so
// `claw <subcommand> --help` has one consistent contract.
Init,
State,
Export,
Version,
SystemPrompt,
DumpManifests,
BootstrapPlan,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CliOutputFormat {
Text,
Json,
}
impl CliOutputFormat {
fn parse(value: &str) -> Result<Self, String> {
match value {
"text" => Ok(Self::Text),
"json" => Ok(Self::Json),
other => Err(format!(
"unsupported value for --output-format: {other} (expected text or json)"
)),
}
}
}
#[allow(clippy::too_many_lines)]
pub(crate) fn parse_args(args: &[String]) -> Result<CliAction, 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 output_format = CliOutputFormat::Text;
let mut permission_mode_override = None;
let mut wants_help = false;
let mut wants_version = false;
let mut allowed_tool_values = Vec::new();
let mut compact = false;
let mut base_commit: Option<String> = None;
let mut reasoning_effort: Option<String> = None;
let mut allow_broad_cwd = false;
let mut rest: Vec<String> = Vec::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--help" | "-h" if rest.is_empty() => {
wants_help = true;
index += 1;
}
"--help" | "-h"
if !rest.is_empty()
&& 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;
}
"--version" | "-V" => {
wants_version = true;
index += 1;
}
"--model" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?;
validate_model_syntax(value)?;
model = resolve_model_alias_with_config(value);
model_flag_raw = Some(value.clone()); // #148
index += 2;
}
flag if flag.starts_with("--model=") => {
let value = &flag[8..];
validate_model_syntax(value)?;
model = resolve_model_alias_with_config(value);
model_flag_raw = Some(value.to_string()); // #148
index += 1;
}
"--output-format" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --output-format".to_string())?;
output_format = CliOutputFormat::parse(value)?;
index += 2;
}
"--permission-mode" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --permission-mode".to_string())?;
permission_mode_override = Some(parse_permission_mode_arg(value)?);
index += 2;
}
flag if flag.starts_with("--output-format=") => {
output_format = CliOutputFormat::parse(&flag[16..])?;
index += 1;
}
flag if flag.starts_with("--permission-mode=") => {
permission_mode_override = Some(parse_permission_mode_arg(&flag[18..])?);
index += 1;
}
"--dangerously-skip-permissions" => {
permission_mode_override = Some(PermissionMode::DangerFullAccess);
index += 1;
}
"--compact" => {
compact = true;
index += 1;
}
"--base-commit" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --base-commit".to_string())?;
base_commit = Some(value.clone());
index += 2;
}
flag if flag.starts_with("--base-commit=") => {
base_commit = Some(flag[14..].to_string());
index += 1;
}
"--reasoning-effort" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --reasoning-effort".to_string())?;
if !matches!(value.as_str(), "low" | "medium" | "high") {
return Err(format!(
"invalid value for --reasoning-effort: '{value}'; must be low, medium, or high"
));
}
reasoning_effort = Some(value.clone());
index += 2;
}
flag if flag.starts_with("--reasoning-effort=") => {
let value = &flag[19..];
if !matches!(value, "low" | "medium" | "high") {
return Err(format!(
"invalid value for --reasoning-effort: '{value}'; must be low, medium, or high"
));
}
reasoning_effort = Some(value.to_string());
index += 1;
}
"--allow-broad-cwd" => {
allow_broad_cwd = true;
index += 1;
}
"-p" => {
// Claw Code compat: -p "prompt" = one-shot prompt
let prompt = args[index + 1..].join(" ");
if prompt.trim().is_empty() {
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,
});
}
"--print" => {
// Claw Code compat: --print makes output non-interactive
output_format = CliOutputFormat::Text;
index += 1;
}
"--resume" if rest.is_empty() => {
rest.push("--resume".to_string());
index += 1;
}
flag if rest.is_empty() && flag.starts_with("--resume=") => {
rest.push("--resume".to_string());
rest.push(flag[9..].to_string());
index += 1;
}
"--acp" | "-acp" => {
rest.push("acp".to_string());
index += 1;
}
"--allowedTools" | "--allowed-tools" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --allowedTools".to_string())?;
allowed_tool_values.push(value.clone());
index += 2;
}
flag if flag.starts_with("--allowedTools=") => {
allowed_tool_values.push(flag[15..].to_string());
index += 1;
}
flag if flag.starts_with("--allowed-tools=") => {
allowed_tool_values.push(flag[16..].to_string());
index += 1;
}
other if rest.is_empty() && other.starts_with('-') => {
return Err(format_unknown_option(other))
}
other => {
rest.push(other.to_string());
index += 1;
}
}
}
if wants_help {
return Ok(CliAction::Help { output_format });
}
if wants_version {
return Ok(CliAction::Version { output_format });
}
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
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
// 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 _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf);
let piped = buf.trim().to_string();
if !piped.is_empty() {
return Ok(CliAction::Prompt {
model,
prompt: piped,
allowed_tools,
permission_mode,
output_format,
compact: false,
base_commit,
reasoning_effort,
allow_broad_cwd,
});
}
}
return Ok(CliAction::Repl {
model,
allowed_tools,
permission_mode,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
});
}
if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..], output_format);
}
if let Some(action) = parse_local_help_action(&rest) {
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);
match rest[0].as_str() {
"dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
"agents" => Ok(CliAction::Agents {
args: join_optional_args(&rest[1..]),
output_format,
}),
"mcp" => Ok(CliAction::Mcp {
args: join_optional_args(&rest[1..]),
output_format,
}),
// #145: `plugins` was routed through the prompt fallback because no
// top-level parser arm produced CliAction::Plugins. That made `claw
// plugins` (and `claw plugins --help`, `claw plugins list`, ...)
// attempt an Anthropic network call, surfacing the misleading error
// `missing Anthropic credentials` even though the command is purely
// local introspection. Mirror `agents`/`mcp`/`skills`: action is the
// first positional arg, target is the second.
"plugins" => {
let tail = &rest[1..];
let action = tail.first().cloned();
let target = tail.get(1).cloned();
if tail.len() > 2 {
return Err(format!(
"unexpected extra arguments after `claw plugins {}`: {}",
tail[..2].join(" "),
tail[2..].join(" ")
));
}
Ok(CliAction::Plugins {
action,
target,
output_format,
})
}
// #146: `config` is pure-local read-only introspection (merges
// `.claw.json` + `.claw/settings.json` from disk, no network, no
// state mutation). Previously callers had to spin up a session with
// `claw --resume SESSION.jsonl /config` to see their own config,
// 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 {
section,
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 })
}
"skills" => {
let args = join_optional_args(&rest[1..]);
match classify_skills_slash_command(args.as_deref()) {
SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
}),
SkillSlashDispatch::Local => Ok(CliAction::Skills {
args,
output_format,
}),
}
}
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
"acp" => parse_acp_args(&rest[1..], output_format),
"login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())),
"init" => Ok(CliAction::Init { output_format }),
"export" => parse_export_args(&rest[1..], output_format),
"prompt" => {
let prompt = rest[1..].join(" ");
if prompt.trim().is_empty() {
return Err("prompt subcommand requires a prompt string".to_string());
}
Ok(CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit: base_commit.clone(),
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
})
}
other if other.starts_with('/') => parse_direct_slash_cli_action(
&rest,
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit,
reasoning_effort,
allow_broad_cwd,
),
other => {
if rest.len() == 1 && looks_like_subcommand_typo(other) {
if let Some(suggestions) = suggest_similar_subcommand(other) {
let mut message = format!("unknown subcommand: {other}.");
if let Some(line) = render_suggestion_line("Did you mean", &suggestions) {
message.push('\n');
message.push_str(&line);
}
message.push_str(
"\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt <text>`.",
);
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: joined,
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
})
}
}
}
pub(crate) fn parse_local_help_action(rest: &[String]) -> Option<Result<CliAction, String>> {
if rest.len() != 2 || !is_help_flag(&rest[1]) {
return None;
}
let topic = match rest[0].as_str() {
"status" => LocalHelpTopic::Status,
"sandbox" => LocalHelpTopic::Sandbox,
"doctor" => LocalHelpTopic::Doctor,
"acp" => LocalHelpTopic::Acp,
// #141: add the subcommands that were previously falling back
// to global help (init/state/export/version) or erroring out
// (system-prompt/dump-manifests) or printing their primary
// output instead of help text (bootstrap-plan).
"init" => LocalHelpTopic::Init,
"state" => LocalHelpTopic::State,
"export" => LocalHelpTopic::Export,
"version" => LocalHelpTopic::Version,
"system-prompt" => LocalHelpTopic::SystemPrompt,
"dump-manifests" => LocalHelpTopic::DumpManifests,
"bootstrap-plan" => LocalHelpTopic::BootstrapPlan,
_ => return None,
};
Some(Ok(CliAction::HelpTopic(topic)))
}
pub(crate) fn is_help_flag(value: &str) -> bool {
matches!(value, "--help" | "-h")
}
pub(crate) fn parse_single_word_command_alias(
rest: &[String],
model: &str,
// #148: raw --model flag input for status provenance. None = no flag.
model_flag_raw: Option<&str>,
permission_mode_override: Option<PermissionMode>,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
) -> Option<Result<CliAction, String>> {
if rest.is_empty() {
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 is_diagnostic = matches!(
verb.as_str(),
"help" | "version" | "status" | "sandbox" | "doctor" | "state"
);
if is_diagnostic && rest.len() > 1 {
// Diagnostic verb with trailing args: reject unrecognized suffix
if is_help_flag(&rest[1]) && rest.len() == 2 {
// "doctor --help" is valid, routed to parse_local_help_action() instead
return None;
}
// Unrecognized suffix like "--json"
let mut msg = format!(
"unrecognized argument `{}` for subcommand `{}`",
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" {
msg.push_str("\nDid you mean `--output-format json`?");
}
return Some(Err(msg));
}
if rest.len() != 1 {
return None;
}
match rest[0].as_str() {
"help" => Some(Ok(CliAction::Help { output_format })),
"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),
}
}
pub(crate) fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
if matches!(
command_name,
"dump-manifests"
| "bootstrap-plan"
| "agents"
| "mcp"
| "skills"
| "system-prompt"
| "init"
| "prompt"
| "export"
) {
return None;
}
let slash_command = slash_command_specs()
.iter()
.find(|spec| spec.name == command_name)?;
let guidance = if slash_command.resume_supported {
format!(
"`claw {command_name}` is a slash command. Use `claw --resume SESSION.jsonl /{command_name}` or start `claw` and run `/{command_name}`."
)
} else {
format!(
"`claw {command_name}` is a slash command. Start `claw` and run `/{command_name}` inside the REPL."
)
};
Some(guidance)
}
pub(crate) fn removed_auth_surface_error(command_name: &str) -> String {
format!(
"`claw {command_name}` has been removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead."
)
}
pub(crate) fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
match args {
[] => Ok(CliAction::Acp { output_format }),
[subcommand] if subcommand == "serve" => Ok(CliAction::Acp { output_format }),
_ => Err(String::from(
"unsupported ACP invocation. Use `claw acp`, `claw acp serve`, `claw --acp`, or `claw -acp`.",
)),
}
}
pub(crate) fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option<String> {
let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default();
let looks_like_skill_name = !bare_first_token.is_empty()
&& !bare_first_token.starts_with('/')
&& bare_first_token
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_');
if !looks_like_skill_name {
return None;
}
match resolve_skill_invocation(cwd, Some(trimmed)) {
Ok(SkillSlashDispatch::Invoke(prompt)) => Some(prompt),
_ => None,
}
}
pub(crate) fn join_optional_args(args: &[String]) -> Option<String> {
let joined = args.join(" ");
let trimmed = joined.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
pub(crate) fn parse_direct_slash_cli_action(
rest: &[String],
model: String,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
compact: bool,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
) -> Result<CliAction, String> {
let raw = rest.join(" ");
match SlashCommand::parse(&raw) {
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }),
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents {
args,
output_format,
}),
Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp {
args: match (action, target) {
(None, None) => None,
(Some(action), None) => Some(action),
(Some(action), Some(target)) => Some(format!("{action} {target}")),
(None, Some(target)) => Some(target),
},
output_format,
}),
Ok(Some(SlashCommand::Skills { args })) => {
match classify_skills_slash_command(args.as_deref()) {
SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt {
prompt,
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
}),
SkillSlashDispatch::Local => Ok(CliAction::Skills {
args,
output_format,
}),
}
}
Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)),
Ok(Some(command)) => Err({
let _ = command;
format!(
"slash command {command_name} is interactive-only. Start `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.",
command_name = rest[0],
latest = LATEST_SESSION_REFERENCE,
)
}),
Ok(None) => Err(format!("unknown subcommand: {}", rest[0])),
Err(error) => Err(error.to_string()),
}
}
pub(crate) fn format_unknown_option(option: &str) -> String {
let mut message = format!("unknown option: {option}");
if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) {
message.push_str("\nDid you mean ");
message.push_str(suggestion);
message.push('?');
}
message.push_str("\nRun `claw --help` for usage.");
message
}
pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String {
let mut message = format!("unknown slash command outside the REPL: /{name}");
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
{
message.push('\n');
message.push_str(&suggestions);
}
if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) {
message.push('\n');
message.push_str(note);
}
message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help.");
message
}
pub(crate) fn format_unknown_slash_command(name: &str) -> String {
let mut message = format!("Unknown slash command: /{name}");
if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name))
{
message.push('\n');
message.push_str(&suggestions);
}
if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) {
message.push('\n');
message.push_str(note);
}
message.push_str("\n Help /help lists available slash commands");
message
}
pub(crate) fn omc_compatibility_note_for_unknown_slash_command(name: &str) -> Option<&'static str> {
name.starts_with("oh-my-claudecode:")
.then_some(
"Compatibility note: `/oh-my-claudecode:*` is a Claude Code/OMC plugin command. `claw` does not yet load plugin slash commands, Claude statusline stdin, or OMC session hooks.",
)
}
pub(crate) fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option<String> {
(!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),))
}
pub(crate) fn suggest_slash_commands(input: &str) -> Vec<String> {
let mut candidates = slash_command_specs()
.iter()
.flat_map(|spec| {
std::iter::once(spec.name)
.chain(spec.aliases.iter().copied())
.map(|name| format!("/{name}"))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
candidates.sort();
candidates.dedup();
let candidate_refs = candidates.iter().map(String::as_str).collect::<Vec<_>>();
ranked_suggestions(input.trim_start_matches('/'), &candidate_refs)
.into_iter()
.map(str::to_string)
.collect()
}
pub(crate) fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> {
ranked_suggestions(input, candidates).into_iter().next()
}
pub(crate) fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
const KNOWN_SUBCOMMANDS: &[&str] = &[
"help",
"version",
"status",
"sandbox",
"doctor",
"state",
"dump-manifests",
"bootstrap-plan",
"agents",
"mcp",
"skills",
"system-prompt",
"acp",
"init",
"export",
"prompt",
];
let normalized_input = input.to_ascii_lowercase();
let mut ranked = KNOWN_SUBCOMMANDS
.iter()
.filter_map(|candidate| {
let normalized_candidate = candidate.to_ascii_lowercase();
let distance = levenshtein_distance(&normalized_input, &normalized_candidate);
let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4;
let substring_match = normalized_candidate.contains(&normalized_input)
|| normalized_input.contains(&normalized_candidate);
((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
ranked.dedup_by(|left, right| left.1 == right.1);
let suggestions = ranked
.into_iter()
.map(|(_, candidate)| candidate.to_string())
.take(3)
.collect::<Vec<_>>();
(!suggestions.is_empty()).then_some(suggestions)
}
pub(crate) fn common_prefix_len(left: &str, right: &str) -> usize {
left.chars()
.zip(right.chars())
.take_while(|(l, r)| l == r)
.count()
}
pub(crate) fn looks_like_subcommand_typo(input: &str) -> bool {
!input.is_empty()
&& input
.chars()
.all(|ch| ch.is_ascii_alphabetic() || ch == '-')
}
pub(crate) fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> {
let normalized_input = input.trim_start_matches('/').to_ascii_lowercase();
let mut ranked = candidates
.iter()
.filter_map(|candidate| {
let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase();
let distance = levenshtein_distance(&normalized_input, &normalized_candidate);
let prefix_bonus = usize::from(
!(normalized_candidate.starts_with(&normalized_input)
|| normalized_input.starts_with(&normalized_candidate)),
);
let score = distance + prefix_bonus;
(score <= 4).then_some((score, *candidate))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
ranked
.into_iter()
.map(|(_, candidate)| candidate)
.take(3)
.collect()
}
pub(crate) fn levenshtein_distance(left: &str, right: &str) -> usize {
if left.is_empty() {
return right.chars().count();
}
if right.is_empty() {
return left.chars().count();
}
let right_chars = right.chars().collect::<Vec<_>>();
let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
let mut current = vec![0; right_chars.len() + 1];
for (left_index, left_char) in left.chars().enumerate() {
current[0] = left_index + 1;
for (right_index, right_char) in right_chars.iter().enumerate() {
let substitution_cost = usize::from(left_char != *right_char);
current[right_index + 1] = (previous[right_index + 1] + 1)
.min(current[right_index] + 1)
.min(previous[right_index] + substitution_cost);
}
previous.clone_from(&current);
}
previous[right_chars.len()]
}
pub(crate) fn normalize_allowed_tools(values: &[String]) -> Result<Option<AllowedToolSet>, String> {
if values.is_empty() {
return Ok(None);
}
current_tool_registry()?.normalize_allowed_tools(values)
}
pub(crate) fn current_tool_registry() -> Result<GlobalToolRegistry, String> {
let cwd = env::current_dir().map_err(|error| error.to_string())?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load().map_err(|error| error.to_string())?;
let state = build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config)
.map_err(|error| error.to_string())?;
let registry = state.tool_registry.clone();
if let Some(mcp_state) = state.mcp_state {
mcp_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.shutdown()
.map_err(|error| error.to_string())?;
}
Ok(registry)
}
pub(crate) fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
normalize_permission_mode(value)
.ok_or_else(|| {
format!(
"unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access."
)
})
.map(permission_mode_from_label)
}
pub(crate) fn permission_mode_from_label(mode: &str) -> PermissionMode {
match mode {
"read-only" => PermissionMode::ReadOnly,
"workspace-write" => PermissionMode::WorkspaceWrite,
"danger-full-access" => PermissionMode::DangerFullAccess,
other => panic!("unsupported permission mode label: {other}"),
}
}
pub(crate) fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode {
match mode {
ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly,
ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite,
ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess,
}
}
pub(crate) fn default_permission_mode() -> PermissionMode {
env::var("RUSTY_CLAUDE_PERMISSION_MODE")
.ok()
.as_deref()
.and_then(normalize_permission_mode)
.map(permission_mode_from_label)
.or_else(config_permission_mode_for_current_dir)
.unwrap_or(PermissionMode::DangerFullAccess)
}
pub(crate) fn config_permission_mode_for_current_dir() -> Option<PermissionMode> {
let cwd = env::current_dir().ok()?;
let loader = ConfigLoader::default_for(&cwd);
loader
.load()
.ok()?
.permission_mode()
.map(permission_mode_from_resolved)
}
pub(crate) fn filter_tool_specs(
tool_registry: &GlobalToolRegistry,
allowed_tools: Option<&AllowedToolSet>,
) -> Vec<ToolDefinition> {
tool_registry.definitions(allowed_tools)
}
pub(crate) fn parse_system_prompt_args(
args: &[String],
output_format: CliOutputFormat,
) -> Result<CliAction, String> {
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
let mut date = DEFAULT_DATE.to_string();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--cwd" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --cwd".to_string())?;
cwd = PathBuf::from(value);
index += 2;
}
"--date" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --date".to_string())?;
date.clone_from(value);
index += 2;
}
other => {
// #152: hint `--output-format json` when user types `--json`.
let mut msg = format!("unknown system-prompt option: {other}");
if other == "--json" {
msg.push_str("\nDid you mean `--output-format json`?");
}
return Err(msg);
}
}
}
Ok(CliAction::PrintSystemPrompt {
cwd,
date,
output_format,
})
}
pub(crate) fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
let mut session_reference = LATEST_SESSION_REFERENCE.to_string();
let mut output_path: Option<PathBuf> = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--session" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --session".to_string())?;
session_reference.clone_from(value);
index += 2;
}
flag if flag.starts_with("--session=") => {
session_reference = flag[10..].to_string();
index += 1;
}
"--output" | "-o" => {
let value = args
.get(index + 1)
.ok_or_else(|| format!("missing value for {}", args[index]))?;
output_path = Some(PathBuf::from(value));
index += 2;
}
flag if flag.starts_with("--output=") => {
output_path = Some(PathBuf::from(&flag[9..]));
index += 1;
}
other if other.starts_with('-') => {
return Err(format!("unknown export option: {other}"));
}
other if output_path.is_none() => {
output_path = Some(PathBuf::from(other));
index += 1;
}
other => {
return Err(format!("unexpected export argument: {other}"));
}
}
}
Ok(CliAction::Export {
session_reference,
output_path,
output_format,
})
}
pub(crate) fn parse_dump_manifests_args(
args: &[String],
output_format: CliOutputFormat,
) -> Result<CliAction, String> {
let mut manifests_dir: Option<PathBuf> = None;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
if arg == "--manifests-dir" {
let value = args
.get(index + 1)
.ok_or_else(|| String::from("--manifests-dir requires a path"))?;
manifests_dir = Some(PathBuf::from(value));
index += 2;
continue;
}
if let Some(value) = arg.strip_prefix("--manifests-dir=") {
if value.is_empty() {
return Err(String::from("--manifests-dir requires a path"));
}
manifests_dir = Some(PathBuf::from(value));
index += 1;
continue;
}
return Err(format!("unknown dump-manifests option: {arg}"));
}
Ok(CliAction::DumpManifests {
output_format,
manifests_dir,
})
}
pub(crate) fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
Some(first) if looks_like_slash_command_token(first) => {
(PathBuf::from(LATEST_SESSION_REFERENCE), args)
}
Some(first) => (PathBuf::from(first), &args[1..]),
};
let mut commands = Vec::new();
let mut current_command = String::new();
for token in command_tokens {
if token.trim_start().starts_with('/') {
if resume_command_can_absorb_token(&current_command, token) {
current_command.push(' ');
current_command.push_str(token);
continue;
}
if !current_command.is_empty() {
commands.push(current_command);
}
current_command = String::from(token.as_str());
continue;
}
if current_command.is_empty() {
return Err("--resume trailing arguments must be slash commands".to_string());
}
current_command.push(' ');
current_command.push_str(token);
}
if !current_command.is_empty() {
commands.push(current_command);
}
Ok(CliAction::ResumeSession {
session_path,
commands,
output_format,
})
}