From 8c0364f3eaf1a3bbb8432dc4fed36e382dae55f6 Mon Sep 17 00:00:00 2001 From: zhaoyanchao Date: Tue, 26 May 2026 17:45:32 +0800 Subject: [PATCH] refactor: remove duplicate args block from main.rs, fix visibility --- rust/crates/rusty-claude-cli/src/args.rs | 3 +- rust/crates/rusty-claude-cli/src/main.rs | 1219 ---------------------- 2 files changed, 2 insertions(+), 1220 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/args.rs b/rust/crates/rusty-claude-cli/src/args.rs index 90a697fc..7c419713 100644 --- a/rust/crates/rusty-claude-cli/src/args.rs +++ b/rust/crates/rusty-claude-cli/src/args.rs @@ -18,6 +18,7 @@ use crate::model::*; use crate::normalize_permission_mode; use crate::resume_command_can_absorb_token; +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CliAction { DumpManifests { output_format: CliOutputFormat, @@ -145,7 +146,7 @@ pub(crate) enum CliOutputFormat { } impl CliOutputFormat { - fn parse(value: &str) -> Result { + pub(crate) fn parse(value: &str) -> Result { match value { "text" => Ok(Self::Text), "json" => Ok(Self::Json), diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index b314e611..3f9a4473 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -32,7 +32,6 @@ pub(crate) use model::*; pub(crate) use models::*; pub(crate) use permission::*; pub(crate) use progress::*; -pub(crate) use repl::*; use std::collections::BTreeSet; use std::env; use std::fs; @@ -385,1224 +384,6 @@ fn run() -> Result<(), Box> { Ok(()) } -#[derive(Debug, Clone, PartialEq, Eq)] -enum CliAction { - DumpManifests { - output_format: CliOutputFormat, - manifests_dir: Option, - }, - BootstrapPlan { - output_format: CliOutputFormat, - }, - Agents { - args: Option, - output_format: CliOutputFormat, - }, - Mcp { - args: Option, - output_format: CliOutputFormat, - }, - Skills { - args: Option, - output_format: CliOutputFormat, - }, - Plugins { - action: Option, - target: Option, - output_format: CliOutputFormat, - }, - PrintSystemPrompt { - cwd: PathBuf, - date: String, - output_format: CliOutputFormat, - }, - Version { - output_format: CliOutputFormat, - }, - ResumeSession { - session_path: PathBuf, - commands: Vec, - 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, - permission_mode: PermissionMode, - output_format: CliOutputFormat, - allowed_tools: Option, - }, - Sandbox { - output_format: CliOutputFormat, - }, - Prompt { - prompt: String, - model: String, - output_format: CliOutputFormat, - allowed_tools: Option, - permission_mode: PermissionMode, - compact: bool, - base_commit: Option, - reasoning_effort: Option, - 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, - output_format: CliOutputFormat, - }, - Diff { - output_format: CliOutputFormat, - }, - Export { - session_reference: String, - output_path: Option, - output_format: CliOutputFormat, - }, - Repl { - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - base_commit: Option, - reasoning_effort: Option, - 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)] -enum LocalHelpTopic { - Status, - Sandbox, - Doctor, - Acp, - // #141: extend the local-help pattern to every subcommand so - // `claw --help` has one consistent contract. - Init, - State, - Export, - Version, - SystemPrompt, - DumpManifests, - BootstrapPlan, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CliOutputFormat { - Text, - Json, -} - -impl CliOutputFormat { - fn parse(value: &str) -> Result { - 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)] -fn parse_args(args: &[String]) -> Result { - - 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 = 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 = None; - let mut reasoning_effort: Option = None; - let mut allow_broad_cwd = false; - let mut rest: Vec = 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 `.", - ); - 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, - }) - } - } -} - -fn parse_local_help_action(rest: &[String]) -> Option> { - 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))) -} - -fn is_help_flag(value: &str) -> bool { - matches!(value, "--help" | "-h") -} - -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, - output_format: CliOutputFormat, - allowed_tools: Option, -) -> Option> { - 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), - } -} - -fn bare_slash_command_guidance(command_name: &str) -> Option { - 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) -} - -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." - ) -} - -fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result { - 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`.", - )), - } -} - -fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option { - 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, - } -} - -fn join_optional_args(args: &[String]) -> Option { - 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)] -fn parse_direct_slash_cli_action( - rest: &[String], - model: String, - output_format: CliOutputFormat, - allowed_tools: Option, - permission_mode: PermissionMode, - compact: bool, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, -) -> Result { - 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()), - } -} - -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 -} - -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 -} - -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 -} - -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.", - ) -} - -fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option { - (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),)) -} - -fn suggest_slash_commands(input: &str) -> Vec { - 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::>() - }) - .collect::>(); - candidates.sort(); - candidates.dedup(); - let candidate_refs = candidates.iter().map(String::as_str).collect::>(); - ranked_suggestions(input.trim_start_matches('/'), &candidate_refs) - .into_iter() - .map(str::to_string) - .collect() -} - -fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> { - ranked_suggestions(input, candidates).into_iter().next() -} - -fn suggest_similar_subcommand(input: &str) -> Option> { - 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::>(); - 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::>(); - (!suggestions.is_empty()).then_some(suggestions) -} - -fn common_prefix_len(left: &str, right: &str) -> usize { - left.chars() - .zip(right.chars()) - .take_while(|(l, r)| l == r) - .count() -} - -fn looks_like_subcommand_typo(input: &str) -> bool { - !input.is_empty() - && input - .chars() - .all(|ch| ch.is_ascii_alphabetic() || ch == '-') -} - -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::>(); - ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); - ranked - .into_iter() - .map(|(_, candidate)| candidate) - .take(3) - .collect() -} - -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::>(); - let mut previous = (0..=right_chars.len()).collect::>(); - 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(¤t); - } - - previous[right_chars.len()] -} - - - -fn normalize_allowed_tools(values: &[String]) -> Result, String> { - if values.is_empty() { - return Ok(None); - } - current_tool_registry()?.normalize_allowed_tools(values) -} - -fn current_tool_registry() -> Result { - 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) -} - -fn parse_permission_mode_arg(value: &str) -> Result { - 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) -} - -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}"), - } -} - -fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode { - match mode { - ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly, - ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite, - ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess, - } -} - -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) -} - -fn config_permission_mode_for_current_dir() -> Option { - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - loader - .load() - .ok()? - .permission_mode() - .map(permission_mode_from_resolved) -} - - - -fn filter_tool_specs( - tool_registry: &GlobalToolRegistry, - allowed_tools: Option<&AllowedToolSet>, -) -> Vec { - tool_registry.definitions(allowed_tools) -} - -fn parse_system_prompt_args( - args: &[String], - output_format: CliOutputFormat, -) -> Result { - 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, - }) -} - -fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result { - let mut session_reference = LATEST_SESSION_REFERENCE.to_string(); - let mut output_path: Option = 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, - }) -} - -fn parse_dump_manifests_args( - args: &[String], - output_format: CliOutputFormat, -) -> Result { - let mut manifests_dir: Option = 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, - }) -} - -fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result { - 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(¤t_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, - }) -} - -/// Starts a minimal Model Context Protocol server that exposes claw's -/// built-in tools over stdio. -/// -/// Tool descriptors come from [`tools::mvp_tool_specs`] and calls are -/// dispatched through [`tools::execute_tool`], so this server exposes exactly -/// Read `.claw/worker-state.json` from the current working directory and print it. -/// This is the file-based worker observability surface: `push_event()` in `worker_boot.rs` -/// atomically writes state transitions here so external observers (clawhip, orchestrators) -/// can poll current `WorkerStatus` without needing an HTTP route on the opencode binary. fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box> { let cwd = env::current_dir()?; let state_path = cwd.join(".claw").join("worker-state.json");