mod input; mod render; use std::collections::{BTreeMap, 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::time::{SystemTime, UNIX_EPOCH}; use api::{ resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; use commands::{ render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use render::{Spinner, TerminalRenderer}; use runtime::{ clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde_json::json; use tools::{execute_tool, mvp_tool_specs, ToolSpec}; const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514"; const DEFAULT_MAX_TOKENS: u32 = 32; 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"); type AllowedToolSet = BTreeSet; fn main() { if let Err(error) = run() { eprintln!( "error: {error} Run `rusty-claude-cli --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::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), CliAction::Version => print_version(), CliAction::ResumeSession { session_path, commands, } => resume_session(&session_path, &commands), CliAction::Prompt { prompt, model, output_format, allowed_tools, permission_mode, color, } => LiveCli::new(model, false, allowed_tools, permission_mode, color)? .run_turn_with_output(&prompt, output_format)?, CliAction::Login => run_login()?, CliAction::Logout => run_logout()?, CliAction::Repl { model, allowed_tools, permission_mode, color, } => run_repl(model, allowed_tools, permission_mode, color)?, CliAction::Help => print_help(), } Ok(()) } #[derive(Debug, Clone, PartialEq, Eq)] enum CliAction { DumpManifests, BootstrapPlan, PrintSystemPrompt { cwd: PathBuf, date: String, }, Version, ResumeSession { session_path: PathBuf, commands: Vec, }, Prompt { prompt: String, model: String, output_format: CliOutputFormat, allowed_tools: Option, permission_mode: PermissionMode, color: bool, }, Login, Logout, Repl { model: String, allowed_tools: Option, permission_mode: PermissionMode, color: bool, }, // 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_version = false; let mut allowed_tool_values = Vec::new(); let mut color = true; let mut rest = Vec::new(); let mut index = 0; while index < args.len() { match args[index].as_str() { "--version" | "-V" => { wants_version = true; index += 1; } "--no-color" => { color = false; index += 1; } "--model" => { let value = args .get(index + 1) .ok_or_else(|| "missing value for --model".to_string())?; model.clone_from(value); index += 2; } flag if flag.starts_with("--model=") => { model = 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; } "--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 => { rest.push(other.to_string()); index += 1; } } } 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, color, }); } if matches!(rest.first().map(String::as_str), Some("--help" | "-h")) { return Ok(CliAction::Help); } if rest.first().map(String::as_str) == Some("--resume") { return parse_resume_args(&rest[1..]); } match rest[0].as_str() { "dump-manifests" => Ok(CliAction::DumpManifests), "bootstrap-plan" => Ok(CliAction::BootstrapPlan), "system-prompt" => parse_system_prompt_args(&rest[1..]), "login" => Ok(CliAction::Login), "logout" => Ok(CliAction::Logout), "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, color, }) } other if !other.starts_with('/') => Ok(CliAction::Prompt { prompt: rest.join(" "), model, output_format, allowed_tools, permission_mode, color, }), other => Err(format!("unknown subcommand: {other}")), } } fn normalize_allowed_tools(values: &[String]) -> Result, String> { if values.is_empty() { return Ok(None); } let canonical_names = mvp_tool_specs() .into_iter() .map(|spec| spec.name.to_string()) .collect::>(); let mut name_map = canonical_names .iter() .map(|name| (normalize_tool_name(name), name.clone())) .collect::>(); for (alias, canonical) in [ ("read", "read_file"), ("write", "write_file"), ("edit", "edit_file"), ("glob", "glob_search"), ("grep", "grep_search"), ] { name_map.insert(alias.to_string(), canonical.to_string()); } let mut allowed = AllowedToolSet::new(); for value in values { for token in value .split(|ch: char| ch == ',' || ch.is_whitespace()) .filter(|token| !token.is_empty()) { let normalized = normalize_tool_name(token); let canonical = name_map.get(&normalized).ok_or_else(|| { format!( "unsupported tool in --allowedTools: {token} (expected one of: {})", canonical_names.join(", ") ) })?; allowed.insert(canonical.clone()); } } Ok(Some(allowed)) } fn normalize_tool_name(value: &str) -> String { value.trim().replace('-', "_").to_ascii_lowercase() } 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::WorkspaceWrite, permission_mode_from_label) } fn filter_tool_specs(allowed_tools: Option<&AllowedToolSet>) -> Vec { mvp_tool_specs() .into_iter() .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) .collect() } 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 = args .first() .ok_or_else(|| "missing session path for --resume".to_string()) .map(PathBuf::from)?; let commands = args[1..].to_vec(); if commands .iter() .any(|command| !command.trim_start().starts_with('/')) { return Err("--resume trailing arguments must be slash commands".to_string()); } Ok(CliAction::ResumeSession { session_path, commands, }) } 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 run_login() -> Result<(), Box> { let cwd = env::current_dir()?; let config = ConfigLoader::default_for(&cwd).load()?; let oauth = config.oauth().ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, "OAuth config is missing. Add settings.oauth.clientId/authorizeUrl/tokenUrl first.", ) })?; 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); 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 session = match Session::load_from_path(session_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).", session_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(session_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, } #[derive(Debug, Clone, Copy)] struct StatusUsage { message_count: usize, turns: u32, latest: TokenUsage, cumulative: TokenUsage, estimated_tokens: usize, } 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 format_init_report(path: &Path, created: bool) -> String { if created { format!( "Init CLAUDE.md {} Result created Next step Review and tailor the generated guidance", path.display() ) } else { format!( "Init CLAUDE.md {} Result skipped (already exists) Next step Edit the existing file intentionally if workflows changed", path.display() ) } } 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 parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { let Some(status) = status else { return (None, None); }; let branch = status.lines().next().and_then(|line| { line.strip_prefix("## ") .map(|line| { line.split(['.', ' ']) .next() .unwrap_or_default() .to_string() }) .filter(|value| !value.is_empty()) }); let project_root = find_git_root().ok(); (project_root, branch) } fn find_git_root() -> Result> { let output = std::process::Command::new("git") .args(["rev-parse", "--show-toplevel"]) .current_dir(env::current_dir()?) .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)) } #[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::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()?), }), 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::Resume { .. } | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } fn run_repl( model: String, allowed_tools: Option, permission_mode: PermissionMode, color: bool, ) -> Result<(), Box> { let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode, color)?; let mut editor = input::LineEditor::new("› ", slash_command_completion_candidates()); println!("{}", cli.startup_banner()); loop { 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_secs: u64, message_count: usize, } struct LiveCli { model: String, allowed_tools: Option, permission_mode: PermissionMode, color: bool, system_prompt: Vec, runtime: ConversationRuntime, session: SessionHandle, renderer: TerminalRenderer, } impl LiveCli { fn new( model: String, enable_tools: bool, allowed_tools: Option, permission_mode: PermissionMode, color: bool, ) -> Result> { let system_prompt = build_system_prompt()?; let session = create_managed_session_handle()?; let runtime = build_runtime( Session::new(), model.clone(), system_prompt.clone(), enable_tools, allowed_tools.clone(), permission_mode, color, )?; let cli = Self { model, allowed_tools, permission_mode, color, system_prompt, runtime, session, renderer: TerminalRenderer::with_color(color), }; cli.persist_session()?; Ok(cli) } fn startup_banner(&self) -> String { format!( "Rusty Claude CLI\n Model {}\n Permission mode {}\n Working directory {}\n Session {}\n\nType /help for commands. Shift+Enter or Ctrl+J inserts a newline.", self.model, self.permission_mode.as_str(), env::current_dir().map_or_else( |_| "".to_string(), |path| path.display().to_string(), ), self.session.id, ) } fn run_turn(&mut self, input: &str) -> Result<(), Box> { let mut spinner = Spinner::new(); let mut stdout = io::stdout(); spinner.tick( "Waiting for Claude", self.renderer.color_theme(), &mut stdout, )?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let result = self.runtime.run_turn(input, Some(&mut permission_prompter)); match result { Ok(summary) => { spinner.finish( "Claude response complete", self.renderer.color_theme(), &mut stdout, )?; println!(); println!( "{}", self.renderer.token_usage_summary( u64::from(summary.usage.input_tokens), u64::from(summary.usage.output_tokens) ) ); self.persist_session()?; Ok(()) } Err(error) => { spinner.fail( "Claude request failed", self.renderer.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 client = AnthropicClient::from_auth(resolve_cli_auth_source()?); let request = MessageRequest { model: self.model.clone(), max_tokens: DEFAULT_MAX_TOKENS, messages: vec![InputMessage { role: "user".to_string(), content: vec![InputContentBlock::Text { text: input.to_string(), }], }], system: (!self.system_prompt.is_empty()).then(|| self.system_prompt.join("\n\n")), tools: None, tool_choice: None, stream: false, }; let runtime = tokio::runtime::Runtime::new()?; let response = runtime.block_on(client.send_message(&request))?; let text = response .content .iter() .filter_map(|block| match block { OutputContentBlock::Text { text } => Some(text.as_str()), OutputContentBlock::ToolUse { .. } => None, }) .collect::>() .join(""); println!( "{}", json!({ "message": text, "model": self.model, "usage": { "input_tokens": response.usage.input_tokens, "output_tokens": response.usage.output_tokens, "cache_creation_input_tokens": response.usage.cache_creation_input_tokens, "cache_read_input_tokens": response.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::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 => { Self::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::Unknown(name) => { eprintln!("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 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); }; 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, model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), self.permission_mode, self.color, )?; 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.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), self.permission_mode, self.color, )?; 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); } self.session = create_managed_session_handle()?; self.runtime = build_runtime( Session::new(), self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), self.permission_mode, self.color, )?; 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!("Usage: /resume "); return Ok(false); }; let handle = resolve_session_reference(&session_ref)?; let session = Session::load_from_path(&handle.path)?; let message_count = session.messages.len(); self.runtime = build_runtime( session, self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), self.permission_mode, self.color, )?; self.session = handle; 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 run_init() -> Result<(), Box> { println!("{}", init_claude_md()?); 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(); self.runtime = build_runtime( session, self.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), self.permission_mode, self.color, )?; self.session = handle; println!( "Session switched\n Active session {}\n File {}\n Messages {}", self.session.id, self.session.path.display(), message_count, ); Ok(true) } Some(other) => { println!("Unknown /session action '{other}'. Use /session list or /session switch ."); Ok(false) } } } 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.model.clone(), self.system_prompt.clone(), true, self.allowed_tools.clone(), self.permission_mode, self.color, )?; self.persist_session()?; println!("{}", format_compact_report(removed, kept, skipped)); Ok(()) } } fn sessions_dir() -> Result> { let cwd = env::current_dir()?; let path = cwd.join(".claude").join("sessions"); fs::create_dir_all(&path)?; Ok(path) } fn create_managed_session_handle() -> Result> { let id = generate_session_id(); let path = sessions_dir()?.join(format!("{id}.json")); Ok(SessionHandle { id, path }) } fn generate_session_id() -> String { let millis = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_millis()) .unwrap_or_default(); format!("session-{millis}") } fn resolve_session_reference(reference: &str) -> Result> { let direct = PathBuf::from(reference); let path = if direct.exists() { direct } else { sessions_dir()?.join(format!("{reference}.json")) }; if !path.exists() { return Err(format!("session not found: {reference}").into()); } let id = path .file_stem() .and_then(|value| value.to_str()) .unwrap_or(reference) .to_string(); Ok(SessionHandle { id, path }) } fn list_managed_sessions() -> Result, Box> { let mut sessions = Vec::new(); for entry in fs::read_dir(sessions_dir()?)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("json") { continue; } let metadata = entry.metadata()?; let modified_epoch_secs = metadata .modified() .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_secs()) .unwrap_or_default(); let message_count = Session::load_from_path(&path) .map(|session| session.messages.len()) .unwrap_or_default(); let id = path .file_stem() .and_then(|value| value.to_str()) .unwrap_or("unknown") .to_string(); sessions.push(ManagedSessionSummary { id, path, modified_epoch_secs, message_count, }); } sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs)); Ok(sessions) } fn render_session_list(active_session_id: &str) -> Result> { let sessions = list_managed_sessions()?; let mut lines = vec![ "Sessions".to_string(), format!(" Directory {}", sessions_dir()?.display()), ]; if sessions.is_empty() { lines.push(" No managed sessions saved yet.".to_string()); return Ok(lines.join("\n")); } for session in sessions { let marker = if session.id == active_session_id { "● current" } else { "○ saved" }; lines.push(format!( " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}", id = session.id, msgs = session.message_count, modified = session.modified_epoch_secs, path = session.path.display(), )); } Ok(lines.join("\n")) } 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 slash commands".to_string(), " Ctrl-C Clear input (or exit on empty prompt)".to_string(), " Shift+Enter/Ctrl+J Insert a newline".to_string(), String::new(), render_slash_command_help(), ] .join( " ", ) } fn status_context( session_path: Option<&Path>, ) -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let discovered_config_files = loader.discover().len(); 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()); 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, }) } 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 {} Session {} Config files loaded {}/{} Memory files {}", 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.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, ), ] .join( " ", ) } fn render_config_report(section: Option<&str>) -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let discovered = loader.discover(); let runtime_config = loader.load()?; let mut lines = vec![ format!( "Config Working directory {} Loaded files {} Merged keys {}", cwd.display(), runtime_config.loaded_entries().len(), runtime_config.merged().len() ), "Discovered files".to_string(), ]; for entry in discovered { let source = match entry.source { ConfigSource::User => "user", ConfigSource::Project => "project", ConfigSource::Local => "local", }; let status = if runtime_config .loaded_entries() .iter() .any(|loaded_entry| loaded_entry.path == entry.path) { "loaded" } else { "missing" }; lines.push(format!( " {source:<7} {status:<7} {}", entry.path.display() )); } if let Some(section) = section { lines.push(format!("Merged section: {section}")); let value = match section { "env" => runtime_config.get("env"), "hooks" => runtime_config.get("hooks"), "model" => runtime_config.get("model"), other => { lines.push(format!( " Unsupported config section '{other}'. Use env, hooks, or model." )); return Ok(lines.join( " ", )); } }; lines.push(format!( " {}", match value { Some(value) => value.render(), None => "".to_string(), } )); return Ok(lines.join( " ", )); } lines.push("Merged JSON".to_string()); lines.push(format!(" {}", runtime_config.as_json().render())); Ok(lines.join( " ", )) } fn render_memory_report() -> Result> { let cwd = env::current_dir()?; let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; let mut lines = vec![format!( "Memory Working directory {} Instruction files {}", cwd.display(), project_context.instruction_files.len() )]; if project_context.instruction_files.is_empty() { lines.push("Discovered files".to_string()); lines.push( " No CLAUDE instruction files discovered in the current directory ancestry." .to_string(), ); } else { lines.push("Discovered files".to_string()); for (index, file) in project_context.instruction_files.iter().enumerate() { let preview = file.content.lines().next().unwrap_or("").trim(); let preview = if preview.is_empty() { "" } else { preview }; lines.push(format!(" {}. {}", index + 1, file.path.display(),)); lines.push(format!( " lines={} preview={}", file.content.lines().count(), preview )); } } Ok(lines.join( " ", )) } fn init_claude_md() -> Result> { let cwd = env::current_dir()?; let claude_md = cwd.join("CLAUDE.md"); if claude_md.exists() { return Ok(format_init_report(&claude_md, false)); } let content = render_init_claude_md(&cwd); fs::write(&claude_md, content)?; Ok(format_init_report(&claude_md, true)) } fn render_init_claude_md(cwd: &Path) -> String { let mut lines = vec![ "# CLAUDE.md".to_string(), String::new(), "This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(), String::new(), ]; let mut command_lines = Vec::new(); if cwd.join("rust").join("Cargo.toml").is_file() { command_lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string()); } else if cwd.join("Cargo.toml").is_file() { command_lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string()); } if cwd.join("tests").is_dir() && cwd.join("src").is_dir() { command_lines.push("- `src/` and `tests/` are also present; check those surfaces before removing or renaming Python-era compatibility assets.".to_string()); } if !command_lines.is_empty() { lines.push("## Verification".to_string()); lines.extend(command_lines); lines.push(String::new()); } let mut structure_lines = Vec::new(); if cwd.join("rust").is_dir() { structure_lines.push( "- `rust/` contains the Rust workspace and the active CLI/runtime implementation." .to_string(), ); } if cwd.join("src").is_dir() { structure_lines.push("- `src/` contains the older Python-first workspace artifacts referenced by the repo history and tests.".to_string()); } if cwd.join("tests").is_dir() { structure_lines.push("- `tests/` exercises compatibility and porting behavior across the repository surfaces.".to_string()); } if !structure_lines.is_empty() { lines.push("## Repository shape".to_string()); lines.extend(structure_lines); lines.push(String::new()); } lines.push("## Working agreement".to_string()); lines.push("- Prefer small, reviewable Rust changes and keep slash-command behavior aligned between the shared command registry and the CLI entrypoints.".to_string()); lines.push("- Do not overwrite existing CLAUDE.md content automatically; update it intentionally when repo workflows change.".to_string()); lines.push(String::new()); lines.join( " ", ) } 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> { let output = std::process::Command::new("git") .args(["diff", "--", ":(exclude).omx"]) .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 diff failed: {stderr}").into()); } let diff = String::from_utf8(output.stdout)?; if diff.trim().is_empty() { return Ok( "Diff\n Result clean working tree\n Detail no current changes" .to_string(), ); } Ok(format!("Diff\n\n{}", diff.trim_end())) } fn render_version_report() -> String { let git_sha = GIT_SHA.unwrap_or("unknown"); let target = BUILD_TARGET.unwrap_or("unknown"); format!( "Version\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" ) } fn render_export_text(session: &Session) -> String { let mut lines = vec!["# Conversation Export".to_string(), String::new()]; for (index, message) in session.messages.iter().enumerate() { let role = match message.role { MessageRole::System => "system", MessageRole::User => "user", MessageRole::Assistant => "assistant", MessageRole::Tool => "tool", }; lines.push(format!("## {}. {role}", index + 1)); for block in &message.blocks { match block { ContentBlock::Text { text } => lines.push(text.clone()), ContentBlock::ToolUse { id, name, input } => { lines.push(format!("[tool_use id={id} name={name}] {input}")); } ContentBlock::ToolResult { tool_use_id, tool_name, output, is_error, } => { lines.push(format!( "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" )); } } } lines.push(String::new()); } lines.join("\n") } fn default_export_filename(session: &Session) -> String { let stem = session .messages .iter() .find_map(|message| match message.role { MessageRole::User => message.blocks.iter().find_map(|block| match block { ContentBlock::Text { text } => Some(text.as_str()), _ => None, }), _ => None, }) .map_or("conversation", |text| { text.lines().next().unwrap_or("conversation") }) .chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '-' } }) .collect::() .split('-') .filter(|part| !part.is_empty()) .take(8) .collect::>() .join("-"); let fallback = if stem.is_empty() { "conversation" } else { &stem }; format!("{fallback}.txt") } fn resolve_export_path( requested_path: Option<&str>, session: &Session, ) -> Result> { let cwd = env::current_dir()?; let file_name = requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned); let final_name = if Path::new(&file_name) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) { file_name } else { format!("{file_name}.txt") }; Ok(cwd.join(final_name)) } fn build_system_prompt() -> Result, Box> { Ok(load_system_prompt( env::current_dir()?, DEFAULT_DATE, env::consts::OS, "unknown", )?) } fn build_runtime( session: Session, model: String, system_prompt: Vec, enable_tools: bool, allowed_tools: Option, permission_mode: PermissionMode, color: bool, ) -> Result, Box> { Ok(ConversationRuntime::new( session, AnthropicRuntimeClient::new(model, enable_tools, allowed_tools.clone(), color)?, CliToolExecutor::new(allowed_tools, color), permission_policy(permission_mode), system_prompt, )) } 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()); 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, allowed_tools: Option, color: bool, } impl AnthropicRuntimeClient { fn new( model: String, enable_tools: bool, allowed_tools: Option, color: bool, ) -> Result> { Ok(Self { runtime: tokio::runtime::Runtime::new()?, client: AnthropicClient::from_auth(resolve_cli_auth_source()?), model, enable_tools, allowed_tools, color, }) } } fn resolve_cli_auth_source() -> Result> { 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, RuntimeError> { let message_request = MessageRequest { model: self.model.clone(), max_tokens: DEFAULT_MAX_TOKENS, 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.allowed_tools.as_ref()) .into_iter() .map(|spec| ToolDefinition { name: spec.name.to_string(), description: Some(spec.description.to_string()), input_schema: spec.input_schema, }) .collect() }), tool_choice: self.enable_tools.then_some(ToolChoice::Auto), stream: true, }; let renderer = TerminalRenderer::with_color(self.color); 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 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( &TerminalRenderer::with_color(true), block, &mut stdout, &mut events, &mut pending_tool, )?; } } ApiStreamEvent::ContentBlockStart(start) => { push_output_block( &renderer, start.content_block, &mut stdout, &mut events, &mut pending_tool, )?; } ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { ContentBlockDelta::TextDelta { text } => { if !text.is_empty() { write!(stdout, "{text}") .and_then(|()| stdout.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); } } }, ApiStreamEvent::ContentBlockStop(_) => { if let Some((id, name, input)) = pending_tool.take() { events.push(AssistantEvent::ToolUse { id, name, input }); } } ApiStreamEvent::MessageDelta(delta) => { events.push(AssistantEvent::Usage(TokenUsage { input_tokens: delta.usage.input_tokens, output_tokens: delta.usage.output_tokens, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, })); } ApiStreamEvent::MessageStop(_) => { saw_stop = true; events.push(AssistantEvent::MessageStop); } } } 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()))?; response_to_events(&renderer, response, &mut stdout) }) } } fn slash_command_completion_candidates() -> Vec { slash_command_specs() .iter() .map(|spec| format!("/{}", spec.name)) .collect() } fn format_tool_call_start(renderer: &TerminalRenderer, name: &str, input: &str) -> String { format!( "{} {} {} {}", renderer.warning("Tool call:"), renderer.info(name), renderer.warning("args="), summarize_tool_payload(input) ) } fn format_tool_result( renderer: &TerminalRenderer, name: &str, output: &str, is_error: bool, ) -> String { let status = if is_error { renderer.error("error") } else { renderer.success("ok") }; format!( "### {} {} - Status: {status} - Output: ```json {} ``` ", renderer.warning("Tool"), renderer.info(format!("`{name}`")), prettify_tool_payload(output) ) } fn summarize_tool_payload(payload: &str) -> String { let compact = match serde_json::from_str::(payload) { Ok(value) => value.to_string(), Err(_) => payload.trim().to_string(), }; truncate_for_summary(&compact, 96) } fn prettify_tool_payload(payload: &str) -> String { match serde_json::from_str::(payload) { Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| payload.to_string()), Err(_) => payload.to_string(), } } fn truncate_for_summary(value: &str, limit: usize) -> String { let mut chars = value.chars(); let truncated = chars.by_ref().take(limit).collect::(); if chars.next().is_some() { format!("{truncated}…") } else { truncated } } fn push_output_block( renderer: &TerminalRenderer, block: OutputContentBlock, out: &mut impl Write, events: &mut Vec, pending_tool: &mut Option<(String, String, String)>, ) -> Result<(), RuntimeError> { match block { OutputContentBlock::Text { text } => { if !text.is_empty() { write!(out, "{text}") .and_then(|()| out.flush()) .map_err(|error| RuntimeError::new(error.to_string()))?; events.push(AssistantEvent::TextDelta(text)); } } OutputContentBlock::ToolUse { id, name, input } => { writeln!( out, " {}", format_tool_call_start(renderer, &name, &input.to_string()) ) .and_then(|()| out.flush()) .map_err(|error| RuntimeError::new(error.to_string()))?; *pending_tool = Some((id, name, input.to_string())); } } Ok(()) } fn response_to_events( renderer: &TerminalRenderer, response: MessageResponse, out: &mut impl Write, ) -> Result, RuntimeError> { let mut events = Vec::new(); let mut pending_tool = None; for block in response.content { push_output_block(renderer, block, out, &mut events, &mut pending_tool)?; if let Some((id, name, input)) = pending_tool.take() { events.push(AssistantEvent::ToolUse { id, name, input }); } } events.push(AssistantEvent::Usage(TokenUsage { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens, cache_creation_input_tokens: response.usage.cache_creation_input_tokens, cache_read_input_tokens: response.usage.cache_read_input_tokens, })); events.push(AssistantEvent::MessageStop); Ok(events) } struct CliToolExecutor { renderer: TerminalRenderer, allowed_tools: Option, } impl CliToolExecutor { fn new(allowed_tools: Option, color: bool) -> Self { Self { renderer: TerminalRenderer::with_color(color), allowed_tools, } } } impl ToolExecutor for CliToolExecutor { fn execute(&mut self, tool_name: &str, input: &str) -> Result { if self .allowed_tools .as_ref() .is_some_and(|allowed| !allowed.contains(tool_name)) { return Err(ToolError::new(format!( "tool `{tool_name}` is not enabled by the current --allowedTools setting" ))); } let value = serde_json::from_str(input) .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; match execute_tool(tool_name, &value) { Ok(output) => { let markdown = format_tool_result(&self.renderer, tool_name, &output, false); self.renderer .stream_markdown(&markdown, &mut io::stdout()) .map_err(|error| ToolError::new(error.to_string()))?; Ok(output) } Err(error) => { let markdown = format_tool_result(&self.renderer, 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) -> PermissionPolicy { tool_permission_specs() .into_iter() .fold(PermissionPolicy::new(mode), |policy, spec| { policy.with_tool_requirement(spec.name, spec.required_permission) }) } fn tool_permission_specs() -> Vec { mvp_tool_specs() } fn convert_messages(messages: &[ConversationMessage]) -> Vec { messages .iter() .filter_map(|message| { let role = match message.role { MessageRole::System | MessageRole::User | MessageRole::Tool => "user", MessageRole::Assistant => "assistant", }; let content = message .blocks .iter() .map(|block| match block { ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { id: id.clone(), name: name.clone(), input: serde_json::from_str(input) .unwrap_or_else(|_| serde_json::json!({ "raw": input })), }, ContentBlock::ToolResult { tool_use_id, output, is_error, .. } => InputContentBlock::ToolResult { tool_use_id: tool_use_id.clone(), content: vec![ToolResultContentBlock::Text { text: output.clone(), }], is_error: *is_error, }, }) .collect::>(); (!content.is_empty()).then(|| InputMessage { role: role.to_string(), content, }) }) .collect() } fn print_help() { println!("rusty-claude-cli v{VERSION}"); println!(); println!("Usage:"); println!(" rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]"); println!(" Start the interactive REPL"); println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT"); println!(" Send one prompt and exit"); println!(" rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT"); println!(" Shorthand non-interactive prompt mode"); println!(" rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]"); println!(" Inspect or maintain a saved session without entering the REPL"); println!(" rusty-claude-cli dump-manifests"); println!(" rusty-claude-cli bootstrap-plan"); println!(" rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]"); println!(" rusty-claude-cli login"); println!(" rusty-claude-cli logout"); println!(); println!("Flags:"); println!(" --model MODEL Override the active model"); println!(" --output-format FORMAT Non-interactive output format: text or json"); println!(" --permission-mode MODE Set read-only, workspace-write, or danger-full-access"); println!(" --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"); println!(" --no-color Disable ANSI color output"); println!(" --version, -V Print version and build information locally"); println!(); println!("Interactive slash commands:"); println!("{}", render_slash_command_help()); println!(); let resume_commands = resume_supported_slash_commands() .into_iter() .map(|spec| match spec.argument_hint { Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), None => format!("/{}", spec.name), }) .collect::>() .join(", "); println!("Resume-safe commands: {resume_commands}"); println!("Examples:"); println!(" rusty-claude-cli --model claude-opus \"summarize this repo\""); println!(" rusty-claude-cli --output-format json prompt \"explain src/main.rs\""); println!(" rusty-claude-cli --allowedTools read,glob \"summarize Cargo.toml\""); println!(" rusty-claude-cli --resume session.json /status /diff /export notes.txt"); println!(" rusty-claude-cli login"); } #[cfg(test)] fn print_help_text_for_test() -> String { use std::fmt::Write as _; let mut output = String::new(); let _ = writeln!( output, "rusty-claude-cli v{VERSION} " ); let _ = writeln!(output, "Usage:"); let _ = writeln!( output, " rusty-claude-cli [--model MODEL] [--allowedTools TOOL[,TOOL...]]" ); let _ = writeln!(output, " Start the interactive REPL"); let _ = writeln!( output, " rusty-claude-cli [--model MODEL] [--output-format text|json] prompt TEXT" ); let _ = writeln!(output, " Send one prompt and exit"); let _ = writeln!( output, " rusty-claude-cli [--model MODEL] [--output-format text|json] TEXT" ); let _ = writeln!(output, " Shorthand non-interactive prompt mode"); let _ = writeln!( output, " rusty-claude-cli --resume SESSION.json [/status] [/compact] [...]" ); let _ = writeln!( output, " Inspect or maintain a saved session without entering the REPL" ); let _ = writeln!(output, " rusty-claude-cli dump-manifests"); let _ = writeln!(output, " rusty-claude-cli bootstrap-plan"); let _ = writeln!( output, " rusty-claude-cli system-prompt [--cwd PATH] [--date YYYY-MM-DD]" ); let _ = writeln!(output, " rusty-claude-cli login"); let _ = writeln!( output, " rusty-claude-cli logout " ); let _ = writeln!(output, "Flags:"); let _ = writeln!( output, " --model MODEL Override the active model" ); let _ = writeln!( output, " --output-format FORMAT Non-interactive output format: text or json" ); let _ = writeln!( output, " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" ); let _ = writeln!(output, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)"); let _ = writeln!( output, " --no-color Disable ANSI color output" ); let _ = writeln!( output, " --version, -V Print version and build information locally" ); output } #[cfg(test)] mod tests { use super::{ filter_tool_specs, format_compact_report, format_cost_report, format_init_report, 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, normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, render_init_claude_md, render_memory_report, render_repl_help, resume_supported_slash_commands, status_context, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use crate::{print_help_text_for_test, render::TerminalRenderer}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode}; use std::path::{Path, PathBuf}; #[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::WorkspaceWrite, color: true, } ); } #[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::WorkspaceWrite, color: true, } ); } #[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::WorkspaceWrite, color: true, } ); } #[test] fn parses_no_color_flag() { let args = vec![ "--no-color".to_string(), "prompt".to_string(), "hello".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::Prompt { prompt: "hello".to_string(), model: DEFAULT_MODEL.to_string(), output_format: CliOutputFormat::Text, allowed_tools: None, permission_mode: PermissionMode::WorkspaceWrite, color: false, } ); } #[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, color: true, } ); } #[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::WorkspaceWrite, color: true, } ); } #[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 ); } #[test] fn parses_resume_flag_with_slash_command() { let args = vec![ "--resume".to_string(), "session.json".to_string(), "/compact".to_string(), ]; assert_eq!( parse_args(&args).expect("args should parse"), CliAction::ResumeSession { session_path: PathBuf::from("session.json"), commands: vec!["/compact".to_string()], } ); } #[test] fn parses_resume_flag_with_multiple_slash_commands() { let args = vec![ "--resume".to_string(), "session.json".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.json"), commands: vec![ "/status".to_string(), "/compact".to_string(), "/cost".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(Some(&allowed)); let names = filtered .into_iter() .map(|spec| spec.name) .collect::>(); assert_eq!(names, vec!["read_file", "grep_search"]); } #[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.json")); } #[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("/status")); 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 ")); assert!(help.contains("/config [env|hooks|model]")); 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 ]")); assert!(help.contains("/exit")); } #[test] fn resume_supported_command_list_matches_expected_surface() { let names = resume_supported_slash_commands() .into_iter() .map(|spec| spec.name) .collect::>(); assert_eq!( names, vec![ "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff", "version", "export", ] ); } #[test] fn resume_report_uses_sectioned_layout() { let report = format_resume_report("session.json", 14, 6); assert!(report.contains("Session resumed")); assert!(report.contains("Session file session.json")); 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_report_uses_structured_output() { let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true); assert!(created.contains("Init")); assert!(created.contains("Result created")); let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false); assert!(skipped.contains("skipped (already exists)")); } #[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 ")); } #[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.json")), loaded_config_files: 2, discovered_config_files: 3, memory_file_count: 4, project_root: Some(PathBuf::from("/tmp")), git_branch: Some("main".to_string()), }, ); 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("Session session.json")); assert!(status.contains("Config files loaded 2/3")); assert!(status.contains("Memory files 4")); } #[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")); } #[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 (root, branch) = parse_git_status_metadata(Some( "## rcc/cli...origin/rcc/cli M src/main.rs", )); assert_eq!(branch.as_deref(), Some("rcc/cli")); let _ = root; } #[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 >= 3); 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.json"), Some(SlashCommand::Resume { session_path: Some("saved-session.json".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)); } #[test] fn init_template_mentions_detected_rust_workspace() { let rendered = render_init_claude_md(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!(print_help_text_for_test().contains("--no-color")); assert!(help.contains("Shift+Enter/Ctrl+J")); } #[test] fn tool_rendering_helpers_compact_output() { let renderer = TerminalRenderer::with_color(false); let start = format_tool_call_start(&renderer, "read_file", r#"{"path":"src/main.rs"}"#); assert!(start.contains("Tool call:")); assert!(start.contains("read_file")); assert!(start.contains("src/main.rs")); let done = format_tool_result(&renderer, "read_file", r#"{"contents":"hello"}"#, false); assert!(done.contains("Tool")); assert!(done.contains("`read_file`")); assert!(done.contains("contents")); } }