mod init; mod input; mod render; use std::collections::BTreeSet; use std::env; use std::fs; use std::io::{self, Read, Write}; use std::net::TcpListener; 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::{ resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock, PromptCache, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; use commands::{ handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; use plugins::{PluginManager, PluginManagerConfig}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, parse_oauth_callback_request_target, resolve_sandbox_status, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; use tools::GlobalToolRegistry; const DEFAULT_MODEL: &str = "claude-opus-4-6"; fn max_tokens_for_model(model: &str) -> u32 { if model.contains("opus") { 32_000 } else { 64_000 } } const DEFAULT_DATE: &str = "2026-03-31"; 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 PRIMARY_SESSION_EXTENSION: &str = "jsonl"; const LEGACY_SESSION_EXTENSION: &str = "json"; 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", "--print", "-p", ]; type AllowedToolSet = BTreeSet; fn main() { if let Err(error) = run() { let message = error.to_string(); if message.contains("`claw --help`") { eprintln!("error: {message}"); } else { eprintln!( "error: {message} Run `claw --help` for usage." ); } std::process::exit(1); } } fn run() -> Result<(), Box> { let args: Vec = env::args().skip(1).collect(); match parse_args(&args)? { CliAction::DumpManifests => dump_manifests(), CliAction::BootstrapPlan => print_bootstrap_plan(), CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?, CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?, CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), CliAction::Version => print_version(), CliAction::ResumeSession { session_path, commands, } => resume_session(&session_path, &commands), CliAction::Status { model, permission_mode, } => print_status_snapshot(&model, permission_mode)?, CliAction::Sandbox => print_sandbox_status_snapshot()?, CliAction::Prompt { prompt, model, output_format, allowed_tools, permission_mode, } => LiveCli::new(model, true, allowed_tools, permission_mode)? .run_turn_with_output(&prompt, output_format)?, CliAction::Login => run_login()?, CliAction::Logout => run_logout()?, CliAction::Init => run_init()?, CliAction::Repl { model, allowed_tools, permission_mode, } => run_repl(model, allowed_tools, permission_mode)?, CliAction::Help => print_help(), } Ok(()) } #[derive(Debug, Clone, PartialEq, Eq)] enum CliAction { DumpManifests, BootstrapPlan, Agents { args: Option, }, Skills { args: Option, }, PrintSystemPrompt { cwd: PathBuf, date: String, }, Version, ResumeSession { session_path: PathBuf, commands: Vec, }, Status { model: String, permission_mode: PermissionMode, }, Sandbox, Prompt { prompt: String, model: String, output_format: CliOutputFormat, allowed_tools: Option, permission_mode: PermissionMode, }, Login, Logout, Init, Repl { model: String, allowed_tools: Option, permission_mode: PermissionMode, }, // prompt-mode formatting is only supported for non-interactive runs Help, } #[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(); let mut output_format = CliOutputFormat::Text; let mut permission_mode = default_permission_mode(); let mut wants_help = false; let mut wants_version = false; let mut allowed_tool_values = Vec::new(); let mut rest = 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; } "--version" | "-V" => { wants_version = true; index += 1; } "--model" => { let value = args .get(index + 1) .ok_or_else(|| "missing value for --model".to_string())?; model = resolve_model_alias(value).to_string(); index += 2; } flag if flag.starts_with("--model=") => { model = resolve_model_alias(&flag[8..]).to_string(); 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 = 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 = parse_permission_mode_arg(&flag[18..])?; index += 1; } "--dangerously-skip-permissions" => { permission_mode = PermissionMode::DangerFullAccess; 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(&model).to_string(), output_format, allowed_tools: normalize_allowed_tools(&allowed_tool_values)?, permission_mode, }); } "--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; } "--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); } if wants_version { return Ok(CliAction::Version); } let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; if rest.is_empty() { return Ok(CliAction::Repl { model, allowed_tools, permission_mode, }); } if rest.first().map(String::as_str) == Some("--resume") { return parse_resume_args(&rest[1..]); } if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode) { return action; } match rest[0].as_str() { "dump-manifests" => Ok(CliAction::DumpManifests), "bootstrap-plan" => Ok(CliAction::BootstrapPlan), "agents" => Ok(CliAction::Agents { args: join_optional_args(&rest[1..]), }), "skills" => Ok(CliAction::Skills { args: join_optional_args(&rest[1..]), }), "system-prompt" => parse_system_prompt_args(&rest[1..]), "login" => Ok(CliAction::Login), "logout" => Ok(CliAction::Logout), "init" => Ok(CliAction::Init), "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, }) } other if other.starts_with('/') => parse_direct_slash_cli_action(&rest), _other => Ok(CliAction::Prompt { prompt: rest.join(" "), model, output_format, allowed_tools, permission_mode, }), } } fn parse_single_word_command_alias( rest: &[String], model: &str, permission_mode: PermissionMode, ) -> Option> { if rest.len() != 1 { return None; } match rest[0].as_str() { "help" => Some(Ok(CliAction::Help)), "version" => Some(Ok(CliAction::Version)), "status" => Some(Ok(CliAction::Status { model: model.to_string(), permission_mode, })), "sandbox" => Some(Ok(CliAction::Sandbox)), other => bare_slash_command_guidance(other).map(Err), } } fn bare_slash_command_guidance(command_name: &str) -> Option { if matches!( command_name, "dump-manifests" | "bootstrap-plan" | "agents" | "skills" | "system-prompt" | "login" | "logout" | "init" | "prompt" ) { return None; } let slash_command = slash_command_specs() .into_iter() .find(|spec| spec.name == command_name)?; let guidance = if slash_command.resume_supported { format!( "`claw {command_name}` is a slash command. Use `claw --resume SESSION.jsonl /{command_name}` or start `claw` and run `/{command_name}`." ) } else { format!( "`claw {command_name}` is a slash command. Start `claw` and run `/{command_name}` inside the REPL." ) }; Some(guidance) } fn join_optional_args(args: &[String]) -> Option { let joined = args.join(" "); let trimmed = joined.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) } fn parse_direct_slash_cli_action(rest: &[String]) -> Result { let raw = rest.join(" "); match SlashCommand::parse(&raw) { Some(SlashCommand::Help) => Ok(CliAction::Help), Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }), Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }), Some(SlashCommand::Unknown(name)) => Err(format_unknown_direct_slash_command(&name)), 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, ) }), None => Err(format!("unknown subcommand: {}", rest[0])), } } fn format_unknown_option(option: &str) -> String { let mut message = format!("unknown option: {option}"); if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) { message.push_str("\nDid you mean "); message.push_str(suggestion); message.push('?'); } message.push_str("\nRun `claw --help` for usage."); message } fn format_unknown_direct_slash_command(name: &str) -> String { let mut message = format!("unknown slash command outside the REPL: /{name}"); if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) { message.push('\n'); message.push_str(&suggestions); } message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help."); message } fn format_unknown_slash_command(name: &str) -> String { let mut message = format!("Unknown slash command: /{name}"); if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) { message.push('\n'); message.push_str(&suggestions); } message.push_str("\n Help /help lists available slash commands"); message } fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option { (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),)) } fn suggest_slash_commands(input: &str) -> Vec { let mut candidates = slash_command_specs() .iter() .flat_map(|spec| { std::iter::once(spec.name) .chain(spec.aliases.iter().copied()) .map(|name| format!("/{name}")) .collect::>() }) .collect::>(); candidates.sort(); candidates.dedup(); let candidate_refs = candidates.iter().map(String::as_str).collect::>(); ranked_suggestions(input.trim_start_matches('/'), &candidate_refs) .into_iter() .map(str::to_string) .collect() } fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> { ranked_suggestions(input, candidates).into_iter().next() } fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> { let normalized_input = input.trim_start_matches('/').to_ascii_lowercase(); let mut ranked = candidates .iter() .filter_map(|candidate| { let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase(); let distance = levenshtein_distance(&normalized_input, &normalized_candidate); let prefix_bonus = usize::from( !(normalized_candidate.starts_with(&normalized_input) || normalized_input.starts_with(&normalized_candidate)), ); let score = distance + prefix_bonus; (score <= 4).then_some((score, *candidate)) }) .collect::>(); ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); ranked .into_iter() .map(|(_, candidate)| candidate) .take(3) .collect() } fn levenshtein_distance(left: &str, right: &str) -> usize { if left.is_empty() { return right.chars().count(); } if right.is_empty() { return left.chars().count(); } let right_chars = right.chars().collect::>(); let mut previous = (0..=right_chars.len()).collect::>(); let mut current = vec![0; right_chars.len() + 1]; for (left_index, left_char) in left.chars().enumerate() { current[0] = left_index + 1; for (right_index, right_char) in right_chars.iter().enumerate() { let substitution_cost = usize::from(left_char != *right_char); current[right_index + 1] = (previous[right_index + 1] + 1) .min(current[right_index] + 1) .min(previous[right_index] + substitution_cost); } previous.clone_from(¤t); } previous[right_chars.len()] } fn 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, } } fn normalize_allowed_tools(values: &[String]) -> Result, String> { 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 plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); let plugin_tools = plugin_manager .aggregated_tools() .map_err(|error| error.to_string())?; GlobalToolRegistry::with_plugin_tools(plugin_tools) } 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 default_permission_mode() -> PermissionMode { env::var("RUSTY_CLAUDE_PERMISSION_MODE") .ok() .as_deref() .and_then(normalize_permission_mode) .map_or(PermissionMode::DangerFullAccess, permission_mode_from_label) } fn filter_tool_specs( tool_registry: &GlobalToolRegistry, allowed_tools: Option<&AllowedToolSet>, ) -> Vec { tool_registry.definitions(allowed_tools) } fn parse_system_prompt_args(args: &[String]) -> 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 => return Err(format!("unknown system-prompt option: {other}")), } } Ok(CliAction::PrintSystemPrompt { cwd, date }) } fn parse_resume_args(args: &[String]) -> 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 = token.clone(); 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, }) } fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { matches!( SlashCommand::parse(current_command), 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() { let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let paths = UpstreamPaths::from_workspace_dir(&workspace_dir); match extract_manifest(&paths) { Ok(manifest) => { println!("commands: {}", manifest.commands.entries().len()); println!("tools: {}", manifest.tools.entries().len()); println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); } Err(error) => { eprintln!("failed to extract manifests: {error}"); std::process::exit(1); } } } fn print_bootstrap_plan() { for phase in runtime::BootstrapPlan::claude_code_default().phases() { println!("- {phase:?}"); } } fn default_oauth_config() -> OAuthConfig { OAuthConfig { client_id: String::from("9d1c250a-e61b-44d9-88ed-5944d1962f5e"), authorize_url: String::from("https://platform.claude.com/oauth/authorize"), token_url: String::from("https://platform.claude.com/v1/oauth/token"), callback_port: None, manual_redirect_url: None, scopes: vec![ String::from("user:profile"), String::from("user:inference"), String::from("user:sessions:claude_code"), ], } } fn run_login() -> Result<(), Box> { let cwd = env::current_dir()?; let config = ConfigLoader::default_for(&cwd).load()?; let default_oauth = default_oauth_config(); let oauth = config.oauth().unwrap_or(&default_oauth); let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT); let redirect_uri = runtime::loopback_redirect_uri(callback_port); let pkce = generate_pkce_pair()?; let state = generate_state()?; let authorize_url = OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce) .build_url(); println!("Starting Claude OAuth login..."); println!("Listening for callback on {redirect_uri}"); if let Err(error) = open_browser(&authorize_url) { eprintln!("warning: failed to open browser automatically: {error}"); println!("Open this URL manually:\n{authorize_url}"); } let callback = wait_for_oauth_callback(callback_port)?; if let Some(error) = callback.error { let description = callback .error_description .unwrap_or_else(|| "authorization failed".to_string()); return Err(io::Error::other(format!("{error}: {description}")).into()); } let code = callback.code.ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidData, "callback did not include code") })?; let returned_state = callback.state.ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidData, "callback did not include state") })?; if returned_state != state { return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into()); } let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url()); let exchange_request = OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri); let runtime = tokio::runtime::Runtime::new()?; let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?; save_oauth_credentials(&runtime::OAuthTokenSet { access_token: token_set.access_token, refresh_token: token_set.refresh_token, expires_at: token_set.expires_at, scopes: token_set.scopes, })?; println!("Claude OAuth login complete."); Ok(()) } fn run_logout() -> Result<(), Box> { clear_oauth_credentials()?; println!("Claude OAuth credentials cleared."); Ok(()) } fn open_browser(url: &str) -> io::Result<()> { let commands = if cfg!(target_os = "macos") { vec![("open", vec![url])] } else if cfg!(target_os = "windows") { vec![("cmd", vec!["/C", "start", "", url])] } else { vec![("xdg-open", vec![url])] }; for (program, args) in commands { match Command::new(program).args(args).spawn() { Ok(_) => return Ok(()), Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error), } } Err(io::Error::new( io::ErrorKind::NotFound, "no supported browser opener command found", )) } fn wait_for_oauth_callback( port: u16, ) -> Result> { let listener = TcpListener::bind(("127.0.0.1", port))?; let (mut stream, _) = listener.accept()?; let mut buffer = [0_u8; 4096]; let bytes_read = stream.read(&mut buffer)?; let request = String::from_utf8_lossy(&buffer[..bytes_read]); let request_line = request.lines().next().ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidData, "missing callback request line") })?; let target = request_line.split_whitespace().nth(1).ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, "missing callback request target", ) })?; let callback = parse_oauth_callback_request_target(target) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; let body = if callback.error.is_some() { "Claude OAuth login failed. You can close this window." } else { "Claude OAuth login succeeded. You can close this window." }; let response = format!( "HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", body.len(), body ); stream.write_all(response.as_bytes())?; Ok(callback) } fn print_system_prompt(cwd: PathBuf, date: String) { match load_system_prompt(cwd, date, env::consts::OS, "unknown") { Ok(sections) => println!("{}", sections.join("\n\n")), Err(error) => { eprintln!("failed to build system prompt: {error}"); std::process::exit(1); } } } fn print_version() { println!("{}", render_version_report()); } fn resume_session(session_path: &Path, commands: &[String]) { let resolved_path = if session_path.exists() { session_path.to_path_buf() } else { match resolve_session_reference(&session_path.display().to_string()) { Ok(handle) => handle.path, Err(error) => { eprintln!("failed to restore session: {error}"); std::process::exit(1); } } }; let session = match Session::load_from_path(&resolved_path) { Ok(session) => session, Err(error) => { eprintln!("failed to restore session: {error}"); std::process::exit(1); } }; if commands.is_empty() { println!( "Restored session from {} ({} messages).", resolved_path.display(), session.messages.len() ); return; } let mut session = session; for raw_command in commands { let Some(command) = SlashCommand::parse(raw_command) else { eprintln!("unsupported resumed command: {raw_command}"); std::process::exit(2); }; match run_resume_command(&resolved_path, &session, &command) { Ok(ResumeCommandOutcome { session: next_session, message, }) => { session = next_session; if let Some(message) = message { println!("{message}"); } } Err(error) => { eprintln!("{error}"); std::process::exit(2); } } } } #[derive(Debug, Clone)] struct ResumeCommandOutcome { session: Session, message: 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, } #[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); if suggestions.is_empty() { format!("unknown slash command: /{name}. Use /help to list available commands.") } else { format!( "unknown slash command: /{name}. Did you mean {}? Use /help to list available commands.", suggestions.join(", ") ) } } 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()), }), 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)), }) } SlashCommand::Clear { confirm } => { if !confirm { return Ok(ResumeCommandOutcome { session: session.clone(), message: Some( "clear: confirmation required; rerun with /clear --confirm".to_string(), ), }); } let cleared = Session::new(); cleared.save_to_path(session_path)?; Ok(ResumeCommandOutcome { session: cleared, message: Some(format!( "Cleared resumed session file {}.", session_path.display() )), }) } SlashCommand::Status => { let tracker = UsageTracker::from_session(session); let usage = tracker.cumulative_usage(); Ok(ResumeCommandOutcome { session: session.clone(), message: Some(format_status_report( "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(), &status_context(Some(session_path))?, )), }) } SlashCommand::Sandbox => { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader.load()?; Ok(ResumeCommandOutcome { session: session.clone(), message: Some(format_sandbox_report(&resolve_sandbox_status( runtime_config.sandbox(), &cwd, ))), }) } SlashCommand::Cost => { let usage = UsageTracker::from_session(session).cumulative_usage(); Ok(ResumeCommandOutcome { session: session.clone(), message: Some(format_cost_report(usage)), }) } SlashCommand::Config { section } => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_config_report(section.as_deref())?), }), SlashCommand::Memory => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_memory_report()?), }), SlashCommand::Init => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(init_claude_md()?), }), SlashCommand::Diff => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_diff_report_for( session_path.parent().unwrap_or_else(|| Path::new(".")), )?), }), SlashCommand::Version => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_version_report()), }), SlashCommand::Export { path } => { let export_path = resolve_export_path(path.as_deref(), session)?; fs::write(&export_path, render_export_text(session))?; Ok(ResumeCommandOutcome { session: session.clone(), message: Some(format!( "Export\n Result wrote transcript\n File {}\n Messages {}", export_path.display(), session.messages.len(), )), }) } SlashCommand::Agents { args } => { let cwd = env::current_dir()?; Ok(ResumeCommandOutcome { session: session.clone(), message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), }) } SlashCommand::Skills { args } => { let cwd = env::current_dir()?; Ok(ResumeCommandOutcome { session: session.clone(), message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), }) } SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), SlashCommand::Bughunter { .. } | SlashCommand::Commit | SlashCommand::Pr { .. } | SlashCommand::Issue { .. } | SlashCommand::Ultraplan { .. } | SlashCommand::Teleport { .. } | SlashCommand::DebugToolCall | SlashCommand::Resume { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } => Err("unsupported resumed slash command".into()), } } fn run_repl( model: String, allowed_tools: Option, permission_mode: PermissionMode, ) -> Result<(), Box> { let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?; let mut editor = input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); println!("{}", cli.startup_banner()); 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; } if let Some(command) = SlashCommand::parse(&trimmed) { if cli.handle_repl_command(command)? { cli.persist_session()?; } continue; } editor.push_history(input); 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, 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: ConversationRuntime, session: SessionHandle, } 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 = Session::new(); 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, }; cli.persist_session()?; Ok(cli) } 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< ( ConversationRuntime, 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 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(); self.runtime = runtime; match result { Ok(summary) => { 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) => { 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, ) -> Result<(), Box> { match output_format { CliOutputFormat::Text => self.run_turn(input), CliOutputFormat::Json => self.run_prompt_json(input), } } 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.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, } }) ); Ok(()) } 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()?; true } 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()?; 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::Memory => { Self::print_memory()?; false } SlashCommand::Init => { run_init()?; false } SlashCommand::Diff => { Self::print_diff()?; false } SlashCommand::Version => { Self::print_version(); 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())?; false } SlashCommand::Skills { args } => { Self::print_skills(args.as_deref())?; 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"), ) ); } 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(&model).to_string(); 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(); self.runtime = build_runtime( session, &self.session.id, model.clone(), self.system_prompt.clone(), true, true, self.allowed_tools.clone(), self.permission_mode, None, )?; 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); self.runtime = build_runtime( session, &self.session.id, self.model.clone(), self.system_prompt.clone(), true, true, self.allowed_tools.clone(), self.permission_mode, None, )?; 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 session_state = Session::new(); self.session = create_managed_session_handle(&session_state.session_id)?; self.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, )?; println!( "Session cleared\n Mode fresh session\n Preserved model {}\n Permission mode {}\n Session {}", self.model, self.permission_mode.as_str(), self.session.id, ); 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 = resolve_session_reference(&session_ref)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); let session_id = session.session_id.clone(); self.runtime = build_runtime( session, &handle.id, self.model.clone(), self.system_prompt.clone(), true, true, self.allowed_tools.clone(), self.permission_mode, None, )?; 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>) -> Result<(), Box> { let cwd = env::current_dir()?; println!("{}", handle_agents_slash_command(args, &cwd)?); Ok(()) } fn print_skills(args: Option<&str>) -> Result<(), Box> { let cwd = env::current_dir()?; println!("{}", handle_skills_slash_command(args, &cwd)?); Ok(()) } fn print_diff() -> Result<(), Box> { println!("{}", render_diff_report()?); Ok(()) } fn print_version() { println!("{}", render_version_report()); } 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(()) } 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 = resolve_session_reference(target)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); let session_id = session.session_id.clone(); self.runtime = build_runtime( session, &handle.id, self.model.clone(), self.system_prompt.clone(), true, true, self.allowed_tools.clone(), self.permission_mode, None, )?; 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)?; self.runtime = build_runtime( forked, &handle.id, self.model.clone(), self.system_prompt.clone(), true, true, self.allowed_tools.clone(), self.permission_mode, None, )?; 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(other) => { println!( "Unknown /session action '{other}'. Use /session list, /session switch , or /session fork [branch-name]." ); 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> { self.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.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; self.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.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))?; Ok(final_assistant_text(&summary).trim().to_string()) } 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> { let scope = scope.unwrap_or("the current repository"); let prompt = format!( "You are /bughunter. Inspect {scope} and identify the most likely bugs or correctness issues. Prioritize concrete findings with file paths, severity, and suggested fixes. Use tools if needed." ); println!("{}", self.run_internal_prompt_text(&prompt, true)?); Ok(()) } fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box> { let task = task.unwrap_or("the current repo work"); let prompt = format!( "You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed." ); let mut progress = InternalPromptProgressRun::start_ultraplan(task); match self.run_internal_prompt_text_with_progress(&prompt, true, Some(progress.reporter())) { Ok(plan) => { progress.finish_success(); println!("{plan}"); Ok(()) } Err(error) => { progress.finish_failure(&error.to_string()); Err(error) } } } #[allow(clippy::unused_self)] fn run_teleport(&self, 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) -> Result<(), Box> { println!("{}", render_last_tool_debug_report(self.runtime.session())?); Ok(()) } fn run_commit(&mut self) -> Result<(), Box> { 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) ); git_status_ok(&["add", "-A"])?; let staged_stat = git_output(&["diff", "--cached", "--stat"])?; let prompt = format!( "Generate a git commit message in plain text Lore format only. Base it on this staged diff summary:\n\n{}\n\nRecent conversation context:\n{}", truncate_for_prompt(&staged_stat, 8_000), recent_user_context(self.runtime.session(), 6) ); let message = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); if message.trim().is_empty() { return Err("generated commit message was empty".into()); } let path = write_temp_text_file("claw-commit-message.txt", &message)?; let output = Command::new("git") .args(["commit", "--file"]) .arg(&path) .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_commit_failure(&stderr).into()); } println!( "{}", format_commit_success_report(branch.as_deref(), summary, &path, &staged_stat, &message,) ); Ok(()) } fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { let staged = git_output(&["diff", "--stat"])?; let prompt = format!( "Generate a pull request title and body from this conversation and diff summary. Output plain text in this format exactly:\nTITLE: \nBODY:\n<body markdown>\n\nContext hint: {}\n\nDiff summary:\n{}", context.unwrap_or("none"), truncate_for_prompt(&staged, 10_000) ); let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); let (title, body) = parse_titled_body(&draft) .ok_or_else(|| "failed to parse generated PR title/body".to_string())?; if command_exists("gh") { let body_path = write_temp_text_file("claw-pr-body.md", &body)?; let output = Command::new("gh") .args(["pr", "create", "--title", &title, "--body-file"]) .arg(&body_path) .current_dir(env::current_dir()?) .output()?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); println!( "PR\n Result created\n Title {title}\n URL {}", if stdout.is_empty() { "<unknown>" } else { &stdout } ); return Ok(()); } } println!("PR draft\n Title {title}\n\n{body}"); Ok(()) } fn run_issue(&self, context: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { let prompt = format!( "Generate a GitHub issue title and body from this conversation. Output plain text in this format exactly:\nTITLE: <title>\nBODY:\n<body markdown>\n\nContext hint: {}\n\nConversation context:\n{}", context.unwrap_or("none"), truncate_for_prompt(&recent_user_context(self.runtime.session(), 10), 10_000) ); let draft = sanitize_generated_message(&self.run_internal_prompt_text(&prompt, false)?); let (title, body) = parse_titled_body(&draft) .ok_or_else(|| "failed to parse generated issue title/body".to_string())?; if command_exists("gh") { let body_path = write_temp_text_file("claw-issue-body.md", &body)?; let output = Command::new("gh") .args(["issue", "create", "--title", &title, "--body-file"]) .arg(&body_path) .current_dir(env::current_dir()?) .output()?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); println!( "Issue\n Result created\n Title {title}\n URL {}", if stdout.is_empty() { "<unknown>" } else { &stdout } ); return Ok(()); } } println!("Issue draft\n Title {title}\n\n{body}"); Ok(()) } } fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> { let cwd = env::current_dir()?; let path = cwd.join(".claw").join("sessions"); fs::create_dir_all(&path)?; Ok(path) } fn create_managed_session_handle( session_id: &str, ) -> Result<SessionHandle, Box<dyn std::error::Error>> { let id = session_id.to_string(); let path = sessions_dir()?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}")); Ok(SessionHandle { id, path }) } fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> { if SESSION_REFERENCE_ALIASES .iter() .any(|alias| reference.eq_ignore_ascii_case(alias)) { let latest = latest_managed_session()?; return Ok(SessionHandle { id: latest.id, path: latest.path, }); } let direct = PathBuf::from(reference); let looks_like_path = direct.extension().is_some() || direct.components().count() > 1; let path = if direct.exists() { direct } else if looks_like_path { return Err(format_missing_session_reference(reference).into()); } else { resolve_managed_session_path(reference)? }; let id = path .file_name() .and_then(|value| value.to_str()) .and_then(|name| { name.strip_suffix(&format!(".{PRIMARY_SESSION_EXTENSION}")) .or_else(|| name.strip_suffix(&format!(".{LEGACY_SESSION_EXTENSION}"))) }) .unwrap_or(reference) .to_string(); Ok(SessionHandle { id, path }) } fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std::error::Error>> { let directory = sessions_dir()?; for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] { let path = directory.join(format!("{session_id}.{extension}")); if path.exists() { return Ok(path); } } Err(format_missing_session_reference(session_id).into()) } fn is_managed_session_file(path: &Path) -> bool { path.extension() .and_then(|ext| ext.to_str()) .is_some_and(|extension| { extension == PRIMARY_SESSION_EXTENSION || extension == LEGACY_SESSION_EXTENSION }) } fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> { let mut sessions = Vec::new(); for entry in fs::read_dir(sessions_dir()?)? { let entry = entry?; let path = entry.path(); if !is_managed_session_file(&path) { continue; } let metadata = entry.metadata()?; let modified_epoch_millis = metadata .modified() .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_millis()) .unwrap_or_default(); let (id, message_count, parent_session_id, branch_name) = match Session::load_from_path(&path) { Ok(session) => { let parent_session_id = session .fork .as_ref() .map(|fork| fork.parent_session_id.clone()); let branch_name = session .fork .as_ref() .and_then(|fork| fork.branch_name.clone()); ( session.session_id, session.messages.len(), parent_session_id, branch_name, ) } Err(_) => ( path.file_stem() .and_then(|value| value.to_str()) .unwrap_or("unknown") .to_string(), 0, None, None, ), }; sessions.push(ManagedSessionSummary { id, path, modified_epoch_millis, message_count, parent_session_id, branch_name, }); } sessions.sort_by(|left, right| { right .modified_epoch_millis .cmp(&left.modified_epoch_millis) .then_with(|| right.id.cmp(&left.id)) }); Ok(sessions) } fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> { list_managed_sessions()? .into_iter() .next() .ok_or_else(|| format_no_managed_sessions().into()) } fn format_missing_session_reference(reference: &str) -> String { format!( "session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL." ) } fn format_no_managed_sessions() -> String { format!( "no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`." ) } fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> { 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 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(), " 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/<session-id>.jsonl".to_string(), " Resume latest /resume latest".to_string(), " Browse sessions /session list".to_string(), String::new(), render_slash_command_help(), ] .join( " ", ) } fn print_status_snapshot( model: &str, permission_mode: PermissionMode, ) -> Result<(), Box<dyn std::error::Error>> { println!( "{}", format_status_report( model, StatusUsage { message_count: 0, turns: 0, latest: TokenUsage::default(), cumulative: TokenUsage::default(), estimated_tokens: 0, }, permission_mode.as_str(), &status_context(None)?, ) ); Ok(()) } fn status_context( session_path: Option<&Path>, ) -> Result<StatusContext, Box<dyn std::error::Error>> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let discovered_config_files = loader.discover().len(); let runtime_config = loader.load()?; 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 sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); Ok(StatusContext { cwd, session_path: session_path.map(Path::to_path_buf), loaded_config_files: runtime_config.loaded_entries().len(), discovered_config_files, memory_file_count: project_context.instruction_files.len(), project_root, git_branch, git_summary, sandbox_status, }) } fn format_status_report( model: &str, usage: StatusUsage, permission_mode: &str, context: &StatusContext, ) -> String { [ format!( "Status Model {model} 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), ] .join( " ", ) } 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() { "<none>".to_string() } else { status.allowed_mounts.join(", ") }, if status.container_markers.is_empty() { "<none>".to_string() } else { status.container_markers.join(", ") }, status .fallback_reason .clone() .unwrap_or_else(|| "<none>".to_string()), ) } fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String { format!( "Commit Result preparing Branch {} Workspace {} Changed files {} Action auto-stage workspace changes and draft Lore commit message", branch.unwrap_or("unknown"), summary.headline(), summary.changed_files, ) } fn format_commit_skipped_report() -> String { "Commit Result skipped Reason no workspace changes Next /status to inspect context · /diff to inspect repo changes" .to_string() } fn format_commit_success_report( branch: Option<&str>, summary: GitWorkspaceSummary, message_path: &Path, staged_stat: &str, message: &str, ) -> String { let staged_summary = staged_stat.trim(); format!( "Commit Result created Branch {} Workspace {} Changed files {} Message file {} Staged diff {} Lore message {}", branch.unwrap_or("unknown"), summary.headline(), summary.changed_files, message_path.display(), if staged_summary.is_empty() { " <summary unavailable>".to_string() } else { indent_block(staged_summary, 2) }, message.trim() ) } fn format_git_commit_failure(stderr: &str) -> String { if stderr.contains("Author identity unknown") || stderr.contains("Please tell me who you are") { "git commit failed: author identity is not configured. Run `git config user.name \"Your Name\"` and `git config user.email \"you@example.com\"`, then retry /commit.".to_string() } else { format!("git commit failed: {stderr}") } } fn print_sandbox_status_snapshot() -> Result<(), Box<dyn std::error::Error>> { let cwd = env::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)) ); Ok(()) } fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> { 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 => "<unset>".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_memory_report() -> Result<String, Box<dyn std::error::Error>> { let cwd = env::current_dir()?; let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; let mut lines = vec![format!( "Memory Working directory {} Instruction files {}", cwd.display(), project_context.instruction_files.len() )]; if project_context.instruction_files.is_empty() { lines.push("Discovered files".to_string()); lines.push( " No CLAUDE instruction files discovered in the current directory ancestry." .to_string(), ); } else { lines.push("Discovered files".to_string()); for (index, file) in project_context.instruction_files.iter().enumerate() { let preview = file.content.lines().next().unwrap_or("").trim(); let preview = if preview.is_empty() { "<empty>" } else { preview }; lines.push(format!(" {}. {}", index + 1, file.path.display(),)); lines.push(format!( " lines={} preview={}", file.content.lines().count(), preview )); } } Ok(lines.join( " ", )) } fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> { let cwd = env::current_dir()?; Ok(initialize_repo(&cwd)?.render()) } fn run_init() -> Result<(), Box<dyn std::error::Error>> { println!("{}", init_claude_md()?); Ok(()) } 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<String, Box<dyn std::error::Error>> { render_diff_report_for(&env::current_dir()?) } fn render_diff_report_for(cwd: &Path) -> Result<String, Box<dyn std::error::Error>> { 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 run_git_diff_command_in( cwd: &Path, args: &[&str], ) -> Result<String, Box<dyn std::error::Error>> { 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<String, Box<dyn std::error::Error>> { 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::<Vec<_>>() } else { Vec::new() }; let content_output = Command::new("rg") .args(["-n", "-S", "--color", "never", target, "."]) .current_dir(&cwd) .output()?; let mut lines = vec![format!("Teleport\n Target {target}")]; 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<String, Box<dyn std::error::Error>> { 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(), 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::<Vec<_>>() .join("\n") } fn git_output(args: &[&str]) -> Result<String, Box<dyn std::error::Error>> { 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<dyn std::error::Error>> { 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<PathBuf, Box<dyn std::error::Error>> { let path = env::temp_dir().join(filename); fs::write(&path, contents)?; Ok(path) } 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::<Vec<_>>(); if requests.is_empty() { "<no prior user messages>".to_string() } else { requests .into_iter() .rev() .enumerate() .map(|(index, text)| format!("{}. {}", index + 1, text)) .collect::<Vec<_>>() .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::<String>(); 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::<String>() .split('-') .filter(|part| !part.is_empty()) .take(8) .collect::<Vec<_>>() .join("-"); let fallback = if stem.is_empty() { "conversation" } else { &stem }; format!("{fallback}.txt") } fn resolve_export_path( requested_path: Option<&str>, session: &Session, ) -> Result<PathBuf, Box<dyn std::error::Error>> { 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)) } fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> { Ok(load_system_prompt( env::current_dir()?, DEFAULT_DATE, env::consts::OS, "unknown", )?) } fn build_runtime_plugin_state( ) -> Result<(runtime::RuntimeFeatureConfig, GlobalToolRegistry), Box<dyn std::error::Error>> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader.load()?; let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config); let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_manager.aggregated_tools()?)?; Ok((runtime_config.feature_config().clone(), tool_registry)) } 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) } } #[derive(Debug, Clone, PartialEq, Eq)] struct InternalPromptProgressState { command_label: &'static str, task_label: String, step: usize, phase: String, detail: Option<String>, saw_final_text: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InternalPromptProgressEvent { Started, Update, Heartbeat, Complete, Failed, } #[derive(Debug)] struct InternalPromptProgressShared { state: Mutex<InternalPromptProgressState>, output_lock: Mutex<()>, started_at: Instant, } #[derive(Debug, Clone)] struct InternalPromptProgressReporter { shared: Arc<InternalPromptProgressShared>, } #[derive(Debug)] struct InternalPromptProgressRun { reporter: InternalPromptProgressReporter, heartbeat_stop: Option<mpsc::Sender<()>>, heartbeat_handle: Option<thread::JoinHandle<()>>, } 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 ) } InternalPromptProgressEvent::Update => { format!("… {} status · {status}", snapshot.command_label) } 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)) } } "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}") } } } } #[allow(clippy::needless_pass_by_value)] #[allow(clippy::too_many_arguments)] fn build_runtime( session: Session, session_id: &str, model: String, system_prompt: Vec<String>, enable_tools: bool, emit_output: bool, allowed_tools: Option<AllowedToolSet>, permission_mode: PermissionMode, progress_reporter: Option<InternalPromptProgressReporter>, ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> { let (feature_config, tool_registry) = build_runtime_plugin_state()?; 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()), permission_policy(permission_mode, &feature_config, &tool_registry), system_prompt, &feature_config, ); if emit_output { runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter)); } Ok(runtime) } 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() ), } } } 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}"); } 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}"), }, } } } struct AnthropicRuntimeClient { runtime: tokio::runtime::Runtime, client: AnthropicClient, model: String, enable_tools: bool, emit_output: bool, allowed_tools: Option<AllowedToolSet>, tool_registry: GlobalToolRegistry, progress_reporter: Option<InternalPromptProgressReporter>, } impl AnthropicRuntimeClient { fn new( session_id: &str, model: String, enable_tools: bool, emit_output: bool, allowed_tools: Option<AllowedToolSet>, tool_registry: GlobalToolRegistry, progress_reporter: Option<InternalPromptProgressReporter>, ) -> Result<Self, Box<dyn std::error::Error>> { Ok(Self { runtime: tokio::runtime::Runtime::new()?, client: AnthropicClient::from_auth(resolve_cli_auth_source()?) .with_base_url(api::read_base_url()) .with_prompt_cache(PromptCache::new(session_id)), model, enable_tools, emit_output, allowed_tools, tool_registry, progress_reporter, }) } } fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> { Ok(resolve_startup_auth_source(|| { let cwd = env::current_dir().map_err(api::ApiError::from)?; let config = ConfigLoader::default_for(&cwd).load().map_err(|error| { api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}")) })?; Ok(config.oauth().cloned()) })?) } impl ApiClient for AnthropicRuntimeClient { #[allow(clippy::too_many_lines)] fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> { if let Some(progress_reporter) = &self.progress_reporter { progress_reporter.mark_model_phase(); } 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, }; self.runtime.block_on(async { let mut stream = self .client .stream_message(&message_request) .await .map_err(|error| RuntimeError::new(error.to_string()))?; 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 saw_stop = false; while let Some(event) = stream .next_event() .await .map_err(|error| RuntimeError::new(error.to_string()))? { match event { ApiStreamEvent::MessageStart(start) => { for block in start.message.content { push_output_block(block, out, &mut events, &mut pending_tool, true)?; } } ApiStreamEvent::ContentBlockStart(start) => { push_output_block( start.content_block, out, &mut events, &mut pending_tool, true, )?; } 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 { .. } | ContentBlockDelta::SignatureDelta { .. } => {} }, ApiStreamEvent::ContentBlockStop(_) => { 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); } } } 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)) { return Ok(events); } let response = self .client .send_message(&MessageRequest { stream: false, ..message_request.clone() }) .await .map_err(|error| RuntimeError::new(error.to_string()))?; let mut events = response_to_events(response, out)?; push_prompt_cache_record(&self.client, &mut events); Ok(events) }) } } 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::<Vec<_>>() .join("") }) .unwrap_or_default() } fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec<serde_json::Value> { 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<serde_json::Value> { 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<serde_json::Value> { 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() } fn slash_command_completion_candidates_with_sessions( model: &str, active_session_id: Option<&str>, recent_session_ids: Vec<String>, ) -> Vec<String> { let mut completions = BTreeSet::new(); for spec in slash_command_specs() { completions.insert(format!("/{}", spec.name)); for alias in spec.aliases { completions.insert(format!("/{alias}")); } } for candidate in [ "/bughunter ", "/clear --confirm", "/config ", "/config env", "/config hooks", "/config model", "/config plugins", "/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", "/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 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), }; 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" }; 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), } } 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<String> { if old_value.is_empty() && new_value.is_empty() { return None; } 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) )) } 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 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, )); } } 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, ) )); } } lines.join("\n\n") } 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} \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 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 format_structured_patch_preview(parsed: &serde_json::Value) -> Option<String> { 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 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} \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::<Vec<_>>() .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 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::<Vec<_>>() .join("\n") }) .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}") } else { summary } } 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}") } else { format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {preview}") } } fn summarize_tool_payload(payload: &str) -> String { let compact = match serde_json::from_str::<serde_json::Value>(payload) { Ok(value) => value.to_string(), Err(_) => payload.trim().to_string(), }; truncate_for_summary(&compact, 96) } fn truncate_for_summary(value: &str, limit: usize) -> String { let mut chars = value.chars(); let truncated = chars.by_ref().take(limit).collect::<String>(); if chars.next().is_some() { format!("{truncated}…") } else { truncated } } 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::<String>()); 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 push_output_block( block: OutputContentBlock, out: &mut (impl Write + ?Sized), events: &mut Vec<AssistantEvent>, pending_tool: &mut Option<(String, String, String)>, streaming_tool_input: 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 { .. } | OutputContentBlock::RedactedThinking { .. } => {} } Ok(()) } fn response_to_events( response: MessageResponse, out: &mut (impl Write + ?Sized), ) -> Result<Vec<AssistantEvent>, RuntimeError> { let mut events = Vec::new(); let mut pending_tool = None; for block in response.content { push_output_block(block, out, &mut events, &mut pending_tool, false)?; 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) } fn push_prompt_cache_record(client: &AnthropicClient, events: &mut Vec<AssistantEvent>) { 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)); } } } fn prompt_cache_record_to_runtime_event( record: api::PromptCacheRecord, ) -> Option<PromptCacheEvent> { 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<AllowedToolSet>, tool_registry: GlobalToolRegistry, } impl CliToolExecutor { fn new( allowed_tools: Option<AllowedToolSet>, emit_output: bool, tool_registry: GlobalToolRegistry, ) -> Self { Self { renderer: TerminalRenderer::new(), emit_output, allowed_tools, tool_registry, } } } impl ToolExecutor for CliToolExecutor { fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> { 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}")))?; match self.tool_registry.execute(tool_name, &value) { 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, true); self.renderer .stream_markdown(&markdown, &mut io::stdout()) .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; } Err(ToolError::new(error)) } } } } fn permission_policy( mode: PermissionMode, feature_config: &runtime::RuntimeFeatureConfig, tool_registry: &GlobalToolRegistry, ) -> PermissionPolicy { 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 convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> { 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::<Vec<_>>(); (!content.is_empty()).then(|| InputMessage { role: role.to_string(), content, }) }) .collect() } #[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 dump-manifests")?; writeln!(out, " claw bootstrap-plan")?; writeln!(out, " claw agents")?; writeln!(out, " claw skills")?; writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; writeln!(out, " claw login")?; writeln!(out, " claw logout")?; writeln!(out, " claw init")?; 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, " --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())?; 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::<Vec<_>>() .join(", "); writeln!(out, "Resume-safe commands: {resume_commands}")?; writeln!(out)?; writeln!(out, "Session shortcuts:")?; writeln!( out, " REPL turns auto-save to .claw/sessions/<session-id>.{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 --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 /skills")?; writeln!(out, " claw login")?; writeln!(out, " claw init")?; Ok(()) } fn print_help() { let _ = print_help_to(&mut io::stdout()); } #[cfg(test)] mod tests { use super::{ create_managed_session_handle, describe_tool_progress, filter_tool_specs, format_commit_preflight_report, format_commit_skipped_report, format_commit_success_report, format_compact_report, format_cost_report, format_internal_prompt_progress_line, format_model_report, format_model_switch_report, format_permissions_report, format_permissions_switch_report, format_resume_report, format_status_report, format_tool_call_start, format_tool_result, format_unknown_slash_command, format_unknown_slash_command_message, normalize_permission_mode, parse_args, parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy, print_help_to, push_output_block, render_config_report, render_diff_report, render_memory_report, render_repl_help, resolve_model_alias, resolve_session_reference, render_resume_usage, response_to_events, resume_supported_slash_commands, run_resume_command, slash_command_completion_candidates_with_sessions, status_context, CliAction, CliOutputFormat, GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; use runtime::{ AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session, }; use serde_json::json; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Mutex, MutexGuard, OnceLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tools::GlobalToolRegistry; fn registry_with_plugin_tool() -> GlobalToolRegistry { GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new( "plugin-demo@external", "plugin-demo", PluginToolDefinition { name: "plugin_echo".to_string(), description: Some("Echo plugin payload".to_string()), input_schema: json!({ "type": "object", "properties": { "message": { "type": "string" } }, "required": ["message"], "additionalProperties": false }), }, "echo".to_string(), Vec::new(), PluginToolPermission::WorkspaceWrite, None, )]) .expect("plugin tool registry should build") } fn temp_dir() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("time should be after epoch") .as_nanos(); std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}")) } fn git(args: &[&str], cwd: &Path) { let status = Command::new("git") .args(args) .current_dir(cwd) .status() .expect("git command should run"); assert!( status.success(), "git command failed: git {}", args.join(" ") ); } fn env_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T { let previous = std::env::current_dir().expect("cwd should load"); std::env::set_current_dir(cwd).expect("cwd should change"); let result = f(); std::env::set_current_dir(previous).expect("cwd should restore"); result } #[test] fn defaults_to_repl_when_no_args() { assert_eq!( parse_args(&[]).expect("args should parse"), CliAction::Repl { model: DEFAULT_MODEL.to_string(), allowed_tools: None, permission_mode: PermissionMode::DangerFullAccess, } ); } #[test] fn parses_prompt_subcommand() { let args = vec![ "prompt".to_string(), "hello".to_string(), "world".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::Prompt { prompt: "hello world".to_string(), model: DEFAULT_MODEL.to_string(), output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: PermissionMode::DangerFullAccess, } ); } #[test] fn parses_bare_prompt_and_json_output_flag() { let args = vec![ "--output-format=json".to_string(), "--model".to_string(), "claude-opus".to_string(), "explain".to_string(), "this".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::Prompt { prompt: "explain this".to_string(), model: "claude-opus".to_string(), output_format: CliOutputFormat::Json, allowed_tools: None, permission_mode: PermissionMode::DangerFullAccess, } ); } #[test] fn resolves_model_aliases_in_args() { let args = vec![ "--model".to_string(), "opus".to_string(), "explain".to_string(), "this".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::Prompt { prompt: "explain this".to_string(), model: "claude-opus-4-6".to_string(), output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: PermissionMode::DangerFullAccess, } ); } #[test] fn resolves_known_model_aliases() { assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6"); assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6"); assert_eq!(resolve_model_alias("haiku"), "claude-haiku-4-5-20251213"); assert_eq!(resolve_model_alias("claude-opus"), "claude-opus"); } #[test] fn parses_version_flags_without_initializing_prompt_mode() { assert_eq!( parse_args(&["--version".to_string()]).expect("args should parse"), CliAction::Version ); assert_eq!( parse_args(&["-V".to_string()]).expect("args should parse"), CliAction::Version ); } #[test] fn parses_permission_mode_flag() { let args = vec!["--permission-mode=read-only".to_string()]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::Repl { model: DEFAULT_MODEL.to_string(), allowed_tools: None, permission_mode: PermissionMode::ReadOnly, } ); } #[test] fn parses_allowed_tools_flags_with_aliases_and_lists() { let args = vec![ "--allowedTools".to_string(), "read,glob".to_string(), "--allowed-tools=write_file".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::Repl { model: DEFAULT_MODEL.to_string(), allowed_tools: Some( ["glob_search", "read_file", "write_file"] .into_iter() .map(str::to_string) .collect() ), permission_mode: PermissionMode::DangerFullAccess, } ); } #[test] fn rejects_unknown_allowed_tools() { let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()]) .expect_err("tool should be rejected"); assert!(error.contains("unsupported tool in --allowedTools: teleport")); } #[test] fn parses_system_prompt_options() { let args = vec![ "system-prompt".to_string(), "--cwd".to_string(), "/tmp/project".to_string(), "--date".to_string(), "2026-04-01".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::PrintSystemPrompt { cwd: PathBuf::from("/tmp/project"), date: "2026-04-01".to_string(), } ); } #[test] fn parses_login_and_logout_subcommands() { assert_eq!( parse_args(&["login".to_string()]).expect("login should parse"), CliAction::Login ); assert_eq!( parse_args(&["logout".to_string()]).expect("logout should parse"), CliAction::Logout ); assert_eq!( parse_args(&["init".to_string()]).expect("init should parse"), CliAction::Init ); assert_eq!( parse_args(&["agents".to_string()]).expect("agents should parse"), CliAction::Agents { args: None } ); assert_eq!( parse_args(&["skills".to_string()]).expect("skills should parse"), CliAction::Skills { args: None } ); assert_eq!( parse_args(&["agents".to_string(), "--help".to_string()]) .expect("agents help should parse"), CliAction::Agents { args: Some("--help".to_string()) } ); } #[test] fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() { assert_eq!( parse_args(&["help".to_string()]).expect("help should parse"), CliAction::Help ); assert_eq!( parse_args(&["version".to_string()]).expect("version should parse"), CliAction::Version ); assert_eq!( parse_args(&["status".to_string()]).expect("status should parse"), CliAction::Status { model: DEFAULT_MODEL.to_string(), permission_mode: PermissionMode::DangerFullAccess, } ); assert_eq!( parse_args(&["sandbox".to_string()]).expect("sandbox should parse"), CliAction::Sandbox ); } #[test] fn single_word_slash_command_names_return_guidance_instead_of_hitting_prompt_mode() { let error = parse_args(&["cost".to_string()]).expect_err("cost should return guidance"); assert!(error.contains("slash command")); assert!(error.contains("/cost")); } #[test] fn multi_word_prompt_still_uses_shorthand_prompt_mode() { assert_eq!( parse_args(&["help".to_string(), "me".to_string(), "debug".to_string()]) .expect("prompt shorthand should still work"), CliAction::Prompt { prompt: "help me debug".to_string(), model: DEFAULT_MODEL.to_string(), output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: PermissionMode::DangerFullAccess, } ); } #[test] fn parses_direct_agents_and_skills_slash_commands() { assert_eq!( parse_args(&["/agents".to_string()]).expect("/agents should parse"), CliAction::Agents { args: None } ); assert_eq!( parse_args(&["/skills".to_string()]).expect("/skills should parse"), CliAction::Skills { args: None } ); assert_eq!( parse_args(&["/skills".to_string(), "help".to_string()]) .expect("/skills help should parse"), CliAction::Skills { args: Some("help".to_string()) } ); let error = parse_args(&["/status".to_string()]) .expect_err("/status should remain REPL-only when invoked directly"); assert!(error.contains("interactive-only")); assert!(error.contains("claw --resume SESSION.jsonl /status")); } #[test] fn formats_unknown_slash_command_with_suggestions() { let report = format_unknown_slash_command_message("stats"); assert!(report.contains("unknown slash command: /stats")); assert!(report.contains("Did you mean /status?")); assert!(report.contains("Use /help")); } #[test] fn parses_resume_flag_with_slash_command() { let args = vec![ "--resume".to_string(), "session.jsonl".to_string(), "/compact".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::ResumeSession { session_path: PathBuf::from("session.jsonl"), commands: vec!["/compact".to_string()], } ); } #[test] fn parses_resume_flag_without_path_as_latest_session() { assert_eq!( parse_args(&["--resume".to_string()]).expect("args should parse"), CliAction::ResumeSession { session_path: PathBuf::from("latest"), commands: vec![], } ); assert_eq!( parse_args(&["--resume".to_string(), "/status".to_string()]) .expect("resume shortcut should parse"), CliAction::ResumeSession { session_path: PathBuf::from("latest"), commands: vec!["/status".to_string()], } ); } #[test] fn parses_resume_flag_with_multiple_slash_commands() { let args = vec![ "--resume".to_string(), "session.jsonl".to_string(), "/status".to_string(), "/compact".to_string(), "/cost".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::ResumeSession { session_path: PathBuf::from("session.jsonl"), commands: vec![ "/status".to_string(), "/compact".to_string(), "/cost".to_string(), ], } ); } #[test] fn rejects_unknown_options_with_helpful_guidance() { let error = parse_args(&["--resum".to_string()]).expect_err("unknown option should fail"); assert!(error.contains("unknown option: --resum")); assert!(error.contains("Did you mean --resume?")); assert!(error.contains("claw --help")); } #[test] fn parses_resume_flag_with_slash_command_arguments() { let args = vec![ "--resume".to_string(), "session.jsonl".to_string(), "/export".to_string(), "notes.txt".to_string(), "/clear".to_string(), "--confirm".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::ResumeSession { session_path: PathBuf::from("session.jsonl"), commands: vec![ "/export notes.txt".to_string(), "/clear --confirm".to_string(), ], } ); } #[test] fn parses_resume_flag_with_absolute_export_path() { let args = vec![ "--resume".to_string(), "session.jsonl".to_string(), "/export".to_string(), "/tmp/notes.txt".to_string(), "/status".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::ResumeSession { session_path: PathBuf::from("session.jsonl"), commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()], } ); } #[test] fn filtered_tool_specs_respect_allowlist() { let allowed = ["read_file", "grep_search"] .into_iter() .map(str::to_string) .collect(); let filtered = filter_tool_specs(&GlobalToolRegistry::builtin(), Some(&allowed)); let names = filtered .into_iter() .map(|spec| spec.name) .collect::<Vec<_>>(); assert_eq!(names, vec!["read_file", "grep_search"]); } #[test] fn filtered_tool_specs_include_plugin_tools() { let filtered = filter_tool_specs(®istry_with_plugin_tool(), None); let names = filtered .into_iter() .map(|definition| definition.name) .collect::<Vec<_>>(); assert!(names.contains(&"bash".to_string())); assert!(names.contains(&"plugin_echo".to_string())); } #[test] fn permission_policy_uses_plugin_tool_permissions() { let feature_config = runtime::RuntimeFeatureConfig::default(); let policy = permission_policy( PermissionMode::ReadOnly, &feature_config, ®istry_with_plugin_tool(), ); let required = policy.required_mode_for("plugin_echo"); assert_eq!(required, PermissionMode::WorkspaceWrite); } #[test] fn shared_help_uses_resume_annotation_copy() { let help = commands::render_slash_command_help(); assert!(help.contains("Slash commands")); assert!(help.contains("works with --resume SESSION.jsonl")); } #[test] fn repl_help_includes_shared_commands_and_exit() { let help = render_repl_help(); assert!(help.contains("REPL")); assert!(help.contains("/help")); assert!(help.contains("Complete commands, modes, and recent sessions")); assert!(help.contains("/status")); assert!(help.contains("/sandbox")); assert!(help.contains("/model [model]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); assert!(help.contains("/resume <session-path>")); assert!(help.contains("/config [env|hooks|model|plugins]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert!(help.contains("/diff")); assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]]")); assert!(help.contains( "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]" )); assert!(help.contains("aliases: /plugins, /marketplace")); assert!(help.contains("/agents")); assert!(help.contains("/skills")); assert!(help.contains("/exit")); assert!(help.contains("Auto-save .claw/sessions/<session-id>.jsonl")); assert!(help.contains("Resume latest /resume latest")); } #[test] fn completion_candidates_include_workflow_shortcuts_and_dynamic_sessions() { let completions = slash_command_completion_candidates_with_sessions( "sonnet", Some("session-current"), vec!["session-old".to_string()], ); assert!(completions.contains(&"/model claude-sonnet-4-6".to_string())); assert!(completions.contains(&"/permissions workspace-write".to_string())); assert!(completions.contains(&"/session list".to_string())); assert!(completions.contains(&"/session switch session-current".to_string())); assert!(completions.contains(&"/resume session-old".to_string())); assert!(completions.contains(&"/ultraplan ".to_string())); } #[test] fn startup_banner_mentions_workflow_completions() { let _guard = env_lock(); let root = temp_dir(); fs::create_dir_all(&root).expect("root dir"); let banner = with_current_dir(&root, || { LiveCli::new( "claude-sonnet-4-6".to_string(), true, None, PermissionMode::DangerFullAccess, ) .expect("cli should initialize") .startup_banner() }); assert!(banner.contains("Tab")); assert!(banner.contains("workflow completions")); fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn resume_supported_command_list_matches_expected_surface() { let names = resume_supported_slash_commands() .into_iter() .map(|spec| spec.name) .collect::<Vec<_>>(); assert_eq!( names, vec![ "help", "status", "sandbox", "compact", "clear", "cost", "config", "memory", "init", "diff", "version", "export", "agents", "skills", ] ); } #[test] fn resume_report_uses_sectioned_layout() { let report = format_resume_report("session.jsonl", 14, 6); assert!(report.contains("Session resumed")); assert!(report.contains("Session file session.jsonl")); assert!(report.contains("Messages 14")); assert!(report.contains("Turns 6")); } #[test] fn compact_report_uses_structured_output() { let compacted = format_compact_report(8, 5, false); assert!(compacted.contains("Compact")); assert!(compacted.contains("Result compacted")); assert!(compacted.contains("Messages removed 8")); let skipped = format_compact_report(0, 3, true); assert!(skipped.contains("Result skipped")); } #[test] fn cost_report_uses_sectioned_layout() { let report = format_cost_report(runtime::TokenUsage { input_tokens: 20, output_tokens: 8, cache_creation_input_tokens: 3, cache_read_input_tokens: 1, }); assert!(report.contains("Cost")); assert!(report.contains("Input tokens 20")); assert!(report.contains("Output tokens 8")); assert!(report.contains("Cache create 3")); assert!(report.contains("Cache read 1")); assert!(report.contains("Total tokens 32")); } #[test] fn permissions_report_uses_sectioned_layout() { let report = format_permissions_report("workspace-write"); assert!(report.contains("Permissions")); assert!(report.contains("Active mode workspace-write")); assert!(report.contains("Modes")); assert!(report.contains("read-only ○ available Read/search tools only")); assert!(report.contains("workspace-write ● current Edit files inside the workspace")); assert!(report.contains("danger-full-access ○ available Unrestricted tool access")); } #[test] fn permissions_switch_report_is_structured() { let report = format_permissions_switch_report("read-only", "workspace-write"); assert!(report.contains("Permissions updated")); assert!(report.contains("Result mode switched")); assert!(report.contains("Previous mode read-only")); assert!(report.contains("Active mode workspace-write")); assert!(report.contains("Applies to subsequent tool calls")); } #[test] fn init_help_mentions_direct_subcommand() { let mut help = Vec::new(); print_help_to(&mut help).expect("help should render"); let help = String::from_utf8(help).expect("help should be utf8"); assert!(help.contains("claw help")); assert!(help.contains("claw version")); assert!(help.contains("claw status")); assert!(help.contains("claw sandbox")); assert!(help.contains("claw init")); assert!(help.contains("claw agents")); assert!(help.contains("claw skills")); assert!(help.contains("claw /skills")); } #[test] fn model_report_uses_sectioned_layout() { let report = format_model_report("claude-sonnet", 12, 4); assert!(report.contains("Model")); assert!(report.contains("Current model claude-sonnet")); assert!(report.contains("Session messages 12")); assert!(report.contains("Switch models with /model <name>")); } #[test] fn model_switch_report_preserves_context_summary() { let report = format_model_switch_report("claude-sonnet", "claude-opus", 9); assert!(report.contains("Model updated")); assert!(report.contains("Previous claude-sonnet")); assert!(report.contains("Current claude-opus")); assert!(report.contains("Preserved msgs 9")); } #[test] fn status_line_reports_model_and_token_totals() { let status = format_status_report( "claude-sonnet", StatusUsage { message_count: 7, turns: 3, latest: runtime::TokenUsage { input_tokens: 5, output_tokens: 4, cache_creation_input_tokens: 1, cache_read_input_tokens: 0, }, cumulative: runtime::TokenUsage { input_tokens: 20, output_tokens: 8, cache_creation_input_tokens: 2, cache_read_input_tokens: 1, }, estimated_tokens: 128, }, "workspace-write", &super::StatusContext { cwd: PathBuf::from("/tmp/project"), session_path: Some(PathBuf::from("session.jsonl")), loaded_config_files: 2, discovered_config_files: 3, memory_file_count: 4, project_root: Some(PathBuf::from("/tmp")), git_branch: Some("main".to_string()), git_summary: GitWorkspaceSummary { changed_files: 3, staged_files: 1, unstaged_files: 1, untracked_files: 1, conflicted_files: 0, }, sandbox_status: runtime::SandboxStatus::default(), }, ); assert!(status.contains("Status")); assert!(status.contains("Model claude-sonnet")); assert!(status.contains("Permission mode workspace-write")); assert!(status.contains("Messages 7")); assert!(status.contains("Latest total 10")); assert!(status.contains("Cumulative total 31")); assert!(status.contains("Cwd /tmp/project")); assert!(status.contains("Project root /tmp")); assert!(status.contains("Git branch main")); assert!( status.contains("Git state dirty · 3 files · 1 staged, 1 unstaged, 1 untracked") ); assert!(status.contains("Changed files 3")); assert!(status.contains("Staged 1")); assert!(status.contains("Unstaged 1")); assert!(status.contains("Untracked 1")); assert!(status.contains("Session session.jsonl")); assert!(status.contains("Config files loaded 2/3")); assert!(status.contains("Memory files 4")); assert!(status.contains("Suggested flow /status → /diff → /commit")); } #[test] fn commit_reports_surface_workspace_context() { let summary = GitWorkspaceSummary { changed_files: 2, staged_files: 1, unstaged_files: 1, untracked_files: 0, conflicted_files: 0, }; let preflight = format_commit_preflight_report(Some("feature/ux"), summary); assert!(preflight.contains("Result preparing")); assert!(preflight.contains("Branch feature/ux")); assert!(preflight.contains("Workspace dirty · 2 files · 1 staged, 1 unstaged")); let success = format_commit_success_report( Some("feature/ux"), summary, Path::new("/tmp/message.txt"), " src/main.rs | 4 ++--", "Improve slash command guidance", ); assert!(success.contains("Result created")); assert!(success.contains("Message file /tmp/message.txt")); assert!(success.contains("Staged diff")); assert!(success.contains("src/main.rs | 4 ++--")); assert!(success.contains("Lore message")); } #[test] fn commit_skipped_report_points_to_next_steps() { let report = format_commit_skipped_report(); assert!(report.contains("Reason no workspace changes")); assert!(report.contains("/status to inspect context")); assert!(report.contains("/diff to inspect repo changes")); } #[test] fn config_report_supports_section_views() { let report = render_config_report(Some("env")).expect("config report should render"); assert!(report.contains("Merged section: env")); let plugins_report = render_config_report(Some("plugins")).expect("plugins config report should render"); assert!(plugins_report.contains("Merged section: plugins")); } #[test] fn memory_report_uses_sectioned_layout() { let report = render_memory_report().expect("memory report should render"); assert!(report.contains("Memory")); assert!(report.contains("Working directory")); assert!(report.contains("Instruction files")); assert!(report.contains("Discovered files")); } #[test] fn config_report_uses_sectioned_layout() { let report = render_config_report(None).expect("config report should render"); assert!(report.contains("Config")); assert!(report.contains("Discovered files")); assert!(report.contains("Merged JSON")); } #[test] fn parses_git_status_metadata() { let _guard = env_lock(); let temp_root = temp_dir(); fs::create_dir_all(&temp_root).expect("root dir"); let (project_root, branch) = parse_git_status_metadata_for( &temp_root, Some( "## rcc/cli...origin/rcc/cli M src/main.rs", ), ); assert_eq!(branch.as_deref(), Some("rcc/cli")); assert!(project_root.is_none()); fs::remove_dir_all(temp_root).expect("cleanup temp dir"); } #[test] fn parses_detached_head_from_status_snapshot() { let _guard = env_lock(); assert_eq!( parse_git_status_branch(Some( "## HEAD (no branch) M src/main.rs" )), Some("detached HEAD".to_string()) ); } #[test] fn parses_git_workspace_summary_counts() { let summary = parse_git_workspace_summary(Some( "## feature/ux M src/main.rs M README.md ?? notes.md UU conflicted.rs", )); assert_eq!( summary, GitWorkspaceSummary { changed_files: 4, staged_files: 2, unstaged_files: 2, untracked_files: 1, conflicted_files: 1, } ); assert_eq!( summary.headline(), "dirty · 4 files · 2 staged, 2 unstaged, 1 untracked, 1 conflicted" ); } #[test] fn render_diff_report_shows_clean_tree_for_committed_repo() { let _guard = env_lock(); let root = temp_dir(); fs::create_dir_all(&root).expect("root dir"); git(&["init", "--quiet"], &root); git(&["config", "user.email", "tests@example.com"], &root); git(&["config", "user.name", "Rusty Claude Tests"], &root); fs::write(root.join("tracked.txt"), "hello\n").expect("write file"); git(&["add", "tracked.txt"], &root); git(&["commit", "-m", "init", "--quiet"], &root); let report = with_current_dir(&root, || { render_diff_report().expect("diff report should render") }); assert!(report.contains("clean working tree")); fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn render_diff_report_includes_staged_and_unstaged_sections() { let _guard = env_lock(); let root = temp_dir(); fs::create_dir_all(&root).expect("root dir"); git(&["init", "--quiet"], &root); git(&["config", "user.email", "tests@example.com"], &root); git(&["config", "user.name", "Rusty Claude Tests"], &root); fs::write(root.join("tracked.txt"), "hello\n").expect("write file"); git(&["add", "tracked.txt"], &root); git(&["commit", "-m", "init", "--quiet"], &root); fs::write(root.join("tracked.txt"), "hello\nstaged\n").expect("update file"); git(&["add", "tracked.txt"], &root); fs::write(root.join("tracked.txt"), "hello\nstaged\nunstaged\n") .expect("update file twice"); let report = with_current_dir(&root, || { render_diff_report().expect("diff report should render") }); assert!(report.contains("Staged changes:")); assert!(report.contains("Unstaged changes:")); assert!(report.contains("tracked.txt")); fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn render_diff_report_omits_ignored_files() { let _guard = env_lock(); let root = temp_dir(); fs::create_dir_all(&root).expect("root dir"); git(&["init", "--quiet"], &root); git(&["config", "user.email", "tests@example.com"], &root); git(&["config", "user.name", "Rusty Claude Tests"], &root); fs::write(root.join(".gitignore"), ".omx/\nignored.txt\n").expect("write gitignore"); fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked"); git(&["add", ".gitignore", "tracked.txt"], &root); git(&["commit", "-m", "init", "--quiet"], &root); fs::create_dir_all(root.join(".omx")).expect("write omx dir"); fs::write(root.join(".omx").join("state.json"), "{}").expect("write ignored omx"); fs::write(root.join("ignored.txt"), "secret\n").expect("write ignored file"); fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("write tracked change"); let report = with_current_dir(&root, || { render_diff_report().expect("diff report should render") }); assert!(report.contains("tracked.txt")); assert!(!report.contains("+++ b/ignored.txt")); assert!(!report.contains("+++ b/.omx/state.json")); fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn resume_diff_command_renders_report_for_saved_session() { let _guard = env_lock(); let root = temp_dir(); fs::create_dir_all(&root).expect("root dir"); git(&["init", "--quiet"], &root); git(&["config", "user.email", "tests@example.com"], &root); git(&["config", "user.name", "Rusty Claude Tests"], &root); fs::write(root.join("tracked.txt"), "hello\n").expect("write tracked"); git(&["add", "tracked.txt"], &root); git(&["commit", "-m", "init", "--quiet"], &root); fs::write(root.join("tracked.txt"), "hello\nworld\n").expect("modify tracked"); let session_path = root.join("session.json"); Session::new() .save_to_path(&session_path) .expect("session should save"); let session = Session::load_from_path(&session_path).expect("session should load"); let outcome = with_current_dir(&root, || { run_resume_command(&session_path, &session, &SlashCommand::Diff) .expect("resume diff should work") }); let message = outcome.message.expect("diff message should exist"); assert!(message.contains("Unstaged changes:")); assert!(message.contains("tracked.txt")); fs::remove_dir_all(root).expect("cleanup temp dir"); } #[test] fn status_context_reads_real_workspace_metadata() { let context = status_context(None).expect("status context should load"); assert!(context.cwd.is_absolute()); assert!(context.discovered_config_files >= context.loaded_config_files); assert!(context.loaded_config_files <= context.discovered_config_files); } #[test] fn normalizes_supported_permission_modes() { assert_eq!(normalize_permission_mode("read-only"), Some("read-only")); assert_eq!( normalize_permission_mode("workspace-write"), Some("workspace-write") ); assert_eq!( normalize_permission_mode("danger-full-access"), Some("danger-full-access") ); assert_eq!(normalize_permission_mode("unknown"), None); } #[test] fn clear_command_requires_explicit_confirmation_flag() { assert_eq!( SlashCommand::parse("/clear"), Some(SlashCommand::Clear { confirm: false }) ); assert_eq!( SlashCommand::parse("/clear --confirm"), Some(SlashCommand::Clear { confirm: true }) ); } #[test] fn parses_resume_and_config_slash_commands() { assert_eq!( SlashCommand::parse("/resume saved-session.jsonl"), Some(SlashCommand::Resume { session_path: Some("saved-session.jsonl".to_string()) }) ); assert_eq!( SlashCommand::parse("/clear --confirm"), Some(SlashCommand::Clear { confirm: true }) ); assert_eq!( SlashCommand::parse("/config"), Some(SlashCommand::Config { section: None }) ); assert_eq!( SlashCommand::parse("/config env"), Some(SlashCommand::Config { section: Some("env".to_string()) }) ); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); assert_eq!( SlashCommand::parse("/session fork incident-review"), Some(SlashCommand::Session { action: Some("fork".to_string()), target: Some("incident-review".to_string()) }) ); } #[test] fn help_mentions_jsonl_resume_examples() { let mut help = Vec::new(); print_help_to(&mut help).expect("help should render"); let help = String::from_utf8(help).expect("help should be utf8"); assert!(help.contains("claw --resume [SESSION.jsonl|session-id|latest]")); assert!(help.contains("Use `latest` with --resume, /resume, or /session switch")); assert!(help.contains("claw --resume latest")); assert!(help.contains("claw --resume latest /status /diff /export notes.txt")); } #[test] fn managed_sessions_default_to_jsonl_and_resolve_legacy_json() { let _guard = cwd_lock().lock().expect("cwd lock"); let workspace = temp_workspace("session-resolution"); std::fs::create_dir_all(&workspace).expect("workspace should create"); let previous = std::env::current_dir().expect("cwd"); std::env::set_current_dir(&workspace).expect("switch cwd"); let handle = create_managed_session_handle("session-alpha").expect("jsonl handle"); assert!(handle.path.ends_with("session-alpha.jsonl")); let legacy_path = workspace.join(".claw/sessions/legacy.json"); std::fs::create_dir_all( legacy_path .parent() .expect("legacy path should have parent directory"), ) .expect("session dir should exist"); Session::new() .with_persistence_path(legacy_path.clone()) .save_to_path(&legacy_path) .expect("legacy session should save"); let resolved = resolve_session_reference("legacy").expect("legacy session should resolve"); assert_eq!( resolved .path .canonicalize() .expect("resolved path should exist"), legacy_path .canonicalize() .expect("legacy path should exist") ); std::env::set_current_dir(previous).expect("restore cwd"); std::fs::remove_dir_all(workspace).expect("workspace should clean up"); } #[test] fn latest_session_alias_resolves_most_recent_managed_session() { let _guard = cwd_lock().lock().expect("cwd lock"); let workspace = temp_workspace("latest-session-alias"); std::fs::create_dir_all(&workspace).expect("workspace should create"); let previous = std::env::current_dir().expect("cwd"); std::env::set_current_dir(&workspace).expect("switch cwd"); let older = create_managed_session_handle("session-older").expect("older handle"); Session::new() .with_persistence_path(older.path.clone()) .save_to_path(&older.path) .expect("older session should save"); std::thread::sleep(Duration::from_millis(20)); let newer = create_managed_session_handle("session-newer").expect("newer handle"); Session::new() .with_persistence_path(newer.path.clone()) .save_to_path(&newer.path) .expect("newer session should save"); let resolved = resolve_session_reference("latest").expect("latest session should resolve"); assert_eq!( resolved .path .canonicalize() .expect("resolved path should exist"), newer.path.canonicalize().expect("newer path should exist") ); std::env::set_current_dir(previous).expect("restore cwd"); std::fs::remove_dir_all(workspace).expect("workspace should clean up"); } #[test] fn unknown_slash_command_guidance_suggests_nearby_commands() { let message = format_unknown_slash_command("stats"); assert!(message.contains("Unknown slash command: /stats")); assert!(message.contains("/status")); assert!(message.contains("/help")); } #[test] fn resume_usage_mentions_latest_shortcut() { let usage = render_resume_usage(); assert!(usage.contains("/resume <session-path|session-id|latest>")); assert!(usage.contains(".claw/sessions/<session-id>.jsonl")); assert!(usage.contains("/session list")); } fn cwd_lock() -> &'static Mutex<()> { static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) } fn temp_workspace(label: &str) -> PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system time should be after epoch") .as_nanos(); std::env::temp_dir().join(format!("claw-cli-{label}-{nanos}")) } #[test] fn init_template_mentions_detected_rust_workspace() { let rendered = crate::init::render_init_claude_md(std::path::Path::new(".")); assert!(rendered.contains("# CLAUDE.md")); assert!(rendered.contains("cargo clippy --workspace --all-targets -- -D warnings")); } #[test] fn converts_tool_roundtrip_messages() { let messages = vec![ ConversationMessage::user_text("hello"), ConversationMessage::assistant(vec![ContentBlock::ToolUse { id: "tool-1".to_string(), name: "bash".to_string(), input: "{\"command\":\"pwd\"}".to_string(), }]), ConversationMessage { role: MessageRole::Tool, blocks: vec![ContentBlock::ToolResult { tool_use_id: "tool-1".to_string(), tool_name: "bash".to_string(), output: "ok".to_string(), is_error: false, }], usage: None, }, ]; let converted = super::convert_messages(&messages); assert_eq!(converted.len(), 3); assert_eq!(converted[1].role, "assistant"); assert_eq!(converted[2].role, "user"); } #[test] fn repl_help_mentions_history_completion_and_multiline() { let help = render_repl_help(); assert!(help.contains("Up/Down")); assert!(help.contains("Tab")); assert!(help.contains("Shift+Enter/Ctrl+J")); } #[test] fn tool_rendering_helpers_compact_output() { let start = format_tool_call_start("read_file", r#"{"path":"src/main.rs"}"#); assert!(start.contains("read_file")); assert!(start.contains("src/main.rs")); let done = format_tool_result( "read_file", r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#, false, ); assert!(done.contains("📄 Read src/main.rs")); assert!(done.contains("hello")); } #[test] fn tool_rendering_truncates_large_read_output_for_display_only() { let content = (0..200) .map(|index| format!("line {index:03}")) .collect::<Vec<_>>() .join("\n"); let output = json!({ "file": { "filePath": "src/main.rs", "content": content, "numLines": 200, "startLine": 1, "totalLines": 200 } }) .to_string(); let rendered = format_tool_result("read_file", &output, false); assert!(rendered.contains("line 000")); assert!(rendered.contains("line 079")); assert!(!rendered.contains("line 199")); assert!(rendered.contains("full result preserved in session")); assert!(output.contains("line 199")); } #[test] fn tool_rendering_truncates_large_bash_output_for_display_only() { let stdout = (0..120) .map(|index| format!("stdout {index:03}")) .collect::<Vec<_>>() .join("\n"); let output = json!({ "stdout": stdout, "stderr": "", "returnCodeInterpretation": "completed successfully" }) .to_string(); let rendered = format_tool_result("bash", &output, false); assert!(rendered.contains("stdout 000")); assert!(rendered.contains("stdout 059")); assert!(!rendered.contains("stdout 119")); assert!(rendered.contains("full result preserved in session")); assert!(output.contains("stdout 119")); } #[test] fn tool_rendering_truncates_generic_long_output_for_display_only() { let items = (0..120) .map(|index| format!("payload {index:03}")) .collect::<Vec<_>>(); let output = json!({ "summary": "plugin payload", "items": items, }) .to_string(); let rendered = format_tool_result("plugin_echo", &output, false); assert!(rendered.contains("plugin_echo")); assert!(rendered.contains("payload 000")); assert!(rendered.contains("payload 040")); assert!(!rendered.contains("payload 080")); assert!(!rendered.contains("payload 119")); assert!(rendered.contains("full result preserved in session")); assert!(output.contains("payload 119")); } #[test] fn tool_rendering_truncates_raw_generic_output_for_display_only() { let output = (0..120) .map(|index| format!("raw {index:03}")) .collect::<Vec<_>>() .join("\n"); let rendered = format_tool_result("plugin_echo", &output, false); assert!(rendered.contains("plugin_echo")); assert!(rendered.contains("raw 000")); assert!(rendered.contains("raw 059")); assert!(!rendered.contains("raw 119")); assert!(rendered.contains("full result preserved in session")); assert!(output.contains("raw 119")); } #[test] fn ultraplan_progress_lines_include_phase_step_and_elapsed_status() { let snapshot = InternalPromptProgressState { command_label: "Ultraplan", task_label: "ship plugin progress".to_string(), step: 3, phase: "running read_file".to_string(), detail: Some("reading rust/crates/rusty-claude-cli/src/main.rs".to_string()), saw_final_text: false, }; let started = format_internal_prompt_progress_line( InternalPromptProgressEvent::Started, &snapshot, Duration::from_secs(0), None, ); let heartbeat = format_internal_prompt_progress_line( InternalPromptProgressEvent::Heartbeat, &snapshot, Duration::from_secs(9), None, ); let completed = format_internal_prompt_progress_line( InternalPromptProgressEvent::Complete, &snapshot, Duration::from_secs(12), None, ); let failed = format_internal_prompt_progress_line( InternalPromptProgressEvent::Failed, &snapshot, Duration::from_secs(12), Some("network timeout"), ); assert!(started.contains("planning started")); assert!(started.contains("current step 3")); assert!(heartbeat.contains("heartbeat")); assert!(heartbeat.contains("9s elapsed")); assert!(heartbeat.contains("phase running read_file")); assert!(completed.contains("completed")); assert!(completed.contains("3 steps total")); assert!(failed.contains("failed")); assert!(failed.contains("network timeout")); } #[test] fn describe_tool_progress_summarizes_known_tools() { assert_eq!( describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#), "reading src/main.rs" ); assert!( describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#) .contains("cargo test -p rusty-claude-cli") ); assert_eq!( describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#), "grep `ultraplan` in rust" ); } #[test] fn push_output_block_renders_markdown_text() { let mut out = Vec::new(); let mut events = Vec::new(); let mut pending_tool = None; push_output_block( OutputContentBlock::Text { text: "# Heading".to_string(), }, &mut out, &mut events, &mut pending_tool, false, ) .expect("text block should render"); let rendered = String::from_utf8(out).expect("utf8"); assert!(rendered.contains("Heading")); assert!(rendered.contains('\u{1b}')); } #[test] fn push_output_block_skips_empty_object_prefix_for_tool_streams() { let mut out = Vec::new(); let mut events = Vec::new(); let mut pending_tool = None; push_output_block( OutputContentBlock::ToolUse { id: "tool-1".to_string(), name: "read_file".to_string(), input: json!({}), }, &mut out, &mut events, &mut pending_tool, true, ) .expect("tool block should accumulate"); assert!(events.is_empty()); assert_eq!( pending_tool, Some(("tool-1".to_string(), "read_file".to_string(), String::new(),)) ); } #[test] fn response_to_events_preserves_empty_object_json_input_outside_streaming() { let mut out = Vec::new(); let events = response_to_events( MessageResponse { id: "msg-1".to_string(), kind: "message".to_string(), model: "claude-opus-4-6".to_string(), role: "assistant".to_string(), content: vec![OutputContentBlock::ToolUse { id: "tool-1".to_string(), name: "read_file".to_string(), input: json!({}), }], stop_reason: Some("tool_use".to_string()), stop_sequence: None, usage: Usage { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, request_id: None, }, &mut out, ) .expect("response conversion should succeed"); assert!(matches!( &events[0], AssistantEvent::ToolUse { name, input, .. } if name == "read_file" && input == "{}" )); } #[test] fn response_to_events_preserves_non_empty_json_input_outside_streaming() { let mut out = Vec::new(); let events = response_to_events( MessageResponse { id: "msg-2".to_string(), kind: "message".to_string(), model: "claude-opus-4-6".to_string(), role: "assistant".to_string(), content: vec![OutputContentBlock::ToolUse { id: "tool-2".to_string(), name: "read_file".to_string(), input: json!({ "path": "rust/Cargo.toml" }), }], stop_reason: Some("tool_use".to_string()), stop_sequence: None, usage: Usage { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, request_id: None, }, &mut out, ) .expect("response conversion should succeed"); assert!(matches!( &events[0], AssistantEvent::ToolUse { name, input, .. } if name == "read_file" && input == "{\"path\":\"rust/Cargo.toml\"}" )); } #[test] fn response_to_events_ignores_thinking_blocks() { let mut out = Vec::new(); let events = response_to_events( MessageResponse { id: "msg-3".to_string(), kind: "message".to_string(), model: "claude-opus-4-6".to_string(), role: "assistant".to_string(), content: vec![ OutputContentBlock::Thinking { thinking: "step 1".to_string(), signature: Some("sig_123".to_string()), }, OutputContentBlock::Text { text: "Final answer".to_string(), }, ], stop_reason: Some("end_turn".to_string()), stop_sequence: None, usage: Usage { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, request_id: None, }, &mut out, ) .expect("response conversion should succeed"); assert!(matches!( &events[0], AssistantEvent::TextDelta(text) if text == "Final answer" )); assert!(!String::from_utf8(out).expect("utf8").contains("step 1")); } } #[cfg(test)] mod sandbox_report_tests { use super::{format_sandbox_report, HookAbortMonitor}; use runtime::HookAbortSignal; use std::sync::mpsc; use std::time::Duration; #[test] fn sandbox_report_renders_expected_fields() { let report = format_sandbox_report(&runtime::SandboxStatus::default()); assert!(report.contains("Sandbox")); assert!(report.contains("Enabled")); assert!(report.contains("Filesystem mode")); assert!(report.contains("Fallback reason")); } #[test] fn hook_abort_monitor_stops_without_aborting() { let abort_signal = HookAbortSignal::new(); let (ready_tx, ready_rx) = mpsc::channel(); let monitor = HookAbortMonitor::spawn_with_waiter( abort_signal.clone(), move |stop_rx, abort_signal| { ready_tx.send(()).expect("ready signal"); let _ = stop_rx.recv(); assert!(!abort_signal.is_aborted()); }, ); ready_rx.recv().expect("waiter should be ready"); monitor.stop(); assert!(!abort_signal.is_aborted()); } #[test] fn hook_abort_monitor_propagates_interrupt() { let abort_signal = HookAbortSignal::new(); let (done_tx, done_rx) = mpsc::channel(); let monitor = HookAbortMonitor::spawn_with_waiter( abort_signal.clone(), move |_stop_rx, abort_signal| { abort_signal.abort(); done_tx.send(()).expect("done signal"); }, ); done_rx .recv_timeout(Duration::from_secs(1)) .expect("interrupt should complete"); monitor.stop(); assert!(abort_signal.is_aborted()); } }