diff --git a/rust/crates/rusty-claude-cli/src/args.rs b/rust/crates/rusty-claude-cli/src/args.rs new file mode 100644 index 00000000..90a697fc --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/args.rs @@ -0,0 +1,1227 @@ +use std::env; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +use api::ToolDefinition; +use commands::{ + classify_skills_slash_command, resolve_skill_invocation, slash_command_specs, + SkillSlashDispatch, SlashCommand, +}; +use runtime::{ConfigLoader, PermissionMode, ResolvedPermissionMode}; +use serde_json::{json, Value}; +use tools::GlobalToolRegistry; + +use crate::build_runtime_plugin_state_with_loader; +use crate::constants::*; +use crate::looks_like_slash_command_token; +use crate::model::*; +use crate::normalize_permission_mode; +use crate::resume_command_can_absorb_token; + +pub(crate) enum CliAction { + DumpManifests { + output_format: CliOutputFormat, + manifests_dir: Option, + }, + BootstrapPlan { + output_format: CliOutputFormat, + }, + Agents { + args: Option, + output_format: CliOutputFormat, + }, + Mcp { + args: Option, + output_format: CliOutputFormat, + }, + Skills { + args: Option, + output_format: CliOutputFormat, + }, + Plugins { + action: Option, + target: Option, + output_format: CliOutputFormat, + }, + PrintSystemPrompt { + cwd: PathBuf, + date: String, + output_format: CliOutputFormat, + }, + Version { + output_format: CliOutputFormat, + }, + ResumeSession { + session_path: PathBuf, + commands: Vec, + output_format: CliOutputFormat, + }, + Status { + model: String, + // #148: raw `--model` flag input (pre-alias-resolution), if any. + // None means no flag was supplied; env/config/default fallback is + // resolved inside `print_status_snapshot`. + model_flag_raw: Option, + permission_mode: PermissionMode, + output_format: CliOutputFormat, + allowed_tools: Option, + }, + Sandbox { + output_format: CliOutputFormat, + }, + Prompt { + prompt: String, + model: String, + output_format: CliOutputFormat, + allowed_tools: Option, + permission_mode: PermissionMode, + compact: bool, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, + }, + Doctor { + output_format: CliOutputFormat, + }, + Acp { + output_format: CliOutputFormat, + }, + State { + output_format: CliOutputFormat, + }, + Init { + output_format: CliOutputFormat, + }, + // #146: `claw config` and `claw diff` are pure-local read-only + // introspection commands; wire them as standalone CLI subcommands. + Config { + section: Option, + output_format: CliOutputFormat, + }, + Diff { + output_format: CliOutputFormat, + }, + Export { + session_reference: String, + output_path: Option, + output_format: CliOutputFormat, + }, + Repl { + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, + }, + HelpTopic(LocalHelpTopic), + // prompt-mode formatting is only supported for non-interactive runs + Help { + output_format: CliOutputFormat, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocalHelpTopic { + Status, + Sandbox, + Doctor, + Acp, + // #141: extend the local-help pattern to every subcommand so + // `claw --help` has one consistent contract. + Init, + State, + Export, + Version, + SystemPrompt, + DumpManifests, + BootstrapPlan, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CliOutputFormat { + Text, + Json, +} + +impl CliOutputFormat { + fn parse(value: &str) -> Result { + match value { + "text" => Ok(Self::Text), + "json" => Ok(Self::Json), + other => Err(format!( + "unsupported value for --output-format: {other} (expected text or json)" + )), + } + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn parse_args(args: &[String]) -> Result { + + let mut model = DEFAULT_MODEL.to_string(); + + // #148: when user passes --model/--model=, capture the raw input so we + // can attribute source: "flag" later. None means no flag was supplied. + let mut model_flag_raw: Option = None; + + let mut output_format = CliOutputFormat::Text; + let mut permission_mode_override = None; + let mut wants_help = false; + let mut wants_version = false; + let mut allowed_tool_values = Vec::new(); + let mut compact = false; + let mut base_commit: Option = None; + let mut reasoning_effort: Option = None; + let mut allow_broad_cwd = false; + let mut rest: Vec = Vec::new(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--help" | "-h" if rest.is_empty() => { + wants_help = true; + index += 1; + } + "--help" | "-h" + if !rest.is_empty() + && matches!(rest[0].as_str(), "prompt" | "commit" | "pr" | "issue") => + { + // `--help` following a subcommand that would otherwise forward + // the arg to the API (e.g. `claw prompt --help`) should show + // top-level help instead. Subcommands that consume their own + // args (agents, mcp, plugins, skills) and local help-topic + // subcommands (status, sandbox, doctor, init, state, export, + // version, system-prompt, dump-manifests, bootstrap-plan) must + // NOT be intercepted here — they handle --help in their own + // dispatch paths via parse_local_help_action(). See #141. + wants_help = true; + index += 1; + } + "--version" | "-V" => { + wants_version = true; + index += 1; + } + "--model" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --model".to_string())?; + validate_model_syntax(value)?; + model = resolve_model_alias_with_config(value); + model_flag_raw = Some(value.clone()); // #148 + index += 2; + } + flag if flag.starts_with("--model=") => { + let value = &flag[8..]; + validate_model_syntax(value)?; + model = resolve_model_alias_with_config(value); + model_flag_raw = Some(value.to_string()); // #148 + index += 1; + } + "--output-format" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --output-format".to_string())?; + output_format = CliOutputFormat::parse(value)?; + index += 2; + } + "--permission-mode" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --permission-mode".to_string())?; + permission_mode_override = Some(parse_permission_mode_arg(value)?); + index += 2; + } + flag if flag.starts_with("--output-format=") => { + output_format = CliOutputFormat::parse(&flag[16..])?; + index += 1; + } + flag if flag.starts_with("--permission-mode=") => { + permission_mode_override = Some(parse_permission_mode_arg(&flag[18..])?); + index += 1; + } + "--dangerously-skip-permissions" => { + permission_mode_override = Some(PermissionMode::DangerFullAccess); + index += 1; + } + "--compact" => { + compact = true; + index += 1; + } + "--base-commit" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --base-commit".to_string())?; + base_commit = Some(value.clone()); + index += 2; + } + flag if flag.starts_with("--base-commit=") => { + base_commit = Some(flag[14..].to_string()); + index += 1; + } + "--reasoning-effort" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --reasoning-effort".to_string())?; + if !matches!(value.as_str(), "low" | "medium" | "high") { + return Err(format!( + "invalid value for --reasoning-effort: '{value}'; must be low, medium, or high" + )); + } + reasoning_effort = Some(value.clone()); + index += 2; + } + flag if flag.starts_with("--reasoning-effort=") => { + let value = &flag[19..]; + if !matches!(value, "low" | "medium" | "high") { + return Err(format!( + "invalid value for --reasoning-effort: '{value}'; must be low, medium, or high" + )); + } + reasoning_effort = Some(value.to_string()); + index += 1; + } + "--allow-broad-cwd" => { + allow_broad_cwd = true; + index += 1; + } + "-p" => { + // Claw Code compat: -p "prompt" = one-shot prompt + let prompt = args[index + 1..].join(" "); + if prompt.trim().is_empty() { + return Err("-p requires a prompt string".to_string()); + } + return Ok(CliAction::Prompt { + prompt, + model: resolve_model_alias_with_config(&model), + output_format, + allowed_tools: normalize_allowed_tools(&allowed_tool_values)?, + permission_mode: permission_mode_override + .unwrap_or_else(default_permission_mode), + compact, + base_commit: base_commit.clone(), + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }); + } + "--print" => { + // Claw Code compat: --print makes output non-interactive + output_format = CliOutputFormat::Text; + index += 1; + } + "--resume" if rest.is_empty() => { + rest.push("--resume".to_string()); + index += 1; + } + flag if rest.is_empty() && flag.starts_with("--resume=") => { + rest.push("--resume".to_string()); + rest.push(flag[9..].to_string()); + index += 1; + } + "--acp" | "-acp" => { + rest.push("acp".to_string()); + index += 1; + } + "--allowedTools" | "--allowed-tools" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --allowedTools".to_string())?; + allowed_tool_values.push(value.clone()); + index += 2; + } + flag if flag.starts_with("--allowedTools=") => { + allowed_tool_values.push(flag[15..].to_string()); + index += 1; + } + flag if flag.starts_with("--allowed-tools=") => { + allowed_tool_values.push(flag[16..].to_string()); + index += 1; + } + other if rest.is_empty() && other.starts_with('-') => { + return Err(format_unknown_option(other)) + } + other => { + rest.push(other.to_string()); + index += 1; + } + } + } + + if wants_help { + return Ok(CliAction::Help { output_format }); + } + + if wants_version { + return Ok(CliAction::Version { output_format }); + } + + let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; + + if rest.is_empty() { + let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); + // When stdin is not a terminal (pipe/redirect) and no prompt is given on the + // command line, read stdin as the prompt and dispatch as a one-shot Prompt + // rather than starting the interactive REPL (which would consume the pipe and + // print the startup banner, then exit without sending anything to the API). + if !std::io::stdin().is_terminal() { + let mut buf = String::new(); + let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf); + let piped = buf.trim().to_string(); + if !piped.is_empty() { + return Ok(CliAction::Prompt { + model, + prompt: piped, + allowed_tools, + permission_mode, + output_format, + compact: false, + base_commit, + reasoning_effort, + allow_broad_cwd, + }); + } + } + return Ok(CliAction::Repl { + model, + allowed_tools, + permission_mode, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }); + } + if rest.first().map(String::as_str) == Some("--resume") { + return parse_resume_args(&rest[1..], output_format); + } + if let Some(action) = parse_local_help_action(&rest) { + return action; + } + if let Some(action) = parse_single_word_command_alias( + &rest, + &model, + model_flag_raw.as_deref(), + permission_mode_override, + output_format, + allowed_tools.clone(), + ) { + return action; + } + + let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); + + match rest[0].as_str() { + "dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format), + "bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }), + "agents" => Ok(CliAction::Agents { + args: join_optional_args(&rest[1..]), + output_format, + }), + "mcp" => Ok(CliAction::Mcp { + args: join_optional_args(&rest[1..]), + output_format, + }), + // #145: `plugins` was routed through the prompt fallback because no + // top-level parser arm produced CliAction::Plugins. That made `claw + // plugins` (and `claw plugins --help`, `claw plugins list`, ...) + // attempt an Anthropic network call, surfacing the misleading error + // `missing Anthropic credentials` even though the command is purely + // local introspection. Mirror `agents`/`mcp`/`skills`: action is the + // first positional arg, target is the second. + "plugins" => { + let tail = &rest[1..]; + let action = tail.first().cloned(); + let target = tail.get(1).cloned(); + if tail.len() > 2 { + return Err(format!( + "unexpected extra arguments after `claw plugins {}`: {}", + tail[..2].join(" "), + tail[2..].join(" ") + )); + } + Ok(CliAction::Plugins { + action, + target, + output_format, + }) + } + // #146: `config` is pure-local read-only introspection (merges + // `.claw.json` + `.claw/settings.json` from disk, no network, no + // state mutation). Previously callers had to spin up a session with + // `claw --resume SESSION.jsonl /config` to see their own config, + // which is synthetic friction. Accepts an optional section name + // (env|hooks|model|plugins) matching the slash command shape. + "config" => { + let tail = &rest[1..]; + let section = tail.first().cloned(); + if tail.len() > 1 { + return Err(format!( + "unexpected extra arguments after `claw config {}`: {}", + tail[0], + tail[1..].join(" ") + )); + } + Ok(CliAction::Config { + section, + output_format, + }) + } + // #146: `diff` is pure-local (shells out to `git diff --cached` + + // `git diff`). No session needed to inspect the working tree. + "diff" => { + if rest.len() > 1 { + return Err(format!( + "unexpected extra arguments after `claw diff`: {}", + rest[1..].join(" ") + )); + } + Ok(CliAction::Diff { output_format }) + } + "skills" => { + let args = join_optional_args(&rest[1..]); + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }), + SkillSlashDispatch::Local => Ok(CliAction::Skills { + args, + output_format, + }), + } + } + "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), + "acp" => parse_acp_args(&rest[1..], output_format), + "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), + "init" => Ok(CliAction::Init { output_format }), + "export" => parse_export_args(&rest[1..], output_format), + "prompt" => { + let prompt = rest[1..].join(" "); + if prompt.trim().is_empty() { + return Err("prompt subcommand requires a prompt string".to_string()); + } + Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit: base_commit.clone(), + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }) + } + other if other.starts_with('/') => parse_direct_slash_cli_action( + &rest, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort, + allow_broad_cwd, + ), + other => { + if rest.len() == 1 && looks_like_subcommand_typo(other) { + if let Some(suggestions) = suggest_similar_subcommand(other) { + let mut message = format!("unknown subcommand: {other}."); + if let Some(line) = render_suggestion_line("Did you mean", &suggestions) { + message.push('\n'); + message.push_str(&line); + } + message.push_str( + "\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt `.", + ); + return Err(message); + } + } + // #147: guard empty/whitespace-only prompts at the fallthrough + // path the same way `"prompt"` arm above does. Without this, + // `claw ""`, `claw " "`, and `claw "" ""` silently route to + // the Anthropic call and surface a misleading + // `missing Anthropic credentials` error (or burn API tokens on + // an empty prompt when credentials are present). + let joined = rest.join(" "); + if joined.trim().is_empty() { + return Err( + "empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string" + .to_string(), + ); + } + Ok(CliAction::Prompt { + prompt: joined, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }) + } + } +} + +pub(crate) fn parse_local_help_action(rest: &[String]) -> Option> { + if rest.len() != 2 || !is_help_flag(&rest[1]) { + return None; + } + + let topic = match rest[0].as_str() { + "status" => LocalHelpTopic::Status, + "sandbox" => LocalHelpTopic::Sandbox, + "doctor" => LocalHelpTopic::Doctor, + "acp" => LocalHelpTopic::Acp, + // #141: add the subcommands that were previously falling back + // to global help (init/state/export/version) or erroring out + // (system-prompt/dump-manifests) or printing their primary + // output instead of help text (bootstrap-plan). + "init" => LocalHelpTopic::Init, + "state" => LocalHelpTopic::State, + "export" => LocalHelpTopic::Export, + "version" => LocalHelpTopic::Version, + "system-prompt" => LocalHelpTopic::SystemPrompt, + "dump-manifests" => LocalHelpTopic::DumpManifests, + "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, + _ => return None, + }; + Some(Ok(CliAction::HelpTopic(topic))) +} + +pub(crate) fn is_help_flag(value: &str) -> bool { + matches!(value, "--help" | "-h") +} + +pub(crate) fn parse_single_word_command_alias( + rest: &[String], + model: &str, + // #148: raw --model flag input for status provenance. None = no flag. + model_flag_raw: Option<&str>, + permission_mode_override: Option, + output_format: CliOutputFormat, + allowed_tools: Option, +) -> Option> { + if rest.is_empty() { + return None; + } + + // Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself + // or --help / -h as a suffix. Any other suffix args are unrecognized. + let verb = &rest[0]; + let is_diagnostic = matches!( + verb.as_str(), + "help" | "version" | "status" | "sandbox" | "doctor" | "state" + ); + + if is_diagnostic && rest.len() > 1 { + // Diagnostic verb with trailing args: reject unrecognized suffix + if is_help_flag(&rest[1]) && rest.len() == 2 { + // "doctor --help" is valid, routed to parse_local_help_action() instead + return None; + } + // Unrecognized suffix like "--json" + let mut msg = format!( + "unrecognized argument `{}` for subcommand `{}`", + rest[1], verb + ); + // #152: common mistake — users type `--json` expecting JSON output. + // Hint at the correct flag so they don't have to re-read --help. + if rest[1] == "--json" { + msg.push_str("\nDid you mean `--output-format json`?"); + } + return Some(Err(msg)); + } + + if rest.len() != 1 { + return None; + } + + match rest[0].as_str() { + "help" => Some(Ok(CliAction::Help { output_format })), + "version" => Some(Ok(CliAction::Version { output_format })), + "status" => Some(Ok(CliAction::Status { + model: model.to_string(), + model_flag_raw: model_flag_raw.map(str::to_string), // #148 + permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), + output_format, + allowed_tools, + })), + "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), + "doctor" => Some(Ok(CliAction::Doctor { output_format })), + "state" => Some(Ok(CliAction::State { output_format })), + // #146: let `config` and `diff` fall through to parse_subcommand + // where they are wired as pure-local introspection, instead of + // producing the "is a slash command" guidance. Zero-arg cases + // reach parse_subcommand too via this None. + "config" | "diff" => None, + other => bare_slash_command_guidance(other).map(Err), + } +} + +pub(crate) fn bare_slash_command_guidance(command_name: &str) -> Option { + if matches!( + command_name, + "dump-manifests" + | "bootstrap-plan" + | "agents" + | "mcp" + | "skills" + | "system-prompt" + | "init" + | "prompt" + | "export" + ) { + return None; + } + let slash_command = slash_command_specs() + .iter() + .find(|spec| spec.name == command_name)?; + let guidance = if slash_command.resume_supported { + format!( + "`claw {command_name}` is a slash command. Use `claw --resume SESSION.jsonl /{command_name}` or start `claw` and run `/{command_name}`." + ) + } else { + format!( + "`claw {command_name}` is a slash command. Start `claw` and run `/{command_name}` inside the REPL." + ) + }; + Some(guidance) +} + +pub(crate) fn removed_auth_surface_error(command_name: &str) -> String { + format!( + "`claw {command_name}` has been removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead." + ) +} + +pub(crate) fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result { + match args { + [] => Ok(CliAction::Acp { output_format }), + [subcommand] if subcommand == "serve" => Ok(CliAction::Acp { output_format }), + _ => Err(String::from( + "unsupported ACP invocation. Use `claw acp`, `claw acp serve`, `claw --acp`, or `claw -acp`.", + )), + } +} + +pub(crate) fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option { + let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default(); + let looks_like_skill_name = !bare_first_token.is_empty() + && !bare_first_token.starts_with('/') + && bare_first_token + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_'); + if !looks_like_skill_name { + return None; + } + match resolve_skill_invocation(cwd, Some(trimmed)) { + Ok(SkillSlashDispatch::Invoke(prompt)) => Some(prompt), + _ => None, + } +} + +pub(crate) fn join_optional_args(args: &[String]) -> Option { + let joined = args.join(" "); + let trimmed = joined.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] +pub(crate) fn parse_direct_slash_cli_action( + rest: &[String], + model: String, + output_format: CliOutputFormat, + allowed_tools: Option, + permission_mode: PermissionMode, + compact: bool, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, +) -> Result { + let raw = rest.join(" "); + match SlashCommand::parse(&raw) { + Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }), + Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { + args, + output_format, + }), + Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp { + args: match (action, target) { + (None, None) => None, + (Some(action), None) => Some(action), + (Some(action), Some(target)) => Some(format!("{action} {target}")), + (None, Some(target)) => Some(target), + }, + output_format, + }), + Ok(Some(SlashCommand::Skills { args })) => { + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }), + SkillSlashDispatch::Local => Ok(CliAction::Skills { + args, + output_format, + }), + } + } + Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)), + Ok(Some(command)) => Err({ + let _ = command; + format!( + "slash command {command_name} is interactive-only. Start `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}` when the command is marked [resume] in /help.", + command_name = rest[0], + latest = LATEST_SESSION_REFERENCE, + ) + }), + Ok(None) => Err(format!("unknown subcommand: {}", rest[0])), + Err(error) => Err(error.to_string()), + } +} + +pub(crate) fn format_unknown_option(option: &str) -> String { + let mut message = format!("unknown option: {option}"); + if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) { + message.push_str("\nDid you mean "); + message.push_str(suggestion); + message.push('?'); + } + message.push_str("\nRun `claw --help` for usage."); + message +} + +pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String { + let mut message = format!("unknown slash command outside the REPL: /{name}"); + if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) + { + message.push('\n'); + message.push_str(&suggestions); + } + if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { + message.push('\n'); + message.push_str(note); + } + message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help."); + message +} + +pub(crate) fn format_unknown_slash_command(name: &str) -> String { + let mut message = format!("Unknown slash command: /{name}"); + if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) + { + message.push('\n'); + message.push_str(&suggestions); + } + if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { + message.push('\n'); + message.push_str(note); + } + message.push_str("\n Help /help lists available slash commands"); + message +} + +pub(crate) fn omc_compatibility_note_for_unknown_slash_command(name: &str) -> Option<&'static str> { + name.starts_with("oh-my-claudecode:") + .then_some( + "Compatibility note: `/oh-my-claudecode:*` is a Claude Code/OMC plugin command. `claw` does not yet load plugin slash commands, Claude statusline stdin, or OMC session hooks.", + ) +} + +pub(crate) fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option { + (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),)) +} + +pub(crate) fn suggest_slash_commands(input: &str) -> Vec { + let mut candidates = slash_command_specs() + .iter() + .flat_map(|spec| { + std::iter::once(spec.name) + .chain(spec.aliases.iter().copied()) + .map(|name| format!("/{name}")) + .collect::>() + }) + .collect::>(); + candidates.sort(); + candidates.dedup(); + let candidate_refs = candidates.iter().map(String::as_str).collect::>(); + ranked_suggestions(input.trim_start_matches('/'), &candidate_refs) + .into_iter() + .map(str::to_string) + .collect() +} + +pub(crate) fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> { + ranked_suggestions(input, candidates).into_iter().next() +} + +pub(crate) fn suggest_similar_subcommand(input: &str) -> Option> { + const KNOWN_SUBCOMMANDS: &[&str] = &[ + "help", + "version", + "status", + "sandbox", + "doctor", + "state", + "dump-manifests", + "bootstrap-plan", + "agents", + "mcp", + "skills", + "system-prompt", + "acp", + "init", + "export", + "prompt", + ]; + + let normalized_input = input.to_ascii_lowercase(); + let mut ranked = KNOWN_SUBCOMMANDS + .iter() + .filter_map(|candidate| { + let normalized_candidate = candidate.to_ascii_lowercase(); + let distance = levenshtein_distance(&normalized_input, &normalized_candidate); + let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4; + let substring_match = normalized_candidate.contains(&normalized_input) + || normalized_input.contains(&normalized_candidate); + ((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate)) + }) + .collect::>(); + ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); + ranked.dedup_by(|left, right| left.1 == right.1); + let suggestions = ranked + .into_iter() + .map(|(_, candidate)| candidate.to_string()) + .take(3) + .collect::>(); + (!suggestions.is_empty()).then_some(suggestions) +} + +pub(crate) fn common_prefix_len(left: &str, right: &str) -> usize { + left.chars() + .zip(right.chars()) + .take_while(|(l, r)| l == r) + .count() +} + +pub(crate) fn looks_like_subcommand_typo(input: &str) -> bool { + !input.is_empty() + && input + .chars() + .all(|ch| ch.is_ascii_alphabetic() || ch == '-') +} + +pub(crate) fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> { + let normalized_input = input.trim_start_matches('/').to_ascii_lowercase(); + let mut ranked = candidates + .iter() + .filter_map(|candidate| { + let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase(); + let distance = levenshtein_distance(&normalized_input, &normalized_candidate); + let prefix_bonus = usize::from( + !(normalized_candidate.starts_with(&normalized_input) + || normalized_input.starts_with(&normalized_candidate)), + ); + let score = distance + prefix_bonus; + (score <= 4).then_some((score, *candidate)) + }) + .collect::>(); + ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); + ranked + .into_iter() + .map(|(_, candidate)| candidate) + .take(3) + .collect() +} + +pub(crate) fn levenshtein_distance(left: &str, right: &str) -> usize { + if left.is_empty() { + return right.chars().count(); + } + if right.is_empty() { + return left.chars().count(); + } + + let right_chars = right.chars().collect::>(); + let mut previous = (0..=right_chars.len()).collect::>(); + let mut current = vec![0; right_chars.len() + 1]; + + for (left_index, left_char) in left.chars().enumerate() { + current[0] = left_index + 1; + for (right_index, right_char) in right_chars.iter().enumerate() { + let substitution_cost = usize::from(left_char != *right_char); + current[right_index + 1] = (previous[right_index + 1] + 1) + .min(current[right_index] + 1) + .min(previous[right_index] + substitution_cost); + } + previous.clone_from(¤t); + } + + previous[right_chars.len()] +} + + + +pub(crate) fn normalize_allowed_tools(values: &[String]) -> Result, String> { + if values.is_empty() { + return Ok(None); + } + current_tool_registry()?.normalize_allowed_tools(values) +} + +pub(crate) fn current_tool_registry() -> Result { + let cwd = env::current_dir().map_err(|error| error.to_string())?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load().map_err(|error| error.to_string())?; + let state = build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) + .map_err(|error| error.to_string())?; + let registry = state.tool_registry.clone(); + if let Some(mcp_state) = state.mcp_state { + mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .shutdown() + .map_err(|error| error.to_string())?; + } + Ok(registry) +} + +pub(crate) fn parse_permission_mode_arg(value: &str) -> Result { + normalize_permission_mode(value) + .ok_or_else(|| { + format!( + "unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access." + ) + }) + .map(permission_mode_from_label) +} + +pub(crate) fn permission_mode_from_label(mode: &str) -> PermissionMode { + match mode { + "read-only" => PermissionMode::ReadOnly, + "workspace-write" => PermissionMode::WorkspaceWrite, + "danger-full-access" => PermissionMode::DangerFullAccess, + other => panic!("unsupported permission mode label: {other}"), + } +} + +pub(crate) fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode { + match mode { + ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly, + ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite, + ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess, + } +} + +pub(crate) fn default_permission_mode() -> PermissionMode { + env::var("RUSTY_CLAUDE_PERMISSION_MODE") + .ok() + .as_deref() + .and_then(normalize_permission_mode) + .map(permission_mode_from_label) + .or_else(config_permission_mode_for_current_dir) + .unwrap_or(PermissionMode::DangerFullAccess) +} + +pub(crate) fn config_permission_mode_for_current_dir() -> Option { + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + loader + .load() + .ok()? + .permission_mode() + .map(permission_mode_from_resolved) +} + + + +pub(crate) fn filter_tool_specs( + tool_registry: &GlobalToolRegistry, + allowed_tools: Option<&AllowedToolSet>, +) -> Vec { + tool_registry.definitions(allowed_tools) +} + +pub(crate) fn parse_system_prompt_args( + args: &[String], + output_format: CliOutputFormat, +) -> Result { + let mut cwd = env::current_dir().map_err(|error| error.to_string())?; + let mut date = DEFAULT_DATE.to_string(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--cwd" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --cwd".to_string())?; + cwd = PathBuf::from(value); + index += 2; + } + "--date" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --date".to_string())?; + date.clone_from(value); + index += 2; + } + other => { + // #152: hint `--output-format json` when user types `--json`. + let mut msg = format!("unknown system-prompt option: {other}"); + if other == "--json" { + msg.push_str("\nDid you mean `--output-format json`?"); + } + return Err(msg); + } + } + } + + Ok(CliAction::PrintSystemPrompt { + cwd, + date, + output_format, + }) +} + +pub(crate) fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result { + let mut session_reference = LATEST_SESSION_REFERENCE.to_string(); + let mut output_path: Option = None; + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--session" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --session".to_string())?; + session_reference.clone_from(value); + index += 2; + } + flag if flag.starts_with("--session=") => { + session_reference = flag[10..].to_string(); + index += 1; + } + "--output" | "-o" => { + let value = args + .get(index + 1) + .ok_or_else(|| format!("missing value for {}", args[index]))?; + output_path = Some(PathBuf::from(value)); + index += 2; + } + flag if flag.starts_with("--output=") => { + output_path = Some(PathBuf::from(&flag[9..])); + index += 1; + } + other if other.starts_with('-') => { + return Err(format!("unknown export option: {other}")); + } + other if output_path.is_none() => { + output_path = Some(PathBuf::from(other)); + index += 1; + } + other => { + return Err(format!("unexpected export argument: {other}")); + } + } + } + + Ok(CliAction::Export { + session_reference, + output_path, + output_format, + }) +} + +pub(crate) fn parse_dump_manifests_args( + args: &[String], + output_format: CliOutputFormat, +) -> Result { + let mut manifests_dir: Option = None; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--manifests-dir" { + let value = args + .get(index + 1) + .ok_or_else(|| String::from("--manifests-dir requires a path"))?; + manifests_dir = Some(PathBuf::from(value)); + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--manifests-dir=") { + if value.is_empty() { + return Err(String::from("--manifests-dir requires a path")); + } + manifests_dir = Some(PathBuf::from(value)); + index += 1; + continue; + } + return Err(format!("unknown dump-manifests option: {arg}")); + } + + Ok(CliAction::DumpManifests { + output_format, + manifests_dir, + }) +} + +pub(crate) fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result { + let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() { + None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]), + Some(first) if looks_like_slash_command_token(first) => { + (PathBuf::from(LATEST_SESSION_REFERENCE), args) + } + Some(first) => (PathBuf::from(first), &args[1..]), + }; + let mut commands = Vec::new(); + let mut current_command = String::new(); + + for token in command_tokens { + if token.trim_start().starts_with('/') { + if resume_command_can_absorb_token(¤t_command, token) { + current_command.push(' '); + current_command.push_str(token); + continue; + } + if !current_command.is_empty() { + commands.push(current_command); + } + current_command = String::from(token.as_str()); + continue; + } + + if current_command.is_empty() { + return Err("--resume trailing arguments must be slash commands".to_string()); + } + + current_command.push(' '); + current_command.push_str(token); + } + + if !current_command.is_empty() { + commands.push(current_command); + } + + Ok(CliAction::ResumeSession { + session_path, + commands, + output_format, + }) +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 7a54fecf..b314e611 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -9,9 +9,11 @@ clippy::doc_markdown, clippy::result_large_err )] +mod args; mod constants; mod doctor; mod error; +mod session; mod init; mod input; mod model; @@ -21,13 +23,15 @@ mod progress; mod repl; mod render; -use constants::*; -use doctor::*; -use error::*; -use model::*; +pub(crate) use args::*; +pub(crate) use constants::*; +pub(crate) use doctor::*; +pub(crate) use error::*; +pub(crate) use session::*; +pub(crate) use model::*; pub(crate) use models::*; -use permission::*; -use progress::*; +pub(crate) use permission::*; +pub(crate) use progress::*; pub(crate) use repl::*; use std::collections::BTreeSet; use std::env; @@ -1664,406 +1668,16 @@ fn run_mcp_serve() -> Result<(), Box> { } #[allow(clippy::too_many_lines)] -fn check_auth_health() -> DiagnosticCheck { - let api_key_present = env::var("ANTHROPIC_API_KEY") - .ok() - .is_some_and(|value| !value.trim().is_empty()); - let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN") - .ok() - .is_some_and(|value| !value.trim().is_empty()); - let env_details = format!( - "Environment api_key={} auth_token={}", - if api_key_present { "present" } else { "absent" }, - if auth_token_present { - "present" - } else { - "absent" - } - ); - match load_oauth_credentials() { - Ok(Some(token_set)) => DiagnosticCheck::new( - "Auth", - if api_key_present || auth_token_present { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - if api_key_present || auth_token_present { - "supported auth env vars are configured; legacy saved OAuth is ignored" - } else { - "legacy saved OAuth credentials are present but unsupported" - }, - ) - .with_details(vec![ - env_details, - format!( - "Legacy OAuth expires_at={} refresh_token={} scopes={}", - token_set - .expires_at - .map_or_else(|| "".to_string(), |value| value.to_string()), - if token_set.refresh_token.is_some() { - "present" - } else { - "absent" - }, - if token_set.scopes.is_empty() { - "".to_string() - } else { - token_set.scopes.join(",") - } - ), - "Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed" - .to_string(), - ]) - .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("legacy_saved_oauth_present".to_string(), json!(true)), - ( - "legacy_saved_oauth_expires_at".to_string(), - json!(token_set.expires_at), - ), - ( - "legacy_refresh_token_present".to_string(), - json!(token_set.refresh_token.is_some()), - ), - ("legacy_scopes".to_string(), json!(token_set.scopes)), - ])), - Ok(None) => DiagnosticCheck::new( - "Auth", - if api_key_present || auth_token_present { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - if api_key_present || auth_token_present { - "supported auth env vars are configured" - } else { - "no supported auth env vars were found" - }, - ) - .with_details(vec![env_details]) - .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("legacy_saved_oauth_present".to_string(), json!(false)), - ("legacy_saved_oauth_expires_at".to_string(), Value::Null), - ("legacy_refresh_token_present".to_string(), json!(false)), - ("legacy_scopes".to_string(), json!(Vec::::new())), - ])), - Err(error) => DiagnosticCheck::new( - "Auth", - DiagnosticLevel::Fail, - format!("failed to inspect legacy saved credentials: {error}"), - ) - .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("legacy_saved_oauth_present".to_string(), Value::Null), - ("legacy_saved_oauth_expires_at".to_string(), Value::Null), - ("legacy_refresh_token_present".to_string(), Value::Null), - ("legacy_scopes".to_string(), Value::Null), - ("legacy_saved_oauth_error".to_string(), json!(error.to_string())), - ])), - } -} -fn check_config_health( - config_loader: &ConfigLoader, - config: Result<&runtime::RuntimeConfig, &runtime::ConfigError>, -) -> DiagnosticCheck { - let discovered = config_loader.discover(); - let discovered_count = discovered.len(); - // Separate candidate paths that actually exist from those that don't. - // Showing non-existent paths as "Discovered file" implies they loaded - // but something went wrong, which is confusing. We only surface paths - // that exist on disk as discovered; non-existent ones are silently - // omitted from the display (they are just the standard search locations). - let present_paths: Vec = discovered - .iter() - .filter(|e| e.path.exists()) - .map(|e| e.path.display().to_string()) - .collect(); - let discovered_paths = discovered - .iter() - .map(|entry| entry.path.display().to_string()) - .collect::>(); - match config { - Ok(runtime_config) => { - let loaded_entries = runtime_config.loaded_entries(); - let loaded_count = loaded_entries.len(); - let present_count = present_paths.len(); - let mut details = vec![format!( - "Config files loaded {}/{}", - loaded_count, present_count - )]; - if let Some(model) = runtime_config.model() { - details.push(format!("Resolved model {model}")); - } - details.push(format!( - "MCP servers {}", - runtime_config.mcp().servers().len() - )); - if present_paths.is_empty() { - details.push("Discovered files (defaults active)".to_string()); - } else { - details.extend( - present_paths - .iter() - .map(|path| format!("Discovered file {path}")), - ); - } - DiagnosticCheck::new( - "Config", - DiagnosticLevel::Ok, - if present_count == 0 { - "no config files present; defaults are active" - } else { - "runtime config loaded successfully" - }, - ) - .with_details(details) - .with_data(Map::from_iter([ - ("discovered_files".to_string(), json!(present_paths)), - ("discovered_files_count".to_string(), json!(present_count)), - ("loaded_config_files".to_string(), json!(loaded_count)), - ("resolved_model".to_string(), json!(runtime_config.model())), - ( - "mcp_servers".to_string(), - json!(runtime_config.mcp().servers().len()), - ), - ])) - } - Err(error) => DiagnosticCheck::new( - "Config", - DiagnosticLevel::Fail, - format!("runtime config failed to load: {error}"), - ) - .with_details(if discovered_paths.is_empty() { - vec!["Discovered files ".to_string()] - } else { - discovered_paths - .iter() - .map(|path| format!("Discovered file {path}")) - .collect() - }) - .with_data(Map::from_iter([ - ("discovered_files".to_string(), json!(discovered_paths)), - ( - "discovered_files_count".to_string(), - json!(discovered_count), - ), - ("loaded_config_files".to_string(), json!(0)), - ("resolved_model".to_string(), Value::Null), - ("mcp_servers".to_string(), Value::Null), - ("load_error".to_string(), json!(error.to_string())), - ])), - } -} - -fn check_install_source_health() -> DiagnosticCheck { - DiagnosticCheck::new( - "Install source", - DiagnosticLevel::Ok, - format!( - "official source of truth is {OFFICIAL_REPO_SLUG}; avoid `{DEPRECATED_INSTALL_COMMAND}`" - ), - ) - .with_details(vec![ - format!("Official repo {OFFICIAL_REPO_URL}"), - "Recommended path build from this repo or use the upstream binary documented in README.md" - .to_string(), - format!( - "Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary" - ) - .to_string(), - ]) - .with_data(Map::from_iter([ - ("official_repo".to_string(), json!(OFFICIAL_REPO_URL)), - ( - "deprecated_install".to_string(), - json!(DEPRECATED_INSTALL_COMMAND), - ), - ( - "recommended_install".to_string(), - json!("build from source or follow the upstream binary instructions in README.md"), - ), - ])) -} - -fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { - let in_repo = context.project_root.is_some(); - DiagnosticCheck::new( - "Workspace", - if in_repo { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - if in_repo { - format!( - "project root detected on branch {}", - context.git_branch.as_deref().unwrap_or("unknown") - ) - } else { - "current directory is not inside a git project".to_string() - }, - ) - .with_details(vec![ - format!("Cwd {}", context.cwd.display()), - format!( - "Project root {}", - context - .project_root - .as_ref() - .map_or_else(|| "".to_string(), |path| path.display().to_string()) - ), - format!( - "Git branch {}", - context.git_branch.as_deref().unwrap_or("unknown") - ), - format!("Git state {}", context.git_summary.headline()), - format!("Changed files {}", context.git_summary.changed_files), - format!( - "Memory files {} · config files loaded {}/{}", - context.memory_file_count, context.loaded_config_files, context.discovered_config_files - ), - ]) - .with_data(Map::from_iter([ - ("cwd".to_string(), json!(context.cwd.display().to_string())), - ( - "project_root".to_string(), - json!(context - .project_root - .as_ref() - .map(|path| path.display().to_string())), - ), - ("in_git_repo".to_string(), json!(in_repo)), - ("git_branch".to_string(), json!(context.git_branch)), - ( - "git_state".to_string(), - json!(context.git_summary.headline()), - ), - ( - "changed_files".to_string(), - json!(context.git_summary.changed_files), - ), - ( - "memory_file_count".to_string(), - json!(context.memory_file_count), - ), - ( - "loaded_config_files".to_string(), - json!(context.loaded_config_files), - ), - ( - "discovered_config_files".to_string(), - json!(context.discovered_config_files), - ), - ])) -} - -fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck { - let degraded = status.enabled && !status.active; - let mut details = vec![ - format!("Enabled {}", status.enabled), - format!("Active {}", status.active), - format!("Supported {}", status.supported), - format!("Filesystem mode {}", status.filesystem_mode.as_str()), - format!("Filesystem live {}", status.filesystem_active), - ]; - if let Some(reason) = &status.fallback_reason { - details.push(format!("Fallback reason {reason}")); - } - DiagnosticCheck::new( - "Sandbox", - if degraded { - DiagnosticLevel::Warn - } else { - DiagnosticLevel::Ok - }, - if degraded { - "sandbox was requested but is not currently active" - } else if status.active { - "sandbox protections are active" - } else { - "sandbox is not active for this session" - }, - ) - .with_details(details) - .with_data(Map::from_iter([ - ("enabled".to_string(), json!(status.enabled)), - ("active".to_string(), json!(status.active)), - ("supported".to_string(), json!(status.supported)), - ( - "namespace_supported".to_string(), - json!(status.namespace_supported), - ), - ( - "namespace_active".to_string(), - json!(status.namespace_active), - ), - ( - "network_supported".to_string(), - json!(status.network_supported), - ), - ("network_active".to_string(), json!(status.network_active)), - ( - "filesystem_mode".to_string(), - json!(status.filesystem_mode.as_str()), - ), - ( - "filesystem_active".to_string(), - json!(status.filesystem_active), - ), - ("allowed_mounts".to_string(), json!(status.allowed_mounts)), - ("in_container".to_string(), json!(status.in_container)), - ( - "container_markers".to_string(), - json!(status.container_markers), - ), - ("fallback_reason".to_string(), json!(status.fallback_reason)), - ])) -} - -fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck { - let default_model = config.and_then(runtime::RuntimeConfig::model); - let mut details = vec![ - format!("OS {} {}", env::consts::OS, env::consts::ARCH), - format!("Working dir {}", cwd.display()), - format!("Version {}", VERSION), - format!("Build target {}", BUILD_TARGET.unwrap_or("")), - format!("Git SHA {}", GIT_SHA.unwrap_or("")), - ]; - if let Some(model) = default_model { - details.push(format!("Default model {model}")); - } - DiagnosticCheck::new( - "System", - DiagnosticLevel::Ok, - "captured local runtime metadata", - ) - .with_details(details) - .with_data(Map::from_iter([ - ("os".to_string(), json!(env::consts::OS)), - ("arch".to_string(), json!(env::consts::ARCH)), - ("working_dir".to_string(), json!(cwd.display().to_string())), - ("version".to_string(), json!(VERSION)), - ("build_target".to_string(), json!(BUILD_TARGET)), - ("git_sha".to_string(), json!(GIT_SHA)), - ("default_model".to_string(), json!(default_model)), - ])) -} - -fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { +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) } -fn looks_like_slash_command_token(token: &str) -> bool { +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 @@ -3229,22 +2843,6 @@ fn run_repl( Ok(()) } -#[derive(Debug, Clone)] -struct SessionHandle { - id: String, - path: PathBuf, -} - -#[derive(Debug, Clone)] -struct ManagedSessionSummary { - id: String, - path: PathBuf, - updated_at_ms: u64, - modified_epoch_millis: u128, - message_count: usize, - parent_session_id: Option, - branch_name: Option, -} struct LiveCli { model: String, @@ -3256,11 +2854,7 @@ struct LiveCli { prompt_history: Vec, } -#[derive(Debug, Clone)] -struct PromptHistoryEntry { - timestamp_ms: u64, - text: String, -} + struct RuntimePluginState { feature_config: runtime::RuntimeFeatureConfig, @@ -3733,1286 +3327,6 @@ impl HookAbortMonitor { } } -impl LiveCli { - fn new( - model: String, - enable_tools: bool, - allowed_tools: Option, - permission_mode: PermissionMode, - ) -> Result> { - let system_prompt = build_system_prompt()?; - let session_state = new_cli_session()?; - let session = create_managed_session_handle(&session_state.session_id)?; - let runtime = build_runtime( - session_state.with_persistence_path(session.path.clone()), - &session.id, - model.clone(), - system_prompt.clone(), - enable_tools, - true, - allowed_tools.clone(), - permission_mode, - None, - )?; - let cli = Self { - model, - allowed_tools, - permission_mode, - system_prompt, - runtime, - session, - prompt_history: Vec::new(), - }; - cli.persist_session()?; - Ok(cli) - } - - fn set_reasoning_effort(&mut self, effort: Option) { - if let Some(rt) = self.runtime.runtime.as_mut() { - rt.api_client_mut().set_reasoning_effort(effort); - } - } - - fn startup_banner(&self) -> String { - let cwd = env::current_dir().map_or_else( - |_| "".to_string(), - |path| path.display().to_string(), - ); - let status = status_context(None).ok(); - let git_branch = status - .as_ref() - .and_then(|context| context.git_branch.as_deref()) - .unwrap_or("unknown"); - let workspace = status.as_ref().map_or_else( - || "unknown".to_string(), - |context| context.git_summary.headline(), - ); - let session_path = self.session.path.strip_prefix(Path::new(&cwd)).map_or_else( - |_| self.session.path.display().to_string(), - |path| path.display().to_string(), - ); - format!( - "\x1b[38;5;196m\ - ██████╗██╗ █████╗ ██╗ ██╗\n\ -██╔════╝██║ ██╔══██╗██║ ██║\n\ -██║ ██║ ███████║██║ █╗ ██║\n\ -██║ ██║ ██╔══██║██║███╗██║\n\ -╚██████╗███████╗██║ ██║╚███╔███╔╝\n\ - ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\ - \x1b[2mModel\x1b[0m {}\n\ - \x1b[2mPermissions\x1b[0m {}\n\ - \x1b[2mBranch\x1b[0m {}\n\ - \x1b[2mWorkspace\x1b[0m {}\n\ - \x1b[2mDirectory\x1b[0m {}\n\ - \x1b[2mSession\x1b[0m {}\n\ - \x1b[2mAuto-save\x1b[0m {}\n\n\ - Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline", - self.model, - self.permission_mode.as_str(), - git_branch, - workspace, - cwd, - self.session.id, - session_path, - ) - } - - fn repl_completion_candidates(&self) -> Result, Box> { - Ok(slash_command_completion_candidates_with_sessions( - &self.model, - Some(&self.session.id), - list_managed_sessions()? - .into_iter() - .map(|session| session.id) - .collect(), - )) - } - - fn prepare_turn_runtime( - &self, - emit_output: bool, - ) -> Result<(BuiltRuntime, HookAbortMonitor), Box> { - let hook_abort_signal = runtime::HookAbortSignal::new(); - let runtime = build_runtime( - self.runtime.session().clone(), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - emit_output, - self.allowed_tools.clone(), - self.permission_mode, - None, - )? - .with_hook_abort_signal(hook_abort_signal.clone()); - let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); - - Ok((runtime, hook_abort_monitor)) - } - - fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box> { - self.runtime.shutdown_plugins()?; - self.runtime = runtime; - Ok(()) - } - - fn run_turn(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; - let mut spinner = Spinner::new(); - let mut stdout = io::stdout(); - spinner.tick( - "🦀 Thinking...", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - match result { - Ok(summary) => { - self.replace_runtime(runtime)?; - spinner.finish( - "✨ Done", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - println!(); - if let Some(event) = summary.auto_compaction { - println!( - "{}", - format_auto_compaction_notice(event.removed_message_count) - ); - } - self.persist_session()?; - Ok(()) - } - Err(error) => { - runtime.shutdown_plugins()?; - spinner.fail( - "❌ Request failed", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - Err(Box::new(error)) - } - } - } - - fn run_turn_with_output( - &mut self, - input: &str, - output_format: CliOutputFormat, - compact: bool, - ) -> Result<(), Box> { - match output_format { - CliOutputFormat::Json if compact => self.run_prompt_compact_json(input), - CliOutputFormat::Text if compact => self.run_prompt_compact(input), - CliOutputFormat::Text => self.run_turn(input), - CliOutputFormat::Json => self.run_prompt_json(input), - } - } - - fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - let final_text = final_assistant_text(&summary); - println!("{final_text}"); - Ok(()) - } - - fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!( - "{}", - json!({ - "message": final_assistant_text(&summary), - "compact": true, - "model": self.model, - "usage": { - "input_tokens": summary.usage.input_tokens, - "output_tokens": summary.usage.output_tokens, - "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, - "cache_read_input_tokens": summary.usage.cache_read_input_tokens, - }, - }) - ); - Ok(()) - } - - fn run_prompt_json(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!( - "{}", - json!({ - "message": final_assistant_text(&summary), - "model": self.model, - "iterations": summary.iterations, - "auto_compaction": summary.auto_compaction.map(|event| json!({ - "removed_messages": event.removed_message_count, - "notice": format_auto_compaction_notice(event.removed_message_count), - })), - "tool_uses": collect_tool_uses(&summary), - "tool_results": collect_tool_results(&summary), - "prompt_cache_events": collect_prompt_cache_events(&summary), - "usage": { - "input_tokens": summary.usage.input_tokens, - "output_tokens": summary.usage.output_tokens, - "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, - "cache_read_input_tokens": summary.usage.cache_read_input_tokens, - }, - "estimated_cost": format_usd( - summary.usage.estimate_cost_usd_with_pricing( - pricing_for_model(&self.model) - .unwrap_or_else(runtime::ModelPricing::default_sonnet_tier) - ).total_cost_usd() - ) - }) - ); - Ok(()) - } - - #[allow(clippy::too_many_lines)] - fn handle_repl_command( - &mut self, - command: SlashCommand, - ) -> Result> { - Ok(match command { - SlashCommand::Help => { - println!("{}", render_repl_help()); - false - } - SlashCommand::Status => { - self.print_status(); - false - } - SlashCommand::Bughunter { scope } => { - self.run_bughunter(scope.as_deref())?; - false - } - SlashCommand::Commit => { - self.run_commit(None)?; - false - } - SlashCommand::Pr { context } => { - self.run_pr(context.as_deref())?; - false - } - SlashCommand::Issue { context } => { - self.run_issue(context.as_deref())?; - false - } - SlashCommand::Ultraplan { task } => { - self.run_ultraplan(task.as_deref())?; - false - } - SlashCommand::Teleport { target } => { - Self::run_teleport(target.as_deref())?; - false - } - SlashCommand::DebugToolCall => { - self.run_debug_tool_call(None)?; - false - } - SlashCommand::Sandbox => { - Self::print_sandbox_status(); - false - } - SlashCommand::Compact => { - self.compact()?; - false - } - SlashCommand::Model { model } => self.set_model(model)?, - SlashCommand::Permissions { mode } => self.set_permissions(mode)?, - SlashCommand::Clear { confirm } => self.clear_session(confirm)?, - SlashCommand::Cost => { - self.print_cost(); - false - } - SlashCommand::Resume { session_path } => self.resume_session(session_path)?, - SlashCommand::Config { section } => { - Self::print_config(section.as_deref())?; - false - } - SlashCommand::Mcp { action, target } => { - let args = match (action.as_deref(), target.as_deref()) { - (None, None) => None, - (Some(action), None) => Some(action.to_string()), - (Some(action), Some(target)) => Some(format!("{action} {target}")), - (None, Some(target)) => Some(target.to_string()), - }; - Self::print_mcp(args.as_deref(), CliOutputFormat::Text)?; - false - } - SlashCommand::Memory => { - Self::print_memory()?; - false - } - SlashCommand::Init => { - run_init(CliOutputFormat::Text)?; - false - } - SlashCommand::Diff => { - Self::print_diff()?; - false - } - SlashCommand::Version => { - Self::print_version(CliOutputFormat::Text); - false - } - SlashCommand::Export { path } => { - self.export_session(path.as_deref())?; - false - } - SlashCommand::Session { action, target } => { - self.handle_session_command(action.as_deref(), target.as_deref())? - } - SlashCommand::Plugins { action, target } => { - self.handle_plugins_command(action.as_deref(), target.as_deref())? - } - SlashCommand::Agents { args } => { - Self::print_agents(args.as_deref(), CliOutputFormat::Text)?; - false - } - SlashCommand::Skills { args } => { - match classify_skills_slash_command(args.as_deref()) { - SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?, - SkillSlashDispatch::Local => { - Self::print_skills(args.as_deref(), CliOutputFormat::Text)?; - } - } - false - } - SlashCommand::Doctor => { - println!("{}", render_doctor_report()?.render()); - false - } - SlashCommand::History { count } => { - self.print_prompt_history(count.as_deref()); - false - } - SlashCommand::Stats => { - let usage = UsageTracker::from_session(self.runtime.session()).cumulative_usage(); - println!("{}", format_cost_report(usage)); - false - } - SlashCommand::Login - | SlashCommand::Logout - | SlashCommand::Vim - | SlashCommand::Upgrade - | SlashCommand::Share - | SlashCommand::Feedback - | SlashCommand::Files - | SlashCommand::Fast - | SlashCommand::Exit - | SlashCommand::Summary - | SlashCommand::Desktop - | SlashCommand::Brief - | SlashCommand::Advisor - | SlashCommand::Stickers - | SlashCommand::Insights - | SlashCommand::Thinkback - | SlashCommand::ReleaseNotes - | SlashCommand::SecurityReview - | SlashCommand::Keybindings - | SlashCommand::PrivacySettings - | SlashCommand::Plan { .. } - | SlashCommand::Review { .. } - | SlashCommand::Tasks { .. } - | SlashCommand::Theme { .. } - | SlashCommand::Voice { .. } - | SlashCommand::Usage { .. } - | SlashCommand::Rename { .. } - | SlashCommand::Copy { .. } - | SlashCommand::Hooks { .. } - | SlashCommand::Context { .. } - | SlashCommand::Color { .. } - | SlashCommand::Effort { .. } - | SlashCommand::Branch { .. } - | SlashCommand::Rewind { .. } - | SlashCommand::Ide { .. } - | SlashCommand::Tag { .. } - | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => { - let cmd_name = command.slash_name(); - eprintln!("{cmd_name} is not yet implemented in this build."); - false - } - SlashCommand::Unknown(name) => { - eprintln!("{}", format_unknown_slash_command(&name)); - false - } - }) - } - - fn persist_session(&self) -> Result<(), Box> { - self.runtime.session().save_to_path(&self.session.path)?; - Ok(()) - } - - fn print_status(&self) { - let cumulative = self.runtime.usage().cumulative_usage(); - let latest = self.runtime.usage().current_turn_usage(); - println!( - "{}", - format_status_report( - &self.model, - StatusUsage { - message_count: self.runtime.session().messages.len(), - turns: self.runtime.usage().turns(), - latest, - cumulative, - estimated_tokens: self.runtime.estimated_tokens(), - }, - self.permission_mode.as_str(), - &status_context(Some(&self.session.path)).expect("status context should load"), - None, // #148: REPL /status doesn't carry flag provenance - ) - ); - } - - fn record_prompt_history(&mut self, prompt: &str) { - let timestamp_ms = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(self.runtime.session().updated_at_ms, |duration| { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) - }); - let entry = PromptHistoryEntry { - timestamp_ms, - text: prompt.to_string(), - }; - self.prompt_history.push(entry); - if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) { - eprintln!("warning: failed to persist prompt history: {error}"); - } - } - - fn print_prompt_history(&self, count: Option<&str>) { - let limit = match parse_history_count(count) { - Ok(limit) => limit, - Err(message) => { - eprintln!("{message}"); - return; - } - }; - let session_entries = &self.runtime.session().prompt_history; - let entries = if session_entries.is_empty() { - if self.prompt_history.is_empty() { - collect_session_prompt_history(self.runtime.session()) - } else { - self.prompt_history - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect() - } - } else { - session_entries - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect() - }; - println!("{}", render_prompt_history_report(&entries, limit)); - } - - fn print_sandbox_status() { - let cwd = env::current_dir().expect("current dir"); - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader - .load() - .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); - println!( - "{}", - format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd)) - ); - } - - fn set_model(&mut self, model: Option) -> Result> { - let Some(model) = model else { - println!( - "{}", - format_model_report( - &self.model, - self.runtime.session().messages.len(), - self.runtime.usage().turns(), - ) - ); - return Ok(false); - }; - - let model = resolve_model_alias_with_config(&model); - - if model == self.model { - println!( - "{}", - format_model_report( - &self.model, - self.runtime.session().messages.len(), - self.runtime.usage().turns(), - ) - ); - return Ok(false); - } - - let previous = self.model.clone(); - let session = self.runtime.session().clone(); - let message_count = session.messages.len(); - let runtime = build_runtime( - session, - &self.session.id, - model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.model.clone_from(&model); - println!( - "{}", - format_model_switch_report(&previous, &model, message_count) - ); - Ok(true) - } - - fn set_permissions( - &mut self, - mode: Option, - ) -> Result> { - let Some(mode) = mode else { - println!( - "{}", - format_permissions_report(self.permission_mode.as_str()) - ); - return Ok(false); - }; - - let normalized = normalize_permission_mode(&mode).ok_or_else(|| { - format!( - "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access." - ) - })?; - - if normalized == self.permission_mode.as_str() { - println!("{}", format_permissions_report(normalized)); - return Ok(false); - } - - let previous = self.permission_mode.as_str().to_string(); - let session = self.runtime.session().clone(); - self.permission_mode = permission_mode_from_label(normalized); - let runtime = build_runtime( - session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - println!( - "{}", - format_permissions_switch_report(&previous, normalized) - ); - Ok(true) - } - - fn clear_session(&mut self, confirm: bool) -> Result> { - if !confirm { - println!( - "clear: confirmation required; run /clear --confirm to start a fresh session." - ); - return Ok(false); - } - - let previous_session = self.session.clone(); - let session_state = new_cli_session()?; - self.session = create_managed_session_handle(&session_state.session_id)?; - let runtime = build_runtime( - session_state.with_persistence_path(self.session.path.clone()), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - println!( - "Session cleared\n Mode fresh session\n Previous session {}\n Resume previous /resume {}\n Preserved model {}\n Permission mode {}\n New session {}\n Session file {}", - previous_session.id, - previous_session.id, - self.model, - self.permission_mode.as_str(), - self.session.id, - self.session.path.display(), - ); - Ok(true) - } - - fn print_cost(&self) { - let cumulative = self.runtime.usage().cumulative_usage(); - println!("{}", format_cost_report(cumulative)); - } - - fn resume_session( - &mut self, - session_path: Option, - ) -> Result> { - let Some(session_ref) = session_path else { - println!("{}", render_resume_usage()); - return Ok(false); - }; - - let (handle, session) = load_session_reference(&session_ref)?; - let message_count = session.messages.len(); - let session_id = session.session_id.clone(); - let runtime = build_runtime( - session, - &handle.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.session = SessionHandle { - id: session_id, - path: handle.path, - }; - println!( - "{}", - format_resume_report( - &self.session.path.display().to_string(), - message_count, - self.runtime.usage().turns(), - ) - ); - Ok(true) - } - - fn print_config(section: Option<&str>) -> Result<(), Box> { - println!("{}", render_config_report(section)?); - Ok(()) - } - - fn print_memory() -> Result<(), Box> { - println!("{}", render_memory_report()?); - Ok(()) - } - - fn print_agents( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)? - ), - } - Ok(()) - } - - fn print_mcp( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - // `claw mcp serve` starts a stdio MCP server exposing claw's built-in - // tools. All other `mcp` subcommands fall through to the existing - // configured-server reporter (`list`, `status`, ...). - if matches!(args.map(str::trim), Some("serve")) { - return run_mcp_serve(); - } - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd))? - ), - } - Ok(()) - } - - fn print_skills( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&handle_skills_slash_command_json(args, &cwd)?)? - ), - } - Ok(()) - } - - fn print_plugins( - action: Option<&str>, - target: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); - let result = handle_plugins_slash_command(action, target, &mut manager)?; - match output_format { - CliOutputFormat::Text => println!("{}", result.message), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "plugin", - "action": action.unwrap_or("list"), - "target": target, - "message": result.message, - "reload_runtime": result.reload_runtime, - }))? - ), - } - Ok(()) - } - - fn print_diff() -> Result<(), Box> { - println!("{}", render_diff_report()?); - Ok(()) - } - - fn print_version(output_format: CliOutputFormat) { - let _ = crate::print_version(output_format); - } - - fn export_session( - &self, - requested_path: Option<&str>, - ) -> Result<(), Box> { - let export_path = resolve_export_path(requested_path, self.runtime.session())?; - fs::write(&export_path, render_export_text(self.runtime.session()))?; - println!( - "Export\n Result wrote transcript\n File {}\n Messages {}", - export_path.display(), - self.runtime.session().messages.len(), - ); - Ok(()) - } - - #[allow(clippy::too_many_lines)] - fn handle_session_command( - &mut self, - action: Option<&str>, - target: Option<&str>, - ) -> Result> { - match action { - None | Some("list") => { - println!("{}", render_session_list(&self.session.id)?); - Ok(false) - } - Some("switch") => { - let Some(target) = target else { - println!("Usage: /session switch "); - return Ok(false); - }; - let (handle, session) = load_session_reference(target)?; - let message_count = session.messages.len(); - let session_id = session.session_id.clone(); - let runtime = build_runtime( - session, - &handle.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.session = SessionHandle { - id: session_id, - path: handle.path, - }; - println!( - "Session switched\n Active session {}\n File {}\n Messages {}", - self.session.id, - self.session.path.display(), - message_count, - ); - Ok(true) - } - Some("fork") => { - let forked = self.runtime.fork_session(target.map(ToOwned::to_owned)); - let parent_session_id = self.session.id.clone(); - let handle = create_managed_session_handle(&forked.session_id)?; - let branch_name = forked - .fork - .as_ref() - .and_then(|fork| fork.branch_name.clone()); - let forked = forked.with_persistence_path(handle.path.clone()); - let message_count = forked.messages.len(); - forked.save_to_path(&handle.path)?; - let runtime = build_runtime( - forked, - &handle.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.session = handle; - println!( - "Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}", - parent_session_id, - self.session.id, - branch_name.as_deref().unwrap_or("(unnamed)"), - self.session.path.display(), - message_count, - ); - Ok(true) - } - Some("delete") => { - let Some(target) = target else { - println!("Usage: /session delete [--force]"); - return Ok(false); - }; - let handle = resolve_session_reference(target)?; - if handle.id == self.session.id { - println!( - "delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch .", - handle.id - ); - return Ok(false); - } - if !confirm_session_deletion(&handle.id) { - println!("delete: cancelled."); - return Ok(false); - } - delete_managed_session(&handle.path)?; - println!( - "Session deleted\n Deleted session {}\n File {}", - handle.id, - handle.path.display(), - ); - Ok(false) - } - Some("delete-force") => { - let Some(target) = target else { - println!("Usage: /session delete [--force]"); - return Ok(false); - }; - let handle = resolve_session_reference(target)?; - if handle.id == self.session.id { - println!( - "delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch .", - handle.id - ); - return Ok(false); - } - delete_managed_session(&handle.path)?; - println!( - "Session deleted\n Deleted session {}\n File {}", - handle.id, - handle.path.display(), - ); - Ok(false) - } - Some(other) => { - println!( - "Unknown /session action '{other}'. Use /session list, /session switch , /session fork [branch-name], or /session delete [--force]." - ); - Ok(false) - } - } - } - - fn handle_plugins_command( - &mut self, - action: Option<&str>, - target: Option<&str>, - ) -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); - let result = handle_plugins_slash_command(action, target, &mut manager)?; - println!("{}", result.message); - if result.reload_runtime { - self.reload_runtime_features()?; - } - Ok(false) - } - - fn reload_runtime_features(&mut self) -> Result<(), Box> { - let runtime = build_runtime( - self.runtime.session().clone(), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.persist_session() - } - - fn compact(&mut self) -> Result<(), Box> { - let result = self.runtime.compact(CompactionConfig::default()); - let removed = result.removed_message_count; - let kept = result.compacted_session.messages.len(); - let skipped = removed == 0; - let runtime = build_runtime( - result.compacted_session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!("{}", format_compact_report(removed, kept, skipped)); - Ok(()) - } - - fn run_internal_prompt_text_with_progress( - &self, - prompt: &str, - enable_tools: bool, - progress: Option, - ) -> Result> { - let session = self.runtime.session().clone(); - let mut runtime = build_runtime( - session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - enable_tools, - false, - self.allowed_tools.clone(), - self.permission_mode, - progress, - )?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; - let text = final_assistant_text(&summary).trim().to_string(); - runtime.shutdown_plugins()?; - Ok(text) - } - - fn run_internal_prompt_text( - &self, - prompt: &str, - enable_tools: bool, - ) -> Result> { - self.run_internal_prompt_text_with_progress(prompt, enable_tools, None) - } - - fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box> { - println!("{}", format_bughunter_report(scope)); - Ok(()) - } - - fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box> { - println!("{}", format_ultraplan_report(task)); - Ok(()) - } - - fn run_teleport(target: Option<&str>) -> Result<(), Box> { - let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { - println!("Usage: /teleport "); - return Ok(()); - }; - - println!("{}", render_teleport_report(target)?); - Ok(()) - } - - fn run_debug_tool_call(&self, args: Option<&str>) -> Result<(), Box> { - validate_no_args("/debug-tool-call", args)?; - println!("{}", render_last_tool_debug_report(self.runtime.session())?); - Ok(()) - } - - fn run_commit(&mut self, args: Option<&str>) -> Result<(), Box> { - validate_no_args("/commit", args)?; - let status = git_output(&["status", "--short", "--branch"])?; - let summary = parse_git_workspace_summary(Some(&status)); - let branch = parse_git_status_branch(Some(&status)); - if summary.is_clean() { - println!("{}", format_commit_skipped_report()); - return Ok(()); - } - - println!( - "{}", - format_commit_preflight_report(branch.as_deref(), summary) - ); - Ok(()) - } - - fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { - let branch = - resolve_git_branch_for(&env::current_dir()?).unwrap_or_else(|| "unknown".to_string()); - println!("{}", format_pr_report(&branch, context)); - Ok(()) - } - - fn run_issue(&self, context: Option<&str>) -> Result<(), Box> { - println!("{}", format_issue_report(context)); - Ok(()) - } -} - -fn sessions_dir() -> Result> { - Ok(current_session_store()?.sessions_dir().to_path_buf()) -} - -fn current_session_store() -> Result> { - let cwd = env::current_dir()?; - runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box) -} - -fn new_cli_session() -> Result> { - Ok(Session::new().with_workspace_root(env::current_dir()?)) -} - -fn create_managed_session_handle( - session_id: &str, -) -> Result> { - let handle = current_session_store()?.create_handle(session_id); - Ok(SessionHandle { - id: handle.id, - path: handle.path, - }) -} - -fn resolve_session_reference(reference: &str) -> Result> { - let handle = current_session_store()? - .resolve_reference(reference) - .map_err(|e| Box::new(e) as Box)?; - Ok(SessionHandle { - id: handle.id, - path: handle.path, - }) -} - -fn resolve_managed_session_path(session_id: &str) -> Result> { - current_session_store()? - .resolve_managed_path(session_id) - .map_err(|e| Box::new(e) as Box) -} - -fn list_managed_sessions() -> Result, Box> { - Ok(current_session_store()? - .list_sessions() - .map_err(|e| Box::new(e) as Box)? - .into_iter() - .map(|session| ManagedSessionSummary { - id: session.id, - path: session.path, - updated_at_ms: session.updated_at_ms, - modified_epoch_millis: session.modified_epoch_millis, - message_count: session.message_count, - parent_session_id: session.parent_session_id, - branch_name: session.branch_name, - }) - .collect()) -} - -fn latest_managed_session() -> Result> { - let session = current_session_store()? - .latest_session() - .map_err(|e| Box::new(e) as Box)?; - Ok(ManagedSessionSummary { - id: session.id, - path: session.path, - updated_at_ms: session.updated_at_ms, - modified_epoch_millis: session.modified_epoch_millis, - message_count: session.message_count, - parent_session_id: session.parent_session_id, - branch_name: session.branch_name, - }) -} - -fn load_session_reference( - reference: &str, -) -> Result<(SessionHandle, Session), Box> { - let loaded = current_session_store()? - .load_session(reference) - .map_err(|e| Box::new(e) as Box)?; - Ok(( - SessionHandle { - id: loaded.handle.id, - path: loaded.handle.path, - }, - loaded.session, - )) -} - -fn delete_managed_session(path: &Path) -> Result<(), Box> { - if !path.exists() { - return Err(format!("session file does not exist: {}", path.display()).into()); - } - fs::remove_file(path)?; - Ok(()) -} - -fn confirm_session_deletion(session_id: &str) -> bool { - print!("Delete session '{session_id}'? This cannot be undone. [y/N]: "); - io::stdout().flush().unwrap_or(()); - let mut answer = String::new(); - if io::stdin().read_line(&mut answer).is_err() { - return false; - } - matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES") -} - -fn render_session_list(active_session_id: &str) -> Result> { - let sessions = list_managed_sessions()?; - let mut lines = vec![ - "Sessions".to_string(), - format!(" Directory {}", sessions_dir()?.display()), - ]; - if sessions.is_empty() { - lines.push(" No managed sessions saved yet.".to_string()); - return Ok(lines.join("\n")); - } - for session in sessions { - let marker = if session.id == active_session_id { - "● current" - } else { - "○ saved" - }; - let lineage = match ( - session.branch_name.as_deref(), - session.parent_session_id.as_deref(), - ) { - (Some(branch_name), Some(parent_session_id)) => { - format!(" branch={branch_name} from={parent_session_id}") - } - (None, Some(parent_session_id)) => format!(" from={parent_session_id}"), - (Some(branch_name), None) => format!(" branch={branch_name}"), - (None, None) => String::new(), - }; - lines.push(format!( - " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}", - id = session.id, - msgs = session.message_count, - modified = format_session_modified_age(session.modified_epoch_millis), - lineage = lineage, - path = session.path.display(), - )); - } - Ok(lines.join("\n")) -} - -fn format_session_modified_age(modified_epoch_millis: u128) -> String { - let now = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(modified_epoch_millis, |duration| duration.as_millis()); - let delta_seconds = now - .saturating_sub(modified_epoch_millis) - .checked_div(1_000) - .unwrap_or_default(); - match delta_seconds { - 0..=4 => "just-now".to_string(), - 5..=59 => format!("{delta_seconds}s-ago"), - 60..=3_599 => format!("{}m-ago", delta_seconds / 60), - 3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600), - _ => format!("{}d-ago", delta_seconds / 86_400), - } -} - -fn write_session_clear_backup( - session: &Session, - session_path: &Path, -) -> Result> { - let backup_path = session_clear_backup_path(session_path); - session.save_to_path(&backup_path)?; - Ok(backup_path) -} - -fn session_clear_backup_path(session_path: &Path) -> PathBuf { - let timestamp = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(0, |duration| duration.as_millis()); - let file_name = session_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("session.jsonl"); - session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak")) -} - -fn render_repl_help() -> String { - [ - "REPL".to_string(), - " /exit Quit the REPL".to_string(), - " /quit Quit the REPL".to_string(), - " Up/Down Navigate prompt history".to_string(), - " Ctrl-R Reverse-search prompt history".to_string(), - " Tab Complete commands, modes, and recent sessions".to_string(), - " Ctrl-C Clear input (or exit on empty prompt)".to_string(), - " Shift+Enter/Ctrl+J Insert a newline".to_string(), - " Auto-save .claw/sessions/.jsonl".to_string(), - " Resume latest /resume latest".to_string(), - " Browse sessions /session list".to_string(), - " Show prompt history /history [count]".to_string(), - String::new(), - render_slash_command_help_filtered(STUB_COMMANDS), - ] - .join( - " -", - ) -} fn print_status_snapshot( model: &str, @@ -5848,7 +4162,7 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso }) } -fn normalize_permission_mode(mode: &str) -> Option<&'static str> { +pub(crate) fn normalize_permission_mode(mode: &str) -> Option<&'static str> { match mode.trim() { "read-only" => Some("read-only"), "workspace-write" => Some("workspace-write"), @@ -6599,7 +4913,7 @@ fn build_runtime_plugin_state() -> Result, - permission_mode: PermissionMode, - system_prompt: Vec, - runtime: BuiltRuntime, - session: SessionHandle, - prompt_history: Vec, -} impl LiveCli { pub(crate) fn new( diff --git a/rust/crates/rusty-claude-cli/src/session.rs b/rust/crates/rusty-claude-cli/src/session.rs new file mode 100644 index 00000000..a7f80096 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/session.rs @@ -0,0 +1,233 @@ +use std::env; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use commands::{render_slash_command_help_filtered, SlashCommand}; +use runtime::Session; + +use crate::constants::*; +use crate::models::*; + +pub(crate) fn current_session_store() -> Result> { + let cwd = env::current_dir()?; + runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box) +} + +pub(crate) fn sessions_dir() -> Result> { + Ok(current_session_store()?.sessions_dir().to_path_buf()) +} + +pub(crate) fn new_cli_session() -> Result> { + Ok(Session::new().with_workspace_root(env::current_dir()?)) +} + +pub(crate) fn create_managed_session_handle( + session_id: &str, +) -> Result> { + let handle = current_session_store()?.create_handle(session_id); + Ok(SessionHandle { + id: handle.id, + path: handle.path, + }) +} + +pub(crate) fn resolve_session_reference(reference: &str) -> Result> { + let handle = current_session_store()? + .resolve_reference(reference) + .map_err(|e| Box::new(e) as Box)?; + Ok(SessionHandle { + id: handle.id, + path: handle.path, + }) +} + +pub(crate) fn resolve_managed_session_path(session_id: &str) -> Result> { + current_session_store()? + .resolve_managed_path(session_id) + .map_err(|e| Box::new(e) as Box) +} + +pub(crate) fn list_managed_sessions() -> Result, Box> { + Ok(current_session_store()? + .list_sessions() + .map_err(|e| Box::new(e) as Box)? + .into_iter() + .map(|session| ManagedSessionSummary { + id: session.id, + path: session.path, + updated_at_ms: session.updated_at_ms, + modified_epoch_millis: session.modified_epoch_millis, + message_count: session.message_count, + parent_session_id: session.parent_session_id, + branch_name: session.branch_name, + lifecycle: SessionLifecycleSummary { + kind: SessionLifecycleKind::SavedOnly, + pane_id: None, + pane_command: None, + pane_path: None, + workspace_dirty: false, + abandoned: false, + }, + }) + .collect()) +} + +pub(crate) fn latest_managed_session() -> Result> { + let session = current_session_store()? + .latest_session() + .map_err(|e| Box::new(e) as Box)?; + Ok(ManagedSessionSummary { + id: session.id, + path: session.path, + updated_at_ms: session.updated_at_ms, + modified_epoch_millis: session.modified_epoch_millis, + message_count: session.message_count, + parent_session_id: session.parent_session_id, + branch_name: session.branch_name, + lifecycle: SessionLifecycleSummary { + kind: SessionLifecycleKind::SavedOnly, + pane_id: None, + pane_command: None, + pane_path: None, + workspace_dirty: false, + abandoned: false, + }, + }) +} + +pub(crate) fn load_session_reference( + reference: &str, +) -> Result<(SessionHandle, Session), Box> { + let loaded = current_session_store()? + .load_session(reference) + .map_err(|e| Box::new(e) as Box)?; + Ok(( + SessionHandle { + id: loaded.handle.id, + path: loaded.handle.path, + }, + loaded.session, + )) +} + +pub(crate) fn delete_managed_session(path: &Path) -> Result<(), Box> { + if !path.exists() { + return Err(format!("session file does not exist: {}", path.display()).into()); + } + fs::remove_file(path)?; + Ok(()) +} + +pub(crate) fn confirm_session_deletion(session_id: &str) -> bool { + print!("Delete session '{session_id}'? This cannot be undone. [y/N]: "); + io::stdout().flush().unwrap_or(()); + let mut answer = String::new(); + if io::stdin().read_line(&mut answer).is_err() { + return false; + } + matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES") +} + +pub(crate) fn render_session_list(active_session_id: &str) -> Result> { + let sessions = list_managed_sessions()?; + let mut lines = vec![ + "Sessions".to_string(), + format!(" Directory {}", sessions_dir()?.display()), + ]; + if sessions.is_empty() { + lines.push(" No managed sessions saved yet.".to_string()); + return Ok(lines.join("\n")); + } + for session in sessions { + let marker = if session.id == active_session_id { + "● current" + } else { + "○ saved" + }; + let lineage = match ( + session.branch_name.as_deref(), + session.parent_session_id.as_deref(), + ) { + (Some(branch_name), Some(parent_session_id)) => { + format!(" branch={branch_name} from={parent_session_id}") + } + (None, Some(parent_session_id)) => format!(" from={parent_session_id}"), + (Some(branch_name), None) => format!(" branch={branch_name}"), + (None, None) => String::new(), + }; + lines.push(format!( + " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}", + id = session.id, + msgs = session.message_count, + modified = format_session_modified_age(session.modified_epoch_millis), + lineage = lineage, + path = session.path.display(), + )); + } + Ok(lines.join("\n")) +} + +pub(crate) fn format_session_modified_age(modified_epoch_millis: u128) -> String { + let now = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map_or(modified_epoch_millis, |duration| duration.as_millis()); + let delta_seconds = now + .saturating_sub(modified_epoch_millis) + .checked_div(1_000) + .unwrap_or_default(); + match delta_seconds { + 0..=4 => "just-now".to_string(), + 5..=59 => format!("{delta_seconds}s-ago"), + 60..=3_599 => format!("{}m-ago", delta_seconds / 60), + 3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600), + _ => format!("{}d-ago", delta_seconds / 86_400), + } +} + +pub(crate) fn write_session_clear_backup( + session: &Session, + session_path: &Path, +) -> Result> { + let backup_path = session_clear_backup_path(session_path); + session.save_to_path(&backup_path)?; + Ok(backup_path) +} + +pub(crate) fn session_clear_backup_path(session_path: &Path) -> PathBuf { + let timestamp = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map_or(0, |duration| duration.as_millis()); + let file_name = session_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("session.jsonl"); + session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak")) +} + +pub(crate) fn render_repl_help() -> String { + [ + "REPL".to_string(), + " /exit Quit the REPL".to_string(), + " /quit Quit the REPL".to_string(), + " Up/Down Navigate prompt history".to_string(), + " Ctrl-R Reverse-search prompt history".to_string(), + " Tab Complete commands, modes, and recent sessions".to_string(), + " Ctrl-C Clear input (or exit on empty prompt)".to_string(), + " Shift+Enter/Ctrl+J Insert a newline".to_string(), + " Auto-save .claw/sessions/.jsonl".to_string(), + " Resume latest /resume latest".to_string(), + " Browse sessions /session list".to_string(), + " Show prompt history /history [count]".to_string(), + String::new(), + render_slash_command_help_filtered(STUB_COMMANDS), + ] + .join( + " +", + ) +} +