use crate::*; use api::{ detect_provider_kind, model_family_identity_for, resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; use commands::{ classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json, handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command, handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help, render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands, slash_command_specs, validate_slash_command_input, PluginsCommandResult, SkillSlashDispatch, SlashCommand, }; use init::initialize_repo; use log::debug; use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials, load_system_prompt, load_system_prompt_with_context, pricing_for_model, resolve_expected_base, resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState, CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile, ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig, McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde::Deserialize; use serde_json::{json, Map, Value}; use std::collections::BTreeSet; use std::env; use std::fs; use std::io::{self, IsTerminal, Read, Write}; use std::net::TcpListener; use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::sync::{Arc, Mutex, OnceLock}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, UNIX_EPOCH}; use tools::{ canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput, }; /// #77: Classify a stringified error message into a machine-readable kind. /// /// Returns a `snake_case` token that downstream consumers can switch on instead /// of regex-scraping the prose. The classification is best-effort prefix/keyword /// matching against the error messages produced throughout the CLI surface. pub(crate) fn classify_error_kind(message: &str) -> &'static str { // Check specific patterns first (more specific before generic) if message.starts_with("unknown_slash_command:") { "unknown_slash_command" } else if message.starts_with("command_not_found:") { "command_not_found" } else if message.contains("missing Anthropic credentials") { "missing_credentials" } else if message.contains("Manifest source files are missing") || message.starts_with("missing_manifests:") { "missing_manifests" } else if message.contains("no worker state file found") { "missing_worker_state" } else if message.contains("session not found") { "session_not_found" } else if message.contains("no managed sessions found") { "no_managed_sessions" } else if message.contains("legacy session is missing workspace binding") { // #780: must precede the generic "failed to restore session" arm — the full // error message is "failed to restore session: legacy session is missing workspace // binding: ...", so the specific arm must be checked first. "legacy_session_no_workspace_binding" } else if message.contains("Is a directory") || message.contains("os error 21") { // #787: --resume given a directory path instead of a .jsonl file "session_path_is_directory" } else if message.contains("failed to restore session") { "session_load_failed" } else if message.contains("unsupported ACP invocation") { "unsupported_acp_invocation" } else if message.starts_with("missing_argument:") { "missing_argument" } else if message.contains("unsupported skills action") { "unsupported_skills_action" } else if message.starts_with("invalid_install_source:") { "invalid_install_source" } else if message.starts_with("invalid_cwd:") { "invalid_cwd" } else if message.starts_with("invalid_output_path:") { "invalid_output_path" } else if message.starts_with("invalid_output_format:") { "invalid_output_format" } else if message.starts_with("invalid_tool_name:") { "invalid_tool_name" } else if message.contains("unrecognized argument") || message.contains("unknown option") { "cli_parse" } else if message.starts_with("missing_flag_value:") { "missing_flag_value" } else if message.starts_with("invalid_permission_mode:") { "invalid_permission_mode" } else if message.starts_with("invalid_flag_value:") { "invalid_flag_value" } else if message.starts_with("invalid_model:") { "invalid_model" } else if message.contains("invalid model syntax") { "invalid_model_syntax" } else if message.contains("is not yet implemented") { "unsupported_command" } else if message.contains("unsupported resumed command") { "unsupported_resumed_command" } else if message.contains("confirmation required") { "confirmation_required" } else if (message.contains("api failed") || message.contains("api returned")) && (message.contains("401") || message.contains("Unauthorized") || message.contains("authentication_error")) { // #781: sub-classify auth failures so wrappers can distinguish from rate-limit / server errors "api_auth_error" } else if (message.contains("api failed") || message.contains("api returned")) && (message.contains("429") || message.contains("rate_limit") || message.contains("rate limit")) { // #781: sub-classify rate-limit failures "api_rate_limit_error" } else if message.contains("api failed") || message.contains("api returned") { "api_http_error" } else if message.contains("mcpServers") { "malformed_mcp_config" } else if message.contains(".claw/settings.json") || message.contains(".claw.json") { // #763: config file JSON parse / validation errors (e.g. unterminated string, type mismatch) "config_parse_error" } else if message.starts_with("empty prompt") { "empty_prompt" } else if message.starts_with("interactive_only:") || message.contains("stdin is not a TTY") { "interactive_only" } else if message.starts_with("unknown agents subcommand:") { "unknown_agents_subcommand" } else if message.starts_with("agent not found:") { "agent_not_found" } else if message.contains("is not installed") || message.starts_with("plugin_not_found:") { "plugin_not_found" } else if message.contains("plugin source") && message.contains("was not found") { // #794: `plugins install /nonexistent/path` → "plugin source ... was not found" "plugin_source_not_found" } else if (message.contains("skill source") && message.contains("not found")) || message.starts_with("skill '") { "skill_not_found" } else if message.contains("Unsupported config section") { "unsupported_config_section" } else if message.contains("unknown_plugins_action") { "unknown_plugins_action" } else if message.starts_with("invalid_history_count:") || message.contains("invalid count") { "invalid_history_count" } else if message.starts_with("missing_prompt:") { "missing_prompt" } else if message.contains("has been removed.") { // #765: removed subcommands (login, logout) — hint contains migration guidance "removed_subcommand" } else if message.starts_with("unknown subcommand:") { // #785/#825: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?) // Unified under command_not_found in #825. "command_not_found" } else if message.starts_with("unexpected extra arguments") || message.starts_with("unexpected_extra_args:") { // #766: extra positionals after commands that take no arguments (e.g. claw diff) // #784: export extra-positional errors use the typed prefix form "unexpected_extra_args" } else if message.starts_with("invalid_resume_argument:") { // #768: --resume trailing arg is not a slash command "invalid_resume_argument" } else if message.starts_with("unknown_option:") { "unknown_option" } else if message.contains("is a slash command") || message.starts_with("interactive_only:") // #735: "slash command /X is interactive-only" emitted by interactive-only guard || (message.starts_with("slash command") && message.contains("interactive-only")) { "interactive_only" } else { "unknown" } } /// #77: Split a multi-line error message into (`short_reason`, `optional_hint`). /// /// The `short_reason` is the first line (up to the first newline), and the hint /// is the remaining text or `None` if there's no newline. This prevents the /// runbook prose from being stuffed into the `error` field that downstream /// parsers expect to be the short reason alone. pub(crate) fn split_error_hint(message: &str) -> (String, Option) { match message.split_once('\n') { Some((short, hint)) => (short.to_string(), Some(hint.trim().to_string())), None => (message.to_string(), None), } } /// Merge a piped stdin payload into a prompt argument. /// /// When `stdin_content` is `None` or empty after trimming, the prompt is /// returned unchanged. Otherwise the trimmed stdin content is appended to the /// prompt separated by a blank line so the model sees the prompt first and the /// piped context immediately after it. pub(crate) fn merge_prompt_with_stdin(prompt: &str, stdin_content: Option<&str>) -> String { let Some(raw) = stdin_content else { return prompt.to_string(); }; let trimmed = raw.trim(); if trimmed.is_empty() { return prompt.to_string(); } if prompt.is_empty() { return trimmed.to_string(); } format!("{prompt}\n\n{trimmed}") } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) 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, model: String, output_format: CliOutputFormat, }, Version { output_format: CliOutputFormat, }, SessionList { output_format: CliOutputFormat, }, ResumeSession { session_path: PathBuf, commands: Vec, output_format: CliOutputFormat, allow_broad_cwd: bool, }, 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: PermissionModeProvenance, 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, permission_mode: PermissionModeProvenance, }, Acp { output_format: CliOutputFormat, }, State { output_format: CliOutputFormat, }, Init { output_format: CliOutputFormat, }, Setup { 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, }, Models { action: 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 { topic: LocalHelpTopic, output_format: CliOutputFormat, }, // 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 --help` has one consistent contract. Init, State, Resume, Session, Compact, Export, Version, SystemPrompt, DumpManifests, BootstrapPlan, // #720: subsystem help topics so `claw help agents` etc. route to usage JSON Agents, Skills, Plugins, Mcp, Config, Model, Settings, Diff, Setup, } #[allow(clippy::too_many_lines)] pub(crate) 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_selection = if cli_has_output_format_flag(args) { OutputFormatSelection::default() } else { output_format_selection_from_env()? }; set_current_output_format_selection(&output_format_selection); let mut output_format = output_format_selection.format; 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; // #755: -p prompt text captured as single token; remaining args continue // flag parsing. None until `-p ` is seen. let mut short_p_prompt: Option = None; let mut rest: Vec = Vec::new(); let mut positional_after_separator = false; 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_flag_value: missing value for --model.\nUsage: --model e.g. --model anthropic/claude-opus-4-7".to_string())?; // #468: track duplicate --model flags if model_flag_raw.is_some() { push_duplicate_flag(&format!( "--model (previous: {}, new: {})", model_flag_raw.as_deref().unwrap_or(""), value )); } let resolved = resolve_model_alias_with_config(value); debug!("Resolved --model '{}' -> '{}'", value, resolved); validate_model_syntax(&resolved)?; model = resolved; model_flag_raw = Some(value.clone()); // #148 index += 2; } flag if flag.starts_with("--model=") => { let value = &flag[8..]; let resolved = resolve_model_alias_with_config(value); debug!("Resolved --model='{}' -> '{}'", value, resolved); validate_model_syntax(&resolved)?; model = resolved; model_flag_raw = Some(value.to_string()); // #148 index += 1; } "--output-format" => { let value = args .get(index + 1) .ok_or_else(|| "missing_flag_value: missing value for --output-format.\nUsage: --output-format text or --output-format json".to_string())?; // #468: track duplicate --output-format flags if output_format != CliOutputFormat::Text || output_format_selection.format != CliOutputFormat::Text { push_duplicate_flag("--output-format (overwriting previous value)"); } output_format = apply_output_format_flag(&mut output_format_selection, value)?; index += 2; } "--permission-mode" => { let value = args .get(index + 1) .ok_or_else(|| "missing_flag_value: missing value for --permission-mode.\nUsage: --permission-mode read-only|workspace-write|danger-full-access".to_string())?; // #468: track duplicate --permission-mode flags if permission_mode_override.is_some() { push_duplicate_flag("--permission-mode (overwriting previous value)"); } permission_mode_override = Some(parse_permission_mode_arg(value)?); index += 2; } flag if flag.starts_with("--output-format=") => { output_format = apply_output_format_flag(&mut output_format_selection, &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" | "--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_flag_value: missing value for --base-commit.\nUsage: --base-commit ".to_string())?; // #122: validate that base-commit looks like a git SHA (hex, 7-64 chars) if value.len() < 7 || value.len() > 64 || !value.chars().all(|c| c.is_ascii_hexdigit()) { return Err(format!( "invalid_flag_value: --base-commit expects a hex SHA (7-64 chars), got '{}'.\nUsage: --base-commit ", value )); } 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_flag_value: missing value for --reasoning-effort.\nUsage: --reasoning-effort low|medium|high".to_string())?; if !matches!(value.as_str(), "low" | "medium" | "high") { return Err(format!( "invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|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_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|high" )); } reasoning_effort = Some(value.to_string()); index += 1; } "--allow-broad-cwd" => { allow_broad_cwd = true; index += 1; } "--" => { if rest.is_empty() { positional_after_separator = true; rest.extend(args[index + 1..].iter().cloned()); } else { rest.push("--".to_string()); rest.extend(args[index + 1..].iter().cloned()); } break; } "-p" => { // Claw Code compat: -p "prompt" = one-shot prompt. // #755: consume exactly one token so subsequent flags like // --model/--output-format are parsed normally instead of // being swallowed into the prompt string (#117). let next = args.get(index + 1).map(|s| s.as_str()); match next { None | Some("") => { return Err("missing_prompt: -p requires a prompt string.\nUsage: claw -p or claw prompt ".to_string()); } Some(tok) if tok.starts_with('-') && tok != "--" => { // Looks like a flag, not a prompt. Reject so the user // knows to quote the literal text or use `--`. return Err(format!( "missing_prompt: -p requires a prompt string before flags; got `{tok}`.\nUsage: claw -p --model sonnet or claw -p -- {tok} (literal)" )); } Some(tok) => { // `--` sentinel: skip it and take the token after as literal let (prompt_text, skip) = if tok == "--" { match args.get(index + 2) { Some(t) => (t.as_str(), 3usize), None => return Err("missing_prompt: -p -- requires a prompt string after `--`.\nUsage: claw -p -- ".to_string()), } } else { (tok, 2usize) }; if prompt_text.trim().is_empty() { return Err("missing_prompt: -p requires a non-empty prompt string.\nUsage: claw -p or claw prompt ".to_string()); } short_p_prompt = Some(prompt_text.to_string()); index += skip; continue; } } } "--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; } // #457: --help after --resume should show resume help, not be consumed as session-id "--help" | "-h" if rest.first().map(String::as_str) == Some("--resume") => { wants_help = true; 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(allowed_tools_missing_error)?; if value.starts_with('-') || is_known_top_level_subcommand(value) { return Err(allowed_tools_missing_error()); } allowed_tool_values.push(value.clone()); index += 2; } flag if flag.starts_with("--allowedTools=") => { let value = flag[15..].to_string(); if value.trim().is_empty() { return Err(allowed_tools_missing_error()); } allowed_tool_values.push(value); index += 1; } flag if flag.starts_with("--allowed-tools=") => { let value = flag[16..].to_string(); if value.trim().is_empty() { return Err(allowed_tools_missing_error()); } allowed_tool_values.push(value); index += 1; } other if rest.is_empty() && other.starts_with('-') => { if should_reject_unknown_option_like(other) { return Err(format_unknown_option(other)); } rest.push(other.to_string()); index += 1; } other => { rest.push(other.to_string()); index += 1; } } } if wants_help { // #684: --help before subcommand should still route to subcommand-specific // help when the subcommand is one of the local-help-topic commands. if let Some(action) = parse_local_help_action(&rest, output_format) { return action; } // When --help was consumed before the subcommand, rest has no help flag. // If rest is a simple local-help subcommand with no extra args, route there. if !rest.is_empty() && rest[1..].iter().all(|a| is_help_flag(a)) { let topic = match rest[0].as_str() { "status" => Some(LocalHelpTopic::Status), "sandbox" => Some(LocalHelpTopic::Sandbox), "doctor" => Some(LocalHelpTopic::Doctor), "acp" => Some(LocalHelpTopic::Acp), "init" => Some(LocalHelpTopic::Init), "setup" => Some(LocalHelpTopic::Setup), "state" => Some(LocalHelpTopic::State), "resume" => Some(LocalHelpTopic::Resume), "session" => Some(LocalHelpTopic::Session), "compact" => Some(LocalHelpTopic::Compact), "--resume" => Some(LocalHelpTopic::Resume), "export" => Some(LocalHelpTopic::Export), "version" => Some(LocalHelpTopic::Version), "system-prompt" => Some(LocalHelpTopic::SystemPrompt), "dump-manifests" => Some(LocalHelpTopic::DumpManifests), "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), "agents" | "agent" => Some(LocalHelpTopic::Agents), "skills" | "skill" => Some(LocalHelpTopic::Skills), "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), "mcp" => Some(LocalHelpTopic::Mcp), "config" => Some(LocalHelpTopic::Config), "model" | "models" => Some(LocalHelpTopic::Model), "settings" => Some(LocalHelpTopic::Settings), "diff" => Some(LocalHelpTopic::Diff), _ => None, }; if let Some(topic) = topic { return Ok(CliAction::HelpTopic { topic, output_format, }); } } return Ok(CliAction::Help { output_format }); } if wants_version { return Ok(CliAction::Version { output_format }); } let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; // #755: -p consumed exactly one token; dispatch now that all flags are parsed if let Some(prompt) = short_p_prompt { return Ok(CliAction::Prompt { prompt, model: resolve_model_alias_with_config(&model), output_format, allowed_tools, permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), compact, base_commit, reasoning_effort, allow_broad_cwd, }); } if positional_after_separator && !rest.is_empty() { let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); return Ok(CliAction::Prompt { prompt: rest.join(" "), model, output_format, allowed_tools, permission_mode, compact, base_commit, reasoning_effort: reasoning_effort.clone(), allow_broad_cwd, }); } if rest.is_empty() { let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); let stdin_is_terminal = std::io::stdin().is_terminal(); if compact && stdin_is_terminal { return Err(compact_missing_argument_error()); } // 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 !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, base_commit, reasoning_effort, allow_broad_cwd, }); } if compact { return Err(compact_missing_argument_error()); } // Non-TTY stdin with no piped content: refuse to start the interactive // REPL (it would block forever waiting for input that will never arrive). // (#696: emit a typed error instead of hanging indefinitely) // Skip this guard in test builds (parse_args tests run in non-TTY context). #[cfg(not(test))] // #746: newline before remediation so split_error_hint populates hint field return Err("interactive_only: claw requires an interactive terminal.\nStdin is not a TTY and no prompt was provided — pipe a prompt with `echo 'task' | claw` or run `claw` in an interactive terminal.".into()); } return Ok(CliAction::Repl { model, allowed_tools, permission_mode, base_commit, reasoning_effort: reasoning_effort.clone(), allow_broad_cwd, }); } if let Some(action) = parse_local_help_action(&rest, output_format) { return action; } if rest.first().map(String::as_str) == Some("--resume") { return parse_resume_args(&rest[1..], output_format, allow_broad_cwd); } if rest.first().map(String::as_str) == Some("resume") { return parse_resume_args(&rest[1..], output_format, allow_broad_cwd); } // #696: `claw compact` is the bare name of the interactive `/compact` // slash command, not a prompt. When extra args such as `--help` appear // after the word `compact`, the generic prompt fallback used to send // `compact --help` to provider startup and could hang under closed stdin / // JSON output. Fail closed before any provider, prompt, TUI, or spinner // startup. `claw --resume SESSION.jsonl /compact` remains the supported // non-interactive session compaction path. if rest.first().map(String::as_str) == Some("compact") { return Err(compact_interactive_only_error()); } 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; } // Keep config-backed defaults lazy so pure-local JSON surfaces (notably // `claw --output-format json config`) can report config warnings // structurally without an earlier default-resolution load writing prose // warnings to stderr. let permission_mode = || permission_mode_override.unwrap_or_else(default_permission_mode); let permission_mode_provenance = || { permission_mode_override .map(PermissionModeProvenance::from_flag) .unwrap_or_else(permission_mode_provenance_for_current_dir) }; // #98: --compact is only meaningful for prompt mode. When a known non-prompt // subcommand is being dispatched, reject --compact so callers don't silently // lose the flag. if compact && rest .first() .map(|s| s.as_str()) .is_some_and(|s| s != "prompt") { // Allow compact for the default prompt fallback (unknown tokens). // Only reject for known top-level subcommands that don't use compact. let first = rest[0].as_str(); if is_known_top_level_subcommand(first) && first != "prompt" { return Err(format!( "invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"\" or echo \"\" | claw --compact" )); } } 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. // `plugin` (singular) and `marketplace` are aliases for `plugins`. // All three must route to the same local handler so that no form // falls through to the LLM/prompt path. "plugins" | "plugin" | "marketplace" => { let tail = &rest[1..]; let action = tail.first().cloned(); let target = tail.get(1).cloned(); if tail.len() > 2 { // #797: append \n usage hint so split_error_hint extracts it (parity with #791 config fix) return Err(format!( "unexpected extra arguments after `claw {} {}`: {}\nUsage: claw plugins [list|show |install |enable |disable |uninstall |update |help]", rest[0], 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 { // #791: append \n hint so split_error_hint extracts it and hint is non-null return Err(format!( "unexpected extra arguments after `claw config {}`: {}\nUsage: claw config [env|hooks|model|plugins|mcp|settings]", 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 { // #3129: keep malformed `diff ... --output-format json` on the // parser/error path, not the prompt/TUI fallback. The newline // before Usage is part of the JSON hint contract. return Err(unexpected_diff_args_error(&rest[1..])); } Ok(CliAction::Diff { output_format }) } // `claw permissions ` 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(), ), // #767: `claw session bogus` bypassed parse_single_word_command_alias (rest.len()>1), // had no match arm, and fell to CliAction::Prompt — reaching the credential gate // instead of a structured error. Mirror the guard on `permissions`. "session" => { // #449: `claw session list` is a pure local filesystem read that // requires no API credentials. Route directly to SessionList instead // of falling through to the resume/auth path. if rest.get(1).map(|s| s.as_str()) == Some("list") { Ok(CliAction::SessionList { output_format }) } else { let action_hint = rest.get(1).map_or(String::new(), |a| format!(" (got: `{a}`)" )); Err(format!( "interactive_only: `claw session` is a slash command{action_hint}.\nUse `claw --resume SESSION.jsonl /session ` or start `claw` and run `/session [list|exists|switch|fork|delete]`." )) } } // #770: same fallthrough gap as #767 — these slash commands had no multi-arg match arm // and fell to CliAction::Prompt reaching the credential gate when called with args. "cost" => Err( "interactive_only: `claw cost` is a slash command.\nUse `claw --resume SESSION.jsonl /cost` or start `claw` and run `/cost`." .to_string(), ), "clear" => Err( "interactive_only: `claw clear` is a slash command.\nUse `claw --resume SESSION.jsonl /clear [--confirm]` or start `claw` and run `/clear`." .to_string(), ), "memory" => Err( "interactive_only: `claw memory` is a slash command.\nStart `claw` and run `/memory` inside the REPL." .to_string(), ), "ultraplan" => Err( "interactive_only: `claw ultraplan` is a slash command.\nStart `claw` and run `/ultraplan` inside the REPL." .to_string(), ), "model" | "models" => { let tail = &rest[1..]; let action = tail.first().cloned(); if tail.len() > 1 { return Err(format!( "unexpected extra arguments after `claw {} {}`: {}\nUsage: claw {} [help] [--output-format json]", rest[0], tail[0], tail[1..].join(" "), rest[0] )); } Ok(CliAction::Models { action, output_format, }) } // #771: usage/stats/fork are slash-only verbs with no multi-arg match arms "usage" => Err( "interactive_only: `claw usage` is a slash command.\nUse `claw --resume SESSION.jsonl /usage` or start `claw` and run `/usage`." .to_string(), ), "stats" => Err( "interactive_only: `claw stats` is a slash command.\nUse `claw --resume SESSION.jsonl /stats` or start `claw` and run `/stats`." .to_string(), ), "fork" => Err( "interactive_only: `claw fork` is a slash command.\nStart `claw` and run `/session fork [branch-name]` inside the REPL." .to_string(), ), "skills" => { let args = join_optional_args(&rest[1..]); if let Some(action) = args.as_deref() { let first_word = action.split_whitespace().next().unwrap_or(action); if matches!(first_word, "add") { return Err(format!( "unsupported skills action: {first_word}. Supported actions: list, show , install , uninstall , help, or [args]" )); } } match classify_skills_slash_command(args.as_deref()) { SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { prompt, model, output_format, allowed_tools, permission_mode: permission_mode(), compact, base_commit, reasoning_effort: reasoning_effort.clone(), allow_broad_cwd, }), SkillSlashDispatch::Local => Ok(CliAction::Skills { args, output_format, }), } } "settings" => { let tail = &rest[1..]; if tail.is_empty() { Ok(CliAction::Config { section: Some("settings".to_string()), output_format, }) } else if tail.len() == 1 && matches!(tail[0].as_str(), "help" | "--help" | "-h") { Ok(CliAction::HelpTopic { topic: LocalHelpTopic::Settings, output_format, }) } else { Err(format!( "unexpected extra arguments after `claw settings`: {}\nUsage: claw settings [help] [--output-format json]", tail.join(" ") )) } } "system-prompt" => parse_system_prompt_args(&rest[1..], model, output_format), "acp" => parse_acp_args(&rest[1..], output_format), "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), "init" => { // #771: extra positional args to `init` were silently ignored — now rejected if rest.len() > 1 { let extra = rest[1..].join(" "); return Err(format!( "unexpected extra arguments after `claw init`: {extra}\nUsage: claw init [--cwd ] [--date ] [--session ]" )); } Ok(CliAction::Init { output_format }) } "setup" => { if rest.len() > 1 { let extra = rest[1..].join(" "); return Err(format!( "unexpected extra arguments after `claw setup`: {extra}\nUsage: claw setup" )); } Ok(CliAction::Setup { output_format }) } "export" => parse_export_args(&rest[1..], output_format), "prompt" => { let mut read_stdin = false; let prompt_parts = rest[1..] .iter() .filter_map(|arg| { if matches!(arg.as_str(), "--stdin" | "--prompt-stdin") { read_stdin = true; None } else { Some(arg.as_str()) } }) .collect::>(); let positional_prompt = prompt_parts.join(" "); let stdin_prompt = if read_stdin || positional_prompt.trim().is_empty() { read_piped_stdin() } else { None }; let prompt = if read_stdin { merge_prompt_with_stdin(&positional_prompt, stdin_prompt.as_deref()) } else { stdin_prompt .as_deref() .map(str::trim) .unwrap_or(&positional_prompt) .to_string() }; if prompt.trim().is_empty() { // #750/#823/#423: provide error_kind-compatible prefix + \n for hint extraction. return Err("missing_prompt: prompt subcommand requires a prompt string. Usage: claw prompt or echo '' | claw prompt".to_string()); } Ok(CliAction::Prompt { prompt, model, output_format, allowed_tools, permission_mode: 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_provenance(), compact, base_commit, reasoning_effort, allow_broad_cwd, ), other => { if !compact && !other.starts_with('-') && looks_like_subcommand_typo(other) && (rest.len() == 1 || (output_format == CliOutputFormat::Json && model_flag_raw.is_none())) { // #825/#826: emit command_not_found before provider startup for // command-shaped tokens that do not match known subcommands. // Text-mode multi-word prompt shorthand remains available, but // JSON-mode automation must not turn an unknown command into a // credential-gated prompt request. let mut message = format!("command_not_found: unknown subcommand: {other}."); if let Some(suggestions) = suggest_similar_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() { // #798: add \n hint so split_error_hint extracts it (was empty_prompt + null) return Err( "empty prompt: provide a subcommand or a non-empty prompt string.\nUsage: claw or claw -p . Run `claw --help` for the full list." .to_string(), ); } Ok(CliAction::Prompt { prompt: joined, model, output_format, allowed_tools, permission_mode: permission_mode(), compact, base_commit, reasoning_effort: reasoning_effort.clone(), allow_broad_cwd, }) } } } pub(crate) fn parse_local_help_action( rest: &[String], output_format: CliOutputFormat, ) -> Option> { if rest.is_empty() { return None; } if !rest.iter().any(|a| is_help_flag(a)) { return None; } let topic = match rest[0].as_str() { "status" => LocalHelpTopic::Status, "sandbox" => LocalHelpTopic::Sandbox, "doctor" => LocalHelpTopic::Doctor, "acp" => LocalHelpTopic::Acp, "init" => LocalHelpTopic::Init, "setup" => LocalHelpTopic::Setup, "state" => LocalHelpTopic::State, "export" => LocalHelpTopic::Export, "version" => LocalHelpTopic::Version, "system-prompt" => LocalHelpTopic::SystemPrompt, "dump-manifests" => LocalHelpTopic::DumpManifests, "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, "resume" | "--resume" => LocalHelpTopic::Resume, "session" => LocalHelpTopic::Session, "compact" => LocalHelpTopic::Compact, "model" | "models" => LocalHelpTopic::Model, "settings" => LocalHelpTopic::Settings, _ => return None, }; let has_non_help = rest[1..].iter().any(|a| !is_help_flag(a)); if has_non_help { return None; } Some(Ok(CliAction::HelpTopic { topic, output_format, })) } 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, 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" | "setup" | "state" ); 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)); if all_extra_are_help { // "doctor --help -h" is valid, routed to parse_local_help_action() instead return None; } // #720: `claw help ` — when the verb is "help" and exactly one // non-flag argument follows, try to route to the topic's handler. if verb == "help" && rest.len() == 2 { let topic_name = rest[1].as_str(); let topic = match topic_name { "status" => Some(LocalHelpTopic::Status), "sandbox" => Some(LocalHelpTopic::Sandbox), "doctor" => Some(LocalHelpTopic::Doctor), "acp" => Some(LocalHelpTopic::Acp), "init" => Some(LocalHelpTopic::Init), "setup" => Some(LocalHelpTopic::Setup), "state" => Some(LocalHelpTopic::State), "export" => Some(LocalHelpTopic::Export), "version" => Some(LocalHelpTopic::Version), "system-prompt" => Some(LocalHelpTopic::SystemPrompt), "dump-manifests" => Some(LocalHelpTopic::DumpManifests), "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), "resume" => Some(LocalHelpTopic::Resume), "session" => Some(LocalHelpTopic::Session), "compact" => Some(LocalHelpTopic::Compact), "agents" | "agent" => Some(LocalHelpTopic::Agents), "skills" | "skill" => Some(LocalHelpTopic::Skills), "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), "mcp" => Some(LocalHelpTopic::Mcp), "config" => Some(LocalHelpTopic::Config), "model" | "models" => Some(LocalHelpTopic::Model), "settings" => Some(LocalHelpTopic::Settings), "diff" => Some(LocalHelpTopic::Diff), _ => None, }; if let Some(t) = topic { return Some(Ok(CliAction::HelpTopic { topic: t, output_format, })); } // Unknown topic: fall through to generic help. return Some(Ok(CliAction::Help { output_format })); } // 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`?"); } else { // #752: generic fallback hint so cli_parse errors always have non-null hint msg.push_str(&format!("\nRun `claw {} --help` for usage.", verb)); } return Some(Err(msg)); } // #720: `claw help ` — when `help` is the verb and a topic follows, // try to route to the topic's help handler instead of erroring. if rest.len() == 2 && rest[0] == "help" { let topic_name = rest[1].as_str(); let topic = match topic_name { "status" => Some(LocalHelpTopic::Status), "sandbox" => Some(LocalHelpTopic::Sandbox), "doctor" => Some(LocalHelpTopic::Doctor), "acp" => Some(LocalHelpTopic::Acp), "init" => Some(LocalHelpTopic::Init), "setup" => Some(LocalHelpTopic::Setup), "state" => Some(LocalHelpTopic::State), "export" => Some(LocalHelpTopic::Export), "version" => Some(LocalHelpTopic::Version), "system-prompt" => Some(LocalHelpTopic::SystemPrompt), "dump-manifests" => Some(LocalHelpTopic::DumpManifests), "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), "resume" => Some(LocalHelpTopic::Resume), "session" => Some(LocalHelpTopic::Session), "compact" => Some(LocalHelpTopic::Compact), "agents" | "agent" => Some(LocalHelpTopic::Agents), "skills" | "skill" => Some(LocalHelpTopic::Skills), "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), "mcp" => Some(LocalHelpTopic::Mcp), "config" => Some(LocalHelpTopic::Config), "model" | "models" => Some(LocalHelpTopic::Model), "settings" => Some(LocalHelpTopic::Settings), "diff" => Some(LocalHelpTopic::Diff), _ => None, }; if let Some(t) = topic { return Some(Ok(CliAction::HelpTopic { topic: t, output_format, })); } // Unknown topic falls through to the generic help action. return Some(Ok(CliAction::Help { output_format })); } // #453: fire guard for multi-word CLI subcommands too (claw cost list, claw model list, etc.) // For slash commands that are commonly used as prompts (explain, cost, tokens, etc.), // only fire the guard when there's exactly one token. if rest.is_empty() { return None; } // Known CLI subcommands that don't accept additional arguments const CLI_SUBCOMMANDS: &[&str] = &[ "help", "version", "status", "sandbox", "doctor", "state", "config", "diff", ]; if rest.len() > 1 && !CLI_SUBCOMMANDS.contains(&rest[0].as_str()) { 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 .map(PermissionModeProvenance::from_flag) .unwrap_or_else(permission_mode_provenance_for_current_dir), output_format, allowed_tools, })), "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), "doctor" => Some(Ok(CliAction::Doctor { output_format, permission_mode: permission_mode_override .map(PermissionModeProvenance::from_flag) .unwrap_or_else(permission_mode_provenance_for_current_dir), })), "setup" => Some(Ok(CliAction::Setup { 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 { if matches!( command_name, "dump-manifests" | "bootstrap-plan" | "agents" | "mcp" | "plugin" | "plugins" | "marketplace" | "skills" | "system-prompt" | "init" | "prompt" | "export" ) { return None; } let slash_command = slash_command_specs() .iter() // #772: check both spec.name and spec.aliases for command-line invocations .find(|spec| spec.name == command_name || spec.aliases.contains(&command_name))?; let canonical_name = slash_command.name; // #745: newline before remediation text so split_error_hint populates hint field let guidance = if slash_command.resume_supported { format!( "`claw {command_name}` is a slash command.\nUse `claw --resume SESSION.jsonl /{canonical_name}` or start `claw` and run `/{canonical_name}`." ) } else { format!( "`claw {command_name}` is a slash command.\nStart `claw` and run `/{canonical_name}` inside the REPL." ) }; // #772: help text still mentions the alias, but the remediation shows canonical form Some(guidance) } pub(crate) fn removed_auth_surface_error(command_name: &str) -> String { // #765: two-line format so split_error_hint() extracts hint into JSON envelope format!( "`claw {command_name}` has been removed.\nSet ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead." ) } pub(crate) 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: unsupported ACP invocation. Use `claw acp` or `claw acp serve`.\nACP/Zed editor integration is not implemented yet; `claw acp serve` reports status only.", )), } } pub(crate) 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)] pub(crate) fn parse_direct_slash_cli_action( rest: &[String], model: String, output_format: CliOutputFormat, allowed_tools: Option, permission_mode: PermissionModeProvenance, 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::Status)) => Ok(CliAction::Status { model, model_flag_raw: None, permission_mode, output_format, allowed_tools, }), Ok(Some(SlashCommand::Sandbox)) => Ok(CliAction::Sandbox { output_format }), Ok(Some(SlashCommand::Diff)) => Ok(CliAction::Diff { output_format }), Ok(Some(SlashCommand::Version)) => Ok(CliAction::Version { output_format }), Ok(Some(SlashCommand::Doctor)) => Ok(CliAction::Doctor { output_format, permission_mode, }), 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: permission_mode.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))) => { // #828: /approve and /deny are valid REPL-only slash commands that // are not SlashCommand enum variants (they require an active tool // call in the REPL to be meaningful). Emit interactive_only so // machine consumers see the correct error_kind instead of // unknown_slash_command. if matches!(name.as_str(), "approve" | "yes" | "y" | "deny" | "no" | "n") { Err(format!( "interactive_only: /{name} requires an active tool call in the REPL.\nStart `claw` and use /{name} to approve or deny a pending tool execution." )) } else { Err(format_unknown_direct_slash_command(&name)) } } Ok(Some(command)) => Err({ let _ = command; let command_name = &rest[0]; // #829: only suggest --resume when the command is actually // resume-safe. Non-resume-safe commands (e.g. /commit, /pr) // previously suggested --resume, which just re-triggered // interactive_only on a second invocation. let bare_name = command_name.trim_start_matches('/'); let is_resume_safe = commands::resume_supported_slash_commands() .iter() .any(|spec| spec.name == bare_name); if is_resume_safe { format!( // #738: newline before remediation so split_error_hint populates hint field "interactive_only: slash command {command_name} requires a live session.\nStart `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}`.", latest = LATEST_SESSION_REFERENCE, ) } else { format!( "interactive_only: slash command {command_name} requires a live REPL session.\nStart `claw` and run it there." ) } }), Ok(None) => Err(format!("unknown subcommand: {}", rest[0])), Err(error) => Err(error.to_string()), } } pub(crate) fn format_unknown_option(option: &str) -> String { if option == "--" { return "end_of_flags: `--` terminates flag parsing. Pass literal prompt text after it, for example `claw -- \"-literal prompt\"`.\nRun `claw --help` for usage.".to_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 { // #827: prefix with classifier-friendly token so classify_error_kind // returns "unknown_slash_command" instead of the opaque fallback. let mut message = format!("unknown_slash_command: 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 { // #827: prefix with classifier-friendly token so classify_error_kind // can return "unknown_slash_command" instead of the opaque fallback. let mut message = format!("unknown_slash_command: 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 { (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),)) } pub(crate) 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() } 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> { const KNOWN_SUBCOMMANDS: &[&str] = &[ "help", "version", "status", "sandbox", "doctor", "setup", "state", "dump-manifests", "bootstrap-plan", "agents", "mcp", "skills", "system-prompt", "acp", "init", "export", "prompt", "list", ]; 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) } 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::>(); 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::>(); 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()] } /// Validate model syntax at parse time. /// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern. /// Rejects: empty, whitespace-only, strings with spaces, or invalid chars. pub(crate) fn validate_model_syntax(model: &str) -> Result<(), String> { let trimmed = model.trim(); // Ollama models use names like "qwen3:8b" that don't match provider/model // syntax. Skip strict validation when OLLAMA_HOST is configured. if std::env::var_os("OLLAMA_HOST").is_some() { if trimmed.is_empty() { return Err("invalid model syntax: model string cannot be empty.\nUsage: --model e.g. --model qwen3:8b".to_string()); } return Ok(()); } if trimmed.is_empty() { return Err("invalid model syntax: model string cannot be empty.\nUsage: --model e.g. --model anthropic/claude-opus-4-7".to_string()); } // Check for spaces (malformed) if trimmed.contains(' ') { return Err(format!( "invalid model syntax: '{}' contains spaces.\nUse provider/model format (e.g., anthropic/claude-opus-4-7) or a known alias.", trimmed )); } if is_bare_provider_model(trimmed) { return Ok(()); } if is_local_openai_model_syntax(trimmed) { return Ok(()); } // Check provider/model format: provider_id/model_id let parts: Vec<&str> = trimmed.split('/').collect(); if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { // #154: hint if the model looks like it belongs to a different provider let mut err_msg = format!( "invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-7)", trimmed ); if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") { err_msg.push_str("\nDid you mean `openai/"); err_msg.push_str(trimmed); err_msg.push_str("`? (Requires OPENAI_API_KEY env var)"); } else if trimmed.starts_with("qwen") && trimmed.contains(':') { err_msg.push_str("\nFor a local Ollama model, set `OPENAI_BASE_URL=http://127.0.0.1:11434/v1` before using tagged names like `"); err_msg.push_str(trimmed); err_msg.push_str("`."); } else if trimmed.starts_with("qwen") { err_msg.push_str("\nDid you mean `qwen/"); err_msg.push_str(trimmed); err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)"); } else if trimmed.starts_with("grok") { err_msg.push_str("\nDid you mean `xai/"); err_msg.push_str(trimmed); err_msg.push_str("`? (Requires XAI_API_KEY env var)"); } return Err(err_msg); } Ok(()) } pub(crate) fn parse_permission_mode_arg(value: &str) -> Result { normalize_permission_mode(value) .ok_or_else(|| { format!( "invalid_permission_mode: unsupported permission mode '{value}'.\nUsage: --permission-mode read-only|workspace-write|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 { permission_mode_provenance_for_current_dir().mode } pub(crate) 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) } pub(crate) fn provider_label(kind: ProviderKind) -> &'static str { match kind { ProviderKind::Anthropic => "anthropic", ProviderKind::Xai => "xai", ProviderKind::OpenAi => "openai", } } pub(crate) fn format_connected_line(model: &str) -> String { let provider = provider_label(detect_provider_kind(model)); format!("Connected: {model} via {provider}") } pub(crate) fn parse_system_prompt_args( args: &[String], model: 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_flag_value: missing value for --cwd.\nUsage: --cwd ".to_string() })?; cwd = PathBuf::from(value); // #99: validate --cwd path exists and is a directory if !cwd.exists() { return Err(format!( "invalid_cwd: path '{value}' does not exist.\nUsage: claw system-prompt --cwd " )); } if !cwd.is_dir() { return Err(format!( "invalid_cwd: path '{value}' is not a directory.\nUsage: claw system-prompt --cwd " )); } index += 2; } "--date" => { let value = args.get(index + 1).ok_or_else(|| { "missing_flag_value: missing value for --date.\nUsage: --date " .to_string() })?; // #99: validate --date is a plausible date string (no newlines, reasonable length) if value.contains('\n') || value.contains('\r') { return Err(format!( "invalid_flag_value: --date value contains invalid characters.\nUsage: --date " )); } if value.len() > 20 { return Err(format!( "invalid_flag_value: --date value is too long ({len} chars, expected YYYY-MM-DD).\nUsage: --date ", len = value.len() )); } date.clone_from(value); index += 2; } other => { // #152: hint `--output-format json` when user types `--json`. // #790: use unknown_option: prefix + \n hint so classify_error_kind returns // unknown_option and split_error_hint extracts the remediation text. let hint = if other == "--json" { "Did you mean `--output-format json`? Usage: claw system-prompt [--cwd ] [--date ] [--output-format text|json]".to_string() } else { "Usage: claw system-prompt [--cwd ] [--date ] [--output-format text|json]".to_string() }; return Err(format!( "unknown_option: unknown system-prompt option: {other}.\n{hint}" )); } } } Ok(CliAction::PrintSystemPrompt { cwd, date, model, output_format, }) } pub(crate) 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_flag_value: missing value for --session.\nUsage: --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_flag_value: missing value for {}.\nUsage: claw export [PATH] [--session SESSION] [--output PATH]", 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_option: unknown export option: {other}.\nRun `claw export --help` for usage.")); } other if output_path.is_none() => { output_path = Some(PathBuf::from(other)); index += 1; } other => { // #784: use typed prefix so classify_error_kind returns unexpected_extra_args return Err(format!("unexpected_extra_args: unexpected export argument: {other}.\nUsage: claw export [PATH] [--session SESSION] [--output PATH]")); } } } Ok(CliAction::Export { session_reference, output_path, output_format, }) } pub(crate) 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("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir [--output-format json]"))?; manifests_dir = Some(PathBuf::from(value)); index += 2; continue; } if let Some(value) = arg.strip_prefix("--manifests-dir=") { if value.is_empty() { // #786: empty --manifests-dir= is also a missing value return Err(String::from("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir [--output-format json]")); } manifests_dir = Some(PathBuf::from(value)); index += 1; continue; } return Err(format!("unknown_option: unknown dump-manifests option: {arg}.\nRun `claw dump-manifests --help` for usage.")); } Ok(CliAction::DumpManifests { output_format, manifests_dir, }) } pub(crate) fn parse_resume_args( args: &[String], output_format: CliOutputFormat, allow_broad_cwd: bool, ) -> 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() { // #768: typed prefix + \n hint so split_error_hint() extracts hint into JSON envelope return Err(format!( "invalid_resume_argument: `{token}` is not a slash command.\nUsage: claw --resume / (e.g. /compact, /status)" )); } 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, allow_broad_cwd, }) } pub(crate) fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { matches!( SlashCommand::parse(current_command), Ok(Some(SlashCommand::Export { path: None })) ) && !looks_like_slash_command_token(token) } pub(crate) fn looks_like_slash_command_token(token: &str) -> bool { let trimmed = token.trim_start(); let Some(name) = trimmed.strip_prefix('/').and_then(|value| { value .split_whitespace() .next() .map(str::trim) .filter(|value| !value.is_empty()) }) else { return false; }; slash_command_specs() .iter() .any(|spec| spec.name == name || spec.aliases.contains(&name)) } #[allow(clippy::too_many_lines)] pub(crate) fn resume_session( session_path: &Path, commands: &[String], output_format: CliOutputFormat, ) { let session_reference = session_path.display().to_string(); let (handle, session) = match load_session_reference(&session_reference) { Ok(loaded) => loaded, Err(error) => { if output_format == CliOutputFormat::Json { // #77: classify session load errors for downstream consumers let full_message = format!("failed to restore session: {error}"); let kind = classify_error_kind(&full_message); let (short_reason, inline_hint) = split_error_hint(&full_message); // #787: fall back to kind-derived hint when message has no \n delimiter let hint = inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from)); let sessions_dir = sessions_dir().ok().map(|path| path.display().to_string()); // #819: JSON mode resume errors go to stdout for parity with other // non-interactive command guards. println!( "{}", serde_json::json!({ "kind": kind, "action": "restore", "status": "error", "error_kind": kind, "error": short_reason, "exit_code": 1, "hint": hint, "sessions_dir": sessions_dir, }) ); } else { eprintln!("failed to restore session: {error}"); } std::process::exit(1); } }; let resolved_path = handle.path.clone(); if commands.is_empty() { if output_format == CliOutputFormat::Json { println!( "{}", serde_json::json!({ "kind": "restored", "action": "restore", "status": "ok", "session_id": session.session_id, "path": handle.path.display().to_string(), "message_count": session.messages.len(), }) ); } else { println!( "Restored session from {} ({} messages).", handle.path.display(), session.messages.len() ); } return; } let mut session = session; for raw_command in commands { // Intercept spec commands that have no parse arm before calling // SlashCommand::parse — they return Err(SlashCommandParseError) which // formats as the confusing circular "Did you mean /X?" message. // STUB_COMMANDS covers both completions-filtered stubs and parse-less // spec entries; treat both as unsupported in resume mode. { let cmd_root = raw_command .trim_start_matches('/') .split_whitespace() .next() .unwrap_or(""); if STUB_COMMANDS.contains(&cmd_root) { if output_format == CliOutputFormat::Json { println!( "{}", serde_json::json!({ "kind": "unsupported_command", "action": "resume", "status": "error", "error_kind": "unsupported_command", "error": format!("/{cmd_root} is not yet implemented in this build"), "hint": "This command is not available in the current build. Update claw or use a different command.", "exit_code": 2, "command": raw_command, }) ); } else { eprintln!("/{cmd_root} is not yet implemented in this build"); } std::process::exit(2); } } let command = match SlashCommand::parse(raw_command) { Ok(Some(command)) => command, Ok(None) => { if output_format == CliOutputFormat::Json { println!( "{}", serde_json::json!({ "kind": "unsupported_resumed_command", "action": "resume", "status": "error", "error_kind": "unsupported_resumed_command", "error": format!("unsupported resumed command: {raw_command}"), "hint": "This command cannot be used with --resume. Use it in an interactive REPL session instead.", "exit_code": 2, "command": raw_command, }) ); } else { eprintln!("unsupported resumed command: {raw_command}"); } std::process::exit(2); } Err(error) => { if output_format == CliOutputFormat::Json { println!( "{}", serde_json::json!({ "kind": "cli_parse", "action": "resume", "status": "error", "error_kind": "cli_parse", "error": error.to_string(), "hint": "Run `claw --help` for usage.", "exit_code": 2, "command": raw_command, }) ); } else { eprintln!("{error}"); } std::process::exit(2); } }; match run_resume_command(&resolved_path, &session, &command) { Ok(ResumeCommandOutcome { session: next_session, message, json, }) => { session = next_session; if output_format == CliOutputFormat::Json { if let Some(value) = json { println!( "{}", serde_json::to_string_pretty(&value) .expect("resume command json output") ); } else if let Some(message) = message { println!("{message}"); } } else if let Some(message) = message { println!("{message}"); } } Err(error) => { if output_format == CliOutputFormat::Json { // #776: classify + split so wrappers get typed fields instead of // hardcoded "resume_command_error" + prose in the error field let full_error = error.to_string(); let error_kind = classify_error_kind(&full_error); let (short_reason, inline_hint) = split_error_hint(&full_error); // #787: fall back to kind-derived hint when error has no \n delimiter let hint = inline_hint .or_else(|| fallback_hint_for_error_kind(error_kind).map(String::from)); println!( "{}", serde_json::json!({ "kind": error_kind, "action": "resume", "status": "error", "error_kind": error_kind, "error": short_reason, "hint": hint, "exit_code": 2, "command": raw_command, }) ); } else { eprintln!("{error}"); } std::process::exit(2); } } } } #[cfg(test)] pub(crate) fn format_unknown_slash_command_message(name: &str) -> String { let suggestions = suggest_slash_commands(name); let mut message = format!("unknown slash command: /{name}."); if !suggestions.is_empty() { message.push_str(" Did you mean "); message.push_str(&suggestions.join(", ")); message.push('?'); } if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { message.push(' '); message.push_str(note); } message.push_str(" Use /help to list available commands."); message } pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { format!( "Model Current model {model} Session messages {message_count} Session turns {turns} Usage Inspect current model with /model Switch models with /model " ) } pub(crate) fn format_model_switch_report( previous: &str, next: &str, message_count: usize, ) -> String { format!( "Model updated Previous {previous} Current {next} Preserved msgs {message_count}" ) } pub(crate) fn format_permissions_report(mode: &str) -> String { let modes = [ ("read-only", "Read/search tools only", mode == "read-only"), ( "workspace-write", "Edit files inside the workspace", mode == "workspace-write", ), ( "danger-full-access", "Unrestricted tool access", mode == "danger-full-access", ), ] .into_iter() .map(|(name, description, is_current)| { let marker = if is_current { "● current" } else { "○ available" }; format!(" {name:<18} {marker:<11} {description}") }) .collect::>() .join( " ", ); format!( "Permissions Active mode {mode} Mode status live session default Modes {modes} Usage Inspect current mode with /permissions Switch modes with /permissions " ) } pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String { format!( "Permissions updated Result mode switched Previous mode {previous} Active mode {next} Applies to subsequent tool calls Usage /permissions to inspect current mode" ) } pub(crate) fn format_cost_report(usage: TokenUsage) -> String { let estimated_cost = usage.estimate_cost_usd(); format!( "Cost Input tokens {} Output tokens {} Cache create {} Cache read {} Total tokens {} Estimated cost {}", usage.input_tokens, usage.output_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens, usage.total_tokens(), format_usd(estimated_cost.total_cost_usd()), ) } pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { format!( "Session resumed Session file {session_path} Messages {message_count} Turns {turns}" ) } pub(crate) fn render_resume_usage() -> String { format!( "Resume Usage /resume Auto-save .claw/sessions//.{PRIMARY_SESSION_EXTENSION} Tip use /session list to inspect saved sessions" ) } pub(crate) fn format_compact_report( removed: usize, resulting_messages: usize, skipped: bool, ) -> String { if skipped { format!( "Compact Result skipped Reason session below compaction threshold Messages kept {resulting_messages}" ) } else { format!( "Compact Result compacted Messages removed {removed} Messages kept {resulting_messages}" ) } } #[derive(Debug, Clone)] pub(crate) struct PromptHistoryEntry { pub(crate) timestamp_ms: u64, pub(crate) text: String, } pub(crate) fn render_repl_help() -> String { [ "REPL".to_string(), " /exit Quit the REPL".to_string(), " /quit Quit the REPL".to_string(), " Up/Down Navigate prompt history".to_string(), " Ctrl-R Reverse-search prompt history".to_string(), " Tab Complete commands, modes, and recent sessions".to_string(), " Ctrl-C Clear input (or exit on empty prompt)".to_string(), " Shift+Enter/Ctrl+J Insert a newline".to_string(), " Auto-save .claw/sessions//.jsonl" .to_string(), " Resume latest /resume latest".to_string(), " Browse sessions /session list".to_string(), " Show prompt history /history [count]".to_string(), String::new(), render_slash_command_help_filtered(STUB_COMMANDS), ] .join( " ", ) } pub(crate) fn format_commit_skipped_report() -> String { "Commit Result skipped Reason no workspace changes Action create a git commit from the current workspace changes Next /status to inspect context · /diff to inspect repo changes" .to_string() } pub(crate) fn render_help_topic(topic: LocalHelpTopic) -> String { match topic { LocalHelpTopic::Status => "Status Usage claw status [--output-format ] Purpose show the local workspace snapshot without entering the REPL Output model, permissions, git state, config files, and sandbox status Formats text (default), json Related /status · claw --resume latest /status" .to_string(), LocalHelpTopic::Sandbox => "Sandbox Usage claw sandbox [--output-format ] Purpose inspect the resolved sandbox and isolation state for the current directory Output namespace, network, filesystem, and fallback details Formats text (default), json Related /sandbox · claw status" .to_string(), LocalHelpTopic::Doctor => "Doctor Usage claw doctor [--output-format ] Purpose diagnose local auth, config, workspace, sandbox, and build metadata Output local-only health report; no provider request or session resume required Formats text (default), json Related /doctor · claw --resume latest /doctor" .to_string(), LocalHelpTopic::Acp => "ACP / Zed Usage claw acp [serve] [--output-format ] Aliases claw --acp · claw -acp Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime Status discoverability only; `serve` is a status alias and does not launch a daemon yet Formats text (default), json Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help" .to_string(), LocalHelpTopic::Init => "Init Usage claw init [--output-format ] Purpose create .claw/settings.json, .claw.json, .gitignore, and CLAUDE.md in the current project Output per-artifact created/updated/partial/deferred/skipped status (idempotent: safe to re-run) Formats text (default), json Related claw status · claw doctor" .to_string(), LocalHelpTopic::State => "State Usage claw state [--output-format ] Purpose read .claw/worker-state.json written by the interactive REPL or a one-shot prompt Output worker id, model, permissions, session reference (text or json) Formats text (default), json Produces state `claw` (interactive REPL) or `claw prompt ` (one non-interactive turn) Observes state `claw state` reads; clawhip/CI may poll this file without HTTP Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise Related claw status · ROADMAP #139 (this worker-concept contract)" .to_string(), LocalHelpTopic::Resume => format!( "Resume\n Usage claw resume [session-path|session-id|{LATEST_SESSION_REFERENCE}] [/slash-command ...] [--output-format ]\n Alias claw --resume [session-path|session-id|{LATEST_SESSION_REFERENCE}]\n Purpose restore or inspect a saved session without starting a new provider turn\n Output session restore or resume-safe command output; missing sessions return session_not_found\n Formats text (default), json\n Related /resume · /session list · claw --resume {LATEST_SESSION_REFERENCE} /status" ), LocalHelpTopic::Session => "Session Usage claw session --help [--output-format ] Purpose show /session command guidance without loading config, credentials, or a session Actions list · exists · switch · fork · delete Direct use run /session in the REPL or claw --resume SESSION.jsonl /session Formats text (default), json Related claw resume · claw export · .claw/sessions/" .to_string(), LocalHelpTopic::Compact => "Compact Usage claw compact --help [--output-format ] Purpose show compaction guidance without loading config, credentials, or a session Direct use run /compact in the REPL or claw --resume SESSION.jsonl /compact Output compaction removes older tool-detail messages when the selected session is large enough Formats text (default), json Related claw resume · /compact · /status" .to_string(), LocalHelpTopic::Export => "Export Usage claw export [--session ] [--output ] [--output-format ] Purpose serialize a managed session to JSON for review, transfer, or archival Defaults --session latest (most recent managed session in .claw/sessions/) Formats text (default), json Related /session list · claw --resume latest" .to_string(), LocalHelpTopic::Version => "Version Usage claw version [--output-format ] Aliases claw --version · claw -V Purpose print the claw CLI version and build metadata Formats text (default), json Related claw doctor (full build/auth/config diagnostic)" .to_string(), LocalHelpTopic::SystemPrompt => "System Prompt Usage claw system-prompt [--cwd ] [--date YYYY-MM-DD] [--output-format ] Purpose render the resolved system prompt that `claw` would send for the given cwd + date Options --cwd overrides the workspace dir · --date injects a deterministic date stamp Formats text (default), json Related claw doctor · claw dump-manifests" .to_string(), LocalHelpTopic::DumpManifests => "Dump Manifests Usage claw dump-manifests [--manifests-dir ] [--output-format ] Purpose emit every skill/agent/tool manifest the resolver would load for the current cwd Options --manifests-dir scopes discovery to a specific directory Formats text (default), json Related claw skills · claw agents · claw doctor" .to_string(), LocalHelpTopic::BootstrapPlan => "Bootstrap Plan Usage claw bootstrap-plan [--output-format ] Purpose list the ordered startup phases the CLI would execute before dispatch Output phase names (text) or structured phase list (json) — primary output is the plan itself Formats text (default), json Related claw doctor · claw status" .to_string(), LocalHelpTopic::Agents => commands::handle_agents_slash_command( Some("--help"), &env::current_dir().unwrap_or_default(), ) .unwrap_or_else(|_| "agents help unavailable".to_string()), LocalHelpTopic::Skills => commands::handle_skills_slash_command( Some("--help"), &env::current_dir().unwrap_or_default(), ) .unwrap_or_else(|_| "skills help unavailable".to_string()), LocalHelpTopic::Plugins => "Plugins Usage claw plugins [list|show |install |enable |disable |uninstall ] Purpose manage lifecycle of plugins that extend tool and hook capabilities Formats text (default), json Related /plugins · claw plugins --help" .to_string(), LocalHelpTopic::Mcp => "MCP Servers Usage claw mcp [list|show ] [--output-format ] Purpose inspect configured MCP servers and their connection status Formats text (default), json Related /mcp · claw mcp list" .to_string(), LocalHelpTopic::Config => "Config Usage claw config [section] [--output-format ] Purpose show effective runtime configuration (model, hooks, plugins, env) Formats text (default), json Related /config · claw doctor" .to_string(), LocalHelpTopic::Model => "Models Usage claw models [help] [--output-format ] Aliases claw model Purpose show bounded local model command guidance without entering the REPL Output supported model-selection surfaces and current config model value Formats text (default), json Related /model · claw config model · claw status" .to_string(), LocalHelpTopic::Settings => "Settings Usage claw settings [help] [--output-format ] Purpose show effective settings/config using the local config envelope Output same as claw config settings; no provider request or session resume required Formats text (default), json Related claw config · claw doctor" .to_string(), LocalHelpTopic::Diff => "Diff Usage claw diff [--output-format ] Purpose show the diff of changes relative to the expected base commit Formats text (default), json Related /diff · ROADMAP #148" .to_string(), LocalHelpTopic::Setup => "Setup Usage claw setup Aliases /setup (inside the REPL) Purpose run the interactive provider setup wizard to configure API key, model, and base URL Output writes provider settings to ~/.claw/settings.json (0600 permissions) Related /model · /config · claw doctor" .to_string(), } } pub(crate) fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str { match topic { LocalHelpTopic::Status => "status", LocalHelpTopic::Sandbox => "sandbox", LocalHelpTopic::Doctor => "doctor", LocalHelpTopic::Acp => "acp", LocalHelpTopic::Init => "init", LocalHelpTopic::State => "state", LocalHelpTopic::Resume => "resume", LocalHelpTopic::Session => "session", LocalHelpTopic::Compact => "compact", LocalHelpTopic::Export => "export", LocalHelpTopic::Version => "version", LocalHelpTopic::SystemPrompt => "system-prompt", LocalHelpTopic::DumpManifests => "dump-manifests", LocalHelpTopic::BootstrapPlan => "bootstrap-plan", LocalHelpTopic::Agents => "agents", LocalHelpTopic::Skills => "skills", LocalHelpTopic::Plugins => "plugins", LocalHelpTopic::Mcp => "mcp", LocalHelpTopic::Config => "config", LocalHelpTopic::Model => "models", LocalHelpTopic::Settings => "settings", LocalHelpTopic::Diff => "diff", LocalHelpTopic::Setup => "setup", } } pub(crate) fn render_export_help_json() -> serde_json::Value { json!({ "kind": "help", "action": "help", "status": "ok", "topic": "export", "command": "export", "usage": "claw export [--session ] [--output ] [--output-format ]", "purpose": "serialize a managed session to JSON for review, transfer, or archival", "defaults": { "session": LATEST_SESSION_REFERENCE, "session_source": ".claw/sessions/", "output": "derived from the selected session when omitted" }, "formats": ["text", "json"], "options": [ { "name": "--session", "value": "", "default": LATEST_SESSION_REFERENCE, "description": "managed session to export" }, { "name": "--output", "aliases": ["-o"], "value": "", "description": "write the exported transcript to this path" }, { "name": "--output-format", "value": "", "values": ["text", "json"], "default": "text", "description": "format for the command result envelope" }, { "name": "--help", "aliases": ["-h"], "description": "show help for the export command" } ], "related": ["/session list", "claw --resume latest"] }) } pub(crate) fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value { if topic == LocalHelpTopic::Export { return render_export_help_json(); } if topic == LocalHelpTopic::Doctor { return render_doctor_help_json(); } // #683-#692: extract structured metadata from help prose for machine consumption let (usage, purpose, output_desc, formats, related, aliases, local_only, requires_credentials) = extract_help_metadata(topic); let mut obj = serde_json::json!({ "kind": "help", "action": "help", "status": "ok", "topic": local_help_topic_command(topic), "command": local_help_topic_command(topic), "message": render_help_topic(topic), "usage": usage, "purpose": purpose, "formats": formats, "related": related, "local_only": local_only, "requires_credentials": requires_credentials, }); if let Some(desc) = output_desc { obj["output_fields"] = serde_json::Value::String(desc); } if let Some(a) = aliases { obj["aliases"] = serde_json::json!(a); } obj } pub(crate) fn acp_status_message() -> &'static str { "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` reports status only and does not launch a daemon or JSON-RPC endpoint. Use the normal terminal surfaces for now." } pub(crate) fn acp_status_json() -> serde_json::Value { json!({ "schema_version": "1.0", "kind": "acp", "action": "status", "status": "not_implemented", "supported": false, "message": acp_status_message(), "launch_command": serde_json::Value::Null, "protocol": { "name": "ACP/Zed", "json_rpc": false, "daemon": false, "endpoint": serde_json::Value::Null, "serve_starts_daemon": false }, "contracts": { "blocking_gates": [ "task_packet_schema", "session_control_schema", "event_report_schema" ], "stable_status_surface": "claw acp [serve] --output-format json", "unsupported_invocation_kind": "unsupported_acp_invocation" }, "aliases": ["acp", "--acp", "-acp"], }) } pub(crate) fn render_memory_report() -> Result> { let cwd = env::current_dir()?; let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; let mut lines = vec![format!( "Memory Working directory {} Instruction files {}", cwd.display(), project_context.instruction_files.len() )]; if project_context.instruction_files.is_empty() { lines.push("Discovered files".to_string()); lines.push( " No CLAUDE.md, CLAW.md, AGENTS.md, or scoped instruction files discovered in the current directory ancestry." .to_string(), ); } else { lines.push("Discovered files".to_string()); for (index, file) in project_context.instruction_files.iter().enumerate() { let preview = file.content.lines().next().unwrap_or("").trim(); let preview = if preview.is_empty() { "" } else { preview }; lines.push(format!(" {}. {}", index + 1, file.path.display(),)); lines.push(format!( " source={} lines={} chars={} preview={}", file.source(), file.content.lines().count(), file.char_count(), preview )); } } Ok(lines.join( " ", )) } pub(crate) fn normalize_permission_mode(mode: &str) -> Option<&'static str> { match mode.trim() { "default" | "plan" | "read-only" => Some("read-only"), "acceptEdits" | "auto" | "workspace-write" => Some("workspace-write"), "dontAsk" | "bypassPermissions" | "dangerFullAccess" | "danger-full-access" => { Some("danger-full-access") } _ => None, } } pub(crate) fn indent_block(value: &str, spaces: usize) -> String { let indent = " ".repeat(spaces); value .lines() .map(|line| format!("{indent}{line}")) .collect::>() .join("\n") } pub(crate) fn parse_history_count(raw: Option<&str>) -> Result { let Some(raw) = raw else { return Ok(DEFAULT_HISTORY_LIMIT); }; // #776: use \n-delimited format so split_error_hint extracts hint into JSON envelopes let parsed: usize = raw .parse() .map_err(|_| format!("invalid_history_count: '{raw}' is not a positive integer.\nUsage: /history [count] (default: {DEFAULT_HISTORY_LIMIT})"))?; if parsed == 0 { return Err(format!("invalid_history_count: count must be greater than 0.\nUsage: /history [count] (default: {DEFAULT_HISTORY_LIMIT})")); } Ok(parsed) } pub(crate) fn format_history_timestamp(timestamp_ms: u64) -> String { let secs = timestamp_ms / 1_000; let subsec_ms = timestamp_ms % 1_000; let days_since_epoch = secs / 86_400; let seconds_of_day = secs % 86_400; let hours = seconds_of_day / 3_600; let minutes = (seconds_of_day % 3_600) / 60; let seconds = seconds_of_day % 60; let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0)); format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z") } // Computes civil (Gregorian) year/month/day from days since the Unix epoch // (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm. #[allow( clippy::cast_sign_loss, clippy::cast_possible_wrap, clippy::cast_possible_truncation )] pub(crate) fn civil_from_days(days: i64) -> (i32, u32, u32) { let z = days + 719_468; let era = if z >= 0 { z / 146_097 } else { (z - 146_096) / 146_097 }; let doe = (z - era * 146_097) as u64; // [0, 146_096] let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] let y = yoe as i64 + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] let mp = (5 * doy + 2) / 153; // [0, 11] let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] let y = y + i64::from(m <= 2); (y as i32, m as u32, d as u32) } pub(crate) fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String { if entries.is_empty() { return "Prompt history\n Result no prompts recorded yet".to_string(); } let total = entries.len(); let start = total.saturating_sub(limit); let shown = &entries[start..]; let mut lines = vec![ "Prompt history".to_string(), format!(" Total {total}"), format!(" Showing {} most recent", shown.len()), format!(" Reverse search Ctrl-R in the REPL"), String::new(), ]; for (offset, entry) in shown.iter().enumerate() { let absolute_index = start + offset + 1; let timestamp = format_history_timestamp(entry.timestamp_ms); let first_line = entry.text.lines().next().unwrap_or("").trim(); let display = if first_line.chars().count() > 80 { let truncated: String = first_line.chars().take(77).collect(); format!("{truncated}...") } else { first_line.to_string() }; lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}")); } lines.join("\n") } pub(crate) fn collect_session_prompt_history(session: &Session) -> Vec { if !session.prompt_history.is_empty() { return session .prompt_history .iter() .map(|entry| PromptHistoryEntry { timestamp_ms: entry.timestamp_ms, text: entry.text.clone(), }) .collect(); } let timestamp_ms = session.updated_at_ms; session .messages .iter() .filter(|message| message.role == MessageRole::User) .filter_map(|message| { message.blocks.iter().find_map(|block| match block { ContentBlock::Text { text } => Some(PromptHistoryEntry { timestamp_ms, text: text.clone(), }), _ => None, }) }) .collect() } pub(crate) fn truncate_for_prompt(value: &str, limit: usize) -> String { if value.chars().count() <= limit { value.trim().to_string() } else { let truncated = value.chars().take(limit).collect::(); format!("{}\n…[truncated]", truncated.trim_end()) } } pub(crate) fn sanitize_generated_message(value: &str) -> String { value.trim().trim_matches('`').trim().replace("\r\n", "\n") } pub(crate) fn parse_titled_body(value: &str) -> Option<(String, String)> { let normalized = sanitize_generated_message(value); let title = normalized .lines() .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?; let body_start = normalized.find("BODY:")?; let body = normalized[body_start + "BODY:".len()..].trim(); Some((title.to_string(), body.to_string())) } pub(crate) fn short_tool_id(id: &str) -> String { let char_count = id.chars().count(); if char_count <= 12 { return id.to_string(); } let prefix: String = id.chars().take(12).collect(); format!("{prefix}…") } pub(crate) struct CliPermissionPrompter { pub(crate) current_mode: PermissionMode, } impl CliPermissionPrompter { pub(crate) fn new(current_mode: PermissionMode) -> Self { Self { current_mode } } } impl runtime::PermissionPrompter for CliPermissionPrompter { fn decide( &mut self, request: &runtime::PermissionRequest, ) -> runtime::PermissionPromptDecision { println!(); println!("Permission approval required"); println!(" Tool {}", request.tool_name); println!(" Current mode {}", self.current_mode.as_str()); println!(" Required mode {}", request.required_mode.as_str()); if let Some(reason) = &request.reason { println!(" Reason {reason}"); } println!(" Input {}", request.input); print!("Approve this tool call? [y/N]: "); let _ = io::stdout().flush(); let mut response = String::new(); match io::stdin().read_line(&mut response) { Ok(_) => { let normalized = response.trim().to_ascii_lowercase(); if matches!(normalized.as_str(), "y" | "yes") { runtime::PermissionPromptDecision::Allow } else { runtime::PermissionPromptDecision::Deny { reason: format!( "tool '{}' denied by user approval prompt", request.tool_name ), } } } Err(error) => runtime::PermissionPromptDecision::Deny { reason: format!("permission approval failed: {error}"), }, } } } pub(crate) fn slash_command_completion_candidates_with_sessions( model: &str, active_session_id: Option<&str>, recent_session_ids: Vec, ) -> Vec { let mut completions = BTreeSet::new(); for spec in slash_command_specs() { if STUB_COMMANDS.contains(&spec.name) { continue; } completions.insert(format!("/{}", spec.name)); for alias in spec.aliases { if !STUB_COMMANDS.contains(alias) { completions.insert(format!("/{alias}")); } } } for candidate in [ "/bughunter ", "/clear --confirm", "/config ", "/config env", "/config hooks", "/config model", "/config plugins", "/mcp ", "/mcp list", "/mcp show ", "/export ", "/issue ", "/model ", "/model opus", "/model sonnet", "/model haiku", "/permissions ", "/permissions read-only", "/permissions workspace-write", "/permissions danger-full-access", "/plugin list", "/plugin install ", "/plugin enable ", "/plugin disable ", "/plugin uninstall ", "/plugin update ", "/plugins list", "/pr ", "/resume ", "/session list", "/session switch ", "/session fork ", "/teleport ", "/ultraplan ", "/agents help", "/mcp help", "/skills help", ] { completions.insert(candidate.to_string()); } if !model.trim().is_empty() { completions.insert(format!("/model {}", resolve_model_alias(model))); completions.insert(format!("/model {model}")); } if let Some(active_session_id) = active_session_id.filter(|value| !value.trim().is_empty()) { completions.insert(format!("/resume {active_session_id}")); completions.insert(format!("/session switch {active_session_id}")); } for session_id in recent_session_ids .into_iter() .filter(|value| !value.trim().is_empty()) .take(10) { completions.insert(format!("/resume {session_id}")); completions.insert(format!("/session switch {session_id}")); } completions.into_iter().collect() }