diff --git a/rust/crates/rusty-claude-cli/src/app.rs b/rust/crates/rusty-claude-cli/src/app.rs new file mode 100644 index 0000000000..daba18712f --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/app.rs @@ -0,0 +1,3124 @@ +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}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, UNIX_EPOCH}; + +use api::{ + detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource, + ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, + OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, ProviderKind, + StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, +}; + +use crate::cli_commands::*; +use crate::init::initialize_repo; +use crate::input; +use crate::render::{MarkdownStreamState, Spinner, TerminalRenderer}; +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, SkillSlashDispatch, SlashCommand, +}; +use compat_harness::{extract_manifest, UpstreamPaths}; +use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; +use runtime::{ + check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials, + load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status, + ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, + ContentBlock, ConversationMessage, ConversationRuntime, McpServer, McpServerManager, + McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, PermissionPolicy, + ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, Session, TokenUsage, + ToolError, ToolExecutor, UsageTracker, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use tools::{ + execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput, +}; + +use crate::args::*; +use crate::format::*; +use crate::tui::permission::{ + format_enhanced_permission_prompt, parse_permission_response, PermissionDecision, +}; +use crate::tui::status_bar::{StatusBar, StatusBarState}; +use crate::tui::terminal::TerminalSize; +use crate::tui::timeline::ToolCallTimeline; +use crate::{ + AllowedToolSet, RuntimePluginStateBuildOutput, DEFAULT_DATE, + INTERNAL_PROGRESS_HEARTBEAT_INTERVAL, POST_TOOL_STALL_TIMEOUT, +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// LiveCli +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BannerStyle { + Full, + Compact, + None, +} + +impl BannerStyle { + pub(crate) fn from_config(value: Option<&str>) -> Self { + match value { + Some("full") => BannerStyle::Full, + Some("none") => BannerStyle::None, + _ => BannerStyle::Compact, + } + } +} + +pub(crate) struct LiveCli { + pub(crate) model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + banner_style: BannerStyle, + system_prompt: Vec, + runtime: BuiltRuntime, + session: SessionHandle, + prompt_history: Vec, +} + +impl LiveCli { + pub(crate) fn new( + model: String, + enable_tools: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + banner_style: Option, + ) -> 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, + banner_style: banner_style.unwrap_or(BannerStyle::Compact), + system_prompt, + runtime, + session, + prompt_history: Vec::new(), + }; + cli.persist_session()?; + Ok(cli) + } + + pub(crate) 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); + } + } + + pub(crate) fn startup_banner(&self) -> String { + match self.banner_style { + BannerStyle::Full => self.full_banner(), + BannerStyle::Compact => self.compact_banner(), + BannerStyle::None => String::new(), + } + } + + fn full_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 compact_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"); + format!( + "\x1b[38;5;196mCLAW\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞 \ + \x1b[2mmodel\x1b[0m {} \ + \x1b[2mperm\x1b[0m {} \ + \x1b[2mbranch\x1b[0m {}\n\ + \x1b[2m{}/ \ + Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for completions\x1b[0m", + self.model, + self.permission_mode.as_str(), + git_branch, + cwd, + ) + } + + pub(crate) 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(()) + } + + pub(crate) 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( + "\u{1f980} 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( + "\u{2728} 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( + "\u{274c} Request failed", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + Err(Box::new(error)) + } + } + } + + pub(crate) 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)] + pub(crate) 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 + } + }) + } + + pub(crate) 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, + ) + ); + } + + pub(crate) 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)); + } + + pub(crate) 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(()) + } + + pub(crate) 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(()) + } + + pub(crate) fn print_mcp( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + 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(()) + } + + pub(crate) 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(()) + } + + pub(crate) 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(()) + } + + pub(crate) 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) + } + + pub(crate) 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(()) + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// BuiltRuntime +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) struct BuiltRuntime { + pub(crate) runtime: Option>, + plugin_registry: PluginRegistry, + plugins_active: bool, + pub(crate) mcp_state: Option>>, + mcp_active: bool, +} + +impl BuiltRuntime { + fn new( + runtime: ConversationRuntime, + plugin_registry: PluginRegistry, + mcp_state: Option>>, + ) -> Self { + Self { + runtime: Some(runtime), + plugin_registry, + plugins_active: true, + mcp_state, + mcp_active: true, + } + } + + fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self { + let runtime = self + .runtime + .take() + .expect("runtime should exist before installing hook abort signal"); + self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal)); + self + } + + pub(crate) fn shutdown_plugins(&mut self) -> Result<(), Box> { + if self.plugins_active { + self.plugin_registry.shutdown()?; + self.plugins_active = false; + } + Ok(()) + } + + fn shutdown_mcp(&mut self) -> Result<(), Box> { + if self.mcp_active { + if let Some(mcp_state) = &self.mcp_state { + mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .shutdown()?; + } + self.mcp_active = false; + } + Ok(()) + } +} + +impl Deref for BuiltRuntime { + type Target = ConversationRuntime; + + fn deref(&self) -> &Self::Target { + self.runtime + .as_ref() + .expect("runtime should exist while built runtime is alive") + } +} + +impl DerefMut for BuiltRuntime { + fn deref_mut(&mut self) -> &mut Self::Target { + self.runtime + .as_mut() + .expect("runtime should exist while built runtime is alive") + } +} + +impl Drop for BuiltRuntime { + fn drop(&mut self) { + let _ = self.shutdown_mcp(); + let _ = self.shutdown_plugins(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ToolSearchRequest / McpToolRequest / Resource request structs +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Deserialize)] +pub(crate) struct ToolSearchRequest { + query: String, + max_results: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct McpToolRequest { + #[serde(rename = "qualifiedName")] + qualified_name: Option, + tool: Option, + arguments: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ListMcpResourcesRequest { + server: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ReadMcpResourceRequest { + server: String, + uri: String, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// RuntimePluginState +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) struct RuntimePluginState { + pub(crate) feature_config: runtime::RuntimeFeatureConfig, + pub(crate) tool_registry: GlobalToolRegistry, + pub(crate) plugin_registry: PluginRegistry, + pub(crate) mcp_state: Option>>, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// RuntimeMcpState +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) struct RuntimeMcpState { + runtime: tokio::runtime::Runtime, + manager: McpServerManager, + pending_servers: Vec, + degraded_report: Option, +} + +impl RuntimeMcpState { + fn new( + runtime_config: &runtime::RuntimeConfig, + ) -> Result, Box> { + let mut manager = McpServerManager::from_runtime_config(runtime_config); + if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() { + return Ok(None); + } + + let runtime = tokio::runtime::Runtime::new()?; + let discovery = runtime.block_on(manager.discover_tools_best_effort()); + let pending_servers = discovery + .failed_servers + .iter() + .map(|failure| failure.server_name.clone()) + .chain( + discovery + .unsupported_servers + .iter() + .map(|server| server.server_name.clone()), + ) + .collect::>() + .into_iter() + .collect::>(); + let available_tools = discovery + .tools + .iter() + .map(|tool| tool.qualified_name.clone()) + .collect::>(); + let failed_server_names = pending_servers.iter().cloned().collect::>(); + let working_servers = manager + .server_names() + .into_iter() + .filter(|server_name| !failed_server_names.contains(server_name)) + .collect::>(); + let failed_servers = + discovery + .failed_servers + .iter() + .map(|failure| runtime::McpFailedServer { + server_name: failure.server_name.clone(), + phase: runtime::McpLifecyclePhase::ToolDiscovery, + error: runtime::McpErrorSurface::new( + runtime::McpLifecyclePhase::ToolDiscovery, + Some(failure.server_name.clone()), + failure.error.clone(), + std::collections::BTreeMap::new(), + true, + ), + }) + .chain(discovery.unsupported_servers.iter().map(|server| { + runtime::McpFailedServer { + server_name: server.server_name.clone(), + phase: runtime::McpLifecyclePhase::ServerRegistration, + error: runtime::McpErrorSurface::new( + runtime::McpLifecyclePhase::ServerRegistration, + Some(server.server_name.clone()), + server.reason.clone(), + std::collections::BTreeMap::from([( + "transport".to_string(), + format!("{:?}", server.transport).to_ascii_lowercase(), + )]), + false, + ), + } + })) + .collect::>(); + let degraded_report = (!failed_servers.is_empty()).then(|| { + runtime::McpDegradedReport::new( + working_servers, + failed_servers, + available_tools.clone(), + available_tools, + ) + }); + + Ok(Some(( + Self { + runtime, + manager, + pending_servers, + degraded_report, + }, + discovery, + ))) + } + + pub(crate) fn shutdown(&mut self) -> Result<(), Box> { + self.runtime.block_on(self.manager.shutdown())?; + Ok(()) + } + + pub(crate) fn pending_servers(&self) -> Option> { + (!self.pending_servers.is_empty()).then(|| self.pending_servers.clone()) + } + + pub(crate) fn degraded_report(&self) -> Option { + self.degraded_report.clone() + } + + pub(crate) fn server_names(&self) -> Vec { + self.manager.server_names() + } + + pub(crate) fn call_tool( + &mut self, + qualified_tool_name: &str, + arguments: Option, + ) -> Result { + let response = self + .runtime + .block_on(self.manager.call_tool(qualified_tool_name, arguments)) + .map_err(|error| ToolError::new(error.to_string()))?; + if let Some(error) = response.error { + return Err(ToolError::new(format!( + "MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})", + error.message, error.code + ))); + } + + let result = response.result.ok_or_else(|| { + ToolError::new(format!( + "MCP tool `{qualified_tool_name}` returned no result payload" + )) + })?; + serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn list_resources_for_server( + &mut self, + server_name: &str, + ) -> Result { + let result = self + .runtime + .block_on(self.manager.list_resources(server_name)) + .map_err(|error| ToolError::new(error.to_string()))?; + serde_json::to_string_pretty(&json!({ + "server": server_name, + "resources": result.resources, + })) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn list_resources_for_all_servers(&mut self) -> Result { + let mut resources = Vec::new(); + let mut failures = Vec::new(); + + for server_name in self.server_names() { + match self + .runtime + .block_on(self.manager.list_resources(&server_name)) + { + Ok(result) => resources.push(json!({ + "server": server_name, + "resources": result.resources, + })), + Err(error) => failures.push(json!({ + "server": server_name, + "error": error.to_string(), + })), + } + } + + if resources.is_empty() && !failures.is_empty() { + let message = failures + .iter() + .filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str)) + .collect::>() + .join("; "); + return Err(ToolError::new(message)); + } + + serde_json::to_string_pretty(&json!({ + "resources": resources, + "failures": failures, + })) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn read_resource( + &mut self, + server_name: &str, + uri: &str, + ) -> Result { + let result = self + .runtime + .block_on(self.manager.read_resource(server_name, uri)) + .map_err(|error| ToolError::new(error.to_string()))?; + serde_json::to_string_pretty(&json!({ + "server": server_name, + "contents": result.contents, + })) + .map_err(|error| ToolError::new(error.to_string())) + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// HookAbortMonitor +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) struct HookAbortMonitor { + stop_tx: Option>, + join_handle: Option>, +} + +impl HookAbortMonitor { + pub(crate) fn spawn(abort_signal: runtime::HookAbortSignal) -> Self { + Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + + runtime.block_on(async move { + let wait_for_stop = tokio::task::spawn_blocking(move || { + let _ = stop_rx.recv(); + }); + + tokio::select! { + result = tokio::signal::ctrl_c() => { + if result.is_ok() { + abort_signal.abort(); + } + } + _ = wait_for_stop => {} + } + }); + }) + } + + pub(crate) fn spawn_with_waiter( + abort_signal: runtime::HookAbortSignal, + wait_for_interrupt: F, + ) -> Self + where + F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static, + { + let (stop_tx, stop_rx) = mpsc::channel(); + let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal)); + + Self { + stop_tx: Some(stop_tx), + join_handle: Some(join_handle), + } + } + + pub(crate) fn stop(mut self) { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + if let Some(join_handle) = self.join_handle.take() { + let _ = join_handle.join(); + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// build_runtime_mcp_state +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) fn build_runtime_mcp_state( + runtime_config: &runtime::RuntimeConfig, +) -> Result> { + let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else { + return Ok((None, Vec::new())); + }; + + let mut runtime_tools = discovery + .tools + .iter() + .map(mcp_runtime_tool_definition) + .collect::>(); + if !mcp_state.server_names().is_empty() { + runtime_tools.extend(mcp_wrapper_tool_definitions()); + } + + Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools)) +} + +pub(crate) fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition { + RuntimeToolDefinition { + name: tool.qualified_name.clone(), + description: Some( + tool.tool + .description + .clone() + .unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)), + ), + input_schema: tool + .tool + .input_schema + .clone() + .unwrap_or_else(|| json!({ "type": "object", "additionalProperties": true })), + required_permission: permission_mode_for_mcp_tool(&tool.tool), + } +} + +pub(crate) fn mcp_wrapper_tool_definitions() -> Vec { + vec![ + RuntimeToolDefinition { + name: "MCPTool".to_string(), + description: Some( + "Call a configured MCP tool by its qualified name and JSON arguments.".to_string(), + ), + input_schema: json!({ + "type": "object", + "properties": { + "qualifiedName": { "type": "string" }, + "arguments": {} + }, + "required": ["qualifiedName"], + "additionalProperties": false + }), + required_permission: PermissionMode::DangerFullAccess, + }, + RuntimeToolDefinition { + name: "ListMcpResourcesTool".to_string(), + description: Some( + "List MCP resources from one configured server or from every connected server." + .to_string(), + ), + input_schema: json!({ + "type": "object", + "properties": { + "server": { "type": "string" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + RuntimeToolDefinition { + name: "ReadMcpResourceTool".to_string(), + description: Some("Read a specific MCP resource from a configured server.".to_string()), + input_schema: json!({ + "type": "object", + "properties": { + "server": { "type": "string" }, + "uri": { "type": "string" } + }, + "required": ["server", "uri"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ] +} + +pub(crate) fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode { + let read_only = mcp_annotation_flag(tool, "readOnlyHint"); + let destructive = mcp_annotation_flag(tool, "destructiveHint"); + let open_world = mcp_annotation_flag(tool, "openWorldHint"); + + if read_only && !destructive && !open_world { + PermissionMode::ReadOnly + } else if destructive || open_world { + PermissionMode::DangerFullAccess + } else { + PermissionMode::WorkspaceWrite + } +} + +pub(crate) fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool { + tool.annotations + .as_ref() + .and_then(|annotations| annotations.get(key)) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// build_runtime / build_runtime_with_plugin_state +// ═══════════════════════════════════════════════════════════════════════════ + +#[allow(clippy::needless_pass_by_value)] +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_runtime( + session: Session, + session_id: &str, + model: String, + system_prompt: Vec, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + progress_reporter: Option, +) -> Result> { + let runtime_plugin_state = build_runtime_plugin_state()?; + build_runtime_with_plugin_state( + session, + session_id, + model, + system_prompt, + enable_tools, + emit_output, + allowed_tools, + permission_mode, + progress_reporter, + runtime_plugin_state, + ) +} + +#[allow(clippy::needless_pass_by_value)] +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_runtime_with_plugin_state( + mut session: Session, + session_id: &str, + model: String, + system_prompt: Vec, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + progress_reporter: Option, + runtime_plugin_state: RuntimePluginState, +) -> Result> { + if session.model.is_none() { + session.model = Some(model.clone()); + } + let RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + mcp_state, + } = runtime_plugin_state; + plugin_registry.initialize()?; + let policy = permission_policy(permission_mode, &feature_config, &tool_registry) + .map_err(std::io::Error::other)?; + let mut runtime = ConversationRuntime::new_with_features( + session, + AnthropicRuntimeClient::new( + session_id, + model, + enable_tools, + emit_output, + allowed_tools.clone(), + tool_registry.clone(), + progress_reporter, + )?, + CliToolExecutor::new( + allowed_tools.clone(), + emit_output, + tool_registry.clone(), + mcp_state.clone(), + ), + policy, + system_prompt, + &feature_config, + ); + if emit_output { + runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter)); + } + Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// build_runtime_plugin_state +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) fn build_runtime_plugin_state() -> Result> +{ + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) +} + +pub(crate) fn build_runtime_plugin_state_with_loader( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> Result> { + let plugin_manager = build_plugin_manager(cwd, loader, runtime_config); + let plugin_registry = plugin_manager.plugin_registry()?; + let plugin_hook_config = + runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?); + let feature_config = runtime_config + .feature_config() + .clone() + .with_hooks(runtime_config.hooks().merged(&plugin_hook_config)); + let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?; + let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)? + .with_runtime_tools(runtime_tools)?; + Ok(RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + mcp_state, + }) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// build_plugin_manager / resolve_plugin_path / runtime_hook_config +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) fn build_plugin_manager( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> PluginManager { + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + PluginManager::new(plugin_config) +} + +pub(crate) fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } +} + +pub(crate) fn runtime_hook_config_from_plugin_hooks( + hooks: PluginHooks, +) -> runtime::RuntimeHookConfig { + runtime::RuntimeHookConfig::new( + hooks.pre_tool_use, + hooks.post_tool_use, + hooks.post_tool_use_failure, + ) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CliHookProgressReporter +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) struct CliHookProgressReporter; + +impl runtime::HookProgressReporter for CliHookProgressReporter { + fn on_event(&mut self, event: &runtime::HookProgressEvent) { + match event { + runtime::HookProgressEvent::Started { + event, + tool_name, + command, + } => eprintln!( + "[hook {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + runtime::HookProgressEvent::Completed { + event, + tool_name, + command, + } => eprintln!( + "[hook done {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + runtime::HookProgressEvent::Cancelled { + event, + tool_name, + command, + } => eprintln!( + "[hook cancelled {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CliPermissionPrompter +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) struct CliPermissionPrompter { + current_mode: PermissionMode, + approve_all: bool, +} + +impl CliPermissionPrompter { + pub(crate) fn new(current_mode: PermissionMode) -> Self { + Self { + current_mode, + approve_all: false, + } + } +} + +impl runtime::PermissionPrompter for CliPermissionPrompter { + fn decide( + &mut self, + request: &runtime::PermissionRequest, + ) -> runtime::PermissionPromptDecision { + if self.approve_all { + return runtime::PermissionPromptDecision::Allow; + } + + let input = serde_json::from_str(&request.input) + .unwrap_or(serde_json::Value::String(request.input.clone())); + let prompt = format_enhanced_permission_prompt( + &request.tool_name, + &input, + &self.current_mode.as_str(), + &request.required_mode.as_str(), + request.reason.as_deref(), + ); + println!("{prompt}"); + let _ = io::stdout().flush(); + + let mut response = String::new(); + match io::stdin().read_line(&mut response) { + Ok(_) => match parse_permission_response(&response) { + PermissionDecision::Allow => runtime::PermissionPromptDecision::Allow, + PermissionDecision::AllowAll => { + self.approve_all = true; + runtime::PermissionPromptDecision::Allow + } + PermissionDecision::ViewInput => { + // Print the raw input on its own line so the user can inspect it + println!(); + println!("Input:\n{}", request.input); + // Re-prompt + self.decide(request) + } + PermissionDecision::Deny { reason: _ } => 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}"), + }, + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// AnthropicRuntimeClient +// ═══════════════════════════════════════════════════════════════════════════ + +// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct +// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI, +// OpenAI, or DashScope at construction time based on +// `detect_provider_kind(&model)`. The struct name is kept to avoid +// churning `BuiltRuntime` and every Deref/DerefMut site that references +// it. See ROADMAP #29 for the provider-dispatch routing fix. +pub(crate) struct AnthropicRuntimeClient { + pub(crate) runtime: tokio::runtime::Runtime, + pub(crate) client: ApiProviderClient, + pub(crate) session_id: String, + pub(crate) model: String, + pub(crate) enable_tools: bool, + pub(crate) emit_output: bool, + pub(crate) allowed_tools: Option, + pub(crate) tool_registry: GlobalToolRegistry, + pub(crate) progress_reporter: Option, + pub(crate) reasoning_effort: Option, +} + +impl AnthropicRuntimeClient { + pub(crate) fn new( + session_id: &str, + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + progress_reporter: Option, + ) -> Result> { + let resolved_model = api::resolve_model_alias(&model); + let client = match detect_provider_kind(&resolved_model) { + ProviderKind::Anthropic => { + let auth = resolve_cli_auth_source()?; + let inner = AnthropicClient::from_auth(auth) + .with_base_url(api::read_base_url()) + .with_prompt_cache(PromptCache::new(session_id)); + ApiProviderClient::Anthropic(inner) + } + ProviderKind::Xai | ProviderKind::OpenAi => { + ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + } + }; + Ok(Self { + runtime: tokio::runtime::Runtime::new()?, + client, + session_id: session_id.to_string(), + model, + enable_tools, + emit_output, + allowed_tools, + tool_registry, + progress_reporter, + reasoning_effort: None, + }) + } + + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + self.reasoning_effort = effort; + } +} + +pub(crate) fn resolve_cli_auth_source() -> Result> { + Ok(resolve_cli_auth_source_for_cwd()?) +} + +pub(crate) fn resolve_cli_auth_source_for_cwd() -> Result { + resolve_startup_auth_source(|| Ok(None)) +} + +impl ApiClient for AnthropicRuntimeClient { + #[allow(clippy::too_many_lines)] + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_model_phase(); + } + let is_post_tool = request_ends_with_tool_result(&request); + let message_request = MessageRequest { + model: self.model.clone(), + max_tokens: max_tokens_for_model(&self.model), + messages: convert_messages(&request.messages), + system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), + tools: self + .enable_tools + .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())), + tool_choice: self.enable_tools.then_some(ToolChoice::Auto), + stream: true, + reasoning_effort: self.reasoning_effort.clone(), + ..Default::default() + }; + + self.runtime.block_on(async { + let max_attempts: usize = if is_post_tool { 2 } else { 1 }; + + for attempt in 1..=max_attempts { + let result = self + .consume_stream(&message_request, is_post_tool && attempt == 1) + .await; + match result { + Ok(events) => return Ok(events), + Err(error) + if error.to_string().contains("post-tool stall") + && attempt < max_attempts => + { + // Stalled after tool completion — nudge the model by + // re-sending the same request. + } + Err(error) => return Err(error), + } + } + + Err(RuntimeError::new("post-tool continuation nudge exhausted")) + }) + } +} + +impl AnthropicRuntimeClient { + /// Consume a single streaming response, optionally applying a stall + /// timeout on the first event for post-tool continuations. + #[allow(clippy::too_many_lines)] + async fn consume_stream( + &self, + message_request: &MessageRequest, + apply_stall_timeout: bool, + ) -> Result, RuntimeError> { + let mut stream = self + .client + .stream_message(message_request) + .await + .map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?; + let mut stdout = io::stdout(); + let mut sink = io::sink(); + let out: &mut dyn Write = if self.emit_output { + &mut stdout + } else { + &mut sink + }; + let renderer = TerminalRenderer::new(); + let mut markdown_stream = MarkdownStreamState::default(); + let mut events = Vec::new(); + let mut pending_tool: Option<(String, String, String)> = None; + let mut block_has_thinking_summary = false; + let mut saw_stop = false; + let mut received_any_event = false; + + // Status bar state + let mut cumulative_input_tokens: u64 = 0; + let mut cumulative_output_tokens: u64 = 0; + let turn_start = std::time::Instant::now(); + let terminal_size = TerminalSize::new(); + let mut tool_timeline = ToolCallTimeline::new(); + + loop { + let next = if apply_stall_timeout && !received_any_event { + match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await { + Ok(inner) => inner.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?, + Err(_elapsed) => { + return Err(RuntimeError::new( + "post-tool stall: model did not respond within timeout", + )); + } + } + } else { + stream.next_event().await.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })? + }; + + let Some(event) = next else { + break; + }; + received_any_event = true; + + match event { + ApiStreamEvent::MessageStart(start) => { + for block in start.message.content { + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + } + ApiStreamEvent::ContentBlockStart(start) => { + push_output_block( + start.content_block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { + ContentBlockDelta::TextDelta { text } => { + if !text.is_empty() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_text_phase(&text); + } + if let Some(rendered) = markdown_stream.push(&renderer, &text) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + events.push(AssistantEvent::TextDelta(text)); + } + } + ContentBlockDelta::InputJsonDelta { partial_json } => { + if let Some((_, _, input)) = &mut pending_tool { + input.push_str(&partial_json); + } + } + ContentBlockDelta::ThinkingDelta { .. } => { + if !block_has_thinking_summary { + render_thinking_block_summary(out, None, false)?; + block_has_thinking_summary = true; + } + } + ContentBlockDelta::SignatureDelta { .. } => {} + }, + ApiStreamEvent::ContentBlockStop(_) => { + block_has_thinking_summary = false; + if let Some(rendered) = markdown_stream.flush(&renderer) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + if let Some((id, name, input)) = pending_tool.take() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_tool_phase(&name, &input); + } + tool_timeline.start_tool(&name); + writeln!(out, "\n{}", format_tool_call_start(&name, &input)) + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + ApiStreamEvent::MessageDelta(delta) => { + let usage = delta.usage.token_usage(); + cumulative_input_tokens += usage.input_tokens as u64; + cumulative_output_tokens += usage.output_tokens as u64; + events.push(AssistantEvent::Usage(usage)); + + // Update status bar + let cost_str = pricing_for_model(&self.model) + .map(|p| { + let estimate = usage.estimate_cost_usd_with_pricing(p); + format!("${:.4}", estimate.total_cost_usd()) + }) + .unwrap_or_else(|| "$—".to_string()); + let status_state = StatusBarState { + model: self.model.clone(), + permission_mode: "active".to_string(), + message_count: 0, + cumulative_input_tokens, + cumulative_output_tokens, + estimated_cost_usd: cost_str, + turn_start, + git_branch: None, + terminal_width: terminal_size.width(), + }; + let _ = StatusBar::render(&status_state, out); + } + ApiStreamEvent::MessageStop(_) => { + saw_stop = true; + if let Some(rendered) = markdown_stream.flush(&renderer) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + events.push(AssistantEvent::MessageStop); + } + } + } + + push_prompt_cache_record(&self.client, &mut events); + + if !saw_stop + && events.iter().any(|event| { + matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty()) + || matches!(event, AssistantEvent::ToolUse { .. }) + }) + { + events.push(AssistantEvent::MessageStop); + } + + if events + .iter() + .any(|event| matches!(event, AssistantEvent::MessageStop)) + { + // Render tool timeline if any tools were called + if !tool_timeline.events().is_empty() { + let timeline_render = tool_timeline.render(); + write!(out, "{timeline_render}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + return Ok(events); + } + + let response = self + .client + .send_message(&MessageRequest { + stream: false, + ..message_request.clone() + }) + .await + .map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?; + let mut events = response_to_events(response, out)?; + push_prompt_cache_record(&self.client, &mut events); + Ok(events) + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// CliToolExecutor +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) struct CliToolExecutor { + renderer: TerminalRenderer, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + mcp_state: Option>>, +} + +impl CliToolExecutor { + pub(crate) fn new( + allowed_tools: Option, + emit_output: bool, + tool_registry: GlobalToolRegistry, + mcp_state: Option>>, + ) -> Self { + Self { + renderer: TerminalRenderer::new(), + emit_output, + allowed_tools, + tool_registry, + mcp_state, + } + } + + fn execute_search_tool(&self, value: serde_json::Value) -> Result { + let input: ToolSearchRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + let (pending_mcp_servers, mcp_degraded) = + self.mcp_state.as_ref().map_or((None, None), |state| { + let state = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (state.pending_servers(), state.degraded_report()) + }); + serde_json::to_string_pretty(&self.tool_registry.search( + &input.query, + input.max_results.unwrap_or(5), + pending_mcp_servers, + mcp_degraded, + )) + .map_err(|error| ToolError::new(error.to_string())) + } + + fn execute_runtime_tool( + &self, + tool_name: &str, + value: serde_json::Value, + ) -> Result { + let Some(mcp_state) = &self.mcp_state else { + return Err(ToolError::new(format!( + "runtime tool `{tool_name}` is unavailable without configured MCP servers" + ))); + }; + let mut mcp_state = mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + match tool_name { + "MCPTool" => { + let input: McpToolRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + let qualified_name = input + .qualified_name + .or(input.tool) + .ok_or_else(|| ToolError::new("missing required field `qualifiedName`"))?; + mcp_state.call_tool(&qualified_name, input.arguments) + } + "ListMcpResourcesTool" => { + let input: ListMcpResourcesRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + match input.server { + Some(server_name) => mcp_state.list_resources_for_server(&server_name), + None => mcp_state.list_resources_for_all_servers(), + } + } + "ReadMcpResourceTool" => { + let input: ReadMcpResourceRequest = serde_json::from_value(value) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + mcp_state.read_resource(&input.server, &input.uri) + } + _ => mcp_state.call_tool(tool_name, Some(value)), + } + } +} + +impl ToolExecutor for CliToolExecutor { + fn execute(&mut self, tool_name: &str, input: &str) -> Result { + if self + .allowed_tools + .as_ref() + .is_some_and(|allowed| !allowed.contains(tool_name)) + { + return Err(ToolError::new(format!( + "tool `{tool_name}` is not enabled by the current --allowedTools setting" + ))); + } + let value = serde_json::from_str(input) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + let result = if tool_name == "ToolSearch" { + self.execute_search_tool(value) + } else if self.tool_registry.has_runtime_tool(tool_name) { + self.execute_runtime_tool(tool_name, value) + } else { + self.tool_registry + .execute(tool_name, &value) + .map_err(ToolError::new) + }; + match result { + Ok(output) => { + if self.emit_output { + let markdown = format_tool_result(tool_name, &output, false); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|error| ToolError::new(error.to_string()))?; + } + Ok(output) + } + Err(error) => { + if self.emit_output { + let markdown = format_tool_result(tool_name, &error.to_string(), true); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; + } + Err(error) + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// permission_policy / convert_messages +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) fn permission_policy( + mode: PermissionMode, + feature_config: &runtime::RuntimeFeatureConfig, + tool_registry: &GlobalToolRegistry, +) -> Result { + Ok(tool_registry.permission_specs(None)?.into_iter().fold( + PermissionPolicy::new(mode).with_permission_rules(feature_config.permission_rules()), + |policy, (name, required_permission)| { + policy.with_tool_requirement(name, required_permission) + }, + )) +} + +pub(crate) fn convert_messages(messages: &[ConversationMessage]) -> Vec { + messages + .iter() + .filter_map(|message| { + let role = match message.role { + MessageRole::System | MessageRole::User | MessageRole::Tool => "user", + MessageRole::Assistant => "assistant", + }; + let content = message + .blocks + .iter() + .map(|block| match block { + ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, + ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { + id: id.clone(), + name: name.clone(), + input: serde_json::from_str(input) + .unwrap_or_else(|_| serde_json::json!({ "raw": input })), + }, + ContentBlock::ToolResult { + tool_use_id, + output, + is_error, + .. + } => InputContentBlock::ToolResult { + tool_use_id: tool_use_id.clone(), + content: vec![ToolResultContentBlock::Text { + text: output.clone(), + }], + is_error: *is_error, + }, + }) + .collect::>(); + (!content.is_empty()).then(|| InputMessage { + role: role.to_string(), + content, + }) + }) + .collect() +} + +// ═══════════════════════════════════════════════════════════════════════════ +// run_repl / run_stale_base_preflight +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) fn run_stale_base_preflight(flag_value: Option<&str>) { + let Ok(cwd) = env::current_dir() else { + return; + }; + let source = resolve_expected_base(flag_value, &cwd); + let state = check_base_commit(&cwd, source.as_ref()); + if let Some(warning) = format_stale_base_warning(&state) { + eprintln!("{warning}"); + } +} + +#[allow(clippy::needless_pass_by_value)] +pub(crate) fn run_repl( + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, +) -> Result<(), Box> { + enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; + run_stale_base_preflight(base_commit.as_deref()); + let resolved_model = resolve_repl_model(model); + let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode, None)?; + cli.set_reasoning_effort(reasoning_effort); + let mut editor = + input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); + println!("{}", cli.startup_banner()); + println!("{}", format_connected_line(&cli.model)); + + loop { + editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); + match editor.read_line()? { + input::ReadOutcome::Submit(input) => { + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if matches!(trimmed.as_str(), "/exit" | "/quit") { + cli.persist_session()?; + break; + } + match SlashCommand::parse(&trimmed) { + Ok(Some(command)) => { + if cli.handle_repl_command(command)? { + cli.persist_session()?; + } + continue; + } + Ok(None) => {} + Err(error) => { + eprintln!("{error}"); + continue; + } + } + // Bare-word skill dispatch: if the first token of the input + // matches a known skill name, invoke it as `/skills ` + // rather than forwarding raw text to the LLM (ROADMAP #36). + let cwd = std::env::current_dir().unwrap_or_default(); + if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) { + editor.push_history(input); + cli.record_prompt_history(&trimmed); + cli.run_turn(&prompt)?; + continue; + } + editor.push_history(input); + cli.record_prompt_history(&trimmed); + cli.run_turn(&trimmed)?; + } + input::ReadOutcome::Cancel => {} + input::ReadOutcome::Exit => { + cli.persist_session()?; + break; + } + } + } + + Ok(()) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// build_system_prompt +// ═══════════════════════════════════════════════════════════════════════════ + +fn build_system_prompt() -> Result, Box> { + Ok(load_system_prompt( + env::current_dir()?, + DEFAULT_DATE, + env::consts::OS, + "unknown", + )?) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Internal prompt progress types +// ═══════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InternalPromptProgressState { + pub(crate) command_label: &'static str, + pub(crate) task_label: String, + pub(crate) step: usize, + pub(crate) phase: String, + pub(crate) detail: Option, + pub(crate) saw_final_text: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InternalPromptProgressEvent { + Started, + Update, + Heartbeat, + Complete, + Failed, +} + +#[derive(Debug)] +struct InternalPromptProgressShared { + state: Mutex, + output_lock: Mutex<()>, + started_at: Instant, +} + +#[derive(Debug, Clone)] +pub(crate) struct InternalPromptProgressReporter { + shared: Arc, +} + +#[derive(Debug)] +pub(crate) struct InternalPromptProgressRun { + reporter: InternalPromptProgressReporter, + heartbeat_stop: Option>, + heartbeat_handle: Option>, +} + +impl InternalPromptProgressReporter { + pub(crate) fn ultraplan(task: &str) -> Self { + Self { + shared: Arc::new(InternalPromptProgressShared { + state: Mutex::new(InternalPromptProgressState { + command_label: "Ultraplan", + task_label: task.to_string(), + step: 0, + phase: "planning started".to_string(), + detail: Some(format!("task: {task}")), + saw_final_text: false, + }), + output_lock: Mutex::new(()), + started_at: Instant::now(), + }), + } + } + + fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { + let snapshot = self.snapshot(); + let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); + self.write_line(&line); + } + + pub(crate) fn mark_model_phase(&self) { + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = if state.step == 1 { + "analyzing request".to_string() + } else { + "reviewing findings".to_string() + }; + state.detail = Some(format!("task: {}", state.task_label)); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + pub(crate) fn mark_tool_phase(&self, name: &str, input: &str) { + let detail = describe_tool_progress(name, input); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = format!("running {name}"); + state.detail = Some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + pub(crate) fn mark_text_phase(&self, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let detail = truncate_for_summary(first_visible_line(trimmed), 120); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + if state.saw_final_text { + return; + } + state.saw_final_text = true; + state.step += 1; + state.phase = "drafting final plan".to_string(); + state.detail = (!detail.is_empty()).then_some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + fn emit_heartbeat(&self) { + let snapshot = self.snapshot(); + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Heartbeat, + &snapshot, + self.elapsed(), + None, + )); + } + + fn snapshot(&self) -> InternalPromptProgressState { + self.shared + .state + .lock() + .expect("internal prompt progress state poisoned") + .clone() + } + + fn elapsed(&self) -> Duration { + self.shared.started_at.elapsed() + } + + fn write_line(&self, line: &str) { + let _guard = self + .shared + .output_lock + .lock() + .expect("internal prompt progress output lock poisoned"); + let mut stdout = io::stdout(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); + } +} + +impl InternalPromptProgressRun { + pub(crate) fn start_ultraplan(task: &str) -> Self { + let reporter = InternalPromptProgressReporter::ultraplan(task); + reporter.emit(InternalPromptProgressEvent::Started, None); + + let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); + let heartbeat_reporter = reporter.clone(); + let heartbeat_handle = thread::spawn(move || loop { + match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), + } + }); + + Self { + reporter, + heartbeat_stop: Some(heartbeat_stop), + heartbeat_handle: Some(heartbeat_handle), + } + } + + pub(crate) fn reporter(&self) -> InternalPromptProgressReporter { + self.reporter.clone() + } + + pub(crate) fn finish_success(&mut self) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Complete, None); + } + + pub(crate) fn finish_failure(&mut self, error: &str) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Failed, Some(error)); + } + + fn stop_heartbeat(&mut self) { + if let Some(sender) = self.heartbeat_stop.take() { + let _ = sender.send(()); + } + if let Some(handle) = self.heartbeat_handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for InternalPromptProgressRun { + fn drop(&mut self) { + self.stop_heartbeat(); + } +} + +pub(crate) fn format_internal_prompt_progress_line( + event: InternalPromptProgressEvent, + snapshot: &InternalPromptProgressState, + elapsed: Duration, + error: Option<&str>, +) -> String { + let elapsed_seconds = elapsed.as_secs(); + let step_label = if snapshot.step == 0 { + "current step pending".to_string() + } else { + format!("current step {}", snapshot.step) + }; + let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)]; + if let Some(detail) = snapshot + .detail + .as_deref() + .filter(|detail| !detail.is_empty()) + { + status_bits.push(detail.to_string()); + } + let status = status_bits.join(" \u{00b7} "); + match event { + InternalPromptProgressEvent::Started => { + format!( + "\u{1f9ed} {} status \u{00b7} planning started \u{00b7} {status}", + snapshot.command_label + ) + } + InternalPromptProgressEvent::Update => { + format!("\u{2026} {} status \u{00b7} {status}", snapshot.command_label) + } + InternalPromptProgressEvent::Heartbeat => format!( + "\u{2026} {} heartbeat \u{00b7} {elapsed_seconds}s elapsed \u{00b7} {status}", + snapshot.command_label + ), + InternalPromptProgressEvent::Complete => format!( + "\u{2714} {} status \u{00b7} completed \u{00b7} {elapsed_seconds}s elapsed \u{00b7} {} steps total", + snapshot.command_label, snapshot.step + ), + InternalPromptProgressEvent::Failed => format!( + "\u{2718} {} status \u{00b7} failed \u{00b7} {elapsed_seconds}s elapsed \u{00b7} {}", + snapshot.command_label, + error.unwrap_or("unknown error") + ), + } +} + +pub(crate) fn describe_tool_progress(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + match name { + "bash" | "Bash" => { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + "running shell command".to_string() + } else { + format!("command {}", truncate_for_summary(command.trim(), 100)) + } + } + "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), + "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), + "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)), + "glob_search" | "Glob" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("glob `{pattern}` in {scope}") + } + "grep_search" | "Grep" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("grep `{pattern}` in {scope}") + } + "web_search" | "WebSearch" => parsed + .get("query") + .and_then(|value| value.as_str()) + .map_or_else( + || "running web search".to_string(), + |query| format!("query {}", truncate_for_summary(query, 100)), + ), + _ => { + let summary = summarize_tool_payload(input); + if summary.is_empty() { + format!("running {name}") + } else { + format!("{name}: {summary}") + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Streaming / response helpers +// ═══════════════════════════════════════════════════════════════════════════ + +pub(crate) fn render_thinking_block_summary( + out: &mut (impl Write + ?Sized), + char_count: Option, + redacted: bool, +) -> Result<(), RuntimeError> { + let summary = crate::tui::thinking::render_thinking_inline(char_count, redacted); + write!(out, "{summary}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string())) +} + +pub(crate) fn push_output_block( + block: OutputContentBlock, + out: &mut (impl Write + ?Sized), + events: &mut Vec, + pending_tool: &mut Option<(String, String, String)>, + streaming_tool_input: bool, + block_has_thinking_summary: &mut bool, +) -> Result<(), RuntimeError> { + match block { + OutputContentBlock::Text { text } => { + if !text.is_empty() { + let rendered = TerminalRenderer::new().markdown_to_ansi(&text); + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + events.push(AssistantEvent::TextDelta(text)); + } + } + OutputContentBlock::ToolUse { id, name, input } => { + let initial_input = if streaming_tool_input + && input.is_object() + && input.as_object().is_some_and(serde_json::Map::is_empty) + { + String::new() + } else { + input.to_string() + }; + *pending_tool = Some((id, name, initial_input)); + } + OutputContentBlock::Thinking { thinking, .. } => { + render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; + *block_has_thinking_summary = true; + } + OutputContentBlock::RedactedThinking { .. } => { + render_thinking_block_summary(out, None, true)?; + *block_has_thinking_summary = true; + } + } + Ok(()) +} + +pub(crate) fn response_to_events( + response: MessageResponse, + out: &mut (impl Write + ?Sized), +) -> Result, RuntimeError> { + let mut events = Vec::new(); + let mut pending_tool = None; + + for block in response.content { + let mut block_has_thinking_summary = false; + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + false, + &mut block_has_thinking_summary, + )?; + if let Some((id, name, input)) = pending_tool.take() { + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + + events.push(AssistantEvent::Usage(response.usage.token_usage())); + events.push(AssistantEvent::MessageStop); + Ok(events) +} + +pub(crate) fn push_prompt_cache_record( + client: &ApiProviderClient, + events: &mut Vec, +) { + if let Some(record) = client.take_last_prompt_cache_record() { + if let Some(event) = prompt_cache_record_to_runtime_event(record) { + events.push(AssistantEvent::PromptCache(event)); + } + } +} + +pub(crate) fn prompt_cache_record_to_runtime_event( + record: api::PromptCacheRecord, +) -> Option { + let cache_break = record.cache_break?; + Some(PromptCacheEvent { + unexpected: cache_break.unexpected, + reason: cache_break.reason, + previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens, + current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens, + token_drop: cache_break.token_drop, + }) +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Turn summary helpers +// ═══════════════════════════════════════════════════════════════════════════ + +/// Returns `true` when the conversation ends with a tool-result message, +/// meaning the model is expected to continue after tool execution. +pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool { + request + .messages + .last() + .is_some_and(|message| message.role == MessageRole::Tool) +} + +pub(crate) fn final_assistant_text(summary: &runtime::TurnSummary) -> String { + summary + .assistant_messages + .last() + .map(|message| { + message + .blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + }) + .unwrap_or_default() +} + +pub(crate) fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec { + summary + .assistant_messages + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => Some(json!({ + "id": id, + "name": name, + "input": input, + })), + _ => None, + }) + .collect() +} + +pub(crate) fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec { + summary + .tool_results + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => Some(json!({ + "tool_use_id": tool_use_id, + "tool_name": tool_name, + "output": output, + "is_error": is_error, + })), + _ => None, + }) + .collect() +} + +pub(crate) fn collect_prompt_cache_events( + summary: &runtime::TurnSummary, +) -> Vec { + summary + .prompt_cache_events + .iter() + .map(|event| { + json!({ + "unexpected": event.unexpected, + "reason": event.reason, + "previous_cache_read_input_tokens": event.previous_cache_read_input_tokens, + "current_cache_read_input_tokens": event.current_cache_read_input_tokens, + "token_drop": event.token_drop, + }) + }) + .collect() +} 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 0000000000..43d8218437 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/args.rs @@ -0,0 +1,1106 @@ +use std::collections::BTreeSet; +use std::env; +use std::io::{self, IsTerminal, Write}; +use std::path::PathBuf; + +use runtime::PermissionMode; + +use crate::format::{ + default_permission_mode, format_unknown_direct_slash_command, format_unknown_option, + looks_like_subcommand_typo, parse_permission_mode_arg, render_suggestion_line, + resolve_model_alias_with_config, suggest_similar_subcommand, validate_model_syntax, + DEFAULT_MODEL, +}; + +use commands::{ + classify_skills_slash_command, resolve_skill_invocation, slash_command_specs, + SkillSlashDispatch, SlashCommand, +}; + +type AllowedToolSet = BTreeSet; + +const LATEST_SESSION_REFERENCE: &str = "latest"; +const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"]; +const CLI_OPTION_SUGGESTIONS: &[&str] = &[ + "--help", + "-h", + "--version", + "-V", + "--model", + "--output-format", + "--permission-mode", + "--dangerously-skip-permissions", + "--allowedTools", + "--allowed-tools", + "--resume", + "--acp", + "-acp", + "--print", + "--compact", + "--base-commit", + "-p", +]; + +// Build-time constants injected by build.rs (fall back to static values when +// build.rs hasn't run, e.g. in doc-test or unusual toolchain environments). +const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") { + Some(d) => d, + None => "unknown", +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CliAction { + DumpManifests { + output_format: CliOutputFormat, + manifests_dir: Option, + }, + BootstrapPlan { + output_format: CliOutputFormat, + }, + Agents { + args: Option, + output_format: CliOutputFormat, + }, + Mcp { + args: Option, + output_format: CliOutputFormat, + }, + Skills { + args: Option, + output_format: CliOutputFormat, + }, + Plugins { + action: Option, + target: Option, + output_format: CliOutputFormat, + }, + PrintSystemPrompt { + cwd: PathBuf, + date: String, + 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, + }, + 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(crate::format::LocalHelpTopic), + // prompt-mode formatting is only supported for non-interactive runs + Help { + output_format: CliOutputFormat, + }, + /// Run JSON-RPC server over stdin/stdout for agent integration. + Rpc, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CliOutputFormat { + Text, + Json, +} + +impl CliOutputFormat { + pub(crate) 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; + } + "--mode" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing value for --mode".to_string())?; + if value == "rpc" { + return Ok(CliAction::Rpc); + } else { + return Err(format!("unknown mode: {value} (supported: rpc)")); + } + } + "--mode=rpc" => { + return Ok(CliAction::Rpc); + } + "--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: crate::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 = crate::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, + ) { + 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" => crate::format::LocalHelpTopic::Status, + "sandbox" => crate::format::LocalHelpTopic::Sandbox, + "doctor" => crate::format::LocalHelpTopic::Doctor, + "acp" => crate::format::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" => crate::format::LocalHelpTopic::Init, + "state" => crate::format::LocalHelpTopic::State, + "export" => crate::format::LocalHelpTopic::Export, + "version" => crate::format::LocalHelpTopic::Version, + "system-prompt" => crate::format::LocalHelpTopic::SystemPrompt, + "dump-manifests" => crate::format::LocalHelpTopic::DumpManifests, + "bootstrap-plan" => crate::format::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, +) -> 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, + })), + "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: &std::path::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 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, + }) +} + +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)) +} + +/// Detect if the current working directory is "broad" (home directory or +/// filesystem root). Returns the cwd path if broad, None otherwise. +pub(crate) fn detect_broad_cwd() -> Option { + let Ok(cwd) = env::current_dir() else { + return None; + }; + let is_home = env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .is_some_and(|h| std::path::Path::new(&h) == cwd); + let is_root = cwd.parent().is_none(); + if is_home || is_root { + Some(cwd) + } else { + None + } +} + +/// Enforce the broad-CWD policy: when running from home or root, either +/// require the --allow-broad-cwd flag, or prompt for confirmation (interactive), +/// or exit with an error (non-interactive). +pub(crate) fn enforce_broad_cwd_policy( + allow_broad_cwd: bool, + output_format: CliOutputFormat, +) -> Result<(), Box> { + if allow_broad_cwd { + return Ok(()); + } + let Some(cwd) = detect_broad_cwd() else { + return Ok(()); + }; + + let is_interactive = io::stdin().is_terminal(); + + if is_interactive { + // Interactive mode: print warning and ask for confirmation + eprintln!( + "Warning: claw is running from a very broad directory ({}).\n\ + The agent can read and search everything under this path.\n\ + Consider running from inside your project: cd /path/to/project && claw", + cwd.display() + ); + eprint!("Continue anyway? [y/N]: "); + io::stderr().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let trimmed = input.trim().to_lowercase(); + if trimmed != "y" && trimmed != "yes" { + eprintln!("Aborted."); + std::process::exit(0); + } + Ok(()) + } else { + // Non-interactive mode: exit with error (JSON or text) + let message = format!( + "claw is running from a very broad directory ({}). \ + The agent can read and search everything under this path. \ + Use --allow-broad-cwd to proceed anyway, \ + or run from inside your project: cd /path/to/project && claw", + cwd.display() + ); + match output_format { + CliOutputFormat::Json => { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": message, + }) + ); + } + CliOutputFormat::Text => { + eprintln!("error: {message}"); + } + } + std::process::exit(1); + } +} + +// NOTE: normalize_allowed_tools is defined in main.rs because it depends on +// build_runtime_plugin_state_with_loader (Phase 0.4 extraction). It cannot be +// extracted until app.rs exists. Args functions call it via crate::normalize_allowed_tools. diff --git a/rust/crates/rusty-claude-cli/src/cli_commands.rs b/rust/crates/rusty-claude-cli/src/cli_commands.rs new file mode 100644 index 0000000000..35646a0472 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/cli_commands.rs @@ -0,0 +1,2406 @@ +//! Subcommand runner functions extracted from main.rs. +//! +//! These are the standalone functions that implement individual CLI subcommands +//! (`claw init`, `claw doctor`, `claw config`, etc.). They were previously +//! defined inline in `main.rs` and are now available from this module. + +use std::env; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::app::*; +use crate::args::*; +use crate::format::*; +use crate::tui::diff_view::format_colored_diff; +use crate::{ + BUILD_TARGET, DEFAULT_DATE, DEPRECATED_INSTALL_COMMAND, GIT_SHA, OFFICIAL_REPO_SLUG, + OFFICIAL_REPO_URL, VERSION, +}; +use api::detect_provider_kind; +use commands::{ + classify_skills_slash_command, handle_agents_slash_command, handle_mcp_slash_command, + handle_mcp_slash_command_json, handle_skills_slash_command, handle_skills_slash_command_json, + SkillSlashDispatch, SlashCommand, +}; +use compat_harness::{extract_manifest, UpstreamPaths}; +use runtime::{ + check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials, + load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status, + ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, + ContentBlock, ConversationMessage, ConversationRuntime, McpServer, McpServerManager, + McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, PermissionPolicy, + ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, Session, TokenUsage, + ToolError, ToolExecutor, UsageTracker, +}; +use serde_json::{json, Map, Value}; +use tools::{execute_tool, mvp_tool_specs, GlobalToolRegistry}; + +// --------------------------------------------------------------------------- +// Constants referenced by functions below — imported from crate root (main.rs) +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Structs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DiagnosticLevel { + Ok, + Warn, + Fail, +} + +impl DiagnosticLevel { + fn label(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Warn => "warn", + Self::Fail => "fail", + } + } + + fn is_failure(self) -> bool { + matches!(self, Self::Fail) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DiagnosticCheck { + name: &'static str, + level: DiagnosticLevel, + summary: String, + details: Vec, + data: Map, +} + +impl DiagnosticCheck { + fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into) -> Self { + Self { + name, + level, + summary: summary.into(), + details: Vec::new(), + data: Map::new(), + } + } + + fn with_details(mut self, details: Vec) -> Self { + self.details = details; + self + } + + fn with_data(mut self, data: Map) -> Self { + self.data = data; + self + } + + pub(crate) fn json_value(&self) -> Value { + let mut value = Map::from_iter([ + ( + "name".to_string(), + Value::String(self.name.to_ascii_lowercase()), + ), + ( + "status".to_string(), + Value::String(self.level.label().to_string()), + ), + ("summary".to_string(), Value::String(self.summary.clone())), + ( + "details".to_string(), + Value::Array( + self.details + .iter() + .cloned() + .map(Value::String) + .collect::>(), + ), + ), + ]); + value.extend(self.data.clone()); + Value::Object(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DoctorReport { + checks: Vec, +} + +impl DoctorReport { + fn counts(&self) -> (usize, usize, usize) { + ( + self.checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Ok) + .count(), + self.checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Warn) + .count(), + self.checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Fail) + .count(), + ) + } + + fn has_failures(&self) -> bool { + self.checks.iter().any(|check| check.level.is_failure()) + } + + pub(crate) fn render(&self) -> String { + let (ok_count, warn_count, fail_count) = self.counts(); + let mut lines = vec![ + "Doctor".to_string(), + format!( + "Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}" + ), + ]; + lines.extend(self.checks.iter().map(render_diagnostic_check)); + lines.join("\n\n") + } + + pub(crate) fn json_value(&self) -> Value { + let report = self.render(); + let (ok_count, warn_count, fail_count) = self.counts(); + json!({ + "kind": "doctor", + "message": report, + "report": report, + "has_failures": self.has_failures(), + "summary": { + "total": self.checks.len(), + "ok": ok_count, + "warnings": warn_count, + "failures": fail_count, + }, + "checks": self + .checks + .iter() + .map(DiagnosticCheck::json_value) + .collect::>(), + }) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ResumeCommandOutcome { + pub(crate) session: Session, + pub(crate) message: Option, + pub(crate) json: Option, +} + +// --------------------------------------------------------------------------- +// Functions +// --------------------------------------------------------------------------- + +pub(crate) fn summarize_tool_payload_for_markdown(payload: &str) -> String { + let compact = match serde_json::from_str::(payload) { + Ok(value) => value.to_string(), + Err(_) => payload.trim().to_string(), + }; + crate::format::tool_fmt::truncate_for_summary(&compact, 96) +} + +pub(crate) fn render_diagnostic_check(check: &DiagnosticCheck) -> String { + let mut lines = vec![format!( + "{}\n Status {}\n Summary {}", + check.name, + check.level.label(), + check.summary + )]; + if !check.details.is_empty() { + lines.push(" Details".to_string()); + lines.extend(check.details.iter().map(|detail| format!(" - {detail}"))); + } + lines.join("\n") +} + +pub(crate) fn render_doctor_report() -> Result> { + let cwd = env::current_dir()?; + let config_loader = ConfigLoader::default_for(&cwd); + let config = config_loader.load(); + let discovered_config = config_loader.discover(); + let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; + let (project_root, git_branch) = + parse_git_status_metadata(project_context.git_status.as_deref()); + let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); + let empty_config = runtime::RuntimeConfig::empty(); + let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config); + let context = StatusContext { + cwd: cwd.clone(), + session_path: None, + loaded_config_files: config + .as_ref() + .ok() + .map_or(0, |runtime_config| runtime_config.loaded_entries().len()), + discovered_config_files: discovered_config.len(), + memory_file_count: project_context.instruction_files.len(), + project_root, + git_branch, + git_summary, + sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd), + config_load_error: config.as_ref().err().map(ToString::to_string), + }; + Ok(DoctorReport { + checks: vec![ + check_auth_health(), + check_config_health(&config_loader, config.as_ref()), + check_install_source_health(), + check_workspace_health(&context), + check_sandbox_health(&context.sandbox_status), + check_system_health(&cwd, config.as_ref().ok()), + ], + }) +} + +pub(crate) fn render_config_report( + section: Option<&str>, +) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + let mut lines = vec![ + format!( + "Config\n Working directory {}\n Loaded files {}\n Merged keys {}", + cwd.display(), + runtime_config.loaded_entries().len(), + runtime_config.merged().len() + ), + "Discovered files".to_string(), + ]; + for entry in discovered { + let source = match entry.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let status = if runtime_config + .loaded_entries() + .iter() + .any(|loaded_entry| loaded_entry.path == entry.path) + { + "loaded" + } else { + "missing" + }; + lines.push(format!( + " {source:<7} {status:<7} {}", + entry.path.display() + )); + } + + if let Some(section) = section { + lines.push(format!("Merged section: {section}")); + let value = match section { + "env" => runtime_config.get("env"), + "hooks" => runtime_config.get("hooks"), + "model" => runtime_config.get("model"), + "plugins" => runtime_config + .get("plugins") + .or_else(|| runtime_config.get("enabledPlugins")), + other => { + lines.push(format!( + " Unsupported config section '{other}'. Use env, hooks, model, or plugins." + )); + return Ok(lines.join("\n\n")); + } + }; + lines.push(format!( + " {}", + match value { + Some(value) => value.render(), + None => "".to_string(), + } + )); + return Ok(lines.join("\n\n")); + } + + lines.push("Merged JSON".to_string()); + lines.push(format!(" {}", runtime_config.as_json().render())); + Ok(lines.join("\n\n")) +} + +pub(crate) fn render_config_json( + _section: Option<&str>, +) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + let loaded_paths: Vec<_> = runtime_config + .loaded_entries() + .iter() + .map(|e| e.path.display().to_string()) + .collect(); + + let files: Vec<_> = discovered + .iter() + .map(|e| { + let source = match e.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let is_loaded = runtime_config + .loaded_entries() + .iter() + .any(|le| le.path == e.path); + serde_json::json!({ + "path": e.path.display().to_string(), + "source": source, + "loaded": is_loaded, + }) + }) + .collect(); + + Ok(serde_json::json!({ + "kind": "config", + "cwd": cwd.display().to_string(), + "loaded_files": loaded_paths.len(), + "merged_keys": runtime_config.merged().len(), + "files": files, + })) +} + +pub(crate) fn render_memory_report() -> Result> { + let cwd = env::current_dir()?; + let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; + let mut lines = vec![format!( + "Memory\n Working directory {}\n 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 instruction files discovered in the current directory ancestry." + .to_string(), + ); + } else { + lines.push("Discovered files".to_string()); + for (index, file) in project_context.instruction_files.iter().enumerate() { + let preview = file.content.lines().next().unwrap_or("").trim(); + let preview = if preview.is_empty() { + "" + } else { + preview + }; + lines.push(format!(" {}. {}", index + 1, file.path.display())); + lines.push(format!( + " lines={} preview={}", + file.content.lines().count(), + preview + )); + } + } + Ok(lines.join("\n\n")) +} + +pub(crate) fn render_memory_json() -> Result> { + let cwd = env::current_dir()?; + let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; + let files: Vec<_> = project_context + .instruction_files + .iter() + .map(|f| { + json!({ + "path": f.path.display().to_string(), + "lines": f.content.lines().count(), + "preview": f.content.lines().next().unwrap_or("").trim(), + }) + }) + .collect(); + Ok(json!({ + "kind": "memory", + "cwd": cwd.display().to_string(), + "instruction_files": files.len(), + "files": files, + })) +} + +pub(crate) fn run_mcp_serve() -> Result<(), Box> { + let tools = mvp_tool_specs() + .into_iter() + .map(|spec| McpTool { + name: spec.name.to_string(), + description: Some(spec.description.to_string()), + input_schema: Some(spec.input_schema), + annotations: None, + meta: None, + }) + .collect(); + + let spec = runtime::McpServerSpec { + server_name: "claw".to_string(), + server_version: VERSION.to_string(), + tools, + tool_handler: Box::new(execute_tool), + }; + + let tokio_runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + tokio_runtime.block_on(async move { + let mut server = runtime::McpServer::new(spec); + server.run().await + })?; + Ok(()) +} + +pub(crate) fn render_diff_report() -> Result> { + render_diff_report_for(&env::current_dir()?) +} + +pub(crate) fn render_diff_report_for(cwd: &Path) -> Result> { + let in_git_repo = std::process::Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(cwd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !in_git_repo { + return Ok(format!( + "Diff\n Result no git repository\n Detail {} is not inside a git project", + cwd.display() + )); + } + let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; + let unstaged = run_git_diff_command_in(cwd, &["diff"])?; + if staged.trim().is_empty() && unstaged.trim().is_empty() { + return Ok( + "Diff\n Result clean working tree\n Detail no current changes" + .to_string(), + ); + } + + let mut sections = Vec::new(); + if !staged.trim().is_empty() { + let colored = format_colored_diff(staged.trim_end()); + sections.push(format!("Staged changes:\n{colored}")); + } + if !unstaged.trim().is_empty() { + let colored = format_colored_diff(unstaged.trim_end()); + sections.push(format!("Unstaged changes:\n{colored}")); + } + + Ok(format!("Diff\n\n{}", sections.join("\n\n"))) +} + +pub(crate) fn render_diff_json_for( + cwd: &Path, +) -> Result> { + let in_git_repo = std::process::Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(cwd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !in_git_repo { + return Ok(serde_json::json!({ + "kind": "diff", + "result": "no_git_repo", + "detail": format!("{} is not inside a git project", cwd.display()), + })); + } + let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; + let unstaged = run_git_diff_command_in(cwd, &["diff"])?; + Ok(serde_json::json!({ + "kind": "diff", + "result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" }, + "staged": staged.trim(), + "unstaged": unstaged.trim(), + })) +} + +pub(crate) fn resolve_export_path( + requested_path: Option<&str>, + session: &Session, +) -> Result> { + let cwd = env::current_dir()?; + let file_name = + requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned); + let final_name = if Path::new(&file_name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) + { + file_name + } else { + format!("{file_name}.txt") + }; + Ok(cwd.join(final_name)) +} + +pub(crate) fn render_export_text(session: &Session) -> String { + let mut lines = vec!["# Conversation Export".to_string(), String::new()]; + for (index, message) in session.messages.iter().enumerate() { + let role = match message.role { + MessageRole::System => "system", + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + MessageRole::Tool => "tool", + }; + lines.push(format!("## {}. {role}", index + 1)); + for block in &message.blocks { + match block { + ContentBlock::Text { text } => lines.push(text.clone()), + ContentBlock::ToolUse { id, name, input } => { + lines.push(format!("[tool_use id={id} name={name}] {input}")); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => { + lines.push(format!( + "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" + )); + } + } + } + lines.push(String::new()); + } + lines.join("\n") +} + +pub(crate) fn format_bughunter_report(scope: Option<&str>) -> String { + format!( + "Bughunter\n Scope {}\n Action inspect the selected code for likely bugs and correctness issues\n Output findings should include file paths, severity, and suggested fixes", + scope.unwrap_or("the current repository") + ) +} + +pub(crate) fn format_ultraplan_report(task: Option<&str>) -> String { + format!( + "Ultraplan\n Task {}\n Action break work into a multi-step execution plan\n Output plan should cover goals, risks, sequencing, verification, and rollback", + task.unwrap_or("the current repo work") + ) +} + +pub(crate) fn render_teleport_report(target: &str) -> Result> { + let cwd = env::current_dir()?; + + let file_list = Command::new("rg") + .args(["--files"]) + .current_dir(&cwd) + .output()?; + let file_matches = if file_list.status.success() { + String::from_utf8(file_list.stdout)? + .lines() + .filter(|line| line.contains(target)) + .take(10) + .map(ToOwned::to_owned) + .collect::>() + } else { + Vec::new() + }; + + let content_output = Command::new("rg") + .args(["-n", "-S", "--color", "never", target, "."]) + .current_dir(&cwd) + .output()?; + + let mut lines = vec![ + "Teleport".to_string(), + format!(" Target {target}"), + " Action search workspace files and content for the target".to_string(), + ]; + if !file_matches.is_empty() { + lines.push(String::new()); + lines.push("File matches".to_string()); + lines.extend(file_matches.into_iter().map(|path| format!(" {path}"))); + } + + if content_output.status.success() { + let matches = String::from_utf8(content_output.stdout)?; + if !matches.trim().is_empty() { + lines.push(String::new()); + lines.push("Content matches".to_string()); + lines.push(truncate_for_prompt(&matches, 4_000)); + } + } + + if lines.len() == 1 { + lines.push(" Result no matches found".to_string()); + } + + Ok(lines.join("\n")) +} + +pub(crate) fn validate_no_args( + command_name: &str, + args: Option<&str>, +) -> Result<(), Box> { + if let Some(args) = args.map(str::trim).filter(|value| !value.is_empty()) { + return Err(format!( + "{command_name} does not accept arguments. Received: {args}\nUsage: {command_name}" + ) + .into()); + } + Ok(()) +} + +pub(crate) fn render_last_tool_debug_report( + session: &Session, +) -> Result> { + let last_tool_use = session + .messages + .iter() + .rev() + .find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => { + Some((id.clone(), name.clone(), input.clone())) + } + _ => None, + }) + }) + .ok_or_else(|| "no prior tool call found in session".to_string())?; + + let tool_result = session.messages.iter().rev().find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } if tool_use_id == &last_tool_use.0 => { + Some((tool_name.clone(), output.clone(), *is_error)) + } + _ => None, + }) + }); + + let mut lines = vec![ + "Debug tool call".to_string(), + " Action inspect the last recorded tool call and its result".to_string(), + format!(" Tool id {}", last_tool_use.0), + format!(" Tool name {}", last_tool_use.1), + " Input".to_string(), + indent_block(&last_tool_use.2, 4), + ]; + + match tool_result { + Some((tool_name, output, is_error)) => { + lines.push(" Result".to_string()); + lines.push(format!(" name {tool_name}")); + lines.push(format!( + " status {}", + if is_error { "error" } else { "ok" } + )); + lines.push(indent_block(&output, 4)); + } + None => lines.push(" Result missing tool result".to_string()), + } + + Ok(lines.join("\n")) +} + +pub(crate) fn git_output(args: &[&str]) -> Result> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(String::from_utf8(output.stdout)?) +} + +pub(crate) fn format_pr_report(branch: &str, context: Option<&str>) -> String { + format!( + "PR\n Branch {branch}\n Context {}\n Action draft or create a pull request for the current branch\n Output title and markdown body suitable for GitHub", + context.unwrap_or("none") + ) +} + +pub(crate) fn format_issue_report(context: Option<&str>) -> String { + format!( + "Issue\n Context {}\n Action draft or create a GitHub issue from the current context\n Output title and markdown body suitable for GitHub", + context.unwrap_or("none") + ) +} + +pub(crate) fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box> { + let report = render_doctor_report()?; + let message = report.render(); + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&report.json_value())?); + } + } + if report.has_failures() { + return Err("doctor found failing checks".into()); + } + Ok(()) +} + +/// Read `.claw/worker-state.json` from the current working directory and print it. +pub(crate) fn run_worker_state( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let cwd = env::current_dir()?; + let state_path = cwd.join(".claw").join("worker-state.json"); + if !state_path.exists() { + return Err(format!( + "no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt # run one non-interactive turn\n Then rerun: claw state [--output-format json]", + path = state_path.display() + ) + .into()); + } + let raw = std::fs::read_to_string(&state_path)?; + match output_format { + CliOutputFormat::Text => println!("{raw}"), + CliOutputFormat::Json => { + let _: serde_json::Value = serde_json::from_str(&raw)?; + println!("{raw}"); + } + } + Ok(()) +} + +pub(crate) fn print_version( + output_format: CliOutputFormat, +) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => println!("{}", render_version_report()), + CliOutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&version_json_value())?); + } + } + Ok(()) +} + +pub(crate) fn print_system_prompt( + cwd: PathBuf, + date: String, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?; + let message = sections.join("\n\n"); + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "system-prompt", + "message": message, + "sections": sections, + }))? + ), + } + Ok(()) +} + +pub(crate) fn print_bootstrap_plan( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let phases = runtime::BootstrapPlan::claude_code_default() + .phases() + .iter() + .map(|phase| format!("{phase:?}")) + .collect::>(); + match output_format { + CliOutputFormat::Text => { + for phase in &phases { + println!("- {phase}"); + } + } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "bootstrap-plan", + "phases": phases, + }))? + ), + } + Ok(()) +} + +#[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 { + let full_message = format!("failed to restore session: {error}"); + let kind = classify_error_kind(&full_message); + let (short_reason, hint) = split_error_hint(&full_message); + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": short_reason, + "kind": kind, + "hint": hint, + }) + ); + } 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", + "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 { + { + let cmd_root = raw_command + .trim_start_matches('/') + .split_whitespace() + .next() + .unwrap_or(""); + if STUB_COMMANDS.contains(&cmd_root) { + if output_format == CliOutputFormat::Json { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": format!("/{cmd_root} is not yet implemented in this build"), + "kind": "unsupported_command", + "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 { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": format!("unsupported resumed command: {raw_command}"), + "kind": "unsupported_resumed_command", + "command": raw_command, + }) + ); + } else { + eprintln!("unsupported resumed command: {raw_command}"); + } + std::process::exit(2); + } + Err(error) => { + if output_format == CliOutputFormat::Json { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": error.to_string(), + "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 { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": error.to_string(), + "command": raw_command, + }) + ); + } else { + eprintln!("{error}"); + } + std::process::exit(2); + } + } + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn run_resume_command( + session_path: &Path, + session: &Session, + command: &SlashCommand, +) -> Result> { + match command { + SlashCommand::Help => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_repl_help()), + json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })), + }), + SlashCommand::Compact => { + let result = runtime::compact_session( + session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ); + let removed = result.removed_message_count; + let kept = result.compacted_session.messages.len(); + let skipped = removed == 0; + result.compacted_session.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: result.compacted_session, + message: Some(format_compact_report(removed, kept, skipped)), + json: Some(serde_json::json!({ + "kind": "compact", + "skipped": skipped, + "removed_messages": removed, + "kept_messages": kept, + })), + }) + } + SlashCommand::Clear { confirm } => { + if !confirm { + return Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some( + "clear: confirmation required; rerun with /clear --confirm".to_string(), + ), + json: Some(serde_json::json!({ + "kind": "error", + "error": "confirmation required", + "hint": "rerun with /clear --confirm", + })), + }); + } + let backup_path = write_session_clear_backup(session, session_path)?; + let previous_session_id = session.session_id.clone(); + let cleared = new_cli_session()?; + let new_session_id = cleared.session_id.clone(); + cleared.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: cleared, + message: Some(format!( + "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", + backup_path.display(), + backup_path.display(), + session_path.display() + )), + json: Some(serde_json::json!({ + "kind": "clear", + "previous_session_id": previous_session_id, + "new_session_id": new_session_id, + "backup": backup_path.display().to_string(), + "session_file": session_path.display().to_string(), + })), + }) + } + SlashCommand::Status => { + let tracker = UsageTracker::from_session(session); + let usage = tracker.cumulative_usage(); + let context = status_context(Some(session_path))?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_status_report( + session.model.as_deref().unwrap_or("restored-session"), + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &context, + None, + )), + json: Some(status_json_value( + session.model.as_deref(), + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &context, + None, + )), + }) + } + SlashCommand::Sandbox => { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_sandbox_report(&status)), + json: Some(sandbox_json_value(&status)), + }) + } + SlashCommand::Cost => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_cost_report(usage)), + json: Some(serde_json::json!({ + "kind": "cost", + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_creation_input_tokens": usage.cache_creation_input_tokens, + "cache_read_input_tokens": usage.cache_read_input_tokens, + "total_tokens": usage.total_tokens(), + })), + }) + } + SlashCommand::Config { section } => { + let message = render_config_report(section.as_deref())?; + let json = render_config_json(section.as_deref())?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message), + json: Some(json), + }) + } + SlashCommand::Mcp { action, target } => { + let cwd = env::current_dir()?; + 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()), + }; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?), + json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)?), + }) + } + SlashCommand::Memory => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_memory_report()?), + json: Some(render_memory_json()?), + }), + SlashCommand::Init => { + let cwd = env::current_dir()?; + let report = crate::init::initialize_repo(&cwd)?; + let message = report.render(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message.clone()), + json: Some(init_json_value(&report, &message)), + }) + } + SlashCommand::Diff => { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let message = render_diff_report_for(&cwd)?; + let json = render_diff_json_for(&cwd)?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message), + json: Some(json), + }) + } + SlashCommand::Version => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_version_report()), + json: Some(version_json_value()), + }), + SlashCommand::Export { path } => { + let export_path = resolve_export_path(path.as_deref(), session)?; + fs::write(&export_path, render_export_text(session))?; + let msg_count = session.messages.len(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + msg_count, + )), + json: Some(serde_json::json!({ + "kind": "export", + "file": export_path.display().to_string(), + "message_count": msg_count, + })), + }) + } + SlashCommand::Agents { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), + json: Some(serde_json::json!({ + "kind": "agents", + "text": handle_agents_slash_command(args.as_deref(), &cwd)?, + })), + }) + } + SlashCommand::Skills { args } => { + if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { + return Err( + "resumed /skills invocations are interactive-only; start `claw` and run `/skills ` in the REPL".into(), + ); + } + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), + json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?), + }) + } + SlashCommand::Doctor => { + let report = render_doctor_report()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(report.render()), + json: Some(report.json_value()), + }) + } + SlashCommand::Stats => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_cost_report(usage)), + json: Some(serde_json::json!({ + "kind": "stats", + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_creation_input_tokens": usage.cache_creation_input_tokens, + "cache_read_input_tokens": usage.cache_read_input_tokens, + "total_tokens": usage.total_tokens(), + })), + }) + } + SlashCommand::History { count } => { + let limit = parse_history_count(count.as_deref()) + .map_err(|error| -> Box { error.into() })?; + let entries = collect_session_prompt_history(session); + let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_prompt_history_report(&entries, limit)), + json: Some(serde_json::json!({ + "kind": "history", + "total": entries.len(), + "showing": shown.len(), + "entries": shown.iter().map(|e| serde_json::json!({ + "timestamp_ms": e.timestamp_ms, + "text": e.text, + })).collect::>(), + })), + }) + } + SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), + SlashCommand::Session { + action: Some(ref act), + .. + } if act == "list" => { + let sessions = list_managed_sessions().unwrap_or_default(); + let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); + let active_id = session.session_id.clone(); + let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(text), + json: Some(serde_json::json!({ + "kind": "session_list", + "sessions": session_ids, + "active": active_id, + })), + }) + } + SlashCommand::Bughunter { .. } + | SlashCommand::Commit { .. } + | SlashCommand::Pr { .. } + | SlashCommand::Issue { .. } + | SlashCommand::Ultraplan { .. } + | SlashCommand::Teleport { .. } + | SlashCommand::DebugToolCall { .. } + | SlashCommand::Resume { .. } + | SlashCommand::Model { .. } + | SlashCommand::Permissions { .. } + | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } + | 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 { .. } => Err("unsupported resumed slash command".into()), + } +} + +pub(crate) fn dump_manifests( + manifests_dir: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + dump_manifests_at_path(&workspace_dir, manifests_dir, output_format) +} + +const DUMP_MANIFESTS_OVERRIDE_HINT: &str = + "Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`."; + +pub(crate) fn dump_manifests_at_path( + workspace_dir: &std::path::Path, + manifests_dir: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let paths = if let Some(dir) = manifests_dir { + let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); + UpstreamPaths::from_repo_root(resolved) + } else { + let resolved = workspace_dir + .canonicalize() + .unwrap_or_else(|_| workspace_dir.to_path_buf()); + UpstreamPaths::from_workspace_dir(&resolved) + }; + + let source_root = paths.repo_root(); + if !source_root.exists() { + return Err(format!( + "Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", + source_root.display(), + ) + .into()); + } + + let required_paths = [ + ("src/commands.ts", paths.commands_path()), + ("src/tools.ts", paths.tools_path()), + ("src/entrypoints/cli.tsx", paths.cli_path()), + ]; + let missing = required_paths + .iter() + .filter_map(|(label, path)| (!path.is_file()).then_some(*label)) + .collect::>(); + if !missing.is_empty() { + return Err(format!( + "Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", + source_root.display(), + missing.join(", "), + ) + .into()); + } + + match extract_manifest(&paths) { + Ok(manifest) => { + match output_format { + CliOutputFormat::Text => { + println!("commands: {}", manifest.commands.entries().len()); + println!("tools: {}", manifest.tools.entries().len()); + println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); + } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "dump-manifests", + "commands": manifest.commands.entries().len(), + "tools": manifest.tools.entries().len(), + "bootstrap_phases": manifest.bootstrap.phases().len(), + }))? + ), + } + Ok(()) + } + Err(error) => Err(format!( + "failed to extract manifests: {error}\n looked in: {path}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", + path = paths.repo_root().display() + ) + .into()), + } +} + +pub(crate) fn init_json_value( + report: &crate::init::InitReport, + message: &str, +) -> serde_json::Value { + use crate::init::InitStatus; + json!({ + "kind": "init", + "project_path": report.project_root.display().to_string(), + "created": report.artifacts_with_status(InitStatus::Created), + "updated": report.artifacts_with_status(InitStatus::Updated), + "skipped": report.artifacts_with_status(InitStatus::Skipped), + "artifacts": report.artifact_json_entries(), + "next_step": crate::init::InitReport::NEXT_STEP, + "message": message, + }) +} + +pub(crate) fn print_acp_status( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support."; + match output_format { + CliOutputFormat::Text => { + println!( + "ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}" + ); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "acp", + "status": "discoverability_only", + "supported": false, + "serve_alias_only": true, + "message": message, + "launch_command": serde_json::Value::Null, + "aliases": ["acp", "--acp", "-acp"], + "discoverability_tracking": "ROADMAP #64a", + "tracking": "ROADMAP #76", + "recommended_workflows": [ + "claw prompt TEXT", + "claw", + "claw doctor" + ], + }))? + ); + } + } + Ok(()) +} + +pub(crate) fn render_session_markdown( + session: &Session, + session_id: &str, + session_path: &Path, +) -> String { + let mut lines = vec![ + "# Conversation Export".to_string(), + String::new(), + format!("- **Session**: `{session_id}`"), + format!("- **File**: `{}`", session_path.display()), + format!("- **Messages**: {}", session.messages.len()), + ]; + if let Some(workspace_root) = session.workspace_root() { + lines.push(format!("- **Workspace**: `{}`", workspace_root.display())); + } + if let Some(fork) = &session.fork { + let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)"); + lines.push(format!( + "- **Forked from**: `{}` (branch `{branch}`)", + fork.parent_session_id + )); + } + if let Some(compaction) = &session.compaction { + lines.push(format!( + "- **Compactions**: {} (last removed {} messages)", + compaction.count, compaction.removed_message_count + )); + } + lines.push(String::new()); + lines.push("---".to_string()); + lines.push(String::new()); + + for (index, message) in session.messages.iter().enumerate() { + let role = match message.role { + MessageRole::System => "System", + MessageRole::User => "User", + MessageRole::Assistant => "Assistant", + MessageRole::Tool => "Tool", + }; + lines.push(format!("## {}. {role}", index + 1)); + lines.push(String::new()); + for block in &message.blocks { + match block { + ContentBlock::Text { text } => { + let trimmed = text.trim_end(); + if !trimmed.is_empty() { + lines.push(trimmed.to_string()); + lines.push(String::new()); + } + } + ContentBlock::ToolUse { id, name, input } => { + lines.push(format!( + "**Tool call** `{name}` _(id `{}`)_", + short_tool_id(id) + )); + let summary = summarize_tool_payload_for_markdown(input); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => { + let status = if *is_error { "error" } else { "ok" }; + lines.push(format!( + "**Tool result** `{tool_name}` _(id `{}`, {status})_", + short_tool_id(tool_use_id) + )); + let summary = summarize_tool_payload_for_markdown(output); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + } + } + if let Some(usage) = message.usage { + lines.push(format!( + "_tokens: in={} out={} cache_create={} cache_read={}_", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + )); + lines.push(String::new()); + } + } + lines.join("\n") +} + +pub(crate) fn run_export( + session_reference: &str, + output_path: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let (handle, session) = load_session_reference(session_reference)?; + let markdown = render_session_markdown(&session, &handle.id, &handle.path); + + if let Some(path) = output_path { + fs::write(path, &markdown)?; + let report = format!( + "Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}", + path.display(), + handle.id, + session.messages.len(), + ); + match output_format { + CliOutputFormat::Text => println!("{report}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "export", + "message": report, + "session_id": handle.id, + "file": path.display().to_string(), + "messages": session.messages.len(), + }))? + ), + } + return Ok(()); + } + + match output_format { + CliOutputFormat::Text => { + print!("{markdown}"); + if !markdown.ends_with('\n') { + println!(); + } + } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "export", + "session_id": handle.id, + "file": handle.path.display().to_string(), + "messages": session.messages.len(), + "markdown": markdown, + }))? + ), + } + Ok(()) +} + +pub(crate) fn default_export_filename(session: &Session) -> String { + let stem = session + .messages + .iter() + .find_map(|message| match message.role { + MessageRole::User => message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }), + _ => None, + }) + .map_or("conversation", |text| { + text.lines().next().unwrap_or("conversation") + }) + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .split('-') + .filter(|part| !part.is_empty()) + .take(8) + .collect::>() + .join("-"); + let fallback = if stem.is_empty() { + "conversation" + } else { + &stem + }; + format!("{fallback}.txt") +} + +pub(crate) fn indent_block(value: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + value + .lines() + .map(|line| format!("{indent}{line}")) + .collect::>() + .join("\n") +} + +pub(crate) fn git_status_ok(args: &[&str]) -> Result<(), Box> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(()) +} + +pub(crate) fn command_exists(name: &str) -> bool { + Command::new("which") + .arg(name) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +pub(crate) fn write_temp_text_file( + filename: &str, + contents: &str, +) -> Result> { + let path = env::temp_dir().join(filename); + fs::write(&path, contents)?; + Ok(path) +} + +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) fn run_git_diff_command_in( + cwd: &Path, + args: &[&str], +) -> Result> { + let output = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(String::from_utf8(output.stdout)?) +} + +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 truncate_for_prompt(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + value.trim().to_string() + } else { + let truncated = value.chars().take(limit).collect::(); + format!("{}\n…[truncated]", truncated.trim_end()) + } +} + +pub(crate) fn recent_user_context(session: &Session, limit: usize) -> String { + let requests = session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.trim().to_string()), + _ => None, + }) + }) + .rev() + .take(limit) + .collect::>(); + + if requests.is_empty() { + "".to_string() + } else { + requests + .into_iter() + .rev() + .enumerate() + .map(|(index, text)| format!("{}. {}", index + 1, text)) + .collect::>() + .join("\n") + } +} + +pub(crate) fn write_mcp_server_fixture(script_path: &Path) { + let script = [ + "#!/usr/bin/env python3", + "import json, sys", + "", + "def read_message():", + " header = b''", + r" while not header.endswith(b'\r\n\r\n'):", + " chunk = sys.stdin.buffer.read(1)", + " if not chunk:", + " return None", + " header += chunk", + " length = 0", + r" for line in header.decode().split('\r\n'):", + r" if line.lower().startswith('content-length:'):", + " length = int(line.split(':', 1)[1].strip())", + " payload = sys.stdin.buffer.read(length)", + " return json.loads(payload.decode())", + "", + "def send_message(message):", + " payload = json.dumps(message).encode()", + r" sys.stdout.buffer.write(f'Content-Length: {len(payload)}\r\n\r\n'.encode() + payload)", + " sys.stdout.buffer.flush()", + "", + "while True:", + " request = read_message()", + " if request is None:", + " break", + " method = request['method']", + " if method == 'initialize':", + " send_message({", + " 'jsonrpc': '2.0',", + " 'id': request['id'],", + " 'result': {", + " 'protocolVersion': request['params']['protocolVersion'],", + " 'capabilities': {'tools': {}, 'resources': {}},", + " 'serverInfo': {'name': 'fixture', 'version': '1.0.0'}", + " }", + " })", + " elif method == 'tools/list':", + " send_message({", + " 'jsonrpc': '2.0',", + " 'id': request['id'],", + " 'result': {", + " 'tools': [", + " {", + " 'name': 'echo',", + " 'description': 'Echo from MCP fixture',", + " 'inputSchema': {", + " 'type': 'object',", + " 'properties': {'text': {'type': 'string'}},", + " 'required': ['text'],", + " 'additionalProperties': False", + " },", + " 'annotations': {'readOnlyHint': True}", + " }", + " ]", + " }", + " })", + " elif method == 'tools/call':", + " args = request['params'].get('arguments') or {}", + " send_message({", + " 'jsonrpc': '2.0',", + " 'id': request['id'],", + " 'result': {", + " 'content': [{'type': 'text', 'text': f\"echo:{args.get('text', '')}\"}],", + " 'structuredContent': {'echoed': args.get('text', '')},", + " 'isError': False", + " }", + " })", + " elif method == 'resources/list':", + " send_message({", + " 'jsonrpc': '2.0',", + " 'id': request['id'],", + " 'result': {", + " 'resources': [{'uri': 'file://guide.txt', 'name': 'guide', 'mimeType': 'text/plain'}]", + " }", + " })", + " elif method == 'resources/read':", + " uri = request['params']['uri']", + " send_message({", + " 'jsonrpc': '2.0',", + " 'id': request['id'],", + " 'result': {", + " 'contents': [{'uri': uri, 'mimeType': 'text/plain', 'text': f'contents for {uri}'}]", + " }", + " })", + " else:", + " send_message({", + " 'jsonrpc': '2.0',", + " 'id': request['id'],", + " 'error': {'code': -32601, 'message': method}", + " })", + "", + ] + .join("\n"); + fs::write(script_path, script).expect("mcp fixture script should write"); +} + +// --------------------------------------------------------------------------- +// Doctor check helpers (private to this module) +// --------------------------------------------------------------------------- + +#[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(); + 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)), + ])) +} + +// --------------------------------------------------------------------------- +// Helper functions referenced by the extracted functions +// --------------------------------------------------------------------------- + +fn version_json_value() -> serde_json::Value { + json!({ + "kind": "version", + "message": render_version_report(), + "version": VERSION, + "git_sha": GIT_SHA, + "target": BUILD_TARGET, + }) +} + +pub(crate) fn render_version_report() -> String { + let git_sha = GIT_SHA.unwrap_or("unknown"); + let target = BUILD_TARGET.unwrap_or("unknown"); + format!( + "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" + ) +} + +pub(crate) fn run_init(output_format: CliOutputFormat) -> Result<(), Box> { + let cwd = env::current_dir()?; + let report = crate::init::initialize_repo(&cwd)?; + let message = report.render(); + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&init_json_value(&report, &message))? + ), + } + Ok(()) +} + +pub(crate) fn print_help(output_format: CliOutputFormat) -> Result<(), Box> { + let mut buffer = Vec::new(); + print_help_to(&mut buffer)?; + let message = String::from_utf8(buffer)?; + match output_format { + CliOutputFormat::Text => print!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "help", + "message": message, + }))? + ), + } + Ok(()) +} + +pub(crate) fn init_claude_md() -> Result> { + let cwd = env::current_dir()?; + Ok(crate::init::initialize_repo(&cwd)?.render()) +} + +/// STUB_COMMANDS — slash commands registered in the spec list but not yet +/// implemented in this build. +pub(crate) const STUB_COMMANDS: &[&str] = &[ + "login", + "logout", + "vim", + "upgrade", + "share", + "feedback", + "files", + "fast", + "exit", + "summary", + "desktop", + "brief", + "advisor", + "stickers", + "insights", + "thinkback", + "release-notes", + "security-review", + "keybindings", + "privacy-settings", + "plan", + "review", + "tasks", + "theme", + "voice", + "usage", + "rename", + "copy", + "hooks", + "context", + "color", + "effort", + "branch", + "rewind", + "ide", + "tag", + "output-style", + "add-dir", + "allowed-tools", + "bookmarks", + "workspace", + "reasoning", + "budget", + "rate-limit", + "changelog", + "diagnostics", + "metrics", + "tool-details", + "focus", + "unfocus", + "pin", + "unpin", + "language", + "profile", + "max-tokens", + "temperature", + "system-prompt", + "notifications", + "telemetry", + "env", + "project", + "terminal-setup", + "api-key", + "reset", + "undo", + "stop", + "retry", + "paste", + "screenshot", + "image", + "search", + "listen", + "speak", + "format", + "test", + "lint", + "build", + "run", + "git", + "stash", + "blame", + "log", + "cron", + "team", + "benchmark", + "migrate", + "templates", + "explain", + "refactor", + "docs", + "fix", + "perf", + "chat", + "web", + "map", + "symbols", + "references", + "definition", + "hover", + "autofix", + "multi", + "macro", + "alias", + "parallel", + "subagent", + "agent", +]; diff --git a/rust/crates/rusty-claude-cli/src/format/cost.rs b/rust/crates/rusty-claude-cli/src/format/cost.rs new file mode 100644 index 0000000000..6f54cbb064 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/format/cost.rs @@ -0,0 +1,60 @@ +use runtime::TokenUsage; + +pub(crate) const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; +pub(crate) const LATEST_SESSION_REFERENCE: &str = "latest"; + +pub(crate) fn format_cost_report(usage: TokenUsage) -> String { + format!( + "Cost + Input tokens {} + Output tokens {} + Cache create {} + Cache read {} + Total tokens {}", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + usage.total_tokens(), + ) +} + +pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { + format!( + "Session resumed + Session file {session_path} + Messages {message_count} + Turns {turns}" + ) +} + +pub(crate) fn render_resume_usage() -> String { + format!( + "Resume + Usage /resume + Auto-save .claw/sessions/.{PRIMARY_SESSION_EXTENSION} + Tip use /session list to inspect saved sessions" + ) +} + +pub(crate) fn format_compact_report( + removed: usize, + resulting_messages: usize, + skipped: bool, +) -> String { + if skipped { + format!( + "Compact + Result skipped + Reason session below compaction threshold + Messages kept {resulting_messages}" + ) + } else { + format!( + "Compact + Result compacted + Messages removed {removed} + Messages kept {resulting_messages}" + ) + } +} diff --git a/rust/crates/rusty-claude-cli/src/format/errors.rs b/rust/crates/rusty-claude-cli/src/format/errors.rs new file mode 100644 index 0000000000..b707e9d36f --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/format/errors.rs @@ -0,0 +1,338 @@ +use crate::format::tool_fmt::truncate_for_summary; +use commands::slash_command_specs; + +/// Classify an error message into a short category tag for structured logging +/// and downstream routing (#77). +pub(crate) fn classify_error_kind(message: &str) -> &'static str { + // Check specific patterns first (more specific before generic) + if message.contains("missing Anthropic credentials") { + "missing_credentials" + } else if message.contains("Manifest source files are missing") { + "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("failed to restore session") { + "session_load_failed" + } else if message.contains("no managed sessions found") { + "no_managed_sessions" + } else if message.contains("unrecognized argument") || message.contains("unknown option") { + "cli_parse" + } 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") { + "api_http_error" + } else { + "unknown" + } +} + +/// #77: Split a multi-line error message into (short_reason, optional_hint). +/// +/// The short_reason is the first line (up to the first newline), and the hint +/// is the remaining text or `None` if there's no newline. This prevents the +/// runbook prose from being stuffed into the `error` field that downstream +/// parsers expect to be the short reason alone. +pub(crate) fn split_error_hint(message: &str) -> (String, Option) { + match message.split_once('\n') { + Some((short, hint)) => (short.to_string(), Some(hint.trim().to_string())), + None => (message.to_string(), None), + } +} + +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 format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String { + if error.is_context_window_failure() { + format_context_window_blocked_error(session_id, error) + } else if error.is_generic_fatal_wrapper() { + let mut qualifiers = vec![format!("session {session_id}")]; + if let Some(request_id) = error.request_id() { + qualifiers.push(format!("trace {request_id}")); + } + format!( + "{} ({}): {}", + error.safe_failure_class(), + qualifiers.join(", "), + error + ) + } else { + error.to_string() + } +} + +pub(crate) fn format_context_window_blocked_error( + session_id: &str, + error: &api::ApiError, +) -> String { + let mut lines = vec![ + "Context window blocked".to_string(), + " Failure class context_window_blocked".to_string(), + format!(" Session {session_id}"), + ]; + + if let Some(request_id) = error.request_id() { + lines.push(format!(" Trace {request_id}")); + } + + match error { + api::ApiError::ContextWindowExceeded { + model, + estimated_input_tokens, + requested_output_tokens, + estimated_total_tokens, + context_window_tokens, + } => { + lines.push(format!(" Model {model}")); + lines.push(format!( + " Input estimate ~{estimated_input_tokens} tokens (heuristic)" + )); + lines.push(format!( + " Requested output {requested_output_tokens} tokens" + )); + lines.push(format!( + " Total estimate ~{estimated_total_tokens} tokens (heuristic)" + )); + lines.push(format!(" Context window {context_window_tokens} tokens")); + } + api::ApiError::Api { message, body, .. } => { + let detail = message.as_deref().unwrap_or(body).trim(); + if !detail.is_empty() { + lines.push(format!( + " Detail {}", + truncate_for_summary(detail, 120) + )); + } + } + api::ApiError::RetriesExhausted { last_error, .. } => { + let detail = match last_error.as_ref() { + api::ApiError::Api { message, body, .. } => message.as_deref().unwrap_or(body), + other => return format_context_window_blocked_error(session_id, other), + } + .trim(); + if !detail.is_empty() { + lines.push(format!( + " Detail {}", + truncate_for_summary(detail, 120) + )); + } + } + _ => {} + } + + lines.push(String::new()); + lines.push("Recovery".to_string()); + lines.push(" Compact /compact".to_string()); + lines.push(format!( + " Resume compact claw --resume {session_id} /compact" + )); + lines.push(" Fresh session /clear --confirm".to_string()); + lines.push( + " Reduce scope remove large pasted context/files or ask for a smaller slice" + .to_string(), + ); + lines.push(" Retry rerun after compacting or reducing the request".to_string()); + + lines.join("\n") +} + +const CLI_OPTION_SUGGESTIONS: &[&str] = &[ + "--help", + "-h", + "--version", + "-V", + "--model", + "--output-format", + "--compact", + "--permission-mode", + "--dangerously-skip-permissions", + "--allowedTools", + "--resume", + "--acp", + "-acp", +]; diff --git a/rust/crates/rusty-claude-cli/src/format/mod.rs b/rust/crates/rusty-claude-cli/src/format/mod.rs new file mode 100644 index 0000000000..f1b3b2e5d0 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/format/mod.rs @@ -0,0 +1,18 @@ +pub mod cost; +pub mod errors; +pub mod model; +pub mod permissions; +pub mod sessions; +pub mod slash_help; +pub mod status; +pub mod tool_fmt; + +// Re-export commonly used types and functions +pub use cost::*; +pub use errors::*; +pub use model::*; +pub use permissions::*; +pub use sessions::*; +pub use slash_help::*; +pub use status::*; +pub use tool_fmt::*; diff --git a/rust/crates/rusty-claude-cli/src/format/model.rs b/rust/crates/rusty-claude-cli/src/format/model.rs new file mode 100644 index 0000000000..60ac7de06a --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/format/model.rs @@ -0,0 +1,249 @@ +use std::env; + +use api::detect_provider_kind; +use api::ProviderKind; +use api::ToolDefinition; +use runtime::ConfigLoader; +use tools::GlobalToolRegistry; + +use crate::AllowedToolSet; + +pub(crate) const DEFAULT_MODEL: &str = "claude-opus-4-6"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ModelSource { + /// Explicit `--model` / `--model=` CLI flag. + Flag, + /// ANTHROPIC_MODEL environment variable (when no flag was passed). + Env, + /// `model` key in `.claw.json` / `.claw/settings.json` (when neither + /// flag nor env set it). + Config, + /// Compiled-in DEFAULT_MODEL fallback. + Default, +} + +impl ModelSource { + pub(crate) fn as_str(&self) -> &'static str { + match self { + ModelSource::Flag => "flag", + ModelSource::Env => "env", + ModelSource::Config => "config", + ModelSource::Default => "default", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ModelProvenance { + /// Resolved model string (after alias expansion). + pub(crate) resolved: String, + /// Raw user input before alias resolution. None when source is Default. + pub(crate) raw: Option, + /// Where the resolved model string originated. + pub(crate) source: ModelSource, +} + +impl ModelProvenance { + pub(crate) fn default_fallback() -> Self { + Self { + resolved: DEFAULT_MODEL.to_string(), + raw: None, + source: ModelSource::Default, + } + } + + pub(crate) fn from_flag(raw: &str) -> Self { + Self { + resolved: resolve_model_alias_with_config(raw), + raw: Some(raw.to_string()), + source: ModelSource::Flag, + } + } + + pub(crate) fn from_env_or_config_or_default(cli_model: &str) -> Self { + // Only called when no --model flag was passed. Probe env first, + // then config, else fall back to default. Mirrors the logic in + // resolve_repl_model() but captures the source. + if cli_model != DEFAULT_MODEL { + // Already resolved from some prior path; treat as flag. + return Self { + resolved: cli_model.to_string(), + raw: Some(cli_model.to_string()), + source: ModelSource::Flag, + }; + } + if let Some(env_model) = env::var("ANTHROPIC_MODEL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return Self { + resolved: resolve_model_alias_with_config(&env_model), + raw: Some(env_model), + source: ModelSource::Env, + }; + } + if let Some(config_model) = config_model_for_current_dir() { + return Self { + resolved: resolve_model_alias_with_config(&config_model), + raw: Some(config_model), + source: ModelSource::Config, + }; + } + Self::default_fallback() + } +} + +pub(crate) fn max_tokens_for_model(model: &str) -> u32 { + if model.contains("opus") { + 32_000 + } else { + 64_000 + } +} + +pub(crate) fn resolve_model_alias(model: &str) -> &str { + match model { + "opus" => "claude-opus-4-6", + "sonnet" => "claude-sonnet-4-6", + "haiku" => "claude-haiku-4-5-20251213", + _ => model, + } +} + +/// Resolve a model name through user-defined config aliases first, then fall +/// back to the built-in alias table. This is the entry point used wherever a +/// user-supplied model string is about to be dispatched to a provider. +pub(crate) fn resolve_model_alias_with_config(model: &str) -> String { + let trimmed = model.trim(); + if let Some(resolved) = config_alias_for_current_dir(trimmed) { + return resolve_model_alias(&resolved).to_string(); + } + resolve_model_alias(trimmed).to_string() +} + +/// 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(); + if trimmed.is_empty() { + return Err("model string cannot be empty".to_string()); + } + // Known aliases are always valid + match trimmed { + "opus" | "sonnet" | "haiku" => return Ok(()), + _ => {} + } + // Check for spaces (malformed) + if trimmed.contains(' ') { + return Err(format!( + "invalid model syntax: '{}' contains spaces. Use provider/model format or known alias", + trimmed + )); + } + // 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: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)", + 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") { + 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 config_alias_for_current_dir(alias: &str) -> Option { + if alias.is_empty() { + return None; + } + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + let config = loader.load().ok()?; + config.aliases().get(alias).cloned() +} + +pub(crate) fn config_model_for_current_dir() -> Option { + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + loader.load().ok()?.model().map(ToOwned::to_owned) +} + +pub(crate) fn resolve_repl_model(cli_model: String) -> String { + if cli_model != DEFAULT_MODEL { + return cli_model; + } + if let Some(env_model) = env::var("ANTHROPIC_MODEL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return resolve_model_alias_with_config(&env_model); + } + if let Some(config_model) = config_model_for_current_dir() { + return resolve_model_alias_with_config(&config_model); + } + cli_model +} + +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 filter_tool_specs( + tool_registry: &GlobalToolRegistry, + allowed_tools: Option<&AllowedToolSet>, +) -> Vec { + tool_registry.definitions(allowed_tools) +} + +pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { + format!( + "Model + Current model {model} + Session messages {message_count} + Session turns {turns} + +Usage + Inspect current model with /model + Switch models with /model " + ) +} + +pub(crate) fn format_model_switch_report( + previous: &str, + next: &str, + message_count: usize, +) -> String { + format!( + "Model updated + Previous {previous} + Current {next} + Preserved msgs {message_count}" + ) +} diff --git a/rust/crates/rusty-claude-cli/src/format/permissions.rs b/rust/crates/rusty-claude-cli/src/format/permissions.rs new file mode 100644 index 0000000000..e041dfa679 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/format/permissions.rs @@ -0,0 +1,113 @@ +use std::env; + +use runtime::{ConfigLoader, PermissionMode, ResolvedPermissionMode}; + +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 format_permissions_report(mode: &str) -> String { + let modes = [ + ("read-only", "Read/search tools only", mode == "read-only"), + ( + "workspace-write", + "Edit files inside the workspace", + mode == "workspace-write", + ), + ( + "danger-full-access", + "Unrestricted tool access", + mode == "danger-full-access", + ), + ] + .into_iter() + .map(|(name, description, is_current)| { + let marker = if is_current { + "● current" + } else { + "○ available" + }; + format!(" {name:<18} {marker:<11} {description}") + }) + .collect::>() + .join( + " +", + ); + + format!( + "Permissions + Active mode {mode} + Mode status live session default + +Modes +{modes} + +Usage + Inspect current mode with /permissions + Switch modes with /permissions " + ) +} + +pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String { + format!( + "Permissions updated + Result mode switched + Previous mode {previous} + Active mode {next} + Applies to subsequent tool calls + Usage /permissions to inspect current mode" + ) +} + +pub(crate) fn normalize_permission_mode(mode: &str) -> Option<&'static str> { + match mode.trim() { + "read-only" => Some("read-only"), + "workspace-write" => Some("workspace-write"), + "danger-full-access" => Some("danger-full-access"), + _ => None, + } +} diff --git a/rust/crates/rusty-claude-cli/src/format/sessions.rs b/rust/crates/rusty-claude-cli/src/format/sessions.rs new file mode 100644 index 0000000000..2258c5c223 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/format/sessions.rs @@ -0,0 +1,341 @@ +use std::env; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use runtime::{ContentBlock, MessageRole, Session, SessionStore}; + +pub(crate) const DEFAULT_HISTORY_LIMIT: usize = 20; + +// ── Structs ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub(crate) struct SessionHandle { + pub(crate) id: String, + pub(crate) path: PathBuf, +} + +#[derive(Debug, Clone)] +pub(crate) struct ManagedSessionSummary { + pub(crate) id: String, + pub(crate) path: PathBuf, + pub(crate) updated_at_ms: u64, + pub(crate) modified_epoch_millis: u128, + pub(crate) message_count: usize, + pub(crate) parent_session_id: Option, + pub(crate) branch_name: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct PromptHistoryEntry { + pub(crate) timestamp_ms: u64, + pub(crate) text: String, +} + +// ── Session directory / store helpers ───────────────────────────────── + +pub(crate) fn sessions_dir() -> Result> { + Ok(current_session_store()?.sessions_dir().to_path_buf()) +} + +pub(crate) fn current_session_store() -> Result> { + let cwd = env::current_dir()?; + SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box) +} + +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, + }) + .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, + }) +} + +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") +} + +// ── Rendering ──────────────────────────────────────────────────────── + +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), + } +} + +// ── Backup / clear ─────────────────────────────────────────────────── + +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")) +} + +// ── History ────────────────────────────────────────────────────────── + +pub(crate) fn parse_history_count(raw: Option<&str>) -> Result { + let Some(raw) = raw else { + return Ok(DEFAULT_HISTORY_LIMIT); + }; + let parsed: usize = raw + .parse() + .map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?; + if parsed == 0 { + return Err("history: count must be greater than 0.".to_string()); + } + Ok(parsed) +} + +pub(crate) fn format_history_timestamp(timestamp_ms: u64) -> String { + let secs = timestamp_ms / 1_000; + let subsec_ms = timestamp_ms % 1_000; + let days_since_epoch = secs / 86_400; + let seconds_of_day = secs % 86_400; + let hours = seconds_of_day / 3_600; + let minutes = (seconds_of_day % 3_600) / 60; + let seconds = seconds_of_day % 60; + + let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0)); + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z") +} + +/// Computes civil (Gregorian) year/month/day from days since the Unix epoch +/// (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm. +#[allow( + clippy::cast_sign_loss, + clippy::cast_possible_wrap, + clippy::cast_possible_truncation +)] +pub(crate) fn civil_from_days(days: i64) -> (i32, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { + z / 146_097 + } else { + (z - 146_096) / 146_097 + }; + let doe = (z - era * 146_097) as u64; // [0, 146_096] + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let y = y + i64::from(m <= 2); + (y as i32, m as u32, d as u32) +} + +pub(crate) fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String { + if entries.is_empty() { + return "Prompt history\n Result no prompts recorded yet".to_string(); + } + + let total = entries.len(); + let start = total.saturating_sub(limit); + let shown = &entries[start..]; + let mut lines = vec![ + "Prompt history".to_string(), + format!(" Total {total}"), + format!(" Showing {} most recent", shown.len()), + format!(" Reverse search Ctrl-R in the REPL"), + String::new(), + ]; + for (offset, entry) in shown.iter().enumerate() { + let absolute_index = start + offset + 1; + let timestamp = format_history_timestamp(entry.timestamp_ms); + let first_line = entry.text.lines().next().unwrap_or("").trim(); + let display = if first_line.chars().count() > 80 { + let truncated: String = first_line.chars().take(77).collect(); + format!("{truncated}...") + } else { + first_line.to_string() + }; + lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}")); + } + lines.join("\n") +} + +pub(crate) fn collect_session_prompt_history(session: &Session) -> Vec { + if !session.prompt_history.is_empty() { + return session + .prompt_history + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect(); + } + let timestamp_ms = session.updated_at_ms; + session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(PromptHistoryEntry { + timestamp_ms, + text: text.clone(), + }), + _ => None, + }) + }) + .collect() +} diff --git a/rust/crates/rusty-claude-cli/src/format/slash_help.rs b/rust/crates/rusty-claude-cli/src/format/slash_help.rs new file mode 100644 index 0000000000..70fc811a29 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/format/slash_help.rs @@ -0,0 +1,502 @@ +use std::collections::BTreeSet; +use std::io::{self, Write}; + +use commands::{render_slash_command_help_filtered, slash_command_specs}; + +use crate::format::cost::{LATEST_SESSION_REFERENCE, PRIMARY_SESSION_EXTENSION}; + +/// Slash commands that are registered in the spec list but not yet implemented +/// in this build. Used to filter both REPL completions and help output so the +/// discovery surface only shows commands that actually work (ROADMAP #39). +const STUB_COMMANDS: &[&str] = &[ + "login", + "logout", + "vim", + "upgrade", + "share", + "feedback", + "files", + "fast", + "exit", + "summary", + "desktop", + "brief", + "advisor", + "stickers", + "insights", + "thinkback", + "release-notes", + "security-review", + "keybindings", + "privacy-settings", + "plan", + "review", + "tasks", + "theme", + "voice", + "usage", + "rename", + "copy", + "hooks", + "context", + "color", + "effort", + "branch", + "rewind", + "ide", + "tag", + "output-style", + "add-dir", + // Spec entries with no parse arm — produce circular "Did you mean" error + // without this guard. Adding here routes them to the proper unsupported + // message and excludes them from REPL completions / help. + // NOTE: do NOT add "stats", "tokens", "cache" — they are implemented. + "allowed-tools", + "bookmarks", + "workspace", + "reasoning", + "budget", + "rate-limit", + "changelog", + "diagnostics", + "metrics", + "tool-details", + "focus", + "unfocus", + "pin", + "unpin", + "language", + "profile", + "max-tokens", + "temperature", + "system-prompt", + "notifications", + "telemetry", + "env", + "project", + "terminal-setup", + "api-key", + "reset", + "undo", + "stop", + "retry", + "paste", + "screenshot", + "image", + "search", + "listen", + "speak", + "format", + "test", + "lint", + "build", + "run", + "git", + "stash", + "blame", + "log", + "cron", + "team", + "benchmark", + "migrate", + "templates", + "explain", + "refactor", + "docs", + "fix", + "perf", + "chat", + "web", + "map", + "symbols", + "references", + "definition", + "hover", + "autofix", + "multi", + "macro", + "alias", + "parallel", + "subagent", + "agent", +]; + +const OFFICIAL_REPO_URL: &str = "https://github.com/ultraworkers/claw-code"; +const OFFICIAL_REPO_SLUG: &str = "ultraworkers/claw-code"; +const DEPRECATED_INSTALL_COMMAND: &str = "cargo install claw-code"; +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[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, +} + +pub(crate) fn render_repl_help() -> String { + [ + "REPL".to_string(), + " /exit Quit the REPL".to_string(), + " /quit Quit the REPL".to_string(), + " Up/Down Navigate prompt history".to_string(), + " Ctrl-R Reverse-search prompt history".to_string(), + " Tab Complete commands, modes, and recent sessions".to_string(), + " Ctrl-C Clear input (or exit on empty prompt)".to_string(), + " Shift+Enter/Ctrl+J Insert a newline".to_string(), + " Auto-save .claw/sessions/.jsonl".to_string(), + " Resume latest /resume latest".to_string(), + " Browse sessions /session list".to_string(), + " Show prompt history /history [count]".to_string(), + String::new(), + render_slash_command_help_filtered(STUB_COMMANDS), + ] + .join( + " +", + ) +} + +pub(crate) fn render_help_topic(topic: LocalHelpTopic) -> String { + match topic { + LocalHelpTopic::Status => "Status + Usage claw status [--output-format ] + Purpose show the local workspace snapshot without entering the REPL + Output model, permissions, git state, config files, and sandbox status + Formats text (default), json + Related /status · claw --resume latest /status" + .to_string(), + LocalHelpTopic::Sandbox => "Sandbox + Usage claw sandbox [--output-format ] + Purpose inspect the resolved sandbox and isolation state for the current directory + Output namespace, network, filesystem, and fallback details + Formats text (default), json + Related /sandbox · claw status" + .to_string(), + LocalHelpTopic::Doctor => "Doctor + Usage claw doctor [--output-format ] + Purpose diagnose local auth, config, workspace, sandbox, and build metadata + Output local-only health report; no provider request or session resume required + Formats text (default), json + Related /doctor · claw --resume latest /doctor" + .to_string(), + LocalHelpTopic::Acp => "ACP / Zed + Usage claw acp [serve] [--output-format ] + Aliases claw --acp · claw -acp + Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime + Status discoverability only; `serve` is a status alias and does not launch a daemon yet + Formats text (default), json + Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help" + .to_string(), + LocalHelpTopic::Init => "Init + Usage claw init [--output-format ] + Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in the current project + Output list of created vs. skipped files (idempotent: safe to re-run) + Formats text (default), json + Related claw status · claw doctor" + .to_string(), + LocalHelpTopic::State => "State + Usage claw state [--output-format ] + Purpose read .claw/worker-state.json written by the interactive REPL or a one-shot prompt + Output worker id, model, permissions, session reference (text or json) + Formats text (default), json + Produces state `claw` (interactive REPL) or `claw prompt ` (one non-interactive turn) + Observes state `claw state` reads; clawhip/CI may poll this file without HTTP + Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise + Related claw status · ROADMAP #139 (this worker-concept contract)" + .to_string(), + LocalHelpTopic::Export => "Export + Usage claw export [--session ] [--output ] [--output-format ] + Purpose serialize a managed session to JSON for review, transfer, or archival + Defaults --session latest (most recent managed session in .claw/sessions/) + Formats text (default), json + Related /session list · claw --resume latest" + .to_string(), + LocalHelpTopic::Version => "Version + Usage claw version [--output-format ] + Aliases claw --version · claw -V + Purpose print the claw CLI version and build metadata + Formats text (default), json + Related claw doctor (full build/auth/config diagnostic)" + .to_string(), + LocalHelpTopic::SystemPrompt => "System Prompt + Usage claw system-prompt [--cwd ] [--date YYYY-MM-DD] [--output-format ] + Purpose render the resolved system prompt that `claw` would send for the given cwd + date + Options --cwd overrides the workspace dir · --date injects a deterministic date stamp + Formats text (default), json + Related claw doctor · claw dump-manifests" + .to_string(), + LocalHelpTopic::DumpManifests => "Dump Manifests + Usage claw dump-manifests [--manifests-dir ] [--output-format ] + Purpose emit every skill/agent/tool manifest the resolver would load for the current cwd + Options --manifests-dir scopes discovery to a specific directory + Formats text (default), json + Related claw skills · claw agents · claw doctor" + .to_string(), + LocalHelpTopic::BootstrapPlan => "Bootstrap Plan + Usage claw bootstrap-plan [--output-format ] + Purpose list the ordered startup phases the CLI would execute before dispatch + Output phase names (text) or structured phase list (json) — primary output is the plan itself + Formats text (default), json + Related claw doctor · claw status" + .to_string(), + } +} + +pub(crate) fn print_help_topic(topic: LocalHelpTopic) { + println!("{}", render_help_topic(topic)); +} + +pub(crate) fn print_help_to(out: &mut impl Write) -> io::Result<()> { + writeln!(out, "claw v{VERSION}")?; + writeln!(out)?; + writeln!(out, "Usage:")?; + writeln!( + out, + " claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]" + )?; + writeln!(out, " Start the interactive REPL")?; + writeln!( + out, + " claw [--model MODEL] [--output-format text|json] prompt TEXT" + )?; + writeln!(out, " Send one prompt and exit")?; + writeln!( + out, + " claw [--model MODEL] [--output-format text|json] TEXT" + )?; + writeln!(out, " Shorthand non-interactive prompt mode")?; + writeln!( + out, + " claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]" + )?; + writeln!( + out, + " Inspect or maintain a saved session without entering the REPL" + )?; + writeln!(out, " claw help")?; + writeln!(out, " Alias for --help")?; + writeln!(out, " claw version")?; + writeln!(out, " Alias for --version")?; + writeln!(out, " claw status")?; + writeln!( + out, + " Show the current local workspace status snapshot" + )?; + writeln!(out, " claw sandbox")?; + writeln!(out, " Show the current sandbox isolation snapshot")?; + writeln!(out, " claw doctor")?; + writeln!( + out, + " Diagnose local auth, config, workspace, and sandbox health" + )?; + writeln!(out, " claw acp [serve]")?; + writeln!( + out, + " Show ACP/Zed editor integration status (currently unsupported; aliases: --acp, -acp)" + )?; + writeln!(out, " Source of truth: {OFFICIAL_REPO_SLUG}")?; + writeln!( + out, + " Warning: do not `{DEPRECATED_INSTALL_COMMAND}` (deprecated stub)" + )?; + writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?; + writeln!(out, " claw bootstrap-plan")?; + writeln!(out, " claw agents")?; + writeln!(out, " claw mcp")?; + writeln!(out, " claw skills")?; + writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; + writeln!(out, " claw init")?; + writeln!( + out, + " claw export [PATH] [--session SESSION] [--output PATH]" + )?; + writeln!( + out, + " Dump the latest (or named) session as markdown; writes to PATH or stdout" + )?; + writeln!(out)?; + writeln!(out, "Flags:")?; + writeln!( + out, + " --model MODEL Override the active model" + )?; + writeln!( + out, + " --output-format FORMAT Non-interactive output format: text or json" + )?; + writeln!( + out, + " --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)" + )?; + writeln!( + out, + " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" + )?; + writeln!( + out, + " --dangerously-skip-permissions Skip all permission checks" + )?; + writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?; + writeln!( + out, + " --version, -V Print version and build information locally" + )?; + writeln!(out)?; + writeln!(out, "Interactive slash commands:")?; + writeln!(out, "{}", render_slash_command_help_filtered(STUB_COMMANDS))?; + writeln!(out)?; + let resume_commands = commands::resume_supported_slash_commands() + .into_iter() + .map(|spec| match spec.argument_hint { + Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), + None => format!("/{}", spec.name), + }) + .collect::>() + .join(", "); + writeln!(out, "Resume-safe commands: {resume_commands}")?; + writeln!(out)?; + writeln!(out, "Session shortcuts:")?; + writeln!( + out, + " REPL turns auto-save to .claw/sessions/.{PRIMARY_SESSION_EXTENSION}" + )?; + writeln!( + out, + " Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session" + )?; + writeln!( + out, + " Use /session list in the REPL to browse managed sessions" + )?; + writeln!(out, "Examples:")?; + writeln!(out, " claw --model claude-opus \"summarize this repo\"")?; + writeln!( + out, + " claw --output-format json prompt \"explain src/main.rs\"" + )?; + writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?; + writeln!( + out, + " claw --allowedTools read,glob \"summarize Cargo.toml\"" + )?; + writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?; + writeln!( + out, + " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt" + )?; + writeln!(out, " claw agents")?; + writeln!(out, " claw mcp show my-server")?; + writeln!(out, " claw /skills")?; + writeln!(out, " claw doctor")?; + writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?; + writeln!( + out, + " do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub" + )?; + writeln!(out, " claw init")?; + writeln!(out, " claw export")?; + writeln!(out, " claw export conversation.md")?; + Ok(()) +} + +pub(crate) fn slash_command_completion_candidates_with_sessions( + model: &str, + active_session_id: Option<&str>, + recent_session_ids: Vec, +) -> Vec { + let mut completions = BTreeSet::new(); + + for spec in slash_command_specs() { + if STUB_COMMANDS.contains(&spec.name) { + continue; + } + completions.insert(format!("/{}", spec.name)); + for alias in spec.aliases { + if !STUB_COMMANDS.contains(alias) { + completions.insert(format!("/{alias}")); + } + } + } + + for candidate in [ + "/bughunter ", + "/clear --confirm", + "/config ", + "/config env", + "/config hooks", + "/config model", + "/config plugins", + "/mcp ", + "/mcp list", + "/mcp show ", + "/export ", + "/issue ", + "/model ", + "/model opus", + "/model sonnet", + "/model haiku", + "/permissions ", + "/permissions read-only", + "/permissions workspace-write", + "/permissions danger-full-access", + "/plugin list", + "/plugin install ", + "/plugin enable ", + "/plugin disable ", + "/plugin uninstall ", + "/plugin update ", + "/plugins list", + "/pr ", + "/resume ", + "/session list", + "/session switch ", + "/session fork ", + "/teleport ", + "/ultraplan ", + "/agents help", + "/mcp help", + "/skills help", + ] { + completions.insert(candidate.to_string()); + } + + if !model.trim().is_empty() { + completions.insert(format!("/model {}", resolve_model_alias(model))); + completions.insert(format!("/model {model}")); + } + + if let Some(active_session_id) = active_session_id.filter(|value| !value.trim().is_empty()) { + completions.insert(format!("/resume {active_session_id}")); + completions.insert(format!("/session switch {active_session_id}")); + } + + for session_id in recent_session_ids + .into_iter() + .filter(|value| !value.trim().is_empty()) + .take(10) + { + completions.insert(format!("/resume {session_id}")); + completions.insert(format!("/session switch {session_id}")); + } + + completions.into_iter().collect() +} + +fn resolve_model_alias(model: &str) -> &str { + match model { + "opus" => "claude-opus-4-6", + "sonnet" => "claude-sonnet-4-6", + "haiku" => "claude-haiku-4-5-20251213", + _ => model, + } +} diff --git a/rust/crates/rusty-claude-cli/src/format/status.rs b/rust/crates/rusty-claude-cli/src/format/status.rs new file mode 100644 index 0000000000..01f0345cfb --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/format/status.rs @@ -0,0 +1,519 @@ +//! Status, sandbox, and commit formatting functions. +//! +//! Extracted from `main.rs` to keep the top-level module focused on CLI +//! orchestration. + +use std::env; +use std::path::{Path, PathBuf}; + +use runtime::{ConfigLoader, ProjectContext, SandboxStatus, TokenUsage}; +use serde_json::{json, Value}; + +use crate::format::model::{ModelProvenance, ModelSource}; +use crate::DEFAULT_DATE; + +// --------------------------------------------------------------------------- +// Structs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub(crate) struct StatusContext { + pub(crate) cwd: PathBuf, + pub(crate) session_path: Option, + pub(crate) loaded_config_files: usize, + pub(crate) discovered_config_files: usize, + pub(crate) memory_file_count: usize, + pub(crate) project_root: Option, + pub(crate) git_branch: Option, + pub(crate) git_summary: GitWorkspaceSummary, + pub(crate) sandbox_status: SandboxStatus, + /// #143: when `.claw.json` (or another loaded config file) fails to parse, + /// we capture the parse error here and still populate every field that + /// doesn't depend on runtime config (workspace, git, sandbox defaults, + /// discovery counts). Top-level JSON output then reports + /// `status: "degraded"` so claws can distinguish "status ran but config + /// is broken" from "status ran cleanly". + pub(crate) config_load_error: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct StatusUsage { + pub(crate) message_count: usize, + pub(crate) turns: u32, + pub(crate) latest: TokenUsage, + pub(crate) cumulative: TokenUsage, + pub(crate) estimated_tokens: usize, +} + +#[allow(clippy::struct_field_names)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct GitWorkspaceSummary { + pub(crate) changed_files: usize, + pub(crate) staged_files: usize, + pub(crate) unstaged_files: usize, + pub(crate) untracked_files: usize, + pub(crate) conflicted_files: usize, +} + +impl GitWorkspaceSummary { + pub(crate) fn is_clean(self) -> bool { + self.changed_files == 0 + } + + pub(crate) fn headline(self) -> String { + if self.is_clean() { + "clean".to_string() + } else { + let mut details = Vec::new(); + if self.staged_files > 0 { + details.push(format!("{} staged", self.staged_files)); + } + if self.unstaged_files > 0 { + details.push(format!("{} unstaged", self.unstaged_files)); + } + if self.untracked_files > 0 { + details.push(format!("{} untracked", self.untracked_files)); + } + if self.conflicted_files > 0 { + details.push(format!("{} conflicted", self.conflicted_files)); + } + format!( + "dirty · {} files · {}", + self.changed_files, + details.join(", ") + ) + } + } +} + +// --------------------------------------------------------------------------- +// Compaction notice +// --------------------------------------------------------------------------- + +pub(crate) fn format_auto_compaction_notice(removed: usize) -> String { + format!("[auto-compacted: removed {removed} messages]") +} + +// --------------------------------------------------------------------------- +// Git helpers +// --------------------------------------------------------------------------- + +pub(crate) fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { + parse_git_status_metadata_for( + &env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + status, + ) +} + +pub(crate) fn parse_git_status_branch(status: Option<&str>) -> Option { + let status = status?; + let first_line = status.lines().next()?; + let line = first_line.strip_prefix("## ")?; + if line.starts_with("HEAD") { + return Some("detached HEAD".to_string()); + } + let branch = line.split(['.', ' ']).next().unwrap_or_default().trim(); + if branch.is_empty() { + None + } else { + Some(branch.to_string()) + } +} + +pub(crate) fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary { + let mut summary = GitWorkspaceSummary::default(); + let Some(status) = status else { + return summary; + }; + + for line in status.lines() { + if line.starts_with("## ") || line.trim().is_empty() { + continue; + } + + summary.changed_files += 1; + let mut chars = line.chars(); + let index_status = chars.next().unwrap_or(' '); + let worktree_status = chars.next().unwrap_or(' '); + + if index_status == '?' && worktree_status == '?' { + summary.untracked_files += 1; + continue; + } + + if index_status != ' ' { + summary.staged_files += 1; + } + if worktree_status != ' ' { + summary.unstaged_files += 1; + } + if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A')) + || index_status == 'U' + || worktree_status == 'U' + { + summary.conflicted_files += 1; + } + } + + summary +} + +pub(crate) fn resolve_git_branch_for(cwd: &Path) -> Option { + let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?; + let branch = branch.trim(); + if !branch.is_empty() { + return Some(branch.to_string()); + } + + let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?; + let fallback = fallback.trim(); + if fallback.is_empty() { + None + } else if fallback == "HEAD" { + Some("detached HEAD".to_string()) + } else { + Some(fallback.to_string()) + } +} + +pub(crate) fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option { + let output = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +pub(crate) fn find_git_root_in(cwd: &Path) -> Result> { + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(cwd) + .output()?; + if !output.status.success() { + return Err("not a git repository".into()); + } + let path = String::from_utf8(output.stdout)?.trim().to_string(); + if path.is_empty() { + return Err("empty git root".into()); + } + Ok(PathBuf::from(path)) +} + +pub(crate) fn parse_git_status_metadata_for( + cwd: &Path, + status: Option<&str>, +) -> (Option, Option) { + let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status)); + let project_root = find_git_root_in(cwd).ok(); + (project_root, branch) +} + +// --------------------------------------------------------------------------- +// JSON builders +// --------------------------------------------------------------------------- + +pub(crate) fn status_json_value( + model: Option<&str>, + usage: StatusUsage, + permission_mode: &str, + context: &StatusContext, + provenance: Option<&ModelProvenance>, +) -> Value { + // #143: top-level `status` marker so claws can distinguish + // a clean run from a degraded run (config parse failed but other fields + // are still populated). `config_load_error` carries the parse-error string + // when present; it's a string rather than a typed object in Phase 1 and + // will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44). + let degraded = context.config_load_error.is_some(); + let model_source = provenance.map(|p| p.source.as_str()); + let model_raw = provenance.and_then(|p| p.raw.clone()); + json!({ + "kind": "status", + "status": if degraded { "degraded" } else { "ok" }, + "config_load_error": context.config_load_error, + "model": model, + "model_source": model_source, + "model_raw": model_raw, + "permission_mode": permission_mode, + "usage": { + "messages": usage.message_count, + "turns": usage.turns, + "latest_total": usage.latest.total_tokens(), + "cumulative_input": usage.cumulative.input_tokens, + "cumulative_output": usage.cumulative.output_tokens, + "cumulative_total": usage.cumulative.total_tokens(), + "estimated_tokens": usage.estimated_tokens, + }, + "workspace": { + "cwd": context.cwd, + "project_root": context.project_root, + "git_branch": context.git_branch, + "git_state": context.git_summary.headline(), + "changed_files": context.git_summary.changed_files, + "staged_files": context.git_summary.staged_files, + "unstaged_files": context.git_summary.unstaged_files, + "untracked_files": context.git_summary.untracked_files, + "session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()), + "session_id": context.session_path.as_ref().and_then(|path| { + // Session files are named .jsonl directly under + // .claw/sessions/. Extract the stem (drop the .jsonl extension). + path.file_stem().map(|n| n.to_string_lossy().into_owned()) + }), + "loaded_config_files": context.loaded_config_files, + "discovered_config_files": context.discovered_config_files, + "memory_file_count": context.memory_file_count, + }, + "sandbox": { + "enabled": context.sandbox_status.enabled, + "active": context.sandbox_status.active, + "supported": context.sandbox_status.supported, + "in_container": context.sandbox_status.in_container, + "requested_namespace": context.sandbox_status.requested.namespace_restrictions, + "active_namespace": context.sandbox_status.namespace_active, + "requested_network": context.sandbox_status.requested.network_isolation, + "active_network": context.sandbox_status.network_active, + "filesystem_mode": context.sandbox_status.filesystem_mode.as_str(), + "filesystem_active": context.sandbox_status.filesystem_active, + "allowed_mounts": context.sandbox_status.allowed_mounts, + "markers": context.sandbox_status.container_markers, + "fallback_reason": context.sandbox_status.fallback_reason, + } + }) +} + +pub(crate) fn status_context( + session_path: Option<&Path>, +) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered_config_files = loader.discover().len(); + // #143: degrade gracefully on config parse failure rather than hard-fail. + // `claw doctor` already does this; `claw status` now matches that contract + // so that one malformed `mcpServers.*` entry doesn't take down the whole + // health surface (workspace, git, model, permission, sandbox can still be + // reported independently). + let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() { + Ok(runtime_config) => ( + runtime_config.loaded_entries().len(), + runtime::resolve_sandbox_status(runtime_config.sandbox(), &cwd), + None, + ), + Err(err) => ( + 0, + // Fall back to defaults for sandbox resolution so claws still see + // a populated sandbox section instead of a missing field. Defaults + // produce the same output as a runtime config with no sandbox + // overrides, which is the right degraded-mode shape: we cannot + // report what the user *intended*, only what is actually in effect. + runtime::resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd), + Some(err.to_string()), + ), + }; + let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; + let (project_root, git_branch) = + parse_git_status_metadata(project_context.git_status.as_deref()); + let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); + Ok(StatusContext { + cwd, + session_path: session_path.map(Path::to_path_buf), + loaded_config_files, + discovered_config_files, + memory_file_count: project_context.instruction_files.len(), + project_root, + git_branch, + git_summary, + sandbox_status, + config_load_error, + }) +} + +// --------------------------------------------------------------------------- +// Text reports +// --------------------------------------------------------------------------- + +pub(crate) fn format_status_report( + model: &str, + usage: StatusUsage, + permission_mode: &str, + context: &StatusContext, + // #148: optional model provenance to surface in a `Model source` line. + // Callers without provenance (legacy resume paths) pass None and the + // source line is omitted for backward compat. + provenance: Option<&ModelProvenance>, +) -> String { + // #143: if config failed to parse, surface a degraded banner at the top + // of the text report so humans see the parse error before the body, while + // the body below still reports everything that could be resolved without + // config (workspace, git, sandbox defaults, etc.). + let status_line = if context.config_load_error.is_some() { + "Status (degraded)" + } else { + "Status" + }; + let mut blocks: Vec = Vec::new(); + if let Some(err) = context.config_load_error.as_deref() { + blocks.push(format!( + "Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun" + )); + } + // #148: render Model source line after Model, showing where the string + // came from (flag / env / config / default) and the raw input if any. + let model_source_line = provenance + .map(|p| match &p.raw { + Some(raw) if raw != model => { + format!("\n Model source {} (raw: {raw})", p.source.as_str()) + } + Some(_) => format!("\n Model source {}", p.source.as_str()), + None => format!("\n Model source {}", p.source.as_str()), + }) + .unwrap_or_default(); + blocks.extend([ + format!( + "{status_line} + Model {model}{model_source_line} + Permission mode {permission_mode} + Messages {} + Turns {} + Estimated tokens {}", + usage.message_count, usage.turns, usage.estimated_tokens, + ), + format!( + "Usage + Latest total {} + Cumulative input {} + Cumulative output {} + Cumulative total {}", + usage.latest.total_tokens(), + usage.cumulative.input_tokens, + usage.cumulative.output_tokens, + usage.cumulative.total_tokens(), + ), + format!( + "Workspace + Cwd {} + Project root {} + Git branch {} + Git state {} + Changed files {} + Staged {} + Unstaged {} + Untracked {} + Session {} + Config files loaded {}/{} + Memory files {} + Suggested flow /status → /diff → /commit", + context.cwd.display(), + context + .project_root + .as_ref() + .map_or_else(|| "unknown".to_string(), |path| path.display().to_string()), + context.git_branch.as_deref().unwrap_or("unknown"), + context.git_summary.headline(), + context.git_summary.changed_files, + context.git_summary.staged_files, + context.git_summary.unstaged_files, + context.git_summary.untracked_files, + context.session_path.as_ref().map_or_else( + || "live-repl".to_string(), + |path| path.display().to_string() + ), + context.loaded_config_files, + context.discovered_config_files, + context.memory_file_count, + ), + format_sandbox_report(&context.sandbox_status), + ]); + blocks.join("\n\n") +} + +pub(crate) fn format_sandbox_report(status: &SandboxStatus) -> String { + format!( + "Sandbox + Enabled {} + Active {} + Supported {} + In container {} + Requested ns {} + Active ns {} + Requested net {} + Active net {} + Filesystem mode {} + Filesystem active {} + Allowed mounts {} + Markers {} + Fallback reason {}", + status.enabled, + status.active, + status.supported, + status.in_container, + status.requested.namespace_restrictions, + status.namespace_active, + status.requested.network_isolation, + status.network_active, + status.filesystem_mode.as_str(), + status.filesystem_active, + if status.allowed_mounts.is_empty() { + "".to_string() + } else { + status.allowed_mounts.join(", ") + }, + if status.container_markers.is_empty() { + "".to_string() + } else { + status.container_markers.join(", ") + }, + status + .fallback_reason + .clone() + .unwrap_or_else(|| "".to_string()), + ) +} + +pub(crate) fn format_commit_preflight_report( + branch: Option<&str>, + summary: GitWorkspaceSummary, +) -> String { + format!( + "Commit + Result ready + Branch {} + Workspace {} + Changed files {} + Action create a git commit from the current workspace changes", + branch.unwrap_or("unknown"), + summary.headline(), + summary.changed_files, + ) +} + +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 sandbox_json_value(status: &SandboxStatus) -> Value { + json!({ + "kind": "sandbox", + "enabled": status.enabled, + "active": status.active, + "supported": status.supported, + "in_container": status.in_container, + "requested_namespace": status.requested.namespace_restrictions, + "active_namespace": status.namespace_active, + "requested_network": status.requested.network_isolation, + "active_network": status.network_active, + "filesystem_mode": status.filesystem_mode.as_str(), + "filesystem_active": status.filesystem_active, + "allowed_mounts": status.allowed_mounts, + "markers": status.container_markers, + "fallback_reason": status.fallback_reason, + }) +} diff --git a/rust/crates/rusty-claude-cli/src/format/tool_fmt.rs b/rust/crates/rusty-claude-cli/src/format/tool_fmt.rs new file mode 100644 index 0000000000..467434e3b5 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/format/tool_fmt.rs @@ -0,0 +1,489 @@ +use serde_json; + +use std::fmt::Write as _; + +use crate::tui::theme::Theme; +use crate::tui::tool_panel::{collapse_tool_output, ToolDisplayConfig}; + +const DISPLAY_TRUNCATION_NOTICE: &str = + "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m"; +const READ_DISPLAY_MAX_LINES: usize = 80; +const READ_DISPLAY_MAX_CHARS: usize = 6_000; + +pub(crate) fn format_tool_call_start(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + + let detail = match name { + "bash" | "Bash" => format_bash_call(&parsed), + "read_file" | "Read" => { + let path = extract_tool_path(&parsed); + format!("{}📄 Reading {path}…{}", Theme::DIM, Theme::RESET) + } + "write_file" | "Write" => { + let path = extract_tool_path(&parsed); + let lines = parsed + .get("content") + .and_then(|value| value.as_str()) + .map_or(0, |content| content.lines().count()); + format!( + "{}✏️ Writing {path}{} {}({lines} lines){}", + Theme::SUCCESS_BOLD, + Theme::RESET, + Theme::DIM, + Theme::RESET + ) + } + "edit_file" | "Edit" => { + let path = extract_tool_path(&parsed); + let old_value = parsed + .get("old_string") + .or_else(|| parsed.get("oldString")) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let new_value = parsed + .get("new_string") + .or_else(|| parsed.get("newString")) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + format!( + "{}📝 Editing {path}{}{}", + Theme::WARNING, + Theme::RESET, + format_patch_preview(old_value, new_value) + .map(|preview| format!("\n{preview}")) + .unwrap_or_default() + ) + } + "glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed), + "grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed), + "web_search" | "WebSearch" => parsed + .get("query") + .and_then(|value| value.as_str()) + .unwrap_or("?") + .to_string(), + _ => summarize_tool_payload(input), + }; + + let border = "─".repeat(name.len() + 8); + format!( + "{}╭─ {}{}{}{} ─╮{}\n{}{} {detail}\n{}╰{border}╯{}", + Theme::MUTED, + Theme::HIGHLIGHT, + name, + Theme::RESET, + Theme::MUTED, + Theme::RESET, + Theme::MUTED, + Theme::RESET, + Theme::MUTED, + Theme::RESET, + ) +} + +pub(crate) fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { + let icon = if is_error { + format!("{}{}{}", Theme::ERROR_BRIGHT, "✗", Theme::RESET) + } else { + format!("{}{}{}", Theme::SUCCESS_BOLD, "✓", Theme::RESET) + }; + if is_error { + let summary = truncate_for_summary(output.trim(), 160); + return if summary.is_empty() { + format!("{icon} {}{}{}", Theme::MUTED, name, Theme::RESET) + } else { + format!( + "{icon} {}{}{}\n{}{}{}", + Theme::MUTED, + name, + Theme::RESET, + Theme::ERROR, + summary, + Theme::RESET + ) + }; + } + + let parsed: serde_json::Value = + serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string())); + match name { + "bash" | "Bash" => format_bash_result(&icon, &parsed), + "read_file" | "Read" => format_read_result(&icon, &parsed), + "write_file" | "Write" => format_write_result(&icon, &parsed), + "edit_file" | "Edit" => format_edit_result(&icon, &parsed), + "glob_search" | "Glob" => format_glob_result(&icon, &parsed), + "grep_search" | "Grep" => format_grep_result(&icon, &parsed), + _ => format_generic_tool_result(&icon, name, &parsed), + } +} + +pub(crate) fn extract_tool_path(parsed: &serde_json::Value) -> String { + parsed + .get("file_path") + .or_else(|| parsed.get("filePath")) + .or_else(|| parsed.get("path")) + .and_then(|value| value.as_str()) + .unwrap_or("?") + .to_string() +} + +pub(crate) fn format_search_start(label: &str, parsed: &serde_json::Value) -> String { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("{label} {pattern}\n{}{scope}{}", Theme::DIM, Theme::RESET) +} + +pub(crate) fn format_patch_preview(old_value: &str, new_value: &str) -> Option { + if old_value.is_empty() && new_value.is_empty() { + return None; + } + Some(format!( + "{}- {}{}\n{}+ {}{}", + Theme::ERROR, + truncate_for_summary(first_visible_line(old_value), 72), + Theme::RESET, + Theme::SUCCESS, + truncate_for_summary(first_visible_line(new_value), 72), + Theme::RESET, + )) +} + +pub(crate) fn format_bash_call(parsed: &serde_json::Value) -> String { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + String::new() + } else { + format!( + "{} $ {} {}", + Theme::COMMAND_BG, + truncate_for_summary(command, 160), + Theme::RESET, + ) + } +} + +pub(crate) fn first_visible_line(text: &str) -> &str { + text.lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(text) +} + +pub(crate) fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { + let mut lines = vec![format!("{icon} {}{}{}", Theme::MUTED, "bash", Theme::RESET)]; + if let Some(task_id) = parsed + .get("backgroundTaskId") + .and_then(|value| value.as_str()) + { + write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string"); + } else if let Some(status) = parsed + .get("returnCodeInterpretation") + .and_then(|value| value.as_str()) + .filter(|status| !status.is_empty()) + { + write!(&mut lines[0], " {status}").expect("write to string"); + } + + let config = ToolDisplayConfig::default(); + if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { + if !stdout.trim().is_empty() { + let collapsed = collapse_tool_output(stdout, "bash", false, &config); + lines.push(collapsed.visible); + } + } + if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) { + if !stderr.trim().is_empty() { + let collapsed = collapse_tool_output(stderr, "bash", true, &config); + lines.push(format!( + "{}{}{}", + Theme::ERROR, + collapsed.visible, + Theme::RESET + )); + } + } + + lines.join("\n\n") +} + +pub(crate) fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { + let file = parsed.get("file").unwrap_or(parsed); + let path = extract_tool_path(file); + let start_line = file + .get("startLine") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1); + let num_lines = file + .get("numLines") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let total_lines = file + .get("totalLines") + .and_then(serde_json::Value::as_u64) + .unwrap_or(num_lines); + let content = file + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let end_line = start_line.saturating_add(num_lines.saturating_sub(1)); + + format!( + "{icon} {}📄 Read {path} (lines {}-{} of {}){}\n{}", + Theme::DIM, + start_line, + end_line.max(start_line), + total_lines, + Theme::RESET, + truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS) + ) +} + +pub(crate) fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { + let path = extract_tool_path(parsed); + let kind = parsed + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("write"); + let line_count = parsed + .get("content") + .and_then(|value| value.as_str()) + .map_or(0, |content| content.lines().count()); + format!( + "{icon} {}✏️ {} {path}{} {}({line_count} lines){}", + Theme::SUCCESS_BOLD, + if kind == "create" { "Wrote" } else { "Updated" }, + Theme::RESET, + Theme::DIM, + Theme::RESET, + ) +} + +pub(crate) fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option { + let hunks = parsed.get("structuredPatch")?.as_array()?; + let mut preview = Vec::new(); + for hunk in hunks.iter().take(2) { + let lines = hunk.get("lines")?.as_array()?; + for line in lines.iter().filter_map(|value| value.as_str()).take(6) { + match line.chars().next() { + Some('+') => preview.push(format!("{}{}{}", Theme::SUCCESS, line, Theme::RESET)), + Some('-') => preview.push(format!("{}{}{}", Theme::ERROR, line, Theme::RESET)), + _ => preview.push(line.to_string()), + } + } + } + if preview.is_empty() { + None + } else { + Some(preview.join("\n")) + } +} + +pub(crate) fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { + let path = extract_tool_path(parsed); + let suffix = if parsed + .get("replaceAll") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + " (replace all)" + } else { + "" + }; + let preview = format_structured_patch_preview(parsed).or_else(|| { + let old_value = parsed + .get("oldString") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let new_value = parsed + .get("newString") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + format_patch_preview(old_value, new_value) + }); + + match preview { + Some(preview) => format!( + "{icon} {}📝 Edited {path}{suffix}{}\n{preview}", + Theme::WARNING, + Theme::RESET + ), + None => format!( + "{icon} {}📝 Edited {path}{suffix}{}", + Theme::WARNING, + Theme::RESET + ), + } +} + +pub(crate) fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { + let num_files = parsed + .get("numFiles") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let filenames = parsed + .get("filenames") + .and_then(|value| value.as_array()) + .map(|files| { + files + .iter() + .filter_map(|value| value.as_str()) + .take(8) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if filenames.is_empty() { + format!( + "{icon} {}glob_search{} matched {num_files} files", + Theme::MUTED, + Theme::RESET + ) + } else { + format!( + "{icon} {}glob_search{} matched {num_files} files\n{filenames}", + Theme::MUTED, + Theme::RESET + ) + } +} + +pub(crate) fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { + let num_matches = parsed + .get("numMatches") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let num_files = parsed + .get("numFiles") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let content = parsed + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let filenames = parsed + .get("filenames") + .and_then(|value| value.as_array()) + .map(|files| { + files + .iter() + .filter_map(|value| value.as_str()) + .take(8) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + let summary = format!( + "{icon} {}grep_search{} {num_matches} matches across {num_files} files", + Theme::MUTED, + Theme::RESET, + ); + if !content.trim().is_empty() { + let collapsed = + collapse_tool_output(content, "grep_search", false, &ToolDisplayConfig::default()); + format!("{summary}\n{}", collapsed.visible) + } else if !filenames.is_empty() { + format!("{summary}\n{filenames}") + } else { + summary + } +} + +pub(crate) fn format_generic_tool_result( + icon: &str, + name: &str, + parsed: &serde_json::Value, +) -> String { + let rendered_output = match parsed { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Null => String::new(), + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string()) + } + _ => parsed.to_string(), + }; + let collapsed = + collapse_tool_output(&rendered_output, name, false, &ToolDisplayConfig::default()); + let preview = collapsed.visible; + + if preview.is_empty() { + format!("{icon} {}{}{}", Theme::MUTED, name, Theme::RESET) + } else if preview.contains('\n') { + format!("{icon} {}{}{}\n{preview}", Theme::MUTED, name, Theme::RESET) + } else { + format!("{icon} {}{}:{} {preview}", Theme::MUTED, name, Theme::RESET) + } +} + +pub(crate) fn summarize_tool_payload(payload: &str) -> String { + let compact = match serde_json::from_str::(payload) { + Ok(value) => value.to_string(), + Err(_) => payload.trim().to_string(), + }; + truncate_for_summary(&compact, 96) +} + +pub(crate) fn truncate_for_summary(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let truncated = chars.by_ref().take(limit).collect::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +pub(crate) fn truncate_output_for_display( + content: &str, + max_lines: usize, + max_chars: usize, +) -> String { + let original = content.trim_end_matches('\n'); + if original.is_empty() { + return String::new(); + } + + let mut preview_lines = Vec::new(); + let mut used_chars = 0usize; + let mut truncated = false; + + for (index, line) in original.lines().enumerate() { + if index >= max_lines { + truncated = true; + break; + } + + let newline_cost = usize::from(!preview_lines.is_empty()); + let available = max_chars.saturating_sub(used_chars + newline_cost); + if available == 0 { + truncated = true; + break; + } + + let line_chars = line.chars().count(); + if line_chars > available { + preview_lines.push(line.chars().take(available).collect::()); + truncated = true; + break; + } + + preview_lines.push(line.to_string()); + used_chars += newline_cost + line_chars; + } + + let mut preview = preview_lines.join("\n"); + if truncated { + if !preview.is_empty() { + preview.push('\n'); + } + preview.push_str(DISPLAY_TRUNCATION_NOTICE); + } + preview +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index fa50ededb7..7b9f1e3859 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -6,9 +6,23 @@ clippy::unnecessary_wraps, clippy::unused_self )] +mod app; +mod args; +mod cli_commands; +mod format; mod init; mod input; mod render; +mod tui; + +// Re-exports from extracted format modules so existing code still compiles. +// After Phase 0 is complete, this import brings all extracted items into scope +// as if they were still defined in main.rs. +use args::*; +use format::*; +// Selective imports from app — avoid conflicting with format::* names +use app::*; +use cli_commands::*; use std::collections::BTreeSet; use std::env; @@ -58,122 +72,26 @@ use tools::{ const DEFAULT_MODEL: &str = "claude-opus-4-6"; -/// #148: Model provenance for `claw status` JSON/text output. Records where -/// the resolved model string came from so claws don't have to re-read argv -/// to audit whether their `--model` flag was honored vs falling back to env -/// or config or default. -#[derive(Debug, Clone, PartialEq, Eq)] -enum ModelSource { - /// Explicit `--model` / `--model=` CLI flag. - Flag, - /// ANTHROPIC_MODEL environment variable (when no flag was passed). - Env, - /// `model` key in `.claw.json` / `.claw/settings.json` (when neither - /// flag nor env set it). - Config, - /// Compiled-in DEFAULT_MODEL fallback. - Default, -} - -impl ModelSource { - fn as_str(&self) -> &'static str { - match self { - ModelSource::Flag => "flag", - ModelSource::Env => "env", - ModelSource::Config => "config", - ModelSource::Default => "default", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ModelProvenance { - /// Resolved model string (after alias expansion). - resolved: String, - /// Raw user input before alias resolution. None when source is Default. - raw: Option, - /// Where the resolved model string originated. - source: ModelSource, -} - -impl ModelProvenance { - fn default_fallback() -> Self { - Self { - resolved: DEFAULT_MODEL.to_string(), - raw: None, - source: ModelSource::Default, - } - } - - fn from_flag(raw: &str) -> Self { - Self { - resolved: resolve_model_alias_with_config(raw), - raw: Some(raw.to_string()), - source: ModelSource::Flag, - } - } - - fn from_env_or_config_or_default(cli_model: &str) -> Self { - // Only called when no --model flag was passed. Probe env first, - // then config, else fall back to default. Mirrors the logic in - // resolve_repl_model() but captures the source. - if cli_model != DEFAULT_MODEL { - // Already resolved from some prior path; treat as flag. - return Self { - resolved: cli_model.to_string(), - raw: Some(cli_model.to_string()), - source: ModelSource::Flag, - }; - } - if let Some(env_model) = env::var("ANTHROPIC_MODEL") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - { - return Self { - resolved: resolve_model_alias_with_config(&env_model), - raw: Some(env_model), - source: ModelSource::Env, - }; - } - if let Some(config_model) = config_model_for_current_dir() { - return Self { - resolved: resolve_model_alias_with_config(&config_model), - raw: Some(config_model), - source: ModelSource::Config, - }; - } - Self::default_fallback() - } -} - -fn max_tokens_for_model(model: &str) -> u32 { - if model.contains("opus") { - 32_000 - } else { - 64_000 - } -} // Build-time constants injected by build.rs (fall back to static values when // build.rs hasn't run, e.g. in doc-test or unusual toolchain environments). -const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") { +pub(crate) const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") { Some(d) => d, None => "unknown", }; const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; -const VERSION: &str = env!("CARGO_PKG_VERSION"); -const BUILD_TARGET: Option<&str> = option_env!("TARGET"); -const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); -const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); -const POST_TOOL_STALL_TIMEOUT: Duration = Duration::from_secs(10); -const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; -const LEGACY_SESSION_EXTENSION: &str = "json"; -const OFFICIAL_REPO_URL: &str = "https://github.com/ultraworkers/claw-code"; -const OFFICIAL_REPO_SLUG: &str = "ultraworkers/claw-code"; -const DEPRECATED_INSTALL_COMMAND: &str = "cargo install claw-code"; -const LATEST_SESSION_REFERENCE: &str = "latest"; -const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"]; -const CLI_OPTION_SUGGESTIONS: &[&str] = &[ +pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub(crate) const BUILD_TARGET: Option<&str> = option_env!("TARGET"); +pub(crate) const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); +pub(crate) const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); +pub(crate) const POST_TOOL_STALL_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; +pub(crate) const LEGACY_SESSION_EXTENSION: &str = "json"; +pub(crate) const OFFICIAL_REPO_URL: &str = "https://github.com/ultraworkers/claw-code"; +pub(crate) const OFFICIAL_REPO_SLUG: &str = "ultraworkers/claw-code"; +pub(crate) const DEPRECATED_INSTALL_COMMAND: &str = "cargo install claw-code"; +pub(crate) const LATEST_SESSION_REFERENCE: &str = "latest"; +pub(crate) const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"]; +pub(crate) const CLI_OPTION_SUGGESTIONS: &[&str] = &[ "--help", "-h", "--version", @@ -193,12 +111,37 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[ "-p", ]; -type AllowedToolSet = BTreeSet; -type RuntimePluginStateBuildOutput = ( +pub(crate) type AllowedToolSet = BTreeSet; +pub(crate) type RuntimePluginStateBuildOutput = ( Option>>, Vec, ); +#[allow(clippy::trivially_copy_pass_by_ref)] +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) +} + fn main() { if let Err(error) = run() { let message = error.to_string(); @@ -228,8 +171,10 @@ fn main() { // don't need to regex-scrape the prose. let kind = classify_error_kind(&message); if message.contains("`claw --help`") { - eprintln!("[error-kind: {kind}] -error: {message}"); + eprintln!( + "[error-kind: {kind}] +error: {message}" + ); } else { eprintln!( "[error-kind: {kind}] @@ -243,55 +188,6 @@ Run `claw --help` for usage." } } -/// #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. -fn classify_error_kind(message: &str) -> &'static str { - // Check specific patterns first (more specific before generic) - if message.contains("missing Anthropic credentials") { - "missing_credentials" - } else if message.contains("Manifest source files are missing") { - "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("failed to restore session") { - "session_load_failed" - } else if message.contains("no managed sessions found") { - "no_managed_sessions" - } else if message.contains("unrecognized argument") || message.contains("unknown option") { - "cli_parse" - } 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") { - "api_http_error" - } 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. -fn split_error_hint(message: &str) -> (String, Option) { - match message.split_once('\n') { - Some((short, hint)) => (short.to_string(), Some(hint.trim().to_string())), - None => (message.to_string(), None), - } -} - /// Read piped stdin content when stdin is not a terminal. /// /// Returns `None` when stdin is attached to a terminal (interactive REPL use), @@ -372,7 +268,12 @@ fn run() -> Result<(), Box> { model_flag_raw, permission_mode, output_format, - } => print_status_snapshot(&model, model_flag_raw.as_deref(), permission_mode, output_format)?, + } => print_status_snapshot( + &model, + model_flag_raw.as_deref(), + permission_mode, + output_format, + )?, CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?, CliAction::Prompt { prompt, @@ -398,7 +299,7 @@ fn run() -> Result<(), Box> { None }; let effective_prompt = merge_prompt_with_stdin(&prompt, stdin_context.as_deref()); - let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?; + let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode, None)?; cli.set_reasoning_effort(reasoning_effort); cli.run_turn_with_output(&effective_prompt, output_format, compact)?; } @@ -412,19 +313,17 @@ fn run() -> Result<(), Box> { CliAction::Config { section, output_format, - } => { - match output_format { - CliOutputFormat::Text => { - println!("{}", render_config_report(section.as_deref())?); - } - CliOutputFormat::Json => { - println!( - "{}", - serde_json::to_string_pretty(&render_config_json(section.as_deref())?)? - ); - } + } => match output_format { + CliOutputFormat::Text => { + println!("{}", render_config_report(section.as_deref())?); } - } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&render_config_json(section.as_deref())?)? + ); + } + }, CliAction::Diff { output_format } => match output_format { CliOutputFormat::Text => { println!("{}", render_diff_report()?); @@ -466,147 +365,6 @@ fn run() -> Result<(), Box> { Ok(()) } -#[derive(Debug, Clone, PartialEq, Eq)] -enum CliAction { - DumpManifests { - output_format: CliOutputFormat, - manifests_dir: Option, - }, - BootstrapPlan { - output_format: CliOutputFormat, - }, - Agents { - args: Option, - output_format: CliOutputFormat, - }, - Mcp { - args: Option, - output_format: CliOutputFormat, - }, - Skills { - args: Option, - output_format: CliOutputFormat, - }, - Plugins { - action: Option, - target: Option, - output_format: CliOutputFormat, - }, - PrintSystemPrompt { - cwd: PathBuf, - date: String, - output_format: CliOutputFormat, - }, - Version { - output_format: CliOutputFormat, - }, - ResumeSession { - session_path: PathBuf, - commands: Vec, - output_format: CliOutputFormat, - }, - Status { - model: String, - // #148: raw `--model` flag input (pre-alias-resolution), if any. - // None means no flag was supplied; env/config/default fallback is - // resolved inside `print_status_snapshot`. - model_flag_raw: Option, - permission_mode: PermissionMode, - output_format: CliOutputFormat, - }, - 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, - }, - /// Run JSON-RPC server over stdin/stdout for agent integration. - Rpc, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LocalHelpTopic { - Status, - Sandbox, - Doctor, - Acp, - // #141: extend the local-help pattern to every subcommand so - // `claw --help` has one consistent contract. - Init, - State, - Export, - Version, - SystemPrompt, - DumpManifests, - BootstrapPlan, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CliOutputFormat { - Text, - Json, -} - -impl CliOutputFormat { - fn parse(value: &str) -> Result { - match value { - "text" => Ok(Self::Text), - "json" => Ok(Self::Json), - other => Err(format!( - "unsupported value for --output-format: {other} (expected text or json)" - )), - } - } -} - -#[allow(clippy::too_many_lines)] fn parse_args(args: &[String]) -> Result { let mut model = DEFAULT_MODEL.to_string(); // #148: when user passes --model/--model=, capture the raw input so we @@ -1330,65 +1088,6 @@ fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&' ranked_suggestions(input, candidates).into_iter().next() } - -fn suggest_similar_subcommand(input: &str) -> Option> { - const KNOWN_SUBCOMMANDS: &[&str] = &[ - "help", - "version", - "status", - "sandbox", - "doctor", - "state", - "dump-manifests", - "bootstrap-plan", - "agents", - "mcp", - "skills", - "system-prompt", - "acp", - "init", - "export", - "prompt", - ]; - - let normalized_input = input.to_ascii_lowercase(); - let mut ranked = KNOWN_SUBCOMMANDS - .iter() - .filter_map(|candidate| { - let normalized_candidate = candidate.to_ascii_lowercase(); - let distance = levenshtein_distance(&normalized_input, &normalized_candidate); - let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4; - let substring_match = normalized_candidate.contains(&normalized_input) - || normalized_input.contains(&normalized_candidate); - ((distance <= 2) || prefix_match || substring_match) - .then_some((distance, *candidate)) - }) - .collect::>(); - ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); - ranked.dedup_by(|left, right| left.1 == right.1); - let suggestions = ranked - .into_iter() - .map(|(_, candidate)| candidate.to_string()) - .take(3) - .collect::>(); - (!suggestions.is_empty()).then_some(suggestions) -} - -fn common_prefix_len(left: &str, right: &str) -> usize { - left.chars() - .zip(right.chars()) - .take_while(|(l, r)| l == r) - .count() -} - - -fn looks_like_subcommand_typo(input: &str) -> bool { - !input.is_empty() - && input - .chars() - .all(|ch| ch.is_ascii_alphabetic() || ch == '-') -} - fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> { let normalized_input = input.trim_start_matches('/').to_ascii_lowercase(); let mut ranked = candidates @@ -1412,7596 +1111,1220 @@ fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str .collect() } -fn levenshtein_distance(left: &str, right: &str) -> usize { - if left.is_empty() { - return right.chars().count(); - } - if right.is_empty() { - return left.chars().count(); - } - - let right_chars = right.chars().collect::>(); - let mut previous = (0..=right_chars.len()).collect::>(); - let mut current = vec![0; right_chars.len() + 1]; - - for (left_index, left_char) in left.chars().enumerate() { - current[0] = left_index + 1; - for (right_index, right_char) in right_chars.iter().enumerate() { - let substitution_cost = usize::from(left_char != *right_char); - current[right_index + 1] = (previous[right_index + 1] + 1) - .min(current[right_index] + 1) - .min(previous[right_index] + substitution_cost); - } - previous.clone_from(¤t); - } - - previous[right_chars.len()] -} - -fn resolve_model_alias(model: &str) -> &str { - match model { - "opus" => "claude-opus-4-6", - "sonnet" => "claude-sonnet-4-6", - "haiku" => "claude-haiku-4-5-20251213", - _ => model, - } -} +const DUMP_MANIFESTS_OVERRIDE_HINT: &str = + "Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`."; -/// Resolve a model name through user-defined config aliases first, then fall -/// back to the built-in alias table. This is the entry point used wherever a -/// user-supplied model string is about to be dispatched to a provider. -fn resolve_model_alias_with_config(model: &str) -> String { - let trimmed = model.trim(); - if let Some(resolved) = config_alias_for_current_dir(trimmed) { - return resolve_model_alias(&resolved).to_string(); - } - resolve_model_alias(trimmed).to_string() +fn version_json_value() -> serde_json::Value { + json!({ + "kind": "version", + "message": render_version_report(), + "version": VERSION, + "git_sha": GIT_SHA, + "target": BUILD_TARGET, + }) } -/// 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. -fn validate_model_syntax(model: &str) -> Result<(), String> { - let trimmed = model.trim(); - if trimmed.is_empty() { - return Err("model string cannot be empty".to_string()); - } - // Known aliases are always valid - match trimmed { - "opus" | "sonnet" | "haiku" => return Ok(()), - _ => {} - } - // Check for spaces (malformed) - if trimmed.contains(' ') { - return Err(format!( - "invalid model syntax: '{}' contains spaces. Use provider/model format or known alias", - trimmed - )); - } - // 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: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)", - 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") { - 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)"); +#[allow(clippy::too_many_lines)] +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, hint) = split_error_hint(&full_message); + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": short_reason, + "kind": kind, + "hint": hint, + }) + ); + } else { + eprintln!("failed to restore session: {error}"); + } + std::process::exit(1); } - return Err(err_msg); - } - Ok(()) -} - -fn config_alias_for_current_dir(alias: &str) -> Option { - if alias.is_empty() { - return None; - } - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - let config = loader.load().ok()?; - config.aliases().get(alias).cloned() -} - -fn normalize_allowed_tools(values: &[String]) -> Result, String> { - if values.is_empty() { - return Ok(None); - } - current_tool_registry()?.normalize_allowed_tools(values) -} - -fn current_tool_registry() -> Result { - let cwd = env::current_dir().map_err(|error| error.to_string())?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load().map_err(|error| error.to_string())?; - let state = build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) - .map_err(|error| error.to_string())?; - let registry = state.tool_registry.clone(); - if let Some(mcp_state) = state.mcp_state { - mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .shutdown() - .map_err(|error| error.to_string())?; - } - Ok(registry) -} - -fn parse_permission_mode_arg(value: &str) -> Result { - normalize_permission_mode(value) - .ok_or_else(|| { - format!( - "unsupported permission mode '{value}'. Use read-only, workspace-write, or danger-full-access." - ) - }) - .map(permission_mode_from_label) -} - -fn permission_mode_from_label(mode: &str) -> PermissionMode { - match mode { - "read-only" => PermissionMode::ReadOnly, - "workspace-write" => PermissionMode::WorkspaceWrite, - "danger-full-access" => PermissionMode::DangerFullAccess, - other => panic!("unsupported permission mode label: {other}"), - } -} - -fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode { - match mode { - ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly, - ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite, - ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess, - } -} - -fn default_permission_mode() -> PermissionMode { - env::var("RUSTY_CLAUDE_PERMISSION_MODE") - .ok() - .as_deref() - .and_then(normalize_permission_mode) - .map(permission_mode_from_label) - .or_else(config_permission_mode_for_current_dir) - .unwrap_or(PermissionMode::DangerFullAccess) -} - -fn config_permission_mode_for_current_dir() -> Option { - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - loader - .load() - .ok()? - .permission_mode() - .map(permission_mode_from_resolved) -} - -fn config_model_for_current_dir() -> Option { - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - loader.load().ok()?.model().map(ToOwned::to_owned) -} - -fn resolve_repl_model(cli_model: String) -> String { - if cli_model != DEFAULT_MODEL { - return cli_model; - } - if let Some(env_model) = env::var("ANTHROPIC_MODEL") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - { - return resolve_model_alias_with_config(&env_model); - } - if let Some(config_model) = config_model_for_current_dir() { - return resolve_model_alias_with_config(&config_model); - } - cli_model -} + }; + let resolved_path = handle.path.clone(); -fn provider_label(kind: ProviderKind) -> &'static str { - match kind { - ProviderKind::Anthropic => "anthropic", - ProviderKind::Xai => "xai", - ProviderKind::OpenAi => "openai", + if commands.is_empty() { + if output_format == CliOutputFormat::Json { + println!( + "{}", + serde_json::json!({ + "kind": "restored", + "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; } -} - -fn format_connected_line(model: &str) -> String { - let provider = provider_label(detect_provider_kind(model)); - format!("Connected: {model} via {provider}") -} - -fn filter_tool_specs( - tool_registry: &GlobalToolRegistry, - allowed_tools: Option<&AllowedToolSet>, -) -> Vec { - tool_registry.definitions(allowed_tools) -} - -fn parse_system_prompt_args( - args: &[String], - output_format: CliOutputFormat, -) -> Result { - let mut cwd = env::current_dir().map_err(|error| error.to_string())?; - let mut date = DEFAULT_DATE.to_string(); - let mut index = 0; - while index < args.len() { - match args[index].as_str() { - "--cwd" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --cwd".to_string())?; - cwd = PathBuf::from(value); - index += 2; - } - "--date" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --date".to_string())?; - date.clone_from(value); - index += 2; - } - other => { - // #152: hint `--output-format json` when user types `--json`. - let mut msg = format!("unknown system-prompt option: {other}"); - if other == "--json" { - msg.push_str("\nDid you mean `--output-format json`?"); + 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 { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": format!("/{cmd_root} is not yet implemented in this build"), + "kind": "unsupported_command", + "command": raw_command, + }) + ); + } else { + eprintln!("/{cmd_root} is not yet implemented in this build"); } - return Err(msg); + std::process::exit(2); } } - } - - Ok(CliAction::PrintSystemPrompt { - cwd, - date, - output_format, - }) -} - -fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result { - let mut session_reference = LATEST_SESSION_REFERENCE.to_string(); - let mut output_path: Option = None; - let mut index = 0; - - while index < args.len() { - match args[index].as_str() { - "--session" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --session".to_string())?; - session_reference.clone_from(value); - index += 2; - } - flag if flag.starts_with("--session=") => { - session_reference = flag[10..].to_string(); - index += 1; - } - "--output" | "-o" => { - let value = args - .get(index + 1) - .ok_or_else(|| format!("missing value for {}", args[index]))?; - output_path = Some(PathBuf::from(value)); - index += 2; - } - flag if flag.starts_with("--output=") => { - output_path = Some(PathBuf::from(&flag[9..])); - index += 1; + let command = match SlashCommand::parse(raw_command) { + Ok(Some(command)) => command, + Ok(None) => { + if output_format == CliOutputFormat::Json { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": format!("unsupported resumed command: {raw_command}"), + "kind": "unsupported_resumed_command", + "command": raw_command, + }) + ); + } else { + eprintln!("unsupported resumed command: {raw_command}"); + } + std::process::exit(2); } - other if other.starts_with('-') => { - return Err(format!("unknown export option: {other}")); + Err(error) => { + if output_format == CliOutputFormat::Json { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": error.to_string(), + "command": raw_command, + }) + ); + } else { + eprintln!("{error}"); + } + std::process::exit(2); } - other if output_path.is_none() => { - output_path = Some(PathBuf::from(other)); - index += 1; + }; + 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}"); + } } - other => { - return Err(format!("unexpected export argument: {other}")); + Err(error) => { + if output_format == CliOutputFormat::Json { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": error.to_string(), + "command": raw_command, + }) + ); + } else { + eprintln!("{error}"); + } + std::process::exit(2); } } } - - Ok(CliAction::Export { - session_reference, - output_path, - output_format, - }) } -fn parse_dump_manifests_args( - args: &[String], - output_format: CliOutputFormat, -) -> Result { - let mut manifests_dir: Option = None; - let mut index = 0; - while index < args.len() { - let arg = &args[index]; - if arg == "--manifests-dir" { - let value = args - .get(index + 1) - .ok_or_else(|| String::from("--manifests-dir requires a path"))?; - manifests_dir = Some(PathBuf::from(value)); - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--manifests-dir=") { - if value.is_empty() { - return Err(String::from("--manifests-dir requires a path")); - } - manifests_dir = Some(PathBuf::from(value)); - index += 1; - continue; - } - return Err(format!("unknown dump-manifests option: {arg}")); - } - - Ok(CliAction::DumpManifests { - output_format, - manifests_dir, - }) +#[derive(Debug, Clone)] +struct ResumeCommandOutcome { + session: Session, + message: Option, + json: Option, } -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); +#[cfg(test)] +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 !current_command.is_empty() { - commands.push(current_command); + if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { + message.push(' '); + message.push_str(note); } - - Ok(CliAction::ResumeSession { - session_path, - commands, - output_format, - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DiagnosticLevel { - Ok, - Warn, - Fail, + message.push_str(" Use /help to list available commands."); + message } -impl DiagnosticLevel { - fn label(self) -> &'static str { - match self { - Self::Ok => "ok", - Self::Warn => "warn", - Self::Fail => "fail", +#[allow(clippy::too_many_lines)] +fn run_resume_command( + session_path: &Path, + session: &Session, + command: &SlashCommand, +) -> Result> { + match command { + SlashCommand::Help => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_repl_help()), + json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })), + }), + SlashCommand::Compact => { + let result = runtime::compact_session( + session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ); + let removed = result.removed_message_count; + let kept = result.compacted_session.messages.len(); + let skipped = removed == 0; + result.compacted_session.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: result.compacted_session, + message: Some(format_compact_report(removed, kept, skipped)), + json: Some(serde_json::json!({ + "kind": "compact", + "skipped": skipped, + "removed_messages": removed, + "kept_messages": kept, + })), + }) } - } - - fn is_failure(self) -> bool { - matches!(self, Self::Fail) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct DiagnosticCheck { - name: &'static str, - level: DiagnosticLevel, - summary: String, - details: Vec, - data: Map, -} - -impl DiagnosticCheck { - fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into) -> Self { - Self { - name, - level, - summary: summary.into(), - details: Vec::new(), - data: Map::new(), + SlashCommand::Clear { confirm } => { + if !confirm { + return Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some( + "clear: confirmation required; rerun with /clear --confirm".to_string(), + ), + json: Some(serde_json::json!({ + "kind": "error", + "error": "confirmation required", + "hint": "rerun with /clear --confirm", + })), + }); + } + let backup_path = write_session_clear_backup(session, session_path)?; + let previous_session_id = session.session_id.clone(); + let cleared = new_cli_session()?; + let new_session_id = cleared.session_id.clone(); + cleared.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: cleared, + message: Some(format!( + "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", + backup_path.display(), + backup_path.display(), + session_path.display() + )), + json: Some(serde_json::json!({ + "kind": "clear", + "previous_session_id": previous_session_id, + "new_session_id": new_session_id, + "backup": backup_path.display().to_string(), + "session_file": session_path.display().to_string(), + })), + }) } - } - - fn with_details(mut self, details: Vec) -> Self { - self.details = details; - self - } - - fn with_data(mut self, data: Map) -> Self { - self.data = data; - self - } - - fn json_value(&self) -> Value { - let mut value = Map::from_iter([ - ( - "name".to_string(), - Value::String(self.name.to_ascii_lowercase()), - ), - ( - "status".to_string(), - Value::String(self.level.label().to_string()), - ), - ("summary".to_string(), Value::String(self.summary.clone())), - ( - "details".to_string(), - Value::Array( - self.details - .iter() - .cloned() - .map(Value::String) - .collect::>(), - ), - ), - ]); - value.extend(self.data.clone()); - Value::Object(value) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct DoctorReport { - checks: Vec, -} - -impl DoctorReport { - fn counts(&self) -> (usize, usize, usize) { - ( - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Ok) - .count(), - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Warn) - .count(), - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Fail) - .count(), - ) - } - - fn has_failures(&self) -> bool { - self.checks.iter().any(|check| check.level.is_failure()) - } - - fn render(&self) -> String { - let (ok_count, warn_count, fail_count) = self.counts(); - let mut lines = vec![ - "Doctor".to_string(), - format!( - "Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}" - ), - ]; - lines.extend(self.checks.iter().map(render_diagnostic_check)); - lines.join("\n\n") - } - - fn json_value(&self) -> Value { - let report = self.render(); - let (ok_count, warn_count, fail_count) = self.counts(); - json!({ - "kind": "doctor", - "message": report, - "report": report, - "has_failures": self.has_failures(), - "summary": { - "total": self.checks.len(), - "ok": ok_count, - "warnings": warn_count, - "failures": fail_count, - }, - "checks": self - .checks - .iter() - .map(DiagnosticCheck::json_value) - .collect::>(), - }) - } -} - -fn render_diagnostic_check(check: &DiagnosticCheck) -> String { - let mut lines = vec![format!( - "{}\n Status {}\n Summary {}", - check.name, - check.level.label(), - check.summary - )]; - if !check.details.is_empty() { - lines.push(" Details".to_string()); - lines.extend(check.details.iter().map(|detail| format!(" - {detail}"))); - } - lines.join("\n") -} - -fn render_doctor_report() -> Result> { - let cwd = env::current_dir()?; - let config_loader = ConfigLoader::default_for(&cwd); - let config = config_loader.load(); - let discovered_config = config_loader.discover(); - let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; - let (project_root, git_branch) = - parse_git_status_metadata(project_context.git_status.as_deref()); - let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); - let empty_config = runtime::RuntimeConfig::empty(); - let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config); - let context = StatusContext { - cwd: cwd.clone(), - session_path: None, - loaded_config_files: config - .as_ref() - .ok() - .map_or(0, |runtime_config| runtime_config.loaded_entries().len()), - discovered_config_files: discovered_config.len(), - memory_file_count: project_context.instruction_files.len(), - project_root, - git_branch, - git_summary, - sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd), - // Doctor path has its own config check; StatusContext here is only - // fed into health renderers that don't read config_load_error. - config_load_error: config.as_ref().err().map(ToString::to_string), - }; - Ok(DoctorReport { - checks: vec![ - check_auth_health(), - check_config_health(&config_loader, config.as_ref()), - check_install_source_health(), - check_workspace_health(&context), - check_sandbox_health(&context.sandbox_status), - check_system_health(&cwd, config.as_ref().ok()), - ], - }) -} - -fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box> { - let report = render_doctor_report()?; - let message = report.render(); - match output_format { - CliOutputFormat::Text => println!("{message}"), - CliOutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&report.json_value())?); - } - } - if report.has_failures() { - return Err("doctor found failing checks".into()); - } - Ok(()) -} - -/// Starts a minimal Model Context Protocol server that exposes claw's -/// built-in tools over stdio. -/// -/// Tool descriptors come from [`tools::mvp_tool_specs`] and calls are -/// dispatched through [`tools::execute_tool`], so this server exposes exactly -/// Read `.claw/worker-state.json` from the current working directory and print it. -/// This is the file-based worker observability surface: `push_event()` in `worker_boot.rs` -/// atomically writes state transitions here so external observers (clawhip, orchestrators) -/// can poll current `WorkerStatus` without needing an HTTP route on the opencode binary. -fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box> { - let cwd = env::current_dir()?; - let state_path = cwd.join(".claw").join("worker-state.json"); - if !state_path.exists() { - // #139: this error used to say "run a worker first" without telling - // callers how to run one. "worker" is an internal concept (there is - // no `claw worker` subcommand), so claws/CI had no discoverable path - // from the error to a fix. Emit an actionable, structured error that - // names the two concrete commands that produce worker state. - // - // Format in both text and JSON modes is stable so scripts can match: - // error: no worker state file found at - // Hint: worker state is written by the interactive REPL or a non-interactive prompt. - // Run: claw # start the REPL (writes state on first turn) - // Or: claw prompt # run one non-interactive turn - // Then rerun: claw state [--output-format json] - return Err(format!( - "no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt # run one non-interactive turn\n Then rerun: claw state [--output-format json]", - path = state_path.display() - ) - .into()); - } - let raw = std::fs::read_to_string(&state_path)?; - match output_format { - CliOutputFormat::Text => println!("{raw}"), - CliOutputFormat::Json => { - // Validate it parses as JSON before re-emitting - let _: serde_json::Value = serde_json::from_str(&raw)?; - println!("{raw}"); - } - } - Ok(()) -} - -/// the same surface the in-process agent loop uses. -fn run_mcp_serve() -> Result<(), Box> { - let tools = mvp_tool_specs() - .into_iter() - .map(|spec| McpTool { - name: spec.name.to_string(), - description: Some(spec.description.to_string()), - input_schema: Some(spec.input_schema), - annotations: None, - meta: None, - }) - .collect(); - - let spec = McpServerSpec { - server_name: "claw".to_string(), - server_version: VERSION.to_string(), - tools, - tool_handler: Box::new(execute_tool), - }; - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - runtime.block_on(async move { - let mut server = McpServer::new(spec); - server.run().await - })?; - Ok(()) -} - -#[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 { - 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 { - 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)) -} - -fn dump_manifests( - manifests_dir: Option<&Path>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - dump_manifests_at_path(&workspace_dir, manifests_dir, output_format) -} - -const DUMP_MANIFESTS_OVERRIDE_HINT: &str = - "Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`."; - -// Internal function for testing that accepts a workspace directory path. -fn dump_manifests_at_path( - workspace_dir: &std::path::Path, - manifests_dir: Option<&Path>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let paths = if let Some(dir) = manifests_dir { - let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); - UpstreamPaths::from_repo_root(resolved) - } else { - // Surface the resolved path in the error so users can diagnose missing - // manifest files without guessing what path the binary expected. - let resolved = workspace_dir - .canonicalize() - .unwrap_or_else(|_| workspace_dir.to_path_buf()); - UpstreamPaths::from_workspace_dir(&resolved) - }; - - let source_root = paths.repo_root(); - if !source_root.exists() { - return Err(format!( - "Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - source_root.display(), - ) - .into()); - } - - let required_paths = [ - ("src/commands.ts", paths.commands_path()), - ("src/tools.ts", paths.tools_path()), - ("src/entrypoints/cli.tsx", paths.cli_path()), - ]; - let missing = required_paths - .iter() - .filter_map(|(label, path)| (!path.is_file()).then_some(*label)) - .collect::>(); - if !missing.is_empty() { - return Err(format!( - "Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - source_root.display(), - missing.join(", "), - ) - .into()); - } - - match extract_manifest(&paths) { - Ok(manifest) => { - match output_format { - CliOutputFormat::Text => { - println!("commands: {}", manifest.commands.entries().len()); - println!("tools: {}", manifest.tools.entries().len()); - println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); - } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "dump-manifests", - "commands": manifest.commands.entries().len(), - "tools": manifest.tools.entries().len(), - "bootstrap_phases": manifest.bootstrap.phases().len(), - }))? - ), - } - Ok(()) - } - Err(error) => Err(format!( - "failed to extract manifests: {error}\n looked in: {path}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - path = paths.repo_root().display() - ) - .into()), - } -} - -fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box> { - let phases = runtime::BootstrapPlan::claude_code_default() - .phases() - .iter() - .map(|phase| format!("{phase:?}")) - .collect::>(); - match output_format { - CliOutputFormat::Text => { - for phase in &phases { - println!("- {phase}"); - } - } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "bootstrap-plan", - "phases": phases, - }))? - ), - } - Ok(()) -} - -fn print_system_prompt( - cwd: PathBuf, - date: String, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?; - let message = sections.join( - " - -", - ); - match output_format { - CliOutputFormat::Text => println!("{message}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "system-prompt", - "message": message, - "sections": sections, - }))? - ), - } - Ok(()) -} - -fn print_version(output_format: CliOutputFormat) -> Result<(), Box> { - match output_format { - CliOutputFormat::Text => println!("{}", render_version_report()), - CliOutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&version_json_value())?); - } - } - Ok(()) -} - -fn version_json_value() -> serde_json::Value { - json!({ - "kind": "version", - "message": render_version_report(), - "version": VERSION, - "git_sha": GIT_SHA, - "target": BUILD_TARGET, - }) -} - -#[allow(clippy::too_many_lines)] -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, hint) = split_error_hint(&full_message); - eprintln!( - "{}", - serde_json::json!({ - "type": "error", - "error": short_reason, - "kind": kind, - "hint": hint, - }) - ); - } 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", - "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 { - eprintln!( - "{}", - serde_json::json!({ - "type": "error", - "error": format!("/{cmd_root} is not yet implemented in this build"), - "kind": "unsupported_command", - "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 { - eprintln!( - "{}", - serde_json::json!({ - "type": "error", - "error": format!("unsupported resumed command: {raw_command}"), - "kind": "unsupported_resumed_command", - "command": raw_command, - }) - ); - } else { - eprintln!("unsupported resumed command: {raw_command}"); - } - std::process::exit(2); - } - Err(error) => { - if output_format == CliOutputFormat::Json { - eprintln!( - "{}", - serde_json::json!({ - "type": "error", - "error": error.to_string(), - "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 { - eprintln!( - "{}", - serde_json::json!({ - "type": "error", - "error": error.to_string(), - "command": raw_command, - }) - ); - } else { - eprintln!("{error}"); - } - std::process::exit(2); - } - } - } -} - -#[derive(Debug, Clone)] -struct ResumeCommandOutcome { - session: Session, - message: Option, - json: Option, -} - -#[derive(Debug, Clone)] -struct StatusContext { - cwd: PathBuf, - session_path: Option, - loaded_config_files: usize, - discovered_config_files: usize, - memory_file_count: usize, - project_root: Option, - git_branch: Option, - git_summary: GitWorkspaceSummary, - sandbox_status: runtime::SandboxStatus, - /// #143: when `.claw.json` (or another loaded config file) fails to parse, - /// we capture the parse error here and still populate every field that - /// doesn't depend on runtime config (workspace, git, sandbox defaults, - /// discovery counts). Top-level JSON output then reports - /// `status: "degraded"` so claws can distinguish "status ran but config - /// is broken" from "status ran cleanly". - config_load_error: Option, -} - -#[derive(Debug, Clone, Copy)] -struct StatusUsage { - message_count: usize, - turns: u32, - latest: TokenUsage, - cumulative: TokenUsage, - estimated_tokens: usize, -} - -#[allow(clippy::struct_field_names)] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct GitWorkspaceSummary { - changed_files: usize, - staged_files: usize, - unstaged_files: usize, - untracked_files: usize, - conflicted_files: usize, -} - -impl GitWorkspaceSummary { - fn is_clean(self) -> bool { - self.changed_files == 0 - } - - fn headline(self) -> String { - if self.is_clean() { - "clean".to_string() - } else { - let mut details = Vec::new(); - if self.staged_files > 0 { - details.push(format!("{} staged", self.staged_files)); - } - if self.unstaged_files > 0 { - details.push(format!("{} unstaged", self.unstaged_files)); - } - if self.untracked_files > 0 { - details.push(format!("{} untracked", self.untracked_files)); - } - if self.conflicted_files > 0 { - details.push(format!("{} conflicted", self.conflicted_files)); - } - format!( - "dirty · {} files · {}", - self.changed_files, - details.join(", ") - ) - } - } -} - -#[cfg(test)] -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 -} - -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 " - ) -} - -fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String { - format!( - "Model updated - Previous {previous} - Current {next} - Preserved msgs {message_count}" - ) -} - -fn format_permissions_report(mode: &str) -> String { - let modes = [ - ("read-only", "Read/search tools only", mode == "read-only"), - ( - "workspace-write", - "Edit files inside the workspace", - mode == "workspace-write", - ), - ( - "danger-full-access", - "Unrestricted tool access", - mode == "danger-full-access", - ), - ] - .into_iter() - .map(|(name, description, is_current)| { - let marker = if is_current { - "● current" - } else { - "○ available" - }; - format!(" {name:<18} {marker:<11} {description}") - }) - .collect::>() - .join( - " -", - ); - - format!( - "Permissions - Active mode {mode} - Mode status live session default - -Modes -{modes} - -Usage - Inspect current mode with /permissions - Switch modes with /permissions " - ) -} - -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" - ) -} - -fn format_cost_report(usage: TokenUsage) -> String { - format!( - "Cost - Input tokens {} - Output tokens {} - Cache create {} - Cache read {} - Total tokens {}", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - usage.total_tokens(), - ) -} - -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}" - ) -} - -fn render_resume_usage() -> String { - format!( - "Resume - Usage /resume - Auto-save .claw/sessions/.{PRIMARY_SESSION_EXTENSION} - Tip use /session list to inspect saved sessions" - ) -} - -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}" - ) - } -} - -fn format_auto_compaction_notice(removed: usize) -> String { - format!("[auto-compacted: removed {removed} messages]") -} - -fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { - parse_git_status_metadata_for( - &env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - status, - ) -} - -fn parse_git_status_branch(status: Option<&str>) -> Option { - let status = status?; - let first_line = status.lines().next()?; - let line = first_line.strip_prefix("## ")?; - if line.starts_with("HEAD") { - return Some("detached HEAD".to_string()); - } - let branch = line.split(['.', ' ']).next().unwrap_or_default().trim(); - if branch.is_empty() { - None - } else { - Some(branch.to_string()) - } -} - -fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary { - let mut summary = GitWorkspaceSummary::default(); - let Some(status) = status else { - return summary; - }; - - for line in status.lines() { - if line.starts_with("## ") || line.trim().is_empty() { - continue; - } - - summary.changed_files += 1; - let mut chars = line.chars(); - let index_status = chars.next().unwrap_or(' '); - let worktree_status = chars.next().unwrap_or(' '); - - if index_status == '?' && worktree_status == '?' { - summary.untracked_files += 1; - continue; - } - - if index_status != ' ' { - summary.staged_files += 1; - } - if worktree_status != ' ' { - summary.unstaged_files += 1; - } - if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A')) - || index_status == 'U' - || worktree_status == 'U' - { - summary.conflicted_files += 1; - } - } - - summary -} - -fn resolve_git_branch_for(cwd: &Path) -> Option { - let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?; - let branch = branch.trim(); - if !branch.is_empty() { - return Some(branch.to_string()); - } - - let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?; - let fallback = fallback.trim(); - if fallback.is_empty() { - None - } else if fallback == "HEAD" { - Some("detached HEAD".to_string()) - } else { - Some(fallback.to_string()) - } -} - -fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option { - let output = std::process::Command::new("git") - .args(args) - .current_dir(cwd) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn find_git_root_in(cwd: &Path) -> Result> { - let output = std::process::Command::new("git") - .args(["rev-parse", "--show-toplevel"]) - .current_dir(cwd) - .output()?; - if !output.status.success() { - return Err("not a git repository".into()); - } - let path = String::from_utf8(output.stdout)?.trim().to_string(); - if path.is_empty() { - return Err("empty git root".into()); - } - Ok(PathBuf::from(path)) -} - -fn parse_git_status_metadata_for( - cwd: &Path, - status: Option<&str>, -) -> (Option, Option) { - let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status)); - let project_root = find_git_root_in(cwd).ok(); - (project_root, branch) -} - -#[allow(clippy::too_many_lines)] -fn run_resume_command( - session_path: &Path, - session: &Session, - command: &SlashCommand, -) -> Result> { - match command { - SlashCommand::Help => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_repl_help()), - json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })), - }), - SlashCommand::Compact => { - let result = runtime::compact_session( - session, - CompactionConfig { - max_estimated_tokens: 0, - ..CompactionConfig::default() - }, - ); - let removed = result.removed_message_count; - let kept = result.compacted_session.messages.len(); - let skipped = removed == 0; - result.compacted_session.save_to_path(session_path)?; - Ok(ResumeCommandOutcome { - session: result.compacted_session, - message: Some(format_compact_report(removed, kept, skipped)), - json: Some(serde_json::json!({ - "kind": "compact", - "skipped": skipped, - "removed_messages": removed, - "kept_messages": kept, - })), - }) - } - SlashCommand::Clear { confirm } => { - if !confirm { - return Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some( - "clear: confirmation required; rerun with /clear --confirm".to_string(), - ), - json: Some(serde_json::json!({ - "kind": "error", - "error": "confirmation required", - "hint": "rerun with /clear --confirm", - })), - }); - } - let backup_path = write_session_clear_backup(session, session_path)?; - let previous_session_id = session.session_id.clone(); - let cleared = new_cli_session()?; - let new_session_id = cleared.session_id.clone(); - cleared.save_to_path(session_path)?; - Ok(ResumeCommandOutcome { - session: cleared, - message: Some(format!( - "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", - backup_path.display(), - backup_path.display(), - session_path.display() - )), - json: Some(serde_json::json!({ - "kind": "clear", - "previous_session_id": previous_session_id, - "new_session_id": new_session_id, - "backup": backup_path.display().to_string(), - "session_file": session_path.display().to_string(), - })), - }) - } - SlashCommand::Status => { - let tracker = UsageTracker::from_session(session); - let usage = tracker.cumulative_usage(); - let context = status_context(Some(session_path))?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_status_report( - session.model.as_deref().unwrap_or("restored-session"), - StatusUsage { - message_count: session.messages.len(), - turns: tracker.turns(), - latest: tracker.current_turn_usage(), - cumulative: usage, - estimated_tokens: 0, - }, - default_permission_mode().as_str(), - &context, - None, // #148: resumed sessions don't have flag provenance - )), - json: Some(status_json_value( - session.model.as_deref(), - StatusUsage { - message_count: session.messages.len(), - turns: tracker.turns(), - latest: tracker.current_turn_usage(), - cumulative: usage, - estimated_tokens: 0, - }, - default_permission_mode().as_str(), - &context, - None, // #148: resumed sessions don't have flag provenance - )), - }) - } - SlashCommand::Sandbox => { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_sandbox_report(&status)), - json: Some(sandbox_json_value(&status)), - }) - } - SlashCommand::Cost => { - let usage = UsageTracker::from_session(session).cumulative_usage(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_cost_report(usage)), - json: Some(serde_json::json!({ - "kind": "cost", - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "cache_creation_input_tokens": usage.cache_creation_input_tokens, - "cache_read_input_tokens": usage.cache_read_input_tokens, - "total_tokens": usage.total_tokens(), - })), - }) - } - SlashCommand::Config { section } => { - let message = render_config_report(section.as_deref())?; - let json = render_config_json(section.as_deref())?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(message), - json: Some(json), - }) - } - SlashCommand::Mcp { action, target } => { - let cwd = env::current_dir()?; - 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()), - }; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?), - json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)?), - }) - } - SlashCommand::Memory => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_memory_report()?), - json: Some(render_memory_json()?), - }), - SlashCommand::Init => { - // #142: run the init once, then render both text + structured JSON - // from the same InitReport so both surfaces stay in sync. - let cwd = env::current_dir()?; - let report = crate::init::initialize_repo(&cwd)?; - let message = report.render(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(message.clone()), - json: Some(init_json_value(&report, &message)), - }) - } - SlashCommand::Diff => { - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let message = render_diff_report_for(&cwd)?; - let json = render_diff_json_for(&cwd)?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(message), - json: Some(json), - }) - } - SlashCommand::Version => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_version_report()), - json: Some(version_json_value()), - }), - SlashCommand::Export { path } => { - let export_path = resolve_export_path(path.as_deref(), session)?; - fs::write(&export_path, render_export_text(session))?; - let msg_count = session.messages.len(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "Export\n Result wrote transcript\n File {}\n Messages {}", - export_path.display(), - msg_count, - )), - json: Some(serde_json::json!({ - "kind": "export", - "file": export_path.display().to_string(), - "message_count": msg_count, - })), - }) - } - SlashCommand::Agents { args } => { - let cwd = env::current_dir()?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), - json: Some(serde_json::json!({ - "kind": "agents", - "text": handle_agents_slash_command(args.as_deref(), &cwd)?, - })), - }) - } - SlashCommand::Skills { args } => { - if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { - return Err( - "resumed /skills invocations are interactive-only; start `claw` and run `/skills ` in the REPL".into(), - ); - } - let cwd = env::current_dir()?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), - json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?), - }) - } - SlashCommand::Doctor => { - let report = render_doctor_report()?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(report.render()), - json: Some(report.json_value()), - }) - } - SlashCommand::Stats => { - let usage = UsageTracker::from_session(session).cumulative_usage(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_cost_report(usage)), - json: Some(serde_json::json!({ - "kind": "stats", - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "cache_creation_input_tokens": usage.cache_creation_input_tokens, - "cache_read_input_tokens": usage.cache_read_input_tokens, - "total_tokens": usage.total_tokens(), - })), - }) - } - SlashCommand::History { count } => { - let limit = parse_history_count(count.as_deref()) - .map_err(|error| -> Box { error.into() })?; - let entries = collect_session_prompt_history(session); - let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_prompt_history_report(&entries, limit)), - json: Some(serde_json::json!({ - "kind": "history", - "total": entries.len(), - "showing": shown.len(), - "entries": shown.iter().map(|e| serde_json::json!({ - "timestamp_ms": e.timestamp_ms, - "text": e.text, - })).collect::>(), - })), - }) - } - SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), - // /session list can be served from the sessions directory without a live session. - SlashCommand::Session { - action: Some(ref act), - .. - } if act == "list" => { - let sessions = list_managed_sessions().unwrap_or_default(); - let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); - let active_id = session.session_id.clone(); - let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(text), - json: Some(serde_json::json!({ - "kind": "session_list", - "sessions": session_ids, - "active": active_id, - })), - }) - } - SlashCommand::Bughunter { .. } - | SlashCommand::Commit { .. } - | SlashCommand::Pr { .. } - | SlashCommand::Issue { .. } - | SlashCommand::Ultraplan { .. } - | SlashCommand::Teleport { .. } - | SlashCommand::DebugToolCall { .. } - | SlashCommand::Resume { .. } - | SlashCommand::Model { .. } - | SlashCommand::Permissions { .. } - | SlashCommand::Session { .. } - | SlashCommand::Plugins { .. } - | 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 { .. } => Err("unsupported resumed slash command".into()), - } -} - -/// Detect if the current working directory is "broad" (home directory or -/// filesystem root). Returns the cwd path if broad, None otherwise. -fn detect_broad_cwd() -> Option { - let Ok(cwd) = env::current_dir() else { - return None; - }; - let is_home = env::var_os("HOME") - .or_else(|| env::var_os("USERPROFILE")) - .is_some_and(|h| Path::new(&h) == cwd); - let is_root = cwd.parent().is_none(); - if is_home || is_root { - Some(cwd) - } else { - None - } -} - -/// Enforce the broad-CWD policy: when running from home or root, either -/// require the --allow-broad-cwd flag, or prompt for confirmation (interactive), -/// or exit with an error (non-interactive). -fn enforce_broad_cwd_policy( - allow_broad_cwd: bool, - output_format: CliOutputFormat, -) -> Result<(), Box> { - if allow_broad_cwd { - return Ok(()); - } - let Some(cwd) = detect_broad_cwd() else { - return Ok(()); - }; - - let is_interactive = io::stdin().is_terminal(); - - if is_interactive { - // Interactive mode: print warning and ask for confirmation - eprintln!( - "Warning: claw is running from a very broad directory ({}).\n\ - The agent can read and search everything under this path.\n\ - Consider running from inside your project: cd /path/to/project && claw", - cwd.display() - ); - eprint!("Continue anyway? [y/N]: "); - io::stderr().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let trimmed = input.trim().to_lowercase(); - if trimmed != "y" && trimmed != "yes" { - eprintln!("Aborted."); - std::process::exit(0); - } - Ok(()) - } else { - // Non-interactive mode: exit with error (JSON or text) - let message = format!( - "claw is running from a very broad directory ({}). \ - The agent can read and search everything under this path. \ - Use --allow-broad-cwd to proceed anyway, \ - or run from inside your project: cd /path/to/project && claw", - cwd.display() - ); - match output_format { - CliOutputFormat::Json => { - eprintln!( - "{}", - serde_json::json!({ - "type": "error", - "error": message, - }) - ); - } - CliOutputFormat::Text => { - eprintln!("error: {message}"); - } - } - std::process::exit(1); - } -} - -fn run_stale_base_preflight(flag_value: Option<&str>) { - let Ok(cwd) = env::current_dir() else { - return; - }; - let source = resolve_expected_base(flag_value, &cwd); - let state = check_base_commit(&cwd, source.as_ref()); - if let Some(warning) = format_stale_base_warning(&state) { - eprintln!("{warning}"); - } -} - -#[allow(clippy::needless_pass_by_value)] -fn run_repl( - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, -) -> Result<(), Box> { - enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; - run_stale_base_preflight(base_commit.as_deref()); - let resolved_model = resolve_repl_model(model); - let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; - cli.set_reasoning_effort(reasoning_effort); - let mut editor = - input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); - println!("{}", cli.startup_banner()); - println!("{}", format_connected_line(&cli.model)); - - loop { - editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); - match editor.read_line()? { - input::ReadOutcome::Submit(input) => { - let trimmed = input.trim().to_string(); - if trimmed.is_empty() { - continue; - } - if matches!(trimmed.as_str(), "/exit" | "/quit") { - cli.persist_session()?; - break; - } - match SlashCommand::parse(&trimmed) { - Ok(Some(command)) => { - if cli.handle_repl_command(command)? { - cli.persist_session()?; - } - continue; - } - Ok(None) => {} - Err(error) => { - eprintln!("{error}"); - continue; - } - } - // Bare-word skill dispatch: if the first token of the input - // matches a known skill name, invoke it as `/skills ` - // rather than forwarding raw text to the LLM (ROADMAP #36). - let cwd = std::env::current_dir().unwrap_or_default(); - if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) { - editor.push_history(input); - cli.record_prompt_history(&trimmed); - cli.run_turn(&prompt)?; - continue; - } - editor.push_history(input); - cli.record_prompt_history(&trimmed); - cli.run_turn(&trimmed)?; - } - input::ReadOutcome::Cancel => {} - input::ReadOutcome::Exit => { - cli.persist_session()?; - break; - } - } - } - - 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, - allowed_tools: Option, - permission_mode: PermissionMode, - system_prompt: Vec, - runtime: BuiltRuntime, - session: SessionHandle, - prompt_history: Vec, -} - -#[derive(Debug, Clone)] -struct PromptHistoryEntry { - timestamp_ms: u64, - text: String, -} - -struct RuntimePluginState { - feature_config: runtime::RuntimeFeatureConfig, - tool_registry: GlobalToolRegistry, - plugin_registry: PluginRegistry, - mcp_state: Option>>, -} - -struct RuntimeMcpState { - runtime: tokio::runtime::Runtime, - manager: McpServerManager, - pending_servers: Vec, - degraded_report: Option, -} - -struct BuiltRuntime { - runtime: Option>, - plugin_registry: PluginRegistry, - plugins_active: bool, - mcp_state: Option>>, - mcp_active: bool, -} - -impl BuiltRuntime { - fn new( - runtime: ConversationRuntime, - plugin_registry: PluginRegistry, - mcp_state: Option>>, - ) -> Self { - Self { - runtime: Some(runtime), - plugin_registry, - plugins_active: true, - mcp_state, - mcp_active: true, - } - } - - fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self { - let runtime = self - .runtime - .take() - .expect("runtime should exist before installing hook abort signal"); - self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal)); - self - } - - fn shutdown_plugins(&mut self) -> Result<(), Box> { - if self.plugins_active { - self.plugin_registry.shutdown()?; - self.plugins_active = false; - } - Ok(()) - } - - fn shutdown_mcp(&mut self) -> Result<(), Box> { - if self.mcp_active { - if let Some(mcp_state) = &self.mcp_state { - mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .shutdown()?; - } - self.mcp_active = false; - } - Ok(()) - } -} - -impl Deref for BuiltRuntime { - type Target = ConversationRuntime; - - fn deref(&self) -> &Self::Target { - self.runtime - .as_ref() - .expect("runtime should exist while built runtime is alive") - } -} - -impl DerefMut for BuiltRuntime { - fn deref_mut(&mut self) -> &mut Self::Target { - self.runtime - .as_mut() - .expect("runtime should exist while built runtime is alive") - } -} - -impl Drop for BuiltRuntime { - fn drop(&mut self) { - let _ = self.shutdown_mcp(); - let _ = self.shutdown_plugins(); - } -} - -#[derive(Debug, Deserialize)] -struct ToolSearchRequest { - query: String, - max_results: Option, -} - -#[derive(Debug, Deserialize)] -struct McpToolRequest { - #[serde(rename = "qualifiedName")] - qualified_name: Option, - tool: Option, - arguments: Option, -} - -#[derive(Debug, Deserialize)] -struct ListMcpResourcesRequest { - server: Option, -} - -#[derive(Debug, Deserialize)] -struct ReadMcpResourceRequest { - server: String, - uri: String, -} - -impl RuntimeMcpState { - fn new( - runtime_config: &runtime::RuntimeConfig, - ) -> Result, Box> { - let mut manager = McpServerManager::from_runtime_config(runtime_config); - if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() { - return Ok(None); - } - - let runtime = tokio::runtime::Runtime::new()?; - let discovery = runtime.block_on(manager.discover_tools_best_effort()); - let pending_servers = discovery - .failed_servers - .iter() - .map(|failure| failure.server_name.clone()) - .chain( - discovery - .unsupported_servers - .iter() - .map(|server| server.server_name.clone()), - ) - .collect::>() - .into_iter() - .collect::>(); - let available_tools = discovery - .tools - .iter() - .map(|tool| tool.qualified_name.clone()) - .collect::>(); - let failed_server_names = pending_servers.iter().cloned().collect::>(); - let working_servers = manager - .server_names() - .into_iter() - .filter(|server_name| !failed_server_names.contains(server_name)) - .collect::>(); - let failed_servers = - discovery - .failed_servers - .iter() - .map(|failure| runtime::McpFailedServer { - server_name: failure.server_name.clone(), - phase: runtime::McpLifecyclePhase::ToolDiscovery, - error: runtime::McpErrorSurface::new( - runtime::McpLifecyclePhase::ToolDiscovery, - Some(failure.server_name.clone()), - failure.error.clone(), - std::collections::BTreeMap::new(), - true, - ), - }) - .chain(discovery.unsupported_servers.iter().map(|server| { - runtime::McpFailedServer { - server_name: server.server_name.clone(), - phase: runtime::McpLifecyclePhase::ServerRegistration, - error: runtime::McpErrorSurface::new( - runtime::McpLifecyclePhase::ServerRegistration, - Some(server.server_name.clone()), - server.reason.clone(), - std::collections::BTreeMap::from([( - "transport".to_string(), - format!("{:?}", server.transport).to_ascii_lowercase(), - )]), - false, - ), - } - })) - .collect::>(); - let degraded_report = (!failed_servers.is_empty()).then(|| { - runtime::McpDegradedReport::new( - working_servers, - failed_servers, - available_tools.clone(), - available_tools, - ) - }); - - Ok(Some(( - Self { - runtime, - manager, - pending_servers, - degraded_report, - }, - discovery, - ))) - } - - fn shutdown(&mut self) -> Result<(), Box> { - self.runtime.block_on(self.manager.shutdown())?; - Ok(()) - } - - fn pending_servers(&self) -> Option> { - (!self.pending_servers.is_empty()).then(|| self.pending_servers.clone()) - } - - fn degraded_report(&self) -> Option { - self.degraded_report.clone() - } - - fn server_names(&self) -> Vec { - self.manager.server_names() - } - - fn call_tool( - &mut self, - qualified_tool_name: &str, - arguments: Option, - ) -> Result { - let response = self - .runtime - .block_on(self.manager.call_tool(qualified_tool_name, arguments)) - .map_err(|error| ToolError::new(error.to_string()))?; - if let Some(error) = response.error { - return Err(ToolError::new(format!( - "MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})", - error.message, error.code - ))); - } - - let result = response.result.ok_or_else(|| { - ToolError::new(format!( - "MCP tool `{qualified_tool_name}` returned no result payload" - )) - })?; - serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string())) - } - - fn list_resources_for_server(&mut self, server_name: &str) -> Result { - let result = self - .runtime - .block_on(self.manager.list_resources(server_name)) - .map_err(|error| ToolError::new(error.to_string()))?; - serde_json::to_string_pretty(&json!({ - "server": server_name, - "resources": result.resources, - })) - .map_err(|error| ToolError::new(error.to_string())) - } - - fn list_resources_for_all_servers(&mut self) -> Result { - let mut resources = Vec::new(); - let mut failures = Vec::new(); - - for server_name in self.server_names() { - match self - .runtime - .block_on(self.manager.list_resources(&server_name)) - { - Ok(result) => resources.push(json!({ - "server": server_name, - "resources": result.resources, - })), - Err(error) => failures.push(json!({ - "server": server_name, - "error": error.to_string(), - })), - } - } - - if resources.is_empty() && !failures.is_empty() { - let message = failures - .iter() - .filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str)) - .collect::>() - .join("; "); - return Err(ToolError::new(message)); - } - - serde_json::to_string_pretty(&json!({ - "resources": resources, - "failures": failures, - })) - .map_err(|error| ToolError::new(error.to_string())) - } - - fn read_resource(&mut self, server_name: &str, uri: &str) -> Result { - let result = self - .runtime - .block_on(self.manager.read_resource(server_name, uri)) - .map_err(|error| ToolError::new(error.to_string()))?; - serde_json::to_string_pretty(&json!({ - "server": server_name, - "contents": result.contents, - })) - .map_err(|error| ToolError::new(error.to_string())) - } -} - -fn build_runtime_mcp_state( - runtime_config: &runtime::RuntimeConfig, -) -> Result> { - let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else { - return Ok((None, Vec::new())); - }; - - let mut runtime_tools = discovery - .tools - .iter() - .map(mcp_runtime_tool_definition) - .collect::>(); - if !mcp_state.server_names().is_empty() { - runtime_tools.extend(mcp_wrapper_tool_definitions()); - } - - Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools)) -} - -fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition { - RuntimeToolDefinition { - name: tool.qualified_name.clone(), - description: Some( - tool.tool - .description - .clone() - .unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)), - ), - input_schema: tool - .tool - .input_schema - .clone() - .unwrap_or_else(|| json!({ "type": "object", "additionalProperties": true })), - required_permission: permission_mode_for_mcp_tool(&tool.tool), - } -} - -fn mcp_wrapper_tool_definitions() -> Vec { - vec![ - RuntimeToolDefinition { - name: "MCPTool".to_string(), - description: Some( - "Call a configured MCP tool by its qualified name and JSON arguments.".to_string(), - ), - input_schema: json!({ - "type": "object", - "properties": { - "qualifiedName": { "type": "string" }, - "arguments": {} - }, - "required": ["qualifiedName"], - "additionalProperties": false - }), - required_permission: PermissionMode::DangerFullAccess, - }, - RuntimeToolDefinition { - name: "ListMcpResourcesTool".to_string(), - description: Some( - "List MCP resources from one configured server or from every connected server." - .to_string(), - ), - input_schema: json!({ - "type": "object", - "properties": { - "server": { "type": "string" } - }, - "additionalProperties": false - }), - required_permission: PermissionMode::ReadOnly, - }, - RuntimeToolDefinition { - name: "ReadMcpResourceTool".to_string(), - description: Some("Read a specific MCP resource from a configured server.".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "server": { "type": "string" }, - "uri": { "type": "string" } - }, - "required": ["server", "uri"], - "additionalProperties": false - }), - required_permission: PermissionMode::ReadOnly, - }, - ] -} - -fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode { - let read_only = mcp_annotation_flag(tool, "readOnlyHint"); - let destructive = mcp_annotation_flag(tool, "destructiveHint"); - let open_world = mcp_annotation_flag(tool, "openWorldHint"); - - if read_only && !destructive && !open_world { - PermissionMode::ReadOnly - } else if destructive || open_world { - PermissionMode::DangerFullAccess - } else { - PermissionMode::WorkspaceWrite - } -} - -fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool { - tool.annotations - .as_ref() - .and_then(|annotations| annotations.get(key)) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) -} - -struct HookAbortMonitor { - stop_tx: Option>, - join_handle: Option>, -} - -impl HookAbortMonitor { - fn spawn(abort_signal: runtime::HookAbortSignal) -> Self { - Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| { - let Ok(runtime) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - else { - return; - }; - - runtime.block_on(async move { - let wait_for_stop = tokio::task::spawn_blocking(move || { - let _ = stop_rx.recv(); - }); - - tokio::select! { - result = tokio::signal::ctrl_c() => { - if result.is_ok() { - abort_signal.abort(); - } - } - _ = wait_for_stop => {} - } - }); - }) - } - - fn spawn_with_waiter(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self - where - F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static, - { - let (stop_tx, stop_rx) = mpsc::channel(); - let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal)); - - Self { - stop_tx: Some(stop_tx), - join_handle: Some(join_handle), - } - } - - fn stop(mut self) { - if let Some(stop_tx) = self.stop_tx.take() { - let _ = stop_tx.send(()); - } - if let Some(join_handle) = self.join_handle.take() { - let _ = join_handle.join(); - } - } -} - -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, - model_flag_raw: Option<&str>, - permission_mode: PermissionMode, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let usage = StatusUsage { - message_count: 0, - turns: 0, - latest: TokenUsage::default(), - cumulative: TokenUsage::default(), - estimated_tokens: 0, - }; - let context = status_context(None)?; - // #148: resolve model provenance. If user passed --model, source is - // "flag" with the raw input preserved. Otherwise probe env -> config - // -> default and record the winning source. - let provenance = match model_flag_raw { - Some(raw) => ModelProvenance { - resolved: model.to_string(), - raw: Some(raw.to_string()), - source: ModelSource::Flag, - }, - None => ModelProvenance::from_env_or_config_or_default(model), - }; - match output_format { - CliOutputFormat::Text => println!( - "{}", - format_status_report(&provenance.resolved, usage, permission_mode.as_str(), &context, Some(&provenance)) - ), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&status_json_value( - Some(&provenance.resolved), - usage, - permission_mode.as_str(), - &context, - Some(&provenance), - ))? - ), - } - Ok(()) -} - -fn status_json_value( - model: Option<&str>, - usage: StatusUsage, - permission_mode: &str, - context: &StatusContext, - // #148: optional provenance for `model` field. Surfaces `model_source` - // ("flag" | "env" | "config" | "default") and `model_raw` (user input - // before alias resolution, or null when source is "default"). Callers - // that don't have provenance (legacy resume paths) pass None, in which - // case both new fields are omitted. - provenance: Option<&ModelProvenance>, -) -> serde_json::Value { - // #143: top-level `status` marker so claws can distinguish - // a clean run from a degraded run (config parse failed but other fields - // are still populated). `config_load_error` carries the parse-error string - // when present; it's a string rather than a typed object in Phase 1 and - // will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44). - let degraded = context.config_load_error.is_some(); - let model_source = provenance.map(|p| p.source.as_str()); - let model_raw = provenance.and_then(|p| p.raw.clone()); - json!({ - "kind": "status", - "status": if degraded { "degraded" } else { "ok" }, - "config_load_error": context.config_load_error, - "model": model, - "model_source": model_source, - "model_raw": model_raw, - "permission_mode": permission_mode, - "usage": { - "messages": usage.message_count, - "turns": usage.turns, - "latest_total": usage.latest.total_tokens(), - "cumulative_input": usage.cumulative.input_tokens, - "cumulative_output": usage.cumulative.output_tokens, - "cumulative_total": usage.cumulative.total_tokens(), - "estimated_tokens": usage.estimated_tokens, - }, - "workspace": { - "cwd": context.cwd, - "project_root": context.project_root, - "git_branch": context.git_branch, - "git_state": context.git_summary.headline(), - "changed_files": context.git_summary.changed_files, - "staged_files": context.git_summary.staged_files, - "unstaged_files": context.git_summary.unstaged_files, - "untracked_files": context.git_summary.untracked_files, - "session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()), - "session_id": context.session_path.as_ref().and_then(|path| { - // Session files are named .jsonl directly under - // .claw/sessions/. Extract the stem (drop the .jsonl extension). - path.file_stem().map(|n| n.to_string_lossy().into_owned()) - }), - "loaded_config_files": context.loaded_config_files, - "discovered_config_files": context.discovered_config_files, - "memory_file_count": context.memory_file_count, - }, - "sandbox": { - "enabled": context.sandbox_status.enabled, - "active": context.sandbox_status.active, - "supported": context.sandbox_status.supported, - "in_container": context.sandbox_status.in_container, - "requested_namespace": context.sandbox_status.requested.namespace_restrictions, - "active_namespace": context.sandbox_status.namespace_active, - "requested_network": context.sandbox_status.requested.network_isolation, - "active_network": context.sandbox_status.network_active, - "filesystem_mode": context.sandbox_status.filesystem_mode.as_str(), - "filesystem_active": context.sandbox_status.filesystem_active, - "allowed_mounts": context.sandbox_status.allowed_mounts, - "markers": context.sandbox_status.container_markers, - "fallback_reason": context.sandbox_status.fallback_reason, - } - }) -} - -fn status_context( - session_path: Option<&Path>, -) -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let discovered_config_files = loader.discover().len(); - // #143: degrade gracefully on config parse failure rather than hard-fail. - // `claw doctor` already does this; `claw status` now matches that contract - // so that one malformed `mcpServers.*` entry doesn't take down the whole - // health surface (workspace, git, model, permission, sandbox can still be - // reported independently). - let (loaded_config_files, sandbox_status, config_load_error) = match loader.load() { - Ok(runtime_config) => ( - runtime_config.loaded_entries().len(), - resolve_sandbox_status(runtime_config.sandbox(), &cwd), - None, - ), - Err(err) => ( - 0, - // Fall back to defaults for sandbox resolution so claws still see - // a populated sandbox section instead of a missing field. Defaults - // produce the same output as a runtime config with no sandbox - // overrides, which is the right degraded-mode shape: we cannot - // report what the user *intended*, only what is actually in effect. - resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd), - Some(err.to_string()), - ), - }; - let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; - let (project_root, git_branch) = - parse_git_status_metadata(project_context.git_status.as_deref()); - let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); - Ok(StatusContext { - cwd, - session_path: session_path.map(Path::to_path_buf), - loaded_config_files, - discovered_config_files, - memory_file_count: project_context.instruction_files.len(), - project_root, - git_branch, - git_summary, - sandbox_status, - config_load_error, - }) -} - -fn format_status_report( - model: &str, - usage: StatusUsage, - permission_mode: &str, - context: &StatusContext, - // #148: optional model provenance to surface in a `Model source` line. - // Callers without provenance (legacy resume paths) pass None and the - // source line is omitted for backward compat. - provenance: Option<&ModelProvenance>, -) -> String { - // #143: if config failed to parse, surface a degraded banner at the top - // of the text report so humans see the parse error before the body, while - // the body below still reports everything that could be resolved without - // config (workspace, git, sandbox defaults, etc.). - let status_line = if context.config_load_error.is_some() { - "Status (degraded)" - } else { - "Status" - }; - let mut blocks: Vec = Vec::new(); - if let Some(err) = context.config_load_error.as_deref() { - blocks.push(format!( - "Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun" - )); - } - // #148: render Model source line after Model, showing where the string - // came from (flag / env / config / default) and the raw input if any. - let model_source_line = provenance - .map(|p| match &p.raw { - Some(raw) if raw != model => { - format!("\n Model source {} (raw: {raw})", p.source.as_str()) - } - Some(_) => format!("\n Model source {}", p.source.as_str()), - None => format!("\n Model source {}", p.source.as_str()), - }) - .unwrap_or_default(); - blocks.extend([ - format!( - "{status_line} - Model {model}{model_source_line} - Permission mode {permission_mode} - Messages {} - Turns {} - Estimated tokens {}", - usage.message_count, usage.turns, usage.estimated_tokens, - ), - format!( - "Usage - Latest total {} - Cumulative input {} - Cumulative output {} - Cumulative total {}", - usage.latest.total_tokens(), - usage.cumulative.input_tokens, - usage.cumulative.output_tokens, - usage.cumulative.total_tokens(), - ), - format!( - "Workspace - Cwd {} - Project root {} - Git branch {} - Git state {} - Changed files {} - Staged {} - Unstaged {} - Untracked {} - Session {} - Config files loaded {}/{} - Memory files {} - Suggested flow /status → /diff → /commit", - context.cwd.display(), - context - .project_root - .as_ref() - .map_or_else(|| "unknown".to_string(), |path| path.display().to_string()), - context.git_branch.as_deref().unwrap_or("unknown"), - context.git_summary.headline(), - context.git_summary.changed_files, - context.git_summary.staged_files, - context.git_summary.unstaged_files, - context.git_summary.untracked_files, - context.session_path.as_ref().map_or_else( - || "live-repl".to_string(), - |path| path.display().to_string() - ), - context.loaded_config_files, - context.discovered_config_files, - context.memory_file_count, - ), - format_sandbox_report(&context.sandbox_status), - ]); - blocks.join("\n\n") -} - -fn format_sandbox_report(status: &runtime::SandboxStatus) -> String { - format!( - "Sandbox - Enabled {} - Active {} - Supported {} - In container {} - Requested ns {} - Active ns {} - Requested net {} - Active net {} - Filesystem mode {} - Filesystem active {} - Allowed mounts {} - Markers {} - Fallback reason {}", - status.enabled, - status.active, - status.supported, - status.in_container, - status.requested.namespace_restrictions, - status.namespace_active, - status.requested.network_isolation, - status.network_active, - status.filesystem_mode.as_str(), - status.filesystem_active, - if status.allowed_mounts.is_empty() { - "".to_string() - } else { - status.allowed_mounts.join(", ") - }, - if status.container_markers.is_empty() { - "".to_string() - } else { - status.container_markers.join(", ") - }, - status - .fallback_reason - .clone() - .unwrap_or_else(|| "".to_string()), - ) -} - -fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String { - format!( - "Commit - Result ready - Branch {} - Workspace {} - Changed files {} - Action create a git commit from the current workspace changes", - branch.unwrap_or("unknown"), - summary.headline(), - summary.changed_files, - ) -} - -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() -} - -fn print_sandbox_status_snapshot( - output_format: CliOutputFormat, -) -> Result<(), Box> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader - .load() - .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); - let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); - match output_format { - CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&sandbox_json_value(&status))? - ), - } - Ok(()) -} - -fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value { - json!({ - "kind": "sandbox", - "enabled": status.enabled, - "active": status.active, - "supported": status.supported, - "in_container": status.in_container, - "requested_namespace": status.requested.namespace_restrictions, - "active_namespace": status.namespace_active, - "requested_network": status.requested.network_isolation, - "active_network": status.network_active, - "filesystem_mode": status.filesystem_mode.as_str(), - "filesystem_active": status.filesystem_active, - "allowed_mounts": status.allowed_mounts, - "markers": status.container_markers, - "fallback_reason": status.fallback_reason, - }) -} - -fn render_help_topic(topic: LocalHelpTopic) -> String { - match topic { - LocalHelpTopic::Status => "Status - Usage claw status [--output-format ] - Purpose show the local workspace snapshot without entering the REPL - Output model, permissions, git state, config files, and sandbox status - Formats text (default), json - Related /status · claw --resume latest /status" - .to_string(), - LocalHelpTopic::Sandbox => "Sandbox - Usage claw sandbox [--output-format ] - Purpose inspect the resolved sandbox and isolation state for the current directory - Output namespace, network, filesystem, and fallback details - Formats text (default), json - Related /sandbox · claw status" - .to_string(), - LocalHelpTopic::Doctor => "Doctor - Usage claw doctor [--output-format ] - Purpose diagnose local auth, config, workspace, sandbox, and build metadata - Output local-only health report; no provider request or session resume required - Formats text (default), json - Related /doctor · claw --resume latest /doctor" - .to_string(), - LocalHelpTopic::Acp => "ACP / Zed - Usage claw acp [serve] [--output-format ] - Aliases claw --acp · claw -acp - Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime - Status discoverability only; `serve` is a status alias and does not launch a daemon yet - Formats text (default), json - Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help" - .to_string(), - LocalHelpTopic::Init => "Init - Usage claw init [--output-format ] - Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in the current project - Output list of created vs. skipped files (idempotent: safe to re-run) - Formats text (default), json - Related claw status · claw doctor" - .to_string(), - LocalHelpTopic::State => "State - Usage claw state [--output-format ] - Purpose read .claw/worker-state.json written by the interactive REPL or a one-shot prompt - Output worker id, model, permissions, session reference (text or json) - Formats text (default), json - Produces state `claw` (interactive REPL) or `claw prompt ` (one non-interactive turn) - Observes state `claw state` reads; clawhip/CI may poll this file without HTTP - Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise - Related claw status · ROADMAP #139 (this worker-concept contract)" - .to_string(), - LocalHelpTopic::Export => "Export - Usage claw export [--session ] [--output ] [--output-format ] - Purpose serialize a managed session to JSON for review, transfer, or archival - Defaults --session latest (most recent managed session in .claw/sessions/) - Formats text (default), json - Related /session list · claw --resume latest" - .to_string(), - LocalHelpTopic::Version => "Version - Usage claw version [--output-format ] - Aliases claw --version · claw -V - Purpose print the claw CLI version and build metadata - Formats text (default), json - Related claw doctor (full build/auth/config diagnostic)" - .to_string(), - LocalHelpTopic::SystemPrompt => "System Prompt - Usage claw system-prompt [--cwd ] [--date YYYY-MM-DD] [--output-format ] - Purpose render the resolved system prompt that `claw` would send for the given cwd + date - Options --cwd overrides the workspace dir · --date injects a deterministic date stamp - Formats text (default), json - Related claw doctor · claw dump-manifests" - .to_string(), - LocalHelpTopic::DumpManifests => "Dump Manifests - Usage claw dump-manifests [--manifests-dir ] [--output-format ] - Purpose emit every skill/agent/tool manifest the resolver would load for the current cwd - Options --manifests-dir scopes discovery to a specific directory - Formats text (default), json - Related claw skills · claw agents · claw doctor" - .to_string(), - LocalHelpTopic::BootstrapPlan => "Bootstrap Plan - Usage claw bootstrap-plan [--output-format ] - Purpose list the ordered startup phases the CLI would execute before dispatch - Output phase names (text) or structured phase list (json) — primary output is the plan itself - Formats text (default), json - Related claw doctor · claw status" - .to_string(), - } -} - -fn print_help_topic(topic: LocalHelpTopic) { - println!("{}", render_help_topic(topic)); -} - -fn print_acp_status(output_format: CliOutputFormat) -> Result<(), Box> { - let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support."; - match output_format { - CliOutputFormat::Text => { - println!( - "ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}" - ); - } - CliOutputFormat::Json => { - println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "acp", - "status": "discoverability_only", - "supported": false, - "serve_alias_only": true, - "message": message, - "launch_command": serde_json::Value::Null, - "aliases": ["acp", "--acp", "-acp"], - "discoverability_tracking": "ROADMAP #64a", - "tracking": "ROADMAP #76", - "recommended_workflows": [ - "claw prompt TEXT", - "claw", - "claw doctor" - ], - }))? - ); - } - } - Ok(()) -} - -fn render_config_report(section: Option<&str>) -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let discovered = loader.discover(); - let runtime_config = loader.load()?; - - let mut lines = vec![ - format!( - "Config - Working directory {} - Loaded files {} - Merged keys {}", - cwd.display(), - runtime_config.loaded_entries().len(), - runtime_config.merged().len() - ), - "Discovered files".to_string(), - ]; - for entry in discovered { - let source = match entry.source { - ConfigSource::User => "user", - ConfigSource::Project => "project", - ConfigSource::Local => "local", - }; - let status = if runtime_config - .loaded_entries() - .iter() - .any(|loaded_entry| loaded_entry.path == entry.path) - { - "loaded" - } else { - "missing" - }; - lines.push(format!( - " {source:<7} {status:<7} {}", - entry.path.display() - )); - } - - if let Some(section) = section { - lines.push(format!("Merged section: {section}")); - let value = match section { - "env" => runtime_config.get("env"), - "hooks" => runtime_config.get("hooks"), - "model" => runtime_config.get("model"), - "plugins" => runtime_config - .get("plugins") - .or_else(|| runtime_config.get("enabledPlugins")), - other => { - lines.push(format!( - " Unsupported config section '{other}'. Use env, hooks, model, or plugins." - )); - return Ok(lines.join( - " -", - )); - } - }; - lines.push(format!( - " {}", - match value { - Some(value) => value.render(), - None => "".to_string(), - } - )); - return Ok(lines.join( - " -", - )); - } - - lines.push("Merged JSON".to_string()); - lines.push(format!(" {}", runtime_config.as_json().render())); - Ok(lines.join( - " -", - )) -} - -fn render_config_json( - _section: Option<&str>, -) -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let discovered = loader.discover(); - let runtime_config = loader.load()?; - - let loaded_paths: Vec<_> = runtime_config - .loaded_entries() - .iter() - .map(|e| e.path.display().to_string()) - .collect(); - - let files: Vec<_> = discovered - .iter() - .map(|e| { - let source = match e.source { - ConfigSource::User => "user", - ConfigSource::Project => "project", - ConfigSource::Local => "local", - }; - let is_loaded = runtime_config - .loaded_entries() - .iter() - .any(|le| le.path == e.path); - serde_json::json!({ - "path": e.path.display().to_string(), - "source": source, - "loaded": is_loaded, - }) - }) - .collect(); - - Ok(serde_json::json!({ - "kind": "config", - "cwd": cwd.display().to_string(), - "loaded_files": loaded_paths.len(), - "merged_keys": runtime_config.merged().len(), - "files": files, - })) -} - -fn render_memory_report() -> Result> { - let cwd = env::current_dir()?; - let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; - let mut lines = vec![format!( - "Memory - Working directory {} - Instruction files {}", - cwd.display(), - project_context.instruction_files.len() - )]; - if project_context.instruction_files.is_empty() { - lines.push("Discovered files".to_string()); - lines.push( - " No CLAUDE instruction files discovered in the current directory ancestry." - .to_string(), - ); - } else { - lines.push("Discovered files".to_string()); - for (index, file) in project_context.instruction_files.iter().enumerate() { - let preview = file.content.lines().next().unwrap_or("").trim(); - let preview = if preview.is_empty() { - "" - } else { - preview - }; - lines.push(format!(" {}. {}", index + 1, file.path.display(),)); - lines.push(format!( - " lines={} preview={}", - file.content.lines().count(), - preview - )); - } - } - Ok(lines.join( - " -", - )) -} - -fn render_memory_json() -> Result> { - let cwd = env::current_dir()?; - let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; - let files: Vec<_> = project_context - .instruction_files - .iter() - .map(|f| { - json!({ - "path": f.path.display().to_string(), - "lines": f.content.lines().count(), - "preview": f.content.lines().next().unwrap_or("").trim(), - }) - }) - .collect(); - Ok(json!({ - "kind": "memory", - "cwd": cwd.display().to_string(), - "instruction_files": files.len(), - "files": files, - })) -} - -fn init_claude_md() -> Result> { - let cwd = env::current_dir()?; - Ok(initialize_repo(&cwd)?.render()) -} - -fn run_init(output_format: CliOutputFormat) -> Result<(), Box> { - let cwd = env::current_dir()?; - let report = initialize_repo(&cwd)?; - let message = report.render(); - match output_format { - CliOutputFormat::Text => println!("{message}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&init_json_value(&report, &message))? - ), - } - Ok(()) -} - -/// #142: emit first-class structured fields alongside the legacy `message` -/// string so claws can detect per-artifact state without substring matching. -fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value { - use crate::init::InitStatus; - json!({ - "kind": "init", - "project_path": report.project_root.display().to_string(), - "created": report.artifacts_with_status(InitStatus::Created), - "updated": report.artifacts_with_status(InitStatus::Updated), - "skipped": report.artifacts_with_status(InitStatus::Skipped), - "artifacts": report.artifact_json_entries(), - "next_step": crate::init::InitReport::NEXT_STEP, - "message": message, - }) -} - -fn normalize_permission_mode(mode: &str) -> Option<&'static str> { - match mode.trim() { - "read-only" => Some("read-only"), - "workspace-write" => Some("workspace-write"), - "danger-full-access" => Some("danger-full-access"), - _ => None, - } -} - -fn render_diff_report() -> Result> { - render_diff_report_for(&env::current_dir()?) -} - -fn render_diff_report_for(cwd: &Path) -> Result> { - // Verify we are inside a git repository before calling `git diff`. - // Running `git diff --cached` outside a git tree produces a misleading - // "unknown option `cached`" error because git falls back to --no-index mode. - let in_git_repo = std::process::Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .current_dir(cwd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if !in_git_repo { - return Ok(format!( - "Diff\n Result no git repository\n Detail {} is not inside a git project", - cwd.display() - )); - } - let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; - let unstaged = run_git_diff_command_in(cwd, &["diff"])?; - if staged.trim().is_empty() && unstaged.trim().is_empty() { - return Ok( - "Diff\n Result clean working tree\n Detail no current changes" - .to_string(), - ); - } - - let mut sections = Vec::new(); - if !staged.trim().is_empty() { - sections.push(format!("Staged changes:\n{}", staged.trim_end())); - } - if !unstaged.trim().is_empty() { - sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end())); - } - - Ok(format!("Diff\n\n{}", sections.join("\n\n"))) -} - -fn render_diff_json_for(cwd: &Path) -> Result> { - let in_git_repo = std::process::Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .current_dir(cwd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if !in_git_repo { - return Ok(serde_json::json!({ - "kind": "diff", - "result": "no_git_repo", - "detail": format!("{} is not inside a git project", cwd.display()), - })); - } - let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; - let unstaged = run_git_diff_command_in(cwd, &["diff"])?; - Ok(serde_json::json!({ - "kind": "diff", - "result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" }, - "staged": staged.trim(), - "unstaged": unstaged.trim(), - })) -} - -fn run_git_diff_command_in( - cwd: &Path, - args: &[&str], -) -> Result> { - let output = std::process::Command::new("git") - .args(args) - .current_dir(cwd) - .output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); - } - Ok(String::from_utf8(output.stdout)?) -} - -fn render_teleport_report(target: &str) -> Result> { - let cwd = env::current_dir()?; - - let file_list = Command::new("rg") - .args(["--files"]) - .current_dir(&cwd) - .output()?; - let file_matches = if file_list.status.success() { - String::from_utf8(file_list.stdout)? - .lines() - .filter(|line| line.contains(target)) - .take(10) - .map(ToOwned::to_owned) - .collect::>() - } else { - Vec::new() - }; - - let content_output = Command::new("rg") - .args(["-n", "-S", "--color", "never", target, "."]) - .current_dir(&cwd) - .output()?; - - let mut lines = vec![ - "Teleport".to_string(), - format!(" Target {target}"), - " Action search workspace files and content for the target".to_string(), - ]; - if !file_matches.is_empty() { - lines.push(String::new()); - lines.push("File matches".to_string()); - lines.extend(file_matches.into_iter().map(|path| format!(" {path}"))); - } - - if content_output.status.success() { - let matches = String::from_utf8(content_output.stdout)?; - if !matches.trim().is_empty() { - lines.push(String::new()); - lines.push("Content matches".to_string()); - lines.push(truncate_for_prompt(&matches, 4_000)); - } - } - - if lines.len() == 1 { - lines.push(" Result no matches found".to_string()); - } - - Ok(lines.join("\n")) -} - -fn render_last_tool_debug_report(session: &Session) -> Result> { - let last_tool_use = session - .messages - .iter() - .rev() - .find_map(|message| { - message.blocks.iter().rev().find_map(|block| match block { - ContentBlock::ToolUse { id, name, input } => { - Some((id.clone(), name.clone(), input.clone())) - } - _ => None, - }) - }) - .ok_or_else(|| "no prior tool call found in session".to_string())?; - - let tool_result = session.messages.iter().rev().find_map(|message| { - message.blocks.iter().rev().find_map(|block| match block { - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } if tool_use_id == &last_tool_use.0 => { - Some((tool_name.clone(), output.clone(), *is_error)) - } - _ => None, - }) - }); - - let mut lines = vec![ - "Debug tool call".to_string(), - " Action inspect the last recorded tool call and its result".to_string(), - format!(" Tool id {}", last_tool_use.0), - format!(" Tool name {}", last_tool_use.1), - " Input".to_string(), - indent_block(&last_tool_use.2, 4), - ]; - - match tool_result { - Some((tool_name, output, is_error)) => { - lines.push(" Result".to_string()); - lines.push(format!(" name {tool_name}")); - lines.push(format!( - " status {}", - if is_error { "error" } else { "ok" } - )); - lines.push(indent_block(&output, 4)); - } - None => lines.push(" Result missing tool result".to_string()), - } - - Ok(lines.join("\n")) -} - -fn indent_block(value: &str, spaces: usize) -> String { - let indent = " ".repeat(spaces); - value - .lines() - .map(|line| format!("{indent}{line}")) - .collect::>() - .join("\n") -} - -fn validate_no_args( - command_name: &str, - args: Option<&str>, -) -> Result<(), Box> { - if let Some(args) = args.map(str::trim).filter(|value| !value.is_empty()) { - return Err(format!( - "{command_name} does not accept arguments. Received: {args}\nUsage: {command_name}" - ) - .into()); - } - Ok(()) -} - -fn format_bughunter_report(scope: Option<&str>) -> String { - format!( - "Bughunter - Scope {} - Action inspect the selected code for likely bugs and correctness issues - Output findings should include file paths, severity, and suggested fixes", - scope.unwrap_or("the current repository") - ) -} - -fn format_ultraplan_report(task: Option<&str>) -> String { - format!( - "Ultraplan - Task {} - Action break work into a multi-step execution plan - Output plan should cover goals, risks, sequencing, verification, and rollback", - task.unwrap_or("the current repo work") - ) -} - -fn format_pr_report(branch: &str, context: Option<&str>) -> String { - format!( - "PR - Branch {branch} - Context {} - Action draft or create a pull request for the current branch - Output title and markdown body suitable for GitHub", - context.unwrap_or("none") - ) -} - -fn format_issue_report(context: Option<&str>) -> String { - format!( - "Issue - Context {} - Action draft or create a GitHub issue from the current context - Output title and markdown body suitable for GitHub", - context.unwrap_or("none") - ) -} - -fn git_output(args: &[&str]) -> Result> { - let output = Command::new("git") - .args(args) - .current_dir(env::current_dir()?) - .output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); - } - Ok(String::from_utf8(output.stdout)?) -} - -fn git_status_ok(args: &[&str]) -> Result<(), Box> { - let output = Command::new("git") - .args(args) - .current_dir(env::current_dir()?) - .output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); - } - Ok(()) -} - -fn command_exists(name: &str) -> bool { - Command::new("which") - .arg(name) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -fn write_temp_text_file( - filename: &str, - contents: &str, -) -> Result> { - let path = env::temp_dir().join(filename); - fs::write(&path, contents)?; - Ok(path) -} - -const DEFAULT_HISTORY_LIMIT: usize = 20; - -fn parse_history_count(raw: Option<&str>) -> Result { - let Some(raw) = raw else { - return Ok(DEFAULT_HISTORY_LIMIT); - }; - let parsed: usize = raw - .parse() - .map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?; - if parsed == 0 { - return Err("history: count must be greater than 0.".to_string()); - } - Ok(parsed) -} - -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 -)] -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) -} - -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") -} - -fn collect_session_prompt_history(session: &Session) -> Vec { - if !session.prompt_history.is_empty() { - return session - .prompt_history - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect(); - } - let timestamp_ms = session.updated_at_ms; - session - .messages - .iter() - .filter(|message| message.role == MessageRole::User) - .filter_map(|message| { - message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(PromptHistoryEntry { - timestamp_ms, - text: text.clone(), - }), - _ => None, - }) - }) - .collect() -} - -fn recent_user_context(session: &Session, limit: usize) -> String { - let requests = session - .messages - .iter() - .filter(|message| message.role == MessageRole::User) - .filter_map(|message| { - message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(text.trim().to_string()), - _ => None, - }) - }) - .rev() - .take(limit) - .collect::>(); - - if requests.is_empty() { - "".to_string() - } else { - requests - .into_iter() - .rev() - .enumerate() - .map(|(index, text)| format!("{}. {}", index + 1, text)) - .collect::>() - .join("\n") - } -} - -fn truncate_for_prompt(value: &str, limit: usize) -> String { - if value.chars().count() <= limit { - value.trim().to_string() - } else { - let truncated = value.chars().take(limit).collect::(); - format!("{}\n…[truncated]", truncated.trim_end()) - } -} - -fn sanitize_generated_message(value: &str) -> String { - value.trim().trim_matches('`').trim().replace("\r\n", "\n") -} - -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())) -} - -fn render_version_report() -> String { - let git_sha = GIT_SHA.unwrap_or("unknown"); - let target = BUILD_TARGET.unwrap_or("unknown"); - format!( - "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" - ) -} - -fn render_export_text(session: &Session) -> String { - let mut lines = vec!["# Conversation Export".to_string(), String::new()]; - for (index, message) in session.messages.iter().enumerate() { - let role = match message.role { - MessageRole::System => "system", - MessageRole::User => "user", - MessageRole::Assistant => "assistant", - MessageRole::Tool => "tool", - }; - lines.push(format!("## {}. {role}", index + 1)); - for block in &message.blocks { - match block { - ContentBlock::Text { text } => lines.push(text.clone()), - ContentBlock::ToolUse { id, name, input } => { - lines.push(format!("[tool_use id={id} name={name}] {input}")); - } - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => { - lines.push(format!( - "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" - )); - } - } - } - lines.push(String::new()); - } - lines.join("\n") -} - -fn default_export_filename(session: &Session) -> String { - let stem = session - .messages - .iter() - .find_map(|message| match message.role { - MessageRole::User => message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }), - _ => None, - }) - .map_or("conversation", |text| { - text.lines().next().unwrap_or("conversation") - }) - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() { - ch.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::() - .split('-') - .filter(|part| !part.is_empty()) - .take(8) - .collect::>() - .join("-"); - let fallback = if stem.is_empty() { - "conversation" - } else { - &stem - }; - format!("{fallback}.txt") -} - -fn resolve_export_path( - requested_path: Option<&str>, - session: &Session, -) -> Result> { - let cwd = env::current_dir()?; - let file_name = - requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned); - let final_name = if Path::new(&file_name) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) - { - file_name - } else { - format!("{file_name}.txt") - }; - Ok(cwd.join(final_name)) -} - -const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280; - -fn summarize_tool_payload_for_markdown(payload: &str) -> String { - let compact = match serde_json::from_str::(payload) { - Ok(value) => value.to_string(), - Err(_) => payload.split_whitespace().collect::>().join(" "), - }; - if compact.is_empty() { - return String::new(); - } - truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT) -} - -fn run_export( - session_reference: &str, - output_path: Option<&Path>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let (handle, session) = load_session_reference(session_reference)?; - let markdown = render_session_markdown(&session, &handle.id, &handle.path); - - if let Some(path) = output_path { - fs::write(path, &markdown)?; - let report = format!( - "Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}", - path.display(), - handle.id, - session.messages.len(), - ); - match output_format { - CliOutputFormat::Text => println!("{report}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "export", - "message": report, - "session_id": handle.id, - "file": path.display().to_string(), - "messages": session.messages.len(), - }))? - ), - } - return Ok(()); - } - - match output_format { - CliOutputFormat::Text => { - print!("{markdown}"); - if !markdown.ends_with('\n') { - println!(); - } - } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "export", - "session_id": handle.id, - "file": handle.path.display().to_string(), - "messages": session.messages.len(), - "markdown": markdown, - }))? - ), - } - Ok(()) -} - -fn render_session_markdown(session: &Session, session_id: &str, session_path: &Path) -> String { - let mut lines = vec![ - "# Conversation Export".to_string(), - String::new(), - format!("- **Session**: `{session_id}`"), - format!("- **File**: `{}`", session_path.display()), - format!("- **Messages**: {}", session.messages.len()), - ]; - if let Some(workspace_root) = session.workspace_root() { - lines.push(format!("- **Workspace**: `{}`", workspace_root.display())); - } - if let Some(fork) = &session.fork { - let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)"); - lines.push(format!( - "- **Forked from**: `{}` (branch `{branch}`)", - fork.parent_session_id - )); - } - if let Some(compaction) = &session.compaction { - lines.push(format!( - "- **Compactions**: {} (last removed {} messages)", - compaction.count, compaction.removed_message_count - )); - } - lines.push(String::new()); - lines.push("---".to_string()); - lines.push(String::new()); - - for (index, message) in session.messages.iter().enumerate() { - let role = match message.role { - MessageRole::System => "System", - MessageRole::User => "User", - MessageRole::Assistant => "Assistant", - MessageRole::Tool => "Tool", - }; - lines.push(format!("## {}. {role}", index + 1)); - lines.push(String::new()); - for block in &message.blocks { - match block { - ContentBlock::Text { text } => { - let trimmed = text.trim_end(); - if !trimmed.is_empty() { - lines.push(trimmed.to_string()); - lines.push(String::new()); - } - } - ContentBlock::ToolUse { id, name, input } => { - lines.push(format!( - "**Tool call** `{name}` _(id `{}`)_", - short_tool_id(id) - )); - let summary = summarize_tool_payload_for_markdown(input); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => { - let status = if *is_error { "error" } else { "ok" }; - lines.push(format!( - "**Tool result** `{tool_name}` _(id `{}`, {status})_", - short_tool_id(tool_use_id) - )); - let summary = summarize_tool_payload_for_markdown(output); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - } - } - if let Some(usage) = message.usage { - lines.push(format!( - "_tokens: in={} out={} cache_create={} cache_read={}_", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - )); - lines.push(String::new()); - } - } - lines.join("\n") -} - -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}…") -} - -fn build_system_prompt() -> Result, Box> { - Ok(load_system_prompt( - env::current_dir()?, - DEFAULT_DATE, - env::consts::OS, - "unknown", - )?) -} - -fn build_runtime_plugin_state() -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) -} - -fn build_runtime_plugin_state_with_loader( - cwd: &Path, - loader: &ConfigLoader, - runtime_config: &runtime::RuntimeConfig, -) -> Result> { - let plugin_manager = build_plugin_manager(cwd, loader, runtime_config); - let plugin_registry = plugin_manager.plugin_registry()?; - let plugin_hook_config = - runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?); - let feature_config = runtime_config - .feature_config() - .clone() - .with_hooks(runtime_config.hooks().merged(&plugin_hook_config)); - let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?; - let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)? - .with_runtime_tools(runtime_tools)?; - Ok(RuntimePluginState { - feature_config, - tool_registry, - plugin_registry, - mcp_state, - }) -} - -fn build_plugin_manager( - cwd: &Path, - loader: &ConfigLoader, - runtime_config: &runtime::RuntimeConfig, -) -> PluginManager { - let plugin_settings = runtime_config.plugins(); - let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); - plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); - plugin_config.external_dirs = plugin_settings - .external_directories() - .iter() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) - .collect(); - plugin_config.install_root = plugin_settings - .install_root() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); - plugin_config.registry_path = plugin_settings - .registry_path() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); - plugin_config.bundled_root = plugin_settings - .bundled_root() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); - PluginManager::new(plugin_config) -} - -fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { - let path = PathBuf::from(value); - if path.is_absolute() { - path - } else if value.starts_with('.') { - cwd.join(path) - } else { - config_home.join(path) - } -} - -fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig { - runtime::RuntimeHookConfig::new( - hooks.pre_tool_use, - hooks.post_tool_use, - hooks.post_tool_use_failure, - ) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct InternalPromptProgressState { - command_label: &'static str, - task_label: String, - step: usize, - phase: String, - detail: Option, - saw_final_text: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InternalPromptProgressEvent { - Started, - Update, - Heartbeat, - Complete, - Failed, -} - -#[derive(Debug)] -struct InternalPromptProgressShared { - state: Mutex, - output_lock: Mutex<()>, - started_at: Instant, -} - -#[derive(Debug, Clone)] -struct InternalPromptProgressReporter { - shared: Arc, -} - -#[derive(Debug)] -struct InternalPromptProgressRun { - reporter: InternalPromptProgressReporter, - heartbeat_stop: Option>, - heartbeat_handle: Option>, -} - -impl InternalPromptProgressReporter { - fn ultraplan(task: &str) -> Self { - Self { - shared: Arc::new(InternalPromptProgressShared { - state: Mutex::new(InternalPromptProgressState { - command_label: "Ultraplan", - task_label: task.to_string(), - step: 0, - phase: "planning started".to_string(), - detail: Some(format!("task: {task}")), - saw_final_text: false, - }), - output_lock: Mutex::new(()), - started_at: Instant::now(), - }), - } - } - - fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { - let snapshot = self.snapshot(); - let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); - self.write_line(&line); - } - - fn mark_model_phase(&self) { - let snapshot = { - let mut state = self - .shared - .state - .lock() - .expect("internal prompt progress state poisoned"); - state.step += 1; - state.phase = if state.step == 1 { - "analyzing request".to_string() - } else { - "reviewing findings".to_string() - }; - state.detail = Some(format!("task: {}", state.task_label)); - state.clone() - }; - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Update, - &snapshot, - self.elapsed(), - None, - )); - } - - fn mark_tool_phase(&self, name: &str, input: &str) { - let detail = describe_tool_progress(name, input); - let snapshot = { - let mut state = self - .shared - .state - .lock() - .expect("internal prompt progress state poisoned"); - state.step += 1; - state.phase = format!("running {name}"); - state.detail = Some(detail); - state.clone() - }; - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Update, - &snapshot, - self.elapsed(), - None, - )); - } - - fn mark_text_phase(&self, text: &str) { - let trimmed = text.trim(); - if trimmed.is_empty() { - return; - } - let detail = truncate_for_summary(first_visible_line(trimmed), 120); - let snapshot = { - let mut state = self - .shared - .state - .lock() - .expect("internal prompt progress state poisoned"); - if state.saw_final_text { - return; - } - state.saw_final_text = true; - state.step += 1; - state.phase = "drafting final plan".to_string(); - state.detail = (!detail.is_empty()).then_some(detail); - state.clone() - }; - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Update, - &snapshot, - self.elapsed(), - None, - )); - } - - fn emit_heartbeat(&self) { - let snapshot = self.snapshot(); - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Heartbeat, - &snapshot, - self.elapsed(), - None, - )); - } - - fn snapshot(&self) -> InternalPromptProgressState { - self.shared - .state - .lock() - .expect("internal prompt progress state poisoned") - .clone() - } - - fn elapsed(&self) -> Duration { - self.shared.started_at.elapsed() - } - - fn write_line(&self, line: &str) { - let _guard = self - .shared - .output_lock - .lock() - .expect("internal prompt progress output lock poisoned"); - let mut stdout = io::stdout(); - let _ = writeln!(stdout, "{line}"); - let _ = stdout.flush(); - } -} - -impl InternalPromptProgressRun { - fn start_ultraplan(task: &str) -> Self { - let reporter = InternalPromptProgressReporter::ultraplan(task); - reporter.emit(InternalPromptProgressEvent::Started, None); - - let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); - let heartbeat_reporter = reporter.clone(); - let heartbeat_handle = thread::spawn(move || loop { - match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { - Ok(()) | Err(RecvTimeoutError::Disconnected) => break, - Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), - } - }); - - Self { - reporter, - heartbeat_stop: Some(heartbeat_stop), - heartbeat_handle: Some(heartbeat_handle), - } - } - - fn reporter(&self) -> InternalPromptProgressReporter { - self.reporter.clone() - } - - fn finish_success(&mut self) { - self.stop_heartbeat(); - self.reporter - .emit(InternalPromptProgressEvent::Complete, None); - } - - fn finish_failure(&mut self, error: &str) { - self.stop_heartbeat(); - self.reporter - .emit(InternalPromptProgressEvent::Failed, Some(error)); - } - - fn stop_heartbeat(&mut self) { - if let Some(sender) = self.heartbeat_stop.take() { - let _ = sender.send(()); - } - if let Some(handle) = self.heartbeat_handle.take() { - let _ = handle.join(); - } - } -} - -impl Drop for InternalPromptProgressRun { - fn drop(&mut self) { - self.stop_heartbeat(); - } -} - -fn format_internal_prompt_progress_line( - event: InternalPromptProgressEvent, - snapshot: &InternalPromptProgressState, - elapsed: Duration, - error: Option<&str>, -) -> String { - let elapsed_seconds = elapsed.as_secs(); - let step_label = if snapshot.step == 0 { - "current step pending".to_string() - } else { - format!("current step {}", snapshot.step) - }; - let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)]; - if let Some(detail) = snapshot - .detail - .as_deref() - .filter(|detail| !detail.is_empty()) - { - status_bits.push(detail.to_string()); - } - let status = status_bits.join(" · "); - match event { - InternalPromptProgressEvent::Started => { - format!( - "🧭 {} status · planning started · {status}", - snapshot.command_label - ) + SlashCommand::Status => { + let tracker = UsageTracker::from_session(session); + let usage = tracker.cumulative_usage(); + let context = status_context(Some(session_path))?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_status_report( + session.model.as_deref().unwrap_or("restored-session"), + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &context, + None, // #148: resumed sessions don't have flag provenance + )), + json: Some(status_json_value( + session.model.as_deref(), + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &context, + None, // #148: resumed sessions don't have flag provenance + )), + }) } - InternalPromptProgressEvent::Update => { - format!("… {} status · {status}", snapshot.command_label) + SlashCommand::Sandbox => { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_sandbox_report(&status)), + json: Some(sandbox_json_value(&status)), + }) } - InternalPromptProgressEvent::Heartbeat => format!( - "… {} heartbeat · {elapsed_seconds}s elapsed · {status}", - snapshot.command_label - ), - InternalPromptProgressEvent::Complete => format!( - "✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total", - snapshot.command_label, snapshot.step - ), - InternalPromptProgressEvent::Failed => format!( - "✘ {} status · failed · {elapsed_seconds}s elapsed · {}", - snapshot.command_label, - error.unwrap_or("unknown error") - ), - } -} - -fn describe_tool_progress(name: &str, input: &str) -> String { - let parsed: serde_json::Value = - serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); - match name { - "bash" | "Bash" => { - let command = parsed - .get("command") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - if command.is_empty() { - "running shell command".to_string() - } else { - format!("command {}", truncate_for_summary(command.trim(), 100)) - } + SlashCommand::Cost => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_cost_report(usage)), + json: Some(serde_json::json!({ + "kind": "cost", + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_creation_input_tokens": usage.cache_creation_input_tokens, + "cache_read_input_tokens": usage.cache_read_input_tokens, + "total_tokens": usage.total_tokens(), + })), + }) } - "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), - "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), - "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)), - "glob_search" | "Glob" => { - let pattern = parsed - .get("pattern") - .and_then(|value| value.as_str()) - .unwrap_or("?"); - let scope = parsed - .get("path") - .and_then(|value| value.as_str()) - .unwrap_or("."); - format!("glob `{pattern}` in {scope}") + SlashCommand::Config { section } => { + let message = render_config_report(section.as_deref())?; + let json = render_config_json(section.as_deref())?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message), + json: Some(json), + }) } - "grep_search" | "Grep" => { - let pattern = parsed - .get("pattern") - .and_then(|value| value.as_str()) - .unwrap_or("?"); - let scope = parsed - .get("path") - .and_then(|value| value.as_str()) - .unwrap_or("."); - format!("grep `{pattern}` in {scope}") + SlashCommand::Mcp { action, target } => { + let cwd = env::current_dir()?; + 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()), + }; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?), + json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)?), + }) } - "web_search" | "WebSearch" => parsed - .get("query") - .and_then(|value| value.as_str()) - .map_or_else( - || "running web search".to_string(), - |query| format!("query {}", truncate_for_summary(query, 100)), - ), - _ => { - let summary = summarize_tool_payload(input); - if summary.is_empty() { - format!("running {name}") - } else { - format!("{name}: {summary}") - } + SlashCommand::Memory => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_memory_report()?), + json: Some(render_memory_json()?), + }), + SlashCommand::Init => { + // #142: run the init once, then render both text + structured JSON + // from the same InitReport so both surfaces stay in sync. + let cwd = env::current_dir()?; + let report = crate::init::initialize_repo(&cwd)?; + let message = report.render(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message.clone()), + json: Some(init_json_value(&report, &message)), + }) } - } -} - -#[allow(clippy::needless_pass_by_value)] -#[allow(clippy::too_many_arguments)] -fn build_runtime( - session: Session, - session_id: &str, - model: String, - system_prompt: Vec, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - permission_mode: PermissionMode, - progress_reporter: Option, -) -> Result> { - let runtime_plugin_state = build_runtime_plugin_state()?; - build_runtime_with_plugin_state( - session, - session_id, - model, - system_prompt, - enable_tools, - emit_output, - allowed_tools, - permission_mode, - progress_reporter, - runtime_plugin_state, - ) -} - -#[allow(clippy::needless_pass_by_value)] -#[allow(clippy::too_many_arguments)] -fn build_runtime_with_plugin_state( - mut session: Session, - session_id: &str, - model: String, - system_prompt: Vec, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - permission_mode: PermissionMode, - progress_reporter: Option, - runtime_plugin_state: RuntimePluginState, -) -> Result> { - // Persist the model in session metadata so resumed sessions can report it. - if session.model.is_none() { - session.model = Some(model.clone()); - } - let RuntimePluginState { - feature_config, - tool_registry, - plugin_registry, - mcp_state, - } = runtime_plugin_state; - plugin_registry.initialize()?; - let policy = permission_policy(permission_mode, &feature_config, &tool_registry) - .map_err(std::io::Error::other)?; - let mut runtime = ConversationRuntime::new_with_features( - session, - AnthropicRuntimeClient::new( - session_id, - model, - enable_tools, - emit_output, - allowed_tools.clone(), - tool_registry.clone(), - progress_reporter, - )?, - CliToolExecutor::new( - allowed_tools.clone(), - emit_output, - tool_registry.clone(), - mcp_state.clone(), - ), - policy, - system_prompt, - &feature_config, - ); - if emit_output { - runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter)); - } - Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) -} - -struct CliHookProgressReporter; - -impl runtime::HookProgressReporter for CliHookProgressReporter { - fn on_event(&mut self, event: &runtime::HookProgressEvent) { - match event { - runtime::HookProgressEvent::Started { - event, - tool_name, - command, - } => eprintln!( - "[hook {event_name}] {tool_name}: {command}", - event_name = event.as_str() - ), - runtime::HookProgressEvent::Completed { - event, - tool_name, - command, - } => eprintln!( - "[hook done {event_name}] {tool_name}: {command}", - event_name = event.as_str() - ), - runtime::HookProgressEvent::Cancelled { - event, - tool_name, - command, - } => eprintln!( - "[hook cancelled {event_name}] {tool_name}: {command}", - event_name = event.as_str() - ), + SlashCommand::Diff => { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let message = render_diff_report_for(&cwd)?; + let json = render_diff_json_for(&cwd)?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message), + json: Some(json), + }) } - } -} - -struct CliPermissionPrompter { - current_mode: PermissionMode, -} - -impl CliPermissionPrompter { - 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}"); + SlashCommand::Version => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_version_report()), + json: Some(version_json_value()), + }), + SlashCommand::Export { path } => { + let export_path = resolve_export_path(path.as_deref(), session)?; + fs::write(&export_path, render_export_text(session))?; + let msg_count = session.messages.len(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + msg_count, + )), + json: Some(serde_json::json!({ + "kind": "export", + "file": export_path.display().to_string(), + "message_count": msg_count, + })), + }) } - 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}"), - }, + SlashCommand::Agents { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), + json: Some(serde_json::json!({ + "kind": "agents", + "text": handle_agents_slash_command(args.as_deref(), &cwd)?, + })), + }) } - } -} - -// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct -// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI, -// OpenAI, or DashScope at construction time based on -// `detect_provider_kind(&model)`. The struct name is kept to avoid -// churning `BuiltRuntime` and every Deref/DerefMut site that references -// it. See ROADMAP #29 for the provider-dispatch routing fix. -struct AnthropicRuntimeClient { - runtime: tokio::runtime::Runtime, - client: ApiProviderClient, - session_id: String, - model: String, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - tool_registry: GlobalToolRegistry, - progress_reporter: Option, - reasoning_effort: Option, -} - -impl AnthropicRuntimeClient { - fn new( - session_id: &str, - model: String, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - tool_registry: GlobalToolRegistry, - progress_reporter: Option, - ) -> Result> { - // Dispatch to the correct provider at construction time. - // `ApiProviderClient` (exposed by the api crate as - // `ProviderClient`) is an enum over Anthropic / xAI / OpenAI - // variants, where xAI and OpenAI both use the OpenAI-compat - // wire format under the hood. We consult - // `detect_provider_kind(&resolved_model)` so model-name prefix - // routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over - // env-var presence. - // - // For Anthropic we build the client directly instead of going - // through `ApiProviderClient::from_model_with_anthropic_auth` - // so we can explicitly apply `api::read_base_url()` — that - // reads `ANTHROPIC_BASE_URL` and is required for the local - // mock-server test harness - // (`crates/rusty-claude-cli/tests/compact_output.rs`) to point - // claw at its fake Anthropic endpoint. We also attach a - // session-scoped prompt cache on the Anthropic path; the - // prompt cache is Anthropic-only so non-Anthropic variants - // skip it. - let resolved_model = api::resolve_model_alias(&model); - let client = match detect_provider_kind(&resolved_model) { - ProviderKind::Anthropic => { - let auth = resolve_cli_auth_source()?; - let inner = AnthropicClient::from_auth(auth) - .with_base_url(api::read_base_url()) - .with_prompt_cache(PromptCache::new(session_id)); - ApiProviderClient::Anthropic(inner) - } - ProviderKind::Xai | ProviderKind::OpenAi => { - // The api crate's `ProviderClient::from_model_with_anthropic_auth` - // with `None` for the anthropic auth routes via - // `detect_provider_kind` and builds an - // `OpenAiCompatClient::from_env` with the matching - // `OpenAiCompatConfig` (openai / xai / dashscope). - // That reads the correct API-key env var and BASE_URL - // override internally, so this one call covers OpenAI, - // OpenRouter, xAI, DashScope, Ollama, and any other - // OpenAI-compat endpoint users configure via - // `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`. - ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + SlashCommand::Skills { args } => { + if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { + return Err( + "resumed /skills invocations are interactive-only; start `claw` and run `/skills ` in the REPL".into(), + ); } - }; - Ok(Self { - runtime: tokio::runtime::Runtime::new()?, - client, - session_id: session_id.to_string(), - model, - enable_tools, - emit_output, - allowed_tools, - tool_registry, - progress_reporter, - reasoning_effort: None, - }) - } - - fn set_reasoning_effort(&mut self, effort: Option) { - self.reasoning_effort = effort; - } -} - -fn resolve_cli_auth_source() -> Result> { - Ok(resolve_cli_auth_source_for_cwd()?) -} - -fn resolve_cli_auth_source_for_cwd() -> Result { - resolve_startup_auth_source(|| Ok(None)) -} - -impl ApiClient for AnthropicRuntimeClient { - #[allow(clippy::too_many_lines)] - fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { - if let Some(progress_reporter) = &self.progress_reporter { - progress_reporter.mark_model_phase(); + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), + json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?), + }) } - let is_post_tool = request_ends_with_tool_result(&request); - let message_request = MessageRequest { - model: self.model.clone(), - max_tokens: max_tokens_for_model(&self.model), - messages: convert_messages(&request.messages), - system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), - tools: self - .enable_tools - .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())), - tool_choice: self.enable_tools.then_some(ToolChoice::Auto), - stream: true, - reasoning_effort: self.reasoning_effort.clone(), - ..Default::default() - }; - - self.runtime.block_on(async { - // When resuming after tool execution, apply a stall timeout on the - // first stream event. If the model does not respond within the - // deadline we drop the stalled connection and re-send the request as - // a continuation nudge (one retry only). - let max_attempts: usize = if is_post_tool { 2 } else { 1 }; - - for attempt in 1..=max_attempts { - let result = self - .consume_stream(&message_request, is_post_tool && attempt == 1) - .await; - match result { - Ok(events) => return Ok(events), - Err(error) - if error.to_string().contains("post-tool stall") - && attempt < max_attempts => - { - // Stalled after tool completion — nudge the model by - // re-sending the same request. - } - Err(error) => return Err(error), - } - } - - Err(RuntimeError::new("post-tool continuation nudge exhausted")) - }) - } -} - -impl AnthropicRuntimeClient { - /// Consume a single streaming response, optionally applying a stall - /// timeout on the first event for post-tool continuations. - #[allow(clippy::too_many_lines)] - async fn consume_stream( - &self, - message_request: &MessageRequest, - apply_stall_timeout: bool, - ) -> Result, RuntimeError> { - let mut stream = self - .client - .stream_message(message_request) - .await - .map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })?; - let mut stdout = io::stdout(); - let mut sink = io::sink(); - let out: &mut dyn Write = if self.emit_output { - &mut stdout - } else { - &mut sink - }; - let renderer = TerminalRenderer::new(); - let mut markdown_stream = MarkdownStreamState::default(); - let mut events = Vec::new(); - let mut pending_tool: Option<(String, String, String)> = None; - let mut block_has_thinking_summary = false; - let mut saw_stop = false; - let mut received_any_event = false; - - loop { - let next = if apply_stall_timeout && !received_any_event { - match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await { - Ok(inner) => inner.map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })?, - Err(_elapsed) => { - return Err(RuntimeError::new( - "post-tool stall: model did not respond within timeout", - )); - } - } - } else { - stream.next_event().await.map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })? - }; - - let Some(event) = next else { - break; - }; - received_any_event = true; - - match event { - ApiStreamEvent::MessageStart(start) => { - for block in start.message.content { - push_output_block( - block, - out, - &mut events, - &mut pending_tool, - true, - &mut block_has_thinking_summary, - )?; - } - } - ApiStreamEvent::ContentBlockStart(start) => { - push_output_block( - start.content_block, - out, - &mut events, - &mut pending_tool, - true, - &mut block_has_thinking_summary, - )?; - } - ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { - ContentBlockDelta::TextDelta { text } => { - if !text.is_empty() { - if let Some(progress_reporter) = &self.progress_reporter { - progress_reporter.mark_text_phase(&text); - } - if let Some(rendered) = markdown_stream.push(&renderer, &text) { - write!(out, "{rendered}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - } - events.push(AssistantEvent::TextDelta(text)); - } - } - ContentBlockDelta::InputJsonDelta { partial_json } => { - if let Some((_, _, input)) = &mut pending_tool { - input.push_str(&partial_json); - } - } - ContentBlockDelta::ThinkingDelta { .. } => { - if !block_has_thinking_summary { - render_thinking_block_summary(out, None, false)?; - block_has_thinking_summary = true; - } - } - ContentBlockDelta::SignatureDelta { .. } => {} - }, - ApiStreamEvent::ContentBlockStop(_) => { - block_has_thinking_summary = false; - if let Some(rendered) = markdown_stream.flush(&renderer) { - write!(out, "{rendered}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - } - if let Some((id, name, input)) = pending_tool.take() { - if let Some(progress_reporter) = &self.progress_reporter { - progress_reporter.mark_tool_phase(&name, &input); - } - // Display tool call now that input is fully accumulated - writeln!(out, "\n{}", format_tool_call_start(&name, &input)) - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - events.push(AssistantEvent::ToolUse { id, name, input }); - } - } - ApiStreamEvent::MessageDelta(delta) => { - events.push(AssistantEvent::Usage(delta.usage.token_usage())); - } - ApiStreamEvent::MessageStop(_) => { - saw_stop = true; - if let Some(rendered) = markdown_stream.flush(&renderer) { - write!(out, "{rendered}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - } - events.push(AssistantEvent::MessageStop); - } - } + SlashCommand::Doctor => { + let report = render_doctor_report()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(report.render()), + json: Some(report.json_value()), + }) } - - push_prompt_cache_record(&self.client, &mut events); - - if !saw_stop - && events.iter().any(|event| { - matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty()) - || matches!(event, AssistantEvent::ToolUse { .. }) + SlashCommand::Stats => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_cost_report(usage)), + json: Some(serde_json::json!({ + "kind": "stats", + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_creation_input_tokens": usage.cache_creation_input_tokens, + "cache_read_input_tokens": usage.cache_read_input_tokens, + "total_tokens": usage.total_tokens(), + })), }) - { - events.push(AssistantEvent::MessageStop); } - - if events - .iter() - .any(|event| matches!(event, AssistantEvent::MessageStop)) - { - return Ok(events); + SlashCommand::History { count } => { + let limit = parse_history_count(count.as_deref()) + .map_err(|error| -> Box { error.into() })?; + let entries = collect_session_prompt_history(session); + let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_prompt_history_report(&entries, limit)), + json: Some(serde_json::json!({ + "kind": "history", + "total": entries.len(), + "showing": shown.len(), + "entries": shown.iter().map(|e| serde_json::json!({ + "timestamp_ms": e.timestamp_ms, + "text": e.text, + })).collect::>(), + })), + }) } - - let response = self - .client - .send_message(&MessageRequest { - stream: false, - ..message_request.clone() + SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), + // /session list can be served from the sessions directory without a live session. + SlashCommand::Session { + action: Some(ref act), + .. + } if act == "list" => { + let sessions = list_managed_sessions().unwrap_or_default(); + let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); + let active_id = session.session_id.clone(); + let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(text), + json: Some(serde_json::json!({ + "kind": "session_list", + "sessions": session_ids, + "active": active_id, + })), }) - .await - .map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })?; - let mut events = response_to_events(response, out)?; - push_prompt_cache_record(&self.client, &mut events); - Ok(events) - } -} - -/// Returns `true` when the conversation ends with a tool-result message, -/// meaning the model is expected to continue after tool execution. -fn request_ends_with_tool_result(request: &ApiRequest) -> bool { - request - .messages - .last() - .is_some_and(|message| message.role == MessageRole::Tool) -} - -fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String { - if error.is_context_window_failure() { - format_context_window_blocked_error(session_id, error) - } else if error.is_generic_fatal_wrapper() { - let mut qualifiers = vec![format!("session {session_id}")]; - if let Some(request_id) = error.request_id() { - qualifiers.push(format!("trace {request_id}")); } - format!( - "{} ({}): {}", - error.safe_failure_class(), - qualifiers.join(", "), - error - ) - } else { - error.to_string() + SlashCommand::Bughunter { .. } + | SlashCommand::Commit { .. } + | SlashCommand::Pr { .. } + | SlashCommand::Issue { .. } + | SlashCommand::Ultraplan { .. } + | SlashCommand::Teleport { .. } + | SlashCommand::DebugToolCall { .. } + | SlashCommand::Resume { .. } + | SlashCommand::Model { .. } + | SlashCommand::Permissions { .. } + | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } + | 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 { .. } => Err("unsupported resumed slash command".into()), } } -fn format_context_window_blocked_error(session_id: &str, error: &api::ApiError) -> String { +fn render_session_list(active_session_id: &str) -> Result> { + let sessions = list_managed_sessions()?; let mut lines = vec![ - "Context window blocked".to_string(), - " Failure class context_window_blocked".to_string(), - format!(" Session {session_id}"), + "Sessions".to_string(), + format!(" Directory {}", sessions_dir()?.display()), ]; - - if let Some(request_id) = error.request_id() { - lines.push(format!(" Trace {request_id}")); - } - - match error { - api::ApiError::ContextWindowExceeded { - model, - estimated_input_tokens, - requested_output_tokens, - estimated_total_tokens, - context_window_tokens, - } => { - lines.push(format!(" Model {model}")); - lines.push(format!( - " Input estimate ~{estimated_input_tokens} tokens (heuristic)" - )); - lines.push(format!( - " Requested output {requested_output_tokens} tokens" - )); - lines.push(format!( - " Total estimate ~{estimated_total_tokens} tokens (heuristic)" - )); - lines.push(format!(" Context window {context_window_tokens} tokens")); - } - api::ApiError::Api { message, body, .. } => { - let detail = message.as_deref().unwrap_or(body).trim(); - if !detail.is_empty() { - lines.push(format!( - " Detail {}", - truncate_for_summary(detail, 120) - )); - } - } - api::ApiError::RetriesExhausted { last_error, .. } => { - let detail = match last_error.as_ref() { - api::ApiError::Api { message, body, .. } => message.as_deref().unwrap_or(body), - other => return format_context_window_blocked_error(session_id, other), - } - .trim(); - if !detail.is_empty() { - lines.push(format!( - " Detail {}", - truncate_for_summary(detail, 120) - )); - } - } - _ => {} - } - - lines.push(String::new()); - lines.push("Recovery".to_string()); - lines.push(" Compact /compact".to_string()); - lines.push(format!( - " Resume compact claw --resume {session_id} /compact" - )); - lines.push(" Fresh session /clear --confirm".to_string()); - lines.push( - " Reduce scope remove large pasted context/files or ask for a smaller slice" - .to_string(), - ); - lines.push(" Retry rerun after compacting or reducing the request".to_string()); - - lines.join("\n") -} - -fn final_assistant_text(summary: &runtime::TurnSummary) -> String { - summary - .assistant_messages - .last() - .map(|message| { - message - .blocks - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("") - }) - .unwrap_or_default() -} - -fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec { - summary - .assistant_messages - .iter() - .flat_map(|message| message.blocks.iter()) - .filter_map(|block| match block { - ContentBlock::ToolUse { id, name, input } => Some(json!({ - "id": id, - "name": name, - "input": input, - })), - _ => None, - }) - .collect() -} - -fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec { - summary - .tool_results - .iter() - .flat_map(|message| message.blocks.iter()) - .filter_map(|block| match block { - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => Some(json!({ - "tool_use_id": tool_use_id, - "tool_name": tool_name, - "output": output, - "is_error": is_error, - })), - _ => None, - }) - .collect() -} - -fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec { - summary - .prompt_cache_events - .iter() - .map(|event| { - json!({ - "unexpected": event.unexpected, - "reason": event.reason, - "previous_cache_read_input_tokens": event.previous_cache_read_input_tokens, - "current_cache_read_input_tokens": event.current_cache_read_input_tokens, - "token_drop": event.token_drop, - }) - }) - .collect() -} - -/// Slash commands that are registered in the spec list but not yet implemented -/// in this build. Used to filter both REPL completions and help output so the -/// discovery surface only shows commands that actually work (ROADMAP #39). -const STUB_COMMANDS: &[&str] = &[ - "login", - "logout", - "vim", - "upgrade", - "share", - "feedback", - "files", - "fast", - "exit", - "summary", - "desktop", - "brief", - "advisor", - "stickers", - "insights", - "thinkback", - "release-notes", - "security-review", - "keybindings", - "privacy-settings", - "plan", - "review", - "tasks", - "theme", - "voice", - "usage", - "rename", - "copy", - "hooks", - "context", - "color", - "effort", - "branch", - "rewind", - "ide", - "tag", - "output-style", - "add-dir", - // Spec entries with no parse arm — produce circular "Did you mean" error - // without this guard. Adding here routes them to the proper unsupported - // message and excludes them from REPL completions / help. - // NOTE: do NOT add "stats", "tokens", "cache" — they are implemented. - "allowed-tools", - "bookmarks", - "workspace", - "reasoning", - "budget", - "rate-limit", - "changelog", - "diagnostics", - "metrics", - "tool-details", - "focus", - "unfocus", - "pin", - "unpin", - "language", - "profile", - "max-tokens", - "temperature", - "system-prompt", - "notifications", - "telemetry", - "env", - "project", - "terminal-setup", - "api-key", - "reset", - "undo", - "stop", - "retry", - "paste", - "screenshot", - "image", - "search", - "listen", - "speak", - "format", - "test", - "lint", - "build", - "run", - "git", - "stash", - "blame", - "log", - "cron", - "team", - "benchmark", - "migrate", - "templates", - "explain", - "refactor", - "docs", - "fix", - "perf", - "chat", - "web", - "map", - "symbols", - "references", - "definition", - "hover", - "autofix", - "multi", - "macro", - "alias", - "parallel", - "subagent", - "agent", -]; - -fn slash_command_completion_candidates_with_sessions( - model: &str, - active_session_id: Option<&str>, - recent_session_ids: Vec, -) -> Vec { - let mut completions = BTreeSet::new(); - - for spec in slash_command_specs() { - if STUB_COMMANDS.contains(&spec.name) { - continue; - } - completions.insert(format!("/{}", spec.name)); - for alias in spec.aliases { - if !STUB_COMMANDS.contains(alias) { - completions.insert(format!("/{alias}")); - } - } + if sessions.is_empty() { + lines.push(" No managed sessions saved yet.".to_string()); + return Ok(lines.join("\n")); } - - 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}")); + 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(), + )); } - - completions.into_iter().collect() + Ok(lines.join("\n")) } -fn format_tool_call_start(name: &str, input: &str) -> String { - let parsed: serde_json::Value = - serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); - - let detail = match name { - "bash" | "Bash" => format_bash_call(&parsed), - "read_file" | "Read" => { - let path = extract_tool_path(&parsed); - format!("\x1b[2m📄 Reading {path}…\x1b[0m") - } - "write_file" | "Write" => { - let path = extract_tool_path(&parsed); - let lines = parsed - .get("content") - .and_then(|value| value.as_str()) - .map_or(0, |content| content.lines().count()); - format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m") - } - "edit_file" | "Edit" => { - let path = extract_tool_path(&parsed); - let old_value = parsed - .get("old_string") - .or_else(|| parsed.get("oldString")) - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let new_value = parsed - .get("new_string") - .or_else(|| parsed.get("newString")) - .and_then(|value| value.as_str()) - .unwrap_or_default(); - format!( - "\x1b[1;33m📝 Editing {path}\x1b[0m{}", - format_patch_preview(old_value, new_value) - .map(|preview| format!("\n{preview}")) - .unwrap_or_default() - ) - } - "glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed), - "grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed), - "web_search" | "WebSearch" => parsed - .get("query") - .and_then(|value| value.as_str()) - .unwrap_or("?") - .to_string(), - _ => summarize_tool_payload(input), +fn print_status_snapshot( + model: &str, + model_flag_raw: Option<&str>, + permission_mode: PermissionMode, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let usage = StatusUsage { + message_count: 0, + turns: 0, + latest: TokenUsage::default(), + cumulative: TokenUsage::default(), + estimated_tokens: 0, }; - - let border = "─".repeat(name.len() + 8); - format!( - "\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}╯\x1b[0m" - ) -} - -fn format_tool_result(name: &str, output: &str, is_error: bool) -> String { - let icon = if is_error { - "\x1b[1;31m✗\x1b[0m" - } else { - "\x1b[1;32m✓\x1b[0m" + let context = status_context(None)?; + // #148: resolve model provenance. If user passed --model, source is + // "flag" with the raw input preserved. Otherwise probe env -> config + // -> default and record the winning source. + let provenance = match model_flag_raw { + Some(raw) => ModelProvenance { + resolved: model.to_string(), + raw: Some(raw.to_string()), + source: ModelSource::Flag, + }, + None => ModelProvenance::from_env_or_config_or_default(model), }; - if is_error { - let summary = truncate_for_summary(output.trim(), 160); - return if summary.is_empty() { - format!("{icon} \x1b[38;5;245m{name}\x1b[0m") - } else { - format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m") - }; - } - - let parsed: serde_json::Value = - serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string())); - match name { - "bash" | "Bash" => format_bash_result(icon, &parsed), - "read_file" | "Read" => format_read_result(icon, &parsed), - "write_file" | "Write" => format_write_result(icon, &parsed), - "edit_file" | "Edit" => format_edit_result(icon, &parsed), - "glob_search" | "Glob" => format_glob_result(icon, &parsed), - "grep_search" | "Grep" => format_grep_result(icon, &parsed), - _ => format_generic_tool_result(icon, name, &parsed), + match output_format { + CliOutputFormat::Text => println!( + "{}", + format_status_report( + &provenance.resolved, + usage, + permission_mode.as_str(), + &context, + Some(&provenance) + ) + ), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&status_json_value( + Some(&provenance.resolved), + usage, + permission_mode.as_str(), + &context, + Some(&provenance), + ))? + ), } + Ok(()) } -const DISPLAY_TRUNCATION_NOTICE: &str = - "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m"; -const READ_DISPLAY_MAX_LINES: usize = 80; -const READ_DISPLAY_MAX_CHARS: usize = 6_000; -const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60; -const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000; - -fn extract_tool_path(parsed: &serde_json::Value) -> String { - parsed - .get("file_path") - .or_else(|| parsed.get("filePath")) - .or_else(|| parsed.get("path")) - .and_then(|value| value.as_str()) - .unwrap_or("?") - .to_string() -} - -fn format_search_start(label: &str, parsed: &serde_json::Value) -> String { - let pattern = parsed - .get("pattern") - .and_then(|value| value.as_str()) - .unwrap_or("?"); - let scope = parsed - .get("path") - .and_then(|value| value.as_str()) - .unwrap_or("."); - format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m") -} - -fn format_patch_preview(old_value: &str, new_value: &str) -> Option { - if old_value.is_empty() && new_value.is_empty() { - return None; +fn print_sandbox_status_snapshot( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader + .load() + .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); + let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); + match output_format { + CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&sandbox_json_value(&status))? + ), } - Some(format!( - "\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m", - truncate_for_summary(first_visible_line(old_value), 72), - truncate_for_summary(first_visible_line(new_value), 72) - )) + Ok(()) } -fn format_bash_call(parsed: &serde_json::Value) -> String { - let command = parsed - .get("command") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - if command.is_empty() { - String::new() - } else { - format!( - "\x1b[48;5;236;38;5;255m $ {} \x1b[0m", - truncate_for_summary(command, 160) - ) +fn render_help_topic(topic: LocalHelpTopic) -> String { + match topic { + LocalHelpTopic::Status => "Status + Usage claw status [--output-format ] + Purpose show the local workspace snapshot without entering the REPL + Output model, permissions, git state, config files, and sandbox status + Formats text (default), json + Related /status · claw --resume latest /status" + .to_string(), + LocalHelpTopic::Sandbox => "Sandbox + Usage claw sandbox [--output-format ] + Purpose inspect the resolved sandbox and isolation state for the current directory + Output namespace, network, filesystem, and fallback details + Formats text (default), json + Related /sandbox · claw status" + .to_string(), + LocalHelpTopic::Doctor => "Doctor + Usage claw doctor [--output-format ] + Purpose diagnose local auth, config, workspace, sandbox, and build metadata + Output local-only health report; no provider request or session resume required + Formats text (default), json + Related /doctor · claw --resume latest /doctor" + .to_string(), + LocalHelpTopic::Acp => "ACP / Zed + Usage claw acp [serve] [--output-format ] + Aliases claw --acp · claw -acp + Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime + Status discoverability only; `serve` is a status alias and does not launch a daemon yet + Formats text (default), json + Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help" + .to_string(), + LocalHelpTopic::Init => "Init + Usage claw init [--output-format ] + Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in the current project + Output list of created vs. skipped files (idempotent: safe to re-run) + Formats text (default), json + Related claw status · claw doctor" + .to_string(), + LocalHelpTopic::State => "State + Usage claw state [--output-format ] + Purpose read .claw/worker-state.json written by the interactive REPL or a one-shot prompt + Output worker id, model, permissions, session reference (text or json) + Formats text (default), json + Produces state `claw` (interactive REPL) or `claw prompt ` (one non-interactive turn) + Observes state `claw state` reads; clawhip/CI may poll this file without HTTP + Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise + Related claw status · ROADMAP #139 (this worker-concept contract)" + .to_string(), + LocalHelpTopic::Export => "Export + Usage claw export [--session ] [--output ] [--output-format ] + Purpose serialize a managed session to JSON for review, transfer, or archival + Defaults --session latest (most recent managed session in .claw/sessions/) + Formats text (default), json + Related /session list · claw --resume latest" + .to_string(), + LocalHelpTopic::Version => "Version + Usage claw version [--output-format ] + Aliases claw --version · claw -V + Purpose print the claw CLI version and build metadata + Formats text (default), json + Related claw doctor (full build/auth/config diagnostic)" + .to_string(), + LocalHelpTopic::SystemPrompt => "System Prompt + Usage claw system-prompt [--cwd ] [--date YYYY-MM-DD] [--output-format ] + Purpose render the resolved system prompt that `claw` would send for the given cwd + date + Options --cwd overrides the workspace dir · --date injects a deterministic date stamp + Formats text (default), json + Related claw doctor · claw dump-manifests" + .to_string(), + LocalHelpTopic::DumpManifests => "Dump Manifests + Usage claw dump-manifests [--manifests-dir ] [--output-format ] + Purpose emit every skill/agent/tool manifest the resolver would load for the current cwd + Options --manifests-dir scopes discovery to a specific directory + Formats text (default), json + Related claw skills · claw agents · claw doctor" + .to_string(), + LocalHelpTopic::BootstrapPlan => "Bootstrap Plan + Usage claw bootstrap-plan [--output-format ] + Purpose list the ordered startup phases the CLI would execute before dispatch + Output phase names (text) or structured phase list (json) — primary output is the plan itself + Formats text (default), json + Related claw doctor · claw status" + .to_string(), } } -fn first_visible_line(text: &str) -> &str { - text.lines() - .find(|line| !line.trim().is_empty()) - .unwrap_or(text) -} - -fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String { - use std::fmt::Write as _; - - let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")]; - if let Some(task_id) = parsed - .get("backgroundTaskId") - .and_then(|value| value.as_str()) - { - write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string"); - } else if let Some(status) = parsed - .get("returnCodeInterpretation") - .and_then(|value| value.as_str()) - .filter(|status| !status.is_empty()) - { - write!(&mut lines[0], " {status}").expect("write to string"); - } - - if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) { - if !stdout.trim().is_empty() { - lines.push(truncate_output_for_display( - stdout, - TOOL_OUTPUT_DISPLAY_MAX_LINES, - TOOL_OUTPUT_DISPLAY_MAX_CHARS, - )); +fn print_acp_status(output_format: CliOutputFormat) -> Result<(), Box> { + let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support."; + match output_format { + CliOutputFormat::Text => { + println!( + "ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}" + ); } - } - if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) { - if !stderr.trim().is_empty() { - lines.push(format!( - "\x1b[38;5;203m{}\x1b[0m", - truncate_output_for_display( - stderr, - TOOL_OUTPUT_DISPLAY_MAX_LINES, - TOOL_OUTPUT_DISPLAY_MAX_CHARS, - ) - )); + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "acp", + "status": "discoverability_only", + "supported": false, + "serve_alias_only": true, + "message": message, + "launch_command": serde_json::Value::Null, + "aliases": ["acp", "--acp", "-acp"], + "discoverability_tracking": "ROADMAP #64a", + "tracking": "ROADMAP #76", + "recommended_workflows": [ + "claw prompt TEXT", + "claw", + "claw doctor" + ], + }))? + ); } } + Ok(()) +} - lines.join("\n\n") +fn init_claude_md() -> Result> { + let cwd = env::current_dir()?; + Ok(initialize_repo(&cwd)?.render()) } -fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { - let file = parsed.get("file").unwrap_or(parsed); - let path = extract_tool_path(file); - let start_line = file - .get("startLine") - .and_then(serde_json::Value::as_u64) - .unwrap_or(1); - let num_lines = file - .get("numLines") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let total_lines = file - .get("totalLines") - .and_then(serde_json::Value::as_u64) - .unwrap_or(num_lines); - let content = file - .get("content") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let end_line = start_line.saturating_add(num_lines.saturating_sub(1)); +/// #142: emit first-class structured fields alongside the legacy `message` +/// string so claws can detect per-artifact state without substring matching. +fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value { + use crate::init::InitStatus; + json!({ + "kind": "init", + "project_path": report.project_root.display().to_string(), + "created": report.artifacts_with_status(InitStatus::Created), + "updated": report.artifacts_with_status(InitStatus::Updated), + "skipped": report.artifacts_with_status(InitStatus::Skipped), + "artifacts": report.artifact_json_entries(), + "next_step": crate::init::InitReport::NEXT_STEP, + "message": message, + }) +} - format!( - "{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}", - start_line, - end_line.max(start_line), - total_lines, - truncate_output_for_display(content, READ_DISPLAY_MAX_LINES, READ_DISPLAY_MAX_CHARS) - ) +fn run_git_diff_command_in( + cwd: &Path, + args: &[&str], +) -> Result> { + let output = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(String::from_utf8(output.stdout)?) } -fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { - let path = extract_tool_path(parsed); - let kind = parsed - .get("type") - .and_then(|value| value.as_str()) - .unwrap_or("write"); - let line_count = parsed - .get("content") - .and_then(|value| value.as_str()) - .map_or(0, |content| content.lines().count()); - format!( - "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", - if kind == "create" { "Wrote" } else { "Updated" }, - ) +fn indent_block(value: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + value + .lines() + .map(|line| format!("{indent}{line}")) + .collect::>() + .join("\n") } -fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option { - let hunks = parsed.get("structuredPatch")?.as_array()?; - let mut preview = Vec::new(); - for hunk in hunks.iter().take(2) { - let lines = hunk.get("lines")?.as_array()?; - for line in lines.iter().filter_map(|value| value.as_str()).take(6) { - match line.chars().next() { - Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")), - Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")), - _ => preview.push(line.to_string()), - } - } - } - if preview.is_empty() { - None - } else { - Some(preview.join("\n")) +fn git_status_ok(args: &[&str]) -> Result<(), Box> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); } + Ok(()) } -fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { - let path = extract_tool_path(parsed); - let suffix = if parsed - .get("replaceAll") - .and_then(serde_json::Value::as_bool) +fn command_exists(name: &str) -> bool { + Command::new("which") + .arg(name) + .output() + .map(|output| output.status.success()) .unwrap_or(false) - { - " (replace all)" - } else { - "" - }; - let preview = format_structured_patch_preview(parsed).or_else(|| { - let old_value = parsed - .get("oldString") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let new_value = parsed - .get("newString") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - format_patch_preview(old_value, new_value) - }); - - match preview { - Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"), - None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"), - } } -fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { - let num_files = parsed - .get("numFiles") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let filenames = parsed - .get("filenames") - .and_then(|value| value.as_array()) - .map(|files| { - files - .iter() - .filter_map(|value| value.as_str()) - .take(8) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); - if filenames.is_empty() { - format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files") - } else { - format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}") - } +fn write_temp_text_file( + filename: &str, + contents: &str, +) -> Result> { + let path = env::temp_dir().join(filename); + fs::write(&path, contents)?; + Ok(path) } -fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { - let num_matches = parsed - .get("numMatches") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let num_files = parsed - .get("numFiles") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let content = parsed - .get("content") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let filenames = parsed - .get("filenames") - .and_then(|value| value.as_array()) - .map(|files| { - files - .iter() - .filter_map(|value| value.as_str()) - .take(8) - .collect::>() - .join("\n") +const DEFAULT_HISTORY_LIMIT: usize = 20; + +// 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 +)] +fn recent_user_context(session: &Session, limit: usize) -> String { + let requests = session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.trim().to_string()), + _ => None, + }) }) - .unwrap_or_default(); - let summary = format!( - "{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files" - ); - if !content.trim().is_empty() { - format!( - "{summary}\n{}", - truncate_output_for_display( - content, - TOOL_OUTPUT_DISPLAY_MAX_LINES, - TOOL_OUTPUT_DISPLAY_MAX_CHARS, - ) - ) - } else if !filenames.is_empty() { - format!("{summary}\n{filenames}") + .rev() + .take(limit) + .collect::>(); + + if requests.is_empty() { + "".to_string() } else { - summary + requests + .into_iter() + .rev() + .enumerate() + .map(|(index, text)| format!("{}. {}", index + 1, text)) + .collect::>() + .join("\n") } } -fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String { - let rendered_output = match parsed { - serde_json::Value::String(text) => text.clone(), - serde_json::Value::Null => String::new(), - serde_json::Value::Object(_) | serde_json::Value::Array(_) => { - serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string()) - } - _ => parsed.to_string(), - }; - let preview = truncate_output_for_display( - &rendered_output, - TOOL_OUTPUT_DISPLAY_MAX_LINES, - TOOL_OUTPUT_DISPLAY_MAX_CHARS, - ); - - if preview.is_empty() { - format!("{icon} \x1b[38;5;245m{name}\x1b[0m") - } else if preview.contains('\n') { - format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n{preview}") +fn truncate_for_prompt(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + value.trim().to_string() } else { - format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}") + let truncated = value.chars().take(limit).collect::(); + format!("{}\n…[truncated]", truncated.trim_end()) } } -fn summarize_tool_payload(payload: &str) -> String { - let compact = match serde_json::from_str::(payload) { - Ok(value) => value.to_string(), - Err(_) => payload.trim().to_string(), - }; - truncate_for_summary(&compact, 96) +fn sanitize_generated_message(value: &str) -> String { + value.trim().trim_matches('`').trim().replace("\r\n", "\n") } -fn truncate_for_summary(value: &str, limit: usize) -> String { - let mut chars = value.chars(); - let truncated = chars.by_ref().take(limit).collect::(); - if chars.next().is_some() { - format!("{truncated}…") - } else { - truncated - } +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())) } -fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String { - let original = content.trim_end_matches('\n'); - if original.is_empty() { - return String::new(); - } - - let mut preview_lines = Vec::new(); - let mut used_chars = 0usize; - let mut truncated = false; - - for (index, line) in original.lines().enumerate() { - if index >= max_lines { - truncated = true; - break; - } - - let newline_cost = usize::from(!preview_lines.is_empty()); - let available = max_chars.saturating_sub(used_chars + newline_cost); - if available == 0 { - truncated = true; - break; - } - - let line_chars = line.chars().count(); - if line_chars > available { - preview_lines.push(line.chars().take(available).collect::()); - truncated = true; - break; - } - - preview_lines.push(line.to_string()); - used_chars += newline_cost + line_chars; - } - - let mut preview = preview_lines.join("\n"); - if truncated { - if !preview.is_empty() { - preview.push('\n'); - } - preview.push_str(DISPLAY_TRUNCATION_NOTICE); - } - preview +fn render_version_report() -> String { + let git_sha = GIT_SHA.unwrap_or("unknown"); + let target = BUILD_TARGET.unwrap_or("unknown"); + format!( + "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" + ) } -fn render_thinking_block_summary( - out: &mut (impl Write + ?Sized), - char_count: Option, - redacted: bool, -) -> Result<(), RuntimeError> { - let summary = if redacted { - "\n▶ Thinking block hidden by provider\n".to_string() - } else if let Some(char_count) = char_count { - format!("\n▶ Thinking ({char_count} chars hidden)\n") +fn default_export_filename(session: &Session) -> String { + let stem = session + .messages + .iter() + .find_map(|message| match message.role { + MessageRole::User => message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }), + _ => None, + }) + .map_or("conversation", |text| { + text.lines().next().unwrap_or("conversation") + }) + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .split('-') + .filter(|part| !part.is_empty()) + .take(8) + .collect::>() + .join("-"); + let fallback = if stem.is_empty() { + "conversation" } else { - "\n▶ Thinking hidden\n".to_string() + &stem }; - write!(out, "{summary}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string())) + format!("{fallback}.txt") } -fn push_output_block( - block: OutputContentBlock, - out: &mut (impl Write + ?Sized), - events: &mut Vec, - pending_tool: &mut Option<(String, String, String)>, - streaming_tool_input: bool, - block_has_thinking_summary: &mut bool, -) -> Result<(), RuntimeError> { - match block { - OutputContentBlock::Text { text } => { - if !text.is_empty() { - let rendered = TerminalRenderer::new().markdown_to_ansi(&text); - write!(out, "{rendered}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - events.push(AssistantEvent::TextDelta(text)); - } - } - OutputContentBlock::ToolUse { id, name, input } => { - // During streaming, the initial content_block_start has an empty input ({}). - // The real input arrives via input_json_delta events. In - // non-streaming responses, preserve a legitimate empty object. - let initial_input = if streaming_tool_input - && input.is_object() - && input.as_object().is_some_and(serde_json::Map::is_empty) - { - String::new() - } else { - input.to_string() - }; - *pending_tool = Some((id, name, initial_input)); - } - OutputContentBlock::Thinking { thinking, .. } => { - render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; - *block_has_thinking_summary = true; - } - OutputContentBlock::RedactedThinking { .. } => { - render_thinking_block_summary(out, None, true)?; - *block_has_thinking_summary = true; - } - } - Ok(()) -} +const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280; -fn response_to_events( - response: MessageResponse, - out: &mut (impl Write + ?Sized), -) -> Result, RuntimeError> { - let mut events = Vec::new(); - let mut pending_tool = None; +fn run_export( + session_reference: &str, + output_path: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let (handle, session) = load_session_reference(session_reference)?; + let markdown = render_session_markdown(&session, &handle.id, &handle.path); - for block in response.content { - let mut block_has_thinking_summary = false; - push_output_block( - block, - out, - &mut events, - &mut pending_tool, - false, - &mut block_has_thinking_summary, - )?; - if let Some((id, name, input)) = pending_tool.take() { - events.push(AssistantEvent::ToolUse { id, name, input }); + if let Some(path) = output_path { + fs::write(path, &markdown)?; + let report = format!( + "Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}", + path.display(), + handle.id, + session.messages.len(), + ); + match output_format { + CliOutputFormat::Text => println!("{report}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "export", + "message": report, + "session_id": handle.id, + "file": path.display().to_string(), + "messages": session.messages.len(), + }))? + ), } + return Ok(()); } - events.push(AssistantEvent::Usage(response.usage.token_usage())); - events.push(AssistantEvent::MessageStop); - Ok(events) -} - -fn push_prompt_cache_record(client: &ApiProviderClient, events: &mut Vec) { - // `ApiProviderClient::take_last_prompt_cache_record` is a pass-through - // to the Anthropic variant and returns `None` for OpenAI-compat / - // xAI variants, which do not have a prompt cache. So this helper - // remains a no-op on non-Anthropic providers without any extra - // branching here. - if let Some(record) = client.take_last_prompt_cache_record() { - if let Some(event) = prompt_cache_record_to_runtime_event(record) { - events.push(AssistantEvent::PromptCache(event)); + match output_format { + CliOutputFormat::Text => { + print!("{markdown}"); + if !markdown.ends_with('\n') { + println!(); + } } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "export", + "session_id": handle.id, + "file": handle.path.display().to_string(), + "messages": session.messages.len(), + "markdown": markdown, + }))? + ), } + Ok(()) } -fn prompt_cache_record_to_runtime_event( - record: api::PromptCacheRecord, -) -> Option { - let cache_break = record.cache_break?; - Some(PromptCacheEvent { - unexpected: cache_break.unexpected, - reason: cache_break.reason, - previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens, - current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens, - token_drop: cache_break.token_drop, - }) -} - -struct CliToolExecutor { - renderer: TerminalRenderer, - emit_output: bool, - allowed_tools: Option, - tool_registry: GlobalToolRegistry, - mcp_state: Option>>, -} - -impl CliToolExecutor { - fn new( - allowed_tools: Option, - emit_output: bool, - tool_registry: GlobalToolRegistry, - mcp_state: Option>>, - ) -> Self { - Self { - renderer: TerminalRenderer::new(), - emit_output, - allowed_tools, - tool_registry, - mcp_state, - } +fn render_session_markdown(session: &Session, session_id: &str, session_path: &Path) -> String { + let mut lines = vec![ + "# Conversation Export".to_string(), + String::new(), + format!("- **Session**: `{session_id}`"), + format!("- **File**: `{}`", session_path.display()), + format!("- **Messages**: {}", session.messages.len()), + ]; + if let Some(workspace_root) = session.workspace_root() { + lines.push(format!("- **Workspace**: `{}`", workspace_root.display())); } - - fn execute_search_tool(&self, value: serde_json::Value) -> Result { - let input: ToolSearchRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - let (pending_mcp_servers, mcp_degraded) = - self.mcp_state.as_ref().map_or((None, None), |state| { - let state = state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - (state.pending_servers(), state.degraded_report()) - }); - serde_json::to_string_pretty(&self.tool_registry.search( - &input.query, - input.max_results.unwrap_or(5), - pending_mcp_servers, - mcp_degraded, - )) - .map_err(|error| ToolError::new(error.to_string())) - } - - fn execute_runtime_tool( - &self, - tool_name: &str, - value: serde_json::Value, - ) -> Result { - let Some(mcp_state) = &self.mcp_state else { - return Err(ToolError::new(format!( - "runtime tool `{tool_name}` is unavailable without configured MCP servers" - ))); - }; - let mut mcp_state = mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - - match tool_name { - "MCPTool" => { - let input: McpToolRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - let qualified_name = input - .qualified_name - .or(input.tool) - .ok_or_else(|| ToolError::new("missing required field `qualifiedName`"))?; - mcp_state.call_tool(&qualified_name, input.arguments) - } - "ListMcpResourcesTool" => { - let input: ListMcpResourcesRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - match input.server { - Some(server_name) => mcp_state.list_resources_for_server(&server_name), - None => mcp_state.list_resources_for_all_servers(), - } - } - "ReadMcpResourceTool" => { - let input: ReadMcpResourceRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - mcp_state.read_resource(&input.server, &input.uri) - } - _ => mcp_state.call_tool(tool_name, Some(value)), - } + if let Some(fork) = &session.fork { + let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)"); + lines.push(format!( + "- **Forked from**: `{}` (branch `{branch}`)", + fork.parent_session_id + )); } -} + if let Some(compaction) = &session.compaction { + lines.push(format!( + "- **Compactions**: {} (last removed {} messages)", + compaction.count, compaction.removed_message_count + )); + } + lines.push(String::new()); + lines.push("---".to_string()); + lines.push(String::new()); -impl ToolExecutor for CliToolExecutor { - fn execute(&mut self, tool_name: &str, input: &str) -> Result { - if self - .allowed_tools - .as_ref() - .is_some_and(|allowed| !allowed.contains(tool_name)) - { - return Err(ToolError::new(format!( - "tool `{tool_name}` is not enabled by the current --allowedTools setting" - ))); - } - let value = serde_json::from_str(input) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - let result = if tool_name == "ToolSearch" { - self.execute_search_tool(value) - } else if self.tool_registry.has_runtime_tool(tool_name) { - self.execute_runtime_tool(tool_name, value) - } else { - self.tool_registry - .execute(tool_name, &value) - .map_err(ToolError::new) + for (index, message) in session.messages.iter().enumerate() { + let role = match message.role { + MessageRole::System => "System", + MessageRole::User => "User", + MessageRole::Assistant => "Assistant", + MessageRole::Tool => "Tool", }; - match result { - Ok(output) => { - if self.emit_output { - let markdown = format_tool_result(tool_name, &output, false); - self.renderer - .stream_markdown(&markdown, &mut io::stdout()) - .map_err(|error| ToolError::new(error.to_string()))?; + lines.push(format!("## {}. {role}", index + 1)); + lines.push(String::new()); + for block in &message.blocks { + match block { + ContentBlock::Text { text } => { + let trimmed = text.trim_end(); + if !trimmed.is_empty() { + lines.push(trimmed.to_string()); + lines.push(String::new()); + } } - Ok(output) - } - Err(error) => { - if self.emit_output { - let markdown = format_tool_result(tool_name, &error.to_string(), true); - self.renderer - .stream_markdown(&markdown, &mut io::stdout()) - .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; + ContentBlock::ToolUse { id, name, input } => { + lines.push(format!( + "**Tool call** `{name}` _(id `{}`)_", + short_tool_id(id) + )); + let summary = summarize_tool_payload_for_markdown(input); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => { + let status = if *is_error { "error" } else { "ok" }; + lines.push(format!( + "**Tool result** `{tool_name}` _(id `{}`, {status})_", + short_tool_id(tool_use_id) + )); + let summary = summarize_tool_payload_for_markdown(output); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); } - Err(error) } } + if let Some(usage) = message.usage { + lines.push(format!( + "_tokens: in={} out={} cache_create={} cache_read={}_", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + )); + lines.push(String::new()); + } } + lines.join("\n") } -fn permission_policy( - mode: PermissionMode, - feature_config: &runtime::RuntimeFeatureConfig, - tool_registry: &GlobalToolRegistry, -) -> Result { - Ok(tool_registry.permission_specs(None)?.into_iter().fold( - PermissionPolicy::new(mode).with_permission_rules(feature_config.permission_rules()), - |policy, (name, required_permission)| { - policy.with_tool_requirement(name, required_permission) - }, - )) +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}…") } -fn convert_messages(messages: &[ConversationMessage]) -> Vec { - messages - .iter() - .filter_map(|message| { - let role = match message.role { - MessageRole::System | MessageRole::User | MessageRole::Tool => "user", - MessageRole::Assistant => "assistant", - }; - let content = message - .blocks - .iter() - .map(|block| match block { - ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, - ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { - id: id.clone(), - name: name.clone(), - input: serde_json::from_str(input) - .unwrap_or_else(|_| serde_json::json!({ "raw": input })), - }, - ContentBlock::ToolResult { - tool_use_id, - output, - is_error, - .. - } => InputContentBlock::ToolResult { - tool_use_id: tool_use_id.clone(), - content: vec![ToolResultContentBlock::Text { - text: output.clone(), - }], - is_error: *is_error, - }, - }) - .collect::>(); - (!content.is_empty()).then(|| InputMessage { - role: role.to_string(), - content, - }) - }) - .collect() -} +/// Slash commands that are registered in the spec list but not yet implemented +/// in this build. Used to filter both REPL completions and help output so the +/// discovery surface only shows commands that actually work (ROADMAP #39). +const STUB_COMMANDS: &[&str] = &[ + "login", + "logout", + "vim", + "upgrade", + "share", + "feedback", + "files", + "fast", + "exit", + "summary", + "desktop", + "brief", + "advisor", + "stickers", + "insights", + "thinkback", + "release-notes", + "security-review", + "keybindings", + "privacy-settings", + "plan", + "review", + "tasks", + "theme", + "voice", + "usage", + "rename", + "copy", + "hooks", + "context", + "color", + "effort", + "branch", + "rewind", + "ide", + "tag", + "output-style", + "add-dir", + // Spec entries with no parse arm — produce circular "Did you mean" error + // without this guard. Adding here routes them to the proper unsupported + // message and excludes them from REPL completions / help. + // NOTE: do NOT add "stats", "tokens", "cache" — they are implemented. + "allowed-tools", + "bookmarks", + "workspace", + "reasoning", + "budget", + "rate-limit", + "changelog", + "diagnostics", + "metrics", + "tool-details", + "focus", + "unfocus", + "pin", + "unpin", + "language", + "profile", + "max-tokens", + "temperature", + "system-prompt", + "notifications", + "telemetry", + "env", + "project", + "terminal-setup", + "api-key", + "reset", + "undo", + "stop", + "retry", + "paste", + "screenshot", + "image", + "search", + "listen", + "speak", + "format", + "test", + "lint", + "build", + "run", + "git", + "stash", + "blame", + "log", + "cron", + "team", + "benchmark", + "migrate", + "templates", + "explain", + "refactor", + "docs", + "fix", + "perf", + "chat", + "web", + "map", + "symbols", + "references", + "definition", + "hover", + "autofix", + "multi", + "macro", + "alias", + "parallel", + "subagent", + "agent", +]; -#[allow(clippy::too_many_lines)] -fn print_help_to(out: &mut impl Write) -> io::Result<()> { - writeln!(out, "claw v{VERSION}")?; - writeln!(out)?; - writeln!(out, "Usage:")?; - writeln!( - out, - " claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]" - )?; - writeln!(out, " Start the interactive REPL")?; - writeln!( - out, - " claw [--model MODEL] [--output-format text|json] prompt TEXT" - )?; - writeln!(out, " Send one prompt and exit")?; - writeln!( - out, - " claw [--model MODEL] [--output-format text|json] TEXT" - )?; - writeln!(out, " Shorthand non-interactive prompt mode")?; - writeln!( - out, - " claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]" - )?; - writeln!( - out, - " Inspect or maintain a saved session without entering the REPL" - )?; - writeln!(out, " claw help")?; - writeln!(out, " Alias for --help")?; - writeln!(out, " claw version")?; - writeln!(out, " Alias for --version")?; - writeln!(out, " claw status")?; - writeln!( - out, - " Show the current local workspace status snapshot" - )?; - writeln!(out, " claw sandbox")?; - writeln!(out, " Show the current sandbox isolation snapshot")?; - writeln!(out, " claw doctor")?; - writeln!( - out, - " Diagnose local auth, config, workspace, and sandbox health" - )?; - writeln!(out, " claw acp [serve]")?; - writeln!( - out, - " Show ACP/Zed editor integration status (currently unsupported; aliases: --acp, -acp)" - )?; - writeln!(out, " Source of truth: {OFFICIAL_REPO_SLUG}")?; - writeln!( - out, - " Warning: do not `{DEPRECATED_INSTALL_COMMAND}` (deprecated stub)" - )?; - writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?; - writeln!(out, " claw bootstrap-plan")?; - writeln!(out, " claw agents")?; - writeln!(out, " claw mcp")?; - writeln!(out, " claw skills")?; - writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; - writeln!(out, " claw init")?; - writeln!( - out, - " claw export [PATH] [--session SESSION] [--output PATH]" - )?; - writeln!( - out, - " Dump the latest (or named) session as markdown; writes to PATH or stdout" - )?; - writeln!(out)?; - writeln!(out, "Flags:")?; - writeln!( - out, - " --model MODEL Override the active model" - )?; - writeln!( - out, - " --output-format FORMAT Non-interactive output format: text or json" - )?; - writeln!( - out, - " --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)" - )?; - writeln!( - out, - " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" - )?; - writeln!( - out, - " --dangerously-skip-permissions Skip all permission checks" - )?; - writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?; - writeln!( - out, - " --version, -V Print version and build information locally" - )?; - writeln!(out)?; - writeln!(out, "Interactive slash commands:")?; - writeln!(out, "{}", render_slash_command_help_filtered(STUB_COMMANDS))?; - writeln!(out)?; - let resume_commands = resume_supported_slash_commands() - .into_iter() - .map(|spec| match spec.argument_hint { - Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), - None => format!("/{}", spec.name), - }) - .collect::>() - .join(", "); - writeln!(out, "Resume-safe commands: {resume_commands}")?; - writeln!(out)?; - writeln!(out, "Session shortcuts:")?; - writeln!( - out, - " REPL turns auto-save to .claw/sessions/.{PRIMARY_SESSION_EXTENSION}" - )?; - writeln!( - out, - " Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session" - )?; - writeln!( - out, - " Use /session list in the REPL to browse managed sessions" - )?; - writeln!(out, "Examples:")?; - writeln!(out, " claw --model claude-opus \"summarize this repo\"")?; - writeln!( - out, - " claw --output-format json prompt \"explain src/main.rs\"" - )?; - writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?; - writeln!( - out, - " claw --allowedTools read,glob \"summarize Cargo.toml\"" - )?; - writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?; - writeln!( - out, - " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt" - )?; - writeln!(out, " claw agents")?; - writeln!(out, " claw mcp show my-server")?; - writeln!(out, " claw /skills")?; - writeln!(out, " claw doctor")?; - writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?; - writeln!( - out, - " do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub" - )?; - writeln!(out, " claw init")?; - writeln!(out, " claw export")?; - writeln!(out, " claw export conversation.md")?; - Ok(()) -} +const DISPLAY_TRUNCATION_NOTICE: &str = + "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m"; +const READ_DISPLAY_MAX_LINES: usize = 80; +const READ_DISPLAY_MAX_CHARS: usize = 6_000; +const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60; +const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000; fn print_help(output_format: CliOutputFormat) -> Result<(), Box> { let mut buffer = Vec::new(); @@ -9024,26 +2347,24 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box Vec { + let mut lines = Vec::new(); + for raw_line in diff.lines() { + if raw_line.is_empty() { + continue; + } + if raw_line.starts_with("diff --git") || raw_line.starts_with("index ") { + lines.push(DiffLine::FileHeader(raw_line.to_string())); + } else if raw_line.starts_with("--- ") || raw_line.starts_with("+++ ") { + lines.push(DiffLine::FileHeader(raw_line.to_string())); + } else if raw_line.starts_with("@@") && raw_line.contains("@@") { + lines.push(DiffLine::HunkHeader(raw_line.to_string())); + } else if raw_line.starts_with("Binary files") { + lines.push(DiffLine::Binary(raw_line.to_string())); + } else if raw_line.starts_with('+') && !raw_line.starts_with("+++") { + lines.push(DiffLine::Addition(raw_line[1..].to_string())); + } else if raw_line.starts_with('-') && !raw_line.starts_with("---") { + lines.push(DiffLine::Deletion(raw_line[1..].to_string())); + } else if raw_line.starts_with(' ') { + lines.push(DiffLine::Context(raw_line[1..].to_string())); + } else { + // Any other lines (no space prefix) — pass as context + lines.push(DiffLine::Context(raw_line.to_string())); + } + } + lines +} + +/// Count additions and deletions in parsed diff lines. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DiffCounts { + pub additions: usize, + pub deletions: usize, +} + +/// Count additions and deletions in a set of parsed diff lines. +pub fn count_diff_lines(lines: &[DiffLine]) -> DiffCounts { + let mut counts = DiffCounts::default(); + for line in lines { + match line { + DiffLine::Addition(_) => counts.additions += 1, + DiffLine::Deletion(_) => counts.deletions += 1, + _ => {} + } + } + counts +} + +/// Count files modified in parsed diff lines. +pub fn count_diff_files(lines: &[DiffLine]) -> usize { + lines + .iter() + .filter(|l| matches!(l, DiffLine::FileHeader(h) if h.starts_with("diff --git"))) + .count() +} + +/// Render parsed diff lines with ANSI colors. +/// +/// - Additions are green +/// - Deletions are red +/// - Hunk headers are cyan +/// - File headers are dim/bold +pub fn render_colored_diff(lines: &[DiffLine]) -> String { + let mut out = String::new(); + for line in lines { + match line { + DiffLine::Context(text) => { + writeln!(out, " {text}").expect("write to string"); + } + DiffLine::Addition(text) => { + writeln!(out, "{}+{}{}", Theme::SUCCESS, text, Theme::RESET) + .expect("write to string"); + } + DiffLine::Deletion(text) => { + writeln!(out, "{}-{}{}", Theme::ERROR, text, Theme::RESET) + .expect("write to string"); + } + DiffLine::HunkHeader(text) => { + writeln!(out, "{}{}{}", Theme::HIGHLIGHT, text, Theme::RESET) + .expect("write to string"); + } + DiffLine::FileHeader(text) => { + // bold + muted — no dedicated Theme:: constant yet + writeln!(out, "\x1b[1;38;5;245m{text}\x1b[0m").expect("write to string"); + } + DiffLine::Binary(text) => { + writeln!(out, "{}{}{}", Theme::MUTED, text, Theme::RESET).expect("write to string"); + } + } + } + out +} + +/// Render a compact file-level summary of a parsed diff. +pub fn render_diff_summary(lines: &[DiffLine]) -> String { + let mut files: Vec = Vec::new(); + let mut current_file = String::new(); + let mut file_counts = DiffCounts::default(); + + for line in lines { + match line { + DiffLine::FileHeader(h) if h.starts_with("diff --git") => { + // Emit previous file summary if any + if !current_file.is_empty() + && (file_counts.additions > 0 || file_counts.deletions > 0) + { + files.push(format!( + " {}\t{}+{}{}/{}-{}{}", + current_file, + Theme::SUCCESS, + file_counts.additions, + Theme::RESET, + Theme::ERROR, + file_counts.deletions, + Theme::RESET, + )); + } + // Extract the b/ path from "diff --git a/path b/path" + let path = h + .strip_prefix("diff --git ") + .and_then(|rest| rest.split_whitespace().nth(1)) + .and_then(|p| p.strip_prefix("b/")) + .unwrap_or(h); + current_file = path.to_string(); + file_counts = DiffCounts::default(); + } + DiffLine::Addition(_) => file_counts.additions += 1, + DiffLine::Deletion(_) => file_counts.deletions += 1, + _ => {} + } + } + // Emit last file + if !current_file.is_empty() && (file_counts.additions > 0 || file_counts.deletions > 0) { + files.push(format!( + " {}\t{}+{}{}/{}-{}{}", + current_file, + Theme::SUCCESS, + file_counts.additions, + Theme::RESET, + Theme::ERROR, + file_counts.deletions, + Theme::RESET, + )); + } + + let total_files = count_diff_files(lines); + let total_counts = count_diff_lines(lines); + let mut out = format!( + "{} file(s) changed\t{}+{}{} {}-{}{}\n", + total_files, + Theme::SUCCESS, + total_counts.additions, + Theme::RESET, + Theme::ERROR, + total_counts.deletions, + Theme::RESET, + ); + for file in &files { + writeln!(out, "{file}").expect("write to string"); + } + out +} + +/// Render full colored diff with summary header. +pub fn format_colored_diff(diff: &str) -> String { + if diff.trim().is_empty() { + return format!("{}(empty diff){}", Theme::DIM, Theme::RESET); + } + let lines = parse_unified_diff(diff); + let summary = render_diff_summary(&lines); + let colored = render_colored_diff(&lines); + format!("{summary}\n{colored}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_diff() -> &'static str { + "diff --git a/src/main.rs b/src/main.rs +index abc..def 100644 +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,5 +1,7 @@ + line one + line two ++added line + line three +-removed line + line four ++another addition +" + } + + #[test] + fn parses_additions_deletions_and_context() { + let lines = parse_unified_diff(sample_diff()); + let adds: Vec<_> = lines + .iter() + .filter(|l| matches!(l, DiffLine::Addition(_))) + .collect(); + let dels: Vec<_> = lines + .iter() + .filter(|l| matches!(l, DiffLine::Deletion(_))) + .collect(); + let contexts: Vec<_> = lines + .iter() + .filter(|l| matches!(l, DiffLine::Context(_))) + .collect(); + assert_eq!(adds.len(), 2); + assert_eq!(dels.len(), 1); + assert_eq!(contexts.len(), 4); // "line one" through "line four" + } + + #[test] + fn parses_hunk_header() { + let lines = parse_unified_diff(sample_diff()); + let headers: Vec<_> = lines + .iter() + .filter(|l| matches!(l, DiffLine::HunkHeader(_))) + .collect(); + assert_eq!(headers.len(), 1); + if let DiffLine::HunkHeader(h) = &headers[0] { + assert!(h.contains("@@")); + } + } + + #[test] + fn counts_additions_and_deletions() { + let lines = parse_unified_diff(sample_diff()); + let counts = count_diff_lines(&lines); + assert_eq!(counts.additions, 2); + assert_eq!(counts.deletions, 1); + } + + #[test] + fn counts_files() { + let lines = parse_unified_diff(sample_diff()); + assert_eq!(count_diff_files(&lines), 1); + } + + #[test] + fn colored_output_contains_ansi_codes() { + let lines = parse_unified_diff(sample_diff()); + let colored = render_colored_diff(&lines); + assert!(colored.contains(Theme::SUCCESS)); // green for additions + assert!(colored.contains(Theme::ERROR)); // red for deletions + assert!(colored.contains(Theme::HIGHLIGHT)); // cyan for hunk headers + } + + #[test] + fn summary_shows_file_and_counts() { + let lines = parse_unified_diff(sample_diff()); + let summary = render_diff_summary(&lines); + assert!(summary.contains("1 file(s) changed")); + assert!(summary.contains("+2")); + assert!(summary.contains("-1")); + assert!(summary.contains("src/main.rs")); + } + + #[test] + fn empty_diff_returns_placeholder() { + let result = format_colored_diff(""); + assert!(result.contains("empty diff")); + } + + #[test] + fn multi_file_diff_shows_multiple_files() { + let diff = "diff --git a/a.rs b/a.rs +index 1..2 100644 +--- a/a.rs ++++ b/a.rs +@@ -1 +1 @@ +-old_a ++new_a +diff --git a/b.rs b/b.rs +index 3..4 100644 +--- a/b.rs ++++ b/b.rs +@@ -1 +1 @@ +-old_b ++new_b +"; + let lines = parse_unified_diff(diff); + assert_eq!(count_diff_files(&lines), 2); + let summary = render_diff_summary(&lines); + assert!(summary.contains("2 file(s) changed")); + assert!(summary.contains("a.rs")); + assert!(summary.contains("b.rs")); + } + + #[test] + fn binary_diff_is_parsed() { + let diff = "diff --git a/image.png b/image.png +index 1..2 100644 +Binary files a/image.png and b/image.png differ +"; + let lines = parse_unified_diff(diff); + let binaries: Vec<_> = lines + .iter() + .filter(|l| matches!(l, DiffLine::Binary(_))) + .collect(); + assert_eq!(binaries.len(), 1); + } +} diff --git a/rust/crates/rusty-claude-cli/src/tui/mod.rs b/rust/crates/rusty-claude-cli/src/tui/mod.rs new file mode 100644 index 0000000000..cf50fe1b9c --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tui/mod.rs @@ -0,0 +1,23 @@ +pub mod diff_view; +pub mod permission; +pub mod status_bar; +pub mod terminal; +pub mod theme; +pub mod thinking; +pub mod timeline; +pub mod tool_panel; + +pub use diff_view::{ + format_colored_diff, parse_unified_diff, render_colored_diff, render_diff_summary, DiffCounts, + DiffLine, +}; +pub use permission::{ + describe_tool_action, format_enhanced_permission_prompt, parse_permission_response, + PermissionDecision, +}; +pub use status_bar::StatusBar; +pub use terminal::TerminalSize; +pub use theme::Theme; +pub use thinking::{format_thinking_completed, render_thinking_inline, ThinkingFrames}; +pub use timeline::ToolCallTimeline; +pub use tool_panel::{collapse_tool_output, CollapsedToolOutput, ToolDisplayConfig}; diff --git a/rust/crates/rusty-claude-cli/src/tui/permission.rs b/rust/crates/rusty-claude-cli/src/tui/permission.rs new file mode 100644 index 0000000000..3c75fd201d --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tui/permission.rs @@ -0,0 +1,228 @@ +use serde_json; + +use crate::format::truncate_for_summary; +use crate::tui::theme::Theme; + +/// Decision from parsing a user's permission prompt response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PermissionDecision { + Allow, + Deny { reason: String }, + AllowAll, + ViewInput, +} + +/// Plain-English description of what a tool will do. +pub fn describe_tool_action(tool_name: &str, input: &serde_json::Value) -> String { + match tool_name { + "bash" | "Bash" => { + let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Execute shell command: {}", truncate_for_summary(cmd, 80)) + } + "edit_file" | "Edit" => { + let path = extract_path(input); + format!("Edit file: {path}") + } + "write_file" | "Write" => { + let path = extract_path(input); + let content = input + .get("content") + .and_then(|v| v.as_str()) + .map(|c| c.lines().count()) + .unwrap_or(0); + format!("Write file: {path} ({content} lines)") + } + "read_file" | "Read" => { + let path = extract_path(input); + let start_line = input + .get("start") + .or_else(|| input.get("startLine")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + format!("Read file: {path} (from line {start_line})") + } + "web_search" | "WebSearch" => { + let query = input.get("query").and_then(|v| v.as_str()).unwrap_or("?"); + format!("Search the web: {query}") + } + "glob_search" | "Glob" | "grep_search" | "Grep" => { + let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("?"); + let path = input.get("path").and_then(|v| v.as_str()).unwrap_or("."); + format!("Search code: {pattern} in {path}") + } + _ => format!("Execute tool: {tool_name}"), + } +} + +fn extract_path(input: &serde_json::Value) -> String { + input + .get("file_path") + .or_else(|| input.get("filePath")) + .or_else(|| input.get("path")) + .and_then(|v| v.as_str()) + .unwrap_or("?") + .to_string() +} + +/// Render an enhanced permission prompt with box-drawing and action descriptions. +pub fn format_enhanced_permission_prompt( + tool_name: &str, + input: &serde_json::Value, + current_mode_str: &str, + required_mode_str: &str, + reason: Option<&str>, +) -> String { + let action = describe_tool_action(tool_name, input); + let header = format!("{}⚠ Permission Required{}", Theme::WARNING, Theme::RESET); + let border = Theme::permission_border(); + let mut lines = vec![ + String::new(), + header, + border.clone(), + format!(" Tool:\t{}", tool_name), + format!(" Action:\t{}", action), + format!( + " Mode:\t{} → \x1b[1m{}\x1b[0m", + current_mode_str, required_mode_str + ), + ]; + if let Some(reason) = reason { + lines.push(format!(" Reason:\t{}", reason)); + } + lines.push(border); + lines.push(format!( + " {}[y]es | [n]o | [a]llow all | [v]iew input{}", + Theme::DIM, + Theme::RESET + )); + lines.push(String::new()); + lines.push(" [\x1b[1my\x1b[0m/\x1b[1mN\x1b[0m/\x1b[1ma\x1b[0m/\x1b[1mv\x1b[0m]: ".to_string()); + lines.join("\n") +} + +/// Parse user input from the permission prompt. +pub fn parse_permission_response(input: &str) -> PermissionDecision { + let normalized = input.trim().to_ascii_lowercase(); + match normalized.as_str() { + "y" | "yes" => PermissionDecision::Allow, + "a" | "all" => PermissionDecision::AllowAll, + "v" | "view" => PermissionDecision::ViewInput, + _ => PermissionDecision::Deny { + reason: "denied by user".to_string(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn describe_bash_action() { + let input = json!({"command": "rm -rf /tmp/test"}); + let result = describe_tool_action("bash", &input); + assert!(result.contains("Execute shell command")); + assert!(result.contains("rm -rf /tmp/test")); + } + + #[test] + fn describe_edit_file_action() { + let input = json!({"file_path": "/src/main.rs"}); + let result = describe_tool_action("edit_file", &input); + assert!(result.contains("Edit file")); + assert!(result.contains("/src/main.rs")); + } + + #[test] + fn describe_read_file_action() { + let input = json!({"filePath": "/src/lib.rs", "startLine": 42}); + let result = describe_tool_action("read_file", &input); + assert!(result.contains("Read file")); + assert!(result.contains("/src/lib.rs")); + assert!(result.contains("42")); + } + + #[test] + fn describe_web_search_action() { + let input = json!({"query": "rust async best practices"}); + let result = describe_tool_action("web_search", &input); + assert!(result.contains("Search the web")); + assert!(result.contains("rust async best practices")); + } + + #[test] + fn describe_generic_tool_action() { + let input = json!({}); + let result = describe_tool_action("custom_plugin", &input); + assert_eq!(result, "Execute tool: custom_plugin"); + } + + #[test] + fn enhanced_prompt_contains_box_borders_and_options() { + let input = json!({"command": "git status"}); + let result = format_enhanced_permission_prompt( + "bash", + &input, + "read-only", + "danger-full-access", + Some("bash requires full access"), + ); + assert!(result.contains("Permission Required")); + assert!(result.contains("bash")); + assert!(result.contains("read-only")); + assert!(result.contains("danger-full-access")); + assert!(result.contains("[y]es")); + assert!(result.contains("[a]llow all")); + assert!(result.contains("[v]iew input")); + } + + #[test] + fn enhanced_prompt_without_reason_still_shows_options() { + let input = json!({"command": "ls"}); + let result = format_enhanced_permission_prompt( + "bash", + &input, + "read-only", + "danger-full-access", + None, + ); + assert!(result.contains("Permission Required")); + assert!(!result.contains("Reason:")); + } + + #[test] + fn parse_allow_response() { + assert_eq!(parse_permission_response("y"), PermissionDecision::Allow); + assert_eq!(parse_permission_response("yes"), PermissionDecision::Allow); + } + + #[test] + fn parse_deny_response() { + assert!(matches!( + parse_permission_response("n"), + PermissionDecision::Deny { .. } + )); + assert!(matches!( + parse_permission_response(""), + PermissionDecision::Deny { .. } + )); + } + + #[test] + fn parse_allow_all_response() { + assert_eq!(parse_permission_response("a"), PermissionDecision::AllowAll); + assert_eq!( + parse_permission_response("all"), + PermissionDecision::AllowAll + ); + } + + #[test] + fn parse_view_response() { + assert_eq!( + parse_permission_response("v"), + PermissionDecision::ViewInput + ); + } +} diff --git a/rust/crates/rusty-claude-cli/src/tui/status_bar.rs b/rust/crates/rusty-claude-cli/src/tui/status_bar.rs new file mode 100644 index 0000000000..2e85d77f02 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tui/status_bar.rs @@ -0,0 +1,174 @@ +use std::io::Write; +use std::time::Instant; + +use crate::tui::theme::Theme; + +/// Data needed to render the status bar. +pub struct StatusBarState { + pub model: String, + pub permission_mode: String, + pub message_count: usize, + pub cumulative_input_tokens: u64, + pub cumulative_output_tokens: u64, + pub estimated_cost_usd: String, + pub turn_start: Instant, + pub git_branch: Option, + pub terminal_width: u16, +} + +pub struct StatusBar; + +impl StatusBar { + /// Render the status bar to `out`. Uses raw ANSI escape sequences + /// so it works with any `Write` implementation (including `&mut dyn Write`). + pub fn render(state: &StatusBarState, out: &mut dyn Write) -> std::io::Result<()> { + let elapsed = state.turn_start.elapsed(); + let secs = elapsed.as_secs(); + let model_display = truncate_str(&state.model, 18); + let total_tokens = state.cumulative_input_tokens + state.cumulative_output_tokens; + let tokens_display = format_tokens(total_tokens); + let branch_display = state.git_branch.as_deref().unwrap_or("?"); + + let content = format!( + " {} · {} · {} msgs · {} tokens · ${} · {}s · {} ", + model_display, + state.permission_mode, + state.message_count, + tokens_display, + state.estimated_cost_usd, + secs, + branch_display, + ); + + // Truncate to terminal width (character count, not byte length) + let width = state.terminal_width as usize; + let display = if content.chars().count() > width { + truncate_str(&content, width.saturating_sub(1)) + } else { + content + }; + + // ANSI: save position, move to col 0, clear line, dark grey, print, reset, restore + write!( + out, + "\x1b7\x1b[0G\x1b[2K{}{}{}", + Theme::status_bar_fg(), + display, + Theme::RESET, + )?; + write!(out, "\x1b8")?; + out.flush() + } + + /// Clear the status bar line (call when generation completes). + pub fn clear(out: &mut dyn Write) -> std::io::Result<()> { + // ANSI: save position, move to col 0, clear line, restore + write!(out, "\x1b7\x1b[0G\x1b[2K\x1b8")?; + out.flush() + } +} + +fn truncate_str(s: &str, max_len: usize) -> String { + if s.chars().count() <= max_len { + s.to_string() + } else { + let mut result = s + .chars() + .take(max_len.saturating_sub(1)) + .collect::(); + result.push('…'); + result + } +} + +fn format_tokens(count: u64) -> String { + if count >= 1_000_000 { + format!("{:.1}M", count as f64 / 1_000_000.0) + } else if count >= 1_000 { + format!("{:.1}k", count as f64 / 1_000.0) + } else { + count.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_str_shorter_than_max() { + assert_eq!(truncate_str("hello", 10), "hello"); + } + + #[test] + fn truncate_str_at_max() { + assert_eq!(truncate_str("hello", 5), "hello"); + } + + #[test] + fn truncate_str_longer_than_max() { + let result = truncate_str("hello world", 6); + assert_eq!(result, "hello…"); + } + + #[test] + fn format_tokens_zero() { + assert_eq!(format_tokens(0), "0"); + } + + #[test] + fn format_tokens_thousands() { + assert_eq!(format_tokens(3200), "3.2k"); + } + + #[test] + fn format_tokens_millions() { + assert_eq!(format_tokens(1_500_000), "1.5M"); + } + + #[test] + fn render_produces_output_without_panicking() { + let state = StatusBarState { + model: "claude-sonnet-4".to_string(), + permission_mode: "read-only".to_string(), + message_count: 5, + cumulative_input_tokens: 3200, + cumulative_output_tokens: 800, + estimated_cost_usd: "0.04".to_string(), + turn_start: Instant::now(), + git_branch: Some("main".to_string()), + terminal_width: 80, + }; + let mut buf: Vec = Vec::new(); + let out: &mut dyn Write = &mut buf; + let _ = StatusBar::render(&state, out); + assert!(!buf.is_empty()); + } + + #[test] + fn render_truncates_to_terminal_width() { + let state = StatusBarState { + model: "claude-sonnet-4-with-a-very-long-name".to_string(), + permission_mode: "read-only".to_string(), + message_count: 5, + cumulative_input_tokens: 3200, + cumulative_output_tokens: 800, + estimated_cost_usd: "0.04".to_string(), + turn_start: Instant::now(), + git_branch: Some("feature/some-long-branch-name".to_string()), + terminal_width: 40, + }; + let mut buf: Vec = Vec::new(); + let out: &mut dyn Write = &mut buf; + let _ = StatusBar::render(&state, out); + assert!(!buf.is_empty()); + } + + #[test] + fn clear_produces_output_without_panicking() { + let mut buf: Vec = Vec::new(); + let out: &mut dyn Write = &mut buf; + let _ = StatusBar::clear(out); + assert!(!buf.is_empty()); + } +} diff --git a/rust/crates/rusty-claude-cli/src/tui/terminal.rs b/rust/crates/rusty-claude-cli/src/tui/terminal.rs new file mode 100644 index 0000000000..9bb82c4e02 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tui/terminal.rs @@ -0,0 +1,98 @@ +use std::sync::atomic::{AtomicU16, Ordering}; +use std::time::{Duration, Instant}; + +/// Tracks terminal dimensions with periodic re-query. +/// +/// This avoids calling crossterm::terminal::size() on every render +/// while still catching SIGWINCH resizes within the polling interval. +pub struct TerminalSize { + cached_width: AtomicU16, + cached_height: AtomicU16, + last_checked: std::sync::Mutex, + poll_interval: Duration, +} + +impl TerminalSize { + /// Create a new TerminalSize with the default polling interval (1s). + pub fn new() -> Self { + let (w, h) = Self::query_size().unwrap_or((80, 24)); + Self { + cached_width: AtomicU16::new(w), + cached_height: AtomicU16::new(h), + last_checked: std::sync::Mutex::new(Instant::now()), + poll_interval: Duration::from_secs(1), + } + } + + /// Get the current terminal width, re-querying if enough time has passed. + pub fn width(&self) -> u16 { + self.maybe_refresh(); + self.cached_width.load(Ordering::Relaxed) + } + + /// Get the current terminal height, re-querying if enough time has passed. + #[allow(dead_code)] + pub fn height(&self) -> u16 { + self.maybe_refresh(); + self.cached_height.load(Ordering::Relaxed) + } + + /// Force a refresh on the next call. + pub fn invalidate(&self) { + if let Ok(mut last) = self.last_checked.lock() { + *last = Instant::now() + .checked_sub(Duration::from_secs(3600)) + .unwrap_or(Instant::now()); + } + } + + fn maybe_refresh(&self) { + let should_refresh = self + .last_checked + .lock() + .map(|last| last.elapsed() >= self.poll_interval) + .unwrap_or(false); + if should_refresh { + if let Ok((w, h)) = Self::query_size() { + self.cached_width.store(w, Ordering::Relaxed); + self.cached_height.store(h, Ordering::Relaxed); + } + if let Ok(mut last) = self.last_checked.lock() { + *last = Instant::now(); + } + } + } + + fn query_size() -> std::io::Result<(u16, u16)> { + crossterm::terminal::size() + } +} + +impl Default for TerminalSize { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn width_returns_reasonable_value() { + let ts = TerminalSize::new(); + // In test environment, crossterm might return 80 + let w = ts.width(); + assert!(w > 0, "terminal width should be positive"); + } + + #[test] + fn invalidate_forces_refresh() { + let ts = TerminalSize::new(); + let w1 = ts.width(); + ts.invalidate(); + let w2 = ts.width(); + assert!(w1 > 0); + assert!(w2 > 0); + } +} diff --git a/rust/crates/rusty-claude-cli/src/tui/theme.rs b/rust/crates/rusty-claude-cli/src/tui/theme.rs new file mode 100644 index 0000000000..dd4438f61c --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tui/theme.rs @@ -0,0 +1,89 @@ +/// Named semantic color tokens for the TUI. +/// +/// All TUI modules should reference these constants instead of +/// hard-coding ANSI escape sequences. This makes theme switching +/// a single-point change. +pub struct Theme; + +/// ── Base palette ────────────────────────────────────────────────────────── +impl Theme { + /// Dark grey/dim for secondary info (status bar, truncation notices). + pub const DIM: &'static str = "\x1b[2m"; + /// Reset all attributes. + pub const RESET: &'static str = "\x1b[0m"; + + /// ── 256-color semantic tokens ────────────────────────────────────────── + + /// Green: success indicators, additions. + pub const SUCCESS: &'static str = "\x1b[38;5;70m"; + /// Red: error indicators, deletions. + pub const ERROR: &'static str = "\x1b[38;5;203m"; + /// Bright red: critical errors. + pub const ERROR_BRIGHT: &'static str = "\x1b[1;31m"; + /// Cyan: highlight (tool names, hunk headers). + pub const HIGHLIGHT: &'static str = "\x1b[1;36m"; + /// Magenta: thinking/reasoning indicators. + pub const THINKING: &'static str = "\x1b[38;5;13m"; + /// Yellow: warning, permission prompts. + pub const WARNING: &'static str = "\x1b[1;33m"; + /// Grey: borders, secondary labels, file headers. + pub const MUTED: &'static str = "\x1b[38;5;245m"; + /// White on dark grey background: command display (bash inline). + pub const COMMAND_BG: &'static str = "\x1b[48;5;236;38;5;255m"; + /// Bold green: file write/create. + pub const SUCCESS_BOLD: &'static str = "\x1b[1;32m"; + /// Bold yellow: file edit. + pub const EDIT: &'static str = "\x1b[1;33m"; + + /// ── Composite styles ─────────────────────────────────────────────────── + + /// Truncation notice suffix. + pub fn truncation_notice() -> String { + format!( + "{}… output truncated for display; full result preserved in session.{}", + Self::DIM, + Self::RESET + ) + } + + /// Status bar foreground. + pub fn status_bar_fg() -> &'static str { + "\x1b[90m" + } + + /// Permission prompt border. + pub fn permission_border() -> String { + format!( + "{}────────────────────────────────────────────────{}", + Self::MUTED, + Self::RESET + ) + } +} + +/// ── Unit tests ──────────────────────────────────────────────────────────── +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn theme_constants_are_non_empty() { + assert!(!Theme::DIM.is_empty()); + assert!(!Theme::RESET.is_empty()); + assert!(!Theme::SUCCESS.is_empty()); + assert!(!Theme::ERROR.is_empty()); + assert!(!Theme::HIGHLIGHT.is_empty()); + assert!(!Theme::THINKING.is_empty()); + assert!(!Theme::WARNING.is_empty()); + assert!(!Theme::MUTED.is_empty()); + assert!(!Theme::EDIT.is_empty()); + } + + #[test] + fn truncation_notice_contains_dim_and_reset() { + let notice = Theme::truncation_notice(); + assert!(notice.contains(Theme::DIM)); + assert!(notice.contains(Theme::RESET)); + assert!(notice.contains("truncated for display")); + } +} diff --git a/rust/crates/rusty-claude-cli/src/tui/thinking.rs b/rust/crates/rusty-claude-cli/src/tui/thinking.rs new file mode 100644 index 0000000000..ebbb2a0597 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tui/thinking.rs @@ -0,0 +1,103 @@ +use std::time::Duration; + +use crate::tui::theme::Theme; + +/// Generate animated thinking indicator frames (dot-wave). +pub struct ThinkingFrames; + +impl ThinkingFrames { + /// Returns an iterator that cycles through animation frames forever. + pub fn frames() -> impl Iterator { + [ + "\x1b[38;5;13m ●\x1b[0m", + "\x1b[38;5;13m ●●\x1b[0m", + "\x1b[38;5;13m ●●●\x1b[0m", + "\x1b[38;5;13m ●●●●\x1b[0m", + "\x1b[38;5;13m ●●●●●\x1b[0m", + "\x1b[38;5;13m ●●●●\x1b[0m", + "\x1b[38;5;13m ●●●\x1b[0m", + "\x1b[38;5;13m ●●\x1b[0m", + ] + .iter() + .copied() + .cycle() + } + + /// Frame delay for smooth animation. + pub fn frame_delay() -> Duration { + Duration::from_millis(120) + } +} + +/// Format the static "Reasoned for X.Xs" line after thinking completes. +pub fn format_thinking_completed(elapsed: Duration) -> String { + let secs = elapsed.as_secs_f64(); + format!( + "{}\u{25b6} Reasoned for {secs:.1}s{}", + Theme::THINKING, + Theme::RESET + ) +} + +/// Render a short inline thinking indicator for non-animated use. +pub fn render_thinking_inline(char_count: Option, redacted: bool) -> String { + let summary = if redacted { + format!( + "{}\u{25b6} Thinking block hidden by provider{}", + Theme::THINKING, + Theme::RESET + ) + } else if let Some(char_count) = char_count { + format!( + "{}\u{25b6} Reasoning ({char_count} chars){}", + Theme::THINKING, + Theme::RESET + ) + } else { + format!("{}\u{25b6} Reasoning{}", Theme::THINKING, Theme::RESET) + }; + format!("\n{summary}\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frames_cycles_indefinitely() { + let frames: Vec<&str> = ThinkingFrames::frames().take(16).collect(); + // 8 unique frames, then repeats + assert_eq!(frames.len(), 16); + let first = frames[0]; + assert_eq!(frames[8], first); // 9th frame = 1st (cycle) + } + + #[test] + fn thinking_completed_formats_seconds() { + let result = format_thinking_completed(Duration::from_secs_f64(3.5)); + assert!(result.contains("Reasoned for")); + assert!(result.contains("3.5s")); + assert!(result.contains(Theme::THINKING)); // magenta + } + + #[test] + fn thinking_inline_with_char_count() { + let result = render_thinking_inline(Some(42), false); + assert!(result.contains("Reasoning")); + assert!(result.contains("42 chars")); + assert!(result.contains(Theme::THINKING)); + } + + #[test] + fn thinking_inline_redacted() { + let result = render_thinking_inline(None, true); + assert!(result.contains("hidden by provider")); + } + + #[test] + fn thinking_inline_without_count() { + let result = render_thinking_inline(None, false); + assert!(result.contains("Reasoning")); + assert!(!result.contains("chars")); + } +} diff --git a/rust/crates/rusty-claude-cli/src/tui/timeline.rs b/rust/crates/rusty-claude-cli/src/tui/timeline.rs new file mode 100644 index 0000000000..24cb802836 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tui/timeline.rs @@ -0,0 +1,184 @@ +use std::fmt::Write as _; +use std::time::Instant; + +use crate::tui::theme::Theme; + +/// A recorded tool call event in the session. +#[derive(Debug, Clone)] +pub struct ToolCallEvent { + pub step: usize, + pub name: String, + pub started_at: Instant, + pub completed_at: Option, + pub is_error: bool, + pub was_truncated: bool, + pub output_lines: usize, +} + +/// Accumulator for building a tool call timeline during a turn. +#[derive(Debug, Default)] +pub struct ToolCallTimeline { + events: Vec, + start: Option, +} + +impl ToolCallTimeline { + /// Create a new empty timeline. + pub fn new() -> Self { + Self::default() + } + + /// Record the start of a tool call. + pub fn start_tool(&mut self, name: &str) { + if self.start.is_none() { + self.start = Some(Instant::now()); + } + self.events.push(ToolCallEvent { + step: self.events.len() + 1, + name: name.to_string(), + started_at: Instant::now(), + completed_at: None, + is_error: false, + was_truncated: false, + output_lines: 0, + }); + } + + /// Mark the most recent tool call as completed. + pub fn complete_tool(&mut self, is_error: bool, was_truncated: bool, output_lines: usize) { + if let Some(event) = self.events.last_mut() { + event.completed_at = Some(Instant::now()); + event.is_error = is_error; + event.was_truncated = was_truncated; + event.output_lines = output_lines; + } + } + + /// Get the current events. + pub fn events(&self) -> &[ToolCallEvent] { + &self.events + } + + /// Total elapsed time since the first tool call started. + pub fn total_elapsed(&self) -> Option { + self.start.map(|s| s.elapsed()) + } + + /// Render the timeline as a string. + pub fn render(&self) -> String { + if self.events.is_empty() { + return String::new(); + } + + let mut out = String::new(); + writeln!(out, "{}── Tool calls ──{}", Theme::MUTED, Theme::RESET).expect("write to string"); + + for event in &self.events { + let elapsed = event + .completed_at + .map(|c| c.duration_since(event.started_at)) + .unwrap_or_default(); + let status_icon = if event.is_error { + format!("{}✗{}", Theme::ERROR_BRIGHT, Theme::RESET) + } else { + format!("{}✓{}", Theme::SUCCESS_BOLD, Theme::RESET) + }; + let truncated_mark = if event.was_truncated { + " (truncated)" + } else { + "" + }; + let secs = elapsed.as_secs_f64(); + writeln!( + out, + " {}. {status_icon} {h}{name}{r} {d}{secs:.1}s {lines} lines{truncated_mark}{r}", + event.step, + name = event.name, + secs = secs, + lines = event.output_lines, + h = Theme::HIGHLIGHT, + r = Theme::RESET, + d = Theme::DIM, + ) + .expect("write to string"); + } + + if let Some(elapsed) = self.total_elapsed() { + writeln!( + out, + "\n {d}Total: {secs:.1}s across {count} tool call(s){r}", + d = Theme::DIM, + r = Theme::RESET, + secs = elapsed.as_secs_f64(), + count = self.events.len(), + ) + .expect("write to string"); + } + + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread::sleep; + use std::time::Duration; + + #[test] + fn empty_timeline_renders_nothing() { + let timeline = ToolCallTimeline::new(); + assert!(timeline.render().is_empty()); + } + + #[test] + fn single_tool_call_appears_in_render() { + let mut timeline = ToolCallTimeline::new(); + timeline.start_tool("bash"); + sleep(Duration::from_millis(10)); + timeline.complete_tool(false, false, 5); + let rendered = timeline.render(); + assert!(rendered.contains("bash")); + assert!(rendered.contains("✓")); + assert!(rendered.contains("Tool calls")); + } + + #[test] + fn error_tool_call_shows_error_icon() { + let mut timeline = ToolCallTimeline::new(); + timeline.start_tool("read_file"); + timeline.complete_tool(true, false, 0); + let rendered = timeline.render(); + assert!(rendered.contains("✗")); + } + + #[test] + fn truncated_tool_call_marks_truncated() { + let mut timeline = ToolCallTimeline::new(); + timeline.start_tool("bash"); + timeline.complete_tool(false, true, 100); + let rendered = timeline.render(); + assert!(rendered.contains("truncated")); + } + + #[test] + fn multiple_tool_calls_are_numbered() { + let mut timeline = ToolCallTimeline::new(); + timeline.start_tool("read_file"); + timeline.complete_tool(false, false, 10); + timeline.start_tool("edit_file"); + timeline.complete_tool(false, false, 3); + let rendered = timeline.render(); + assert!(rendered.contains("1.")); + assert!(rendered.contains("2.")); + assert!(rendered.contains("2 tool call(s)")); + } + + #[test] + fn events_reflects_count() { + let mut timeline = ToolCallTimeline::new(); + assert_eq!(timeline.events().len(), 0); + timeline.start_tool("bash"); + assert_eq!(timeline.events().len(), 1); + } +} diff --git a/rust/crates/rusty-claude-cli/src/tui/tool_panel.rs b/rust/crates/rusty-claude-cli/src/tui/tool_panel.rs new file mode 100644 index 0000000000..505f355ab5 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/tui/tool_panel.rs @@ -0,0 +1,166 @@ +use std::fmt::Write as _; + +use crate::tui::theme::Theme; + +/// Configuration for tool output truncation. +pub struct ToolDisplayConfig { + pub visible_lines: usize, + pub max_chars: usize, +} + +impl Default for ToolDisplayConfig { + fn default() -> Self { + Self { + visible_lines: 10, + max_chars: 4_000, + } + } +} + +/// Result of collapsing tool output for display. +pub struct CollapsedToolOutput { + /// The visible portion (first N lines). + pub visible: String, + /// Total lines in the full output. + pub total_lines: usize, + /// Whether the output was truncated. + pub was_truncated: bool, + /// Summary line for the collapsed indicator. + pub summary: String, +} + +const DISPLAY_TRUNCATION_NOTICE: &str = + "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m"; + +/// Collapse tool output to the configured visible line count. +/// Returns a struct with the visible portion and metadata. +pub fn collapse_tool_output( + output: &str, + tool_name: &str, + is_error: bool, + config: &ToolDisplayConfig, +) -> CollapsedToolOutput { + let lines: Vec<&str> = output.lines().collect(); + let total_lines = lines.len(); + let was_truncated = total_lines > config.visible_lines; + + let mut visible = if was_truncated { + lines + .iter() + .take(config.visible_lines) + .cloned() + .collect::>() + .join("\n") + } else { + output.to_string() + }; + + // Also enforce max_chars (character count) on the visible portion + if visible.chars().count() > config.max_chars { + let prefix: String = visible + .chars() + .take(config.max_chars.saturating_sub(1)) + .collect(); + visible = format!("{prefix}…"); + } + + let icon = if is_error { "✗" } else { "✓" }; + let summary = if was_truncated { + format!( + "{} {} ({} lines) — full output in session · [scroll up or /debugToolCall to inspect]", + icon, tool_name, total_lines + ) + } else { + format!("{} {}", icon, tool_name) + }; + + if was_truncated { + write!(&mut visible, "\n{}", DISPLAY_TRUNCATION_NOTICE).expect("write to string"); + } + + CollapsedToolOutput { + visible, + total_lines, + was_truncated, + summary, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_output_is_not_truncated() { + let config = ToolDisplayConfig::default(); + let result = collapse_tool_output("line 1\nline 2\n", "bash", false, &config); + assert!(!result.was_truncated); + assert_eq!(result.total_lines, 2); + } + + #[test] + fn long_output_is_truncated() { + let config = ToolDisplayConfig { + visible_lines: 3, + max_chars: 100_000, + }; + let input = (1..=20) + .map(|i| format!("line {i}")) + .collect::>() + .join("\n"); + let result = collapse_tool_output(&input, "bash", false, &config); + assert!(result.was_truncated); + assert_eq!(result.total_lines, 20); + assert!(result.visible.contains("line 1")); + assert!(result.visible.contains("line 3")); + assert!(!result.visible.contains("line 4")); + } + + #[test] + fn error_tool_gets_error_icon() { + let config = ToolDisplayConfig::default(); + let result = collapse_tool_output("error", "bash", true, &config); + assert!(result.summary.starts_with('✗')); + } + + #[test] + fn success_tool_gets_check_icon() { + let config = ToolDisplayConfig::default(); + let result = collapse_tool_output("ok", "bash", false, &config); + assert!(result.summary.starts_with('✓')); + } + + #[test] + fn empty_output_is_not_truncated() { + let config = ToolDisplayConfig::default(); + let result = collapse_tool_output("", "bash", false, &config); + assert!(!result.was_truncated); + assert_eq!(result.total_lines, 0); + } + + #[test] + fn max_chars_enforced_on_visible() { + let config = ToolDisplayConfig { + visible_lines: 100, + max_chars: 20, + }; + let input = "a".repeat(50); + let result = collapse_tool_output(&input, "bash", false, &config); + assert!(result.visible.chars().count() <= 20); + assert!(result.visible.ends_with('…')); + } + + #[test] + fn summary_contains_line_count() { + let config = ToolDisplayConfig { + visible_lines: 2, + max_chars: 100_000, + }; + let input = (1..=10) + .map(|i| format!("line {i}")) + .collect::>() + .join("\n"); + let result = collapse_tool_output(&input, "bash", false, &config); + assert!(result.summary.contains("10 lines")); + } +} diff --git a/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs b/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs index 066abb686b..fd055e7042 100644 --- a/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs +++ b/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs @@ -624,8 +624,10 @@ fn assert_bash_stdout_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) { } fn assert_bash_permission_prompt_approved(_: &HarnessWorkspace, run: &ScenarioRun) { - assert!(run.stdout.contains("Permission approval required")); - assert!(run.stdout.contains("Approve this tool call? [y/N]:")); + assert!(run.stdout.contains("Permission Required")); + assert!(run + .stdout + .contains("[y]es | [n]o | [a]llow all | [v]iew input")); assert_eq!(run.response["iterations"], Value::from(2)); assert_eq!( run.response["tool_results"][0]["is_error"], @@ -646,8 +648,10 @@ fn assert_bash_permission_prompt_approved(_: &HarnessWorkspace, run: &ScenarioRu } fn assert_bash_permission_prompt_denied(_: &HarnessWorkspace, run: &ScenarioRun) { - assert!(run.stdout.contains("Permission approval required")); - assert!(run.stdout.contains("Approve this tool call? [y/N]:")); + assert!(run.stdout.contains("Permission Required")); + assert!(run + .stdout + .contains("[y]es | [n]o | [a]llow all | [v]iew input")); assert_eq!(run.response["iterations"], Value::from(2)); let tool_output = run.response["tool_results"][0]["output"] .as_str()