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

3114 lines
126 KiB
Rust

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<String>) {
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<PathBuf>,
},
BootstrapPlan {
output_format: CliOutputFormat,
},
Agents {
args: Option<String>,
output_format: CliOutputFormat,
},
Mcp {
args: Option<String>,
output_format: CliOutputFormat,
},
Skills {
args: Option<String>,
output_format: CliOutputFormat,
},
Plugins {
action: Option<String>,
target: Option<String>,
output_format: CliOutputFormat,
},
PrintSystemPrompt {
cwd: PathBuf,
date: String,
model: String,
output_format: CliOutputFormat,
},
Version {
output_format: CliOutputFormat,
},
SessionList {
output_format: CliOutputFormat,
},
ResumeSession {
session_path: PathBuf,
commands: Vec<String>,
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<String>,
permission_mode: PermissionModeProvenance,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
},
Sandbox {
output_format: CliOutputFormat,
},
Prompt {
prompt: String,
model: String,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
compact: bool,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
},
Doctor {
output_format: CliOutputFormat,
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<String>,
output_format: CliOutputFormat,
},
Models {
action: Option<String>,
output_format: CliOutputFormat,
},
Diff {
output_format: CliOutputFormat,
},
Export {
session_reference: String,
output_path: Option<PathBuf>,
output_format: CliOutputFormat,
},
Repl {
model: String,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
},
HelpTopic {
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 <subcommand> --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<CliAction, String> {
let mut model = DEFAULT_MODEL.to_string();
// #148: when user passes --model/--model=, capture the raw input so we
// can attribute source: "flag" later. None means no flag was supplied.
let mut model_flag_raw: Option<String> = None;
let mut output_format_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<String> = None;
let mut reasoning_effort: Option<String> = None;
let mut allow_broad_cwd = false;
// #755: -p prompt text captured as single token; remaining args continue
// flag parsing. None until `-p <text>` is seen.
let mut short_p_prompt: Option<String> = None;
let mut rest: Vec<String> = 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 <provider/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 <git-sha>".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 <git-sha>",
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 <text> or claw prompt <text>".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 <text> --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 -- <text>".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 <text> or claw prompt <text>".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 \"<prompt>\" or echo \"<prompt>\" | 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 <id>|install <id>|enable <id>|disable <id>|uninstall <id>|update <id>|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 <mode>` falls through to the LLM when called
// with a subcommand argument because parse_single_word_command_alias
// only intercepts the bare single-word form. Catch all multi-word
// forms here and return a structured guidance error so no network
// call or session is created.
"permissions" => Err(
"`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]"
.to_string(),
),
// #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 <action>` 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 <name>, install <path>, uninstall <name>, help, or <skill> [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 <dir>] [--date <date>] [--session <session-id>]"
));
}
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::<Vec<_>>();
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 <text> or echo '<text>' | 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 <text>`.",
);
return Err(message);
}
// #147: guard empty/whitespace-only prompts at the fallthrough
// path the same way `"prompt"` arm above does. Without this,
// `claw ""`, `claw " "`, and `claw "" ""` silently route to
// the Anthropic call and surface a misleading
// `missing Anthropic credentials` error (or burn API tokens on
// an empty prompt when credentials are present).
let joined = rest.join(" ");
if joined.trim().is_empty() {
// #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 <subcommand> or claw -p <prompt>. 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<Result<CliAction, String>> {
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<PermissionMode>,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
) -> Option<Result<CliAction, String>> {
if rest.is_empty() {
return None;
}
// Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself
// or --help / -h as a suffix. Any other suffix args are unrecognized.
let verb = &rest[0];
let is_diagnostic = matches!(
verb.as_str(),
"help" | "version" | "status" | "sandbox" | "doctor" | "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 <topic>` — 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 <topic>` — 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<String> {
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<CliAction, String> {
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<String> {
let joined = args.join(" ");
let trimmed = joined.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
pub(crate) fn parse_direct_slash_cli_action(
rest: &[String],
model: String,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionModeProvenance,
compact: bool,
base_commit: Option<String>,
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
) -> Result<CliAction, String> {
let raw = rest.join(" ");
match SlashCommand::parse(&raw) {
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }),
Ok(Some(SlashCommand::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<String> {
(!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),))
}
pub(crate) fn suggest_slash_commands(input: &str) -> Vec<String> {
let mut candidates = slash_command_specs()
.iter()
.flat_map(|spec| {
std::iter::once(spec.name)
.chain(spec.aliases.iter().copied())
.map(|name| format!("/{name}"))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
candidates.sort();
candidates.dedup();
let candidate_refs = candidates.iter().map(String::as_str).collect::<Vec<_>>();
ranked_suggestions(input.trim_start_matches('/'), &candidate_refs)
.into_iter()
.map(str::to_string)
.collect()
}
pub(crate) fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> {
ranked_suggestions(input, candidates).into_iter().next()
}
pub(crate) fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
const KNOWN_SUBCOMMANDS: &[&str] = &[
"help",
"version",
"status",
"sandbox",
"doctor",
"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::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
ranked.dedup_by(|left, right| left.1 == right.1);
let suggestions = ranked
.into_iter()
.map(|(_, candidate)| candidate.to_string())
.take(3)
.collect::<Vec<_>>();
(!suggestions.is_empty()).then_some(suggestions)
}
pub(crate) fn common_prefix_len(left: &str, right: &str) -> usize {
left.chars()
.zip(right.chars())
.take_while(|(l, r)| l == r)
.count()
}
pub(crate) fn looks_like_subcommand_typo(input: &str) -> bool {
!input.is_empty()
&& input
.chars()
.all(|ch| ch.is_ascii_alphabetic() || ch == '-')
}
pub(crate) fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> {
let normalized_input = input.trim_start_matches('/').to_ascii_lowercase();
let mut ranked = candidates
.iter()
.filter_map(|candidate| {
let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase();
let distance = levenshtein_distance(&normalized_input, &normalized_candidate);
let prefix_bonus = usize::from(
!(normalized_candidate.starts_with(&normalized_input)
|| normalized_input.starts_with(&normalized_candidate)),
);
let score = distance + prefix_bonus;
(score <= 4).then_some((score, *candidate))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
ranked
.into_iter()
.map(|(_, candidate)| candidate)
.take(3)
.collect()
}
pub(crate) fn levenshtein_distance(left: &str, right: &str) -> usize {
if left.is_empty() {
return right.chars().count();
}
if right.is_empty() {
return left.chars().count();
}
let right_chars = right.chars().collect::<Vec<_>>();
let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
let mut current = vec![0; right_chars.len() + 1];
for (left_index, left_char) in left.chars().enumerate() {
current[0] = left_index + 1;
for (right_index, right_char) in right_chars.iter().enumerate() {
let substitution_cost = usize::from(left_char != *right_char);
current[right_index + 1] = (previous[right_index + 1] + 1)
.min(current[right_index] + 1)
.min(previous[right_index] + substitution_cost);
}
previous.clone_from(&current);
}
previous[right_chars.len()]
}
/// 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 <model-name> 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 <provider/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<PermissionMode, String> {
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<PermissionMode> {
let cwd = env::current_dir().ok()?;
let loader = ConfigLoader::default_for(&cwd);
loader
.load()
.ok()?
.permission_mode()
.map(permission_mode_from_resolved)
}
pub(crate) fn 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<CliAction, String> {
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
let mut date = DEFAULT_DATE.to_string();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--cwd" => {
let value = args.get(index + 1).ok_or_else(|| {
"missing_flag_value: missing value for --cwd.\nUsage: --cwd <path>".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 <existing-directory>"
));
}
if !cwd.is_dir() {
return Err(format!(
"invalid_cwd: path '{value}' is not a directory.\nUsage: claw system-prompt --cwd <existing-directory>"
));
}
index += 2;
}
"--date" => {
let value = args.get(index + 1).ok_or_else(|| {
"missing_flag_value: missing value for --date.\nUsage: --date <YYYY-MM-DD>"
.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 <YYYY-MM-DD>"
));
}
if value.len() > 20 {
return Err(format!(
"invalid_flag_value: --date value is too long ({len} chars, expected YYYY-MM-DD).\nUsage: --date <YYYY-MM-DD>",
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 <dir>] [--date <YYYY-MM-DD>] [--output-format text|json]".to_string()
} else {
"Usage: claw system-prompt [--cwd <dir>] [--date <YYYY-MM-DD>] [--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<CliAction, String> {
let mut session_reference = LATEST_SESSION_REFERENCE.to_string();
let mut output_path: Option<PathBuf> = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--session" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing_flag_value: missing value for --session.\nUsage: --session <session-id>".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<CliAction, String> {
let mut manifests_dir: Option<PathBuf> = None;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
if arg == "--manifests-dir" {
let value = args
.get(index + 1)
.ok_or_else(|| String::from("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir <path> [--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 <path> [--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<CliAction, String> {
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
Some(first) if looks_like_slash_command_token(first) => {
(PathBuf::from(LATEST_SESSION_REFERENCE), args)
}
Some(first) => (PathBuf::from(first), &args[1..]),
};
let mut commands = Vec::new();
let mut current_command = String::new();
for token in command_tokens {
if token.trim_start().starts_with('/') {
if resume_command_can_absorb_token(&current_command, token) {
current_command.push(' ');
current_command.push_str(token);
continue;
}
if !current_command.is_empty() {
commands.push(current_command);
}
current_command = String::from(token.as_str());
continue;
}
if current_command.is_empty() {
// #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 <session-id|latest> /<slash-command> (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 <name>"
)
}
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::<Vec<_>>()
.join(
"
",
);
format!(
"Permissions
Active mode {mode}
Mode status live session default
Modes
{modes}
Usage
Inspect current mode with /permissions
Switch modes with /permissions <mode>"
)
}
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 <session-path|session-id|{LATEST_SESSION_REFERENCE}>
Auto-save .claw/sessions/<workspace-fingerprint>/<session-id>.{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/<workspace-fingerprint>/<session-id>.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 <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 <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 <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 <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 <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 <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 <text>` (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 <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 <format>]
Purpose show /session command guidance without loading config, credentials, or a session
Actions list · exists <id> · switch <id> · fork <name> · delete <id>
Direct use run /session in the REPL or claw --resume SESSION.jsonl /session <action>
Formats text (default), json
Related claw resume · claw export · .claw/sessions/"
.to_string(),
LocalHelpTopic::Compact => "Compact
Usage claw compact --help [--output-format <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 <id|latest>] [--output <path>] [--output-format <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 <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 <path>] [--date YYYY-MM-DD] [--output-format <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 <path>] [--output-format <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 <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 <name>|install <path>|enable <name>|disable <name>|uninstall <name>]
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 <server>] [--output-format <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 <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 <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 <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 <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 <id|latest>] [--output <path>] [--output-format <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": "<id|latest>",
"default": LATEST_SESSION_REFERENCE,
"description": "managed session to export"
},
{
"name": "--output",
"aliases": ["-o"],
"value": "<path>",
"description": "write the exported transcript to this path"
},
{
"name": "--output-format",
"value": "<format>",
"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<String, Box<dyn std::error::Error>> {
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() {
"<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::<Vec<_>>()
.join("\n")
}
pub(crate) fn parse_history_count(raw: Option<&str>) -> Result<usize, String> {
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<PromptHistoryEntry> {
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::<String>();
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<String>,
) -> Vec<String> {
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()
}