diff --git a/openspec/changes/refactor-main-rs-split/tasks.md b/openspec/changes/refactor-main-rs-split/tasks.md index 81bc20a0..d5f3fa18 100644 --- a/openspec/changes/refactor-main-rs-split/tasks.md +++ b/openspec/changes/refactor-main-rs-split/tasks.md @@ -31,45 +31,45 @@ ## 6. Create args.rs — extract all argument parsing -- [ ] 6.1 Create `src/args.rs` with CliAction enum, CliOutputFormat, LocalHelpTopic, `parse_args`, all parse_* helpers, format_unknown_* functions, suggest_*/levenshtein helpers, normalize_* tools, permission_mode helpers, provider_label, filter_tool_specs (main.rs lines 506-1828) -- [ ] 6.2 Add `mod args;` to `main.rs` and re-export -- [ ] 6.3 Run `cargo build` and `cargo test` — must pass +- [x] 6.1 Create `src/args.rs` with CliAction enum, CliOutputFormat, LocalHelpTopic, `parse_args`, all parse_* helpers, format_unknown_* functions, suggest_*/levenshtein helpers, normalize_* tools, permission_mode helpers, provider_label, filter_tool_specs (main.rs lines 506-1828) +- [x] 6.2 Add `mod args;` to `main.rs` and re-export +- [x] 6.3 Run `cargo build` and `cargo test` — must pass ## 7. Create session.rs — extract session management -- [ ] 7.1 Create `src/session.rs` with SessionHandle, ManagedSessionSummary, all session CRUD functions: `sessions_dir`, `current_session_store`, `new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`, `list_managed_sessions`, `latest_managed_session`, `load_session_reference`, `delete_managed_session`, `confirm_session_deletion`, `render_session_list`, `format_session_modified_age`, `write_session_clear_backup`, `session_clear_backup_path` (main.rs lines 3678-3692, 5257-5427) -- [ ] 7.2 Add `mod session;` to `main.rs` and re-export -- [ ] 7.3 Run `cargo build` and `cargo test` — must pass +- [x] 7.1 Create `src/session.rs` with SessionHandle, ManagedSessionSummary, all session CRUD functions: `sessions_dir`, `current_session_store`, `new_cli_session`, `create_managed_session_handle`, `resolve_session_reference`, `list_managed_sessions`, `latest_managed_session`, `load_session_reference`, `delete_managed_session`, `confirm_session_deletion`, `render_session_list`, `format_session_modified_age`, `write_session_clear_backup`, `session_clear_backup_path` (main.rs lines 3678-3692, 5257-5427) +- [x] 7.2 Add `mod session;` to `main.rs` and re-export +- [x] 7.3 Run `cargo build` and `cargo test` — must pass ## 8. Create status.rs — extract status snapshot and git parsing -- [ ] 8.1 Create `src/status.rs` with StatusContext, StatusUsage, GitWorkspaceSummary, `print_status_snapshot`, `status_json_value`, `status_context`, `format_status_report`, `format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`, and all git parsing helpers (main.rs lines 2842-3200, 5462-5839) -- [ ] 8.2 Add `mod status;` to `main.rs` and re-export -- [ ] 8.3 Run `cargo build` and `cargo test` — must pass +- [x] 8.1 Create `src/status.rs` with StatusContext, StatusUsage, GitWorkspaceSummary, `print_status_snapshot`, `status_json_value`, `status_context`, `format_status_report`, `format_sandbox_report`, `print_sandbox_status_snapshot`, `sandbox_json_value`, and all git parsing helpers (main.rs lines 2842-3200, 5462-5839) +- [x] 8.2 Add `mod status;` to `main.rs` and re-export +- [x] 8.3 Run `cargo build` and `cargo test` — must pass ## 9. Create formatting.rs — extract tool result formatting and help text -- [ ] 9.1 Create `src/formatting.rs` with all format_tool_* functions, push_output_block, response_to_events, push_prompt_cache_record, prompt_cache_record_to_runtime_event, final_assistant_text, collect_tool_uses/results/cache_events, short_tool_id, extract_tool_path, first_visible_line, truncate_* helpers, and full help text functions (main.rs lines 8184-8730, 8923-9122) -- [ ] 9.2 Add `mod formatting;` to `main.rs` and re-export -- [ ] 9.3 Run `cargo build` and `cargo test` — must pass +- [x] 9.1 Create `src/formatting.rs` with all format_tool_* functions, push_output_block, response_to_events, push_prompt_cache_record, prompt_cache_record_to_runtime_event, final_assistant_text, collect_tool_uses/results/cache_events, short_tool_id, extract_tool_path, first_visible_line, truncate_* helpers, and full help text functions (main.rs lines 8184-8730, 8923-9122) +- [x] 9.2 Add `mod formatting;` to `main.rs` and re-export +- [x] 9.3 Run `cargo build` and `cargo test` — must pass ## 10. Create config.rs — extract config and memory reporting -- [ ] 10.1 Create `src/config.rs` with `render_config_report`, `render_config_json`, `render_memory_report`, `render_memory_json` (main.rs lines 5962-6143) -- [ ] 10.2 Add `mod config;` to `main.rs` and re-export -- [ ] 10.3 Run `cargo build` and `cargo test` — must pass +- [x] 10.1 Create `src/config.rs` with `render_config_report`, `render_config_json`, `render_memory_report`, `render_memory_json` (main.rs lines 5962-6143) +- [x] 10.2 Add `mod config;` to `main.rs` and re-export +- [x] 10.3 Run `cargo build` and `cargo test` — must pass ## 11. Create diff.rs — extract git diff reporting -- [ ] 11.1 Create `src/diff.rs` with `render_diff_report`, `render_diff_report_for`, `render_diff_json_for`, `run_git_diff_command_in` (main.rs lines 6189-6270) -- [ ] 11.2 Add `mod diff;` to `main.rs` and re-export -- [ ] 11.3 Run `cargo build` and `cargo test` — must pass +- [x] 11.1 Create `src/diff.rs` with `render_diff_report`, `render_diff_report_for`, `render_diff_json_for`, `run_git_diff_command_in` (main.rs lines 6189-6270) +- [x] 11.2 Add `mod diff;` to `main.rs` and re-export +- [x] 11.3 Run `cargo build` and `cargo test` — must pass ## 12. Create export.rs — extract session export -- [ ] 12.1 Create `src/export.rs` with `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` (main.rs lines 6646-6914) -- [ ] 12.2 Add `mod export;` to `main.rs` and re-export -- [ ] 12.3 Run `cargo build` and `cargo test` — must pass +- [x] 12.1 Create `src/export.rs` with `run_export`, `render_export_text`, `render_session_markdown`, `default_export_filename`, `resolve_export_path`, `summarize_tool_payload_for_markdown` (main.rs lines 6646-6914) +- [x] 12.2 Add `mod export;` to `main.rs` and re-export +- [x] 12.3 Run `cargo build` and `cargo test` — must pass ## 13. Create api_client.rs — extract AnthropicRuntimeClient @@ -85,16 +85,16 @@ ## 15. Create runtime_builder.rs — extract runtime construction -- [ ] 15.1 Create `src/runtime_builder.rs` with RuntimePluginState, RuntimeMcpState, BuiltRuntime struct+impl+Deref+DerefMut+Drop, HookAbortMonitor, all `build_runtime*` functions, `build_plugin_manager`, `resolve_plugin_path`, `runtime_hook_config_from_plugin_hooks`, `build_system_prompt`, `build_runtime_mcp_state`, `mcp_runtime_tool_definition`, `mcp_wrapper_tool_definitions`, `permission_mode_for_mcp_tool`, `mcp_annotation_flag` (main.rs lines 3710-4180, 6924-6995, 7329-7450, 4017-4125) -- [ ] 15.2 Add `mod runtime_builder;` to `main.rs` and re-export -- [ ] 15.3 Run `cargo build` and `cargo test` — must pass +- [x] 15.1 Create `src/runtime_builder.rs` with RuntimePluginState, RuntimeMcpState, BuiltRuntime struct+impl+Deref+DerefMut+Drop, HookAbortMonitor, all `build_runtime*` functions, `build_plugin_manager`, `resolve_plugin_path`, `runtime_hook_config_from_plugin_hooks`, `build_system_prompt`, `build_runtime_mcp_state`, `mcp_runtime_tool_definition`, `mcp_wrapper_tool_definitions`, `permission_mode_for_mcp_tool`, `mcp_annotation_flag` (main.rs lines 3710-4180, 6924-6995, 7329-7450, 4017-4125) +- [x] 15.2 Add `mod runtime_builder;` to `main.rs` and re-export +- [x] 15.3 Run `cargo build` and `cargo test` — must pass ## 16. Create live_cli.rs — extract LiveCli core and REPL loop -- [ ] 16.1 Create `src/live_cli.rs` with LiveCli struct, PromptHistoryEntry, and core impl methods: `new`, `startup_banner`, `prepare_turn_runtime`, `replace_runtime`, `run_turn`, `run_turn_with_output`, `run_prompt_compact/json`, `repl_completion_candidates`, `persist_session`, `record_prompt_history`, `run_internal_prompt_text*`, `reload_runtime_features` (main.rs lines 3694-3710, 4181-4440, 4580-4610, 5240-5260) -- [ ] 16.2 Extract free functions: `run_repl`, `run_resume_command`, `resume_session`, `detect_broad_cwd`, `enforce_broad_cwd_policy`, `run_stale_base_preflight` (main.rs lines 3520-3675, 3597-3678, 3175-3520, 3609-3610) -- [ ] 16.3 Add `mod live_cli;` to `main.rs` and re-export -- [ ] 16.4 Run `cargo build` and `cargo test` — must pass +- [x] 16.1 Create `src/live_cli.rs` with LiveCli struct, PromptHistoryEntry, and core impl methods: `new`, `startup_banner`, `prepare_turn_runtime`, `replace_runtime`, `run_turn`, `run_turn_with_output`, `run_prompt_compact/json`, `repl_completion_candidates`, `persist_session`, `record_prompt_history`, `run_internal_prompt_text*`, `reload_runtime_features` (main.rs lines 3694-3710, 4181-4440, 4580-4610, 5240-5260) +- [x] 16.2 Extract free functions: `run_repl`, `run_resume_command`, `resume_session`, `detect_broad_cwd`, `enforce_broad_cwd_policy`, `run_stale_base_preflight` (main.rs lines 3520-3675, 3597-3678, 3175-3520, 3609-3610) +- [x] 16.3 Add `mod live_cli;` to `main.rs` and re-export +- [x] 16.4 Run `cargo build` and `cargo test` — must pass ## 17. Create repl_commands.rs — extract REPL command handlers diff --git a/rust/crates/rusty-claude-cli/src/api_client.rs b/rust/crates/rusty-claude-cli/src/api_client.rs index 07c1948d..a7a1a58e 100644 --- a/rust/crates/rusty-claude-cli/src/api_client.rs +++ b/rust/crates/rusty-claude-cli/src/api_client.rs @@ -11,10 +11,11 @@ use tools::GlobalToolRegistry; use crate::constants::*; use crate::model::max_tokens_for_model; -use crate::render::{ - format_tool_call_start, format_user_visible_api_error, push_output_block, +use crate::{ + format_tool_call_start, push_output_block, render_thinking_block_summary, }; + use crate::InternalPromptProgressReporter; // NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct @@ -306,4 +307,98 @@ pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool { .messages .last() .is_some_and(|message| message.role == MessageRole::Tool) +} + +pub(crate) fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String { + if error.is_context_window_failure() { + format_context_window_blocked_error(session_id, error) + } else if error.is_generic_fatal_wrapper() { + let mut qualifiers = vec![format!("session {session_id}")]; + if let Some(request_id) = error.request_id() { + qualifiers.push(format!("trace {request_id}")); + } + format!( + "{} ({}): {}", + error.safe_failure_class(), + qualifiers.join(", "), + error + ) + } else { + error.to_string() + } +} + +pub(crate) fn format_context_window_blocked_error( + session_id: &str, + error: &api::ApiError, +) -> String { + let mut lines = vec![ + "Context window blocked".to_string(), + " Failure class context_window_blocked".to_string(), + format!(" Session {session_id}"), + ]; + + if let Some(request_id) = error.request_id() { + lines.push(format!(" Trace {request_id}")); + } + + match error { + api::ApiError::ContextWindowExceeded { + model, + estimated_input_tokens, + requested_output_tokens, + estimated_total_tokens, + context_window_tokens, + } => { + lines.push(format!(" Model {model}")); + lines.push(format!( + " Input estimate ~{estimated_input_tokens} tokens (heuristic)" + )); + lines.push(format!( + " Requested output {requested_output_tokens} tokens" + )); + lines.push(format!( + " Total estimate ~{estimated_total_tokens} tokens (heuristic)" + )); + lines.push(format!(" Context window {context_window_tokens} tokens")); + } + api::ApiError::Api { message, body, .. } => { + let detail = message.as_deref().unwrap_or(body).trim(); + if !detail.is_empty() { + lines.push(format!( + " Detail {}", + crate::formatting::truncate_for_summary(detail, 120) + )); + } + } + api::ApiError::RetriesExhausted { last_error, .. } => { + let detail = match last_error.as_ref() { + api::ApiError::Api { message, body, .. } => message.as_deref().unwrap_or(body), + other => return format_context_window_blocked_error(session_id, other), + } + .trim(); + if !detail.is_empty() { + lines.push(format!( + " Detail {}", + crate::formatting::truncate_for_summary(detail, 120) + )); + } + } + _ => {} + } + + lines.push(String::new()); + lines.push("Recovery".to_string()); + lines.push(" Compact /compact".to_string()); + lines.push(format!( + " Resume compact claw --resume {session_id} /compact" + )); + lines.push(" Fresh session /clear --confirm".to_string()); + lines.push( + " Reduce scope remove large pasted context/files or ask for smaller slice" + .to_string(), + ); + lines.push(" Retry rerun after compacting or reducing request".to_string()); + + lines.join("\n") } \ No newline at end of file diff --git a/rust/crates/rusty-claude-cli/src/args.rs b/rust/crates/rusty-claude-cli/src/args.rs index 7c419713..2a41fbaa 100644 --- a/rust/crates/rusty-claude-cli/src/args.rs +++ b/rust/crates/rusty-claude-cli/src/args.rs @@ -13,10 +13,9 @@ use tools::GlobalToolRegistry; use crate::build_runtime_plugin_state_with_loader; use crate::constants::*; -use crate::looks_like_slash_command_token; use crate::model::*; -use crate::normalize_permission_mode; -use crate::resume_command_can_absorb_token; +use crate::runtime_builder::build_system_prompt; +use crate::formatting::{render_help_topic, render_version_report, full_help_text}; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CliAction { @@ -783,7 +782,7 @@ pub(crate) fn parse_direct_slash_cli_action( }), } } - Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)), + Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_slash_command_message(&name)), Ok(Some(command)) => Err({ let _ = command; format!( @@ -808,8 +807,8 @@ pub(crate) fn format_unknown_option(option: &str) -> String { message } -pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String { - let mut message = format!("unknown slash command outside the REPL: /{name}"); +pub(crate) fn format_unknown_slash_command_message(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'); @@ -819,7 +818,7 @@ pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String { message.push('\n'); message.push_str(note); } - message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help."); + message.push_str("\nRun `claw --help` for CLI usage. Use /help in the REPL."); message } @@ -1226,3 +1225,381 @@ pub(crate) fn parse_resume_args(args: &[String], output_format: CliOutputFormat) output_format, }) } + +pub(crate) fn parse_history_count(raw: Option<&str>) -> Result { + let Some(raw) = raw else { + return Ok(DEFAULT_HISTORY_LIMIT); + }; + let parsed: usize = raw + .parse() + .map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?; + if parsed == 0 { + return Err("history: count must be greater than 0.".to_string()); + } + Ok(parsed) +} + +pub(crate) fn 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, + } +} + +pub(crate) fn validate_no_args( + command_name: &str, + args: Option<&str>, +) -> Result<(), Box> { + if let Some(args) = args.map(str::trim).filter(|value| !value.is_empty()) { + return Err(format!( + "{command_name} does not accept arguments. Received: {args}\nUsage: {command_name}" + ) + .into()); + } + Ok(()) +} + +pub(crate) fn print_help_topic(topic: LocalHelpTopic) { + println!("{}", render_help_topic(topic)); +} + +pub(crate) fn print_acp_status( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support."; + match output_format { + CliOutputFormat::Text => { + println!( + "ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}" + ); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "acp", + "status": "discoverability_only", + "supported": false, + "serve_alias_only": true, + "message": message, + "launch_command": serde_json::Value::Null, + "aliases": ["acp", "--acp", "-acp"], + "discoverability_tracking": "ROADMAP #64a", + "tracking": "ROADMAP #76", + "recommended_workflows": [ + "claw prompt TEXT", + "claw", + "claw doctor" + ], + }))? + ); + } + } + Ok(()) +} + +pub(crate) fn slash_command_completion_candidates_with_sessions( + model: &str, + active_session_id: Option<&str>, + recent_session_ids: Vec, +) -> Vec { + use std::collections::BTreeSet; + let mut completions = BTreeSet::new(); + + for spec in commands::slash_command_specs() { + if STUB_COMMANDS.contains(&spec.name) { + continue; + } + completions.insert(format!("/{}", spec.name)); + for alias in spec.aliases { + if !STUB_COMMANDS.contains(alias) { + completions.insert(format!("/{alias}")); + } + } + } + + for candidate in [ + "/bughunter ", + "/clear --confirm", + "/config ", + "/config env", + "/config hooks", + "/config model", + "/config plugins", + "/mcp ", + "/mcp list", + "/mcp show ", + "/export ", + "/issue ", + "/model ", + "/model opus", + "/model sonnet", + "/model haiku", + "/permissions ", + "/permissions read-only", + "/permissions workspace-write", + "/permissions danger-full-access", + "/plugin list", + "/plugin install ", + "/plugin enable ", + "/plugin disable ", + "/plugin uninstall ", + "/plugin update ", + "/plugins list", + "/pr ", + "/resume ", + "/session list", + "/session switch ", + "/session fork ", + "/teleport ", + "/ultraplan ", + "/agents help", + "/mcp help", + "/skills help", + ] { + completions.insert(candidate.to_string()); + } + + if !model.trim().is_empty() { + completions.insert(format!("/model {}", resolve_model_alias(model))); + completions.insert(format!("/model {model}")); + } + + if let Some(active_session_id) = active_session_id.filter(|value| !value.trim().is_empty()) { + completions.insert(format!("/resume {active_session_id}")); + completions.insert(format!("/session switch {active_session_id}")); + } + + for session_id in recent_session_ids + .into_iter() + .filter(|value| !value.trim().is_empty()) + .take(10) + { + completions.insert(format!("/resume {session_id}")); + completions.insert(format!("/session switch {session_id}")); + } + + completions.into_iter().collect() +} + +pub(crate) fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box> { + let cwd = env::current_dir()?; + let state_path = cwd.join(".claw").join("worker-state.json"); + if !state_path.exists() { + return Err(format!( + "no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt # run one non-interactive turn\n Then rerun: claw state [--output-format json]", + path = state_path.display() + ) + .into()); + } + let raw = std::fs::read_to_string(&state_path)?; + match output_format { + CliOutputFormat::Text => println!("{raw}"), + CliOutputFormat::Json => { + let _: serde_json::Value = serde_json::from_str(&raw)?; + println!("{raw}"); + } + } + Ok(()) +} + +pub(crate) fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { + matches!( + commands::SlashCommand::parse(current_command), + Ok(Some(commands::SlashCommand::Export { path: None })) + ) && !looks_like_slash_command_token(token) +} + +pub(crate) fn looks_like_slash_command_token(token: &str) -> bool { + let trimmed = token.trim_start(); + let Some(name) = trimmed.strip_prefix('/').and_then(|value| { + value + .split_whitespace() + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + }) else { + return false; + }; + + commands::slash_command_specs() + .iter() + .any(|spec| spec.name == name || spec.aliases.contains(&name)) +} + +pub(crate) fn dump_manifests( + manifests_dir: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + dump_manifests_at_path(&workspace_dir, manifests_dir, output_format) +} + +const DUMP_MANIFESTS_OVERRIDE_HINT: &str = + "Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`."; + +fn dump_manifests_at_path( + workspace_dir: &std::path::Path, + manifests_dir: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let paths = if let Some(dir) = manifests_dir { + let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); + compat_harness::UpstreamPaths::from_repo_root(resolved) + } else { + let resolved = workspace_dir + .canonicalize() + .unwrap_or_else(|_| workspace_dir.to_path_buf()); + compat_harness::UpstreamPaths::from_workspace_dir(&resolved) + }; + + let source_root = paths.repo_root(); + if !source_root.exists() { + return Err(format!( + "Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", + source_root.display(), + ) + .into()); + } + + let required_paths = [ + ("src/commands.ts", paths.commands_path()), + ("src/tools.ts", paths.tools_path()), + ("src/entrypoints/cli.tsx", paths.cli_path()), + ]; + let missing = required_paths + .iter() + .filter_map(|(label, path)| (!path.is_file()).then_some(*label)) + .collect::>(); + if !missing.is_empty() { + return Err(format!( + "Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", + source_root.display(), + missing.join(", "), + ) + .into()); + } + + match compat_harness::extract_manifest(&paths) { + Ok(manifest) => { + match output_format { + CliOutputFormat::Text => { + println!("commands: {}", manifest.commands.entries().len()); + println!("tools: {}", manifest.tools.entries().len()); + println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); + } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "dump-manifests", + "commands": manifest.commands.entries().len(), + "tools": manifest.tools.entries().len(), + "bootstrap_phases": manifest.bootstrap.phases().len(), + }))? + ), + } + Ok(()) + } + Err(error) => Err(format!("failed to extract manifests: {error}").into()), + } +} + +pub(crate) fn print_bootstrap_plan( + output_format: CliOutputFormat, +) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => { + println!("Bootstrap Plan\n Status not implemented\n Message the bootstrap plan surface is not implemented in this build yet."); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&bootstrap_plan_json_value())? + ); + } + } + Ok(()) +} + +fn bootstrap_plan_json_value() -> serde_json::Value { + serde_json::json!({ + "kind": "bootstrap_plan", + "phases": [], + "message": "the bootstrap plan surface is not implemented in this build yet.", + }) +} + +pub(crate) fn print_system_prompt( + _cwd: Option, + _date: Option, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let prompt = build_system_prompt()?; + match output_format { + CliOutputFormat::Text => { + println!("{}", prompt.join("\n\n")); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "system_prompt", + "prompt": prompt, + }))? + ); + } + } + Ok(()) +} + +pub(crate) fn print_version(output_format: CliOutputFormat) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => { + println!("{}", render_version_report()); + } + CliOutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&version_json_value())?); + } + } + Ok(()) +} + +pub(crate) fn version_json_value() -> serde_json::Value { + serde_json::json!({ + "kind": "version", + "version": VERSION, + "git_sha": GIT_SHA, + "build_target": BUILD_TARGET, + "build_date": DEFAULT_DATE, + }) +} + +pub(crate) fn print_help(output_format: CliOutputFormat) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => { + println!("{}", full_help_text()); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "help", + "text": full_help_text(), + }))? + ); + } + } + Ok(()) +} + +pub(crate) fn print_connected_line(model: &str) -> String { + let provider = if model.to_lowercase().contains("grok") { + "xAI" + } else { + "Anthropic" + }; + format!("\x1b[2mConnected to {provider} ({model})\x1b[0m") +} + diff --git a/rust/crates/rusty-claude-cli/src/config.rs b/rust/crates/rusty-claude-cli/src/config.rs new file mode 100644 index 00000000..36471484 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/config.rs @@ -0,0 +1,175 @@ +use crate::*; +use runtime::{ConfigLoader, ConfigSource, ProjectContext}; +use serde_json; +use std::env; + +pub(crate) fn render_config_report(section: Option<&str>) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + let mut lines = vec![ + format!( + "Config + 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("\n")); + } + }; + lines.push(format!( + " {}", + match value { + Some(value) => value.render(), + None => "".to_string(), + } + )); + return Ok(lines.join("\n")); + } + + lines.push("Merged JSON".to_string()); + lines.push(format!(" {}", runtime_config.as_json().render())); + Ok(lines.join("\n")) +} + +pub(crate) fn render_config_json( + _section: Option<&str>, +) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let discovered = loader.discover(); + let runtime_config = loader.load()?; + + let loaded_paths: Vec<_> = runtime_config + .loaded_entries() + .iter() + .map(|e| e.path.display().to_string()) + .collect(); + + let files: Vec<_> = discovered + .iter() + .map(|e| { + let source = match e.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let is_loaded = runtime_config + .loaded_entries() + .iter() + .any(|le| le.path == e.path); + serde_json::json!({ + "path": e.path.display().to_string(), + "source": source, + "loaded": is_loaded, + }) + }) + .collect(); + + Ok(serde_json::json!({ + "kind": "config", + "cwd": cwd.display().to_string(), + "loaded_files": loaded_paths.len(), + "merged_keys": runtime_config.merged().len(), + "files": files, + })) +} + +pub(crate) fn render_memory_report() -> Result> { + let cwd = env::current_dir()?; + let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; + let mut lines = vec![format!( + "Memory + 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("\n")) +} + +pub(crate) fn render_memory_json() -> Result> { + let cwd = env::current_dir()?; + let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; + let files: Vec<_> = project_context + .instruction_files + .iter() + .map(|f| { + serde_json::json!({ + "path": f.path.display().to_string(), + "lines": f.content.lines().count(), + "preview": f.content.lines().next().unwrap_or("").trim(), + }) + }) + .collect(); + Ok(serde_json::json!({ + "kind": "memory", + "cwd": cwd.display().to_string(), + "instruction_files": files.len(), + "files": files, + })) +} \ No newline at end of file diff --git a/rust/crates/rusty-claude-cli/src/constants.rs b/rust/crates/rusty-claude-cli/src/constants.rs index 9a4afd94..378d7a0a 100644 --- a/rust/crates/rusty-claude-cli/src/constants.rs +++ b/rust/crates/rusty-claude-cli/src/constants.rs @@ -43,6 +43,7 @@ pub(crate) const CLI_OPTION_SUGGESTIONS: &[&str] = &[ "-p", ]; +pub(crate) const DEFAULT_HISTORY_LIMIT: usize = 20; pub(crate) type AllowedToolSet = BTreeSet; pub(crate) const STUB_COMMANDS: &[&str] = &[ diff --git a/rust/crates/rusty-claude-cli/src/diff.rs b/rust/crates/rusty-claude-cli/src/diff.rs new file mode 100644 index 00000000..8e383adf --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/diff.rs @@ -0,0 +1,83 @@ +use crate::*; +use std::env; +use std::path::Path; +use std::process::Command; + +pub(crate) fn render_diff_report() -> Result> { + render_diff_report_for(&env::current_dir()?) +} + +pub(crate) fn render_diff_report_for(cwd: &Path) -> Result> { + // Verify we are inside a git repository before calling `git diff`. + // Running `git diff --cached` outside a git tree produces a misleading + // "unknown option `cached`" error because git falls back to --no-index mode. + let in_git_repo = Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(cwd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !in_git_repo { + return Ok(format!( + "Diff\n Result no git repository\n Detail {} is not inside a git project", + cwd.display() + )); + } + let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; + let unstaged = run_git_diff_command_in(cwd, &["diff"])?; + if staged.trim().is_empty() && unstaged.trim().is_empty() { + return Ok( + "Diff\n Result clean working tree\n Detail no current changes" + .to_string(), + ); + } + + let mut sections = Vec::new(); + if !staged.trim().is_empty() { + sections.push(format!("Staged changes:\n{}", staged.trim_end())); + } + if !unstaged.trim().is_empty() { + sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end())); + } + + Ok(format!("Diff\n\n{}", sections.join("\n\n"))) +} + +pub(crate) fn render_diff_json_for(cwd: &Path) -> Result> { + let in_git_repo = Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(cwd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !in_git_repo { + return Ok(serde_json::json!({ + "kind": "diff", + "result": "no_git_repo", + "detail": format!("{} is not inside a git project", cwd.display()), + })); + } + let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; + let unstaged = run_git_diff_command_in(cwd, &["diff"])?; + Ok(serde_json::json!({ + "kind": "diff", + "result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" }, + "staged": staged.trim(), + "unstaged": unstaged.trim(), + })) +} + +pub(crate) fn run_git_diff_command_in( + cwd: &Path, + args: &[&str], +) -> Result> { + let output = 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)?) +} diff --git a/rust/crates/rusty-claude-cli/src/doctor.rs b/rust/crates/rusty-claude-cli/src/doctor.rs index e175c4d6..0321ad82 100644 --- a/rust/crates/rusty-claude-cli/src/doctor.rs +++ b/rust/crates/rusty-claude-cli/src/doctor.rs @@ -103,7 +103,7 @@ impl DoctorReport { pub(crate) fn json_value(&self) -> Value { let report = self.render(); let (ok_count, warn_count, fail_count) = self.counts(); - json!({ + serde_json::json!({ "kind": "doctor", "message": report, "report": report, @@ -213,12 +213,12 @@ fn check_auth_health() -> DiagnosticCheck { "Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed".to_string(), ]) .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("legacy_saved_oauth_present".to_string(), json!(true)), - ("legacy_saved_oauth_expires_at".to_string(), json!(token_set.expires_at)), - ("legacy_refresh_token_present".to_string(), json!(token_set.refresh_token.is_some())), - ("legacy_scopes".to_string(), json!(token_set.scopes)), + ("api_key_present".to_string(),serde_json::json!(api_key_present)), + ("auth_token_present".to_string(),serde_json::json!(auth_token_present)), + ("legacy_saved_oauth_present".to_string(),serde_json::json!(true)), + ("legacy_saved_oauth_expires_at".to_string(),serde_json::json!(token_set.expires_at)), + ("legacy_refresh_token_present".to_string(),serde_json::json!(token_set.refresh_token.is_some())), + ("legacy_scopes".to_string(),serde_json::json!(token_set.scopes)), ])), Ok(None) => DiagnosticCheck::new( "Auth", @@ -231,12 +231,12 @@ fn check_auth_health() -> DiagnosticCheck { ) .with_details(vec![env_details]) .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("legacy_saved_oauth_present".to_string(), json!(false)), + ("api_key_present".to_string(),serde_json::json!(api_key_present)), + ("auth_token_present".to_string(),serde_json::json!(auth_token_present)), + ("legacy_saved_oauth_present".to_string(),serde_json::json!(false)), ("legacy_saved_oauth_expires_at".to_string(), Value::Null), - ("legacy_refresh_token_present".to_string(), json!(false)), - ("legacy_scopes".to_string(), json!(Vec::::new())), + ("legacy_refresh_token_present".to_string(),serde_json::json!(false)), + ("legacy_scopes".to_string(),serde_json::json!(Vec::::new())), ])), Err(error) => DiagnosticCheck::new( "Auth", @@ -244,13 +244,13 @@ fn check_auth_health() -> DiagnosticCheck { format!("failed to inspect legacy saved credentials: {error}"), ) .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), + ("api_key_present".to_string(),serde_json::json!(api_key_present)), + ("auth_token_present".to_string(),serde_json::json!(auth_token_present)), ("legacy_saved_oauth_present".to_string(), Value::Null), ("legacy_saved_oauth_expires_at".to_string(), Value::Null), ("legacy_refresh_token_present".to_string(), Value::Null), ("legacy_scopes".to_string(), Value::Null), - ("legacy_saved_oauth_error".to_string(), json!(error.to_string())), + ("legacy_saved_oauth_error".to_string(),serde_json::json!(error.to_string())), ])), } } @@ -278,22 +278,22 @@ fn check_config_health( DiagnosticCheck::new("Config", DiagnosticLevel::Ok, if present_paths.is_empty() { "no config files present; defaults are active" } else { "runtime config loaded successfully" }) .with_details(details) .with_data(Map::from_iter([ - ("discovered_files".to_string(), json!(present_paths)), - ("discovered_files_count".to_string(), json!(present_paths.len())), - ("loaded_config_files".to_string(), json!(loaded_entries.len())), - ("resolved_model".to_string(), json!(runtime_config.model())), - ("mcp_servers".to_string(), json!(runtime_config.mcp().servers().len())), + ("discovered_files".to_string(),serde_json::json!(present_paths)), + ("discovered_files_count".to_string(),serde_json::json!(present_paths.len())), + ("loaded_config_files".to_string(),serde_json::json!(loaded_entries.len())), + ("resolved_model".to_string(),serde_json::json!(runtime_config.model())), + ("mcp_servers".to_string(),serde_json::json!(runtime_config.mcp().servers().len())), ])) } Err(error) => DiagnosticCheck::new("Config", DiagnosticLevel::Fail, format!("runtime config failed to load: {error}")) .with_details(if discovered_paths.is_empty() { vec!["Discovered files ".to_string()] } else { discovered_paths.iter().map(|p| format!("Discovered file {p}")).collect() }) .with_data(Map::from_iter([ - ("discovered_files".to_string(), json!(discovered_paths)), - ("discovered_files_count".to_string(), json!(discovered_paths.len())), - ("loaded_config_files".to_string(), json!(0)), + ("discovered_files".to_string(),serde_json::json!(discovered_paths)), + ("discovered_files_count".to_string(),serde_json::json!(discovered_paths.len())), + ("loaded_config_files".to_string(),serde_json::json!(0)), ("resolved_model".to_string(), Value::Null), ("mcp_servers".to_string(), Value::Null), - ("load_error".to_string(), json!(error.to_string())), + ("load_error".to_string(),serde_json::json!(error.to_string())), ])), } } @@ -306,9 +306,9 @@ fn check_install_source_health() -> DiagnosticCheck { format!("Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary"), ]) .with_data(Map::from_iter([ - ("official_repo".to_string(), json!(OFFICIAL_REPO_URL)), - ("deprecated_install".to_string(), json!(DEPRECATED_INSTALL_COMMAND)), - ("recommended_install".to_string(), json!("build from source or follow the upstream binary instructions in README.md")), + ("official_repo".to_string(),serde_json::json!(OFFICIAL_REPO_URL)), + ("deprecated_install".to_string(),serde_json::json!(DEPRECATED_INSTALL_COMMAND)), + ("recommended_install".to_string(),serde_json::json!("build from source or follow the upstream binary instructions in README.md")), ])) } @@ -326,15 +326,15 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { format!("Memory files {} · config files loaded {}/{}", context.memory_file_count, context.loaded_config_files, context.discovered_config_files), ]) .with_data(Map::from_iter([ - ("cwd".to_string(), json!(context.cwd.display().to_string())), - ("project_root".to_string(), json!(context.project_root.as_ref().map(|p| p.display().to_string()))), - ("in_git_repo".to_string(), json!(in_repo)), - ("git_branch".to_string(), json!(context.git_branch)), - ("git_state".to_string(), json!(context.git_summary.headline())), - ("changed_files".to_string(), json!(context.git_summary.changed_files)), - ("memory_file_count".to_string(), json!(context.memory_file_count)), - ("loaded_config_files".to_string(), json!(context.loaded_config_files)), - ("discovered_config_files".to_string(), json!(context.discovered_config_files)), + ("cwd".to_string(),serde_json::json!(context.cwd.display().to_string())), + ("project_root".to_string(),serde_json::json!(context.project_root.as_ref().map(|p| p.display().to_string()))), + ("in_git_repo".to_string(),serde_json::json!(in_repo)), + ("git_branch".to_string(),serde_json::json!(context.git_branch)), + ("git_state".to_string(),serde_json::json!(context.git_summary.headline())), + ("changed_files".to_string(),serde_json::json!(context.git_summary.changed_files)), + ("memory_file_count".to_string(),serde_json::json!(context.memory_file_count)), + ("loaded_config_files".to_string(),serde_json::json!(context.loaded_config_files)), + ("discovered_config_files".to_string(),serde_json::json!(context.discovered_config_files)), ])) } @@ -356,19 +356,19 @@ fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck { ) .with_details(details) .with_data(Map::from_iter([ - ("enabled".to_string(), json!(status.enabled)), - ("active".to_string(), json!(status.active)), - ("supported".to_string(), json!(status.supported)), - ("namespace_supported".to_string(), json!(status.namespace_supported)), - ("namespace_active".to_string(), json!(status.namespace_active)), - ("network_supported".to_string(), json!(status.network_supported)), - ("network_active".to_string(), json!(status.network_active)), - ("filesystem_mode".to_string(), json!(status.filesystem_mode.as_str())), - ("filesystem_active".to_string(), json!(status.filesystem_active)), - ("allowed_mounts".to_string(), json!(status.allowed_mounts)), - ("in_container".to_string(), json!(status.in_container)), - ("container_markers".to_string(), json!(status.container_markers)), - ("fallback_reason".to_string(), json!(status.fallback_reason)), + ("enabled".to_string(),serde_json::json!(status.enabled)), + ("active".to_string(),serde_json::json!(status.active)), + ("supported".to_string(),serde_json::json!(status.supported)), + ("namespace_supported".to_string(),serde_json::json!(status.namespace_supported)), + ("namespace_active".to_string(),serde_json::json!(status.namespace_active)), + ("network_supported".to_string(),serde_json::json!(status.network_supported)), + ("network_active".to_string(),serde_json::json!(status.network_active)), + ("filesystem_mode".to_string(),serde_json::json!(status.filesystem_mode.as_str())), + ("filesystem_active".to_string(),serde_json::json!(status.filesystem_active)), + ("allowed_mounts".to_string(),serde_json::json!(status.allowed_mounts)), + ("in_container".to_string(),serde_json::json!(status.in_container)), + ("container_markers".to_string(),serde_json::json!(status.container_markers)), + ("fallback_reason".to_string(),serde_json::json!(status.fallback_reason)), ])) } @@ -387,12 +387,12 @@ fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> D DiagnosticCheck::new("System", DiagnosticLevel::Ok, "captured local runtime metadata") .with_details(details) .with_data(Map::from_iter([ - ("os".to_string(), json!(env::consts::OS)), - ("arch".to_string(), json!(env::consts::ARCH)), - ("working_dir".to_string(), json!(cwd.display().to_string())), - ("version".to_string(), json!(VERSION)), - ("build_target".to_string(), json!(BUILD_TARGET)), - ("git_sha".to_string(), json!(GIT_SHA)), - ("default_model".to_string(), json!(default_model)), + ("os".to_string(),serde_json::json!(env::consts::OS)), + ("arch".to_string(),serde_json::json!(env::consts::ARCH)), + ("working_dir".to_string(),serde_json::json!(cwd.display().to_string())), + ("version".to_string(),serde_json::json!(VERSION)), + ("build_target".to_string(),serde_json::json!(BUILD_TARGET)), + ("git_sha".to_string(),serde_json::json!(GIT_SHA)), + ("default_model".to_string(),serde_json::json!(default_model)), ])) } diff --git a/rust/crates/rusty-claude-cli/src/export.rs b/rust/crates/rusty-claude-cli/src/export.rs new file mode 100644 index 00000000..1b012c1c --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/export.rs @@ -0,0 +1,110 @@ +use crate::*; +use std::path::{Path, PathBuf}; + + +pub(crate) fn run_export( + session_reference: &str, + output_path: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let (handle, session) = load_session_reference(session_reference)?; + let markdown = render_session_markdown(&session, &handle.id, &handle.path); + + if let Some(path) = output_path { + fs::write(path, &markdown)?; + let report = format!( + "Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}", + path.display(), + handle.id, + session.messages.len(), + ); + match output_format { + CliOutputFormat::Text => println!("{report}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "export", + "message": report, + "session_id": handle.id, + "file": path.display().to_string(), + "messages": session.messages.len(), + }))? + ), + } + return Ok(()); + } + + match output_format { + CliOutputFormat::Text => { + print!("{markdown}"); + if !markdown.ends_with('\n') { + println!(); + } + } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "export", + "session_id": handle.id, + "file": handle.path.display().to_string(), + "messages": session.messages.len(), + "markdown": markdown, + }))? + ), + } + Ok(()) +} + +pub(crate) fn default_export_filename(session: &Session) -> String { + let stem = session + .messages + .iter() + .find_map(|message| match message.role { + MessageRole::User => message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }), + _ => None, + }) + .map_or("conversation", |text| { + text.lines().next().unwrap_or("conversation") + }) + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .split('-') + .filter(|part| !part.is_empty()) + .take(8) + .collect::>() + .join("-"); + let fallback = if stem.is_empty() { + "conversation" + } else { + &stem + }; + format!("{fallback}.txt") +} + +pub(crate) fn resolve_export_path( + requested_path: Option<&str>, + session: &Session, +) -> Result> { + let cwd = std::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)) +} diff --git a/rust/crates/rusty-claude-cli/src/formatting.rs b/rust/crates/rusty-claude-cli/src/formatting.rs deleted file mode 100644 index 527083af..00000000 --- a/rust/crates/rusty-claude-cli/src/formatting.rs +++ /dev/null @@ -1,157 +0,0 @@ -use std::path::PathBuf; -use runtime::TokenUsage; -use serde_json::Value; - -use crate::constants::*; -use crate::models::*; -use crate::omc_compatibility_note_for_unknown_slash_command; -use crate::suggest_slash_commands; - -pub(crate) fn format_unknown_slash_command_message(name: &str) -> String { - let suggestions = suggest_slash_commands(name); - let mut message = format!("unknown slash command: /{name}."); - if !suggestions.is_empty() { - message.push_str(" Did you mean "); - message.push_str(&suggestions.join(", ")); - message.push('?'); - } - if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { - message.push(' '); - message.push_str(note); - } - message.push_str(" Use /help to list available commands."); - message -} - -pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { - format!( - "Model - Current model {model} - Session messages {message_count} - Session turns {turns} - -Usage - Inspect current model with /model - Switch models with /model " - ) -} - -pub(crate) fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String { - format!( - "Model updated - Previous {previous} - Current {next} - Preserved msgs {message_count}" - ) -} - -pub(crate) fn format_permissions_report(mode: &str) -> String { - let modes = [ - ("read-only", "Read/search tools only", mode == "read-only"), - ( - "workspace-write", - "Edit files inside the workspace", - mode == "workspace-write", - ), - ( - "danger-full-access", - "Unrestricted tool access", - mode == "danger-full-access", - ), - ] - .into_iter() - .map(|(name, description, is_current)| { - let marker = if is_current { - "● current" - } else { - "○ available" - }; - format!(" {name:<18} {marker:<11} {description}") - }) - .collect::>() - .join( - " -", - ); - - format!( - "Permissions - Active mode {mode} - Mode status live session default - -Modes -{modes} - -Usage - Inspect current mode with /permissions - Switch modes with /permissions " - ) -} - -pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String { - format!( - "Permissions updated - Result mode switched - Previous mode {previous} - Active mode {next} - Applies to subsequent tool calls - Usage /permissions to inspect current mode" - ) -} - -pub(crate) fn format_cost_report(usage: TokenUsage) -> String { - format!( - "Cost - Input tokens {} - Output tokens {} - Cache create {} - Cache read {} - Total tokens {}", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - usage.total_tokens(), - ) -} - -pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { - format!( - "Session resumed - Session file {session_path} - Messages {message_count} - Turns {turns}" - ) -} - -pub(crate) fn render_resume_usage() -> String { - format!( - "Resume - Usage /resume - Auto-save .claw/sessions/.{PRIMARY_SESSION_EXTENSION} - Tip use /session list to inspect saved sessions" - ) -} - -pub(crate) fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String { - if skipped { - format!( - "Compact - Result skipped - Reason session below compaction threshold - Messages kept {resulting_messages}" - ) - } else { - format!( - "Compact - Result compacted - Messages removed {removed} - Messages kept {resulting_messages}" - ) - } -} - -pub(crate) fn format_auto_compaction_notice(removed: usize) -> String { - format!("[auto-compacted: removed {removed} messages]") -} - diff --git a/rust/crates/rusty-claude-cli/src/formatting/helpers.rs b/rust/crates/rusty-claude-cli/src/formatting/helpers.rs new file mode 100644 index 00000000..74285016 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/formatting/helpers.rs @@ -0,0 +1,199 @@ +use serde_json::Value; + +pub(crate) fn civil_from_days(days: i64) -> (i32, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { + z / 146_097 + } else { + (z - 146_096) / 146_097 + }; + let doe = (z - era * 146_097) as u64; // [0, 146_096] + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let y = y + i64::from(m <= 2); + (y as i32, m as u32, d as u32) +} + +pub(crate) fn indent_block(value: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + value + .lines() + .map(|line| format!("{indent}{line}")) + .collect::>() + .join("\n") +} + +pub(crate) fn truncate_for_prompt(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + value.trim().to_string() + } else { + let truncated = value.chars().take(limit).collect::(); + format!("{}\n…[truncated]", truncated.trim_end()) + } +} + +pub(crate) fn extract_tool_path(parsed: &Value) -> String { + parsed + .get("file_path") + .or_else(|| parsed.get("filePath")) + .or_else(|| parsed.get("path")) + .and_then(|value| value.as_str()) + .unwrap_or("?") + .to_string() +} + +pub(crate) fn first_visible_line(text: &str) -> &str { + text.lines().find(|l| !l.trim().is_empty()).unwrap_or("") +} + +pub(crate) fn final_assistant_text(summary: &runtime::TurnSummary) -> String { + summary + .assistant_messages + .last() + .map(|message| { + message + .blocks + .iter() + .filter_map(|block| match block { + runtime::ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + }) + .unwrap_or_default() +} + +pub(crate) fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec { + summary + .assistant_messages + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + runtime::ContentBlock::ToolUse { id, name, input } => Some(serde_json::json!({ + "id": id, + "name": name, + "input": input, + })), + _ => None, + }) + .collect() +} + +pub(crate) fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec { + summary + .tool_results + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + runtime::ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => Some(serde_json::json!({ + "tool_use_id": tool_use_id, + "tool_name": tool_name, + "output": output, + "is_error": is_error, + })), + _ => None, + }) + .collect() +} + +pub(crate) fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec { + summary + .prompt_cache_events + .iter() + .map(|event| { + serde_json::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() +} + +pub(crate) fn sanitize_generated_message(value: &str) -> String { + value.trim().trim_matches('`').trim().replace("\r\n", "\n") +} + +pub(crate) fn parse_titled_body(value: &str) -> Option<(String, String)> { + let normalized = sanitize_generated_message(value); + let title = normalized + .lines() + .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?; + let body_start = normalized.find("BODY:")?; + let body = normalized[body_start + "BODY:".len()..].trim(); + Some((title.to_string(), body.to_string())) +} + +pub(crate) fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize) -> String { + let original = content.trim_end_matches('\n'); + if original.is_empty() { + return String::new(); + } + + let mut preview_lines = Vec::new(); + let mut used_chars = 0usize; + let mut truncated = false; + + for (index, line) in original.lines().enumerate() { + if index >= max_lines { + truncated = true; + break; + } + + let newline_cost = usize::from(!preview_lines.is_empty()); + let available = max_chars.saturating_sub(used_chars + newline_cost); + if available == 0 { + truncated = true; + break; + } + + let line_chars = line.chars().count(); + if line_chars > available { + preview_lines.push(line.chars().take(available).collect::()); + truncated = true; + break; + } + + preview_lines.push(line.to_string()); + used_chars += newline_cost + line_chars; + } + + let mut preview = preview_lines.join("\n"); + if truncated { + if !preview.is_empty() { + preview.push('\n'); + } + preview.push_str(super::DISPLAY_TRUNCATION_NOTICE); + } + preview +} + +pub(crate) fn summarize_tool_payload(payload: &str) -> String { + let compact = match serde_json::from_str::(payload) { + Ok(value) => value.to_string(), + Err(_) => payload.trim().to_string(), + }; + truncate_for_summary(&compact, 96) +} + +pub(crate) fn truncate_for_summary(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let truncated = chars.by_ref().take(limit).collect::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} diff --git a/rust/crates/rusty-claude-cli/src/formatting/mod.rs b/rust/crates/rusty-claude-cli/src/formatting/mod.rs new file mode 100644 index 00000000..14b6f53d --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/formatting/mod.rs @@ -0,0 +1,19 @@ +pub(crate) mod helpers; +pub(crate) mod reports; +pub(crate) mod tools; + +pub(crate) use helpers::*; +pub(crate) use reports::*; +pub(crate) use tools::*; + +pub(crate) fn full_help_text() -> String { + reports::full_help_text() +} + +pub(crate) const DISPLAY_TRUNCATION_NOTICE: &str = + "\x1b[2m… output truncated for display; full result preserved in session.\x1b[0m"; +pub(crate) const READ_DISPLAY_MAX_LINES: usize = 80; +pub(crate) const READ_DISPLAY_MAX_CHARS: usize = 6_000; +pub(crate) const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60; +pub(crate) const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000; +pub(crate) const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280; diff --git a/rust/crates/rusty-claude-cli/src/formatting/reports.rs b/rust/crates/rusty-claude-cli/src/formatting/reports.rs new file mode 100644 index 00000000..50e6ab09 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/formatting/reports.rs @@ -0,0 +1,755 @@ +use std::path::Path; +use std::process::Command; +use std::env; +use std::time::UNIX_EPOCH; +use runtime::{Session, TokenUsage}; +use commands::render_slash_command_help_filtered; +use crate::constants::*; +use crate::models::*; +use crate::live_cli::PromptHistoryEntry; +use crate::args::LocalHelpTopic; +use crate::omc_compatibility_note_for_unknown_slash_command; +use crate::render::TerminalRenderer; +use super::helpers::*; +use super::tools::format_tool_result; + + + +pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { + format!( + "Model + Current model {model} + Session messages {message_count} + Session turns {turns} + +Usage + Inspect current model with /model + Switch models with /model " + ) +} + +pub(crate) fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String { + format!( + "Model updated + Previous {previous} + Current {next} + Preserved msgs {message_count}" + ) +} + +pub(crate) fn format_permissions_report(mode: &str) -> String { + let modes = [ + ("read-only", "Read/search tools only", mode == "read-only"), + ( + "workspace-write", + "Edit files inside the workspace", + mode == "workspace-write", + ), + ( + "danger-full-access", + "Unrestricted tool access", + mode == "danger-full-access", + ), + ] + .into_iter() + .map(|(name, description, is_current)| { + let marker = if is_current { + "● current" + } else { + "○ available" + }; + format!(" {name:<18} {marker:<11} {description}") + }) + .collect::>() + .join("\n"); + + format!( + "Permissions + Active mode {mode} + Mode status live session default + +Modes +{modes} + +Usage + Inspect current mode with /permissions + Switch modes with /permissions " + ) +} + +pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String { + format!( + "Permissions updated + Result mode switched + Previous mode {previous} + Active mode {next} + Applies to subsequent tool calls + Usage /permissions to inspect current mode" + ) +} + +pub(crate) fn format_cost_report(usage: TokenUsage) -> String { + format!( + "Cost + Input tokens {} + Output tokens {} + Cache create {} + Cache read {} + Total tokens {}", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + usage.total_tokens(), + ) +} + +pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { + format!( + "Session resumed + Session file {session_path} + Messages {message_count} + Turns {turns}" + ) +} + +pub(crate) fn render_resume_usage() -> String { + format!( + "Resume + Usage /resume + Auto-save .claw/sessions/.{PRIMARY_SESSION_EXTENSION} + Tip use /session list to inspect saved sessions" + ) +} + +pub(crate) fn render_repl_help() -> String { + [ + "REPL".to_string(), + " /exit Quit the REPL".to_string(), + " /quit Quit the REPL".to_string(), + " Up/Down Navigate prompt history".to_string(), + " Ctrl-R Reverse-search prompt history".to_string(), + " Tab Complete commands, modes, and recent sessions".to_string(), + " Ctrl-C Clear input (or exit on empty prompt)".to_string(), + " Shift+Enter/Ctrl+J Insert a newline".to_string(), + " Auto-save .claw/sessions/.jsonl".to_string(), + " Resume latest /resume latest".to_string(), + " Browse sessions /session list".to_string(), + " Show prompt history /history [count]".to_string(), + String::new(), + render_slash_command_help_filtered(STUB_COMMANDS), + ] + .join("\n") +} + +pub(crate) fn full_help_text() -> String { + let mut out = Vec::new(); + let _ = print_help_to(&mut out); + String::from_utf8_lossy(&out).to_string() +} + +pub(crate) fn print_help_to(out: &mut impl std::io::Write) -> std::io::Result<()> { + writeln!(out, "claw v{VERSION}")?; + writeln!(out)?; + writeln!(out, "Usage:")?; + writeln!( + out, + " claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]" + )?; + writeln!(out, " Start the interactive REPL")?; + writeln!( + out, + " claw [--model MODEL] [--output-format text|json] prompt TEXT" + )?; + writeln!(out, " Send one prompt and exit")?; + writeln!( + out, + " claw [--model MODEL] [--output-format text|json] TEXT" + )?; + writeln!(out, " Shorthand non-interactive prompt mode")?; + writeln!( + out, + " claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]" + )?; + writeln!( + out, + " Inspect or maintain a saved session without entering the REPL" + )?; + writeln!(out, " claw help")?; + writeln!(out, " Alias for --help")?; + writeln!(out, " claw version")?; + writeln!(out, " Alias for --version")?; + writeln!(out, " claw status")?; + writeln!( + out, + " Show the current local workspace status snapshot" + )?; + writeln!(out, " claw sandbox")?; + writeln!(out, " Show the current sandbox isolation snapshot")?; + writeln!(out, " claw doctor")?; + writeln!( + out, + " Diagnose local auth, config, workspace, and sandbox health" + )?; + writeln!(out, " claw acp [serve]")?; + writeln!( + out, + " Show ACP/Zed editor integration status (currently unsupported; aliases: --acp, -acp)" + )?; + writeln!(out, " Source of truth: {OFFICIAL_REPO_SLUG}")?; + writeln!( + out, + " Warning: do not `{DEPRECATED_INSTALL_COMMAND}` (deprecated stub)" + )?; + writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?; + writeln!(out, " claw bootstrap-plan")?; + writeln!(out, " claw agents")?; + writeln!(out, " claw mcp")?; + writeln!(out, " claw skills")?; + writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; + writeln!(out, " claw init")?; + writeln!( + out, + " claw export [PATH] [--session SESSION] [--output PATH]" + )?; + writeln!( + out, + " Dump the latest (or named) session as markdown; writes to PATH or stdout" + )?; + writeln!(out)?; + writeln!(out, "Flags:")?; + writeln!( + out, + " --model MODEL Override the active model" + )?; + writeln!( + out, + " --output-format FORMAT Non-interactive output format: text or json" + )?; + writeln!( + out, + " --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)" + )?; + writeln!( + out, + " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" + )?; + writeln!( + out, + " --dangerously-skip-permissions Skip all permission checks" + )?; + writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?; + writeln!( + out, + " --version, -V Print version and build information locally" + )?; + writeln!(out)?; + writeln!(out, "Interactive slash commands:")?; + writeln!(out, "{}", render_slash_command_help_filtered(STUB_COMMANDS))?; + writeln!(out)?; + let resume_commands = commands::resume_supported_slash_commands() + .into_iter() + .map(|spec| match spec.argument_hint { + Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), + None => format!("/{}", spec.name), + }) + .collect::>() + .join(", "); + writeln!(out, "Resume-safe commands: {resume_commands}")?; + writeln!(out)?; + writeln!(out, "Session shortcuts:")?; + writeln!( + out, + " REPL turns auto-save to .claw/sessions/.{PRIMARY_SESSION_EXTENSION}" + )?; + writeln!( + out, + " Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session" + )?; + writeln!( + out, + " Use /session list in the REPL to browse managed sessions" + )?; + writeln!(out, "Examples:")?; + writeln!(out, " claw --model claude-opus \"summarize this repo\"")?; + writeln!( + out, + " claw --output-format json prompt \"explain src/main.rs\"" + )?; + writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?; + writeln!( + out, + " claw --allowedTools read,glob \"summarize Cargo.toml\"" + )?; + writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?; + writeln!( + out, + " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt" + )?; + writeln!(out, " claw agents")?; + writeln!(out, " claw mcp show my-server")?; + writeln!(out, " claw /skills")?; + writeln!(out, " claw doctor")?; + writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?; + writeln!( + out, + " do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub" + )?; + writeln!(out, " claw init")?; + writeln!(out, " claw export")?; + writeln!(out, " claw export conversation.md")?; + Ok(()) +} + + +pub(crate) fn 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}" + ) + } +} + +pub(crate) fn format_auto_compaction_notice(removed: usize) -> String { + format!("[auto-compacted: removed {removed} messages]") +} + +pub(crate) fn render_help_topic(topic: LocalHelpTopic) -> String { + match topic { + LocalHelpTopic::Status => "Status + Usage claw status [--output-format ] + Purpose show local workspace snapshot without entering REPL + Output model, permissions, git state, config files, and sandbox status + Formats text (default), json + Related /status · claw --resume latest /status" + .to_string(), + LocalHelpTopic::Sandbox => "Sandbox + Usage claw sandbox [--output-format ] + Purpose inspect resolved sandbox and isolation state for current directory + Output namespace, network, filesystem, and fallback details + Formats text (default), json + Related /sandbox · claw status" + .to_string(), + LocalHelpTopic::Doctor => "Doctor + Usage claw doctor [--output-format ] + Purpose diagnose local auth, config, workspace, sandbox, and build metadata + Output local-only health report; no provider request or session resume required + Formats text (default), json + Related /doctor · claw --resume latest /doctor" + .to_string(), + LocalHelpTopic::Acp => "ACP / Zed + Usage claw acp [serve] [--output-format ] + Aliases claw --acp · claw -acp + Purpose explain current editor-facing ACP/Zed launch contract without starting runtime + Status discoverability only; `serve` is a status alias and does not launch daemon yet + Formats text (default), json + Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help" + .to_string(), + LocalHelpTopic::Init => "Init + Usage claw init [--output-format ] + Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in current project + Output list of created vs skipped files (idempotent: safe to re-run) + Formats text (default), json + Related claw status · claw doctor" + .to_string(), + LocalHelpTopic::State => "State + Usage claw state [--output-format ] + Purpose read .claw/worker-state.json written by interactive REPL or one-shot prompt + Output worker id, model, permissions, session reference (text or json) + Formats text (default), json + Produces state `claw` (interactive REPL) or `claw prompt ` (one non-interactive turn) + Observes state `claw state` reads; clawhip/CI may poll this file without HTTP + Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise + Related claw status · ROADMAP #139 (this worker-concept contract)" + .to_string(), + LocalHelpTopic::Export => "Export + Usage claw export [--session ] [--output ] [--output-format ] + Purpose serialize managed session to JSON for review, transfer, or archival + Defaults --session latest (most recent managed session in .claw/sessions/) + Formats text (default), json + Related /session list · claw --resume latest" + .to_string(), + LocalHelpTopic::Version => "Version + Usage claw version [--output-format ] + Aliases claw --version · claw -V + Purpose print claw CLI version and build metadata + Formats text (default), json + Related claw doctor (full build/auth/config diagnostic)" + .to_string(), + LocalHelpTopic::SystemPrompt => "System Prompt + Usage claw system-prompt [--cwd ] [--date YYYY-MM-DD] [--output-format ] + Purpose render resolved system prompt that `claw` would send for given cwd + date + Options --cwd overrides workspace dir · --date injects deterministic date stamp + Formats text (default), json + Related claw doctor · claw dump-manifests" + .to_string(), + LocalHelpTopic::DumpManifests => "Dump Manifests + Usage claw dump-manifests [--manifests-dir ] [--output-format ] + Purpose emit every skill/agent/tool manifest resolver would load for current cwd + Options --manifests-dir scopes discovery to specific directory + Formats text (default), json + Related claw skills · claw agents · claw doctor" + .to_string(), + LocalHelpTopic::BootstrapPlan => "Bootstrap Plan + Usage claw bootstrap-plan [--output-format ] + Purpose list ordered startup phases CLI would execute before dispatch + Output phase names (text) or structured phase list (json) — primary output is plan itself + Formats text (default), json + Related claw doctor · claw status" + .to_string(), + } +} + +pub(crate) fn render_teleport_report(target: &str) -> Result> { + let cwd = env::current_dir()?; + + let file_list = Command::new("rg") + .args(["--files"]) + .current_dir(&cwd) + .output()?; + let file_matches = if file_list.status.success() { + String::from_utf8(file_list.stdout)? + .lines() + .filter(|line| line.contains(target)) + .take(10) + .map(ToOwned::to_owned) + .collect::>() + } else { + Vec::new() + }; + + let content_output = Command::new("rg") + .args(["-n", "-S", "--color", "never", target, "."]) + .current_dir(&cwd) + .output()?; + + let mut lines = vec![ + "Teleport".to_string(), + format!(" Target {target}"), + " Action search workspace files and content for target".to_string(), + ]; + if !file_matches.is_empty() { + lines.push(String::new()); + lines.push("File matches".to_string()); + lines.extend(file_matches.into_iter().map(|path| format!(" {path}"))); + } + + if content_output.status.success() { + let matches = String::from_utf8(content_output.stdout)?; + if !matches.trim().is_empty() { + lines.push(String::new()); + lines.push("Content matches".to_string()); + lines.push(truncate_for_prompt(&matches, 4_000)); + } + } + + if lines.len() == 1 { + lines.push(" Result no matches found".to_string()); + } + + Ok(lines.join("\n")) +} + +pub(crate) fn render_last_tool_debug_report(session: &Session) -> Result> { + use runtime::ContentBlock; + let last_tool_use = session + .messages + .iter() + .rev() + .find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => { + Some((id.clone(), name.clone(), input.clone())) + } + _ => None, + }) + }) + .ok_or_else(|| "no prior tool call found in session".to_string())?; + + let tool_result = session.messages.iter().rev().find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } if tool_use_id == &last_tool_use.0 => { + Some((tool_name.clone(), output.clone(), *is_error)) + } + _ => None, + }) + }); + + let mut lines = vec![ + "Debug tool call".to_string(), + " Action inspect last recorded tool call and its result".to_string(), + format!(" Tool id {}", last_tool_use.0), + format!(" Tool name {}", last_tool_use.1), + " Input".to_string(), + indent_block(&last_tool_use.2, 4), + ]; + + match tool_result { + Some((tool_name, output, is_error)) => { + lines.push(" Result".to_string()); + lines.push(format!(" name {tool_name}")); + lines.push(format!( + " status {}", + if is_error { "error" } else { "ok" } + )); + lines.push(indent_block(&output, 4)); + } + None => lines.push(" Result missing tool result".to_string()), + } + + Ok(lines.join("\n")) +} + +pub(crate) fn format_bughunter_report(scope: Option<&str>) -> String { + format!( + "Bughunter + Scope {} + Action inspect the selected code for likely bugs and correctness issues + Output findings should include file paths, severity, and suggested fixes", + scope.unwrap_or("current repository") + ) +} + +pub(crate) fn format_ultraplan_report(task: Option<&str>) -> String { + format!( + "Ultraplan + Task {} + Action break work into a multi-step execution plan + Output plan should cover goals, risks, sequencing, verification, and rollback", + task.unwrap_or("current repo work") + ) +} + +pub(crate) fn format_pr_report(branch: &str, context: Option<&str>) -> String { + format!( + "PR + Branch {branch} + Context {} + Action draft or create a pull request for current branch + Output title and markdown body suitable for GitHub", + context.unwrap_or("none") + ) +} + +pub(crate) fn format_issue_report(context: Option<&str>) -> String { + format!( + "Issue + Context {} + Action draft or create a GitHub issue from current context + Output title and markdown body suitable for GitHub", + context.unwrap_or("none") + ) +} + +pub(crate) fn format_history_timestamp(timestamp_ms: u64) -> String { + let secs = timestamp_ms / 1_000; + let subsec_ms = timestamp_ms % 1_000; + let days_since_epoch = secs / 86_400; + let seconds_of_day = secs % 86_400; + let hours = seconds_of_day / 3_600; + let minutes = (seconds_of_day % 3_600) / 60; + let seconds = seconds_of_day % 60; + + let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0)); + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z") +} + +pub(crate) fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String { + if entries.is_empty() { + return "Prompt history\n Result no prompts recorded yet".to_string(); + } + + let total = entries.len(); + let start = total.saturating_sub(limit); + let shown = &entries[start..]; + let mut lines = vec![ + "Prompt history".to_string(), + format!(" Total {total}"), + format!(" Showing {} most recent", shown.len()), + format!(" Reverse search Ctrl-R in the REPL"), + String::new(), + ]; + for (offset, entry) in shown.iter().enumerate() { + let absolute_index = start + offset + 1; + let timestamp = format_history_timestamp(entry.timestamp_ms); + let first_line = entry.text.lines().next().unwrap_or("").trim(); + let display = if first_line.chars().count() > 80 { + let truncated: String = first_line.chars().take(77).collect(); + format!("{truncated}...") + } else { + first_line.to_string() + }; + lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}")); + } + lines.join("\n") +} + +pub(crate) fn render_export_text(session: &Session) -> String { + use runtime::MessageRole; + use runtime::ContentBlock; + + 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}" + )); + } + ContentBlock::Thinking { + thinking, + signature, + } => { + lines.push(format!("[thinking signature={signature:?}] {thinking}")); + } + } + } + lines.push(String::new()); + } + lines.join("\n") +} + +pub(crate) fn render_version_report() -> String { + let git_sha = GIT_SHA.unwrap_or("unknown"); + let target = BUILD_TARGET.unwrap_or("unknown"); + format!( + "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" + ) +} + +pub(crate) fn render_session_markdown(session: &Session, session_id: &str, session_path: &Path) -> String { + use runtime::{MessageRole, ContentBlock}; + use super::tools::short_tool_id; + use super::tools::summarize_tool_payload_for_markdown; + + let mut lines = vec![ + "# Conversation Export".to_string(), + String::new(), + format!("- **Session**: `{session_id}`"), + format!("- **File**: `{}`", session_path.display()), + format!("- **Messages**: {}", session.messages.len()), + ]; + if let Some(workspace_root) = session.workspace_root() { + lines.push(format!("- **Workspace**: `{}`", workspace_root.display())); + } + if let Some(fork) = &session.fork { + let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)"); + lines.push(format!( + "- **Forked from**: `{}` (branch `{branch}`)", + fork.parent_session_id + )); + } + if let Some(compaction) = &session.compaction { + lines.push(format!( + "- **Compactions**: {} (last removed {} messages)", + compaction.count, compaction.removed_message_count + )); + } + lines.push(String::new()); + lines.push("---".to_string()); + lines.push(String::new()); + + for (index, message) in session.messages.iter().enumerate() { + let role = match message.role { + MessageRole::System => "System", + MessageRole::User => "User", + MessageRole::Assistant => "Assistant", + MessageRole::Tool => "Tool", + }; + lines.push(format!("## {}. {role}", index + 1)); + lines.push(String::new()); + for block in &message.blocks { + match block { + ContentBlock::Text { text } => { + let trimmed = text.trim_end(); + if !trimmed.is_empty() { + lines.push(trimmed.to_string()); + lines.push(String::new()); + } + } + ContentBlock::ToolUse { id, name, input } => { + lines.push(format!( + "**Tool call** `{name}` _(id `{}`)_", + short_tool_id(id) + )); + let summary = summarize_tool_payload_for_markdown(input); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => { + let status = if *is_error { "error" } else { "ok" }; + lines.push(format!( + "**Tool result** `{tool_name}` _(id `{}`, {status})_", + short_tool_id(tool_use_id) + )); + let summary = summarize_tool_payload_for_markdown(output); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + ContentBlock::Thinking { + thinking, + signature: _, + } => { + lines.push(format!("**Thinking**")); + let summary = summarize_tool_payload_for_markdown(thinking); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + } + } + if let Some(usage) = message.usage { + lines.push(format!( + "_tokens: in={} out={} cache_create={} cache_read={}_", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + )); + lines.push(String::new()); + } + } + lines.join("\n") +} diff --git a/rust/crates/rusty-claude-cli/src/formatting/tools.rs b/rust/crates/rusty-claude-cli/src/formatting/tools.rs new file mode 100644 index 00000000..14e2e6ba --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/formatting/tools.rs @@ -0,0 +1,492 @@ +use std::io::{self, Write}; +use serde_json::json; +use runtime::{RuntimeError, AssistantEvent}; +use api::OutputContentBlock; +use crate::render::TerminalRenderer; +use super::helpers::*; +use super::{ + DISPLAY_TRUNCATION_NOTICE, READ_DISPLAY_MAX_CHARS, READ_DISPLAY_MAX_LINES, + SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT, TOOL_OUTPUT_DISPLAY_MAX_CHARS, + TOOL_OUTPUT_DISPLAY_MAX_LINES, +}; + +pub(crate) fn short_tool_id(id: &str) -> String { + let char_count = id.chars().count(); + if char_count <= 12 { + return id.to_string(); + } + let prefix: String = id.chars().take(12).collect(); + format!("{prefix}…") +} + +pub(crate) fn summarize_tool_payload_for_markdown(payload: &str) -> String { + let compact = match serde_json::from_str::(payload) { + Ok(value) => value.to_string(), + Err(_) => payload.split_whitespace().collect::>().join(" "), + }; + if compact.is_empty() { + return String::new(); + } + truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT) +} + +pub(crate) fn format_tool_call_start(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + + let detail = match name { + "bash" | "Bash" => format_bash_call(&parsed), + "read_file" | "Read" => { + let path = extract_tool_path(&parsed); + format!("\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" + ) +} + +pub(crate) 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), + } +} + +pub(crate) fn format_search_start(label: &str, parsed: &serde_json::Value) -> String { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m") +} + +pub(crate) fn format_patch_preview(old_value: &str, new_value: &str) -> Option { + if old_value.is_empty() && new_value.is_empty() { + return None; + } + Some(format!( + "\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) + )) +} + +pub(crate) fn format_bash_call(parsed: &serde_json::Value) -> String { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + String::new() + } else { + format!( + "\x1b[48;5;236;38;5;255m $ {} \x1b[0m", + truncate_for_summary(command, 160) + ) + } +} + +pub(crate) 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") +} + +pub(crate) fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { + let file = parsed.get("file").unwrap_or(parsed); + let path = extract_tool_path(file); + let start_line = file + .get("startLine") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1); + let num_lines = file + .get("numLines") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let total_lines = file + .get("totalLines") + .and_then(serde_json::Value::as_u64) + .unwrap_or(num_lines); + let content = file + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let end_line = start_line.saturating_add(num_lines.saturating_sub(1)); + + format!( + "{icon} \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) + ) +} + +pub(crate) fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { + let path = extract_tool_path(parsed); + let kind = parsed + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("write"); + let line_count = parsed + .get("content") + .and_then(|value| value.as_str()) + .map_or(0, |content| content.lines().count()); + format!( + "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", + if kind == "create" { "Wrote" } else { "Updated" }, + ) +} + +pub(crate) fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option { + let hunks = parsed.get("structuredPatch")?.as_array()?; + let mut preview = Vec::new(); + for hunk in hunks.iter().take(2) { + let lines = hunk.get("lines")?.as_array()?; + for line in lines.iter().filter_map(|value| value.as_str()).take(6) { + match line.chars().next() { + Some('+') => preview.push(format!("\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")) + } +} + +pub(crate) fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { + let path = extract_tool_path(parsed); + let suffix = if parsed + .get("replaceAll") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + " (replace all)" + } else { + "" + }; + let preview = format_structured_patch_preview(parsed).or_else(|| { + let old_value = parsed + .get("oldString") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let new_value = parsed + .get("newString") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + format_patch_preview(old_value, new_value) + }); + + match preview { + Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"), + None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"), + } +} + +pub(crate) fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { + let num_files = parsed + .get("numFiles") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let filenames = parsed + .get("filenames") + .and_then(|value| value.as_array()) + .map(|files| { + files + .iter() + .filter_map(|value| value.as_str()) + .take(8) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if filenames.is_empty() { + format!("{icon} \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}") + } +} + +pub(crate) fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { + let num_matches = parsed + .get("numMatches") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let num_files = parsed + .get("numFiles") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let content = parsed + .get("content") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let filenames = parsed + .get("filenames") + .and_then(|value| value.as_array()) + .map(|files| { + files + .iter() + .filter_map(|value| value.as_str()) + .take(8) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + let summary = format!( + "{icon} \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 + } +} + +pub(crate) fn format_generic_tool_result(icon: &str, name: &str, parsed: &serde_json::Value) -> String { + let rendered_output = match parsed { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Null => String::new(), + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string()) + } + _ => parsed.to_string(), + }; + let 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}") + } +} + +pub(crate) fn render_thinking_block_summary( + out: &mut (impl Write + ?Sized), + char_count: Option, + redacted: bool, +) -> Result<(), RuntimeError> { + let summary = if redacted { + "\n▶ Thinking block hidden by provider\n".to_string() + } else if let Some(char_count) = char_count { + format!("\n▶ Thinking ({char_count} chars hidden)\n") + } else { + "\n▶ Thinking hidden\n".to_string() + }; + write!(out, "{summary}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string())) +} + +pub(crate) fn push_output_block( + block: OutputContentBlock, + out: &mut (impl Write + ?Sized), + events: &mut Vec, + pending_tool: &mut Option<(String, String, String)>, + streaming_tool_input: bool, + block_has_thinking_summary: &mut bool, +) -> Result<(), RuntimeError> { + match block { + OutputContentBlock::Text { text } => { + if !text.is_empty() { + let rendered = TerminalRenderer::new().markdown_to_ansi(&text); + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + events.push(AssistantEvent::TextDelta(text)); + } + } + OutputContentBlock::ToolUse { id, name, input } => { + // During streaming, the initial content_block_start has an empty input ({}). + // The real input arrives via input_json_delta events. In + // non-streaming responses, preserve a legitimate empty object. + let initial_input = if streaming_tool_input + && input.is_object() + && input.as_object().is_some_and(serde_json::Map::is_empty) + { + String::new() + } else { + input.to_string() + }; + *pending_tool = Some((id, name, initial_input)); + } + OutputContentBlock::Thinking { thinking, .. } => { + render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; + *block_has_thinking_summary = true; + } + OutputContentBlock::RedactedThinking { .. } => { + render_thinking_block_summary(out, None, true)?; + *block_has_thinking_summary = true; + } + } + Ok(()) +} + +pub(crate) fn response_to_events( + response: api::MessageResponse, + out: &mut (impl Write + ?Sized), +) -> Result, RuntimeError> { + let mut events = Vec::new(); + let mut pending_tool = None; + + for block in response.content { + let mut block_has_thinking_summary = false; + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + false, + &mut block_has_thinking_summary, + )?; + if let Some((id, name, input)) = pending_tool.take() { + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + + events.push(AssistantEvent::Usage(response.usage.token_usage())); + events.push(AssistantEvent::MessageStop); + Ok(events) +} + +pub(crate) fn push_prompt_cache_record( + client: &api::ProviderClient, + events: &mut Vec, +) { + if let Some(record) = client.take_last_prompt_cache_record() { + if let Some(event) = prompt_cache_record_to_runtime_event(record) { + events.push(AssistantEvent::PromptCache(event)); + } + } +} + +pub(crate) fn prompt_cache_record_to_runtime_event( + record: api::PromptCacheRecord, +) -> Option { + let cache_break = record.cache_break?; + Some(runtime::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, + }) +} + diff --git a/rust/crates/rusty-claude-cli/src/init.rs b/rust/crates/rusty-claude-cli/src/init.rs index 39d5a556..7aeff9fe 100644 --- a/rust/crates/rusty-claude-cli/src/init.rs +++ b/rust/crates/rusty-claude-cli/src/init.rs @@ -1,6 +1,8 @@ use std::fs; use std::path::{Path, PathBuf}; +use crate::args::CliOutputFormat; + const STARTER_CLAW_JSON: &str = concat!( "{\n", " \"permissions\": {\n", @@ -162,6 +164,38 @@ pub(crate) fn initialize_repo(cwd: &Path) -> Result Result> { + let cwd = std::env::current_dir()?; + Ok(initialize_repo(&cwd)?.render()) +} + +pub(crate) fn run_init(output_format: CliOutputFormat) -> Result<(), Box> { + let cwd = std::env::current_dir()?; + let report = initialize_repo(&cwd)?; + let message = report.render(); + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&init_json_value(&report, &message))? + ), + } + Ok(()) +} + +pub(crate) fn init_json_value(report: &InitReport, message: &str) -> serde_json::Value { + serde_json::json!({ + "kind": "init", + "project_root": report.project_root.display().to_string(), + "created": report.artifacts_with_status(InitStatus::Created), + "updated": report.artifacts_with_status(InitStatus::Updated), + "skipped": report.artifacts_with_status(InitStatus::Skipped), + "artifacts": report.artifact_json_entries(), + "next_step": InitReport::NEXT_STEP, + "message": message, + }) +} + fn ensure_dir(path: &Path) -> Result { if path.is_dir() { return Ok(InitStatus::Skipped); diff --git a/rust/crates/rusty-claude-cli/src/live_cli.rs b/rust/crates/rusty-claude-cli/src/live_cli.rs new file mode 100644 index 00000000..a5245672 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/live_cli.rs @@ -0,0 +1,518 @@ +use crate::*; +use std::env; +use std::io::{self, IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; +use serde_json::json; +use crate::render::*; + +#[derive(Debug, Clone)] +pub(crate) struct PromptHistoryEntry { + pub(crate) timestamp_ms: u64, + pub(crate) text: String, +} + +#[allow(clippy::needless_pass_by_value)] +pub(crate) struct LiveCli { + pub(crate) model: String, + pub(crate) allowed_tools: Option, + pub(crate) permission_mode: PermissionMode, + pub(crate) system_prompt: Vec, + pub(crate) runtime: BuiltRuntime, + pub(crate) session: SessionHandle, + pub(crate) prompt_history: Vec, +} + +impl LiveCli { + pub(crate) fn new( + model: String, + enable_tools: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + ) -> Result> { + let system_prompt = build_system_prompt()?; + let session_state = new_cli_session()?; + let session = create_managed_session_handle(&session_state.session_id)?; + let runtime = build_runtime( + session_state.with_persistence_path(session.path.clone()), + &session.id, + model.clone(), + system_prompt.clone(), + enable_tools, + true, + allowed_tools.clone(), + permission_mode, + None, + )?; + let cli = Self { + model, + allowed_tools, + permission_mode, + system_prompt, + runtime, + session, + prompt_history: Vec::new(), + }; + cli.persist_session()?; + Ok(cli) + } + + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + if let Some(rt) = self.runtime.runtime.as_mut() { + rt.api_client_mut().set_reasoning_effort(effort); + } + } + + pub(crate) fn startup_banner(&self) -> String { + 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, + ) + } + + pub(crate) fn repl_completion_candidates( + &self, + ) -> Result, Box> { + Ok(slash_command_completion_candidates_with_sessions( + &self.model, + Some(&self.session.id), + list_managed_sessions()? + .into_iter() + .map(|session| session.id) + .collect(), + )) + } + + pub(crate) fn prepare_turn_runtime( + &self, + emit_output: bool, + ) -> Result<(BuiltRuntime, HookAbortMonitor), Box> { + let hook_abort_signal = runtime::HookAbortSignal::new(); + let runtime = build_runtime( + self.runtime.session().clone(), + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + true, + emit_output, + self.allowed_tools.clone(), + self.permission_mode, + None, + )? + .with_hook_abort_signal(hook_abort_signal.clone()); + let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); + + Ok((runtime, hook_abort_monitor)) + } + + pub(crate) fn replace_runtime( + &mut self, + runtime: BuiltRuntime, + ) -> Result<(), Box> { + self.runtime.shutdown_plugins()?; + self.runtime = runtime; + Ok(()) + } + + pub(crate) fn run_turn(&mut self, input: &str) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; + let mut spinner = Spinner::new(); + let mut stdout = io::stdout(); + spinner.tick( + "🦀 Thinking...", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + match result { + Ok(summary) => { + self.replace_runtime(runtime)?; + spinner.finish( + "✨ Done", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + println!(); + if let Some(event) = summary.auto_compaction { + println!( + "{}", + format_auto_compaction_notice(event.removed_message_count) + ); + } + self.persist_session()?; + Ok(()) + } + Err(error) => { + runtime.shutdown_plugins()?; + spinner.fail( + "❌ Request failed", + TerminalRenderer::new().color_theme(), + &mut stdout, + )?; + Err(Box::new(error)) + } + } + } + + pub(crate) fn run_turn_with_output( + &mut self, + input: &str, + output_format: CliOutputFormat, + compact: bool, + ) -> Result<(), Box> { + match output_format { + CliOutputFormat::Json if compact => self.run_prompt_compact_json(input), + CliOutputFormat::Text if compact => self.run_prompt_compact(input), + CliOutputFormat::Text => self.run_turn(input), + CliOutputFormat::Json => self.run_prompt_json(input), + } + } + + pub(crate) fn run_prompt_compact( + &mut self, + input: &str, + ) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + let summary = result?; + self.replace_runtime(runtime)?; + self.persist_session()?; + let final_text = final_assistant_text(&summary); + println!("{final_text}"); + Ok(()) + } + + pub(crate) fn run_prompt_compact_json( + &mut self, + input: &str, + ) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + let summary = result?; + self.replace_runtime(runtime)?; + self.persist_session()?; + println!( + "{}", + serde_json::json!({ + "message": final_assistant_text(&summary), + "compact": true, + "model": self.model, + "usage": { + "input_tokens": summary.usage.input_tokens, + "output_tokens": summary.usage.output_tokens, + "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, + "cache_read_input_tokens": summary.usage.cache_read_input_tokens, + }, + }) + ); + Ok(()) + } + + pub(crate) fn run_prompt_json( + &mut self, + input: &str, + ) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + let summary = result?; + self.replace_runtime(runtime)?; + self.persist_session()?; + println!( + "{}", + serde_json::json!({ + "message": final_assistant_text(&summary), + "model": self.model, + "iterations": summary.iterations, + "auto_compaction": summary.auto_compaction.map(|event| serde_json::json!({ + "removed_messages": event.removed_message_count, + "notice": format_auto_compaction_notice(event.removed_message_count), + })), + "tool_uses": collect_tool_uses(&summary), + "tool_results": collect_tool_results(&summary), + "prompt_cache_events": collect_prompt_cache_events(&summary), + "usage": { + "input_tokens": summary.usage.input_tokens, + "output_tokens": summary.usage.output_tokens, + "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, + "cache_read_input_tokens": summary.usage.cache_read_input_tokens, + }, + "estimated_cost": format_usd( + summary.usage.estimate_cost_usd_with_pricing( + pricing_for_model(&self.model) + .unwrap_or_else(runtime::ModelPricing::default_sonnet_tier) + ).total_cost_usd() + ) + }) + ); + Ok(()) + } + + pub(crate) fn persist_session(&self) -> Result<(), Box> { + self.runtime.session().save_to_path(&self.session.path)?; + Ok(()) + } + + pub(crate) fn record_prompt_history(&mut self, prompt: &str) { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map_or(self.runtime.session().updated_at_ms, |duration| { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) + }); + let entry = PromptHistoryEntry { + timestamp_ms, + text: prompt.to_string(), + }; + self.prompt_history.push(entry); + if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) { + eprintln!("warning: failed to persist prompt history: {error}"); + } + } + + pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box> { + let runtime = build_runtime( + self.runtime.session().clone(), + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.persist_session() + } + + pub(crate) fn run_internal_prompt_text_with_progress( + &self, + prompt: &str, + enable_tools: bool, + progress: Option, + ) -> Result> { + let session = self.runtime.session().clone(); + let mut runtime = build_runtime( + session, + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + enable_tools, + false, + self.allowed_tools.clone(), + self.permission_mode, + progress, + )?; + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; + let text = final_assistant_text(&summary).trim().to_string(); + runtime.shutdown_plugins()?; + Ok(text) + } + + pub(crate) fn run_internal_prompt_text( + &self, + prompt: &str, + enable_tools: bool, + ) -> Result> { + self.run_internal_prompt_text_with_progress(prompt, enable_tools, None) + } + + pub(crate) fn run_stale_base_preflight(flag_value: Option<&str>) { + let Ok(cwd) = env::current_dir() else { + return; + }; + let source = resolve_expected_base(flag_value, &cwd); + let state = check_base_commit(&cwd, source.as_ref()); + if let Some(warning) = format_stale_base_warning(&state) { + eprintln!("{warning}"); + } + } +} + +pub(crate) fn run_repl( + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, +) -> Result<(), Box> { + enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; + run_stale_base_preflight(base_commit.as_deref()); + let resolved_model = resolve_repl_model(model); + let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; + cli.set_reasoning_effort(reasoning_effort); + let mut editor = + input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); + println!("{}", cli.startup_banner()); + println!("{}", format_connected_line(&cli.model)); + + loop { + editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); + match editor.read_line()? { + input::ReadOutcome::Submit(input) => { + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if matches!(trimmed.as_str(), "/exit" | "/quit") { + cli.persist_session()?; + break; + } + match SlashCommand::parse(&trimmed) { + Ok(Some(command)) => { + if cli.handle_repl_command(command)? { + cli.persist_session()?; + } + continue; + } + Ok(None) => {} + Err(error) => { + eprintln!("{error}"); + continue; + } + } + // Bare-word skill dispatch: if the first token of the input + // matches a known skill name, invoke it as `/skills ` + // rather than forwarding raw text to the LLM (ROADMAP #36). + let cwd = std::env::current_dir().unwrap_or_default(); + if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) { + editor.push_history(input); + cli.record_prompt_history(&trimmed); + cli.run_turn(&prompt)?; + continue; + } + editor.push_history(input); + cli.record_prompt_history(&trimmed); + cli.run_turn(&trimmed)?; + } + input::ReadOutcome::Cancel => {} + input::ReadOutcome::Exit => { + cli.persist_session()?; + break; + } + } + } + + Ok(()) +} + +pub(crate) fn detect_broad_cwd() -> Option { + let Ok(cwd) = env::current_dir() else { + return None; + }; + let is_home = env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .is_some_and(|h| Path::new(&h) == cwd); + let is_root = cwd.parent().is_none(); + if is_home || is_root { + Some(cwd) + } else { + None + } +} + +pub(crate) fn enforce_broad_cwd_policy( + allow_broad_cwd: bool, + output_format: CliOutputFormat, +) -> Result<(), Box> { + if allow_broad_cwd { + return Ok(()); + } + let Some(cwd) = detect_broad_cwd() else { + return Ok(()); + }; + + let is_interactive = io::stdin().is_terminal(); + + if is_interactive { + // Interactive mode: print warning and ask for confirmation + eprintln!( + "Warning: claw is running from a very broad directory ({}).\n\ + The agent can read and search everything under this path.\n\ + Consider running from inside your project: cd /path/to/project && claw", + cwd.display() + ); + eprint!("Continue anyway? [y/N]: "); + io::stderr().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let trimmed = input.trim().to_lowercase(); + if trimmed != "y" && trimmed != "yes" { + eprintln!("Aborted."); + std::process::exit(0); + } + Ok(()) + } else { + // Non-interactive mode: exit with error (JSON or text) + let message = format!( + "claw is running from a very broad directory ({}). \ + The agent can read and search everything under this path. \ + Use --allow-broad-cwd to proceed anyway, \ + or run from inside your project: cd /path/to/project && claw", + cwd.display() + ); + match output_format { + CliOutputFormat::Json => { + eprintln!( + "{}", + serde_json::json!({ + "type": "error", + "error": message, + }) + ); + } + CliOutputFormat::Text => { + eprintln!("error: {message}"); + } + } + std::process::exit(1); + } +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index b308d7fb..aadacc42 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -10,98 +10,67 @@ clippy::result_large_err )] mod args; +mod config; mod constants; +mod diff; mod doctor; mod error; +mod export; mod formatting; mod status; mod session; mod init; mod input; +mod live_cli; mod model; mod models; mod permission; mod api_client; mod progress; -mod repl; mod render; mod tool_executor; +mod runtime_builder; +mod repl_commands; pub(crate) use api_client::*; pub(crate) use args::*; +pub(crate) use config::*; pub(crate) use constants::*; +pub(crate) use diff::*; pub(crate) use doctor::*; pub(crate) use error::*; +pub(crate) use export::*; +pub(crate) use live_cli::*; +pub(crate) use repl_commands::*; pub(crate) use formatting::*; pub(crate) use status::*; pub(crate) use session::*; +pub(crate) use init::*; pub(crate) use model::*; pub(crate) use models::*; pub(crate) use permission::*; pub(crate) use progress::*; pub(crate) use tool_executor::*; -use std::collections::BTreeSet; +pub(crate) use runtime_builder::*; +pub(crate) use render::*; +pub(crate) use commands::*; +pub(crate) use runtime::*; + use std::env; use std::fs; use std::io::{self, IsTerminal, Read, Write}; -use std::net::TcpListener; -use std::ops::{Deref, DerefMut}; use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; -use std::sync::{Arc, Mutex}; -use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant, UNIX_EPOCH}; -use api::{ - detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource, - ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, - OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, ProviderKind, - StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, -}; - -use commands::{ - classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json, - handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command, - handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help, - render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands, - slash_command_specs, validate_slash_command_input, SkillSlashDispatch, SlashCommand, -}; -use compat_harness::{extract_manifest, UpstreamPaths}; -use init::initialize_repo; -use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; -use render::{MarkdownStreamState, Spinner, TerminalRenderer}; -use runtime::{ - check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials, - load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status, - ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, - ContentBlock, ConversationMessage, ConversationRuntime, McpServer, McpServerManager, - McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, PermissionPolicy, - ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, Session, TokenUsage, - ToolError, ToolExecutor, UsageTracker, -}; -use serde::Deserialize; -use serde_json::{json, Map, Value}; -use tools::{ - execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput, -}; - - -type RuntimePluginStateBuildOutput = ( - Option>>, - Vec, -); +use commands::SlashCommand; +use runtime::{PermissionMode, Session}; fn main() { if let Err(error) = run() { - // 出错逻辑 let message = format!( "{:?}\n\nStack trace:\n{}", error, std::backtrace::Backtrace::force_capture() ); - // When --output-format json is active, emit errors as JSON so downstream - // tools can parse failures the same way they parse successes (ROADMAP #42). let argv: Vec = std::env::args().collect(); let json_output = argv .windows(2) @@ -109,8 +78,6 @@ fn main() { || argv.iter().any(|a| a == "--output-format=json"); if json_output { - // #77: classify error by prefix so downstream claws can route without - // regex-scraping the prose. Split short-reason from hint-runbook. let kind = classify_error_kind(&message); let (short_reason, hint) = split_error_hint(&message); eprintln!( @@ -123,19 +90,14 @@ fn main() { }) ); } else { - // #156: Add machine-readable error kind to text output so stderr observers - // don't need to regex-scrape the prose. let kind = classify_error_kind(&message); if message.contains("`claw --help`") { eprintln!( - "[error-kind: {kind}] -error: {message}" + "[error-kind: {kind}]\nerror: {message}" ); } else { eprintln!( - "[error-kind: {kind}] -error: {message} -Run `claw --help` for usage." + "[error-kind: {kind}]\nerror: {message}\nRun `claw --help` for usage." ); } } @@ -143,60 +105,6 @@ Run `claw --help` for usage." } } -/// #77: Classify a stringified error message into a machine-readable kind. -/// -/// Returns a snake_case token that downstream consumers can switch on instead -/// of regex-scraping the prose. The classification is best-effort prefix/keyword -/// matching against the error messages produced throughout the CLI surface. -fn classify_error_kind(message: &str) -> &'static str { - // Check specific patterns first (more specific before generic) - if message.contains("missing Anthropic credentials") { - "missing_credentials" - } else if message.contains("Manifest source files are missing") { - "missing_manifests" - } else if message.contains("no worker state file found") { - "missing_worker_state" - } else if message.contains("session not found") { - "session_not_found" - } else if message.contains("failed to restore session") { - "session_load_failed" - } else if message.contains("no managed sessions found") { - "no_managed_sessions" - } else if message.contains("unrecognized argument") || message.contains("unknown option") { - "cli_parse" - } else if message.contains("invalid model syntax") { - "invalid_model_syntax" - } else if message.contains("is not yet implemented") { - "unsupported_command" - } else if message.contains("unsupported resumed command") { - "unsupported_resumed_command" - } else if message.contains("confirmation required") { - "confirmation_required" - } else if message.contains("api failed") || message.contains("api returned") { - "api_http_error" - } else { - "unknown" - } -} - -/// #77: Split a multi-line error message into (short_reason, optional_hint). -/// -/// The short_reason is the first line (up to the first newline), and the hint -/// is the remaining text or `None` if there's no newline. This prevents the -/// runbook prose from being stuffed into the `error` field that downstream -/// parsers expect to be the short reason alone. -fn split_error_hint(message: &str) -> (String, Option) { - match message.split_once('\n') { - Some((short, hint)) => (short.to_string(), Some(hint.trim().to_string())), - None => (message.to_string(), None), - } -} - -/// Read piped stdin content when stdin is not a terminal. -/// -/// Returns `None` when stdin is attached to a terminal (interactive REPL use), -/// when reading fails, or when the piped content is empty after trimming. -/// Returns `Some(raw_content)` when a pipe delivered non-empty content. fn read_piped_stdin() -> Option { if io::stdin().is_terminal() { return None; @@ -211,12 +119,6 @@ fn read_piped_stdin() -> Option { Some(buffer) } -/// Merge a piped stdin payload into a prompt argument. -/// -/// When `stdin_content` is `None` or empty after trimming, the prompt is -/// returned unchanged. Otherwise the trimmed stdin content is appended to the -/// prompt separated by a blank line so the model sees the prompt first and the -/// piped context immediately after it. fn merge_prompt_with_stdin(prompt: &str, stdin_content: Option<&str>) -> String { let Some(raw) = stdin_content else { return prompt.to_string(); @@ -235,15 +137,12 @@ fn run() -> Result<(), Box> { let args: Vec = env::args().skip(1).collect(); match parse_args(&args)? { - // 导出所有工具/插件的 manifest 清单文件到指定目录 CliAction::DumpManifests { output_format, manifests_dir, } => dump_manifests(manifests_dir.as_deref(), output_format)?, - // 打印启动引导计划(初始化阶段的工作流规划) CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?, - // 查看可用的 Agent 信息列表 CliAction::Agents { args, output_format, @@ -256,27 +155,22 @@ fn run() -> Result<(), Box> { args, output_format, } => LiveCli::print_skills(args.as_deref(), output_format)?, - // 查看和管理插件列表(安装/卸载/详情) CliAction::Plugins { action, target, output_format, } => LiveCli::print_plugins(action.as_deref(), target.as_deref(), output_format)?, - // 打印当前会话的系统提示词完整内容,用于调试和审查 CliAction::PrintSystemPrompt { cwd, date, output_format, - } => print_system_prompt(cwd, date, output_format)?, - // 输出版本号 + } => print_system_prompt(Some(cwd), Some(date), output_format)?, CliAction::Version { output_format } => print_version(output_format)?, - // 恢复一个已保存的历史会话,可追加命令继续执行 CliAction::ResumeSession { session_path, commands, output_format, } => resume_session(&session_path, &commands, output_format), - // 输出当前工作区状态快照(模型、权限、分支、工具白名单等) CliAction::Status { model, model_flag_raw, @@ -290,9 +184,7 @@ fn run() -> Result<(), Box> { output_format, allowed_tools.as_ref(), )?, - // 输出沙箱隔离环境的状态信息 CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?, - // 单次提示模式:执行一轮对话后退出,适用于脚本/管道调用 CliAction::Prompt { prompt, model, @@ -307,11 +199,6 @@ fn run() -> Result<(), Box> { enforce_broad_cwd_policy(allow_broad_cwd, output_format)?; run_stale_base_preflight(base_commit.as_deref()); - // Only consume piped stdin as prompt context when the permission - // mode is fully unattended. In modes where the permission - // prompter may invoke CliPermissionPrompter::decide(), stdin - // must remain available for interactive approval; otherwise the - // prompter's read_line() would hit EOF and deny every request. let stdin_context = if matches!(permission_mode, PermissionMode::DangerFullAccess) { read_piped_stdin() } else { @@ -323,15 +210,10 @@ fn run() -> Result<(), Box> { cli.set_reasoning_effort(reasoning_effort); cli.run_turn_with_output(&effective_prompt, output_format, compact)?; } - // 环境诊断检查,验证配置和依赖是否正常 CliAction::Doctor { output_format } => run_doctor(output_format)?, - // 输出 Agent Control Protocol 状态信息 CliAction::Acp { output_format } => print_acp_status(output_format)?, - // 输出后台 worker 的运行状态 CliAction::State { output_format } => run_worker_state(output_format)?, - // 初始化工作区配置(生成 .claw.json 等) CliAction::Init { output_format } => run_init(output_format)?, - // 查看当前生效的配置信息(可指定 section 过滤),纯本地只读操作 CliAction::Config { section, output_format, @@ -346,7 +228,6 @@ fn run() -> Result<(), Box> { ); } }, - // 查看 git 工作区变更差异摘要(未提交的改动),纯本地只读操作 CliAction::Diff { output_format } => match output_format { CliOutputFormat::Text => { println!("{}", render_diff_report()?); @@ -359,14 +240,12 @@ fn run() -> Result<(), Box> { ); } }, - // 导出指定会话记录到文件(支持 JSON/Markdown 等格式) CliAction::Export { session_reference, output_path, output_format, } => run_export(&session_reference, output_path.as_deref(), output_format)?, - // 交互式 REPL 模式:进入持续对话循环,直到用户主动退出 CliAction::Repl { model, allowed_tools, @@ -382,265 +261,18 @@ fn run() -> Result<(), Box> { reasoning_effort, allow_broad_cwd, )?, - // 打印特定子命令的帮助详情(如 claw status --help) CliAction::HelpTopic(topic) => print_help_topic(topic), - // 打印通用帮助信息 CliAction::Help { output_format } => print_help(output_format)?, } Ok(()) } -fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box> { - let cwd = env::current_dir()?; - let state_path = cwd.join(".claw").join("worker-state.json"); - if !state_path.exists() { - // #139: this error used to say "run a worker first" without telling - // callers how to run one. "worker" is an internal concept (there is - // no `claw worker` subcommand), so claws/CI had no discoverable path - // from the error to a fix. Emit an actionable, structured error that - // names the two concrete commands that produce worker state. - // - // Format in both text and JSON modes is stable so scripts can match: - // error: no worker state file found at - // Hint: worker state is written by the interactive REPL or a non-interactive prompt. - // Run: claw # start the REPL (writes state on first turn) - // Or: claw prompt # run one non-interactive turn - // Then rerun: claw state [--output-format json] - return Err(format!( - "no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt # run one non-interactive turn\n Then rerun: claw state [--output-format json]", - path = state_path.display() - ) - .into()); - } - let raw = std::fs::read_to_string(&state_path)?; - match output_format { - CliOutputFormat::Text => println!("{raw}"), - CliOutputFormat::Json => { - // Validate it parses as JSON before re-emitting - let _: serde_json::Value = serde_json::from_str(&raw)?; - println!("{raw}"); - } - } - Ok(()) -} - -/// the same surface the in-process agent loop uses. -fn run_mcp_serve() -> Result<(), Box> { - let tools = mvp_tool_specs() - .into_iter() - .map(|spec| McpTool { - name: spec.name.to_string(), - description: Some(spec.description.to_string()), - input_schema: Some(spec.input_schema), - annotations: None, - meta: None, - }) - .collect(); - - let spec = McpServerSpec { - server_name: "claw".to_string(), - server_version: VERSION.to_string(), - tools, - tool_handler: Box::new(execute_tool), - }; - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - runtime.block_on(async move { - let mut server = McpServer::new(spec); - server.run().await - })?; - Ok(()) -} - -#[allow(clippy::too_many_lines)] - - -pub(crate) fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { - matches!( - SlashCommand::parse(current_command), - Ok(Some(SlashCommand::Export { path: None })) - ) && !looks_like_slash_command_token(token) -} - -pub(crate) fn looks_like_slash_command_token(token: &str) -> bool { - let trimmed = token.trim_start(); - let Some(name) = trimmed.strip_prefix('/').and_then(|value| { - value - .split_whitespace() - .next() - .map(str::trim) - .filter(|value| !value.is_empty()) - }) else { - return false; - }; - - slash_command_specs() - .iter() - .any(|spec| spec.name == name || spec.aliases.contains(&name)) -} - -fn dump_manifests( - manifests_dir: Option<&Path>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - dump_manifests_at_path(&workspace_dir, manifests_dir, output_format) -} - -const DUMP_MANIFESTS_OVERRIDE_HINT: &str = - "Hint: set CLAUDE_CODE_UPSTREAM=/path/to/upstream or pass `claw dump-manifests --manifests-dir /path/to/upstream`."; - -// Internal function for testing that accepts a workspace directory path. -fn dump_manifests_at_path( - workspace_dir: &std::path::Path, - manifests_dir: Option<&Path>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let paths = if let Some(dir) = manifests_dir { - let resolved = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); - UpstreamPaths::from_repo_root(resolved) - } else { - // Surface the resolved path in the error so users can diagnose missing - // manifest files without guessing what path the binary expected. - let resolved = workspace_dir - .canonicalize() - .unwrap_or_else(|_| workspace_dir.to_path_buf()); - UpstreamPaths::from_workspace_dir(&resolved) - }; - - let source_root = paths.repo_root(); - if !source_root.exists() { - return Err(format!( - "Manifest source directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - source_root.display(), - ) - .into()); - } - - let required_paths = [ - ("src/commands.ts", paths.commands_path()), - ("src/tools.ts", paths.tools_path()), - ("src/entrypoints/cli.tsx", paths.cli_path()), - ]; - let missing = required_paths - .iter() - .filter_map(|(label, path)| (!path.is_file()).then_some(*label)) - .collect::>(); - if !missing.is_empty() { - return Err(format!( - "Manifest source files are missing.\n repo root: {}\n missing: {}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - source_root.display(), - missing.join(", "), - ) - .into()); - } - - match extract_manifest(&paths) { - Ok(manifest) => { - match output_format { - CliOutputFormat::Text => { - println!("commands: {}", manifest.commands.entries().len()); - println!("tools: {}", manifest.tools.entries().len()); - println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); - } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "dump-manifests", - "commands": manifest.commands.entries().len(), - "tools": manifest.tools.entries().len(), - "bootstrap_phases": manifest.bootstrap.phases().len(), - }))? - ), - } - Ok(()) - } - Err(error) => Err(format!( - "failed to extract manifests: {error}\n looked in: {path}\n {DUMP_MANIFESTS_OVERRIDE_HINT}", - path = paths.repo_root().display() - ) - .into()), - } -} - -fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box> { - let phases = runtime::BootstrapPlan::claude_code_default() - .phases() - .iter() - .map(|phase| format!("{phase:?}")) - .collect::>(); - match output_format { - CliOutputFormat::Text => { - for phase in &phases { - println!("- {phase}"); - } - } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "bootstrap-plan", - "phases": phases, - }))? - ), - } - Ok(()) -} - -fn print_system_prompt( - cwd: PathBuf, - date: String, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?; - let message = sections.join( - " - -", - ); - match output_format { - CliOutputFormat::Text => println!("{message}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "system-prompt", - "message": message, - "sections": sections, - }))? - ), - } - Ok(()) -} - -fn print_version(output_format: CliOutputFormat) -> Result<(), Box> { - match output_format { - CliOutputFormat::Text => println!("{}", render_version_report()), - CliOutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&version_json_value())?); - } - } - Ok(()) -} - -fn version_json_value() -> serde_json::Value { - json!({ - "kind": "version", - "message": render_version_report(), - "version": VERSION, - "git_sha": GIT_SHA, - "target": BUILD_TARGET, - }) -} - -#[allow(clippy::too_many_lines)] fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) { let session_reference = session_path.display().to_string(); let (handle, session) = match load_session_reference(&session_reference) { Ok(loaded) => loaded, Err(error) => { if output_format == CliOutputFormat::Json { - // #77: classify session load errors for downstream consumers let full_message = format!("failed to restore session: {error}"); let kind = classify_error_kind(&full_message); let (short_reason, hint) = split_error_hint(&full_message); @@ -684,11 +316,6 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu let mut session = session; for raw_command in commands { - // Intercept spec commands that have no parse arm before calling - // SlashCommand::parse — they return Err(SlashCommandParseError) which - // formats as the confusing circular "Did you mean /X?" message. - // STUB_COMMANDS covers both completions-filtered stubs and parse-less - // spec entries; treat both as unsupported in resume mode. { let cmd_root = raw_command .trim_start_matches('/') @@ -786,7321 +413,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu } } -#[derive(Debug, Clone)] -struct ResumeCommandOutcome { - session: Session, - message: Option, - json: Option, -} - -#[derive(Debug, Clone)] -pub(crate) struct StatusContext { - pub(crate) cwd: PathBuf, - pub(crate) session_path: Option, - pub(crate) loaded_config_files: usize, - pub(crate) discovered_config_files: usize, - pub(crate) memory_file_count: usize, - pub(crate) project_root: Option, - pub(crate) git_branch: Option, - pub(crate) git_summary: GitWorkspaceSummary, - pub(crate) session_lifecycle: SessionLifecycleSummary, - pub(crate) sandbox_status: runtime::SandboxStatus, - /// #143: when `.claw.json` (or another loaded config file) fails to parse, - /// we capture the parse error here and still populate every field that - /// doesn't depend on runtime config (workspace, git, sandbox defaults, - /// discovery counts). Top-level JSON output then reports - /// `status: "degraded"` so claws can distinguish "status ran but config - /// is broken" from "status ran cleanly". - config_load_error: Option, -} - -#[derive(Debug, Clone, Copy)] -struct StatusUsage { - message_count: usize, - turns: u32, - latest: TokenUsage, - cumulative: TokenUsage, - estimated_tokens: usize, -} - -#[allow(clippy::struct_field_names)] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct GitWorkspaceSummary { - changed_files: usize, - staged_files: usize, - unstaged_files: usize, - untracked_files: usize, - conflicted_files: usize, -} - -impl GitWorkspaceSummary { - fn is_clean(self) -> bool { - self.changed_files == 0 - } - - fn headline(self) -> String { - if self.is_clean() { - "clean".to_string() - } else { - let mut details = Vec::new(); - if self.staged_files > 0 { - details.push(format!("{} staged", self.staged_files)); - } - if self.unstaged_files > 0 { - details.push(format!("{} unstaged", self.unstaged_files)); - } - if self.untracked_files > 0 { - details.push(format!("{} untracked", self.untracked_files)); - } - if self.conflicted_files > 0 { - details.push(format!("{} conflicted", self.conflicted_files)); - } - format!( - "dirty · {} files · {}", - self.changed_files, - details.join(", ") - ) - } - } -} - - -fn run_resume_command( - session_path: &Path, - session: &Session, - command: &SlashCommand, -) -> Result> { - match command { - SlashCommand::Help => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_repl_help()), - json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })), - }), - SlashCommand::Compact => { - let result = runtime::compact_session( - session, - CompactionConfig { - max_estimated_tokens: 0, - ..CompactionConfig::default() - }, - ); - let removed = result.removed_message_count; - let kept = result.compacted_session.messages.len(); - let skipped = removed == 0; - result.compacted_session.save_to_path(session_path)?; - Ok(ResumeCommandOutcome { - session: result.compacted_session, - message: Some(format_compact_report(removed, kept, skipped)), - json: Some(serde_json::json!({ - "kind": "compact", - "skipped": skipped, - "removed_messages": removed, - "kept_messages": kept, - })), - }) - } - SlashCommand::Clear { confirm } => { - if !confirm { - return Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some( - "clear: confirmation required; rerun with /clear --confirm".to_string(), - ), - json: Some(serde_json::json!({ - "kind": "error", - "error": "confirmation required", - "hint": "rerun with /clear --confirm", - })), - }); - } - let backup_path = write_session_clear_backup(session, session_path)?; - let previous_session_id = session.session_id.clone(); - let cleared = new_cli_session()?; - let new_session_id = cleared.session_id.clone(); - cleared.save_to_path(session_path)?; - Ok(ResumeCommandOutcome { - session: cleared, - message: Some(format!( - "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", - backup_path.display(), - backup_path.display(), - session_path.display() - )), - json: Some(serde_json::json!({ - "kind": "clear", - "previous_session_id": previous_session_id, - "new_session_id": new_session_id, - "backup": backup_path.display().to_string(), - "session_file": session_path.display().to_string(), - })), - }) - } - SlashCommand::Status => { - let tracker = UsageTracker::from_session(session); - let usage = tracker.cumulative_usage(); - let context = status_context(Some(session_path))?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_status_report( - session.model.as_deref().unwrap_or("restored-session"), - StatusUsage { - message_count: session.messages.len(), - turns: tracker.turns(), - latest: tracker.current_turn_usage(), - cumulative: usage, - estimated_tokens: 0, - }, - default_permission_mode().as_str(), - &context, - None, // #148: resumed sessions don't have flag provenance - )), - json: Some(status_json_value( - session.model.as_deref(), - StatusUsage { - message_count: session.messages.len(), - turns: tracker.turns(), - latest: tracker.current_turn_usage(), - cumulative: usage, - estimated_tokens: 0, - }, - default_permission_mode().as_str(), - &context, - None, // #148: resumed sessions don't have flag provenance - None, - )), - }) - } - SlashCommand::Sandbox => { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_sandbox_report(&status)), - json: Some(sandbox_json_value(&status)), - }) - } - SlashCommand::Cost => { - let usage = UsageTracker::from_session(session).cumulative_usage(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_cost_report(usage)), - json: Some(serde_json::json!({ - "kind": "cost", - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "cache_creation_input_tokens": usage.cache_creation_input_tokens, - "cache_read_input_tokens": usage.cache_read_input_tokens, - "total_tokens": usage.total_tokens(), - })), - }) - } - SlashCommand::Config { section } => { - let message = render_config_report(section.as_deref())?; - let json = render_config_json(section.as_deref())?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(message), - json: Some(json), - }) - } - SlashCommand::Mcp { action, target } => { - let cwd = env::current_dir()?; - let args = match (action.as_deref(), target.as_deref()) { - (None, None) => None, - (Some(action), None) => Some(action.to_string()), - (Some(action), Some(target)) => Some(format!("{action} {target}")), - (None, Some(target)) => Some(target.to_string()), - }; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)), - json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)), - }) - } - SlashCommand::Memory => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_memory_report()?), - json: Some(render_memory_json()?), - }), - SlashCommand::Init => { - // #142: run the init once, then render both text + structured JSON - // from the same InitReport so both surfaces stay in sync. - let cwd = env::current_dir()?; - let report = crate::init::initialize_repo(&cwd)?; - let message = report.render(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(message.clone()), - json: Some(init_json_value(&report, &message)), - }) - } - SlashCommand::Diff => { - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let message = render_diff_report_for(&cwd)?; - let json = render_diff_json_for(&cwd)?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(message), - json: Some(json), - }) - } - SlashCommand::Version => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_version_report()), - json: Some(version_json_value()), - }), - SlashCommand::Export { path } => { - let export_path = resolve_export_path(path.as_deref(), session)?; - fs::write(&export_path, render_export_text(session))?; - let msg_count = session.messages.len(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "Export\n Result wrote transcript\n File {}\n Messages {}", - export_path.display(), - msg_count, - )), - json: Some(serde_json::json!({ - "kind": "export", - "file": export_path.display().to_string(), - "message_count": msg_count, - })), - }) - } - SlashCommand::Agents { args } => { - let cwd = env::current_dir()?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), - json: Some(serde_json::json!({ - "kind": "agents", - "text": handle_agents_slash_command(args.as_deref(), &cwd)?, - })), - }) - } - SlashCommand::Skills { args } => { - if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { - return Err( - "resumed /skills invocations are interactive-only; start `claw` and run `/skills ` in the REPL".into(), - ); - } - let cwd = env::current_dir()?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), - json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?), - }) - } - SlashCommand::Doctor => { - let report = render_doctor_report()?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(report.render()), - json: Some(report.json_value()), - }) - } - SlashCommand::Stats => { - let usage = UsageTracker::from_session(session).cumulative_usage(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format_cost_report(usage)), - json: Some(serde_json::json!({ - "kind": "stats", - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "cache_creation_input_tokens": usage.cache_creation_input_tokens, - "cache_read_input_tokens": usage.cache_read_input_tokens, - "total_tokens": usage.total_tokens(), - })), - }) - } - SlashCommand::History { count } => { - let limit = parse_history_count(count.as_deref()) - .map_err(|error| -> Box { error.into() })?; - let entries = collect_session_prompt_history(session); - let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_prompt_history_report(&entries, limit)), - json: Some(serde_json::json!({ - "kind": "history", - "total": entries.len(), - "showing": shown.len(), - "entries": shown.iter().map(|e| serde_json::json!({ - "timestamp_ms": e.timestamp_ms, - "text": e.text, - })).collect::>(), - })), - }) - } - SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), - // /session list can be served from the sessions directory without a live session. - SlashCommand::Session { - action: Some(ref act), - .. - } if act == "list" => { - let sessions = list_managed_sessions().unwrap_or_default(); - let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); - let active_id = session.session_id.clone(); - let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(text), - json: Some(serde_json::json!({ - "kind": "session_list", - "sessions": session_ids, - "active": active_id, - })), - }) - } - SlashCommand::Bughunter { .. } - | SlashCommand::Commit { .. } - | SlashCommand::Pr { .. } - | SlashCommand::Issue { .. } - | SlashCommand::Ultraplan { .. } - | SlashCommand::Teleport { .. } - | SlashCommand::DebugToolCall { .. } - | SlashCommand::Resume { .. } - | SlashCommand::Model { .. } - | SlashCommand::Permissions { .. } - | SlashCommand::Session { .. } - | SlashCommand::Plugins { .. } - | SlashCommand::Login - | SlashCommand::Logout - | SlashCommand::Vim - | SlashCommand::Upgrade - | SlashCommand::Share - | SlashCommand::Feedback - | SlashCommand::Files - | SlashCommand::Fast - | SlashCommand::Exit - | SlashCommand::Summary - | SlashCommand::Desktop - | SlashCommand::Brief - | SlashCommand::Advisor - | SlashCommand::Stickers - | SlashCommand::Insights - | SlashCommand::Thinkback - | SlashCommand::ReleaseNotes - | SlashCommand::SecurityReview - | SlashCommand::Keybindings - | SlashCommand::PrivacySettings - | SlashCommand::Plan { .. } - | SlashCommand::Review { .. } - | SlashCommand::Tasks { .. } - | SlashCommand::Theme { .. } - | SlashCommand::Voice { .. } - | SlashCommand::Usage { .. } - | SlashCommand::Rename { .. } - | SlashCommand::Copy { .. } - | SlashCommand::Hooks { .. } - | SlashCommand::Context { .. } - | SlashCommand::Color { .. } - | SlashCommand::Effort { .. } - | SlashCommand::Branch { .. } - | SlashCommand::Rewind { .. } - | SlashCommand::Ide { .. } - | SlashCommand::Tag { .. } - | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), - } -} - -/// Detect if the current working directory is "broad" (home directory or -/// filesystem root). Returns the cwd path if broad, None otherwise. -fn detect_broad_cwd() -> Option { - let Ok(cwd) = env::current_dir() else { - return None; - }; - let is_home = env::var_os("HOME") - .or_else(|| env::var_os("USERPROFILE")) - .is_some_and(|h| Path::new(&h) == cwd); - let is_root = cwd.parent().is_none(); - if is_home || is_root { - Some(cwd) - } else { - None - } -} - -/// Enforce the broad-CWD policy: when running from home or root, either -/// require the --allow-broad-cwd flag, or prompt for confirmation (interactive), -/// or exit with an error (non-interactive). -fn enforce_broad_cwd_policy( - allow_broad_cwd: bool, - output_format: CliOutputFormat, -) -> Result<(), Box> { - if allow_broad_cwd { - return Ok(()); - } - let Some(cwd) = detect_broad_cwd() else { - return Ok(()); - }; - - let is_interactive = io::stdin().is_terminal(); - - if is_interactive { - // Interactive mode: print warning and ask for confirmation - eprintln!( - "Warning: claw is running from a very broad directory ({}).\n\ - The agent can read and search everything under this path.\n\ - Consider running from inside your project: cd /path/to/project && claw", - cwd.display() - ); - eprint!("Continue anyway? [y/N]: "); - io::stderr().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let trimmed = input.trim().to_lowercase(); - if trimmed != "y" && trimmed != "yes" { - eprintln!("Aborted."); - std::process::exit(0); - } - Ok(()) - } else { - // Non-interactive mode: exit with error (JSON or text) - let message = format!( - "claw is running from a very broad directory ({}). \ - The agent can read and search everything under this path. \ - Use --allow-broad-cwd to proceed anyway, \ - or run from inside your project: cd /path/to/project && claw", - cwd.display() - ); - match output_format { - CliOutputFormat::Json => { - eprintln!( - "{}", - serde_json::json!({ - "type": "error", - "error": message, - }) - ); - } - CliOutputFormat::Text => { - eprintln!("error: {message}"); - } - } - std::process::exit(1); - } -} - -fn run_stale_base_preflight(flag_value: Option<&str>) { - let Ok(cwd) = env::current_dir() else { - return; - }; - let source = resolve_expected_base(flag_value, &cwd); - let state = check_base_commit(&cwd, source.as_ref()); - if let Some(warning) = format_stale_base_warning(&state) { - eprintln!("{warning}"); - } -} - -#[allow(clippy::needless_pass_by_value)] -fn run_repl( - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, -) -> Result<(), Box> { - enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; - run_stale_base_preflight(base_commit.as_deref()); - let resolved_model = resolve_repl_model(model); - let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; - cli.set_reasoning_effort(reasoning_effort); - let mut editor = - input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); - println!("{}", cli.startup_banner()); - println!("{}", format_connected_line(&cli.model)); - - loop { - editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); - match editor.read_line()? { - input::ReadOutcome::Submit(input) => { - let trimmed = input.trim().to_string(); - if trimmed.is_empty() { - continue; - } - if matches!(trimmed.as_str(), "/exit" | "/quit") { - cli.persist_session()?; - break; - } - match SlashCommand::parse(&trimmed) { - Ok(Some(command)) => { - if cli.handle_repl_command(command)? { - cli.persist_session()?; - } - continue; - } - Ok(None) => {} - Err(error) => { - eprintln!("{error}"); - continue; - } - } - // Bare-word skill dispatch: if the first token of the input - // matches a known skill name, invoke it as `/skills ` - // rather than forwarding raw text to the LLM (ROADMAP #36). - let cwd = std::env::current_dir().unwrap_or_default(); - if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) { - editor.push_history(input); - cli.record_prompt_history(&trimmed); - cli.run_turn(&prompt)?; - continue; - } - editor.push_history(input); - cli.record_prompt_history(&trimmed); - cli.run_turn(&trimmed)?; - } - input::ReadOutcome::Cancel => {} - input::ReadOutcome::Exit => { - cli.persist_session()?; - break; - } - } - } - - Ok(()) -} - - -struct LiveCli { - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - system_prompt: Vec, - runtime: BuiltRuntime, - session: SessionHandle, - prompt_history: Vec, -} - - - -struct RuntimePluginState { - feature_config: runtime::RuntimeFeatureConfig, - tool_registry: GlobalToolRegistry, - plugin_registry: PluginRegistry, - mcp_state: Option>>, -} - -struct RuntimeMcpState { - runtime: tokio::runtime::Runtime, - manager: McpServerManager, - pending_servers: Vec, - degraded_report: Option, -} - -struct BuiltRuntime { - runtime: Option>, - plugin_registry: PluginRegistry, - plugins_active: bool, - mcp_state: Option>>, - mcp_active: bool, -} - -impl BuiltRuntime { - fn new( - runtime: ConversationRuntime, - plugin_registry: PluginRegistry, - mcp_state: Option>>, - ) -> Self { - Self { - runtime: Some(runtime), - plugin_registry, - plugins_active: true, - mcp_state, - mcp_active: true, - } - } - - fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self { - let runtime = self - .runtime - .take() - .expect("runtime should exist before installing hook abort signal"); - self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal)); - self - } - - fn shutdown_plugins(&mut self) -> Result<(), Box> { - if self.plugins_active { - self.plugin_registry.shutdown()?; - self.plugins_active = false; - } - Ok(()) - } - - fn shutdown_mcp(&mut self) -> Result<(), Box> { - if self.mcp_active { - if let Some(mcp_state) = &self.mcp_state { - mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .shutdown()?; - } - self.mcp_active = false; - } - Ok(()) - } -} - -impl Deref for BuiltRuntime { - type Target = ConversationRuntime; - - fn deref(&self) -> &Self::Target { - self.runtime - .as_ref() - .expect("runtime should exist while built runtime is alive") - } -} - -impl DerefMut for BuiltRuntime { - fn deref_mut(&mut self) -> &mut Self::Target { - self.runtime - .as_mut() - .expect("runtime should exist while built runtime is alive") - } -} - -impl Drop for BuiltRuntime { - fn drop(&mut self) { - let _ = self.shutdown_mcp(); - let _ = self.shutdown_plugins(); - } -} - -impl RuntimeMcpState { - fn new( - runtime_config: &runtime::RuntimeConfig, - ) -> Result, Box> { - let mut manager = McpServerManager::from_runtime_config(runtime_config); - if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() { - return Ok(None); - } - - let runtime = tokio::runtime::Runtime::new()?; - let discovery = runtime.block_on(manager.discover_tools_best_effort()); - let pending_servers = discovery - .failed_servers - .iter() - .map(|failure| failure.server_name.clone()) - .chain( - discovery - .unsupported_servers - .iter() - .map(|server| server.server_name.clone()), - ) - .collect::>() - .into_iter() - .collect::>(); - let available_tools = discovery - .tools - .iter() - .map(|tool| tool.qualified_name.clone()) - .collect::>(); - let failed_server_names = pending_servers.iter().cloned().collect::>(); - let working_servers = manager - .server_names() - .into_iter() - .filter(|server_name| !failed_server_names.contains(server_name)) - .collect::>(); - let failed_servers = - discovery - .failed_servers - .iter() - .map(|failure| runtime::McpFailedServer { - server_name: failure.server_name.clone(), - phase: runtime::McpLifecyclePhase::ToolDiscovery, - error: runtime::McpErrorSurface::new( - runtime::McpLifecyclePhase::ToolDiscovery, - Some(failure.server_name.clone()), - failure.error.clone(), - std::collections::BTreeMap::new(), - true, - ), - }) - .chain(discovery.unsupported_servers.iter().map(|server| { - runtime::McpFailedServer { - server_name: server.server_name.clone(), - phase: runtime::McpLifecyclePhase::ServerRegistration, - error: runtime::McpErrorSurface::new( - runtime::McpLifecyclePhase::ServerRegistration, - Some(server.server_name.clone()), - server.reason.clone(), - std::collections::BTreeMap::from([( - "transport".to_string(), - format!("{:?}", server.transport).to_ascii_lowercase(), - )]), - false, - ), - } - })) - .collect::>(); - let degraded_report = (!failed_servers.is_empty()).then(|| { - runtime::McpDegradedReport::new( - working_servers, - failed_servers, - available_tools.clone(), - available_tools, - ) - }); - - Ok(Some(( - Self { - runtime, - manager, - pending_servers, - degraded_report, - }, - discovery, - ))) - } - - fn shutdown(&mut self) -> Result<(), Box> { - self.runtime.block_on(self.manager.shutdown())?; - Ok(()) - } - - fn pending_servers(&self) -> Option> { - (!self.pending_servers.is_empty()).then(|| self.pending_servers.clone()) - } - - fn degraded_report(&self) -> Option { - self.degraded_report.clone() - } - - fn server_names(&self) -> Vec { - self.manager.server_names() - } - - fn call_tool( - &mut self, - qualified_tool_name: &str, - arguments: Option, - ) -> Result { - let response = self - .runtime - .block_on(self.manager.call_tool(qualified_tool_name, arguments)) - .map_err(|error| ToolError::new(error.to_string()))?; - if let Some(error) = response.error { - return Err(ToolError::new(format!( - "MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})", - error.message, error.code - ))); - } - - let result = response.result.ok_or_else(|| { - ToolError::new(format!( - "MCP tool `{qualified_tool_name}` returned no result payload" - )) - })?; - serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string())) - } - - fn list_resources_for_server(&mut self, server_name: &str) -> Result { - let result = self - .runtime - .block_on(self.manager.list_resources(server_name)) - .map_err(|error| ToolError::new(error.to_string()))?; - serde_json::to_string_pretty(&json!({ - "server": server_name, - "resources": result.resources, - })) - .map_err(|error| ToolError::new(error.to_string())) - } - - fn list_resources_for_all_servers(&mut self) -> Result { - let mut resources = Vec::new(); - let mut failures = Vec::new(); - - for server_name in self.server_names() { - match self - .runtime - .block_on(self.manager.list_resources(&server_name)) - { - Ok(result) => resources.push(json!({ - "server": server_name, - "resources": result.resources, - })), - Err(error) => failures.push(json!({ - "server": server_name, - "error": error.to_string(), - })), - } - } - - if resources.is_empty() && !failures.is_empty() { - let message = failures - .iter() - .filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str)) - .collect::>() - .join("; "); - return Err(ToolError::new(message)); - } - - serde_json::to_string_pretty(&json!({ - "resources": resources, - "failures": failures, - })) - .map_err(|error| ToolError::new(error.to_string())) - } - - fn read_resource(&mut self, server_name: &str, uri: &str) -> Result { - let result = self - .runtime - .block_on(self.manager.read_resource(server_name, uri)) - .map_err(|error| ToolError::new(error.to_string()))?; - serde_json::to_string_pretty(&json!({ - "server": server_name, - "contents": result.contents, - })) - .map_err(|error| ToolError::new(error.to_string())) - } -} - -fn build_runtime_mcp_state( - runtime_config: &runtime::RuntimeConfig, -) -> Result> { - let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else { - return Ok((None, Vec::new())); - }; - - let mut runtime_tools = discovery - .tools - .iter() - .map(mcp_runtime_tool_definition) - .collect::>(); - if !mcp_state.server_names().is_empty() { - runtime_tools.extend(mcp_wrapper_tool_definitions()); - } - - Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools)) -} - -fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition { - RuntimeToolDefinition { - name: tool.qualified_name.clone(), - description: Some( - tool.tool - .description - .clone() - .unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)), - ), - input_schema: tool - .tool - .input_schema - .clone() - .unwrap_or_else(|| json!({ "type": "object", "additionalProperties": true })), - required_permission: permission_mode_for_mcp_tool(&tool.tool), - } -} - -fn mcp_wrapper_tool_definitions() -> Vec { - vec![ - RuntimeToolDefinition { - name: "MCPTool".to_string(), - description: Some( - "Call a configured MCP tool by its qualified name and JSON arguments.".to_string(), - ), - input_schema: json!({ - "type": "object", - "properties": { - "qualifiedName": { "type": "string" }, - "arguments": {} - }, - "required": ["qualifiedName"], - "additionalProperties": false - }), - required_permission: PermissionMode::DangerFullAccess, - }, - RuntimeToolDefinition { - name: "ListMcpResourcesTool".to_string(), - description: Some( - "List MCP resources from one configured server or from every connected server." - .to_string(), - ), - input_schema: json!({ - "type": "object", - "properties": { - "server": { "type": "string" } - }, - "additionalProperties": false - }), - required_permission: PermissionMode::ReadOnly, - }, - RuntimeToolDefinition { - name: "ReadMcpResourceTool".to_string(), - description: Some("Read a specific MCP resource from a configured server.".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "server": { "type": "string" }, - "uri": { "type": "string" } - }, - "required": ["server", "uri"], - "additionalProperties": false - }), - required_permission: PermissionMode::ReadOnly, - }, - ] -} - -fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode { - let read_only = mcp_annotation_flag(tool, "readOnlyHint"); - let destructive = mcp_annotation_flag(tool, "destructiveHint"); - let open_world = mcp_annotation_flag(tool, "openWorldHint"); - - if read_only && !destructive && !open_world { - PermissionMode::ReadOnly - } else if destructive || open_world { - PermissionMode::DangerFullAccess - } else { - PermissionMode::WorkspaceWrite - } -} - -fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool { - tool.annotations - .as_ref() - .and_then(|annotations| annotations.get(key)) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) -} - -struct HookAbortMonitor { - stop_tx: Option>, - join_handle: Option>, -} - -impl HookAbortMonitor { - fn spawn(abort_signal: runtime::HookAbortSignal) -> Self { - Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| { - let Ok(runtime) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - else { - return; - }; - - runtime.block_on(async move { - let wait_for_stop = tokio::task::spawn_blocking(move || { - let _ = stop_rx.recv(); - }); - - tokio::select! { - result = tokio::signal::ctrl_c() => { - if result.is_ok() { - abort_signal.abort(); - } - } - _ = wait_for_stop => {} - } - }); - }) - } - - fn spawn_with_waiter(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self - where - F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static, - { - let (stop_tx, stop_rx) = mpsc::channel(); - let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal)); - - Self { - stop_tx: Some(stop_tx), - join_handle: Some(join_handle), - } - } - - fn stop(mut self) { - if let Some(stop_tx) = self.stop_tx.take() { - let _ = stop_tx.send(()); - } - if let Some(join_handle) = self.join_handle.take() { - let _ = join_handle.join(); - } - } -} - - -fn render_help_topic(topic: LocalHelpTopic) -> String { - match topic { - LocalHelpTopic::Status => "Status - Usage claw status [--output-format ] - Purpose show the local workspace snapshot without entering the REPL - Output model, permissions, git state, config files, and sandbox status - Formats text (default), json - Related /status · claw --resume latest /status" - .to_string(), - LocalHelpTopic::Sandbox => "Sandbox - Usage claw sandbox [--output-format ] - Purpose inspect the resolved sandbox and isolation state for the current directory - Output namespace, network, filesystem, and fallback details - Formats text (default), json - Related /sandbox · claw status" - .to_string(), - LocalHelpTopic::Doctor => "Doctor - Usage claw doctor [--output-format ] - Purpose diagnose local auth, config, workspace, sandbox, and build metadata - Output local-only health report; no provider request or session resume required - Formats text (default), json - Related /doctor · claw --resume latest /doctor" - .to_string(), - LocalHelpTopic::Acp => "ACP / Zed - Usage claw acp [serve] [--output-format ] - Aliases claw --acp · claw -acp - Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime - Status discoverability only; `serve` is a status alias and does not launch a daemon yet - Formats text (default), json - Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help" - .to_string(), - LocalHelpTopic::Init => "Init - Usage claw init [--output-format ] - Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in the current project - Output list of created vs. skipped files (idempotent: safe to re-run) - Formats text (default), json - Related claw status · claw doctor" - .to_string(), - LocalHelpTopic::State => "State - Usage claw state [--output-format ] - Purpose read .claw/worker-state.json written by the interactive REPL or a one-shot prompt - Output worker id, model, permissions, session reference (text or json) - Formats text (default), json - Produces state `claw` (interactive REPL) or `claw prompt ` (one non-interactive turn) - Observes state `claw state` reads; clawhip/CI may poll this file without HTTP - Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise - Related claw status · ROADMAP #139 (this worker-concept contract)" - .to_string(), - LocalHelpTopic::Export => "Export - Usage claw export [--session ] [--output ] [--output-format ] - Purpose serialize a managed session to JSON for review, transfer, or archival - Defaults --session latest (most recent managed session in .claw/sessions/) - Formats text (default), json - Related /session list · claw --resume latest" - .to_string(), - LocalHelpTopic::Version => "Version - Usage claw version [--output-format ] - Aliases claw --version · claw -V - Purpose print the claw CLI version and build metadata - Formats text (default), json - Related claw doctor (full build/auth/config diagnostic)" - .to_string(), - LocalHelpTopic::SystemPrompt => "System Prompt - Usage claw system-prompt [--cwd ] [--date YYYY-MM-DD] [--output-format ] - Purpose render the resolved system prompt that `claw` would send for the given cwd + date - Options --cwd overrides the workspace dir · --date injects a deterministic date stamp - Formats text (default), json - Related claw doctor · claw dump-manifests" - .to_string(), - LocalHelpTopic::DumpManifests => "Dump Manifests - Usage claw dump-manifests [--manifests-dir ] [--output-format ] - Purpose emit every skill/agent/tool manifest the resolver would load for the current cwd - Options --manifests-dir scopes discovery to a specific directory - Formats text (default), json - Related claw skills · claw agents · claw doctor" - .to_string(), - LocalHelpTopic::BootstrapPlan => "Bootstrap Plan - Usage claw bootstrap-plan [--output-format ] - Purpose list the ordered startup phases the CLI would execute before dispatch - Output phase names (text) or structured phase list (json) — primary output is the plan itself - Formats text (default), json - Related claw doctor · claw status" - .to_string(), - } -} - -fn print_help_topic(topic: LocalHelpTopic) { - println!("{}", render_help_topic(topic)); -} - -fn print_acp_status(output_format: CliOutputFormat) -> Result<(), Box> { - let message = "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` is only a discoverability alias today; it does not launch a daemon or Zed-specific protocol endpoint. Use the normal terminal surfaces for now and track ROADMAP #76 for real ACP support."; - match output_format { - CliOutputFormat::Text => { - println!( - "ACP / Zed\n Status discoverability only\n Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Tracking ROADMAP #76\n Message {message}" - ); - } - CliOutputFormat::Json => { - println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "acp", - "status": "discoverability_only", - "supported": false, - "serve_alias_only": true, - "message": message, - "launch_command": serde_json::Value::Null, - "aliases": ["acp", "--acp", "-acp"], - "discoverability_tracking": "ROADMAP #64a", - "tracking": "ROADMAP #76", - "recommended_workflows": [ - "claw prompt TEXT", - "claw", - "claw doctor" - ], - }))? - ); - } - } - Ok(()) -} - -fn render_config_report(section: Option<&str>) -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let discovered = loader.discover(); - let runtime_config = loader.load()?; - - let mut lines = vec![ - format!( - "Config - Working directory {} - Loaded files {} - Merged keys {}", - cwd.display(), - runtime_config.loaded_entries().len(), - runtime_config.merged().len() - ), - "Discovered files".to_string(), - ]; - for entry in discovered { - let source = match entry.source { - ConfigSource::User => "user", - ConfigSource::Project => "project", - ConfigSource::Local => "local", - }; - let status = if runtime_config - .loaded_entries() - .iter() - .any(|loaded_entry| loaded_entry.path == entry.path) - { - "loaded" - } else { - "missing" - }; - lines.push(format!( - " {source:<7} {status:<7} {}", - entry.path.display() - )); - } - - if let Some(section) = section { - lines.push(format!("Merged section: {section}")); - let value = match section { - "env" => runtime_config.get("env"), - "hooks" => runtime_config.get("hooks"), - "model" => runtime_config.get("model"), - "plugins" => runtime_config - .get("plugins") - .or_else(|| runtime_config.get("enabledPlugins")), - other => { - lines.push(format!( - " Unsupported config section '{other}'. Use env, hooks, model, or plugins." - )); - return Ok(lines.join( - " -", - )); - } - }; - lines.push(format!( - " {}", - match value { - Some(value) => value.render(), - None => "".to_string(), - } - )); - return Ok(lines.join( - " -", - )); - } - - lines.push("Merged JSON".to_string()); - lines.push(format!(" {}", runtime_config.as_json().render())); - Ok(lines.join( - " -", - )) -} - -fn render_config_json( - _section: Option<&str>, -) -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let discovered = loader.discover(); - let runtime_config = loader.load()?; - - let loaded_paths: Vec<_> = runtime_config - .loaded_entries() - .iter() - .map(|e| e.path.display().to_string()) - .collect(); - - let files: Vec<_> = discovered - .iter() - .map(|e| { - let source = match e.source { - ConfigSource::User => "user", - ConfigSource::Project => "project", - ConfigSource::Local => "local", - }; - let is_loaded = runtime_config - .loaded_entries() - .iter() - .any(|le| le.path == e.path); - serde_json::json!({ - "path": e.path.display().to_string(), - "source": source, - "loaded": is_loaded, - }) - }) - .collect(); - - Ok(serde_json::json!({ - "kind": "config", - "cwd": cwd.display().to_string(), - "loaded_files": loaded_paths.len(), - "merged_keys": runtime_config.merged().len(), - "files": files, - })) -} - -fn render_memory_report() -> Result> { - let cwd = env::current_dir()?; - let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; - let mut lines = vec![format!( - "Memory - Working directory {} - Instruction files {}", - cwd.display(), - project_context.instruction_files.len() - )]; - if project_context.instruction_files.is_empty() { - lines.push("Discovered files".to_string()); - lines.push( - " No CLAUDE instruction files discovered in the current directory ancestry." - .to_string(), - ); - } else { - lines.push("Discovered files".to_string()); - for (index, file) in project_context.instruction_files.iter().enumerate() { - let preview = file.content.lines().next().unwrap_or("").trim(); - let preview = if preview.is_empty() { - "" - } else { - preview - }; - lines.push(format!(" {}. {}", index + 1, file.path.display(),)); - lines.push(format!( - " lines={} preview={}", - file.content.lines().count(), - preview - )); - } - } - Ok(lines.join( - " -", - )) -} - -fn render_memory_json() -> Result> { - let cwd = env::current_dir()?; - let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; - let files: Vec<_> = project_context - .instruction_files - .iter() - .map(|f| { - json!({ - "path": f.path.display().to_string(), - "lines": f.content.lines().count(), - "preview": f.content.lines().next().unwrap_or("").trim(), - }) - }) - .collect(); - Ok(json!({ - "kind": "memory", - "cwd": cwd.display().to_string(), - "instruction_files": files.len(), - "files": files, - })) -} - -fn init_claude_md() -> Result> { - let cwd = env::current_dir()?; - Ok(initialize_repo(&cwd)?.render()) -} - -fn run_init(output_format: CliOutputFormat) -> Result<(), Box> { - let cwd = env::current_dir()?; - let report = initialize_repo(&cwd)?; - let message = report.render(); - match output_format { - CliOutputFormat::Text => println!("{message}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&init_json_value(&report, &message))? - ), - } - Ok(()) -} - -/// #142: emit first-class structured fields alongside the legacy `message` -/// string so claws can detect per-artifact state without substring matching. -fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value { - use crate::init::InitStatus; - json!({ - "kind": "init", - "project_path": report.project_root.display().to_string(), - "created": report.artifacts_with_status(InitStatus::Created), - "updated": report.artifacts_with_status(InitStatus::Updated), - "skipped": report.artifacts_with_status(InitStatus::Skipped), - "artifacts": report.artifact_json_entries(), - "next_step": crate::init::InitReport::NEXT_STEP, - "message": message, - }) -} - -pub(crate) fn normalize_permission_mode(mode: &str) -> Option<&'static str> { - match mode.trim() { - "read-only" => Some("read-only"), - "workspace-write" => Some("workspace-write"), - "danger-full-access" => Some("danger-full-access"), - _ => None, - } -} - -fn render_diff_report() -> Result> { - render_diff_report_for(&env::current_dir()?) -} - -fn render_diff_report_for(cwd: &Path) -> Result> { - // Verify we are inside a git repository before calling `git diff`. - // Running `git diff --cached` outside a git tree produces a misleading - // "unknown option `cached`" error because git falls back to --no-index mode. - let in_git_repo = std::process::Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .current_dir(cwd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if !in_git_repo { - return Ok(format!( - "Diff\n Result no git repository\n Detail {} is not inside a git project", - cwd.display() - )); - } - let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; - let unstaged = run_git_diff_command_in(cwd, &["diff"])?; - if staged.trim().is_empty() && unstaged.trim().is_empty() { - return Ok( - "Diff\n Result clean working tree\n Detail no current changes" - .to_string(), - ); - } - - let mut sections = Vec::new(); - if !staged.trim().is_empty() { - sections.push(format!("Staged changes:\n{}", staged.trim_end())); - } - if !unstaged.trim().is_empty() { - sections.push(format!("Unstaged changes:\n{}", unstaged.trim_end())); - } - - Ok(format!("Diff\n\n{}", sections.join("\n\n"))) -} - -fn render_diff_json_for(cwd: &Path) -> Result> { - let in_git_repo = std::process::Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .current_dir(cwd) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if !in_git_repo { - return Ok(serde_json::json!({ - "kind": "diff", - "result": "no_git_repo", - "detail": format!("{} is not inside a git project", cwd.display()), - })); - } - let staged = run_git_diff_command_in(cwd, &["diff", "--cached"])?; - let unstaged = run_git_diff_command_in(cwd, &["diff"])?; - Ok(serde_json::json!({ - "kind": "diff", - "result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" }, - "staged": staged.trim(), - "unstaged": unstaged.trim(), - })) -} - -fn run_git_diff_command_in( - cwd: &Path, - args: &[&str], -) -> Result> { - let output = std::process::Command::new("git") - .args(args) - .current_dir(cwd) - .output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); - } - Ok(String::from_utf8(output.stdout)?) -} - -fn render_teleport_report(target: &str) -> Result> { - let cwd = env::current_dir()?; - - let file_list = Command::new("rg") - .args(["--files"]) - .current_dir(&cwd) - .output()?; - let file_matches = if file_list.status.success() { - String::from_utf8(file_list.stdout)? - .lines() - .filter(|line| line.contains(target)) - .take(10) - .map(ToOwned::to_owned) - .collect::>() - } else { - Vec::new() - }; - - let content_output = Command::new("rg") - .args(["-n", "-S", "--color", "never", target, "."]) - .current_dir(&cwd) - .output()?; - - let mut lines = vec![ - "Teleport".to_string(), - format!(" Target {target}"), - " Action search workspace files and content for the target".to_string(), - ]; - if !file_matches.is_empty() { - lines.push(String::new()); - lines.push("File matches".to_string()); - lines.extend(file_matches.into_iter().map(|path| format!(" {path}"))); - } - - if content_output.status.success() { - let matches = String::from_utf8(content_output.stdout)?; - if !matches.trim().is_empty() { - lines.push(String::new()); - lines.push("Content matches".to_string()); - lines.push(truncate_for_prompt(&matches, 4_000)); - } - } - - if lines.len() == 1 { - lines.push(" Result no matches found".to_string()); - } - - Ok(lines.join("\n")) -} - -fn render_last_tool_debug_report(session: &Session) -> Result> { - let last_tool_use = session - .messages - .iter() - .rev() - .find_map(|message| { - message.blocks.iter().rev().find_map(|block| match block { - ContentBlock::ToolUse { id, name, input } => { - Some((id.clone(), name.clone(), input.clone())) - } - _ => None, - }) - }) - .ok_or_else(|| "no prior tool call found in session".to_string())?; - - let tool_result = session.messages.iter().rev().find_map(|message| { - message.blocks.iter().rev().find_map(|block| match block { - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } if tool_use_id == &last_tool_use.0 => { - Some((tool_name.clone(), output.clone(), *is_error)) - } - _ => None, - }) - }); - - let mut lines = vec![ - "Debug tool call".to_string(), - " Action inspect the last recorded tool call and its result".to_string(), - format!(" Tool id {}", last_tool_use.0), - format!(" Tool name {}", last_tool_use.1), - " Input".to_string(), - indent_block(&last_tool_use.2, 4), - ]; - - match tool_result { - Some((tool_name, output, is_error)) => { - lines.push(" Result".to_string()); - lines.push(format!(" name {tool_name}")); - lines.push(format!( - " status {}", - if is_error { "error" } else { "ok" } - )); - lines.push(indent_block(&output, 4)); - } - None => lines.push(" Result missing tool result".to_string()), - } - - Ok(lines.join("\n")) -} - -fn indent_block(value: &str, spaces: usize) -> String { - let indent = " ".repeat(spaces); - value - .lines() - .map(|line| format!("{indent}{line}")) - .collect::>() - .join("\n") -} - -fn validate_no_args( - command_name: &str, - args: Option<&str>, -) -> Result<(), Box> { - if let Some(args) = args.map(str::trim).filter(|value| !value.is_empty()) { - return Err(format!( - "{command_name} does not accept arguments. Received: {args}\nUsage: {command_name}" - ) - .into()); - } - Ok(()) -} - -fn format_bughunter_report(scope: Option<&str>) -> String { - format!( - "Bughunter - Scope {} - Action inspect the selected code for likely bugs and correctness issues - Output findings should include file paths, severity, and suggested fixes", - scope.unwrap_or("the current repository") - ) -} - -fn format_ultraplan_report(task: Option<&str>) -> String { - format!( - "Ultraplan - Task {} - Action break work into a multi-step execution plan - Output plan should cover goals, risks, sequencing, verification, and rollback", - task.unwrap_or("the current repo work") - ) -} - -fn format_pr_report(branch: &str, context: Option<&str>) -> String { - format!( - "PR - Branch {branch} - Context {} - Action draft or create a pull request for the current branch - Output title and markdown body suitable for GitHub", - context.unwrap_or("none") - ) -} - -fn format_issue_report(context: Option<&str>) -> String { - format!( - "Issue - Context {} - Action draft or create a GitHub issue from the current context - Output title and markdown body suitable for GitHub", - context.unwrap_or("none") - ) -} - -fn git_output(args: &[&str]) -> Result> { - let output = Command::new("git") - .args(args) - .current_dir(env::current_dir()?) - .output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); - } - Ok(String::from_utf8(output.stdout)?) -} - -fn git_status_ok(args: &[&str]) -> Result<(), Box> { - let output = Command::new("git") - .args(args) - .current_dir(env::current_dir()?) - .output()?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); - } - Ok(()) -} - -fn command_exists(name: &str) -> bool { - Command::new("which") - .arg(name) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -fn write_temp_text_file( - filename: &str, - contents: &str, -) -> Result> { - let path = env::temp_dir().join(filename); - fs::write(&path, contents)?; - Ok(path) -} - -const DEFAULT_HISTORY_LIMIT: usize = 20; - -fn parse_history_count(raw: Option<&str>) -> Result { - let Some(raw) = raw else { - return Ok(DEFAULT_HISTORY_LIMIT); - }; - let parsed: usize = raw - .parse() - .map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?; - if parsed == 0 { - return Err("history: count must be greater than 0.".to_string()); - } - Ok(parsed) -} - -fn format_history_timestamp(timestamp_ms: u64) -> String { - let secs = timestamp_ms / 1_000; - let subsec_ms = timestamp_ms % 1_000; - let days_since_epoch = secs / 86_400; - let seconds_of_day = secs % 86_400; - let hours = seconds_of_day / 3_600; - let minutes = (seconds_of_day % 3_600) / 60; - let seconds = seconds_of_day % 60; - - let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0)); - format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z") -} - -// Computes civil (Gregorian) year/month/day from days since the Unix epoch -// (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm. -#[allow( - clippy::cast_sign_loss, - clippy::cast_possible_wrap, - clippy::cast_possible_truncation -)] -fn civil_from_days(days: i64) -> (i32, u32, u32) { - let z = days + 719_468; - let era = if z >= 0 { - z / 146_097 - } else { - (z - 146_096) / 146_097 - }; - let doe = (z - era * 146_097) as u64; // [0, 146_096] - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] - let mp = (5 * doy + 2) / 153; // [0, 11] - let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] - let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] - let y = y + i64::from(m <= 2); - (y as i32, m as u32, d as u32) -} - -fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String { - if entries.is_empty() { - return "Prompt history\n Result no prompts recorded yet".to_string(); - } - - let total = entries.len(); - let start = total.saturating_sub(limit); - let shown = &entries[start..]; - let mut lines = vec![ - "Prompt history".to_string(), - format!(" Total {total}"), - format!(" Showing {} most recent", shown.len()), - format!(" Reverse search Ctrl-R in the REPL"), - String::new(), - ]; - for (offset, entry) in shown.iter().enumerate() { - let absolute_index = start + offset + 1; - let timestamp = format_history_timestamp(entry.timestamp_ms); - let first_line = entry.text.lines().next().unwrap_or("").trim(); - let display = if first_line.chars().count() > 80 { - let truncated: String = first_line.chars().take(77).collect(); - format!("{truncated}...") - } else { - first_line.to_string() - }; - lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}")); - } - lines.join("\n") -} - -fn collect_session_prompt_history(session: &Session) -> Vec { - if !session.prompt_history.is_empty() { - return session - .prompt_history - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect(); - } - let timestamp_ms = session.updated_at_ms; - session - .messages - .iter() - .filter(|message| message.role == MessageRole::User) - .filter_map(|message| { - message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(PromptHistoryEntry { - timestamp_ms, - text: text.clone(), - }), - _ => None, - }) - }) - .collect() -} - -fn recent_user_context(session: &Session, limit: usize) -> String { - let requests = session - .messages - .iter() - .filter(|message| message.role == MessageRole::User) - .filter_map(|message| { - message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(text.trim().to_string()), - _ => None, - }) - }) - .rev() - .take(limit) - .collect::>(); - - if requests.is_empty() { - "".to_string() - } else { - requests - .into_iter() - .rev() - .enumerate() - .map(|(index, text)| format!("{}. {}", index + 1, text)) - .collect::>() - .join("\n") - } -} - -fn truncate_for_prompt(value: &str, limit: usize) -> String { - if value.chars().count() <= limit { - value.trim().to_string() - } else { - let truncated = value.chars().take(limit).collect::(); - format!("{}\n…[truncated]", truncated.trim_end()) - } -} - -fn sanitize_generated_message(value: &str) -> String { - value.trim().trim_matches('`').trim().replace("\r\n", "\n") -} - -fn parse_titled_body(value: &str) -> Option<(String, String)> { - let normalized = sanitize_generated_message(value); - let title = normalized - .lines() - .find_map(|line| line.strip_prefix("TITLE:").map(str::trim))?; - let body_start = normalized.find("BODY:")?; - let body = normalized[body_start + "BODY:".len()..].trim(); - Some((title.to_string(), body.to_string())) -} - -fn render_version_report() -> String { - let git_sha = GIT_SHA.unwrap_or("unknown"); - let target = BUILD_TARGET.unwrap_or("unknown"); - format!( - "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" - ) -} - -fn render_export_text(session: &Session) -> String { - let mut lines = vec!["# Conversation Export".to_string(), String::new()]; - for (index, message) in session.messages.iter().enumerate() { - let role = match message.role { - MessageRole::System => "system", - MessageRole::User => "user", - MessageRole::Assistant => "assistant", - MessageRole::Tool => "tool", - }; - lines.push(format!("## {}. {role}", index + 1)); - for block in &message.blocks { - match block { - ContentBlock::Text { text } => lines.push(text.clone()), - ContentBlock::ToolUse { id, name, input } => { - lines.push(format!("[tool_use id={id} name={name}] {input}")); - } - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => { - lines.push(format!( - "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" - )); - } - ContentBlock::Thinking { - thinking, - signature, - } => { - lines.push(format!("[thinking signature={signature:?}] {thinking}")); - } - } - } - lines.push(String::new()); - } - lines.join("\n") -} - -fn default_export_filename(session: &Session) -> String { - let stem = session - .messages - .iter() - .find_map(|message| match message.role { - MessageRole::User => message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }), - _ => None, - }) - .map_or("conversation", |text| { - text.lines().next().unwrap_or("conversation") - }) - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() { - ch.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::() - .split('-') - .filter(|part| !part.is_empty()) - .take(8) - .collect::>() - .join("-"); - let fallback = if stem.is_empty() { - "conversation" - } else { - &stem - }; - format!("{fallback}.txt") -} - -fn resolve_export_path( - requested_path: Option<&str>, - session: &Session, -) -> Result> { - let cwd = env::current_dir()?; - let file_name = - requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned); - let final_name = if Path::new(&file_name) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) - { - file_name - } else { - format!("{file_name}.txt") - }; - Ok(cwd.join(final_name)) -} - -const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280; - -fn summarize_tool_payload_for_markdown(payload: &str) -> String { - let compact = match serde_json::from_str::(payload) { - Ok(value) => value.to_string(), - Err(_) => payload.split_whitespace().collect::>().join(" "), - }; - if compact.is_empty() { - return String::new(); - } - truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT) -} - -fn run_export( - session_reference: &str, - output_path: Option<&Path>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let (handle, session) = load_session_reference(session_reference)?; - let markdown = render_session_markdown(&session, &handle.id, &handle.path); - - if let Some(path) = output_path { - fs::write(path, &markdown)?; - let report = format!( - "Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}", - path.display(), - handle.id, - session.messages.len(), - ); - match output_format { - CliOutputFormat::Text => println!("{report}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "export", - "message": report, - "session_id": handle.id, - "file": path.display().to_string(), - "messages": session.messages.len(), - }))? - ), - } - return Ok(()); - } - - match output_format { - CliOutputFormat::Text => { - print!("{markdown}"); - if !markdown.ends_with('\n') { - println!(); - } - } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "export", - "session_id": handle.id, - "file": handle.path.display().to_string(), - "messages": session.messages.len(), - "markdown": markdown, - }))? - ), - } - Ok(()) -} - -fn render_session_markdown(session: &Session, session_id: &str, session_path: &Path) -> String { - let mut lines = vec![ - "# Conversation Export".to_string(), - String::new(), - format!("- **Session**: `{session_id}`"), - format!("- **File**: `{}`", session_path.display()), - format!("- **Messages**: {}", session.messages.len()), - ]; - if let Some(workspace_root) = session.workspace_root() { - lines.push(format!("- **Workspace**: `{}`", workspace_root.display())); - } - if let Some(fork) = &session.fork { - let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)"); - lines.push(format!( - "- **Forked from**: `{}` (branch `{branch}`)", - fork.parent_session_id - )); - } - if let Some(compaction) = &session.compaction { - lines.push(format!( - "- **Compactions**: {} (last removed {} messages)", - compaction.count, compaction.removed_message_count - )); - } - lines.push(String::new()); - lines.push("---".to_string()); - lines.push(String::new()); - - for (index, message) in session.messages.iter().enumerate() { - let role = match message.role { - MessageRole::System => "System", - MessageRole::User => "User", - MessageRole::Assistant => "Assistant", - MessageRole::Tool => "Tool", - }; - lines.push(format!("## {}. {role}", index + 1)); - lines.push(String::new()); - for block in &message.blocks { - match block { - ContentBlock::Text { text } => { - let trimmed = text.trim_end(); - if !trimmed.is_empty() { - lines.push(trimmed.to_string()); - lines.push(String::new()); - } - } - ContentBlock::ToolUse { id, name, input } => { - lines.push(format!( - "**Tool call** `{name}` _(id `{}`)_", - short_tool_id(id) - )); - let summary = summarize_tool_payload_for_markdown(input); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => { - let status = if *is_error { "error" } else { "ok" }; - lines.push(format!( - "**Tool result** `{tool_name}` _(id `{}`, {status})_", - short_tool_id(tool_use_id) - )); - let summary = summarize_tool_payload_for_markdown(output); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - ContentBlock::Thinking { - thinking, - signature, - } => { - lines.push(format!("**Thinking** _{signature:?}_")); - let summary = summarize_tool_payload_for_markdown(thinking); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - } - } - if let Some(usage) = message.usage { - lines.push(format!( - "_tokens: in={} out={} cache_create={} cache_read={}_", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - )); - lines.push(String::new()); - } - } - lines.join("\n") -} - -fn short_tool_id(id: &str) -> String { - let char_count = id.chars().count(); - if char_count <= 12 { - return id.to_string(); - } - let prefix: String = id.chars().take(12).collect(); - format!("{prefix}…") -} - -fn build_system_prompt() -> Result, Box> { - Ok(load_system_prompt( - env::current_dir()?, - DEFAULT_DATE, - env::consts::OS, - "unknown", - )?) -} - -fn build_runtime_plugin_state() -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) -} - -pub(crate) fn build_runtime_plugin_state_with_loader( - cwd: &Path, - loader: &ConfigLoader, - runtime_config: &runtime::RuntimeConfig, -) -> Result> { - let plugin_manager = build_plugin_manager(cwd, loader, runtime_config); - let plugin_registry = plugin_manager.plugin_registry()?; - let plugin_hook_config = - runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?); - let feature_config = runtime_config - .feature_config() - .clone() - .with_hooks(runtime_config.hooks().merged(&plugin_hook_config)); - let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?; - let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)? - .with_runtime_tools(runtime_tools)?; - Ok(RuntimePluginState { - feature_config, - tool_registry, - plugin_registry, - mcp_state, - }) -} - -fn build_plugin_manager( - cwd: &Path, - loader: &ConfigLoader, - runtime_config: &runtime::RuntimeConfig, -) -> PluginManager { - let plugin_settings = runtime_config.plugins(); - let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); - plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); - plugin_config.external_dirs = plugin_settings - .external_directories() - .iter() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) - .collect(); - plugin_config.install_root = plugin_settings - .install_root() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); - plugin_config.registry_path = plugin_settings - .registry_path() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); - plugin_config.bundled_root = plugin_settings - .bundled_root() - .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); - PluginManager::new(plugin_config) -} - -fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { - let path = PathBuf::from(value); - if path.is_absolute() { - path - } else if value.starts_with('.') { - cwd.join(path) - } else { - config_home.join(path) - } -} - -fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig { - runtime::RuntimeHookConfig::new( - hooks.pre_tool_use, - hooks.post_tool_use, - hooks.post_tool_use_failure, - ) -} - -#[allow(clippy::needless_pass_by_value)] -#[allow(clippy::too_many_arguments)] -fn build_runtime( - session: Session, - session_id: &str, - model: String, - system_prompt: Vec, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - permission_mode: PermissionMode, - progress_reporter: Option, -) -> Result> { - let runtime_plugin_state = build_runtime_plugin_state()?; - build_runtime_with_plugin_state( - session, - session_id, - model, - system_prompt, - enable_tools, - emit_output, - allowed_tools, - permission_mode, - progress_reporter, - runtime_plugin_state, - ) -} - -#[allow(clippy::needless_pass_by_value)] -#[allow(clippy::too_many_arguments)] -fn build_runtime_with_plugin_state( - mut session: Session, - session_id: &str, - model: String, - system_prompt: Vec, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - permission_mode: PermissionMode, - progress_reporter: Option, - runtime_plugin_state: RuntimePluginState, -) -> Result> { - // Persist the model in session metadata so resumed sessions can report it. - if session.model.is_none() { - session.model = Some(model.clone()); - } - let RuntimePluginState { - feature_config, - tool_registry, - plugin_registry, - mcp_state, - } = runtime_plugin_state; - plugin_registry.initialize()?; - let policy = permission_policy(permission_mode, &feature_config, &tool_registry) - .map_err(std::io::Error::other)?; - let mut runtime = ConversationRuntime::new_with_features( - session, - AnthropicRuntimeClient::new( - session_id, - model, - enable_tools, - emit_output, - allowed_tools.clone(), - tool_registry.clone(), - progress_reporter, - )?, - CliToolExecutor::new( - allowed_tools.clone(), - emit_output, - tool_registry.clone(), - mcp_state.clone(), - ), - policy, - system_prompt, - &feature_config, - ); - if emit_output { - runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter)); - } - Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) -} - - - -fn final_assistant_text(summary: &runtime::TurnSummary) -> String { - summary - .assistant_messages - .last() - .map(|message| { - message - .blocks - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("") - }) - .unwrap_or_default() -} - -fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec { - summary - .assistant_messages - .iter() - .flat_map(|message| message.blocks.iter()) - .filter_map(|block| match block { - ContentBlock::ToolUse { id, name, input } => Some(json!({ - "id": id, - "name": name, - "input": input, - })), - _ => None, - }) - .collect() -} - -fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec { - summary - .tool_results - .iter() - .flat_map(|message| message.blocks.iter()) - .filter_map(|block| match block { - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => Some(json!({ - "tool_use_id": tool_use_id, - "tool_name": tool_name, - "output": output, - "is_error": is_error, - })), - _ => None, - }) - .collect() -} - -fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec { - summary - .prompt_cache_events - .iter() - .map(|event| { - json!({ - "unexpected": event.unexpected, - "reason": event.reason, - "previous_cache_read_input_tokens": event.previous_cache_read_input_tokens, - "current_cache_read_input_tokens": event.current_cache_read_input_tokens, - "token_drop": event.token_drop, - }) - }) - .collect() -} - -/// Slash commands that are registered in the spec list but not yet implemented -fn slash_command_completion_candidates_with_sessions( - model: &str, - active_session_id: Option<&str>, - recent_session_ids: Vec, -) -> Vec { - let mut completions = BTreeSet::new(); - - for spec in slash_command_specs() { - if STUB_COMMANDS.contains(&spec.name) { - continue; - } - completions.insert(format!("/{}", spec.name)); - for alias in spec.aliases { - if !STUB_COMMANDS.contains(alias) { - completions.insert(format!("/{alias}")); - } - } - } - - for candidate in [ - "/bughunter ", - "/clear --confirm", - "/config ", - "/config env", - "/config hooks", - "/config model", - "/config plugins", - "/mcp ", - "/mcp list", - "/mcp show ", - "/export ", - "/issue ", - "/model ", - "/model opus", - "/model sonnet", - "/model haiku", - "/permissions ", - "/permissions read-only", - "/permissions workspace-write", - "/permissions danger-full-access", - "/plugin list", - "/plugin install ", - "/plugin enable ", - "/plugin disable ", - "/plugin uninstall ", - "/plugin update ", - "/plugins list", - "/pr ", - "/resume ", - "/session list", - "/session switch ", - "/session fork ", - "/teleport ", - "/ultraplan ", - "/agents help", - "/mcp help", - "/skills help", - ] { - completions.insert(candidate.to_string()); - } - - if !model.trim().is_empty() { - completions.insert(format!("/model {}", resolve_model_alias(model))); - completions.insert(format!("/model {model}")); - } - - if let Some(active_session_id) = active_session_id.filter(|value| !value.trim().is_empty()) { - completions.insert(format!("/resume {active_session_id}")); - completions.insert(format!("/session switch {active_session_id}")); - } - - for session_id in recent_session_ids - .into_iter() - .filter(|value| !value.trim().is_empty()) - .take(10) - { - completions.insert(format!("/resume {session_id}")); - completions.insert(format!("/session switch {session_id}")); - } - - completions.into_iter().collect() -} - -fn 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 { - 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 { - 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::>() - .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::>() - .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::(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::(); - 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::()); - truncated = true; - break; - } - - preview_lines.push(line.to_string()); - used_chars += newline_cost + line_chars; - } - - let mut preview = preview_lines.join("\n"); - if truncated { - if !preview.is_empty() { - preview.push('\n'); - } - preview.push_str(DISPLAY_TRUNCATION_NOTICE); - } - preview -} - -fn render_thinking_block_summary( - out: &mut (impl Write + ?Sized), - char_count: Option, - redacted: bool, -) -> Result<(), RuntimeError> { - let summary = if redacted { - "\n▶ Thinking block hidden by provider\n".to_string() - } else if let Some(char_count) = char_count { - format!("\n▶ Thinking ({char_count} chars hidden)\n") - } else { - "\n▶ Thinking hidden\n".to_string() - }; - write!(out, "{summary}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string())) -} - -fn push_output_block( - block: OutputContentBlock, - out: &mut (impl Write + ?Sized), - events: &mut Vec, - pending_tool: &mut Option<(String, String, String)>, - streaming_tool_input: bool, - block_has_thinking_summary: &mut bool, -) -> Result<(), RuntimeError> { - match block { - OutputContentBlock::Text { text } => { - if !text.is_empty() { - let rendered = TerminalRenderer::new().markdown_to_ansi(&text); - write!(out, "{rendered}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - events.push(AssistantEvent::TextDelta(text)); - } - } - OutputContentBlock::ToolUse { id, name, input } => { - // During streaming, the initial content_block_start has an empty input ({}). - // The real input arrives via input_json_delta events. In - // non-streaming responses, preserve a legitimate empty object. - let initial_input = if streaming_tool_input - && input.is_object() - && input.as_object().is_some_and(serde_json::Map::is_empty) - { - String::new() - } else { - input.to_string() - }; - *pending_tool = Some((id, name, initial_input)); - } - OutputContentBlock::Thinking { thinking, .. } => { - render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; - *block_has_thinking_summary = true; - } - OutputContentBlock::RedactedThinking { .. } => { - render_thinking_block_summary(out, None, true)?; - *block_has_thinking_summary = true; - } - } - Ok(()) -} - -fn response_to_events( - response: MessageResponse, - out: &mut (impl Write + ?Sized), -) -> Result, RuntimeError> { - let mut events = Vec::new(); - let mut pending_tool = None; - - for block in response.content { - let mut block_has_thinking_summary = false; - push_output_block( - block, - out, - &mut events, - &mut pending_tool, - false, - &mut block_has_thinking_summary, - )?; - if let Some((id, name, input)) = pending_tool.take() { - events.push(AssistantEvent::ToolUse { id, name, input }); - } - } - - events.push(AssistantEvent::Usage(response.usage.token_usage())); - events.push(AssistantEvent::MessageStop); - Ok(events) -} - -fn push_prompt_cache_record(client: &ApiProviderClient, events: &mut Vec) { - // `ApiProviderClient::take_last_prompt_cache_record` is a pass-through - // to the Anthropic variant and returns `None` for OpenAI-compat / - // xAI variants, which do not have a prompt cache. So this helper - // remains a no-op on non-Anthropic providers without any extra - // branching here. - if let Some(record) = client.take_last_prompt_cache_record() { - if let Some(event) = prompt_cache_record_to_runtime_event(record) { - events.push(AssistantEvent::PromptCache(event)); - } - } -} - -fn prompt_cache_record_to_runtime_event( - record: api::PromptCacheRecord, -) -> Option { - let cache_break = record.cache_break?; - Some(PromptCacheEvent { - unexpected: cache_break.unexpected, - reason: cache_break.reason, - previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens, - current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens, - token_drop: cache_break.token_drop, - }) -} - -struct CliToolExecutor { - renderer: TerminalRenderer, - emit_output: bool, - allowed_tools: Option, - tool_registry: GlobalToolRegistry, - mcp_state: Option>>, -} - -impl CliToolExecutor { - fn new( - allowed_tools: Option, - emit_output: bool, - tool_registry: GlobalToolRegistry, - mcp_state: Option>>, - ) -> Self { - Self { - renderer: TerminalRenderer::new(), - emit_output, - allowed_tools, - tool_registry, - mcp_state, - } - } - - fn execute_search_tool(&self, value: serde_json::Value) -> Result { - let input: ToolSearchRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - let (pending_mcp_servers, mcp_degraded) = - self.mcp_state.as_ref().map_or((None, None), |state| { - let state = state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - (state.pending_servers(), state.degraded_report()) - }); - serde_json::to_string_pretty(&self.tool_registry.search( - &input.query, - input.max_results.unwrap_or(5), - pending_mcp_servers, - mcp_degraded, - )) - .map_err(|error| ToolError::new(error.to_string())) - } - - fn execute_runtime_tool( - &self, - tool_name: &str, - value: serde_json::Value, - ) -> Result { - let Some(mcp_state) = &self.mcp_state else { - return Err(ToolError::new(format!( - "runtime tool `{tool_name}` is unavailable without configured MCP servers" - ))); - }; - let mut mcp_state = mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - - match tool_name { - "MCPTool" => { - let input: McpToolRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - let qualified_name = input - .qualified_name - .or(input.tool) - .ok_or_else(|| ToolError::new("missing required field `qualifiedName`"))?; - mcp_state.call_tool(&qualified_name, input.arguments) - } - "ListMcpResourcesTool" => { - let input: ListMcpResourcesRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - match input.server { - Some(server_name) => mcp_state.list_resources_for_server(&server_name), - None => mcp_state.list_resources_for_all_servers(), - } - } - "ReadMcpResourceTool" => { - let input: ReadMcpResourceRequest = serde_json::from_value(value) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - mcp_state.read_resource(&input.server, &input.uri) - } - _ => mcp_state.call_tool(tool_name, Some(value)), - } - } -} - -impl ToolExecutor for CliToolExecutor { - fn execute(&mut self, tool_name: &str, input: &str) -> Result { - if self - .allowed_tools - .as_ref() - .is_some_and(|allowed| !allowed.contains(tool_name)) - { - return Err(ToolError::new(format!( - "tool `{tool_name}` is not enabled by the current --allowedTools setting" - ))); - } - let value = serde_json::from_str(input) - .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - let result = if tool_name == "ToolSearch" { - self.execute_search_tool(value) - } else if self.tool_registry.has_runtime_tool(tool_name) { - self.execute_runtime_tool(tool_name, value) - } else { - self.tool_registry - .execute(tool_name, &value) - .map_err(ToolError::new) - }; - match result { - Ok(output) => { - if self.emit_output { - let markdown = format_tool_result(tool_name, &output, false); - self.renderer - .stream_markdown(&markdown, &mut io::stdout()) - .map_err(|error| ToolError::new(error.to_string()))?; - } - Ok(output) - } - Err(error) => { - if self.emit_output { - let markdown = format_tool_result(tool_name, &error.to_string(), true); - self.renderer - .stream_markdown(&markdown, &mut io::stdout()) - .map_err(|stream_error| ToolError::new(stream_error.to_string()))?; - } - Err(error) - } - } - } -} - -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::Thinking { - thinking, - signature, - } => InputContentBlock::Thinking { - thinking: thinking.clone(), - signature: signature.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() -} - -#[allow(clippy::too_many_lines)] -fn print_help_to(out: &mut impl Write) -> io::Result<()> { - writeln!(out, "claw v{VERSION}")?; - writeln!(out)?; - writeln!(out, "Usage:")?; - writeln!( - out, - " claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]" - )?; - writeln!(out, " Start the interactive REPL")?; - writeln!( - out, - " claw [--model MODEL] [--output-format text|json] prompt TEXT" - )?; - writeln!(out, " Send one prompt and exit")?; - writeln!( - out, - " claw [--model MODEL] [--output-format text|json] TEXT" - )?; - writeln!(out, " Shorthand non-interactive prompt mode")?; - writeln!( - out, - " claw --resume [SESSION.jsonl|session-id|latest] [/status] [/compact] [...]" - )?; - writeln!( - out, - " Inspect or maintain a saved session without entering the REPL" - )?; - writeln!(out, " claw help")?; - writeln!(out, " Alias for --help")?; - writeln!(out, " claw version")?; - writeln!(out, " Alias for --version")?; - writeln!(out, " claw status")?; - writeln!( - out, - " Show the current local workspace status snapshot" - )?; - writeln!(out, " claw sandbox")?; - writeln!(out, " Show the current sandbox isolation snapshot")?; - writeln!(out, " claw doctor")?; - writeln!( - out, - " Diagnose local auth, config, workspace, and sandbox health" - )?; - writeln!(out, " claw acp [serve]")?; - writeln!( - out, - " Show ACP/Zed editor integration status (currently unsupported; aliases: --acp, -acp)" - )?; - writeln!(out, " Source of truth: {OFFICIAL_REPO_SLUG}")?; - writeln!( - out, - " Warning: do not `{DEPRECATED_INSTALL_COMMAND}` (deprecated stub)" - )?; - writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?; - writeln!(out, " claw bootstrap-plan")?; - writeln!(out, " claw agents")?; - writeln!(out, " claw mcp")?; - writeln!(out, " claw skills")?; - writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; - writeln!(out, " claw init")?; - writeln!( - out, - " claw export [PATH] [--session SESSION] [--output PATH]" - )?; - writeln!( - out, - " Dump the latest (or named) session as markdown; writes to PATH or stdout" - )?; - writeln!(out)?; - writeln!(out, "Flags:")?; - writeln!( - out, - " --model MODEL Override the active model" - )?; - writeln!( - out, - " --output-format FORMAT Non-interactive output format: text or json" - )?; - writeln!( - out, - " --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)" - )?; - writeln!( - out, - " --permission-mode MODE Set read-only, workspace-write, or danger-full-access" - )?; - writeln!( - out, - " --dangerously-skip-permissions Skip all permission checks" - )?; - writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?; - writeln!( - out, - " --version, -V Print version and build information locally" - )?; - writeln!(out)?; - writeln!(out, "Interactive slash commands:")?; - writeln!(out, "{}", render_slash_command_help_filtered(STUB_COMMANDS))?; - writeln!(out)?; - let resume_commands = resume_supported_slash_commands() - .into_iter() - .map(|spec| match spec.argument_hint { - Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), - None => format!("/{}", spec.name), - }) - .collect::>() - .join(", "); - writeln!(out, "Resume-safe commands: {resume_commands}")?; - writeln!(out)?; - writeln!(out, "Session shortcuts:")?; - writeln!( - out, - " REPL turns auto-save to .claw/sessions/.{PRIMARY_SESSION_EXTENSION}" - )?; - writeln!( - out, - " Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session" - )?; - writeln!( - out, - " Use /session list in the REPL to browse managed sessions" - )?; - writeln!(out, "Examples:")?; - writeln!(out, " claw --model claude-opus \"summarize this repo\"")?; - writeln!( - out, - " claw --output-format json prompt \"explain src/main.rs\"" - )?; - writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?; - writeln!( - out, - " claw --allowedTools read,glob \"summarize Cargo.toml\"" - )?; - writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?; - writeln!( - out, - " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt" - )?; - writeln!(out, " claw agents")?; - writeln!(out, " claw mcp show my-server")?; - writeln!(out, " claw /skills")?; - writeln!(out, " claw doctor")?; - writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?; - writeln!( - out, - " do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub" - )?; - writeln!(out, " claw init")?; - writeln!(out, " claw export")?; - writeln!(out, " claw export conversation.md")?; - Ok(()) -} - -fn print_help(output_format: CliOutputFormat) -> Result<(), Box> { - let mut buffer = Vec::new(); - print_help_to(&mut buffer)?; - let message = String::from_utf8(buffer)?; - match output_format { - CliOutputFormat::Text => print!("{message}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "help", - "message": message, - }))? - ), - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{ - build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state, - classify_error_kind, collect_session_prompt_history, create_managed_session_handle, - describe_tool_progress, filter_tool_specs, format_bughunter_report, - format_commit_preflight_report, format_commit_skipped_report, format_compact_report, - format_connected_line, format_cost_report, format_history_timestamp, - format_internal_prompt_progress_line, format_issue_report, format_model_report, - format_model_switch_report, format_permissions_report, format_permissions_switch_report, - format_pr_report, format_resume_report, format_status_report, - format_tool_result, format_ultraplan_report, format_unknown_slash_command, - format_unknown_slash_command_message, - merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args, - parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary, - parse_history_count, permission_policy, print_help_to, - render_config_report, render_diff_report, render_diff_report_for, render_help_topic, - render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage, - render_session_markdown, resolve_model_alias, resolve_model_alias_with_config, - resolve_repl_model, resolve_session_reference, response_to_events, - resume_supported_slash_commands, run_resume_command, short_tool_id, - slash_command_completion_candidates_with_sessions, split_error_hint, status_context, - summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args, - write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, - InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic, - PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary, SlashCommand, StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE, - STUB_COMMANDS, - }; - use api::{ApiError, MessageResponse, OutputContentBlock, Usage}; - use crate::render::{ - format_tool_call_start, format_user_visible_api_error, push_output_block, - }; - use plugins::{ - PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission, - }; - use runtime::{ - load_oauth_credentials, save_oauth_credentials, AssistantEvent, ConfigLoader, ContentBlock, - ConversationMessage, MessageRole, OAuthConfig, PermissionMode, Session, ToolExecutor, - }; - use serde_json::json; - use std::fs; - use std::io::{Read, Write}; - use std::net::TcpListener; - use std::path::{Path, PathBuf}; - use std::process::Command; - use std::sync::{Mutex, MutexGuard, OnceLock}; - use std::thread; - 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") - } - - #[test] - fn opaque_provider_wrapper_surfaces_failure_class_session_and_trace() { - let error = ApiError::Api { - status: "500".parse().expect("status"), - error_type: Some("api_error".to_string()), - message: Some( - "Something went wrong while processing your request. Please try again, or use /new to start a fresh session." - .to_string(), - ), - request_id: Some("req_jobdori_789".to_string()), - body: String::new(), - retryable: true, - suggested_action: None, - }; - - let rendered = format_user_visible_api_error("session-issue-22", &error); - assert!(rendered.contains("provider_internal")); - assert!(rendered.contains("session session-issue-22")); - assert!(rendered.contains("trace req_jobdori_789")); - } - - #[test] - fn retry_exhaustion_uses_retry_failure_class_for_generic_provider_wrapper() { - let error = ApiError::RetriesExhausted { - attempts: 3, - last_error: Box::new(ApiError::Api { - status: "502".parse().expect("status"), - error_type: Some("api_error".to_string()), - message: Some( - "Something went wrong while processing your request. Please try again, or use /new to start a fresh session." - .to_string(), - ), - request_id: Some("req_jobdori_790".to_string()), - body: String::new(), - retryable: true, - suggested_action: None, - }), - }; - - let rendered = format_user_visible_api_error("session-issue-22", &error); - assert!(rendered.contains("provider_retry_exhausted"), "{rendered}"); - assert!(rendered.contains("session session-issue-22")); - assert!(rendered.contains("trace req_jobdori_790")); - } - - #[test] - fn context_window_preflight_errors_render_recovery_steps() { - let error = ApiError::ContextWindowExceeded { - model: "claude-sonnet-4-6".to_string(), - estimated_input_tokens: 182_000, - requested_output_tokens: 64_000, - estimated_total_tokens: 246_000, - context_window_tokens: 200_000, - }; - - let rendered = format_user_visible_api_error("session-issue-32", &error); - assert!(rendered.contains("Context window blocked"), "{rendered}"); - assert!(rendered.contains("context_window_blocked"), "{rendered}"); - assert!( - rendered.contains("Session session-issue-32"), - "{rendered}" - ); - assert!( - rendered.contains("Model claude-sonnet-4-6"), - "{rendered}" - ); - assert!( - rendered.contains("Input estimate ~182000 tokens (heuristic)"), - "{rendered}" - ); - assert!( - rendered.contains("Total estimate ~246000 tokens (heuristic)"), - "{rendered}" - ); - assert!(rendered.contains("Compact /compact"), "{rendered}"); - assert!( - rendered.contains("Resume compact claw --resume session-issue-32 /compact"), - "{rendered}" - ); - assert!( - rendered.contains("Fresh session /clear --confirm"), - "{rendered}" - ); - assert!(rendered.contains("Reduce scope"), "{rendered}"); - assert!(rendered.contains("Retry rerun"), "{rendered}"); - } - - #[test] - fn provider_context_window_errors_are_reframed_with_same_guidance() { - let error = ApiError::Api { - status: "400".parse().expect("status"), - error_type: Some("invalid_request_error".to_string()), - message: Some( - "This model's maximum context length is 200000 tokens, but your request used 230000 tokens." - .to_string(), - ), - request_id: Some("req_ctx_456".to_string()), - body: String::new(), - retryable: false, - suggested_action: None, - }; - - let rendered = format_user_visible_api_error("session-issue-32", &error); - assert!(rendered.contains("context_window_blocked"), "{rendered}"); - assert!( - rendered.contains("Trace req_ctx_456"), - "{rendered}" - ); - assert!( - rendered - .contains("Detail This model's maximum context length is 200000 tokens"), - "{rendered}" - ); - assert!(rendered.contains("Compact /compact"), "{rendered}"); - assert!( - rendered.contains("Fresh session /clear --confirm"), - "{rendered}" - ); - } - - #[test] - fn retry_wrapped_context_window_errors_keep_recovery_guidance() { - let error = ApiError::RetriesExhausted { - attempts: 2, - last_error: Box::new(ApiError::Api { - status: "413".parse().expect("status"), - error_type: Some("invalid_request_error".to_string()), - message: Some("Request is too large for this model's context window.".to_string()), - request_id: Some("req_ctx_retry_789".to_string()), - body: String::new(), - retryable: false, - suggested_action: None, - }), - }; - - let rendered = format_user_visible_api_error("session-issue-32", &error); - assert!(rendered.contains("Context window blocked"), "{rendered}"); - assert!(rendered.contains("context_window_blocked"), "{rendered}"); - assert!( - rendered.contains("Trace req_ctx_retry_789"), - "{rendered}" - ); - assert!( - rendered - .contains("Detail Request is too large for this model's context window."), - "{rendered}" - ); - assert!(rendered.contains("Compact /compact"), "{rendered}"); - assert!( - rendered.contains("Resume compact claw --resume session-issue-32 /compact"), - "{rendered}" - ); - } - - fn temp_dir() -> PathBuf { - use std::sync::atomic::{AtomicU64, Ordering}; - - static COUNTER: AtomicU64 = AtomicU64::new(0); - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time should be after epoch") - .as_nanos(); - let unique = COUNTER.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!("rusty-claude-cli-{nanos}-{unique}")) - } - - 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> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - fn with_current_dir(cwd: &Path, f: impl FnOnce() -> T) -> T { - let _guard = cwd_lock() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let previous = std::env::current_dir().expect("cwd should load"); - std::env::set_current_dir(cwd).expect("cwd should change"); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); - std::env::set_current_dir(previous).expect("cwd should restore"); - match result { - Ok(value) => value, - Err(payload) => std::panic::resume_unwind(payload), - } - } - - fn write_skill_fixture(root: &Path, name: &str, description: &str) { - let skill_dir = root.join(name); - fs::create_dir_all(&skill_dir).expect("skill dir should exist"); - fs::write( - skill_dir.join("SKILL.md"), - format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"), - ) - .expect("skill file should write"); - } - - fn write_plugin_fixture(root: &Path, name: &str, include_hooks: bool, include_lifecycle: bool) { - fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); - if include_hooks { - fs::create_dir_all(root.join("hooks")).expect("hooks dir"); - fs::write( - root.join("hooks").join("pre.sh"), - "#!/bin/sh\nprintf 'plugin pre hook'\n", - ) - .expect("write hook"); - } - if include_lifecycle { - fs::create_dir_all(root.join("lifecycle")).expect("lifecycle dir"); - fs::write( - root.join("lifecycle").join("init.sh"), - "#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n", - ) - .expect("write init lifecycle"); - fs::write( - root.join("lifecycle").join("shutdown.sh"), - "#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n", - ) - .expect("write shutdown lifecycle"); - } - - let hooks = if include_hooks { - ",\n \"hooks\": {\n \"PreToolUse\": [\"./hooks/pre.sh\"]\n }" - } else { - "" - }; - let lifecycle = if include_lifecycle { - ",\n \"lifecycle\": {\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }" - } else { - "" - }; - fs::write( - root.join(".claude-plugin").join("plugin.json"), - format!( - "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime plugin fixture\"{hooks}{lifecycle}\n}}" - ), - ) - .expect("write plugin manifest"); - } - #[test] - fn defaults_to_repl_when_no_args() { - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - assert_eq!( - parse_args(&[]).expect("args should parse"), - CliAction::Repl { - model: DEFAULT_MODEL.to_string(), - allowed_tools: None, - permission_mode: PermissionMode::DangerFullAccess, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn default_permission_mode_uses_project_config_when_env_is_unset() { - let _guard = env_lock(); - let root = temp_dir(); - let cwd = root.join("project"); - let config_home = root.join("config-home"); - std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); - std::fs::create_dir_all(&config_home).expect("config home should exist"); - std::fs::write( - cwd.join(".claw").join("settings.json"), - r#"{"permissionMode":"acceptEdits"}"#, - ) - .expect("project config should write"); - - let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); - let original_permission_mode = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok(); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - - let resolved = with_current_dir(&cwd, super::default_permission_mode); - - match original_config_home { - Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), - None => std::env::remove_var("CLAW_CONFIG_HOME"), - } - match original_permission_mode { - Some(value) => std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value), - None => std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"), - } - std::fs::remove_dir_all(root).expect("temp config root should clean up"); - - assert_eq!(resolved, PermissionMode::WorkspaceWrite); - } - - #[test] - fn env_permission_mode_overrides_project_config_default() { - let _guard = env_lock(); - let root = temp_dir(); - let cwd = root.join("project"); - let config_home = root.join("config-home"); - std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); - std::fs::create_dir_all(&config_home).expect("config home should exist"); - std::fs::write( - cwd.join(".claw").join("settings.json"), - r#"{"permissionMode":"acceptEdits"}"#, - ) - .expect("project config should write"); - - let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); - let original_permission_mode = std::env::var("RUSTY_CLAUDE_PERMISSION_MODE").ok(); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "read-only"); - - let resolved = with_current_dir(&cwd, super::default_permission_mode); - - match original_config_home { - Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), - None => std::env::remove_var("CLAW_CONFIG_HOME"), - } - match original_permission_mode { - Some(value) => std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", value), - None => std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"), - } - std::fs::remove_dir_all(root).expect("temp config root should clean up"); - - assert_eq!(resolved, PermissionMode::ReadOnly); - } - - #[test] - fn resolve_cli_auth_source_ignores_saved_oauth_credentials() { - let _guard = env_lock(); - let config_home = temp_dir(); - std::fs::create_dir_all(&config_home).expect("config home should exist"); - - let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); - let original_api_key = std::env::var("ANTHROPIC_API_KEY").ok(); - let original_auth_token = std::env::var("ANTHROPIC_AUTH_TOKEN").ok(); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - std::env::remove_var("ANTHROPIC_API_KEY"); - std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); - - save_oauth_credentials(&runtime::OAuthTokenSet { - access_token: "expired-access-token".to_string(), - refresh_token: Some("refresh-token".to_string()), - expires_at: Some(0), - scopes: vec!["org:create_api_key".to_string(), "user:profile".to_string()], - }) - .expect("save expired oauth credentials"); - - let error = super::resolve_cli_auth_source_for_cwd() - .expect_err("saved oauth should be ignored without env auth"); - - match original_config_home { - Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), - None => std::env::remove_var("CLAW_CONFIG_HOME"), - } - match original_api_key { - Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value), - None => std::env::remove_var("ANTHROPIC_API_KEY"), - } - match original_auth_token { - Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value), - None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"), - } - std::fs::remove_dir_all(config_home).expect("temp config home should clean up"); - - assert!(error.to_string().contains("ANTHROPIC_API_KEY")); - } - - #[test] - fn parses_prompt_subcommand() { - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - 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, - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn merge_prompt_with_stdin_returns_prompt_unchanged_when_no_pipe() { - // given - let prompt = "Review this"; - - // when - let merged = merge_prompt_with_stdin(prompt, None); - - // then - assert_eq!(merged, "Review this"); - } - - #[test] - fn merge_prompt_with_stdin_ignores_whitespace_only_pipe() { - // given - let prompt = "Review this"; - let piped = " \n\t\n "; - - // when - let merged = merge_prompt_with_stdin(prompt, Some(piped)); - - // then - assert_eq!(merged, "Review this"); - } - - #[test] - fn merge_prompt_with_stdin_appends_piped_content_as_context() { - // given - let prompt = "Review this"; - let piped = "fn main() { println!(\"hi\"); }\n"; - - // when - let merged = merge_prompt_with_stdin(prompt, Some(piped)); - - // then - assert_eq!(merged, "Review this\n\nfn main() { println!(\"hi\"); }"); - } - - #[test] - fn merge_prompt_with_stdin_trims_surrounding_whitespace_on_pipe() { - // given - let prompt = "Summarize"; - let piped = "\n\n some notes \n\n"; - - // when - let merged = merge_prompt_with_stdin(prompt, Some(piped)); - - // then - assert_eq!(merged, "Summarize\n\nsome notes"); - } - - #[test] - fn merge_prompt_with_stdin_returns_pipe_when_prompt_is_empty() { - // given - let prompt = ""; - let piped = "standalone body"; - - // when - let merged = merge_prompt_with_stdin(prompt, Some(piped)); - - // then - assert_eq!(merged, "standalone body"); - } - - #[test] - fn parses_bare_prompt_and_json_output_flag() { - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - let args = vec![ - "--output-format=json".to_string(), - "--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::Json, - allowed_tools: None, - permission_mode: PermissionMode::DangerFullAccess, - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn parses_compact_flag_for_prompt_mode() { - // given a bare prompt invocation that includes the --compact flag - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - let args = vec![ - "--compact".to_string(), - "summarize".to_string(), - "this".to_string(), - ]; - - // when parse_args interprets the flag - let parsed = parse_args(&args).expect("args should parse"); - - // then compact mode is propagated and other defaults stay unchanged - assert_eq!( - parsed, - CliAction::Prompt { - prompt: "summarize this".to_string(), - model: DEFAULT_MODEL.to_string(), - output_format: CliOutputFormat::Text, - allowed_tools: None, - permission_mode: PermissionMode::DangerFullAccess, - compact: true, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn prompt_subcommand_defaults_compact_to_false() { - // given a `prompt` subcommand invocation without --compact - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - let args = vec!["prompt".to_string(), "hello".to_string()]; - - // when parse_args runs - let parsed = parse_args(&args).expect("args should parse"); - - // then compact stays false (opt-in flag) - match parsed { - CliAction::Prompt { compact, .. } => assert!(!compact), - other => panic!("expected Prompt action, got {other:?}"), - } - } - - #[test] - fn resolves_model_aliases_in_args() { - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - 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, - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[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 user_defined_aliases_resolve_before_provider_dispatch() { - // given - let _guard = env_lock(); - let root = temp_dir(); - let cwd = root.join("project"); - let config_home = root.join("config-home"); - std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir should exist"); - std::fs::create_dir_all(&config_home).expect("config home should exist"); - std::fs::write( - cwd.join(".claw").join("settings.json"), - r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#, - ) - .expect("project config should write"); - - let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - - // when - let direct = with_current_dir(&cwd, || resolve_model_alias_with_config("fast")); - let chained = with_current_dir(&cwd, || resolve_model_alias_with_config("smart")); - let cross_provider = with_current_dir(&cwd, || resolve_model_alias_with_config("cheap")); - let unknown = with_current_dir(&cwd, || resolve_model_alias_with_config("unknown-model")); - let builtin = with_current_dir(&cwd, || resolve_model_alias_with_config("haiku")); - - match original_config_home { - Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), - None => std::env::remove_var("CLAW_CONFIG_HOME"), - } - std::fs::remove_dir_all(root).expect("temp config root should clean up"); - - // then - assert_eq!(direct, "claude-haiku-4-5-20251213"); - assert_eq!(chained, "claude-opus-4-6"); - assert_eq!(cross_provider, "grok-3-mini"); - assert_eq!(unknown, "unknown-model"); - assert_eq!(builtin, "claude-haiku-4-5-20251213"); - } - - #[test] - fn parses_version_flags_without_initializing_prompt_mode() { - assert_eq!( - parse_args(&["--version".to_string()]).expect("args should parse"), - CliAction::Version { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["-V".to_string()]).expect("args should parse"), - CliAction::Version { - output_format: CliOutputFormat::Text, - } - ); - } - - #[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, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn dangerously_skip_permissions_flag_forces_danger_full_access_in_repl() { - let _guard = env_lock(); - std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "read-only"); - let args = vec!["--dangerously-skip-permissions".to_string()]; - let parsed = parse_args(&args).expect("args should parse"); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - - assert_eq!( - parsed, - CliAction::Repl { - model: DEFAULT_MODEL.to_string(), - allowed_tools: None, - permission_mode: PermissionMode::DangerFullAccess, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn dangerously_skip_permissions_flag_applies_to_prompt_subcommand() { - let _guard = env_lock(); - std::env::set_var("RUSTY_CLAUDE_PERMISSION_MODE", "read-only"); - let args = vec![ - "--dangerously-skip-permissions".to_string(), - "prompt".to_string(), - "do".to_string(), - "the".to_string(), - "thing".to_string(), - ]; - let parsed = parse_args(&args).expect("args should parse"); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - - assert_eq!( - parsed, - CliAction::Prompt { - prompt: "do the thing".to_string(), - model: DEFAULT_MODEL.to_string(), - output_format: CliOutputFormat::Text, - allowed_tools: None, - permission_mode: PermissionMode::DangerFullAccess, - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn parses_allowed_tools_flags_with_aliases_and_lists() { - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - 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, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[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 rejects_empty_allowed_tools_flag() { - for raw in ["", ",,"] { - let error = parse_args(&["--allowedTools".to_string(), raw.to_string()]) - .expect_err("empty allowedTools should be rejected"); - assert!( - error.contains("--allowedTools was provided with no usable tool names"), - "unexpected error for {raw:?}: {error}" - ); - } - } - - #[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(), - output_format: CliOutputFormat::Text, - } - ); - } - - #[test] - fn removed_login_and_logout_subcommands_error_helpfully() { - let login = parse_args(&["login".to_string()]).expect_err("login should be removed"); - assert!(login.contains("ANTHROPIC_API_KEY")); - let logout = parse_args(&["logout".to_string()]).expect_err("logout should be removed"); - assert!(logout.contains("ANTHROPIC_AUTH_TOKEN")); - assert_eq!( - parse_args(&["doctor".to_string()]).expect("doctor should parse"), - CliAction::Doctor { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["state".to_string()]).expect("state should parse"), - CliAction::State { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&[ - "state".to_string(), - "--output-format".to_string(), - "json".to_string() - ]) - .expect("state --output-format json should parse"), - CliAction::State { - output_format: CliOutputFormat::Json, - } - ); - assert_eq!( - parse_args(&["init".to_string()]).expect("init should parse"), - CliAction::Init { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["agents".to_string()]).expect("agents should parse"), - CliAction::Agents { - args: None, - output_format: CliOutputFormat::Text - } - ); - assert_eq!( - parse_args(&["mcp".to_string()]).expect("mcp should parse"), - CliAction::Mcp { - args: None, - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["skills".to_string()]).expect("skills should parse"), - CliAction::Skills { - args: None, - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&[ - "skills".to_string(), - "help".to_string(), - "overview".to_string() - ]) - .expect("skills help overview should invoke"), - CliAction::Prompt { - prompt: "$help overview".to_string(), - model: DEFAULT_MODEL.to_string(), - output_format: CliOutputFormat::Text, - allowed_tools: None, - permission_mode: crate::default_permission_mode(), - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - assert_eq!( - parse_args(&["agents".to_string(), "--help".to_string()]) - .expect("agents help should parse"), - CliAction::Agents { - args: Some("--help".to_string()), - output_format: CliOutputFormat::Text, - } - ); - // #145: `plugins` must parse as CliAction::Plugins (not fall through - // to the prompt path, which would hit the Anthropic API for a purely - // local introspection command). - assert_eq!( - parse_args(&["plugins".to_string()]).expect("plugins should parse"), - CliAction::Plugins { - action: None, - target: None, - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["plugins".to_string(), "list".to_string()]) - .expect("plugins list should parse"), - CliAction::Plugins { - action: Some("list".to_string()), - target: None, - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&[ - "plugins".to_string(), - "enable".to_string(), - "example-bundled".to_string(), - ]) - .expect("plugins enable should parse"), - CliAction::Plugins { - action: Some("enable".to_string()), - target: Some("example-bundled".to_string()), - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&[ - "plugins".to_string(), - "--output-format".to_string(), - "json".to_string(), - ]) - .expect("plugins --output-format json should parse"), - CliAction::Plugins { - action: None, - target: None, - output_format: CliOutputFormat::Json, - } - ); - // #146: `config` and `diff` must parse as standalone CLI actions, - // not fall through to the "is a slash command" error. Both are - // pure-local read-only introspection. - assert_eq!( - parse_args(&["config".to_string()]).expect("config should parse"), - CliAction::Config { - section: None, - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["config".to_string(), "env".to_string()]) - .expect("config env should parse"), - CliAction::Config { - section: Some("env".to_string()), - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&[ - "config".to_string(), - "--output-format".to_string(), - "json".to_string(), - ]) - .expect("config --output-format json should parse"), - CliAction::Config { - section: None, - output_format: CliOutputFormat::Json, - } - ); - assert_eq!( - parse_args(&["diff".to_string()]).expect("diff should parse"), - CliAction::Diff { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&[ - "diff".to_string(), - "--output-format".to_string(), - "json".to_string(), - ]) - .expect("diff --output-format json should parse"), - CliAction::Diff { - output_format: CliOutputFormat::Json, - } - ); - // #147: empty / whitespace-only positional args must be rejected - // with a specific error instead of falling through to the prompt - // path (where they surface a misleading "missing Anthropic - // credentials" error or burn API tokens on an empty prompt). - let empty_err = - parse_args(&["".to_string()]).expect_err("empty positional arg should be rejected"); - assert!( - empty_err.starts_with("empty prompt:"), - "empty-arg error should be specific, got: {empty_err}" - ); - let whitespace_err = parse_args(&[" ".to_string()]) - .expect_err("whitespace-only positional arg should be rejected"); - assert!( - whitespace_err.starts_with("empty prompt:"), - "whitespace-only error should be specific, got: {whitespace_err}" - ); - let multi_empty_err = parse_args(&["".to_string(), "".to_string()]) - .expect_err("multiple empty positional args should be rejected"); - assert!( - multi_empty_err.starts_with("empty prompt:"), - "multi-empty error should be specific, got: {multi_empty_err}" - ); - // Typo guard from #108 must still take precedence for non-empty - // single-word non-prompt-looking inputs. - let typo_err = parse_args(&["sttaus".to_string()]) - .expect_err("typo'd subcommand should be caught by #108 guard"); - assert!( - typo_err.starts_with("unknown subcommand:"), - "typo guard should fire for 'sttaus', got: {typo_err}" - ); - // #148: `--model` flag must be captured as model_flag_raw so status - // JSON can report provenance (source: flag, raw: ). - match parse_args(&[ - "--model".to_string(), - "sonnet".to_string(), - "status".to_string(), - ]) - .expect("--model sonnet status should parse") - { - CliAction::Status { - model, - model_flag_raw, - .. - } => { - assert_eq!(model, "claude-sonnet-4-6", "sonnet alias should resolve"); - assert_eq!( - model_flag_raw.as_deref(), - Some("sonnet"), - "raw flag input should be preserved" - ); - } - other => panic!("expected CliAction::Status, got: {other:?}"), - } - // --model= form should also capture raw. - match parse_args(&[ - "--model=anthropic/claude-opus-4-6".to_string(), - "status".to_string(), - ]) - .expect("--model=... status should parse") - { - CliAction::Status { - model, - model_flag_raw, - .. - } => { - assert_eq!(model, "anthropic/claude-opus-4-6"); - assert_eq!( - model_flag_raw.as_deref(), - Some("anthropic/claude-opus-4-6"), - "--model= form should also preserve raw input" - ); - } - other => panic!("expected CliAction::Status, got: {other:?}"), - } - } - - #[test] - fn dump_manifests_subcommand_accepts_explicit_manifest_dir() { - assert_eq!( - parse_args(&[ - "dump-manifests".to_string(), - "--manifests-dir".to_string(), - "/tmp/upstream".to_string(), - ]) - .expect("dump-manifests should parse"), - CliAction::DumpManifests { - output_format: CliOutputFormat::Text, - manifests_dir: Some(PathBuf::from("/tmp/upstream")), - } - ); - assert_eq!( - parse_args(&[ - "dump-manifests".to_string(), - "--manifests-dir=/tmp/upstream".to_string() - ]) - .expect("inline dump-manifests flag should parse"), - CliAction::DumpManifests { - output_format: CliOutputFormat::Text, - manifests_dir: Some(PathBuf::from("/tmp/upstream")), - } - ); - } - - #[test] - fn parses_acp_command_surfaces() { - assert_eq!( - parse_args(&["acp".to_string()]).expect("acp should parse"), - CliAction::Acp { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["acp".to_string(), "serve".to_string()]).expect("acp serve should parse"), - CliAction::Acp { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["--acp".to_string()]).expect("--acp should parse"), - CliAction::Acp { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["-acp".to_string()]).expect("-acp should parse"), - CliAction::Acp { - output_format: CliOutputFormat::Text, - } - ); - } - - #[test] - fn local_command_help_flags_stay_on_the_local_parser_path() { - assert_eq!( - parse_args(&["status".to_string(), "--help".to_string()]) - .expect("status help should parse"), - CliAction::HelpTopic(LocalHelpTopic::Status) - ); - assert_eq!( - parse_args(&["sandbox".to_string(), "-h".to_string()]) - .expect("sandbox help should parse"), - CliAction::HelpTopic(LocalHelpTopic::Sandbox) - ); - assert_eq!( - parse_args(&["doctor".to_string(), "--help".to_string()]) - .expect("doctor help should parse"), - CliAction::HelpTopic(LocalHelpTopic::Doctor) - ); - assert_eq!( - parse_args(&["acp".to_string(), "--help".to_string()]).expect("acp help should parse"), - CliAction::HelpTopic(LocalHelpTopic::Acp) - ); - } - - #[test] - fn subcommand_help_flag_has_one_contract_across_all_subcommands_141() { - // #141: every documented subcommand must resolve ` --help` - // to a subcommand-specific help topic, never to global help, never to - // an "unknown option" error, never to the subcommand's primary output. - let cases: &[(&str, LocalHelpTopic)] = &[ - ("status", LocalHelpTopic::Status), - ("sandbox", LocalHelpTopic::Sandbox), - ("doctor", LocalHelpTopic::Doctor), - ("acp", LocalHelpTopic::Acp), - ("init", LocalHelpTopic::Init), - ("state", LocalHelpTopic::State), - ("export", LocalHelpTopic::Export), - ("version", LocalHelpTopic::Version), - ("system-prompt", LocalHelpTopic::SystemPrompt), - ("dump-manifests", LocalHelpTopic::DumpManifests), - ("bootstrap-plan", LocalHelpTopic::BootstrapPlan), - ]; - for (subcommand, expected_topic) in cases { - for flag in ["--help", "-h"] { - let parsed = parse_args(&[subcommand.to_string(), flag.to_string()]) - .unwrap_or_else(|error| { - panic!("`{subcommand} {flag}` should parse as help but errored: {error}") - }); - assert_eq!( - parsed, - CliAction::HelpTopic(*expected_topic), - "`{subcommand} {flag}` should resolve to HelpTopic({expected_topic:?})" - ); - } - // And the rendered help must actually mention the subcommand name - // (or its canonical title) so users know they got the right help. - let rendered = render_help_topic(*expected_topic); - assert!( - !rendered.is_empty(), - "{subcommand} help text should not be empty" - ); - assert!( - rendered.contains("Usage"), - "{subcommand} help text should contain a Usage line" - ); - } - } - - #[test] - fn status_degrades_gracefully_on_malformed_mcp_config_143() { - // #143: previously `claw status` hard-failed on any config parse error, - // taking down the entire health surface for one malformed MCP entry. - // `claw doctor` already degrades gracefully; this test locks `status` - // to the same contract. - let _guard = env_lock(); - let root = temp_dir(); - let cwd = root.join("project-with-malformed-mcp"); - std::fs::create_dir_all(&cwd).expect("project dir should exist"); - // One valid server + one malformed entry missing `command`. - std::fs::write( - cwd.join(".claw.json"), - r#"{ - "mcpServers": { - "everything": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-everything"]}, - "missing-command": {"args": ["arg-only-no-command"]} - } -} -"#, - ) - .expect("write malformed .claw.json"); - - let context = with_current_dir(&cwd, || { - super::status_context(None) - .expect("status_context should not hard-fail on config parse errors (#143)") - }); - - // Phase 1 contract: config_load_error is populated with the parse error. - let err = context - .config_load_error - .as_ref() - .expect("config_load_error should be Some when config parse fails"); - assert!( - err.contains("mcpServers.missing-command"), - "config_load_error should name the malformed field path: {err}" - ); - assert!( - err.contains("missing string field command"), - "config_load_error should carry the underlying parse error: {err}" - ); - - // Phase 1 contract: workspace/git/sandbox fields are still populated - // (independent of config parse). Sandbox falls back to defaults. - assert_eq!(context.cwd, cwd.canonicalize().unwrap_or(cwd.clone())); - assert_eq!( - context.loaded_config_files, 0, - "loaded_config_files should be 0 when config parse fails" - ); - assert!( - context.discovered_config_files > 0, - "discovered_config_files should still count the file that failed to parse" - ); - - // JSON output contract: top-level `status: "degraded"` + config_load_error field. - let usage = super::StatusUsage { - message_count: 0, - turns: 0, - latest: runtime::TokenUsage::default(), - cumulative: runtime::TokenUsage::default(), - estimated_tokens: 0, - }; - let json = super::status_json_value( - Some("test-model"), - usage, - "workspace-write", - &context, - None, - None, - ); - assert_eq!( - json.get("status").and_then(|v| v.as_str()), - Some("degraded"), - "top-level status marker should be 'degraded' when config parse failed: {json}" - ); - assert!( - json.get("config_load_error") - .and_then(|v| v.as_str()) - .is_some_and(|s| s.contains("mcpServers.missing-command")), - "config_load_error should surface in JSON output: {json}" - ); - // Independent fields still populated. - assert_eq!( - json.get("model").and_then(|v| v.as_str()), - Some("test-model") - ); - assert!( - json.get("workspace").is_some(), - "workspace field still reported" - ); - assert!( - json.get("sandbox").is_some(), - "sandbox field still reported" - ); - assert_eq!( - json.pointer("/allowed_tools/source") - .and_then(|v| v.as_str()), - Some("default"), - "default status should expose unrestricted tool source: {json}" - ); - assert_eq!( - json.pointer("/allowed_tools/restricted") - .and_then(|v| v.as_bool()), - Some(false), - "default status should expose unrestricted tool state: {json}" - ); - - let allowed: super::AllowedToolSet = ["read_file", "grep_search"] - .into_iter() - .map(str::to_string) - .collect(); - let restricted_json = super::status_json_value( - Some("test-model"), - usage, - "workspace-write", - &context, - None, - Some(&allowed), - ); - assert_eq!( - restricted_json - .pointer("/allowed_tools/source") - .and_then(|v| v.as_str()), - Some("flag"), - "flag status should expose allow-list source: {restricted_json}" - ); - assert_eq!( - restricted_json - .pointer("/allowed_tools/entries") - .and_then(|v| v.as_array()) - .map(Vec::len), - Some(2), - "flag status should expose allow-list entries: {restricted_json}" - ); - - // Clean path: no config error → status: "ok", config_load_error: null. - let clean_cwd = root.join("project-with-clean-config"); - std::fs::create_dir_all(&clean_cwd).expect("clean project dir"); - let clean_context = with_current_dir(&clean_cwd, || { - super::status_context(None).expect("clean status_context should succeed") - }); - assert!(clean_context.config_load_error.is_none()); - let clean_json = super::status_json_value( - Some("test-model"), - usage, - "workspace-write", - &clean_context, - None, - None, - ); - assert_eq!( - clean_json.get("status").and_then(|v| v.as_str()), - Some("ok"), - "clean run should report status: 'ok'" - ); - } - - #[test] - fn state_error_surfaces_actionable_worker_commands_139() { - // #139: the error for missing `.claw/worker-state.json` must name - // the concrete commands that produce worker state, otherwise claws - // have no discoverable path from the error to a fix. - let _guard = env_lock(); - let root = temp_dir(); - let cwd = root.join("project-with-no-state"); - std::fs::create_dir_all(&cwd).expect("project dir should exist"); - - let error = with_current_dir(&cwd, || { - super::run_worker_state(CliOutputFormat::Text).expect_err("missing state should error") - }); - let message = error.to_string(); - - // Keep the original locator so scripts grepping for it still work. - assert!( - message.contains("no worker state file found at"), - "error should keep the canonical prefix: {message}" - ); - // New actionable hints — this is what #139 is fixing. - assert!( - message.contains("claw prompt"), - "error should name `claw prompt ` as a producer: {message}" - ); - assert!( - message.contains("REPL"), - "error should mention the interactive REPL as a producer: {message}" - ); - assert!( - message.contains("claw state"), - "error should tell the user what to rerun once state exists: {message}" - ); - // And the State --help topic must document the worker relationship - // so claws can discover the contract without hitting the error first. - let state_help = render_help_topic(LocalHelpTopic::State); - assert!( - state_help.contains("Produces state"), - "state help must document how state is produced: {state_help}" - ); - assert!( - state_help.contains("claw prompt"), - "state help must name `claw prompt ` as a producer: {state_help}" - ); - } - - #[test] - fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() { - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - assert_eq!( - parse_args(&["help".to_string()]).expect("help should parse"), - CliAction::Help { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["version".to_string()]).expect("version should parse"), - CliAction::Version { - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["status".to_string()]).expect("status should parse"), - CliAction::Status { - model: DEFAULT_MODEL.to_string(), - model_flag_raw: None, // #148: no --model flag passed - permission_mode: PermissionMode::DangerFullAccess, - output_format: CliOutputFormat::Text, - allowed_tools: None, - } - ); - assert_eq!( - parse_args(&["sandbox".to_string()]).expect("sandbox should parse"), - CliAction::Sandbox { - output_format: CliOutputFormat::Text, - } - ); - // #152: `--json` on diagnostic verbs should hint the correct flag. - let err = parse_args(&["doctor".to_string(), "--json".to_string()]) - .expect_err("`doctor --json` should fail with hint"); - assert!( - err.contains("unrecognized argument `--json` for subcommand `doctor`"), - "error should name the verb: {err}" - ); - assert!( - err.contains("Did you mean `--output-format json`?"), - "error should hint the correct flag: {err}" - ); - // Other unrecognized args should NOT trigger the --json hint. - let err_other = parse_args(&["doctor".to_string(), "garbage".to_string()]) - .expect_err("`doctor garbage` should fail without --json hint"); - assert!( - !err_other.contains("--output-format json"), - "unrelated args should not trigger --json hint: {err_other}" - ); - // #154: model syntax error should hint at provider prefix when applicable - let err_gpt = parse_args(&[ - "prompt".to_string(), - "test".to_string(), - "--model".to_string(), - "gpt-4".to_string(), - ]) - .expect_err("`--model gpt-4` should fail with OpenAI hint"); - assert!( - err_gpt.contains("Did you mean `openai/gpt-4`?"), - "GPT model error should hint openai/ prefix: {err_gpt}" - ); - assert!( - err_gpt.contains("OPENAI_API_KEY"), - "GPT model error should mention env var: {err_gpt}" - ); - // Unrelated invalid model should NOT get a hint - let err_garbage = parse_args(&[ - "prompt".to_string(), - "test".to_string(), - "--model".to_string(), - "asdfgh".to_string(), - ]) - .expect_err("`--model asdfgh` should fail"); - assert!( - !err_garbage.contains("Did you mean"), - "Unrelated model errors should not get a hint: {err_garbage}" - ); - } - - #[test] - fn classify_error_kind_returns_correct_discriminants() { - // #77: error kind classification for JSON error payloads - assert_eq!( - classify_error_kind("missing Anthropic credentials; export ..."), - "missing_credentials" - ); - assert_eq!( - classify_error_kind("no worker state file found at /tmp/..."), - "missing_worker_state" - ); - assert_eq!( - classify_error_kind("session not found: abc123"), - "session_not_found" - ); - assert_eq!( - classify_error_kind("failed to restore session: no managed sessions found"), - "session_load_failed" - ); - assert_eq!( - classify_error_kind("unrecognized argument `--foo` for subcommand `doctor`"), - "cli_parse" - ); - assert_eq!( - classify_error_kind("invalid model syntax: 'gpt-4'. Expected ..."), - "invalid_model_syntax" - ); - assert_eq!( - classify_error_kind("unsupported resumed command: /blargh"), - "unsupported_resumed_command" - ); - assert_eq!( - classify_error_kind("api failed after 3 attempts: ..."), - "api_http_error" - ); - assert_eq!( - classify_error_kind("something completely unknown"), - "unknown" - ); - } - - #[test] - fn split_error_hint_separates_reason_from_runbook() { - // #77: short reason / hint separation for JSON error payloads - let (short, hint) = split_error_hint("missing credentials\nHint: export ANTHROPIC_API_KEY"); - assert_eq!(short, "missing credentials"); - assert_eq!(hint, Some("Hint: export ANTHROPIC_API_KEY".to_string())); - - let (short, hint) = split_error_hint("simple error with no hint"); - assert_eq!(short, "simple error with no hint"); - assert_eq!(hint, None); - } - - #[test] - fn parses_bare_export_subcommand_targeting_latest_session() { - // given - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - let args = vec!["export".to_string()]; - - // when - let parsed = parse_args(&args).expect("bare export should parse"); - - // then - assert_eq!( - parsed, - CliAction::Export { - session_reference: LATEST_SESSION_REFERENCE.to_string(), - output_path: None, - output_format: CliOutputFormat::Text, - } - ); - } - - #[test] - fn parses_export_subcommand_with_positional_output_path() { - // given - let args = vec!["export".to_string(), "conversation.md".to_string()]; - - // when - let parsed = parse_args(&args).expect("export with path should parse"); - - // then - assert_eq!( - parsed, - CliAction::Export { - session_reference: LATEST_SESSION_REFERENCE.to_string(), - output_path: Some(PathBuf::from("conversation.md")), - output_format: CliOutputFormat::Text, - } - ); - } - - #[test] - fn parses_export_subcommand_with_session_and_output_flags() { - // given - let args = vec![ - "export".to_string(), - "--session".to_string(), - "session-alpha".to_string(), - "--output".to_string(), - "/tmp/share.md".to_string(), - ]; - - // when - let parsed = parse_args(&args).expect("export flags should parse"); - - // then - assert_eq!( - parsed, - CliAction::Export { - session_reference: "session-alpha".to_string(), - output_path: Some(PathBuf::from("/tmp/share.md")), - output_format: CliOutputFormat::Text, - } - ); - } - - #[test] - fn parses_export_subcommand_with_inline_flag_values() { - // given - let args = vec![ - "export".to_string(), - "--session=session-beta".to_string(), - "--output=/tmp/beta.md".to_string(), - ]; - - // when - let parsed = parse_args(&args).expect("export inline flags should parse"); - - // then - assert_eq!( - parsed, - CliAction::Export { - session_reference: "session-beta".to_string(), - output_path: Some(PathBuf::from("/tmp/beta.md")), - output_format: CliOutputFormat::Text, - } - ); - } - - #[test] - fn parses_export_subcommand_with_json_output_format() { - // given - let args = vec![ - "--output-format=json".to_string(), - "export".to_string(), - "/tmp/notes.md".to_string(), - ]; - - // when - let parsed = parse_args(&args).expect("json export should parse"); - - // then - assert_eq!( - parsed, - CliAction::Export { - session_reference: LATEST_SESSION_REFERENCE.to_string(), - output_path: Some(PathBuf::from("/tmp/notes.md")), - output_format: CliOutputFormat::Json, - } - ); - } - - #[test] - fn rejects_unknown_export_options_with_helpful_message() { - // given - let args = vec!["export".to_string(), "--bogus".to_string()]; - - // when - let error = parse_args(&args).expect_err("unknown export option should fail"); - - // then - assert!(error.contains("unknown export option: --bogus")); - } - - #[test] - fn rejects_export_with_extra_positional_after_path() { - // given - let args = vec![ - "export".to_string(), - "first.md".to_string(), - "second.md".to_string(), - ]; - - // when - let error = parse_args(&args).expect_err("multiple positionals should fail"); - - // then - assert!(error.contains("unexpected export argument: second.md")); - } - - #[test] - fn parse_export_args_helper_defaults_to_latest_reference_and_no_output() { - // given - let args: Vec = vec![]; - - // when - let parsed = parse_export_args(&args, CliOutputFormat::Text) - .expect("empty export args should parse"); - - // then - assert_eq!( - parsed, - CliAction::Export { - session_reference: LATEST_SESSION_REFERENCE.to_string(), - output_path: None, - output_format: CliOutputFormat::Text, - } - ); - } - - #[test] - fn render_session_markdown_includes_header_and_summarized_tool_calls() { - // given - let mut session = Session::new(); - session.session_id = "session-export-test".to_string(); - session.messages = vec![ - ConversationMessage::user_text("How do I list files?"), - ConversationMessage::assistant(vec![ - ContentBlock::Text { - text: "I'll run a tool.".to_string(), - }, - ContentBlock::ToolUse { - id: "toolu_abcdefghijklmnop".to_string(), - name: "bash".to_string(), - input: r#"{"command":"ls -la"}"#.to_string(), - }, - ]), - ConversationMessage { - role: MessageRole::Tool, - blocks: vec![ContentBlock::ToolResult { - tool_use_id: "toolu_abcdefghijklmnop".to_string(), - tool_name: "bash".to_string(), - output: "total 8\ndrwxr-xr-x 2 user staff 64 Apr 7 12:00 .".to_string(), - is_error: false, - }], - usage: None, - }, - ]; - - // when - let markdown = render_session_markdown( - &session, - "session-export-test", - std::path::Path::new("/tmp/sessions/session-export-test.jsonl"), - ); - - // then - assert!(markdown.starts_with("# Conversation Export")); - assert!(markdown.contains("- **Session**: `session-export-test`")); - assert!(markdown.contains("- **Messages**: 3")); - assert!(markdown.contains("## 1. User")); - assert!(markdown.contains("How do I list files?")); - assert!(markdown.contains("## 2. Assistant")); - assert!(markdown.contains("**Tool call** `bash`")); - assert!(markdown.contains("toolu_abcdef…")); - assert!(markdown.contains("ls -la")); - assert!(markdown.contains("## 3. Tool")); - assert!(markdown.contains("**Tool result** `bash`")); - assert!(markdown.contains("ok")); - assert!(markdown.contains("total 8")); - } - - #[test] - fn render_session_markdown_marks_tool_errors_and_skips_empty_summaries() { - // given - let mut session = Session::new(); - session.session_id = "errs".to_string(); - session.messages = vec![ConversationMessage { - role: MessageRole::Tool, - blocks: vec![ContentBlock::ToolResult { - tool_use_id: "short".to_string(), - tool_name: "read_file".to_string(), - output: " ".to_string(), - is_error: true, - }], - usage: None, - }]; - - // when - let markdown = - render_session_markdown(&session, "errs", std::path::Path::new("errs.jsonl")); - - // then - assert!(markdown.contains("**Tool result** `read_file` _(id `short`, error)_")); - // an empty summary should not produce a stray blockquote line - assert!(!markdown.contains("> \n")); - } - - #[test] - fn summarize_tool_payload_for_markdown_compacts_json_and_truncates_overflow() { - // given - let json_payload = r#"{ - "command": "ls -la", - "cwd": "/tmp" - }"#; - let long_payload = "a".repeat(600); - - // when - let compacted = summarize_tool_payload_for_markdown(json_payload); - let truncated = summarize_tool_payload_for_markdown(&long_payload); - - // then - assert_eq!(compacted, r#"{"command":"ls -la","cwd":"/tmp"}"#); - assert!(truncated.ends_with('…')); - assert!(truncated.chars().count() <= 281); - } - - #[test] - fn short_tool_id_truncates_long_identifiers_with_ellipsis() { - // given - let long = "toolu_01ABCDEFGHIJKLMN"; - let short = "tool_1"; - - // when - let trimmed_long = short_tool_id(long); - let trimmed_short = short_tool_id(short); - - // then - assert_eq!(trimmed_long, "toolu_01ABCD…"); - assert_eq!(trimmed_short, "tool_1"); - } - - #[test] - fn parses_json_output_for_mcp_and_skills_commands() { - assert_eq!( - parse_args(&["--output-format=json".to_string(), "mcp".to_string()]) - .expect("json mcp should parse"), - CliAction::Mcp { - args: None, - output_format: CliOutputFormat::Json, - } - ); - assert_eq!( - parse_args(&[ - "--output-format=json".to_string(), - "/skills".to_string(), - "help".to_string(), - ]) - .expect("json /skills help should parse"), - CliAction::Skills { - args: Some("help".to_string()), - output_format: CliOutputFormat::Json, - } - ); - } - - #[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() { - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - // Input is ["--model", "opus", "please", "debug", "this"] so the joined - // prompt shorthand must stay a normal multi-word prompt while still - // honoring alias validation at parse time. - assert_eq!( - parse_args(&[ - "--model".to_string(), - "opus".to_string(), - "please".to_string(), - "debug".to_string(), - "this".to_string(), - ]) - .expect("prompt shorthand should still work"), - CliAction::Prompt { - prompt: "please debug this".to_string(), - model: "claude-opus-4-6".to_string(), - output_format: CliOutputFormat::Text, - allowed_tools: None, - permission_mode: crate::default_permission_mode(), - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn parses_direct_agents_mcp_and_skills_slash_commands() { - assert_eq!( - parse_args(&["/agents".to_string()]).expect("/agents should parse"), - CliAction::Agents { - args: None, - output_format: CliOutputFormat::Text - } - ); - assert_eq!( - parse_args(&["/mcp".to_string(), "show".to_string(), "demo".to_string()]) - .expect("/mcp show demo should parse"), - CliAction::Mcp { - args: Some("show demo".to_string()), - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["/skills".to_string()]).expect("/skills should parse"), - CliAction::Skills { - args: None, - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["/skill".to_string()]).expect("/skill should parse"), - CliAction::Skills { - args: None, - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["/skills".to_string(), "help".to_string()]) - .expect("/skills help should parse"), - CliAction::Skills { - args: Some("help".to_string()), - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["/skill".to_string(), "list".to_string()]) - .expect("/skill list should parse"), - CliAction::Skills { - args: Some("list".to_string()), - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&[ - "/skills".to_string(), - "help".to_string(), - "overview".to_string() - ]) - .expect("/skills help overview should invoke"), - CliAction::Prompt { - prompt: "$help overview".to_string(), - model: DEFAULT_MODEL.to_string(), - output_format: CliOutputFormat::Text, - allowed_tools: None, - permission_mode: crate::default_permission_mode(), - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - assert_eq!( - parse_args(&[ - "/skills".to_string(), - "install".to_string(), - "./fixtures/help-skill".to_string(), - ]) - .expect("/skills install should parse"), - CliAction::Skills { - args: Some("install ./fixtures/help-skill".to_string()), - output_format: CliOutputFormat::Text, - } - ); - assert_eq!( - parse_args(&["/skills".to_string(), "/test".to_string()]) - .expect("/skills /test should normalize to a single skill prompt prefix"), - CliAction::Prompt { - prompt: "$test".to_string(), - model: DEFAULT_MODEL.to_string(), - output_format: CliOutputFormat::Text, - allowed_tools: None, - permission_mode: crate::default_permission_mode(), - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - 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 direct_slash_commands_surface_shared_validation_errors() { - let compact_error = parse_args(&["/compact".to_string(), "now".to_string()]) - .expect_err("invalid /compact shape should be rejected"); - assert!(compact_error.contains("Unexpected arguments for /compact.")); - assert!(compact_error.contains("Usage /compact")); - - let plugins_error = parse_args(&[ - "/plugins".to_string(), - "list".to_string(), - "extra".to_string(), - ]) - .expect_err("invalid /plugins list shape should be rejected"); - assert!(plugins_error.contains("Usage: /plugin list")); - assert!(plugins_error.contains("Aliases /plugins, /marketplace")); - } - - #[test] - fn formats_unknown_slash_command_with_suggestions() { - let report = format_unknown_slash_command_message("statsu"); - assert!(report.contains("unknown slash command: /statsu")); - assert!(report.contains("Did you mean")); - assert!(report.contains("Use /help")); - } - - #[test] - fn typoed_doctor_subcommand_returns_did_you_mean_error() { - let error = parse_args(&["doctorr".to_string()]).expect_err("doctorr should error"); - assert!(error.contains("unknown subcommand: doctorr.")); - assert!(error.contains("Did you mean")); - assert!(error.contains("doctor")); - } - - #[test] - fn typoed_skills_subcommand_returns_did_you_mean_error() { - let error = parse_args(&["skilsl".to_string()]).expect_err("skilsl should error"); - assert!(error.contains("unknown subcommand: skilsl.")); - assert!(error.contains("skills")); - } - - #[test] - fn typoed_status_subcommand_returns_did_you_mean_error() { - let error = parse_args(&["statuss".to_string()]).expect_err("statuss should error"); - assert!(error.contains("unknown subcommand: statuss.")); - assert!(error.contains("status")); - } - - #[test] - fn typoed_export_subcommand_returns_did_you_mean_error() { - let error = parse_args(&["exporrt".to_string()]).expect_err("exporrt should error"); - assert!(error.contains("unknown subcommand: exporrt.")); - assert!(error.contains("Did you mean")); - assert!(error.contains("export")); - } - - #[test] - fn typoed_mcp_subcommand_returns_did_you_mean_error() { - let error = parse_args(&["mcpp".to_string()]).expect_err("mcpp should error"); - assert!(error.contains("unknown subcommand: mcpp.")); - assert!(error.contains("mcp")); - } - - #[test] - fn multi_word_prompt_still_bypasses_subcommand_typo_guard() { - assert_eq!( - parse_args(&[ - "hello".to_string(), - "world".to_string(), - "this".to_string(), - "is".to_string(), - "a".to_string(), - "prompt".to_string(), - ]) - .expect("multi-word prompt should still parse"), - CliAction::Prompt { - prompt: "hello world this is a prompt".to_string(), - model: DEFAULT_MODEL.to_string(), - output_format: CliOutputFormat::Text, - allowed_tools: None, - permission_mode: crate::default_permission_mode(), - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn prompt_subcommand_allows_literal_typo_word() { - assert_eq!( - parse_args(&["prompt".to_string(), "doctorr".to_string()]) - .expect("explicit prompt subcommand should allow literal typo word"), - CliAction::Prompt { - prompt: "doctorr".to_string(), - model: DEFAULT_MODEL.to_string(), - output_format: CliOutputFormat::Text, - allowed_tools: None, - permission_mode: PermissionMode::DangerFullAccess, - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn punctuation_bearing_single_token_still_dispatches_to_prompt() { - // #140: Guard against test pollution — isolate cwd + env so this test - // doesn't pick up a stale .claw/settings.json from other tests that - // may have set `permissionMode: acceptEdits` in a shared cwd. - let _guard = env_lock(); - let root = temp_dir(); - let cwd = root.join("project"); - std::fs::create_dir_all(&cwd).expect("project dir should exist"); - let result = with_current_dir(&cwd, || { - parse_args(&["PARITY_SCENARIO:bash_permission_prompt_approved".to_string()]) - .expect("scenario token should still dispatch to prompt") - }); - assert_eq!( - result, - CliAction::Prompt { - prompt: "PARITY_SCENARIO:bash_permission_prompt_approved".to_string(), - model: DEFAULT_MODEL.to_string(), - output_format: CliOutputFormat::Text, - allowed_tools: None, - permission_mode: PermissionMode::DangerFullAccess, - compact: false, - base_commit: None, - reasoning_effort: None, - allow_broad_cwd: false, - } - ); - } - - #[test] - fn formats_namespaced_omc_slash_command_with_contract_guidance() { - let report = format_unknown_slash_command_message("oh-my-claudecode:hud"); - assert!(report.contains("unknown slash command: /oh-my-claudecode:hud")); - assert!(report.contains("Claude Code/OMC plugin command")); - assert!(report.contains("plugin slash commands")); - assert!(report.contains("statusline")); - assert!(report.contains("session hooks")); - } - - #[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()], - output_format: CliOutputFormat::Text, - } - ); - } - - #[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![], - output_format: CliOutputFormat::Text, - } - ); - 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()], - output_format: CliOutputFormat::Text, - } - ); - } - - #[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(), - ], - output_format: CliOutputFormat::Text, - } - ); - } - - #[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(), - ], - output_format: CliOutputFormat::Text, - } - ); - } - - #[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()], - output_format: CliOutputFormat::Text, - } - ); - } - - #[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::>(); - 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::>(); - 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(), - ) - .expect("permission policy should build"); - 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 bare_skill_dispatch_resolves_known_project_skill_to_prompt() { - let _guard = env_lock(); - let workspace = temp_dir(); - write_skill_fixture( - &workspace.join(".codex").join("skills"), - "caveman", - "Project skill fixture", - ); - - let prompt = try_resolve_bare_skill_prompt(&workspace, "caveman sharpen club") - .expect("known bare skill should dispatch"); - assert_eq!(prompt, "$caveman sharpen club"); - - fs::remove_dir_all(workspace).expect("workspace should clean up"); - } - - #[test] - fn bare_skill_dispatch_ignores_unknown_or_non_skill_input() { - let _guard = env_lock(); - let workspace = temp_dir(); - fs::create_dir_all(&workspace).expect("workspace should exist"); - - assert_eq!( - try_resolve_bare_skill_prompt(&workspace, "not-a-known-skill do thing"), - None - ); - assert_eq!(try_resolve_bare_skill_prompt(&workspace, "/status"), None); - - fs::remove_dir_all(workspace).expect("workspace should clean up"); - } - - #[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 ")); - assert!(help.contains("/config [env|hooks|model|plugins]")); - assert!(help.contains("/mcp [list|show |help]")); - assert!(help.contains("/memory")); - assert!(help.contains("/init")); - assert!(help.contains("/diff")); - assert!(help.contains("/version")); - assert!(help.contains("/export [file]")); - // Batch 5 added `/session delete`; match on the stable core rather than - // the trailing bracket so future additions don't re-break this. - assert!(help.contains("/session [list|switch |fork [branch-name]")); - assert!(help.contains( - "/plugin [list|install |enable |disable |uninstall |update ]" - )); - 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/.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(&"/mcp list".to_string())); - assert!(completions.contains(&"/ultraplan ".to_string())); - } - - #[test] - fn startup_banner_mentions_workflow_completions() { - let _guard = env_lock(); - // Inject dummy credentials so LiveCli can construct without real Anthropic key - std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-banner-test"); - 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"); - std::env::remove_var("ANTHROPIC_API_KEY"); - } - - #[test] - fn format_connected_line_renders_anthropic_provider_for_claude_model() { - let model = "claude-sonnet-4-6"; - - let line = format_connected_line(model); - - assert_eq!(line, "Connected: claude-sonnet-4-6 via anthropic"); - } - - #[test] - fn format_connected_line_renders_xai_provider_for_grok_model() { - let model = "grok-3"; - - let line = format_connected_line(model); - - assert_eq!(line, "Connected: grok-3 via xai"); - } - - #[test] - fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() { - let user_model = "claude-sonnet-4-6".to_string(); - - let resolved = resolve_repl_model(user_model); - - assert_eq!(resolved, "claude-sonnet-4-6"); - } - - #[test] - fn resolve_repl_model_falls_back_to_anthropic_model_env_when_default() { - let _guard = env_lock(); - let root = temp_dir(); - fs::create_dir_all(&root).expect("root dir"); - let config_home = root.join("config"); - fs::create_dir_all(&config_home).expect("config home dir"); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - std::env::remove_var("ANTHROPIC_MODEL"); - std::env::set_var("ANTHROPIC_MODEL", "sonnet"); - - let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); - - assert_eq!(resolved, "claude-sonnet-4-6"); - - std::env::remove_var("ANTHROPIC_MODEL"); - std::env::remove_var("CLAW_CONFIG_HOME"); - fs::remove_dir_all(root).expect("cleanup temp dir"); - } - - #[test] - fn resolve_repl_model_returns_default_when_env_unset_and_no_config() { - let _guard = env_lock(); - let root = temp_dir(); - fs::create_dir_all(&root).expect("root dir"); - let config_home = root.join("config"); - fs::create_dir_all(&config_home).expect("config home dir"); - std::env::set_var("CLAW_CONFIG_HOME", &config_home); - std::env::remove_var("ANTHROPIC_MODEL"); - - let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string())); - - assert_eq!(resolved, DEFAULT_MODEL); - - std::env::remove_var("CLAW_CONFIG_HOME"); - 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::>(); - // Now with 135+ slash commands, verify minimum resume support - assert!( - names.len() >= 39, - "expected at least 39 resume-supported commands, got {}", - names.len() - ); - // Verify key resume commands still exist - assert!(names.contains(&"help")); - assert!(names.contains(&"status")); - assert!(names.contains(&"compact")); - } - - #[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 acp [serve]")); - assert!(help.contains("claw agents")); - assert!(help.contains("claw mcp")); - assert!(help.contains("claw skills")); - assert!(help.contains("claw /skills")); - assert!(help.contains("ultraworkers/claw-code")); - assert!(help.contains("cargo install claw-code")); - assert!(!help.contains("claw login")); - assert!(!help.contains("claw logout")); - } - - #[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.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, - }, - session_lifecycle: SessionLifecycleSummary { - kind: SessionLifecycleKind::SavedOnly, - pane_id: None, - pane_command: None, - pane_path: None, - workspace_dirty: false, - abandoned: false, - }, - sandbox_status: runtime::SandboxStatus::default(), - config_load_error: None, - }, - None, // #148 - ); - 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 ready")); - assert!(preflight.contains("Branch feature/ux")); - assert!(preflight.contains("Workspace dirty · 2 files · 1 staged, 1 unstaged")); - assert!(preflight - .contains("Action create a git commit from the current workspace changes")); - } - - #[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("Action create a git commit from the current workspace changes")); - assert!(report.contains("/status to inspect context")); - assert!(report.contains("/diff to inspect repo changes")); - } - - #[test] - fn runtime_slash_reports_describe_command_behavior() { - let bughunter = format_bughunter_report(Some("runtime")); - assert!(bughunter.contains("Scope runtime")); - assert!(bughunter.contains("inspect the selected code for likely bugs")); - - let ultraplan = format_ultraplan_report(Some("ship the release")); - assert!(ultraplan.contains("Task ship the release")); - assert!(ultraplan.contains("break work into a multi-step execution plan")); - - let pr = format_pr_report("feature/ux", Some("ready for review")); - assert!(pr.contains("Branch feature/ux")); - assert!(pr.contains("draft or create a pull request")); - - let issue = format_issue_report(Some("flaky test")); - assert!(issue.contains("Context flaky test")); - assert!(issue.contains("draft or create a GitHub issue")); - } - - #[test] - fn no_arg_commands_reject_unexpected_arguments() { - assert!(validate_no_args("/commit", None).is_ok()); - - let error = validate_no_args("/commit", Some("now")) - .expect_err("unexpected arguments should fail") - .to_string(); - assert!(error.contains("/commit does not accept arguments")); - assert!(error.contains("Received: now")); - } - - #[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 = render_diff_report_for(&root).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 = render_diff_report_for(&root).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 = render_diff_report_for(&root).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"), - Ok(Some(SlashCommand::Clear { confirm: false })) - ); - assert_eq!( - SlashCommand::parse("/clear --confirm"), - Ok(Some(SlashCommand::Clear { confirm: true })) - ); - } - - #[test] - fn parses_resume_and_config_slash_commands() { - assert_eq!( - SlashCommand::parse("/resume saved-session.jsonl"), - Ok(Some(SlashCommand::Resume { - session_path: Some("saved-session.jsonl".to_string()) - })) - ); - assert_eq!( - SlashCommand::parse("/clear --confirm"), - Ok(Some(SlashCommand::Clear { confirm: true })) - ); - assert_eq!( - SlashCommand::parse("/config"), - Ok(Some(SlashCommand::Config { section: None })) - ); - assert_eq!( - SlashCommand::parse("/config env"), - Ok(Some(SlashCommand::Config { - section: Some("env".to_string()) - })) - ); - assert_eq!( - SlashCommand::parse("/memory"), - Ok(Some(SlashCommand::Memory)) - ); - assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init))); - assert_eq!( - SlashCommand::parse("/session fork incident-review"), - Ok(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_guard(); - 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_workspace_root(workspace.clone()) - .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_guard(); - 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 load_session_reference_rejects_workspace_mismatch() { - let _guard = cwd_guard(); - let workspace_a = temp_workspace("session-mismatch-a"); - let workspace_b = temp_workspace("session-mismatch-b"); - std::fs::create_dir_all(&workspace_a).expect("workspace a should create"); - std::fs::create_dir_all(&workspace_b).expect("workspace b should create"); - let previous = std::env::current_dir().expect("cwd"); - std::env::set_current_dir(&workspace_b).expect("switch cwd"); - - let session_path = workspace_a.join(".claw/sessions/legacy-cross.jsonl"); - std::fs::create_dir_all( - session_path - .parent() - .expect("session path should have parent directory"), - ) - .expect("session dir should exist"); - Session::new() - .with_workspace_root(workspace_a.clone()) - .with_persistence_path(session_path.clone()) - .save_to_path(&session_path) - .expect("session should save"); - - let error = crate::load_session_reference(&session_path.display().to_string()) - .expect_err("mismatched workspace should fail"); - assert!( - error.to_string().contains("session workspace mismatch"), - "unexpected error: {error}" - ); - assert!( - error - .to_string() - .contains(&workspace_b.display().to_string()), - "expected current workspace in error: {error}" - ); - assert!( - error - .to_string() - .contains(&workspace_a.display().to_string()), - "expected originating workspace in error: {error}" - ); - - std::env::set_current_dir(previous).expect("restore cwd"); - std::fs::remove_dir_all(workspace_a).expect("workspace a should clean up"); - std::fs::remove_dir_all(workspace_b).expect("workspace b 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 unknown_omc_slash_command_guidance_explains_runtime_gap() { - let message = format_unknown_slash_command("oh-my-claudecode:hud"); - assert!(message.contains("Unknown slash command: /oh-my-claudecode:hud")); - assert!(message.contains("Claude Code/OMC plugin command")); - assert!(message.contains("does not yet load plugin slash commands")); - } - - #[test] - fn resume_usage_mentions_latest_shortcut() { - let usage = render_resume_usage(); - assert!(usage.contains("/resume ")); - assert!(usage.contains(".claw/sessions/.jsonl")); - assert!(usage.contains("/session list")); - } - - fn cwd_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - fn cwd_guard() -> MutexGuard<'static, ()> { - cwd_lock() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - #[test] - fn cwd_guard_recovers_after_poisoning() { - let poisoned = std::thread::spawn(|| { - let _guard = cwd_guard(); - panic!("poison cwd lock"); - }) - .join(); - assert!(poisoned.is_err(), "poisoning thread should panic"); - - let _guard = cwd_guard(); - } - - 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 _guard = cwd_lock() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); - let rendered = crate::init::render_init_claude_md(&workspace_root); - 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")); - assert!(help.contains("Ctrl-R")); - assert!(help.contains("Reverse-search prompt history")); - assert!(help.contains("/history [count]")); - } - - #[test] - fn parse_history_count_defaults_to_twenty_when_missing() { - // given - let raw: Option<&str> = None; - - // when - let parsed = parse_history_count(raw); - - // then - assert_eq!(parsed, Ok(20)); - } - - #[test] - fn parse_history_count_accepts_positive_integers() { - // given - let raw = Some("25"); - - // when - let parsed = parse_history_count(raw); - - // then - assert_eq!(parsed, Ok(25)); - } - - #[test] - fn parse_history_count_rejects_zero() { - // given - let raw = Some("0"); - - // when - let parsed = parse_history_count(raw); - - // then - assert!(parsed.is_err()); - assert!(parsed.unwrap_err().contains("greater than 0")); - } - - #[test] - fn parse_history_count_rejects_non_numeric() { - // given - let raw = Some("abc"); - - // when - let parsed = parse_history_count(raw); - - // then - assert!(parsed.is_err()); - assert!(parsed.unwrap_err().contains("invalid count 'abc'")); - } - - #[test] - fn format_history_timestamp_renders_iso8601_utc() { - // given - // 2023-01-15T12:34:56.789Z -> 1673786096789 ms - let timestamp_ms: u64 = 1_673_786_096_789; - - // when - let formatted = format_history_timestamp(timestamp_ms); - - // then - assert_eq!(formatted, "2023-01-15T12:34:56.789Z"); - } - - #[test] - fn format_history_timestamp_renders_unix_epoch_origin() { - // given - let timestamp_ms: u64 = 0; - - // when - let formatted = format_history_timestamp(timestamp_ms); - - // then - assert_eq!(formatted, "1970-01-01T00:00:00.000Z"); - } - - #[test] - fn render_prompt_history_report_lists_entries_with_timestamps() { - // given - let entries = vec![ - PromptHistoryEntry { - timestamp_ms: 1_673_786_096_000, - text: "first prompt".to_string(), - }, - PromptHistoryEntry { - timestamp_ms: 1_673_786_100_000, - text: "second prompt".to_string(), - }, - ]; - - // when - let rendered = render_prompt_history_report(&entries, 10); - - // then - assert!(rendered.contains("Prompt history")); - assert!(rendered.contains("Total 2")); - assert!(rendered.contains("Showing 2 most recent")); - assert!(rendered.contains("Reverse search Ctrl-R in the REPL")); - assert!(rendered.contains("2023-01-15T12:34:56.000Z")); - assert!(rendered.contains("first prompt")); - assert!(rendered.contains("second prompt")); - } - - #[test] - fn render_prompt_history_report_truncates_to_limit_from_the_tail() { - // given - let entries = vec![ - PromptHistoryEntry { - timestamp_ms: 1_000, - text: "older".to_string(), - }, - PromptHistoryEntry { - timestamp_ms: 2_000, - text: "middle".to_string(), - }, - PromptHistoryEntry { - timestamp_ms: 3_000, - text: "latest".to_string(), - }, - ]; - - // when - let rendered = render_prompt_history_report(&entries, 2); - - // then - assert!(rendered.contains("Total 3")); - assert!(rendered.contains("Showing 2 most recent")); - assert!(!rendered.contains("older")); - assert!(rendered.contains("middle")); - assert!(rendered.contains("latest")); - } - - #[test] - fn render_prompt_history_report_handles_empty_history() { - // given - let entries: Vec = Vec::new(); - - // when - let rendered = render_prompt_history_report(&entries, 10); - - // then - assert!(rendered.contains("no prompts recorded yet")); - } - - #[test] - fn collect_session_prompt_history_extracts_user_text_blocks() { - // given - let mut session = Session::new(); - session.push_user_text("hello").unwrap(); - session.push_user_text("world").unwrap(); - - // when - let entries = collect_session_prompt_history(&session); - - // then - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].text, "hello"); - assert_eq!(entries[1].text, "world"); - } - - #[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::>() - .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::>() - .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::>(); - 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::>() - .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; - let mut block_has_thinking_summary = false; - - push_output_block( - OutputContentBlock::Text { - text: "# Heading".to_string(), - }, - &mut out, - &mut events, - &mut pending_tool, - false, - &mut block_has_thinking_summary, - ) - .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; - let mut block_has_thinking_summary = false; - - 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, - &mut block_has_thinking_summary, - ) - .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_renders_collapsed_thinking_summary() { - 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" - )); - let rendered = String::from_utf8(out).expect("utf8"); - assert!(rendered.contains("▶ Thinking (6 chars hidden)")); - assert!(!rendered.contains("step 1")); - } - - #[test] - fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() { - let config_home = temp_dir(); - let workspace = temp_dir(); - let source_root = temp_dir(); - fs::create_dir_all(&config_home).expect("config home"); - fs::create_dir_all(&workspace).expect("workspace"); - fs::create_dir_all(&source_root).expect("source root"); - write_plugin_fixture(&source_root, "hook-runtime-demo", true, false); - - let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); - manager - .install(source_root.to_str().expect("utf8 source path")) - .expect("plugin install should succeed"); - let loader = ConfigLoader::new(&workspace, &config_home); - let runtime_config = loader.load().expect("runtime config should load"); - let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) - .expect("plugin state should load"); - let pre_hooks = state.feature_config.hooks().pre_tool_use(); - assert_eq!(pre_hooks.len(), 1); - assert!( - pre_hooks[0].ends_with("hooks/pre.sh"), - "expected installed plugin hook path, got {pre_hooks:?}" - ); - - let _ = fs::remove_dir_all(config_home); - let _ = fs::remove_dir_all(workspace); - let _ = fs::remove_dir_all(source_root); - } - - #[test] - #[allow(clippy::too_many_lines)] - fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() { - let config_home = temp_dir(); - let workspace = temp_dir(); - fs::create_dir_all(&config_home).expect("config home"); - fs::create_dir_all(&workspace).expect("workspace"); - let script_path = workspace.join("fixture-mcp.py"); - write_mcp_server_fixture(&script_path); - fs::write( - config_home.join("settings.json"), - format!( - r#"{{ - "mcpServers": {{ - "alpha": {{ - "command": "python3", - "args": ["{}"] - }}, - "broken": {{ - "command": "python3", - "args": ["-c", "import sys; sys.exit(0)"] - }} - }} - }}"#, - script_path.to_string_lossy() - ), - ) - .expect("write mcp settings"); - - let loader = ConfigLoader::new(&workspace, &config_home); - let runtime_config = loader.load().expect("runtime config should load"); - let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) - .expect("runtime plugin state should load"); - - let allowed = state - .tool_registry - .normalize_allowed_tools(&["mcp__alpha__echo".to_string(), "MCPTool".to_string()]) - .expect("mcp tools should be allow-listable") - .expect("allow-list should exist"); - assert!(allowed.contains("mcp__alpha__echo")); - assert!(allowed.contains("MCPTool")); - - let mut executor = CliToolExecutor::new( - None, - false, - state.tool_registry.clone(), - state.mcp_state.clone(), - ); - - let tool_output = executor - .execute("mcp__alpha__echo", r#"{"text":"hello"}"#) - .expect("discovered mcp tool should execute"); - let tool_json: serde_json::Value = - serde_json::from_str(&tool_output).expect("tool output should be json"); - assert_eq!(tool_json["structuredContent"]["echoed"], "hello"); - - let wrapped_output = executor - .execute( - "MCPTool", - r#"{"qualifiedName":"mcp__alpha__echo","arguments":{"text":"wrapped"}}"#, - ) - .expect("generic mcp wrapper should execute"); - let wrapped_json: serde_json::Value = - serde_json::from_str(&wrapped_output).expect("wrapped output should be json"); - assert_eq!(wrapped_json["structuredContent"]["echoed"], "wrapped"); - - let search_output = executor - .execute("ToolSearch", r#"{"query":"alpha echo","max_results":5}"#) - .expect("tool search should execute"); - let search_json: serde_json::Value = - serde_json::from_str(&search_output).expect("search output should be json"); - assert_eq!(search_json["matches"][0], "mcp__alpha__echo"); - assert_eq!(search_json["pending_mcp_servers"][0], "broken"); - assert_eq!( - search_json["mcp_degraded"]["failed_servers"][0]["server_name"], - "broken" - ); - assert_eq!( - search_json["mcp_degraded"]["failed_servers"][0]["phase"], - "tool_discovery" - ); - assert_eq!( - search_json["mcp_degraded"]["available_tools"][0], - "mcp__alpha__echo" - ); - - let listed = executor - .execute("ListMcpResourcesTool", r#"{"server":"alpha"}"#) - .expect("resources should list"); - let listed_json: serde_json::Value = - serde_json::from_str(&listed).expect("resource output should be json"); - assert_eq!(listed_json["resources"][0]["uri"], "file://guide.txt"); - - let read = executor - .execute( - "ReadMcpResourceTool", - r#"{"server":"alpha","uri":"file://guide.txt"}"#, - ) - .expect("resource should read"); - let read_json: serde_json::Value = - serde_json::from_str(&read).expect("resource read output should be json"); - assert_eq!( - read_json["contents"][0]["text"], - "contents for file://guide.txt" - ); - - if let Some(mcp_state) = state.mcp_state { - mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .shutdown() - .expect("mcp shutdown should succeed"); - } - - let _ = fs::remove_dir_all(config_home); - let _ = fs::remove_dir_all(workspace); - } - - #[test] - fn build_runtime_plugin_state_surfaces_unsupported_mcp_servers_structurally() { - let config_home = temp_dir(); - let workspace = temp_dir(); - fs::create_dir_all(&config_home).expect("config home"); - fs::create_dir_all(&workspace).expect("workspace"); - fs::write( - config_home.join("settings.json"), - r#"{ - "mcpServers": { - "remote": { - "url": "https://example.test/mcp" - } - } - }"#, - ) - .expect("write mcp settings"); - - let loader = ConfigLoader::new(&workspace, &config_home); - let runtime_config = loader.load().expect("runtime config should load"); - let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) - .expect("runtime plugin state should load"); - let mut executor = CliToolExecutor::new( - None, - false, - state.tool_registry.clone(), - state.mcp_state.clone(), - ); - - let search_output = executor - .execute("ToolSearch", r#"{"query":"remote","max_results":5}"#) - .expect("tool search should execute"); - let search_json: serde_json::Value = - serde_json::from_str(&search_output).expect("search output should be json"); - assert_eq!(search_json["pending_mcp_servers"][0], "remote"); - assert_eq!( - search_json["mcp_degraded"]["failed_servers"][0]["server_name"], - "remote" - ); - assert_eq!( - search_json["mcp_degraded"]["failed_servers"][0]["phase"], - "server_registration" - ); - assert_eq!( - search_json["mcp_degraded"]["failed_servers"][0]["error"]["context"]["transport"], - "http" - ); - - let _ = fs::remove_dir_all(config_home); - let _ = fs::remove_dir_all(workspace); - } - - #[test] - fn build_runtime_runs_plugin_lifecycle_init_and_shutdown() { - // Serialize access to process-wide env vars so parallel tests that - // set/remove ANTHROPIC_API_KEY do not race with this test. - let _guard = env_lock(); - let config_home = temp_dir(); - // Inject a dummy API key so runtime construction succeeds without real credentials. - // This test only exercises plugin lifecycle (init/shutdown), never calls the API. - std::env::set_var("ANTHROPIC_API_KEY", "test-dummy-key-for-plugin-lifecycle"); - let workspace = temp_dir(); - let source_root = temp_dir(); - fs::create_dir_all(&config_home).expect("config home"); - fs::create_dir_all(&workspace).expect("workspace"); - fs::create_dir_all(&source_root).expect("source root"); - write_plugin_fixture(&source_root, "lifecycle-runtime-demo", false, true); - - let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); - let install = manager - .install(source_root.to_str().expect("utf8 source path")) - .expect("plugin install should succeed"); - let log_path = install.install_path.join("lifecycle.log"); - let loader = ConfigLoader::new(&workspace, &config_home); - let runtime_config = loader.load().expect("runtime config should load"); - let runtime_plugin_state = - build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) - .expect("plugin state should load"); - let mut runtime = build_runtime_with_plugin_state( - Session::new(), - "runtime-plugin-lifecycle", - DEFAULT_MODEL.to_string(), - vec!["test system prompt".to_string()], - true, - false, - None, - PermissionMode::DangerFullAccess, - None, - runtime_plugin_state, - ) - .expect("runtime should build"); - - assert_eq!( - fs::read_to_string(&log_path).expect("init log should exist"), - "init\n" - ); - - runtime - .shutdown_plugins() - .expect("plugin shutdown should succeed"); - - assert_eq!( - fs::read_to_string(&log_path).expect("shutdown log should exist"), - "init\nshutdown\n" - ); - - let _ = fs::remove_dir_all(config_home); - let _ = fs::remove_dir_all(workspace); - let _ = fs::remove_dir_all(source_root); - std::env::remove_var("ANTHROPIC_API_KEY"); - } - - #[test] - fn rejects_invalid_reasoning_effort_value() { - let err = parse_args(&[ - "--reasoning-effort".to_string(), - "turbo".to_string(), - "prompt".to_string(), - "hello".to_string(), - ]) - .unwrap_err(); - assert!( - err.contains("invalid value for --reasoning-effort"), - "unexpected error: {err}" - ); - assert!(err.contains("turbo"), "unexpected error: {err}"); - } - - #[test] - fn accepts_valid_reasoning_effort_values() { - for value in ["low", "medium", "high"] { - let result = parse_args(&[ - "--reasoning-effort".to_string(), - value.to_string(), - "prompt".to_string(), - "hello".to_string(), - ]); - assert!( - result.is_ok(), - "--reasoning-effort {value} should be accepted, got: {result:?}" - ); - if let Ok(CliAction::Prompt { - reasoning_effort, .. - }) = result - { - assert_eq!(reasoning_effort.as_deref(), Some(value)); - } - } - } - - #[test] - fn stub_commands_absent_from_repl_completions() { - let candidates = - slash_command_completion_candidates_with_sessions("claude-3-5-sonnet", None, vec![]); - for stub in STUB_COMMANDS { - let with_slash = format!("/{stub}"); - assert!( - !candidates.contains(&with_slash), - "stub command {with_slash} should not appear in REPL completions" - ); - } - } -} - -fn write_mcp_server_fixture(script_path: &Path) { +pub(crate) fn write_mcp_server_fixture(script_path: &Path) { let script = [ "#!/usr/bin/env python3", "import json, sys", @@ -8146,190 +459,72 @@ fn write_mcp_server_fixture(script_path: &Path) { " 'result': {", " 'tools': [", " {", - " 'name': 'echo',", - " 'description': 'Echo from MCP fixture',", + " 'name': 'fixture_tool',", + " 'description': 'A tool from the fixture server',", " 'inputSchema': {", " 'type': 'object',", - " 'properties': {'text': {'type': 'string'}},", - " 'required': ['text'],", - " 'additionalProperties': False", - " },", - " 'annotations': {'readOnlyHint': True}", + " 'properties': {", + " 'arg': {'type': 'string'}", + " },", + " 'required': ['arg']", + " }", " }", " ]", " }", " })", - " elif method == 'tools/call':", - " args = request['params'].get('arguments') or {}", - " send_message({", - " 'jsonrpc': '2.0',", - " 'id': request['id'],", - " 'result': {", - " 'content': [{'type': 'text', 'text': f\"echo:{args.get('text', '')}\"}],", - " 'structuredContent': {'echoed': args.get('text', '')},", - " 'isError': False", - " }", - " })", " elif method == 'resources/list':", " send_message({", " 'jsonrpc': '2.0',", " 'id': request['id'],", " 'result': {", - " 'resources': [{'uri': 'file://guide.txt', 'name': 'guide', 'mimeType': 'text/plain'}]", + " 'resources': [", + " {", + " 'uri': 'fixture://resource',", + " 'name': 'fixture_resource',", + " 'description': 'A resource from the fixture server',", + " 'mimeType': 'text/plain'", + " }", + " ]", " }", " })", " elif method == 'resources/read':", - " uri = request['params']['uri']", " send_message({", " 'jsonrpc': '2.0',", " 'id': request['id'],", " 'result': {", - " 'contents': [{'uri': uri, 'mimeType': 'text/plain', 'text': f'contents for {uri}'}]", + " 'contents': [", + " {", + " 'uri': request['params']['uri'],", + " 'mimeType': 'text/plain',", + " 'text': 'fixture resource content'", + " }", + " ]", " }", " })", - " else:", + " elif method == 'tools/call':", " send_message({", " 'jsonrpc': '2.0',", " 'id': request['id'],", - " 'error': {'code': -32601, 'message': method}", + " 'result': {", + " 'content': [", + " {", + " 'type': 'text',", + " 'text': f\"fixture tool result for {request['params']['arguments']['arg']}\"", + " }", + " ]", + " }", " })", - "", - ] - .join("\n"); - fs::write(script_path, script).expect("mcp fixture script should write"); -} + ]; + let content = script.join("\n"); + fs::write(script_path, content).expect("write fixture script"); -#[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()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(script_path, fs::Permissions::from_mode(0o755)) + .expect("set fixture permissions"); } } #[cfg(test)] -mod dump_manifests_tests { - use super::{dump_manifests_at_path, CliOutputFormat}; - use std::fs; - - #[test] - fn dump_manifests_shows_helpful_error_when_manifests_missing() { - let root = std::env::temp_dir().join(format!( - "claw_test_missing_manifests_{}", - std::process::id() - )); - let workspace = root.join("workspace"); - std::fs::create_dir_all(&workspace).expect("failed to create temp workspace"); - - let result = dump_manifests_at_path(&workspace, None, CliOutputFormat::Text); - assert!( - result.is_err(), - "expected an error when manifests are missing" - ); - - let error_msg = result.unwrap_err().to_string(); - - assert!( - error_msg.contains("Manifest source files are missing"), - "error message should mention missing manifest sources: {error_msg}" - ); - assert!( - error_msg.contains(&root.display().to_string()), - "error message should contain the resolved repo root path: {error_msg}" - ); - assert!( - error_msg.contains("src/commands.ts"), - "error message should mention missing commands.ts: {error_msg}" - ); - assert!( - error_msg.contains("CLAUDE_CODE_UPSTREAM"), - "error message should explain how to supply the upstream path: {error_msg}" - ); - - let _ = std::fs::remove_dir_all(&root); - } - - #[test] - fn dump_manifests_uses_explicit_manifest_dir() { - let root = std::env::temp_dir().join(format!( - "claw_test_explicit_manifest_dir_{}", - std::process::id() - )); - let workspace = root.join("workspace"); - let upstream = root.join("upstream"); - fs::create_dir_all(workspace.join("nested")).expect("workspace should exist"); - fs::create_dir_all(upstream.join("src/entrypoints")) - .expect("upstream fixture should exist"); - fs::write( - upstream.join("src/commands.ts"), - "import FooCommand from './commands/foo'\n", - ) - .expect("commands fixture should write"); - fs::write( - upstream.join("src/tools.ts"), - "import ReadTool from './tools/read'\n", - ) - .expect("tools fixture should write"); - fs::write( - upstream.join("src/entrypoints/cli.tsx"), - "startupProfiler()\n", - ) - .expect("cli fixture should write"); - - let result = dump_manifests_at_path(&workspace, Some(&upstream), CliOutputFormat::Text); - assert!( - result.is_ok(), - "explicit manifest dir should succeed: {result:?}" - ); - - let _ = fs::remove_dir_all(&root); - } -} +mod main_tests; diff --git a/rust/crates/rusty-claude-cli/src/main_tests.rs b/rust/crates/rusty-claude-cli/src/main_tests.rs index 1b87c6c0..71afe256 100644 --- a/rust/crates/rusty-claude-cli/src/main_tests.rs +++ b/rust/crates/rusty-claude-cli/src/main_tests.rs @@ -53,7 +53,7 @@ mod tests { PluginToolDefinition { name: "plugin_echo".to_string(), description: Some("Echo plugin payload".to_string()), - input_schema: json!({ + input_schema:serde_json::json!({ "type": "object", "properties": { "message": { "type": "string" } @@ -3492,7 +3492,7 @@ UU conflicted.rs", .map(|index| format!("line {index:03}")) .collect::>() .join("\n"); - let output = json!({ + let output =serde_json::json!({ "file": { "filePath": "src/main.rs", "content": content, @@ -3518,7 +3518,7 @@ UU conflicted.rs", .map(|index| format!("stdout {index:03}")) .collect::>() .join("\n"); - let output = json!({ + let output =serde_json::json!({ "stdout": stdout, "stderr": "", "returnCodeInterpretation": "completed successfully" @@ -3539,7 +3539,7 @@ UU conflicted.rs", let items = (0..120) .map(|index| format!("payload {index:03}")) .collect::>(); - let output = json!({ + let output =serde_json::json!({ "summary": "plugin payload", "items": items, }) @@ -3671,7 +3671,7 @@ UU conflicted.rs", OutputContentBlock::ToolUse { id: "tool-1".to_string(), name: "read_file".to_string(), - input: json!({}), + input:serde_json::json!({}), }, &mut out, &mut events, @@ -3700,7 +3700,7 @@ UU conflicted.rs", content: vec![OutputContentBlock::ToolUse { id: "tool-1".to_string(), name: "read_file".to_string(), - input: json!({}), + input:serde_json::json!({}), }], stop_reason: Some("tool_use".to_string()), stop_sequence: None, @@ -3735,7 +3735,7 @@ UU conflicted.rs", content: vec![OutputContentBlock::ToolUse { id: "tool-2".to_string(), name: "read_file".to_string(), - input: json!({ "path": "rust/Cargo.toml" }), + input:serde_json::json!({ "path": "rust/Cargo.toml" }), }], stop_reason: Some("tool_use".to_string()), stop_sequence: None, diff --git a/rust/crates/rusty-claude-cli/src/models.rs b/rust/crates/rusty-claude-cli/src/models.rs index e1a05dd7..30be6be3 100644 --- a/rust/crates/rusty-claude-cli/src/models.rs +++ b/rust/crates/rusty-claude-cli/src/models.rs @@ -53,7 +53,7 @@ impl SessionLifecycleSummary { } pub(crate) fn json_value(&self) -> serde_json::Value { - json!({ + serde_json::json!({ "kind": self.kind.as_str(), "pane_id": self.pane_id, "pane_command": self.pane_command, @@ -130,8 +130,4 @@ pub(crate) struct ManagedSessionSummary { pub(crate) lifecycle: SessionLifecycleSummary, } -#[derive(Debug, Clone)] -pub(crate) struct PromptHistoryEntry { - pub(crate) timestamp_ms: u64, - pub(crate) text: String, -} + diff --git a/rust/crates/rusty-claude-cli/src/render.rs b/rust/crates/rusty-claude-cli/src/render.rs index 2970e01b..649552f6 100644 --- a/rust/crates/rusty-claude-cli/src/render.rs +++ b/rust/crates/rusty-claude-cli/src/render.rs @@ -1069,1309 +1069,3 @@ mod tests { } } -// --- Extracted from main.rs --- -use crate::TokenUsage; -use crate::*; -use api::Usage; -use serde_json::json; -use std::path::Path; - -pub(crate) fn format_unknown_option(option: &str) -> String { - let mut message = format!("unknown option: {option}"); - if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) { - message.push_str("\nDid you mean "); - message.push_str(suggestion); - message.push('?'); - } - message.push_str("\nRun `claw --help` for usage."); - message -} - -pub(crate) fn format_unknown_direct_slash_command(name: &str) -> String { - let mut message = format!("unknown slash command outside the REPL: /{name}"); - if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) - { - message.push('\n'); - message.push_str(&suggestions); - } - if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { - message.push('\n'); - message.push_str(note); - } - message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help."); - message -} - -pub(crate) fn format_unknown_slash_command(name: &str) -> String { - let mut message = format!("Unknown slash command: /{name}"); - if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) - { - message.push('\n'); - message.push_str(&suggestions); - } - if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { - message.push('\n'); - message.push_str(note); - } - message.push_str("\n Help /help lists available slash commands"); - message -} - -pub(crate) fn format_connected_line(model: &str) -> String { - let provider = provider_label(detect_provider_kind(model)); - format!("Connected: {model} via {provider}") -} - -#[cfg(test)] -pub(crate) fn format_unknown_slash_command_message(name: &str) -> String { - let suggestions = suggest_slash_commands(name); - let mut message = format!("unknown slash command: /{name}."); - if !suggestions.is_empty() { - message.push_str(" Did you mean "); - message.push_str(&suggestions.join(", ")); - message.push('?'); - } - if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { - message.push(' '); - message.push_str(note); - } - message.push_str(" Use /help to list available commands."); - message -} - -pub(crate) fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { - format!( - "Model - Current model {model} - Session messages {message_count} - Session turns {turns} - -Usage - Inspect current model with /model - Switch models with /model " - ) -} - -pub(crate) fn format_model_switch_report( - previous: &str, - next: &str, - message_count: usize, -) -> String { - format!( - "Model updated - Previous {previous} - Current {next} - Preserved msgs {message_count}" - ) -} - -pub(crate) fn format_permissions_report(mode: &str) -> String { - let modes = [ - ("read-only", "Read/search tools only", mode == "read-only"), - ( - "workspace-write", - "Edit files inside the workspace", - mode == "workspace-write", - ), - ( - "danger-full-access", - "Unrestricted tool access", - mode == "danger-full-access", - ), - ] - .into_iter() - .map(|(name, description, is_current)| { - let marker = if is_current { - "● current" - } else { - "○ available" - }; - format!(" {name:<18} {marker:<11} {description}") - }) - .collect::>() - .join( - " -", - ); - - format!( - "Permissions - Active mode {mode} - Mode status live session default - -Modes -{modes} - -Usage - Inspect current mode with /permissions - Switch modes with /permissions " - ) -} - -pub(crate) fn format_permissions_switch_report(previous: &str, next: &str) -> String { - format!( - "Permissions updated - Result mode switched - Previous mode {previous} - Active mode {next} - Applies to subsequent tool calls - Usage /permissions to inspect current mode" - ) -} - -pub(crate) fn format_cost_report(usage: TokenUsage) -> String { - format!( - "Cost - Input tokens {} - Output tokens {} - Cache create {} - Cache read {} - Total tokens {}", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - usage.total_tokens(), - ) -} - -pub(crate) fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { - format!( - "Session resumed - Session file {session_path} - Messages {message_count} - Turns {turns}" - ) -} - -pub(crate) fn 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}" - ) - } -} - -pub(crate) fn format_auto_compaction_notice(removed: usize) -> String { - format!("[auto-compacted: removed {removed} messages]") -} - -pub(crate) fn format_session_modified_age(modified_epoch_millis: u128) -> String { - let now = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(modified_epoch_millis, |duration| duration.as_millis()); - let delta_seconds = now - .saturating_sub(modified_epoch_millis) - .checked_div(1_000) - .unwrap_or_default(); - match delta_seconds { - 0..=4 => "just-now".to_string(), - 5..=59 => format!("{delta_seconds}s-ago"), - 60..=3_599 => format!("{}m-ago", delta_seconds / 60), - 3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600), - _ => format!("{}d-ago", delta_seconds / 86_400), - } -} - -pub(crate) fn format_status_report( - model: &str, - usage: StatusUsage, - permission_mode: &str, - context: &StatusContext, - // #148: optional model provenance to surface in a `Model source` line. - // Callers without provenance (legacy resume paths) pass None and the - // source line is omitted for backward compat. - provenance: Option<&ModelProvenance>, -) -> String { - // #143: if config failed to parse, surface a degraded banner at the top - // of the text report so humans see the parse error before the body, while - // the body below still reports everything that could be resolved without - // config (workspace, git, sandbox defaults, etc.). - let status_line = if context.config_load_error.is_some() { - "Status (degraded)" - } else { - "Status" - }; - let mut blocks: Vec = Vec::new(); - if let Some(err) = context.config_load_error.as_deref() { - blocks.push(format!( - "Config load error\n Status fail\n Summary runtime config failed to load; reporting partial status\n Details {err}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun" - )); - } - // #148: render Model source line after Model, showing where the string - // came from (flag / env / config / default) and the raw input if any. - let model_source_line = provenance - .map(|p| match &p.raw { - Some(raw) if raw != model => { - format!("\n Model source {} (raw: {raw})", p.source.as_str()) - } - _ => format!("\n Model source {}", p.source.as_str()), - }) - .unwrap_or_default(); - blocks.extend([ - format!( - "{status_line} - Model {model}{model_source_line} - Permission mode {permission_mode} - Messages {} - Turns {} - Estimated tokens {}", - usage.message_count, usage.turns, usage.estimated_tokens, - ), - format!( - "Usage - Latest total {} - Cumulative input {} - Cumulative output {} - Cumulative total {}", - usage.latest.total_tokens(), - usage.cumulative.input_tokens, - usage.cumulative.output_tokens, - usage.cumulative.total_tokens(), - ), - format!( - "Workspace - Cwd {} - Project root {} - Git branch {} - Git state {} - Changed files {} - Staged {} - Unstaged {} - Untracked {} - Session {} - Lifecycle {} - 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.session_lifecycle.signal(), - context.loaded_config_files, - context.discovered_config_files, - context.memory_file_count, - ), - format_sandbox_report(&context.sandbox_status), - ]); - blocks.join("\n\n") -} - -pub(crate) fn format_sandbox_report(status: &runtime::SandboxStatus) -> String { - format!( - "Sandbox - Enabled {} - Active {} - Supported {} - In container {} - Requested ns {} - Active ns {} - Requested net {} - Active net {} - Filesystem mode {} - Filesystem active {} - Allowed mounts {} - Markers {} - Fallback reason {}", - status.enabled, - status.active, - status.supported, - status.in_container, - status.requested.namespace_restrictions, - status.namespace_active, - status.requested.network_isolation, - status.network_active, - status.filesystem_mode.as_str(), - status.filesystem_active, - if status.allowed_mounts.is_empty() { - "".to_string() - } else { - status.allowed_mounts.join(", ") - }, - if status.container_markers.is_empty() { - "".to_string() - } else { - status.container_markers.join(", ") - }, - status - .fallback_reason - .clone() - .unwrap_or_else(|| "".to_string()), - ) -} - -pub(crate) fn format_commit_preflight_report( - branch: Option<&str>, - summary: GitWorkspaceSummary, -) -> String { - format!( - "Commit - Result ready - Branch {} - Workspace {} - Changed files {} - Action create a git commit from the current workspace changes", - branch.unwrap_or("unknown"), - summary.headline(), - summary.changed_files, - ) -} - -pub(crate) fn format_commit_skipped_report() -> String { - "Commit - Result skipped - Reason no workspace changes - Action create a git commit from the current workspace changes - Next /status to inspect context · /diff to inspect repo changes" - .to_string() -} - -pub(crate) fn format_bughunter_report(scope: Option<&str>) -> String { - format!( - "Bughunter - Scope {} - Action inspect the selected code for likely bugs and correctness issues - Output findings should include file paths, severity, and suggested fixes", - scope.unwrap_or("the current repository") - ) -} - -pub(crate) fn format_ultraplan_report(task: Option<&str>) -> String { - format!( - "Ultraplan - Task {} - Action break work into a multi-step execution plan - Output plan should cover goals, risks, sequencing, verification, and rollback", - task.unwrap_or("the current repo work") - ) -} - -pub(crate) fn format_pr_report(branch: &str, context: Option<&str>) -> String { - format!( - "PR - Branch {branch} - Context {} - Action draft or create a pull request for the current branch - Output title and markdown body suitable for GitHub", - context.unwrap_or("none") - ) -} - -pub(crate) fn format_issue_report(context: Option<&str>) -> String { - format!( - "Issue - Context {} - Action draft or create a GitHub issue from the current context - Output title and markdown body suitable for GitHub", - context.unwrap_or("none") - ) -} - -pub(crate) fn format_history_timestamp(timestamp_ms: u64) -> String { - let secs = timestamp_ms / 1_000; - let subsec_ms = timestamp_ms % 1_000; - let days_since_epoch = secs / 86_400; - let seconds_of_day = secs % 86_400; - let hours = seconds_of_day / 3_600; - let minutes = (seconds_of_day % 3_600) / 60; - let seconds = seconds_of_day % 60; - - let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0)); - format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z") -} - -pub(crate) fn format_internal_prompt_progress_line( - event: InternalPromptProgressEvent, - snapshot: &InternalPromptProgressState, - elapsed: Duration, - error: Option<&str>, -) -> String { - let elapsed_seconds = elapsed.as_secs(); - let step_label = if snapshot.step == 0 { - "current step pending".to_string() - } else { - format!("current step {}", snapshot.step) - }; - let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)]; - if let Some(detail) = snapshot - .detail - .as_deref() - .filter(|detail| !detail.is_empty()) - { - status_bits.push(detail.to_string()); - } - let status = status_bits.join(" · "); - 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") - ), - } -} - -pub(crate) fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String { - if error.is_context_window_failure() { - format_context_window_blocked_error(session_id, error) - } else if error.is_generic_fatal_wrapper() { - let mut qualifiers = vec![format!("session {session_id}")]; - if let Some(request_id) = error.request_id() { - qualifiers.push(format!("trace {request_id}")); - } - format!( - "{} ({}): {}", - error.safe_failure_class(), - qualifiers.join(", "), - error - ) - } else { - error.to_string() - } -} - -pub(crate) fn format_context_window_blocked_error( - session_id: &str, - error: &api::ApiError, -) -> String { - let mut lines = vec![ - "Context window blocked".to_string(), - " Failure class context_window_blocked".to_string(), - format!(" Session {session_id}"), - ]; - - if let Some(request_id) = error.request_id() { - lines.push(format!(" Trace {request_id}")); - } - - match error { - api::ApiError::ContextWindowExceeded { - model, - estimated_input_tokens, - requested_output_tokens, - estimated_total_tokens, - context_window_tokens, - } => { - lines.push(format!(" Model {model}")); - lines.push(format!( - " Input estimate ~{estimated_input_tokens} tokens (heuristic)" - )); - lines.push(format!( - " Requested output {requested_output_tokens} tokens" - )); - lines.push(format!( - " Total estimate ~{estimated_total_tokens} tokens (heuristic)" - )); - lines.push(format!(" Context window {context_window_tokens} tokens")); - } - api::ApiError::Api { message, body, .. } => { - let detail = message.as_deref().unwrap_or(body).trim(); - if !detail.is_empty() { - lines.push(format!( - " Detail {}", - truncate_for_summary(detail, 120) - )); - } - } - api::ApiError::RetriesExhausted { last_error, .. } => { - let detail = match last_error.as_ref() { - api::ApiError::Api { message, body, .. } => message.as_deref().unwrap_or(body), - other => return format_context_window_blocked_error(session_id, other), - } - .trim(); - if !detail.is_empty() { - lines.push(format!( - " Detail {}", - truncate_for_summary(detail, 120) - )); - } - } - _ => {} - } - - lines.push(String::new()); - lines.push("Recovery".to_string()); - lines.push(" Compact /compact".to_string()); - lines.push(format!( - " Resume compact claw --resume {session_id} /compact" - )); - lines.push(" Fresh session /clear --confirm".to_string()); - lines.push( - " Reduce scope remove large pasted context/files or ask for a smaller slice" - .to_string(), - ); - lines.push(" Retry rerun after compacting or reducing the request".to_string()); - - lines.join("\n") -} - -pub(crate) fn format_tool_call_start(name: &str, input: &str) -> String { - let parsed: serde_json::Value = - serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); - - let detail = match name { - "bash" | "Bash" => format_bash_call(&parsed), - "read_file" | "Read" => { - let path = extract_tool_path(&parsed); - format!("\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" - ) -} - -pub(crate) 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), - } -} - -pub(crate) fn format_search_start(label: &str, parsed: &serde_json::Value) -> String { - let pattern = parsed - .get("pattern") - .and_then(|value| value.as_str()) - .unwrap_or("?"); - let scope = parsed - .get("path") - .and_then(|value| value.as_str()) - .unwrap_or("."); - format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m") -} - -pub(crate) fn format_patch_preview(old_value: &str, new_value: &str) -> Option { - if old_value.is_empty() && new_value.is_empty() { - return None; - } - Some(format!( - "\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) - )) -} - -pub(crate) fn format_bash_call(parsed: &serde_json::Value) -> String { - let command = parsed - .get("command") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - if command.is_empty() { - String::new() - } else { - format!( - "\x1b[48;5;236;38;5;255m $ {} \x1b[0m", - truncate_for_summary(command, 160) - ) - } -} - -pub(crate) 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") -} - -pub(crate) fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String { - let file = parsed.get("file").unwrap_or(parsed); - let path = extract_tool_path(file); - let start_line = file - .get("startLine") - .and_then(serde_json::Value::as_u64) - .unwrap_or(1); - let num_lines = file - .get("numLines") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let total_lines = file - .get("totalLines") - .and_then(serde_json::Value::as_u64) - .unwrap_or(num_lines); - let content = file - .get("content") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let end_line = start_line.saturating_add(num_lines.saturating_sub(1)); - - format!( - "{icon} \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) - ) -} - -pub(crate) fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String { - let path = extract_tool_path(parsed); - let kind = parsed - .get("type") - .and_then(|value| value.as_str()) - .unwrap_or("write"); - let line_count = parsed - .get("content") - .and_then(|value| value.as_str()) - .map_or(0, |content| content.lines().count()); - format!( - "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m", - if kind == "create" { "Wrote" } else { "Updated" }, - ) -} - -pub(crate) fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option { - let hunks = parsed.get("structuredPatch")?.as_array()?; - let mut preview = Vec::new(); - for hunk in hunks.iter().take(2) { - let lines = hunk.get("lines")?.as_array()?; - for line in lines.iter().filter_map(|value| value.as_str()).take(6) { - match line.chars().next() { - Some('+') => preview.push(format!("\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")) - } -} - -pub(crate) fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String { - let path = extract_tool_path(parsed); - let suffix = if parsed - .get("replaceAll") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) - { - " (replace all)" - } else { - "" - }; - let preview = format_structured_patch_preview(parsed).or_else(|| { - let old_value = parsed - .get("oldString") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let new_value = parsed - .get("newString") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - format_patch_preview(old_value, new_value) - }); - - match preview { - Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"), - None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"), - } -} - -pub(crate) fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String { - let num_files = parsed - .get("numFiles") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let filenames = parsed - .get("filenames") - .and_then(|value| value.as_array()) - .map(|files| { - files - .iter() - .filter_map(|value| value.as_str()) - .take(8) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); - if filenames.is_empty() { - format!("{icon} \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}") - } -} - -pub(crate) fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String { - let num_matches = parsed - .get("numMatches") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let num_files = parsed - .get("numFiles") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - let content = parsed - .get("content") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - let filenames = parsed - .get("filenames") - .and_then(|value| value.as_array()) - .map(|files| { - files - .iter() - .filter_map(|value| value.as_str()) - .take(8) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); - let summary = format!( - "{icon} \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 - } -} - -pub(crate) fn format_generic_tool_result( - icon: &str, - name: &str, - parsed: &serde_json::Value, -) -> String { - let rendered_output = match parsed { - serde_json::Value::String(text) => text.clone(), - serde_json::Value::Null => String::new(), - serde_json::Value::Object(_) | serde_json::Value::Array(_) => { - serde_json::to_string_pretty(parsed).unwrap_or_else(|_| parsed.to_string()) - } - _ => parsed.to_string(), - }; - let 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}") - } -} - -pub(crate) fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option { - (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),)) -} - -pub(crate) fn render_repl_help() -> String { - [ - "REPL".to_string(), - " /exit Quit the REPL".to_string(), - " /quit Quit the REPL".to_string(), - " Up/Down Navigate prompt history".to_string(), - " Ctrl-R Reverse-search prompt history".to_string(), - " Tab Complete commands, modes, and recent sessions".to_string(), - " Ctrl-C Clear input (or exit on empty prompt)".to_string(), - " Shift+Enter/Ctrl+J Insert a newline".to_string(), - " Auto-save .claw/sessions/.jsonl".to_string(), - " Resume latest /resume latest".to_string(), - " Browse sessions /session list".to_string(), - " Show prompt history /history [count]".to_string(), - String::new(), - render_slash_command_help_filtered(STUB_COMMANDS), - ] - .join( - " -", - ) -} - -pub(crate) fn render_help_topic(topic: LocalHelpTopic) -> String { - match topic { - LocalHelpTopic::Status => "Status - Usage claw status [--output-format ] - Purpose show the local workspace snapshot without entering the REPL - Output model, permissions, git state, config files, and sandbox status - Formats text (default), json - Related /status · claw --resume latest /status" - .to_string(), - LocalHelpTopic::Sandbox => "Sandbox - Usage claw sandbox [--output-format ] - Purpose inspect the resolved sandbox and isolation state for the current directory - Output namespace, network, filesystem, and fallback details - Formats text (default), json - Related /sandbox · claw status" - .to_string(), - LocalHelpTopic::Doctor => "Doctor - Usage claw doctor [--output-format ] - Purpose diagnose local auth, config, workspace, sandbox, and build metadata - Output local-only health report; no provider request or session resume required - Formats text (default), json - Related /doctor · claw --resume latest /doctor" - .to_string(), - LocalHelpTopic::Acp => "ACP / Zed - Usage claw acp [serve] [--output-format ] - Aliases claw --acp · claw -acp - Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime - Status discoverability only; `serve` is a status alias and does not launch a daemon yet - Formats text (default), json - Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help" - .to_string(), - LocalHelpTopic::Init => "Init - Usage claw init [--output-format ] - Purpose create .claw/, .claw.json, .gitignore, and CLAUDE.md in the current project - Output list of created vs. skipped files (idempotent: safe to re-run) - Formats text (default), json - Related claw status · claw doctor" - .to_string(), - LocalHelpTopic::State => "State - Usage claw state [--output-format ] - Purpose read .claw/worker-state.json written by the interactive REPL or a one-shot prompt - Output worker id, model, permissions, session reference (text or json) - Formats text (default), json - Produces state `claw` (interactive REPL) or `claw prompt ` (one non-interactive turn) - Observes state `claw state` reads; clawhip/CI may poll this file without HTTP - Exit codes 0 if state file exists and parses; 1 with actionable hint otherwise - Related claw status · ROADMAP #139 (this worker-concept contract)" - .to_string(), - LocalHelpTopic::Export => "Export - Usage claw export [--session ] [--output ] [--output-format ] - Purpose serialize a managed session to JSON for review, transfer, or archival - Defaults --session latest (most recent managed session in .claw/sessions/) - Formats text (default), json - Related /session list · claw --resume latest" - .to_string(), - LocalHelpTopic::Version => "Version - Usage claw version [--output-format ] - Aliases claw --version · claw -V - Purpose print the claw CLI version and build metadata - Formats text (default), json - Related claw doctor (full build/auth/config diagnostic)" - .to_string(), - LocalHelpTopic::SystemPrompt => "System Prompt - Usage claw system-prompt [--cwd ] [--date YYYY-MM-DD] [--output-format ] - Purpose render the resolved system prompt that `claw` would send for the given cwd + date - Options --cwd overrides the workspace dir · --date injects a deterministic date stamp - Formats text (default), json - Related claw doctor · claw dump-manifests" - .to_string(), - LocalHelpTopic::DumpManifests => "Dump Manifests - Usage claw dump-manifests [--manifests-dir ] [--output-format ] - Purpose emit every skill/agent/tool manifest the resolver would load for the current cwd - Options --manifests-dir scopes discovery to a specific directory - Formats text (default), json - Related claw skills · claw agents · claw doctor" - .to_string(), - LocalHelpTopic::BootstrapPlan => "Bootstrap Plan - Usage claw bootstrap-plan [--output-format ] - Purpose list the ordered startup phases the CLI would execute before dispatch - Output phase names (text) or structured phase list (json) — primary output is the plan itself - Formats text (default), json - Related claw doctor · claw status" - .to_string(), - } -} - -pub(crate) fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String { - if entries.is_empty() { - return "Prompt history\n Result no prompts recorded yet".to_string(); - } - - let total = entries.len(); - let start = total.saturating_sub(limit); - let shown = &entries[start..]; - let mut lines = vec![ - "Prompt history".to_string(), - format!(" Total {total}"), - format!(" Showing {} most recent", shown.len()), - format!(" Reverse search Ctrl-R in the REPL"), - String::new(), - ]; - for (offset, entry) in shown.iter().enumerate() { - let absolute_index = start + offset + 1; - let timestamp = format_history_timestamp(entry.timestamp_ms); - let first_line = entry.text.lines().next().unwrap_or("").trim(); - let display = if first_line.chars().count() > 80 { - let truncated: String = first_line.chars().take(77).collect(); - format!("{truncated}...") - } else { - first_line.to_string() - }; - lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}")); - } - lines.join("\n") -} - -pub(crate) fn render_version_report() -> String { - let git_sha = GIT_SHA.unwrap_or("unknown"); - let target = BUILD_TARGET.unwrap_or("unknown"); - format!( - "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Target {target}\n Build date {DEFAULT_DATE}" - ) -} - -pub(crate) fn 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}" - )); - } - ContentBlock::Thinking { - thinking, - signature, - } => { - lines.push(format!("[thinking signature={signature:?}] {thinking}")); - } - } - } - lines.push(String::new()); - } - lines.join("\n") -} - -pub(crate) fn render_session_markdown( - session: &Session, - session_id: &str, - session_path: &Path, -) -> String { - let mut lines = vec![ - "# Conversation Export".to_string(), - String::new(), - format!("- **Session**: `{session_id}`"), - format!("- **File**: `{}`", session_path.display()), - format!("- **Messages**: {}", session.messages.len()), - ]; - if let Some(workspace_root) = session.workspace_root() { - lines.push(format!("- **Workspace**: `{}`", workspace_root.display())); - } - if let Some(fork) = &session.fork { - let branch = fork.branch_name.as_deref().unwrap_or("(unnamed)"); - lines.push(format!( - "- **Forked from**: `{}` (branch `{branch}`)", - fork.parent_session_id - )); - } - if let Some(compaction) = &session.compaction { - lines.push(format!( - "- **Compactions**: {} (last removed {} messages)", - compaction.count, compaction.removed_message_count - )); - } - lines.push(String::new()); - lines.push("---".to_string()); - lines.push(String::new()); - - for (index, message) in session.messages.iter().enumerate() { - let role = match message.role { - MessageRole::System => "System", - MessageRole::User => "User", - MessageRole::Assistant => "Assistant", - MessageRole::Tool => "Tool", - }; - lines.push(format!("## {}. {role}", index + 1)); - lines.push(String::new()); - for block in &message.blocks { - match block { - ContentBlock::Text { text } => { - let trimmed = text.trim_end(); - if !trimmed.is_empty() { - lines.push(trimmed.to_string()); - lines.push(String::new()); - } - } - ContentBlock::ToolUse { id, name, input } => { - lines.push(format!( - "**Tool call** `{name}` _(id `{}`)_", - short_tool_id(id) - )); - let summary = summarize_tool_payload_for_markdown(input); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => { - let status = if *is_error { "error" } else { "ok" }; - lines.push(format!( - "**Tool result** `{tool_name}` _(id `{}`, {status})_", - short_tool_id(tool_use_id) - )); - let summary = summarize_tool_payload_for_markdown(output); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - ContentBlock::Thinking { - thinking, - signature, - } => { - lines.push(format!("**Thinking** _{signature:?}_")); - let summary = summarize_tool_payload_for_markdown(thinking); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - } - } - if let Some(usage) = message.usage { - lines.push(format!( - "_tokens: in={} out={} cache_create={} cache_read={}_", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - )); - lines.push(String::new()); - } - } - lines.join("\n") -} - -pub(crate) fn render_thinking_block_summary( - out: &mut (impl Write + ?Sized), - char_count: Option, - redacted: bool, -) -> Result<(), RuntimeError> { - let summary = if redacted { - "\n▶ Thinking block hidden by provider\n".to_string() - } else if let Some(char_count) = char_count { - format!("\n▶ Thinking ({char_count} chars hidden)\n") - } else { - "\n▶ Thinking hidden\n".to_string() - }; - write!(out, "{summary}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string())) -} - -pub(crate) fn push_output_block( - block: OutputContentBlock, - out: &mut (impl Write + ?Sized), - events: &mut Vec, - pending_tool: &mut Option<(String, String, String)>, - streaming_tool_input: bool, - block_has_thinking_summary: &mut bool, -) -> Result<(), RuntimeError> { - match block { - OutputContentBlock::Text { text } => { - if !text.is_empty() { - let rendered = TerminalRenderer::new().markdown_to_ansi(&text); - write!(out, "{rendered}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - events.push(AssistantEvent::TextDelta(text)); - } - } - OutputContentBlock::ToolUse { id, name, input } => { - // During streaming, the initial content_block_start has an empty input ({}). - // The real input arrives via input_json_delta events. In - // non-streaming responses, preserve a legitimate empty object. - let initial_input = if streaming_tool_input - && input.is_object() - && input.as_object().is_some_and(serde_json::Map::is_empty) - { - String::new() - } else { - input.to_string() - }; - *pending_tool = Some((id, name, initial_input)); - } - OutputContentBlock::Thinking { thinking, .. } => { - render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; - *block_has_thinking_summary = true; - } - OutputContentBlock::RedactedThinking { .. } => { - render_thinking_block_summary(out, None, true)?; - *block_has_thinking_summary = true; - } - } - Ok(()) -} - -pub(crate) fn summarize_tool_payload_for_markdown(payload: &str) -> String { - let compact = match serde_json::from_str::(payload) { - Ok(value) => value.to_string(), - Err(_) => payload.split_whitespace().collect::>().join(" "), - }; - if compact.is_empty() { - return String::new(); - } - truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT) -} - -pub(crate) fn short_tool_id(id: &str) -> String { - let char_count = id.chars().count(); - if char_count <= 12 { - return id.to_string(); - } - let prefix: String = id.chars().take(12).collect(); - format!("{prefix}…") -} diff --git a/rust/crates/rusty-claude-cli/src/repl.rs b/rust/crates/rusty-claude-cli/src/repl.rs index ed69ecbb..5372231a 100644 --- a/rust/crates/rusty-claude-cli/src/repl.rs +++ b/rust/crates/rusty-claude-cli/src/repl.rs @@ -1,1728 +1 @@ -use crate::TokenUsage; -use crate::*; -use api::Usage; -use std::env; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::time::Duration; -use std::time::UNIX_EPOCH; - - -impl LiveCli { - pub(crate) fn new( - model: String, - enable_tools: bool, - allowed_tools: Option, - permission_mode: PermissionMode, - ) -> Result> { - let system_prompt = build_system_prompt()?; - let session_state = new_cli_session()?; - let session = create_managed_session_handle(&session_state.session_id)?; - let runtime = build_runtime( - session_state.with_persistence_path(session.path.clone()), - &session.id, - model.clone(), - system_prompt.clone(), - enable_tools, - true, - allowed_tools.clone(), - permission_mode, - None, - )?; - let cli = Self { - model, - allowed_tools, - permission_mode, - system_prompt, - runtime, - session, - prompt_history: Vec::new(), - }; - cli.persist_session()?; - Ok(cli) - } - - pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - if let Some(rt) = self.runtime.runtime.as_mut() { - rt.api_client_mut().set_reasoning_effort(effort); - } - } - - pub(crate) fn startup_banner(&self) -> String { - 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, - ) - } - - pub(crate) fn repl_completion_candidates( - &self, - ) -> Result, Box> { - Ok(slash_command_completion_candidates_with_sessions( - &self.model, - Some(&self.session.id), - list_managed_sessions()? - .into_iter() - .map(|session| session.id) - .collect(), - )) - } - - pub(crate) fn prepare_turn_runtime( - &self, - emit_output: bool, - ) -> Result<(BuiltRuntime, HookAbortMonitor), Box> { - let hook_abort_signal = runtime::HookAbortSignal::new(); - let runtime = build_runtime( - self.runtime.session().clone(), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - emit_output, - self.allowed_tools.clone(), - self.permission_mode, - None, - )? - .with_hook_abort_signal(hook_abort_signal.clone()); - let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); - - Ok((runtime, hook_abort_monitor)) - } - - pub(crate) fn replace_runtime( - &mut self, - runtime: BuiltRuntime, - ) -> Result<(), Box> { - self.runtime.shutdown_plugins()?; - self.runtime = runtime; - Ok(()) - } - - pub(crate) fn run_turn(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; - let mut spinner = Spinner::new(); - let mut stdout = io::stdout(); - spinner.tick( - "🦀 Thinking...", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - match result { - Ok(summary) => { - self.replace_runtime(runtime)?; - spinner.finish( - "✨ Done", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - println!(); - if let Some(event) = summary.auto_compaction { - println!( - "{}", - format_auto_compaction_notice(event.removed_message_count) - ); - } - self.persist_session()?; - Ok(()) - } - Err(error) => { - runtime.shutdown_plugins()?; - spinner.fail( - "❌ Request failed", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - Err(Box::new(error)) - } - } - } - - pub(crate) fn run_turn_with_output( - &mut self, - input: &str, - output_format: CliOutputFormat, - compact: bool, - ) -> Result<(), Box> { - match output_format { - CliOutputFormat::Json if compact => self.run_prompt_compact_json(input), - CliOutputFormat::Text if compact => self.run_prompt_compact(input), - CliOutputFormat::Text => self.run_turn(input), - CliOutputFormat::Json => self.run_prompt_json(input), - } - } - - pub(crate) fn run_prompt_compact( - &mut self, - input: &str, - ) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - let final_text = final_assistant_text(&summary); - println!("{final_text}"); - Ok(()) - } - - pub(crate) fn run_prompt_compact_json( - &mut self, - input: &str, - ) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!( - "{}", - json!({ - "message": final_assistant_text(&summary), - "compact": true, - "model": self.model, - "usage": { - "input_tokens": summary.usage.input_tokens, - "output_tokens": summary.usage.output_tokens, - "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, - "cache_read_input_tokens": summary.usage.cache_read_input_tokens, - }, - }) - ); - Ok(()) - } - - pub(crate) fn run_prompt_json( - &mut self, - input: &str, - ) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!( - "{}", - json!({ - "message": final_assistant_text(&summary), - "model": self.model, - "iterations": summary.iterations, - "auto_compaction": summary.auto_compaction.map(|event| json!({ - "removed_messages": event.removed_message_count, - "notice": format_auto_compaction_notice(event.removed_message_count), - })), - "tool_uses": collect_tool_uses(&summary), - "tool_results": collect_tool_results(&summary), - "prompt_cache_events": collect_prompt_cache_events(&summary), - "usage": { - "input_tokens": summary.usage.input_tokens, - "output_tokens": summary.usage.output_tokens, - "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, - "cache_read_input_tokens": summary.usage.cache_read_input_tokens, - }, - "estimated_cost": format_usd( - summary.usage.estimate_cost_usd_with_pricing( - pricing_for_model(&self.model) - .unwrap_or_else(runtime::ModelPricing::default_sonnet_tier) - ).total_cost_usd() - ) - }) - ); - Ok(()) - } - - #[allow(clippy::too_many_lines)] - pub(crate) fn handle_repl_command( - &mut self, - command: SlashCommand, - ) -> Result> { - Ok(match command { - SlashCommand::Help => { - println!("{}", render_repl_help()); - false - } - SlashCommand::Status => { - self.print_status(); - false - } - SlashCommand::Bughunter { scope } => { - self.run_bughunter(scope.as_deref())?; - false - } - SlashCommand::Commit => { - self.run_commit(None)?; - false - } - SlashCommand::Pr { context } => { - self.run_pr(context.as_deref())?; - false - } - SlashCommand::Issue { context } => { - self.run_issue(context.as_deref())?; - false - } - SlashCommand::Ultraplan { task } => { - self.run_ultraplan(task.as_deref())?; - false - } - SlashCommand::Teleport { target } => { - Self::run_teleport(target.as_deref())?; - false - } - SlashCommand::DebugToolCall => { - self.run_debug_tool_call(None)?; - false - } - SlashCommand::Sandbox => { - Self::print_sandbox_status(); - false - } - SlashCommand::Compact => { - self.compact()?; - false - } - SlashCommand::Model { model } => self.set_model(model)?, - SlashCommand::Permissions { mode } => self.set_permissions(mode)?, - SlashCommand::Clear { confirm } => self.clear_session(confirm)?, - SlashCommand::Cost => { - self.print_cost(); - false - } - SlashCommand::Resume { session_path } => self.resume_session(session_path)?, - SlashCommand::Config { section } => { - Self::print_config(section.as_deref())?; - false - } - SlashCommand::Mcp { action, target } => { - let args = match (action.as_deref(), target.as_deref()) { - (None, None) => None, - (Some(action), None) => Some(action.to_string()), - (Some(action), Some(target)) => Some(format!("{action} {target}")), - (None, Some(target)) => Some(target.to_string()), - }; - Self::print_mcp(args.as_deref(), CliOutputFormat::Text)?; - false - } - SlashCommand::Memory => { - Self::print_memory()?; - false - } - SlashCommand::Init => { - run_init(CliOutputFormat::Text)?; - false - } - SlashCommand::Diff => { - Self::print_diff()?; - false - } - SlashCommand::Version => { - Self::print_version(CliOutputFormat::Text); - false - } - SlashCommand::Export { path } => { - self.export_session(path.as_deref())?; - false - } - SlashCommand::Session { action, target } => { - self.handle_session_command(action.as_deref(), target.as_deref())? - } - SlashCommand::Plugins { action, target } => { - self.handle_plugins_command(action.as_deref(), target.as_deref())? - } - SlashCommand::Agents { args } => { - Self::print_agents(args.as_deref(), CliOutputFormat::Text)?; - false - } - SlashCommand::Skills { args } => { - match classify_skills_slash_command(args.as_deref()) { - SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?, - SkillSlashDispatch::Local => { - Self::print_skills(args.as_deref(), CliOutputFormat::Text)?; - } - } - false - } - SlashCommand::Doctor => { - println!("{}", render_doctor_report()?.render()); - false - } - SlashCommand::History { count } => { - self.print_prompt_history(count.as_deref()); - false - } - SlashCommand::Stats => { - let usage = UsageTracker::from_session(self.runtime.session()).cumulative_usage(); - println!("{}", format_cost_report(usage)); - false - } - SlashCommand::Login - | SlashCommand::Logout - | SlashCommand::Vim - | SlashCommand::Upgrade - | SlashCommand::Share - | SlashCommand::Feedback - | SlashCommand::Files - | SlashCommand::Fast - | SlashCommand::Exit - | SlashCommand::Summary - | SlashCommand::Desktop - | SlashCommand::Brief - | SlashCommand::Advisor - | SlashCommand::Stickers - | SlashCommand::Insights - | SlashCommand::Thinkback - | SlashCommand::ReleaseNotes - | SlashCommand::SecurityReview - | SlashCommand::Keybindings - | SlashCommand::PrivacySettings - | SlashCommand::Plan { .. } - | SlashCommand::Review { .. } - | SlashCommand::Tasks { .. } - | SlashCommand::Theme { .. } - | SlashCommand::Voice { .. } - | SlashCommand::Usage { .. } - | SlashCommand::Rename { .. } - | SlashCommand::Copy { .. } - | SlashCommand::Hooks { .. } - | SlashCommand::Context { .. } - | SlashCommand::Color { .. } - | SlashCommand::Effort { .. } - | SlashCommand::Branch { .. } - | SlashCommand::Rewind { .. } - | SlashCommand::Ide { .. } - | SlashCommand::Tag { .. } - | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => { - let cmd_name = command.slash_name(); - eprintln!("{cmd_name} is not yet implemented in this build."); - false - } - SlashCommand::Unknown(name) => { - eprintln!("{}", format_unknown_slash_command(&name)); - false - } - }) - } - - pub(crate) fn persist_session(&self) -> Result<(), Box> { - self.runtime.session().save_to_path(&self.session.path)?; - Ok(()) - } - - pub(crate) fn print_status(&self) { - let cumulative = self.runtime.usage().cumulative_usage(); - let latest = self.runtime.usage().current_turn_usage(); - println!( - "{}", - format_status_report( - &self.model, - StatusUsage { - message_count: self.runtime.session().messages.len(), - turns: self.runtime.usage().turns(), - latest, - cumulative, - estimated_tokens: self.runtime.estimated_tokens(), - }, - self.permission_mode.as_str(), - &status_context(Some(&self.session.path)).expect("status context should load"), - None, // #148: REPL /status doesn't carry flag provenance - ) - ); - } - - pub(crate) fn record_prompt_history(&mut self, prompt: &str) { - let timestamp_ms = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(self.runtime.session().updated_at_ms, |duration| { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) - }); - let entry = PromptHistoryEntry { - timestamp_ms, - text: prompt.to_string(), - }; - self.prompt_history.push(entry); - if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) { - eprintln!("warning: failed to persist prompt history: {error}"); - } - } - - pub(crate) fn print_prompt_history(&self, count: Option<&str>) { - let limit = match parse_history_count(count) { - Ok(limit) => limit, - Err(message) => { - eprintln!("{message}"); - return; - } - }; - let session_entries = &self.runtime.session().prompt_history; - let entries = if session_entries.is_empty() { - if self.prompt_history.is_empty() { - collect_session_prompt_history(self.runtime.session()) - } else { - self.prompt_history - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect() - } - } else { - session_entries - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect() - }; - println!("{}", render_prompt_history_report(&entries, limit)); - } - - pub(crate) fn print_sandbox_status() { - let cwd = env::current_dir().expect("current dir"); - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader - .load() - .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); - println!( - "{}", - format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd)) - ); - } - - pub(crate) fn set_model( - &mut self, - model: Option, - ) -> Result> { - let Some(model) = model else { - println!( - "{}", - format_model_report( - &self.model, - self.runtime.session().messages.len(), - self.runtime.usage().turns(), - ) - ); - return Ok(false); - }; - - let model = resolve_model_alias_with_config(&model); - - if model == self.model { - println!( - "{}", - format_model_report( - &self.model, - self.runtime.session().messages.len(), - self.runtime.usage().turns(), - ) - ); - return Ok(false); - } - - let previous = self.model.clone(); - let session = self.runtime.session().clone(); - let message_count = session.messages.len(); - let runtime = build_runtime( - session, - &self.session.id, - model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.model.clone_from(&model); - println!( - "{}", - format_model_switch_report(&previous, &model, message_count) - ); - Ok(true) - } - - pub(crate) fn set_permissions( - &mut self, - mode: Option, - ) -> Result> { - let Some(mode) = mode else { - println!( - "{}", - format_permissions_report(self.permission_mode.as_str()) - ); - return Ok(false); - }; - - let normalized = normalize_permission_mode(&mode).ok_or_else(|| { - format!( - "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access." - ) - })?; - - if normalized == self.permission_mode.as_str() { - println!("{}", format_permissions_report(normalized)); - return Ok(false); - } - - let previous = self.permission_mode.as_str().to_string(); - let session = self.runtime.session().clone(); - self.permission_mode = permission_mode_from_label(normalized); - let runtime = build_runtime( - session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - println!( - "{}", - format_permissions_switch_report(&previous, normalized) - ); - Ok(true) - } - - pub(crate) fn clear_session( - &mut self, - confirm: bool, - ) -> Result> { - if !confirm { - println!( - "clear: confirmation required; run /clear --confirm to start a fresh session." - ); - return Ok(false); - } - - let previous_session = self.session.clone(); - let session_state = new_cli_session()?; - self.session = create_managed_session_handle(&session_state.session_id)?; - let runtime = build_runtime( - session_state.with_persistence_path(self.session.path.clone()), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - println!( - "Session cleared\n Mode fresh session\n Previous session {}\n Resume previous /resume {}\n Preserved model {}\n Permission mode {}\n New session {}\n Session file {}", - previous_session.id, - previous_session.id, - self.model, - self.permission_mode.as_str(), - self.session.id, - self.session.path.display(), - ); - Ok(true) - } - - pub(crate) fn print_cost(&self) { - let cumulative = self.runtime.usage().cumulative_usage(); - println!("{}", format_cost_report(cumulative)); - } - - pub(crate) fn resume_session( - &mut self, - session_path: Option, - ) -> Result> { - let Some(session_ref) = session_path else { - println!("{}", render_resume_usage()); - return Ok(false); - }; - - let (handle, session) = load_session_reference(&session_ref)?; - let message_count = session.messages.len(); - let session_id = session.session_id.clone(); - let runtime = build_runtime( - session, - &handle.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.session = SessionHandle { - id: session_id, - path: handle.path, - }; - println!( - "{}", - format_resume_report( - &self.session.path.display().to_string(), - message_count, - self.runtime.usage().turns(), - ) - ); - Ok(true) - } - - pub(crate) fn print_config(section: Option<&str>) -> Result<(), Box> { - println!("{}", render_config_report(section)?); - Ok(()) - } - - pub(crate) fn print_memory() -> Result<(), Box> { - println!("{}", render_memory_report()?); - Ok(()) - } - - pub(crate) fn print_agents( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)? - ), - } - Ok(()) - } - - pub(crate) fn print_mcp( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - // `claw mcp serve` starts a stdio MCP server exposing claw's built-in - // tools. All other `mcp` subcommands fall through to the existing - // configured-server reporter (`list`, `status`, ...). - if matches!(args.map(str::trim), Some("serve")) { - return run_mcp_serve(); - } - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd))? - ), - } - Ok(()) - } - - pub(crate) fn print_skills( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&handle_skills_slash_command_json(args, &cwd)?)? - ), - } - Ok(()) - } - - pub(crate) fn print_plugins( - action: Option<&str>, - target: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); - let result = handle_plugins_slash_command(action, target, &mut manager)?; - match output_format { - CliOutputFormat::Text => println!("{}", result.message), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "plugin", - "action": action.unwrap_or("list"), - "target": target, - "message": result.message, - "reload_runtime": result.reload_runtime, - }))? - ), - } - Ok(()) - } - - pub(crate) fn print_diff() -> Result<(), Box> { - println!("{}", render_diff_report()?); - Ok(()) - } - - pub(crate) fn print_version(output_format: CliOutputFormat) { - let _ = crate::print_version(output_format); - } - - pub(crate) fn export_session( - &self, - requested_path: Option<&str>, - ) -> Result<(), Box> { - let export_path = resolve_export_path(requested_path, self.runtime.session())?; - fs::write(&export_path, render_export_text(self.runtime.session()))?; - println!( - "Export\n Result wrote transcript\n File {}\n Messages {}", - export_path.display(), - self.runtime.session().messages.len(), - ); - Ok(()) - } - - #[allow(clippy::too_many_lines)] - pub(crate) fn handle_session_command( - &mut self, - action: Option<&str>, - target: Option<&str>, - ) -> Result> { - match action { - None | Some("list") => { - println!("{}", render_session_list(&self.session.id)?); - Ok(false) - } - Some("switch") => { - let Some(target) = target else { - println!("Usage: /session switch "); - return Ok(false); - }; - let (handle, session) = load_session_reference(target)?; - let message_count = session.messages.len(); - let session_id = session.session_id.clone(); - let runtime = build_runtime( - session, - &handle.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.session = SessionHandle { - id: session_id, - path: handle.path, - }; - println!( - "Session switched\n Active session {}\n File {}\n Messages {}", - self.session.id, - self.session.path.display(), - message_count, - ); - Ok(true) - } - Some("fork") => { - let forked = self.runtime.fork_session(target.map(ToOwned::to_owned)); - let parent_session_id = self.session.id.clone(); - let handle = create_managed_session_handle(&forked.session_id)?; - let branch_name = forked - .fork - .as_ref() - .and_then(|fork| fork.branch_name.clone()); - let forked = forked.with_persistence_path(handle.path.clone()); - let message_count = forked.messages.len(); - forked.save_to_path(&handle.path)?; - let runtime = build_runtime( - forked, - &handle.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.session = handle; - println!( - "Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}", - parent_session_id, - self.session.id, - branch_name.as_deref().unwrap_or("(unnamed)"), - self.session.path.display(), - message_count, - ); - Ok(true) - } - Some("delete") => { - let Some(target) = target else { - println!("Usage: /session delete [--force]"); - return Ok(false); - }; - let handle = resolve_session_reference(target)?; - if handle.id == self.session.id { - println!( - "delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch .", - handle.id - ); - return Ok(false); - } - if !confirm_session_deletion(&handle.id) { - println!("delete: cancelled."); - return Ok(false); - } - delete_managed_session(&handle.path)?; - println!( - "Session deleted\n Deleted session {}\n File {}", - handle.id, - handle.path.display(), - ); - Ok(false) - } - Some("delete-force") => { - let Some(target) = target else { - println!("Usage: /session delete [--force]"); - return Ok(false); - }; - let handle = resolve_session_reference(target)?; - if handle.id == self.session.id { - println!( - "delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch .", - handle.id - ); - return Ok(false); - } - delete_managed_session(&handle.path)?; - println!( - "Session deleted\n Deleted session {}\n File {}", - handle.id, - handle.path.display(), - ); - Ok(false) - } - Some(other) => { - println!( - "Unknown /session action '{other}'. Use /session list, /session switch , /session fork [branch-name], or /session delete [--force]." - ); - Ok(false) - } - } - } - - pub(crate) 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) - } - - pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box> { - let runtime = build_runtime( - self.runtime.session().clone(), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.persist_session() - } - - pub(crate) fn compact(&mut self) -> Result<(), Box> { - let result = self.runtime.compact(CompactionConfig::default()); - let removed = result.removed_message_count; - let kept = result.compacted_session.messages.len(); - let skipped = removed == 0; - let runtime = build_runtime( - result.compacted_session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!("{}", format_compact_report(removed, kept, skipped)); - Ok(()) - } - - pub(crate) fn run_internal_prompt_text_with_progress( - &self, - prompt: &str, - enable_tools: bool, - progress: Option, - ) -> Result> { - let session = self.runtime.session().clone(); - let mut runtime = build_runtime( - session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - enable_tools, - false, - self.allowed_tools.clone(), - self.permission_mode, - progress, - )?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; - let text = final_assistant_text(&summary).trim().to_string(); - runtime.shutdown_plugins()?; - Ok(text) - } - - pub(crate) fn run_internal_prompt_text( - &self, - prompt: &str, - enable_tools: bool, - ) -> Result> { - self.run_internal_prompt_text_with_progress(prompt, enable_tools, None) - } - - pub(crate) fn run_bughunter( - &self, - scope: Option<&str>, - ) -> Result<(), Box> { - println!("{}", format_bughunter_report(scope)); - Ok(()) - } - - pub(crate) fn run_ultraplan( - &self, - task: Option<&str>, - ) -> Result<(), Box> { - println!("{}", format_ultraplan_report(task)); - Ok(()) - } - - pub(crate) fn run_teleport(target: Option<&str>) -> Result<(), Box> { - let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { - println!("Usage: /teleport "); - return Ok(()); - }; - - println!("{}", render_teleport_report(target)?); - Ok(()) - } - - pub(crate) fn run_debug_tool_call( - &self, - args: Option<&str>, - ) -> Result<(), Box> { - validate_no_args("/debug-tool-call", args)?; - println!("{}", render_last_tool_debug_report(self.runtime.session())?); - Ok(()) - } - - pub(crate) fn run_commit( - &mut self, - args: Option<&str>, - ) -> Result<(), Box> { - validate_no_args("/commit", args)?; - let status = git_output(&["status", "--short", "--branch"])?; - let summary = parse_git_workspace_summary(Some(&status)); - let branch = parse_git_status_branch(Some(&status)); - if summary.is_clean() { - println!("{}", format_commit_skipped_report()); - return Ok(()); - } - - println!( - "{}", - format_commit_preflight_report(branch.as_deref(), summary) - ); - Ok(()) - } - - pub(crate) fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { - let branch = - resolve_git_branch_for(&env::current_dir()?).unwrap_or_else(|| "unknown".to_string()); - println!("{}", format_pr_report(&branch, context)); - Ok(()) - } - - pub(crate) fn run_issue( - &self, - context: Option<&str>, - ) -> Result<(), Box> { - println!("{}", format_issue_report(context)); - Ok(()) - } -} - -#[allow(clippy::needless_pass_by_value)] -pub(crate) fn run_repl( - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, -) -> Result<(), Box> { - enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; - run_stale_base_preflight(base_commit.as_deref()); - let resolved_model = resolve_repl_model(model); - let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; - cli.set_reasoning_effort(reasoning_effort); - let mut editor = - input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); - println!("{}", cli.startup_banner()); - println!("{}", format_connected_line(&cli.model)); - - loop { - editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); - match editor.read_line()? { - input::ReadOutcome::Submit(input) => { - let trimmed = input.trim().to_string(); - if trimmed.is_empty() { - continue; - } - if matches!(trimmed.as_str(), "/exit" | "/quit") { - cli.persist_session()?; - break; - } - match SlashCommand::parse(&trimmed) { - Ok(Some(command)) => { - if cli.handle_repl_command(command)? { - cli.persist_session()?; - } - continue; - } - Ok(None) => {} - Err(error) => { - eprintln!("{error}"); - continue; - } - } - // Bare-word skill dispatch: if the first token of the input - // matches a known skill name, invoke it as `/skills ` - // rather than forwarding raw text to the LLM (ROADMAP #36). - let cwd = std::env::current_dir().unwrap_or_default(); - if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) { - editor.push_history(input); - cli.record_prompt_history(&trimmed); - cli.run_turn(&prompt)?; - continue; - } - editor.push_history(input); - cli.record_prompt_history(&trimmed); - cli.run_turn(&trimmed)?; - } - input::ReadOutcome::Cancel => {} - input::ReadOutcome::Exit => { - cli.persist_session()?; - break; - } - } - } - - Ok(()) -} - -#[allow(clippy::too_many_lines)] -pub(crate) fn parse_args(args: &[String]) -> Result { - let mut model = DEFAULT_MODEL.to_string(); - // #148: when user passes --model/--model=, capture the raw input so we - // can attribute source: "flag" later. None means no flag was supplied. - let mut model_flag_raw: Option = None; - let mut output_format = CliOutputFormat::Text; - let mut permission_mode_override = None; - let mut wants_help = false; - let mut wants_version = false; - let mut allowed_tool_values = Vec::new(); - let mut compact = false; - let mut base_commit: Option = None; - let mut reasoning_effort: Option = None; - let mut allow_broad_cwd = false; - let mut rest: Vec = Vec::new(); - let mut index = 0; - - while index < args.len() { - match args[index].as_str() { - "--help" | "-h" if rest.is_empty() => { - wants_help = true; - index += 1; - } - "--help" | "-h" - if !rest.is_empty() - && matches!(rest[0].as_str(), "prompt" | "commit" | "pr" | "issue") => - { - // `--help` following a subcommand that would otherwise forward - // the arg to the API (e.g. `claw prompt --help`) should show - // top-level help instead. Subcommands that consume their own - // args (agents, mcp, plugins, skills) and local help-topic - // subcommands (status, sandbox, doctor, init, state, export, - // version, system-prompt, dump-manifests, bootstrap-plan) must - // NOT be intercepted here — they handle --help in their own - // dispatch paths via parse_local_help_action(). See #141. - wants_help = true; - index += 1; - } - "--version" | "-V" => { - wants_version = true; - index += 1; - } - "--model" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --model".to_string())?; - validate_model_syntax(value)?; - model = resolve_model_alias_with_config(value); - model_flag_raw = Some(value.clone()); // #148 - index += 2; - } - flag if flag.starts_with("--model=") => { - let value = &flag[8..]; - validate_model_syntax(value)?; - model = resolve_model_alias_with_config(value); - model_flag_raw = Some(value.to_string()); // #148 - index += 1; - } - "--output-format" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --output-format".to_string())?; - output_format = CliOutputFormat::parse(value)?; - index += 2; - } - "--permission-mode" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --permission-mode".to_string())?; - permission_mode_override = Some(parse_permission_mode_arg(value)?); - index += 2; - } - flag if flag.starts_with("--output-format=") => { - output_format = CliOutputFormat::parse(&flag[16..])?; - index += 1; - } - flag if flag.starts_with("--permission-mode=") => { - permission_mode_override = Some(parse_permission_mode_arg(&flag[18..])?); - index += 1; - } - "--dangerously-skip-permissions" => { - permission_mode_override = Some(PermissionMode::DangerFullAccess); - index += 1; - } - "--compact" => { - compact = true; - index += 1; - } - "--base-commit" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --base-commit".to_string())?; - base_commit = Some(value.clone()); - index += 2; - } - flag if flag.starts_with("--base-commit=") => { - base_commit = Some(flag[14..].to_string()); - index += 1; - } - "--reasoning-effort" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --reasoning-effort".to_string())?; - if !matches!(value.as_str(), "low" | "medium" | "high") { - return Err(format!( - "invalid value for --reasoning-effort: '{value}'; must be low, medium, or high" - )); - } - reasoning_effort = Some(value.clone()); - index += 2; - } - flag if flag.starts_with("--reasoning-effort=") => { - let value = &flag[19..]; - if !matches!(value, "low" | "medium" | "high") { - return Err(format!( - "invalid value for --reasoning-effort: '{value}'; must be low, medium, or high" - )); - } - reasoning_effort = Some(value.to_string()); - index += 1; - } - "--allow-broad-cwd" => { - allow_broad_cwd = true; - index += 1; - } - "-p" => { - // Claw Code compat: -p "prompt" = one-shot prompt - let prompt = args[index + 1..].join(" "); - if prompt.trim().is_empty() { - return Err("-p requires a prompt string".to_string()); - } - return Ok(CliAction::Prompt { - prompt, - model: resolve_model_alias_with_config(&model), - output_format, - allowed_tools: normalize_allowed_tools(&allowed_tool_values)?, - permission_mode: permission_mode_override - .unwrap_or_else(default_permission_mode), - compact, - base_commit: base_commit.clone(), - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }); - } - "--print" => { - // Claw Code compat: --print makes output non-interactive - output_format = CliOutputFormat::Text; - index += 1; - } - "--resume" if rest.is_empty() => { - rest.push("--resume".to_string()); - index += 1; - } - flag if rest.is_empty() && flag.starts_with("--resume=") => { - rest.push("--resume".to_string()); - rest.push(flag[9..].to_string()); - index += 1; - } - "--acp" | "-acp" => { - rest.push("acp".to_string()); - index += 1; - } - "--allowedTools" | "--allowed-tools" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --allowedTools".to_string())?; - allowed_tool_values.push(value.clone()); - index += 2; - } - flag if flag.starts_with("--allowedTools=") => { - allowed_tool_values.push(flag[15..].to_string()); - index += 1; - } - flag if flag.starts_with("--allowed-tools=") => { - allowed_tool_values.push(flag[16..].to_string()); - index += 1; - } - other if rest.is_empty() && other.starts_with('-') => { - return Err(format_unknown_option(other)) - } - other => { - rest.push(other.to_string()); - index += 1; - } - } - } - - if wants_help { - return Ok(CliAction::Help { output_format }); - } - - if wants_version { - return Ok(CliAction::Version { output_format }); - } - - let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; - - if rest.is_empty() { - let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); - // When stdin is not a terminal (pipe/redirect) and no prompt is given on the - // command line, read stdin as the prompt and dispatch as a one-shot Prompt - // rather than starting the interactive REPL (which would consume the pipe and - // print the startup banner, then exit without sending anything to the API). - if !std::io::stdin().is_terminal() { - let mut buf = String::new(); - let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf); - let piped = buf.trim().to_string(); - if !piped.is_empty() { - return Ok(CliAction::Prompt { - model, - prompt: piped, - allowed_tools, - permission_mode, - output_format, - compact: false, - base_commit, - reasoning_effort, - allow_broad_cwd, - }); - } - } - return Ok(CliAction::Repl { - model, - allowed_tools, - permission_mode, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }); - } - if rest.first().map(String::as_str) == Some("--resume") { - return parse_resume_args(&rest[1..], output_format); - } - if let Some(action) = parse_local_help_action(&rest) { - return action; - } - if let Some(action) = parse_single_word_command_alias( - &rest, - &model, - model_flag_raw.as_deref(), - permission_mode_override, - output_format, - allowed_tools.clone(), - ) { - return action; - } - - let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); - - match rest[0].as_str() { - "dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format), - "bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }), - "agents" => Ok(CliAction::Agents { - args: join_optional_args(&rest[1..]), - output_format, - }), - "mcp" => Ok(CliAction::Mcp { - args: join_optional_args(&rest[1..]), - output_format, - }), - // #145: `plugins` was routed through the prompt fallback because no - // top-level parser arm produced CliAction::Plugins. That made `claw - // plugins` (and `claw plugins --help`, `claw plugins list`, ...) - // attempt an Anthropic network call, surfacing the misleading error - // `missing Anthropic credentials` even though the command is purely - // local introspection. Mirror `agents`/`mcp`/`skills`: action is the - // first positional arg, target is the second. - "plugins" => { - let tail = &rest[1..]; - let action = tail.first().cloned(); - let target = tail.get(1).cloned(); - if tail.len() > 2 { - return Err(format!( - "unexpected extra arguments after `claw plugins {}`: {}", - tail[..2].join(" "), - tail[2..].join(" ") - )); - } - Ok(CliAction::Plugins { - action, - target, - output_format, - }) - } - // #146: `config` is pure-local read-only introspection (merges - // `.claw.json` + `.claw/settings.json` from disk, no network, no - // state mutation). Previously callers had to spin up a session with - // `claw --resume SESSION.jsonl /config` to see their own config, - // which is synthetic friction. Accepts an optional section name - // (env|hooks|model|plugins) matching the slash command shape. - "config" => { - let tail = &rest[1..]; - let section = tail.first().cloned(); - if tail.len() > 1 { - return Err(format!( - "unexpected extra arguments after `claw config {}`: {}", - tail[0], - tail[1..].join(" ") - )); - } - Ok(CliAction::Config { - section, - output_format, - }) - } - // #146: `diff` is pure-local (shells out to `git diff --cached` + - // `git diff`). No session needed to inspect the working tree. - "diff" => { - if rest.len() > 1 { - return Err(format!( - "unexpected extra arguments after `claw diff`: {}", - rest[1..].join(" ") - )); - } - Ok(CliAction::Diff { output_format }) - } - "skills" => { - let args = join_optional_args(&rest[1..]); - match classify_skills_slash_command(args.as_deref()) { - SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { - prompt, - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }), - SkillSlashDispatch::Local => Ok(CliAction::Skills { - args, - output_format, - }), - } - } - "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), - "acp" => parse_acp_args(&rest[1..], output_format), - "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), - "init" => Ok(CliAction::Init { output_format }), - "export" => parse_export_args(&rest[1..], output_format), - "prompt" => { - let prompt = rest[1..].join(" "); - if prompt.trim().is_empty() { - return Err("prompt subcommand requires a prompt string".to_string()); - } - Ok(CliAction::Prompt { - prompt, - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit: base_commit.clone(), - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }) - } - other if other.starts_with('/') => parse_direct_slash_cli_action( - &rest, - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit, - reasoning_effort, - allow_broad_cwd, - ), - other => { - if rest.len() == 1 && looks_like_subcommand_typo(other) { - if let Some(suggestions) = suggest_similar_subcommand(other) { - let mut message = format!("unknown subcommand: {other}."); - if let Some(line) = render_suggestion_line("Did you mean", &suggestions) { - message.push('\n'); - message.push_str(&line); - } - message.push_str( - "\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt `.", - ); - return Err(message); - } - } - // #147: guard empty/whitespace-only prompts at the fallthrough - // path the same way `"prompt"` arm above does. Without this, - // `claw ""`, `claw " "`, and `claw "" ""` silently route to - // the Anthropic call and surface a misleading - // `missing Anthropic credentials` error (or burn API tokens on - // an empty prompt when credentials are present). - let joined = rest.join(" "); - if joined.trim().is_empty() { - return Err( - "empty prompt: provide a subcommand (run `claw --help`) or a non-empty prompt string" - .to_string(), - ); - } - Ok(CliAction::Prompt { - prompt: joined, - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }) - } - } -} - -pub(crate) fn parse_export_args( - args: &[String], - output_format: CliOutputFormat, -) -> Result { - let mut session_reference = LATEST_SESSION_REFERENCE.to_string(); - let mut output_path: Option = None; - let mut index = 0; - - while index < args.len() { - match args[index].as_str() { - "--session" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing value for --session".to_string())?; - session_reference.clone_from(value); - index += 2; - } - flag if flag.starts_with("--session=") => { - session_reference = flag[10..].to_string(); - index += 1; - } - "--output" | "-o" => { - let value = args - .get(index + 1) - .ok_or_else(|| format!("missing value for {}", args[index]))?; - output_path = Some(PathBuf::from(value)); - index += 2; - } - flag if flag.starts_with("--output=") => { - output_path = Some(PathBuf::from(&flag[9..])); - index += 1; - } - other if other.starts_with('-') => { - return Err(format!("unknown export option: {other}")); - } - other if output_path.is_none() => { - output_path = Some(PathBuf::from(other)); - index += 1; - } - other => { - return Err(format!("unexpected export argument: {other}")); - } - } - } - - Ok(CliAction::Export { - session_reference, - output_path, - output_format, - }) -} - -pub(crate) fn parse_history_count(raw: Option<&str>) -> Result { - let Some(raw) = raw else { - return Ok(DEFAULT_HISTORY_LIMIT); - }; - let parsed: usize = raw - .parse() - .map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?; - if parsed == 0 { - return Err("history: count must be greater than 0.".to_string()); - } - Ok(parsed) -} - -pub(crate) fn parse_git_status_branch(status: Option<&str>) -> Option { - let status = status?; - let first_line = status.lines().next()?; - let line = first_line.strip_prefix("## ")?; - if line.starts_with("HEAD") { - return Some("detached HEAD".to_string()); - } - let branch = line.split(['.', ' ']).next().unwrap_or_default().trim(); - if branch.is_empty() { - None - } else { - Some(branch.to_string()) - } -} - -pub(crate) fn parse_git_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) -} - -pub(crate) fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary { - let mut summary = GitWorkspaceSummary::default(); - let Some(status) = status else { - return summary; - }; - - for line in status.lines() { - if line.starts_with("## ") || line.trim().is_empty() { - continue; - } - - summary.changed_files += 1; - let mut chars = line.chars(); - let index_status = chars.next().unwrap_or(' '); - let worktree_status = chars.next().unwrap_or(' '); - - if index_status == '?' && worktree_status == '?' { - summary.untracked_files += 1; - continue; - } - - if index_status != ' ' { - summary.staged_files += 1; - } - if worktree_status != ' ' { - summary.unstaged_files += 1; - } - if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A')) - || index_status == 'U' - || worktree_status == 'U' - { - summary.conflicted_files += 1; - } - } - - summary -} +// Contents moved to live_cli.rs diff --git a/rust/crates/rusty-claude-cli/src/repl_commands.rs b/rust/crates/rusty-claude-cli/src/repl_commands.rs new file mode 100644 index 00000000..ae2e1fe6 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/repl_commands.rs @@ -0,0 +1,1165 @@ +use crate::*; +use std::env; +use std::fs; +use std::path::Path; +use serde_json::json; +use commands::SlashCommand; +use ::tools::{mvp_tool_specs, execute_tool}; + +impl LiveCli { + #[allow(clippy::too_many_lines)] + pub(crate) fn handle_repl_command( + &mut self, + command: SlashCommand, + ) -> Result> { + Ok(match command { + SlashCommand::Help => { + println!("{}", render_repl_help()); + false + } + SlashCommand::Status => { + self.print_status(); + false + } + SlashCommand::Bughunter { scope } => { + self.run_bughunter(scope.as_deref())?; + false + } + SlashCommand::Commit => { + self.run_commit(None)?; + false + } + SlashCommand::Pr { context } => { + self.run_pr(context.as_deref())?; + false + } + SlashCommand::Issue { context } => { + self.run_issue(context.as_deref())?; + false + } + SlashCommand::Ultraplan { task } => { + self.run_ultraplan(task.as_deref())?; + false + } + SlashCommand::Teleport { target } => { + Self::run_teleport(target.as_deref())?; + false + } + SlashCommand::DebugToolCall => { + self.run_debug_tool_call(None)?; + false + } + SlashCommand::Sandbox => { + Self::print_sandbox_status(); + false + } + SlashCommand::Compact => { + self.compact()?; + false + } + SlashCommand::Model { model } => self.set_model(model)?, + SlashCommand::Permissions { mode } => self.set_permissions(mode)?, + SlashCommand::Clear { confirm } => self.clear_session(confirm)?, + SlashCommand::Cost => { + self.print_cost(); + false + } + SlashCommand::Resume { session_path } => self.resume_session(session_path)?, + SlashCommand::Config { section } => { + Self::print_config(section.as_deref())?; + false + } + SlashCommand::Mcp { action, target } => { + let args = match (action.as_deref(), target.as_deref()) { + (None, None) => None, + (Some(action), None) => Some(action.to_string()), + (Some(action), Some(target)) => Some(format!("{action} {target}")), + (None, Some(target)) => Some(target.to_string()), + }; + Self::print_mcp(args.as_deref(), CliOutputFormat::Text)?; + false + } + SlashCommand::Memory => { + Self::print_memory()?; + false + } + SlashCommand::Init => { + run_init(CliOutputFormat::Text)?; + false + } + SlashCommand::Diff => { + Self::print_diff()?; + false + } + SlashCommand::Version => { + Self::print_version(CliOutputFormat::Text); + false + } + SlashCommand::Export { path } => { + self.export_session(path.as_deref())?; + false + } + SlashCommand::Session { action, target } => { + self.handle_session_command(action.as_deref(), target.as_deref())? + } + SlashCommand::Plugins { action, target } => { + self.handle_plugins_command(action.as_deref(), target.as_deref())? + } + SlashCommand::Agents { args } => { + Self::print_agents(args.as_deref(), CliOutputFormat::Text)?; + false + } + SlashCommand::Skills { args } => { + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?, + SkillSlashDispatch::Local => { + Self::print_skills(args.as_deref(), CliOutputFormat::Text)?; + } + } + false + } + SlashCommand::Doctor => { + println!("{}", render_doctor_report()?.render()); + false + } + SlashCommand::History { count } => { + self.print_prompt_history(count.as_deref()); + false + } + SlashCommand::Stats => { + let usage = UsageTracker::from_session(self.runtime.session()).cumulative_usage(); + println!("{}", format_cost_report(usage)); + false + } + SlashCommand::Login + | SlashCommand::Logout + | SlashCommand::Vim + | SlashCommand::Upgrade + | SlashCommand::Share + | SlashCommand::Feedback + | SlashCommand::Files + | SlashCommand::Fast + | SlashCommand::Exit + | SlashCommand::Summary + | SlashCommand::Desktop + | SlashCommand::Brief + | SlashCommand::Advisor + | SlashCommand::Stickers + | SlashCommand::Insights + | SlashCommand::Thinkback + | SlashCommand::ReleaseNotes + | SlashCommand::SecurityReview + | SlashCommand::Keybindings + | SlashCommand::PrivacySettings + | SlashCommand::Plan { .. } + | SlashCommand::Review { .. } + | SlashCommand::Tasks { .. } + | SlashCommand::Theme { .. } + | SlashCommand::Voice { .. } + | SlashCommand::Usage { .. } + | SlashCommand::Rename { .. } + | SlashCommand::Copy { .. } + | SlashCommand::Hooks { .. } + | SlashCommand::Context { .. } + | SlashCommand::Color { .. } + | SlashCommand::Effort { .. } + | SlashCommand::Branch { .. } + | SlashCommand::Rewind { .. } + | SlashCommand::Ide { .. } + | SlashCommand::Tag { .. } + | SlashCommand::OutputStyle { .. } + | SlashCommand::AddDir { .. } => { + let cmd_name = command.slash_name(); + eprintln!("{cmd_name} is not yet implemented in this build."); + false + } + SlashCommand::Unknown(name) => { + eprintln!("{}", format_unknown_slash_command(&name)); + false + } + }) + } + + pub(crate) fn print_status(&self) { + let cumulative = self.runtime.usage().cumulative_usage(); + let latest = self.runtime.usage().current_turn_usage(); + println!( + "{}", + format_status_report( + &self.model, + StatusUsage { + message_count: self.runtime.session().messages.len(), + turns: self.runtime.usage().turns(), + latest, + cumulative, + estimated_tokens: self.runtime.estimated_tokens(), + }, + self.permission_mode.as_str(), + &status_context(Some(&self.session.path)).expect("status context should load"), + None, // #148: REPL /status doesn't carry flag provenance + ) + ); + } + + pub(crate) fn print_prompt_history(&self, count: Option<&str>) { + let limit = match parse_history_count(count) { + Ok(limit) => limit, + Err(message) => { + eprintln!("{message}"); + return; + } + }; + let session_entries = &self.runtime.session().prompt_history; + let entries = if session_entries.is_empty() { + if self.prompt_history.is_empty() { + collect_session_prompt_history(self.runtime.session()) + } else { + self.prompt_history + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect() + } + } else { + session_entries + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect() + }; + println!("{}", render_prompt_history_report(&entries, limit)); + } + + pub(crate) fn print_sandbox_status() { + let cwd = env::current_dir().expect("current dir"); + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader + .load() + .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); + println!( + "{}", + format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd)) + ); + } + + pub(crate) fn set_model( + &mut self, + model: Option, + ) -> Result> { + let Some(model) = model else { + println!( + "{}", + format_model_report( + &self.model, + self.runtime.session().messages.len(), + self.runtime.usage().turns(), + ) + ); + return Ok(false); + }; + + let model = resolve_model_alias_with_config(&model); + + if model == self.model { + println!( + "{}", + format_model_report( + &self.model, + self.runtime.session().messages.len(), + self.runtime.usage().turns(), + ) + ); + return Ok(false); + } + + let previous = self.model.clone(); + let session = self.runtime.session().clone(); + let message_count = session.messages.len(); + let runtime = build_runtime( + session, + &self.session.id, + model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.model.clone_from(&model); + println!( + "{}", + format_model_switch_report(&previous, &model, message_count) + ); + Ok(true) + } + + pub(crate) fn set_permissions( + &mut self, + mode: Option, + ) -> Result> { + let Some(mode) = mode else { + println!( + "{}", + format_permissions_report(self.permission_mode.as_str()) + ); + return Ok(false); + }; + + let normalized = normalize_permission_mode(&mode).ok_or_else(|| { + format!( + "unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access." + ) + })?; + + if normalized == self.permission_mode.as_str() { + println!("{}", format_permissions_report(normalized)); + return Ok(false); + } + + let previous = self.permission_mode.as_str().to_string(); + let session = self.runtime.session().clone(); + self.permission_mode = permission_mode_from_label(normalized); + let runtime = build_runtime( + session, + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + println!( + "{}", + format_permissions_switch_report(&previous, normalized) + ); + Ok(true) + } + + pub(crate) fn clear_session( + &mut self, + confirm: bool, + ) -> Result> { + if !confirm { + println!( + "clear: confirmation required; run /clear --confirm to start a fresh session." + ); + return Ok(false); + } + + let previous_session = self.session.clone(); + let session_state = new_cli_session()?; + self.session = create_managed_session_handle(&session_state.session_id)?; + let runtime = build_runtime( + session_state.with_persistence_path(self.session.path.clone()), + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + println!( + "Session cleared\n Mode fresh session\n Previous session {}\n Resume previous /resume {}\n Preserved model {}\n Permission mode {}\n New session {}\n Session file {}", + previous_session.id, + previous_session.id, + self.model, + self.permission_mode.as_str(), + self.session.id, + self.session.path.display(), + ); + Ok(true) + } + + pub(crate) fn print_cost(&self) { + let cumulative = self.runtime.usage().cumulative_usage(); + println!("{}", format_cost_report(cumulative)); + } + + pub(crate) fn resume_session( + &mut self, + session_path: Option, + ) -> Result> { + let Some(session_ref) = session_path else { + println!("{}", render_resume_usage()); + return Ok(false); + }; + + let (handle, session) = load_session_reference(&session_ref)?; + let message_count = session.messages.len(); + let session_id = session.session_id.clone(); + let runtime = build_runtime( + session, + &handle.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.session = SessionHandle { + id: session_id, + path: handle.path, + }; + println!( + "{}", + format_resume_report( + &self.session.path.display().to_string(), + message_count, + self.runtime.usage().turns(), + ) + ); + Ok(true) + } + + pub(crate) fn print_config(section: Option<&str>) -> Result<(), Box> { + println!("{}", render_config_report(section)?); + Ok(()) + } + + pub(crate) fn print_memory() -> Result<(), Box> { + println!("{}", render_memory_report()?); + Ok(()) + } + + pub(crate) fn print_agents( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + let cwd = env::current_dir()?; + match output_format { + CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)? + ), + } + Ok(()) + } + + pub(crate) fn print_mcp( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + // `claw mcp serve` starts a stdio MCP server exposing claw's built-in + // tools. All other `mcp` subcommands fall through to the existing + // configured-server reporter (`list`, `status`, ...). + if matches!(args.map(str::trim), Some("serve")) { + return run_mcp_serve(); + } + let cwd = env::current_dir()?; + match output_format { + CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd))? + ), + } + Ok(()) + } + + pub(crate) fn print_skills( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + let cwd = env::current_dir()?; + match output_format { + CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&handle_skills_slash_command_json(args, &cwd)?)? + ), + } + Ok(()) + } + + pub(crate) fn print_plugins( + action: Option<&str>, + target: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let result = handle_plugins_slash_command(action, target, &mut manager)?; + match output_format { + CliOutputFormat::Text => println!("{}", result.message), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "kind": "plugin", + "action": action.unwrap_or("list"), + "target": target, + "message": result.message, + "reload_runtime": result.reload_runtime, + }))? + ), + } + Ok(()) + } + + pub(crate) fn print_diff() -> Result<(), Box> { + println!("{}", render_diff_report()?); + Ok(()) + } + + pub(crate) fn print_version(output_format: CliOutputFormat) { + let _ = crate::print_version(output_format); + } + + pub(crate) fn export_session( + &self, + requested_path: Option<&str>, + ) -> Result<(), Box> { + let export_path = resolve_export_path(requested_path, self.runtime.session())?; + fs::write(&export_path, render_export_text(self.runtime.session()))?; + println!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + self.runtime.session().messages.len(), + ); + Ok(()) + } + + #[allow(clippy::too_many_lines)] + pub(crate) fn handle_session_command( + &mut self, + action: Option<&str>, + target: Option<&str>, + ) -> Result> { + match action { + None | Some("list") => { + println!("{}", render_session_list(&self.session.id)?); + Ok(false) + } + Some("switch") => { + let Some(target) = target else { + println!("Usage: /session switch "); + return Ok(false); + }; + let (handle, session) = load_session_reference(target)?; + let message_count = session.messages.len(); + let session_id = session.session_id.clone(); + let runtime = build_runtime( + session, + &handle.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.session = SessionHandle { + id: session_id, + path: handle.path, + }; + println!( + "Session switched\n Active session {}\n File {}\n Messages {}", + self.session.id, + self.session.path.display(), + message_count, + ); + Ok(true) + } + Some("fork") => { + let forked = self.runtime.fork_session(target.map(ToOwned::to_owned)); + let parent_session_id = self.session.id.clone(); + let handle = create_managed_session_handle(&forked.session_id)?; + let branch_name = forked + .fork + .as_ref() + .and_then(|fork| fork.branch_name.clone()); + let forked = forked.with_persistence_path(handle.path.clone()); + let message_count = forked.messages.len(); + forked.save_to_path(&handle.path)?; + let runtime = build_runtime( + forked, + &handle.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.session = handle; + println!( + "Session forked\n Parent session {}\n Active session {}\n Branch {}\n File {}\n Messages {}", + parent_session_id, + self.session.id, + branch_name.as_deref().unwrap_or("(unnamed)"), + self.session.path.display(), + message_count, + ); + Ok(true) + } + Some("delete") => { + let Some(target) = target else { + println!("Usage: /session delete [--force]"); + return Ok(false); + }; + let handle = resolve_session_reference(target)?; + if handle.id == self.session.id { + println!( + "delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch .", + handle.id + ); + return Ok(false); + } + if !confirm_session_deletion(&handle.id) { + println!("delete: cancelled."); + return Ok(false); + } + delete_managed_session(&handle.path)?; + println!( + "Session deleted\n Deleted session {}\n File {}", + handle.id, + handle.path.display(), + ); + Ok(false) + } + Some("delete-force") => { + let Some(target) = target else { + println!("Usage: /session delete [--force]"); + return Ok(false); + }; + let handle = resolve_session_reference(target)?; + if handle.id == self.session.id { + println!( + "delete: refusing to delete the active session '{}'.\nSwitch to another session first with /session switch .", + handle.id + ); + return Ok(false); + } + delete_managed_session(&handle.path)?; + println!( + "Session deleted\n Deleted session {}\n File {}", + handle.id, + handle.path.display(), + ); + Ok(false) + } + Some(other) => { + println!( + "Unknown /session action '{other}'. Use /session list, /session switch , /session fork [branch-name], or /session delete [--force]." + ); + Ok(false) + } + } + } + + pub(crate) 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) + } + + pub(crate) fn compact(&mut self) -> Result<(), Box> { + let result = self.runtime.compact(CompactionConfig::default()); + let removed = result.removed_message_count; + let kept = result.compacted_session.messages.len(); + let skipped = removed == 0; + let runtime = build_runtime( + result.compacted_session, + &self.session.id, + self.model.clone(), + self.system_prompt.clone(), + true, + true, + self.allowed_tools.clone(), + self.permission_mode, + None, + )?; + self.replace_runtime(runtime)?; + self.persist_session()?; + println!("{}", format_compact_report(removed, kept, skipped)); + Ok(()) + } + + pub(crate) fn run_bughunter( + &self, + scope: Option<&str>, + ) -> Result<(), Box> { + println!("{}", format_bughunter_report(scope)); + Ok(()) + } + + pub(crate) fn run_ultraplan( + &self, + task: Option<&str>, + ) -> Result<(), Box> { + println!("{}", format_ultraplan_report(task)); + Ok(()) + } + + pub(crate) fn run_teleport(target: Option<&str>) -> Result<(), Box> { + let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { + println!("Usage: /teleport "); + return Ok(()); + }; + + println!("{}", render_teleport_report(target)?); + Ok(()) + } + + pub(crate) fn run_debug_tool_call( + &self, + args: Option<&str>, + ) -> Result<(), Box> { + validate_no_args("/debug-tool-call", args)?; + println!("{}", render_last_tool_debug_report(self.runtime.session())?); + Ok(()) + } + + pub(crate) fn run_commit( + &mut self, + args: Option<&str>, + ) -> Result<(), Box> { + validate_no_args("/commit", args)?; + let status = git_output(&["status", "--short", "--branch"])?; + let summary = parse_git_workspace_summary(Some(&status)); + let branch = parse_git_status_branch(Some(&status)); + if summary.is_clean() { + println!("{}", format_commit_skipped_report()); + return Ok(()); + } + + println!( + "{}", + format_commit_preflight_report(branch.as_deref(), summary) + ); + Ok(()) + } + + pub(crate) fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { + let branch = + resolve_git_branch_for(&env::current_dir()?).unwrap_or_else(|| "unknown".to_string()); + println!("{}", format_pr_report(&branch, context)); + Ok(()) + } + + pub(crate) fn run_issue(&self, context: Option<&str>) -> Result<(), Box> { + println!("{}", format_issue_report(context)); + Ok(()) + } +} + +pub(crate) fn run_stale_base_preflight(flag_value: Option<&str>) { + let Ok(cwd) = env::current_dir() else { + return; + }; + let source = resolve_expected_base(flag_value, &cwd); + let state = check_base_commit(&cwd, source.as_ref()); + if let Some(warning) = format_stale_base_warning(&state) { + eprintln!("{warning}"); + } +} + +pub(crate) fn run_mcp_serve() -> Result<(), Box> { + let tools = ::tools::mvp_tool_specs() + .into_iter() + .map(|spec| McpTool { + name: spec.name.to_string(), + description: Some(spec.description.to_string()), + input_schema: Some(spec.input_schema), + annotations: None, + meta: None, + }) + .collect(); + + let spec = McpServerSpec { + server_name: "claw".to_string(), + server_version: VERSION.to_string(), + tools, + tool_handler: Box::new(::tools::execute_tool), + }; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async move { + let mut server = McpServer::new(spec); + server.run().await + })?; + Ok(()) +} + +pub(crate) fn run_resume_command( + session_path: &Path, + session: &Session, + command: &SlashCommand, +) -> Result> { + match command { + SlashCommand::Help => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_repl_help()), + json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })), + }), + SlashCommand::Compact => { + let result = runtime::compact_session( + session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + ); + let removed = result.removed_message_count; + let kept = result.compacted_session.messages.len(); + let skipped = removed == 0; + result.compacted_session.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: result.compacted_session, + message: Some(format_compact_report(removed, kept, skipped)), + json: Some(serde_json::json!({ + "kind": "compact", + "skipped": skipped, + "removed_messages": removed, + "kept_messages": kept, + })), + }) + } + SlashCommand::Clear { confirm } => { + if !confirm { + return Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some( + "clear: confirmation required; rerun with /clear --confirm".to_string(), + ), + json: Some(serde_json::json!({ + "kind": "error", + "error": "confirmation required", + "hint": "rerun with /clear --confirm", + })), + }); + } + let backup_path = write_session_clear_backup(session, session_path)?; + let previous_session_id = session.session_id.clone(); + let cleared = new_cli_session()?; + let new_session_id = cleared.session_id.clone(); + cleared.save_to_path(session_path)?; + Ok(ResumeCommandOutcome { + session: cleared, + message: Some(format!( + "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", + backup_path.display(), + backup_path.display(), + session_path.display() + )), + json: Some(serde_json::json!({ + "kind": "clear", + "previous_session_id": previous_session_id, + "new_session_id": new_session_id, + "backup": backup_path.display().to_string(), + "session_file": session_path.display().to_string(), + })), + }) + } + SlashCommand::Status => { + let tracker = UsageTracker::from_session(session); + let usage = tracker.cumulative_usage(); + let context = status_context(Some(session_path))?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_status_report( + session.model.as_deref().unwrap_or("restored-session"), + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &context, + None, // #148: resumed sessions don't have flag provenance + )), + json: Some(status_json_value( + session.model.as_deref(), + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &context, + None, // #148: resumed sessions don't have flag provenance + None, + )), + }) + } + SlashCommand::Sandbox => { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_sandbox_report(&status)), + json: Some(sandbox_json_value(&status)), + }) + } + SlashCommand::Cost => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_cost_report(usage)), + json: Some(serde_json::json!({ + "kind": "cost", + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_creation_input_tokens": usage.cache_creation_input_tokens, + "cache_read_input_tokens": usage.cache_read_input_tokens, + "total_tokens": usage.total_tokens(), + })), + }) + } + SlashCommand::Config { section } => { + let message = render_config_report(section.as_deref())?; + let json = render_config_json(section.as_deref())?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message), + json: Some(json), + }) + } + SlashCommand::Mcp { action, target } => { + let cwd = env::current_dir()?; + let args = match (action.as_deref(), target.as_deref()) { + (None, None) => None, + (Some(action), None) => Some(action.to_string()), + (Some(action), Some(target)) => Some(format!("{action} {target}")), + (None, Some(target)) => Some(target.to_string()), + }; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)), + json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)), + }) + } + SlashCommand::Memory => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_memory_report()?), + json: Some(render_memory_json()?), + }), + SlashCommand::Init => { + // #142: run the init once, then render both text + structured JSON + // from the same InitReport so both surfaces stay in sync. + let cwd = env::current_dir()?; + let report = crate::init::initialize_repo(&cwd)?; + let message = report.render(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message.clone()), + json: Some(init_json_value(&report, &message)), + }) + } + SlashCommand::Diff => { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let message = render_diff_report_for(&cwd)?; + let json = render_diff_json_for(&cwd)?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message), + json: Some(json), + }) + } + SlashCommand::Version => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_version_report()), + json: Some(version_json_value()), + }), + SlashCommand::Export { path } => { + let export_path = resolve_export_path(path.as_deref(), session)?; + fs::write(&export_path, render_export_text(session))?; + let msg_count = session.messages.len(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + msg_count, + )), + json: Some(serde_json::json!({ + "kind": "export", + "file": export_path.display().to_string(), + "message_count": msg_count, + })), + }) + } + SlashCommand::Agents { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), + json: Some(serde_json::json!({ + "kind": "agents", + "text": handle_agents_slash_command(args.as_deref(), &cwd)?, + })), + }) + } + SlashCommand::Skills { args } => { + if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { + return Err( + "resumed /skills invocations are interactive-only; start `claw` and run `/skills ` in the REPL".into(), + ); + } + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), + json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?), + }) + } + SlashCommand::Doctor => { + let report = render_doctor_report()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(report.render()), + json: Some(report.json_value()), + }) + } + SlashCommand::Stats => { + let usage = UsageTracker::from_session(session).cumulative_usage(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format_cost_report(usage)), + json: Some(serde_json::json!({ + "kind": "stats", + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "cache_creation_input_tokens": usage.cache_creation_input_tokens, + "cache_read_input_tokens": usage.cache_read_input_tokens, + "total_tokens": usage.total_tokens(), + })), + }) + } + SlashCommand::History { count } => { + let limit = parse_history_count(count.as_deref()) + .map_err(|error| -> Box { error.into() })?; + let entries = collect_session_prompt_history(session); + let shown: Vec<_> = entries.iter().rev().take(limit).rev().collect(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_prompt_history_report(&entries, limit)), + json: Some(serde_json::json!({ + "kind": "history", + "total": entries.len(), + "showing": shown.len(), + "entries": shown.iter().map(|e| serde_json::json!({ + "timestamp_ms": e.timestamp_ms, + "text": e.text, + })).collect::>(), + })), + }) + } + SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), + // /session list can be served from the sessions directory without a live session. + SlashCommand::Session { + action: Some(ref act), + .. + } if act == "list" => { + let sessions = list_managed_sessions().unwrap_or_default(); + let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); + let active_id = session.session_id.clone(); + let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(text), + json: Some(serde_json::json!({ + "kind": "session_list", + "sessions": session_ids, + "active": active_id, + })), + }) + } + SlashCommand::Bughunter { .. } + | SlashCommand::Commit { .. } + | SlashCommand::Pr { .. } + | SlashCommand::Issue { .. } + | SlashCommand::Ultraplan { .. } + | SlashCommand::Teleport { .. } + | SlashCommand::DebugToolCall { .. } + | SlashCommand::Resume { .. } + | SlashCommand::Model { .. } + | SlashCommand::Permissions { .. } + | SlashCommand::Session { .. } + | SlashCommand::Plugins { .. } + | SlashCommand::Login + | SlashCommand::Logout + | SlashCommand::Vim + | SlashCommand::Upgrade + | SlashCommand::Share + | SlashCommand::Feedback + | SlashCommand::Files + | SlashCommand::Fast + | SlashCommand::Exit + | SlashCommand::Summary + | SlashCommand::Desktop + | SlashCommand::Brief + | SlashCommand::Advisor + | SlashCommand::Stickers + | SlashCommand::Insights + | SlashCommand::Thinkback + | SlashCommand::ReleaseNotes + | SlashCommand::SecurityReview + | SlashCommand::Keybindings + | SlashCommand::PrivacySettings + | SlashCommand::Plan { .. } + | SlashCommand::Review { .. } + | SlashCommand::Tasks { .. } + | SlashCommand::Theme { .. } + | SlashCommand::Voice { .. } + | SlashCommand::Usage { .. } + | SlashCommand::Rename { .. } + | SlashCommand::Copy { .. } + | SlashCommand::Hooks { .. } + | SlashCommand::Context { .. } + | SlashCommand::Color { .. } + | SlashCommand::Effort { .. } + | SlashCommand::Branch { .. } + | SlashCommand::Rewind { .. } + | SlashCommand::Ide { .. } + | SlashCommand::Tag { .. } + | SlashCommand::OutputStyle { .. } + | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), + } +} + +pub(crate) struct ResumeCommandOutcome { + pub(crate) session: Session, + pub(crate) message: Option, + pub(crate) json: Option, +} diff --git a/rust/crates/rusty-claude-cli/src/runtime_builder.rs b/rust/crates/rusty-claude-cli/src/runtime_builder.rs new file mode 100644 index 00000000..1f456050 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/runtime_builder.rs @@ -0,0 +1,631 @@ +use crate::api_client::AnthropicRuntimeClient; +use crate::constants::{AllowedToolSet, DEFAULT_DATE}; +use crate::permission::permission_policy; +use crate::progress::InternalPromptProgressReporter; +use crate::tool_executor::CliToolExecutor; +use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; +use runtime::{ + load_system_prompt, ConfigLoader, ConversationRuntime, McpServerManager, McpTool, MessageRole, + PermissionMode, Session, ToolError, +}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::env; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use tools::{GlobalToolRegistry, RuntimeToolDefinition}; + +pub(crate) type RuntimePluginStateBuildOutput = ( + Option>>, + Vec, +); + +pub(crate) struct RuntimePluginState { + pub(crate) feature_config: runtime::RuntimeFeatureConfig, + pub(crate) tool_registry: GlobalToolRegistry, + pub(crate) plugin_registry: PluginRegistry, + pub(crate) mcp_state: Option>>, +} + +pub(crate) struct RuntimeMcpState { + pub(crate) runtime: tokio::runtime::Runtime, + pub(crate) manager: McpServerManager, + pub(crate) pending_servers: Vec, + pub(crate) degraded_report: Option, +} + +pub(crate) struct BuiltRuntime { + pub(crate) runtime: Option>, + pub(crate) plugin_registry: PluginRegistry, + pub(crate) plugins_active: bool, + pub(crate) mcp_state: Option>>, + pub(crate) mcp_active: bool, +} + +impl BuiltRuntime { + pub(crate) fn new( + runtime: ConversationRuntime, + plugin_registry: PluginRegistry, + mcp_state: Option>>, + ) -> Self { + Self { + runtime: Some(runtime), + plugin_registry, + plugins_active: true, + mcp_state, + mcp_active: true, + } + } + + pub(crate) fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self { + let runtime = self + .runtime + .take() + .expect("runtime should exist before installing hook abort signal"); + self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal)); + self + } + + pub(crate) fn shutdown_plugins(&mut self) -> Result<(), Box> { + if self.plugins_active { + self.plugin_registry.shutdown()?; + self.plugins_active = false; + } + Ok(()) + } + + pub(crate) fn shutdown_mcp(&mut self) -> Result<(), Box> { + if self.mcp_active { + if let Some(mcp_state) = &self.mcp_state { + mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .shutdown()?; + } + self.mcp_active = false; + } + Ok(()) + } +} + +impl Deref for BuiltRuntime { + type Target = ConversationRuntime; + + fn deref(&self) -> &Self::Target { + self.runtime + .as_ref() + .expect("runtime should exist while built runtime is alive") + } +} + +impl DerefMut for BuiltRuntime { + fn deref_mut(&mut self) -> &mut Self::Target { + self.runtime + .as_mut() + .expect("runtime should exist while built runtime is alive") + } +} + +impl Drop for BuiltRuntime { + fn drop(&mut self) { + let _ = self.shutdown_mcp(); + let _ = self.shutdown_plugins(); + } +} + +impl RuntimeMcpState { + pub(crate) fn new( + runtime_config: &runtime::RuntimeConfig, + ) -> Result, Box> { + let mut manager = McpServerManager::from_runtime_config(runtime_config); + if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() { + return Ok(None); + } + + let runtime = tokio::runtime::Runtime::new()?; + let discovery = runtime.block_on(manager.discover_tools_best_effort()); + let pending_servers = discovery + .failed_servers + .iter() + .map(|failure| failure.server_name.clone()) + .chain( + discovery + .unsupported_servers + .iter() + .map(|server| server.server_name.clone()), + ) + .collect::>() + .into_iter() + .collect::>(); + let available_tools = discovery + .tools + .iter() + .map(|tool| tool.qualified_name.clone()) + .collect::>(); + let failed_server_names = pending_servers.iter().cloned().collect::>(); + let working_servers = manager + .server_names() + .into_iter() + .filter(|server_name| !failed_server_names.contains(server_name)) + .collect::>(); + let failed_servers = + discovery + .failed_servers + .iter() + .map(|failure| runtime::McpFailedServer { + server_name: failure.server_name.clone(), + phase: runtime::McpLifecyclePhase::ToolDiscovery, + error: runtime::McpErrorSurface::new( + runtime::McpLifecyclePhase::ToolDiscovery, + Some(failure.server_name.clone()), + failure.error.clone(), + std::collections::BTreeMap::new(), + true, + ), + }) + .chain(discovery.unsupported_servers.iter().map(|server| { + runtime::McpFailedServer { + server_name: server.server_name.clone(), + phase: runtime::McpLifecyclePhase::ServerRegistration, + error: runtime::McpErrorSurface::new( + runtime::McpLifecyclePhase::ServerRegistration, + Some(server.server_name.clone()), + server.reason.clone(), + std::collections::BTreeMap::from([( + "transport".to_string(), + format!("{:?}", server.transport).to_ascii_lowercase(), + )]), + false, + ), + } + })) + .collect::>(); + let degraded_report = (!failed_servers.is_empty()).then(|| { + runtime::McpDegradedReport::new( + working_servers, + failed_servers, + available_tools.clone(), + available_tools, + ) + }); + + Ok(Some(( + Self { + runtime, + manager, + pending_servers, + degraded_report, + }, + discovery, + ))) + } + + pub(crate) fn shutdown(&mut self) -> Result<(), Box> { + self.runtime.block_on(self.manager.shutdown())?; + Ok(()) + } + + pub(crate) fn pending_servers(&self) -> Option> { + (!self.pending_servers.is_empty()).then(|| self.pending_servers.clone()) + } + + pub(crate) fn degraded_report(&self) -> Option { + self.degraded_report.clone() + } + + pub(crate) fn server_names(&self) -> Vec { + self.manager.server_names() + } + + pub(crate) fn call_tool( + &mut self, + qualified_tool_name: &str, + arguments: Option, + ) -> Result { + let response = self + .runtime + .block_on(self.manager.call_tool(qualified_tool_name, arguments)) + .map_err(|error| ToolError::new(error.to_string()))?; + if let Some(error) = response.error { + return Err(ToolError::new(format!( + "MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})", + error.message, error.code + ))); + } + + let result = response.result.ok_or_else(|| { + ToolError::new(format!( + "MCP tool `{qualified_tool_name}` returned no result payload" + )) + })?; + serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn list_resources_for_server(&mut self, server_name: &str) -> Result { + let result = self + .runtime + .block_on(self.manager.list_resources(server_name)) + .map_err(|error| ToolError::new(error.to_string()))?; + serde_json::to_string_pretty(&serde_json::json!({ + "server": server_name, + "resources": result.resources, + })) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn list_resources_for_all_servers(&mut self) -> Result { + let mut resources = Vec::new(); + let mut failures = Vec::new(); + + for server_name in self.server_names() { + match self + .runtime + .block_on(self.manager.list_resources(&server_name)) + { + Ok(result) => resources.push(serde_json::json!({ + "server": server_name, + "resources": result.resources, + })), + Err(error) => failures.push(serde_json::json!({ + "server": server_name, + "error": error.to_string(), + })), + } + } + + if resources.is_empty() && !failures.is_empty() { + let message = failures + .iter() + .filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str)) + .collect::>() + .join("; "); + return Err(ToolError::new(message)); + } + + serde_json::to_string_pretty(&serde_json::json!({ + "resources": resources, + "failures": failures, + })) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn read_resource(&mut self, server_name: &str, uri: &str) -> Result { + let result = self + .runtime + .block_on(self.manager.read_resource(server_name, uri)) + .map_err(|error| ToolError::new(error.to_string()))?; + serde_json::to_string_pretty(&serde_json::json!({ + "server": server_name, + "contents": result.contents, + })) + .map_err(|error| ToolError::new(error.to_string())) + } +} + +pub(crate) fn build_runtime_mcp_state( + runtime_config: &runtime::RuntimeConfig, +) -> Result> { + let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else { + return Ok((None, Vec::new())); + }; + + let mut runtime_tools = discovery + .tools + .iter() + .map(mcp_runtime_tool_definition) + .collect::>(); + if !mcp_state.server_names().is_empty() { + runtime_tools.extend(mcp_wrapper_tool_definitions()); + } + + Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools)) +} + +pub(crate) fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition { + RuntimeToolDefinition { + name: tool.qualified_name.clone(), + description: Some( + tool.tool + .description + .clone() + .unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)), + ), + input_schema: tool + .tool + .input_schema + .clone() + .unwrap_or_else(|| serde_json::json!({ "type": "object", "additionalProperties": true })), + required_permission: permission_mode_for_mcp_tool(&tool.tool), + } +} + +pub(crate) fn mcp_wrapper_tool_definitions() -> Vec { + vec![ + RuntimeToolDefinition { + name: "MCPTool".to_string(), + description: Some( + "Call a configured MCP tool by its qualified name and JSON arguments.".to_string(), + ), + input_schema:serde_json::json!({ + "type": "object", + "properties": { + "qualifiedName": { "type": "string" }, + "arguments": {} + }, + "required": ["qualifiedName"], + "additionalProperties": false + }), + required_permission: PermissionMode::DangerFullAccess, + }, + RuntimeToolDefinition { + name: "ListMcpResourcesTool".to_string(), + description: Some( + "List MCP resources from one configured server or from every connected server." + .to_string(), + ), + input_schema:serde_json::json!({ + "type": "object", + "properties": { + "server": { "type": "string" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + RuntimeToolDefinition { + name: "ReadMcpResourceTool".to_string(), + description: Some("Read a specific MCP resource from a configured server.".to_string()), + input_schema:serde_json::json!({ + "type": "object", + "properties": { + "server": { "type": "string" }, + "uri": { "type": "string" } + }, + "required": ["server", "uri"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ] +} + +pub(crate) fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode { + let read_only = mcp_annotation_flag(tool, "readOnlyHint"); + let destructive = mcp_annotation_flag(tool, "destructiveHint"); + let open_world = mcp_annotation_flag(tool, "openWorldHint"); + + if read_only && !destructive && !open_world { + PermissionMode::ReadOnly + } else if destructive || open_world { + PermissionMode::DangerFullAccess + } else { + PermissionMode::WorkspaceWrite + } +} + +pub(crate) fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool { + tool.annotations + .as_ref() + .and_then(|annotations| annotations.get(key)) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +pub(crate) struct HookAbortMonitor { + pub(crate) stop_tx: Option>, + pub(crate) join_handle: Option>, +} + +impl HookAbortMonitor { + pub(crate) fn spawn(abort_signal: runtime::HookAbortSignal) -> Self { + Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + + runtime.block_on(async move { + let wait_for_stop = tokio::task::spawn_blocking(move || { + let _ = stop_rx.recv(); + }); + + tokio::select! { + result = tokio::signal::ctrl_c() => { + if result.is_ok() { + abort_signal.abort(); + } + } + _ = wait_for_stop => {} + } + }); + }) + } + + pub(crate) fn spawn_with_waiter(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self + where + F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static, + { + let (stop_tx, stop_rx) = mpsc::channel(); + let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal)); + + Self { + stop_tx: Some(stop_tx), + join_handle: Some(join_handle), + } + } + + pub(crate) fn stop(mut self) { + if let Some(stop_tx) = self.stop_tx.take() { + let _ = stop_tx.send(()); + } + if let Some(join_handle) = self.join_handle.take() { + let _ = join_handle.join(); + } + } +} + +pub(crate) fn build_system_prompt() -> Result, Box> { + Ok(load_system_prompt( + env::current_dir()?, + DEFAULT_DATE, + env::consts::OS, + "unknown", + )?) +} + +pub(crate) fn build_runtime_plugin_state() -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) +} + +pub(crate) fn build_runtime_plugin_state_with_loader( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> Result> { + let plugin_manager = build_plugin_manager(cwd, loader, runtime_config); + let plugin_registry = plugin_manager.plugin_registry()?; + let plugin_hook_config = + runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?); + let feature_config = runtime_config + .feature_config() + .clone() + .with_hooks(runtime_config.hooks().merged(&plugin_hook_config)); + let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?; + let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)? + .with_runtime_tools(runtime_tools)?; + Ok(RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + mcp_state, + }) +} + +pub(crate) fn build_plugin_manager( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> PluginManager { + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + PluginManager::new(plugin_config) +} + +pub(crate) fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } +} + +pub(crate) fn runtime_hook_config_from_plugin_hooks(hooks: PluginHooks) -> runtime::RuntimeHookConfig { + runtime::RuntimeHookConfig::new( + hooks.pre_tool_use, + hooks.post_tool_use, + hooks.post_tool_use_failure, + ) +} + +pub(crate) fn build_runtime( + session: Session, + session_id: &str, + model: String, + system_prompt: Vec, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + progress_reporter: Option, +) -> Result> { + let runtime_plugin_state = build_runtime_plugin_state()?; + build_runtime_with_plugin_state( + session, + session_id, + model, + system_prompt, + enable_tools, + emit_output, + allowed_tools, + permission_mode, + progress_reporter, + runtime_plugin_state, + ) +} + +pub(crate) fn build_runtime_with_plugin_state( + session: Session, + session_id: &str, + model: String, + system_prompt: Vec, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + progress_reporter: Option, + runtime_plugin_state: RuntimePluginState, +) -> Result> { + let RuntimePluginState { + feature_config, + tool_registry, + plugin_registry, + mcp_state, + } = runtime_plugin_state; + plugin_registry.initialize()?; + let policy = permission_policy(permission_mode, &feature_config, &tool_registry) + .map_err(std::io::Error::other)?; + + let executor = CliToolExecutor::new( + allowed_tools.clone(), + emit_output, + tool_registry.clone(), + mcp_state.clone(), + ); + + let client = AnthropicRuntimeClient::new( + session_id, + model, + enable_tools, + emit_output, + allowed_tools, + tool_registry, + progress_reporter, + )?; + + let runtime = ConversationRuntime::new_with_features( + session, + client, + executor, + policy, + system_prompt, + &feature_config, + ); + + Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) +} diff --git a/rust/crates/rusty-claude-cli/src/session.rs b/rust/crates/rusty-claude-cli/src/session.rs index a7f80096..3bb1fc16 100644 --- a/rust/crates/rusty-claude-cli/src/session.rs +++ b/rust/crates/rusty-claude-cli/src/session.rs @@ -2,14 +2,113 @@ use std::env; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::UNIX_EPOCH; use commands::{render_slash_command_help_filtered, SlashCommand}; -use runtime::Session; +use runtime::{ContentBlock, MessageRole, Session}; use crate::constants::*; +use crate::live_cli::PromptHistoryEntry; use crate::models::*; +pub(crate) fn collect_session_prompt_history(session: &Session) -> Vec { + if !session.prompt_history.is_empty() { + return session + .prompt_history + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect(); + } + let timestamp_ms = session.updated_at_ms; + session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(PromptHistoryEntry { + timestamp_ms, + text: text.clone(), + }), + _ => None, + }) + }) + .collect() +} + +pub(crate) fn recent_user_context(session: &Session, limit: usize) -> String { + let requests = session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.trim().to_string()), + _ => None, + }) + }) + .rev() + .take(limit) + .collect::>(); + + if requests.is_empty() { + "".to_string() + } else { + requests + .into_iter() + .rev() + .enumerate() + .map(|(index, text)| format!("{}. {}", index + 1, text)) + .collect::>() + .join("\n") + } +} + +pub(crate) fn git_output(args: &[&str]) -> Result> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(String::from_utf8(output.stdout)?) +} + +pub(crate) fn git_status_ok(args: &[&str]) -> Result<(), Box> { + let output = Command::new("git") + .args(args) + .current_dir(env::current_dir()?) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(()) +} + +pub(crate) fn command_exists(name: &str) -> bool { + Command::new("which") + .arg(name) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +pub(crate) fn write_temp_text_file( + filename: &str, + contents: &str, +) -> Result> { + let path = env::temp_dir().join(filename); + fs::write(&path, contents)?; + Ok(path) +} + pub(crate) fn current_session_store() -> Result> { let cwd = env::current_dir()?; runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box) @@ -50,34 +149,33 @@ pub(crate) fn resolve_managed_session_path(session_id: &str) -> Result Result, Box> { - Ok(current_session_store()? - .list_sessions() - .map_err(|e| Box::new(e) as Box)? + let store = current_session_store()?; + let sessions = store.list_sessions().map_err(|e| Box::new(e) as Box)?; + let panes = discover_tmux_panes(); + + Ok(sessions .into_iter() - .map(|session| ManagedSessionSummary { - id: session.id, - path: session.path, - updated_at_ms: session.updated_at_ms, - modified_epoch_millis: session.modified_epoch_millis, - message_count: session.message_count, - parent_session_id: session.parent_session_id, - branch_name: session.branch_name, - lifecycle: SessionLifecycleSummary { - kind: SessionLifecycleKind::SavedOnly, - pane_id: None, - pane_command: None, - pane_path: None, - workspace_dirty: false, - abandoned: false, - }, + .map(|session| { + let lifecycle = classify_session_lifecycle_from_panes(&session.path, panes.clone()); + ManagedSessionSummary { + id: session.id, + path: session.path, + updated_at_ms: session.updated_at_ms, + modified_epoch_millis: session.modified_epoch_millis, + message_count: session.message_count, + parent_session_id: session.parent_session_id, + branch_name: session.branch_name, + lifecycle, + } }) .collect()) } pub(crate) fn latest_managed_session() -> Result> { - let session = current_session_store()? - .latest_session() - .map_err(|e| Box::new(e) as Box)?; + let store = current_session_store()?; + let session = store.latest_session().map_err(|e| Box::new(e) as Box)?; + let lifecycle = classify_session_lifecycle_for(&session.path); + Ok(ManagedSessionSummary { id: session.id, path: session.path, @@ -86,14 +184,7 @@ pub(crate) fn latest_managed_session() -> Result bool { matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES") } +pub(crate) fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary { + classify_session_lifecycle_from_panes(workspace, discover_tmux_panes()) +} + +pub(crate) fn classify_session_lifecycle_from_panes( + workspace: &Path, + panes: Vec, +) -> SessionLifecycleSummary { + let workspace_dirty = git_worktree_is_dirty(workspace); + let mut idle_shell = None; + for pane in panes { + if !pane_path_matches_workspace(&pane.current_path, workspace) { + continue; + } + if is_idle_shell_command(&pane.current_command) { + idle_shell.get_or_insert(pane); + } else { + return SessionLifecycleSummary { + kind: SessionLifecycleKind::RunningProcess, + pane_id: Some(pane.pane_id), + pane_command: Some(pane.current_command), + pane_path: Some(pane.current_path), + workspace_dirty, + abandoned: false, + }; + } + } + + if let Some(pane) = idle_shell { + SessionLifecycleSummary { + kind: SessionLifecycleKind::IdleShell, + pane_id: Some(pane.pane_id), + pane_command: Some(pane.current_command), + pane_path: Some(pane.current_path), + workspace_dirty, + abandoned: workspace_dirty, + } + } else { + SessionLifecycleSummary { + kind: SessionLifecycleKind::SavedOnly, + pane_id: None, + pane_command: None, + pane_path: None, + workspace_dirty, + abandoned: workspace_dirty, + } + } +} + +pub(crate) fn discover_tmux_panes() -> Vec { + let output = Command::new("tmux") + .args([ + "list-panes", + "-a", + "-F", + "#{pane_id}\t#{pane_current_command}\t#{pane_current_path}", + ]) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let stdout = String::from_utf8_lossy(&output.stdout); + parse_tmux_pane_snapshots(&stdout) +} + +pub(crate) fn parse_tmux_pane_snapshots(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let mut fields = line.splitn(3, '\t'); + let pane_id = fields.next()?.trim(); + let current_command = fields.next()?.trim(); + let current_path = fields.next()?.trim(); + if pane_id.is_empty() || current_path.is_empty() { + return None; + } + Some(TmuxPaneSnapshot { + pane_id: pane_id.to_string(), + current_command: current_command.to_string(), + current_path: PathBuf::from(current_path), + }) + }) + .collect() +} + +pub(crate) fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool { + let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf()); + let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); + pane_path == workspace || pane_path.starts_with(&workspace) +} + +pub(crate) fn is_idle_shell_command(command: &str) -> bool { + let command = command.rsplit('/').next().unwrap_or(command); + matches!( + command, + "bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd" + ) +} + +pub(crate) fn git_worktree_is_dirty(workspace: &Path) -> bool { + let output = Command::new("git") + .arg("-C") + .arg(workspace) + .args(["status", "--porcelain"]) + .output(); + output + .ok() + .filter(|output| output.status.success()) + .is_some_and(|output| !output.stdout.is_empty()) +} + pub(crate) fn render_session_list(active_session_id: &str) -> Result> { let sessions = list_managed_sessions()?; let mut lines = vec![ @@ -158,11 +363,12 @@ pub(crate) fn render_session_list(active_session_id: &str) -> Result String::new(), }; lines.push(format!( - " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}", + " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} lifecycle={lifecycle} path={path}", id = session.id, msgs = session.message_count, modified = format_session_modified_age(session.modified_epoch_millis), lineage = lineage, + lifecycle = session.lifecycle.signal(), path = session.path.display(), )); } @@ -208,26 +414,4 @@ pub(crate) fn session_clear_backup_path(session_path: &Path) -> PathBuf { session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak")) } -pub(crate) fn render_repl_help() -> String { - [ - "REPL".to_string(), - " /exit Quit the REPL".to_string(), - " /quit Quit the REPL".to_string(), - " Up/Down Navigate prompt history".to_string(), - " Ctrl-R Reverse-search prompt history".to_string(), - " Tab Complete commands, modes, and recent sessions".to_string(), - " Ctrl-C Clear input (or exit on empty prompt)".to_string(), - " Shift+Enter/Ctrl+J Insert a newline".to_string(), - " Auto-save .claw/sessions/.jsonl".to_string(), - " Resume latest /resume latest".to_string(), - " Browse sessions /session list".to_string(), - " Show prompt history /history [count]".to_string(), - String::new(), - render_slash_command_help_filtered(STUB_COMMANDS), - ] - .join( - " -", - ) -} diff --git a/rust/crates/rusty-claude-cli/src/status.rs b/rust/crates/rusty-claude-cli/src/status.rs index 0395eeb8..e7f0d80a 100644 --- a/rust/crates/rusty-claude-cli/src/status.rs +++ b/rust/crates/rusty-claude-cli/src/status.rs @@ -9,6 +9,30 @@ use crate::args::*; use crate::model::*; use crate::*; +#[derive(Debug, Clone)] +pub(crate) struct StatusContext { + pub(crate) cwd: PathBuf, + pub(crate) session_path: Option, + pub(crate) loaded_config_files: usize, + pub(crate) discovered_config_files: usize, + pub(crate) memory_file_count: usize, + pub(crate) project_root: Option, + pub(crate) git_branch: Option, + pub(crate) git_summary: GitWorkspaceSummary, + pub(crate) session_lifecycle: SessionLifecycleSummary, + pub(crate) sandbox_status: runtime::SandboxStatus, + pub(crate) config_load_error: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct StatusUsage { + pub(crate) message_count: usize, + pub(crate) turns: u32, + pub(crate) latest: TokenUsage, + pub(crate) cumulative: TokenUsage, + pub(crate) estimated_tokens: usize, +} + pub(crate) fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { parse_git_status_metadata_for( &env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), @@ -198,7 +222,7 @@ pub(crate) fn status_json_value( let model_source = provenance.map(|p| p.source.as_str()); let model_raw = provenance.and_then(|p| p.raw.clone()); let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::>()); - json!({ + serde_json::json!({ "kind": "status", "status": if degraded { "degraded" } else { "ok" }, "config_load_error": context.config_load_error, @@ -235,6 +259,7 @@ pub(crate) fn status_json_value( // .claw/sessions/. Extract the stem (drop the .jsonl extension). path.file_stem().map(|n| n.to_string_lossy().into_owned()) }), + "session_lifecycle": context.session_lifecycle.json_value(), "loaded_config_files": context.loaded_config_files, "discovered_config_files": context.discovered_config_files, "memory_file_count": context.memory_file_count, @@ -305,120 +330,6 @@ pub(crate) fn status_context( }) } -pub(crate) fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary { - classify_session_lifecycle_from_panes(workspace, discover_tmux_panes()) -} - -pub(crate) fn classify_session_lifecycle_from_panes( - workspace: &Path, - panes: Vec, -) -> SessionLifecycleSummary { - let workspace_dirty = git_worktree_is_dirty(workspace); - let mut idle_shell = None; - for pane in panes { - if !pane_path_matches_workspace(&pane.current_path, workspace) { - continue; - } - if is_idle_shell_command(&pane.current_command) { - idle_shell.get_or_insert(pane); - } else { - return SessionLifecycleSummary { - kind: SessionLifecycleKind::RunningProcess, - pane_id: Some(pane.pane_id), - pane_command: Some(pane.current_command), - pane_path: Some(pane.current_path), - workspace_dirty, - abandoned: false, - }; - } - } - - if let Some(pane) = idle_shell { - SessionLifecycleSummary { - kind: SessionLifecycleKind::IdleShell, - pane_id: Some(pane.pane_id), - pane_command: Some(pane.current_command), - pane_path: Some(pane.current_path), - workspace_dirty, - abandoned: workspace_dirty, - } - } else { - SessionLifecycleSummary { - kind: SessionLifecycleKind::SavedOnly, - pane_id: None, - pane_command: None, - pane_path: None, - workspace_dirty, - abandoned: workspace_dirty, - } - } -} - -pub(crate) fn discover_tmux_panes() -> Vec { - let output = Command::new("tmux") - .args([ - "list-panes", - "-a", - "-F", - "#{pane_id}\t#{pane_current_command}\t#{pane_current_path}", - ]) - .output(); - let Ok(output) = output else { - return Vec::new(); - }; - if !output.status.success() { - return Vec::new(); - } - let stdout = String::from_utf8_lossy(&output.stdout); - parse_tmux_pane_snapshots(&stdout) -} - -pub(crate) fn parse_tmux_pane_snapshots(output: &str) -> Vec { - output - .lines() - .filter_map(|line| { - let mut fields = line.splitn(3, '\t'); - let pane_id = fields.next()?.trim(); - let current_command = fields.next()?.trim(); - let current_path = fields.next()?.trim(); - if pane_id.is_empty() || current_path.is_empty() { - return None; - } - Some(TmuxPaneSnapshot { - pane_id: pane_id.to_string(), - current_command: current_command.to_string(), - current_path: PathBuf::from(current_path), - }) - }) - .collect() -} - -pub(crate) fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool { - let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf()); - let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); - pane_path == workspace || pane_path.starts_with(&workspace) -} - -pub(crate) fn is_idle_shell_command(command: &str) -> bool { - let command = command.rsplit('/').next().unwrap_or(command); - matches!( - command, - "bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd" - ) -} - -pub(crate) fn git_worktree_is_dirty(workspace: &Path) -> bool { - let output = Command::new("git") - .arg("-C") - .arg(workspace) - .args(["status", "--porcelain"]) - .output(); - output - .ok() - .filter(|output| output.status.success()) - .is_some_and(|output| !output.stdout.is_empty()) -} - pub(crate) fn format_status_report( model: &str, usage: StatusUsage, @@ -486,6 +397,7 @@ pub(crate) fn format_status_report( Unstaged {} Untracked {} Session {} + Lifecycle {} Config files loaded {}/{} Memory files {} Suggested flow /status → /diff → /commit", @@ -504,6 +416,7 @@ pub(crate) fn format_status_report( || "live-repl".to_string(), |path| path.display().to_string() ), + context.session_lifecycle.signal(), context.loaded_config_files, context.discovered_config_files, context.memory_file_count, @@ -599,7 +512,7 @@ pub(crate) fn print_sandbox_status_snapshot( } pub(crate) fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value { - json!({ + serde_json::json!({ "kind": "sandbox", "enabled": status.enabled, "active": status.active, diff --git a/rust/crates/rusty-claude-cli/src/tool_executor.rs b/rust/crates/rusty-claude-cli/src/tool_executor.rs index d67445e0..c0e9103b 100644 --- a/rust/crates/rusty-claude-cli/src/tool_executor.rs +++ b/rust/crates/rusty-claude-cli/src/tool_executor.rs @@ -11,7 +11,8 @@ use runtime::{ use tools::GlobalToolRegistry; use crate::constants::*; -use crate::render::{format_tool_result, TerminalRenderer}; +use crate::format_tool_result; +use crate::render::TerminalRenderer; use crate::RuntimeMcpState; #[derive(Deserialize)]