From cf963c30716bec6aa2cde0ea0d221fdd3ea08d13 Mon Sep 17 00:00:00 2001 From: Thiago Margatho Date: Tue, 7 Jul 2026 09:15:58 -0300 Subject: [PATCH 1/2] docs: fix .claw vs .claude confusion in root CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root CLAUDE.md said shared defaults live in .claude.json and machine-local overrides in .claude/settings.local.json. Both are wrong: .claude.json is Claude Code's own permission config, unrelated to this project. The project's actual shared-defaults file is .claw.json (model aliases etc.), and its local-override path is .claw/settings.local.json — matching rust/CLAUDE.md and .claw.json. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc3e2b6f8a..4c110467c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,5 +17,5 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Working agreement - Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows. -- Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides. +- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides. - Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change. From 0089e4572472a2ffe675275aea0febe61712abaf Mon Sep 17 00:00:00 2001 From: Thiago Margatho Date: Tue, 7 Jul 2026 09:16:13 -0300 Subject: [PATCH 2/2] refactor(cli): split main.rs into per-responsibility modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.rs had grown to ~19.8k lines with 8+ unrelated concerns interleaved function-by-function throughout the file (arg parsing, boot/health checks, runtime bootstrap, provider client, progress reporting, model resolution, skill dispatch all mixed together). Split along those natural boundaries into separate files, keeping main.rs as a thin entry point (constants, main(), mod declarations, existing tests). No logic changed — items moved verbatim, marked pub(crate) where a new module boundary required it, with `use crate::*;` in each new file so cross-module references keep resolving through main.rs's re-exports. Verified with cargo check/clippy/test on this crate and scripts/fmt.sh; confirmed the few remaining test failures (provider-detection defaults in the api/rusty-claude-cli crates) are pre-existing on a clean checkout of main, unrelated to this change. New files: - model_provenance.rs — model alias/version resolution - cli_parse.rs — CLI arg parsing, help, report formatting - preflight.rs — doctor/health checks, boot snapshot, git status - bootstrap.rs — runtime bootstrap, LiveCli, session management - provider_client.rs — Anthropic client, tool executor, MCP wiring - progress.rs — prompt/hook progress reporting - skill_dispatch.rs — bare-skill dispatch, cwd guard, diff reports Co-Authored-By: Claude Sonnet 5 --- rust/crates/rusty-claude-cli/src/bootstrap.rs | 3079 ++++ rust/crates/rusty-claude-cli/src/cli_parse.rs | 3113 ++++ rust/crates/rusty-claude-cli/src/main.rs | 14274 ++-------------- .../rusty-claude-cli/src/model_provenance.rs | 211 + rust/crates/rusty-claude-cli/src/preflight.rs | 3900 +++++ rust/crates/rusty-claude-cli/src/progress.rs | 416 + .../rusty-claude-cli/src/provider_client.rs | 1696 ++ .../rusty-claude-cli/src/skill_dispatch.rs | 105 + 8 files changed, 13655 insertions(+), 13139 deletions(-) create mode 100644 rust/crates/rusty-claude-cli/src/bootstrap.rs create mode 100644 rust/crates/rusty-claude-cli/src/cli_parse.rs create mode 100644 rust/crates/rusty-claude-cli/src/model_provenance.rs create mode 100644 rust/crates/rusty-claude-cli/src/preflight.rs create mode 100644 rust/crates/rusty-claude-cli/src/progress.rs create mode 100644 rust/crates/rusty-claude-cli/src/provider_client.rs create mode 100644 rust/crates/rusty-claude-cli/src/skill_dispatch.rs diff --git a/rust/crates/rusty-claude-cli/src/bootstrap.rs b/rust/crates/rusty-claude-cli/src/bootstrap.rs new file mode 100644 index 0000000000..e2c0de1b18 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/bootstrap.rs @@ -0,0 +1,3079 @@ +use crate::*; + +use api::{ + detect_provider_kind, model_family_identity_for, 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, PluginsCommandResult, SkillSlashDispatch, + SlashCommand, +}; +use init::initialize_repo; +use log::debug; +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, load_system_prompt_with_context, pricing_for_model, resolve_expected_base, + resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState, + CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile, + ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig, + McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, + PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, + RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::net::TcpListener; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, UNIX_EPOCH}; +use tools::{ + canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry, + RuntimeToolDefinition, ToolSearchOutput, +}; + +pub(crate) fn run() -> Result<(), Box> { + let args: Vec = env::args().skip(1).collect(); + // #824: suppress config deprecation prose warnings to stderr when JSON + // output mode is active. Scan the raw argv before parse_args so the + // suppression is in place before any settings file is loaded. + let json_mode = raw_args_request_json_output(&args); + if json_mode { + runtime::suppress_config_warnings_for_json_mode(); + } + let (args, cwd) = split_global_cwd_args(&args)?; + apply_global_cwd(cwd)?; + match parse_args(&args)? { + CliAction::DumpManifests { + output_format, + manifests_dir, + } => dump_manifests(manifests_dir.as_deref(), output_format)?, + CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?, + CliAction::Agents { + args, + output_format, + } => LiveCli::print_agents(args.as_deref(), output_format)?, + CliAction::Mcp { + args, + output_format, + } => LiveCli::print_mcp(args.as_deref(), output_format)?, + CliAction::Skills { + 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, + model, + output_format, + } => print_system_prompt(cwd, date, &model, output_format)?, + CliAction::Version { output_format } => print_version(output_format)?, + CliAction::ResumeSession { + session_path, + commands, + output_format, + allow_broad_cwd, + } => { + enforce_broad_cwd_policy(allow_broad_cwd, output_format)?; + resume_session(&session_path, &commands, output_format) + } + CliAction::Status { + model, + model_flag_raw, + permission_mode, + output_format, + allowed_tools, + } => print_status_snapshot( + &model, + model_flag_raw.as_deref(), + permission_mode, + output_format, + allowed_tools.as_ref(), + )?, + CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?, + CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort, + allow_broad_cwd, + } => { + 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 { + None + }; + let effective_prompt = merge_prompt_with_stdin(&prompt, stdin_context.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); + cli.run_turn_with_output(&effective_prompt, output_format, compact)?; + } + CliAction::Doctor { + output_format, + permission_mode, + } => run_doctor(output_format, permission_mode)?, + CliAction::Acp { output_format } => { + print_acp_status(output_format)?; + std::process::exit(2); + } + CliAction::SessionList { output_format } => run_session_list(output_format)?, + CliAction::State { output_format } => run_worker_state(output_format)?, + CliAction::Init { output_format } => run_init(output_format)?, + CliAction::Setup { output_format: _ } => run_setup()?, + // #146: dispatch pure-local introspection. Text mode uses existing + // render_config_report/render_diff_report; JSON mode uses the + // corresponding _json helpers already exposed for resume sessions. + CliAction::Config { + section, + output_format, + } => match output_format { + CliOutputFormat::Text => { + println!("{}", render_config_report(section.as_deref())?); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&render_config_json(section.as_deref())?)? + ); + } + }, + CliAction::Models { + action, + output_format, + } => print_models(action.as_deref(), output_format)?, + CliAction::Diff { output_format } => match output_format { + CliOutputFormat::Text => { + println!("{}", render_diff_report()?); + } + CliOutputFormat::Json => { + let cwd = friendly_cwd(env::current_dir()?); + println!( + "{}", + serde_json::to_string_pretty(&render_diff_json_for(&cwd)?)? + ); + } + }, + CliAction::Export { + session_reference, + output_path, + output_format, + } => run_export(&session_reference, output_path.as_deref(), output_format)?, + CliAction::Repl { + model, + allowed_tools, + permission_mode, + base_commit, + reasoning_effort, + allow_broad_cwd, + } => run_repl( + model, + allowed_tools, + permission_mode, + base_commit, + reasoning_effort, + allow_broad_cwd, + )?, + CliAction::HelpTopic { + topic, + output_format, + } => print_help_topic(topic, output_format)?, + CliAction::Help { output_format } => print_help(output_format)?, + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CliOutputFormat { + Text, + Json, +} + +impl CliOutputFormat { + pub(crate) fn parse(value: &str) -> Result { + match value.trim() { + value if value.eq_ignore_ascii_case("text") => Ok(Self::Text), + value if value.eq_ignore_ascii_case("json") => Ok(Self::Json), + other => Err(format!( + "invalid_output_format: unsupported value for --output-format: {other}\nExpected: text, json\nHint: Use --output-format text or --output-format json." + )), + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::Json => "json", + } + } +} + +pub(crate) fn run_doctor( + output_format: CliOutputFormat, + permission_mode: PermissionModeProvenance, +) -> Result<(), Box> { + let report = render_doctor_report( + match output_format { + CliOutputFormat::Json => ConfigWarningMode::SuppressStderr, + CliOutputFormat::Text => ConfigWarningMode::EmitStderr, + }, + permission_mode, + )?; + let message = report.render(); + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&report.json_value())?); + } + } + if report.has_failures() { + return Err("doctor found failing checks".into()); + } + Ok(()) +} + +/// Starts a minimal Model Context Protocol server that exposes claw's +/// built-in tools over stdio. +/// +/// Tool descriptors come from [`tools::mvp_tool_specs`] and calls are +/// dispatched through [`tools::execute_tool`], so this server exposes exactly +/// Read `.claw/worker-state.json` from the current working directory and print it. +/// This is the file-based worker observability surface: `push_event()` in `worker_boot.rs` +/// atomically writes state transitions here so external observers (clawhip, orchestrators) +/// can poll current `WorkerStatus` without needing an HTTP route on the opencode binary. +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() { + // #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. +pub(crate) fn run_mcp_serve() -> Result<(), Box> { + let tools = mvp_tool_specs() + .into_iter() + .map(|spec| McpTool { + name: spec.name.to_string(), + description: Some(spec.description.to_string()), + input_schema: Some(spec.input_schema), + annotations: None, + meta: None, + }) + .collect(); + + let spec = 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(()) +} + +pub(crate) fn dump_manifests( + manifests_dir: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let workspace_dir = env::current_dir()?; + dump_manifests_at_path(&workspace_dir, manifests_dir, output_format) +} + +// Internal function for testing that accepts a workspace directory path. +pub(crate) fn dump_manifests_at_path( + workspace_dir: &std::path::Path, + manifests_dir: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let discovery_root = manifests_dir.unwrap_or(workspace_dir); + let resolved_root = discovery_root + .canonicalize() + .unwrap_or_else(|_| discovery_root.to_path_buf()); + + if !resolved_root.exists() { + return Err(format!( + "missing_manifests: manifest discovery directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_USAGE_HINT}", + resolved_root.display(), + ) + .into()); + } + if !resolved_root.is_dir() { + return Err(format!( + "missing_manifests: manifest discovery path is not a directory.\n looked in: {}\n {DUMP_MANIFESTS_USAGE_HINT}", + resolved_root.display(), + ) + .into()); + } + + let manifest = build_rust_resolver_manifest(&resolved_root)?; + match output_format { + CliOutputFormat::Text => { + println!("Manifest Dump"); + println!(" Source rust-resolver"); + println!(" Workspace {}", resolved_root.display()); + println!(" Commands {}", manifest["commands"]); + println!(" Tools {}", manifest["tools"]); + println!(" Agents {}", manifest["agents"]); + println!(" Skills {}", manifest["skills"]); + println!(" Bootstrap phases {}", manifest["bootstrap_phases"]); + } + CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&manifest)?), + } + Ok(()) +} + +pub(crate) fn print_bootstrap_plan( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let phases = runtime::BootstrapPlan::claude_code_default(); + match output_format { + CliOutputFormat::Text => { + for phase in phases.phases() { + println!("- {phase:?}"); + } + } + CliOutputFormat::Json => { + // #412: emit structured phase objects with label and description + let phase_objects: Vec = phases + .phases() + .iter() + .enumerate() + .map(|(i, phase)| { + let (label, description) = bootstrap_phase_metadata(phase); + json!({ + "id": format!("{phase:?}"), + "label": label, + "description": description, + "order": i, + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "bootstrap-plan", + "action": "show", + "status": "ok", + "total_phases": phases.phases().len(), + "phases": phase_objects, + }))? + ); + } + } + Ok(()) +} + +pub(crate) fn print_system_prompt( + cwd: PathBuf, + date: String, + model: &str, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let (sections, project_context) = load_system_prompt_with_context( + cwd, + date, + env::consts::OS, + "unknown", + model_family_identity_for(model), + )?; + let (project_root, _) = + parse_git_status_metadata_for(&project_context.cwd, project_context.git_status.as_deref()); + let memory_files = memory_file_summaries_for( + &project_context.cwd, + project_root.as_deref(), + &project_context.instruction_files, + ); + let message = sections.join( + " + +", + ); + // #418: filter out the internal boundary sentinel from the sections array + // and expose the boundary index as a structured field. + let filtered_sections: Vec<&str> = sections + .iter() + .filter(|s| !s.contains("__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__")) + .map(|s| s.as_str()) + .collect(); + let boundary_index = sections + .iter() + .position(|s| s.contains("__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__")); + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "system-prompt", + "action": "show", + "status": "ok", + "message": message, + "sections": filtered_sections, + "boundary_index": boundary_index, + "memory_file_count": memory_files.len(), + "memory_files": memory_files_json(&memory_files), + }))? + ), + } + 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 format_auto_compaction_notice(removed: usize) -> String { + format!("[auto-compacted: removed {removed} messages]") +} + +/// Enforce the broad-CWD policy: when running from home or root, either +/// require the --allow-broad-cwd flag, or prompt for confirmation (interactive), +/// or exit with an error (non-interactive). +pub(crate) fn enforce_broad_cwd_policy( + allow_broad_cwd: bool, + output_format: CliOutputFormat, +) -> Result<(), Box> { + if allow_broad_cwd { + return Ok(()); + } + let Some(cwd) = detect_broad_cwd() else { + return Ok(()); + }; + + let is_interactive = io::stdin().is_terminal(); + + if is_interactive { + // Interactive mode: print warning and ask for confirmation + eprintln!( + "Warning: claw is running from a very broad directory ({}).\n\ + The agent can read and search everything under this path.\n\ + Consider running from inside your project: cd /path/to/project && claw", + cwd.display() + ); + eprint!("Continue anyway? [y/N]: "); + io::stderr().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let trimmed = input.trim().to_lowercase(); + if trimmed != "y" && trimmed != "yes" { + eprintln!("Aborted."); + std::process::exit(0); + } + Ok(()) + } else { + // Non-interactive mode: exit with error (JSON or text) + let message = format!( + "claw is running from a very broad directory ({}). \ + The agent can read and search everything under this path. \ + Use --allow-broad-cwd to proceed anyway, \ + or run from inside your project: cd /path/to/project && claw", + cwd.display() + ); + match output_format { + CliOutputFormat::Json => { + println!( + "{}", + serde_json::json!({ + "kind": "broad_cwd", + "action": "abort", + "status": "error", + "error_kind": "broad_cwd", + "error": message, + "hint": "Change to a more specific project directory, or use --cwd to set the workspace root.", + "exit_code": 1, + }) + ); + } + CliOutputFormat::Text => { + eprintln!("error: {message}"); + } + } + std::process::exit(1); + } +} + +#[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(()) +} + +#[derive(Debug, Clone)] +pub(crate) struct SessionHandle { + pub(crate) id: String, + pub(crate) path: PathBuf, +} + +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, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ReadMcpResourceRequest { + pub(crate) server: String, + pub(crate) uri: String, +} + +impl LiveCli { + pub(crate) fn new( + model: String, + enable_tools: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + ) -> Result> { + let system_prompt = build_system_prompt(&model)?; + 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, + )?; + let final_text = final_assistant_text(&summary); + if !final_text.is_empty() { + println!("{final_text}"); + } + 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, + )?; + + // ============================================================================ + // Auto-compact retry on context window errors + // ============================================================================ + // When the model API returns a context_window_blocked error (because the request + // exceeds the model's context window), we automatically: + // 1. Compact the session (remove old messages to free up space) + // 2. Retry the original request with the compacted session + // 3. Report results to the user + // + // This eliminates the need for users to manually run /compact when they + // hit context limits - the recovery happens automatically. + // + // Detection: We look for "context_window" or "Context window" in the error + // message, which covers error types like: + // - "context_window_blocked" + // - "Context window blocked" + // - "This model's maximum context length is X tokens..." + // ============================================================================ + + let error_str = error.to_string(); + // Detect context window overflow. Some providers (e.g. OpenAI-compat backends) + // return 400 with "no parseable body" instead of a proper context_length_exceeded + // error when the request is too large to even parse — treat that as context overflow too. + // Also detect model-specific context error markers (e.g. llama.cpp returns + // "Context size has been exceeded." / "exceed_context_size_error" / "exceeds the available context size"). + let is_context_window = error_str.contains("context_window") + || error_str.contains("Context window") + || error_str.contains("no parseable body") + || error_str.contains("exceed_context_size") + || error_str.contains("exceeds the available context size") + || error_str + .to_ascii_lowercase() + .contains("context size has been exceeded"); + + // Also treat "assistant stream produced no content" and reqwest decode failures + // as recoverable errors that may benefit from auto-compaction. Some backends (e.g. + // llama.cpp) return a non-SSE HTTP 500 body when context overflows, causing + // reqwest to fail with "error decoding response body" — treat that as context overflow too. + let is_no_content = error_str.contains("assistant stream produced no content") + || error_str.contains("Failed to parse input at pos") + || error_str.contains("error decoding response body"); + + if is_context_window || is_no_content { + // If the error tells us the server's actual context window, adapt our + // auto-compaction threshold so future auto-compact-trigger checks are accurate. + if let Some(window) = extract_context_window_tokens_from_error(&error_str) { + // Set threshold at 70% of the reported window to leave headroom. + let threshold: u32 = (window as f64 * 0.7).round() as u32; + println!( + " Server context window: {} tokens — setting auto-compaction threshold to {}", + window, threshold + ); + runtime.set_auto_compaction_input_tokens_threshold(threshold); + } + + // A single compaction pass may not free enough context space. + // Progressive retry: each round preserves fewer recent messages (4→2→1→0), + // trading conversation continuity for a smaller payload until it fits. + // Max 4 rounds before giving up and surfacing the error to the user. + let max_compact_rounds = 4; + let preserve_schedule = [4, 2, 1, 0]; + + for round in 0..max_compact_rounds { + let preserve = preserve_schedule[round]; + println!( + " Auto-compacting session (round {}/{}, preserving {} recent messages)...", + round + 1, + max_compact_rounds, + preserve + ); + + // Run Trident pipeline then summary-based compaction + let result = runtime::trident::trident_compact_session( + runtime.session(), + CompactionConfig { + preserve_recent_messages: preserve, + max_estimated_tokens: 0, + }, + &runtime::trident::TridentConfig::default(), + ); + let removed = result.removed_message_count; + + if removed == 0 && round > 0 { + // No more messages to compact — further rounds won't help + println!(" No further compaction possible."); + break; + } + + if removed > 0 { + println!( + "{}", + format_compact_report( + removed, + result.compacted_session.messages.len(), + false + ) + ); + } + + // Without this, prepare_turn_runtime() reads from self.runtime.session() + // which still holds the ORIGINAL un-compacted session, so every retry round + // would send the same bloated request — compaction was wasted. + *self.runtime.session_mut() = result.compacted_session.clone(); + + // Build a new runtime with the compacted session and retry + let (mut new_runtime, hook_abort_monitor) = + self.prepare_turn_runtime(true)?; + drop(hook_abort_monitor); + + let mut rp = CliPermissionPrompter::new(self.permission_mode); + match new_runtime.run_turn(input, Some(&mut rp)) { + Ok(summary) => { + self.replace_runtime(new_runtime)?; + spinner.finish( + if round == 0 { + "✨ Done (after auto-compact)" + } else { + "✨ Done (after aggressive auto-compact)" + }, + 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()?; + return Ok(()); + } + Err(retry_error) => { + let retry_str = retry_error.to_string(); + let still_context_window = retry_str.contains("context_window") + || retry_str.contains("Context window") + || retry_str.contains("no parseable body") + || retry_str.contains("exceed_context_size") + || retry_str.contains("exceeds the available context size") + || retry_str + .to_ascii_lowercase() + .contains("context size has been exceeded"); + let still_no_content = retry_str + .contains("assistant stream produced no content") + || retry_str.contains("Failed to parse input at pos") + || retry_str.contains("error decoding response body"); + + if (still_context_window || still_no_content) + && round + 1 < max_compact_rounds + { + // If the retry error reveals the context window, adapt threshold. + if let Some(window) = + extract_context_window_tokens_from_error(&retry_str) + { + let threshold: u32 = (window as f64 * 0.7).round() as u32; + new_runtime + .set_auto_compaction_input_tokens_threshold(threshold); + } + + // The compacted session was still too large for the model's context. + // Shut down the old runtime, adopt the partially-compacted one, + // and loop — the next round will compact more aggressively. + runtime.shutdown_plugins()?; + runtime = new_runtime; + continue; + } + + // Not a context window error, or out of rounds + return Err(Box::new(retry_error)); + } + } + } + } + + // If not a context window error, return original error + 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 } => { + if let Err(error) = Self::print_agents(args.as_deref(), CliOutputFormat::Text) { + eprintln!("{error}"); + } + false + } + SlashCommand::Skills { args } => { + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?, + SkillSlashDispatch::Local => { + if let Err(error) = + Self::print_skills(args.as_deref(), CliOutputFormat::Text) + { + eprintln!("{error}"); + } + } + } + false + } + SlashCommand::Doctor => { + println!( + "{}", + render_doctor_report( + ConfigWarningMode::EmitStderr, + permission_mode_provenance_for_current_dir(), + )? + .render() + ); + false + } + SlashCommand::Setup => { + if let Err(e) = setup_wizard::run_setup_wizard() { + eprintln!("Setup wizard failed: {e}"); + } + 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 { .. } + | SlashCommand::Team { .. } => { + 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 + None, + ) + ); + } + + pub(crate) fn record_prompt_history(&mut self, prompt: &str) { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map_or(self.runtime.session().updated_at_ms, |duration| { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) + }); + let entry = PromptHistoryEntry { + timestamp_ms, + text: prompt.to_string(), + }; + self.prompt_history.push(entry); + if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) { + eprintln!("warning: failed to persist prompt history: {error}"); + } + } + + 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!( + "invalid_flag_value: unsupported permission mode '{mode}'.\nUsage: --permission-mode read-only|workspace-write|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_excluding(&session_ref, Some(&self.session.id))?; + 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 => { + let value = handle_agents_slash_command_json(args, &cwd)?; + // #789: parity with print_mcp/#788 print_skills — exit 1 when envelope + // reports an error so automation can rely on exit code instead of + // parsing the JSON status field. + let is_error = value.get("status").and_then(|v| v.as_str()) == Some("error"); + println!("{}", serde_json::to_string_pretty(&value)?); + if is_error { + std::process::exit(1); + } + } + } + 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 => { + let value = handle_mcp_slash_command_json(args, &cwd)?; + // Propagate ok:false → non-zero exit so automation callers + // can rely on exit code instead of inspecting the envelope. + // (#68: mcp error envelopes previously always exited 0.) + let is_error = value.get("ok").and_then(serde_json::Value::as_bool) == Some(false) + || value.get("status").and_then(serde_json::Value::as_str) == Some("error"); + println!("{}", serde_json::to_string_pretty(&value)?); + if is_error { + std::process::exit(1); + } + } + } + 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 => { + let result = handle_skills_slash_command_json(args, &cwd)?; + let is_error = result.get("status").and_then(|v| v.as_str()) == Some("error"); + // #739: action:"help" with unexpected set is a usage response, not a fatal error; + // don't return Err which would emit a second error envelope from the generic path. + let is_help_action = result.get("action").and_then(|v| v.as_str()) == Some("help"); + println!("{}", serde_json::to_string_pretty(&result)?); + if is_error && !is_help_action { + // #788: the error JSON is already emitted above; returning Err here + // would cause the top-level handler to emit a second error envelope. + // Exit directly to signal failure without a duplicate envelope. + std::process::exit(1); + } + } + } + Ok(()) + } + + pub(crate) fn print_plugins( + action: Option<&str>, + target: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + let cwd = env::current_dir()?; + // #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes. + // Previously the guard was JSON-only (#793); text mode silently returned empty success. + if action.as_deref() == Some("list") { + if let Some(filter) = target.as_deref() { + if filter.starts_with('-') { + if matches!(output_format, CliOutputFormat::Json) { + // ROADMAP #817: this is a handled local inventory parse error. + // Keep it on stdout in JSON mode so `plugins list --` matches the + // sibling JSON inventory/local surfaces instead of falling through + // to the top-level stderr error path. + let obj = json!({ + "type": "error", + "kind": "plugin", + "action": "list", + "status": "error", + "error_kind": "cli_parse", + "error": format!("unknown option for `claw plugins list`: {filter}"), + "message": format!("unknown option for `claw plugins list`: {filter}"), + "unexpected": filter, + "hint": "Usage: claw plugins list []\nFilters are id substrings, not flags.", + "exit_code": 1, + }); + println!("{}", serde_json::to_string_pretty(&obj)?); + std::process::exit(1); + } + return Err(format!( + "unknown option for `claw plugins list`: {filter}\nUsage: claw plugins list []\nFilters are id substrings, not flags." + ).into()); + } + } + } + let payload = plugins_command_payload_for( + &cwd, + action, + target, + match output_format { + CliOutputFormat::Json => ConfigWarningMode::SuppressStderr, + CliOutputFormat::Text => ConfigWarningMode::EmitStderr, + }, + )?; + match output_format { + CliOutputFormat::Text => { + // #806: text-mode show must return error when plugin not found (parity with JSON) + let action_str = action.unwrap_or("list"); + if matches!(action_str, "show" | "info" | "describe") { + if let Some(name) = target { + let needle = name.to_lowercase(); + let found = payload.plugins.iter().any(|p| { + p.get("id") + .and_then(|v| v.as_str()) + .map(|id| id.to_lowercase() == needle) + .unwrap_or(false) + }); + if !found { + return Err(format!( + "plugin_not_found: plugin '{}' not found\nRun `claw plugins list` to see available plugins.", + name + ).into()); + } + } + } + println!("{}", payload.message); + } + CliOutputFormat::Json => { + let action_str = action.unwrap_or("list"); + // #743/#420: plugins help must return a usage envelope matching agents/mcp/skills help shape. + if matches!(action_str, "help" | "-h" | "--help") { + let cwd_str = cwd.display().to_string(); + let obj = json!({ + "kind": "plugin", + "action": "help", + "status": "ok", + "unexpected": null, + "usage": { + "direct_cli": "claw plugins [list|show |install |enable |disable |uninstall |update |help]", + "slash_command": "/plugins [list|show |install |enable |disable |uninstall |update |help]", + }, + "cwd": cwd_str, + }); + println!("{}", serde_json::to_string_pretty(&obj)?); + return Ok(()); + } + // For show/info/describe, filter to the named plugin (exact match). + // For list with a target, treat target as a substring filter. + let is_show_action = matches!(action_str, "show" | "info" | "describe"); + let is_list_action = action_str == "list"; + let filtered_plugins: Vec<_> = if is_show_action { + if let Some(name) = target { + let needle = name.to_lowercase(); + payload + .plugins + .iter() + .filter(|p| { + p.get("id") + .and_then(|v| v.as_str()) + .map(|id| id.to_lowercase() == needle) + .unwrap_or(false) + }) + .cloned() + .collect() + } else { + payload.plugins.clone() + } + } else if is_list_action { + if let Some(filter) = target { + let needle = filter.to_lowercase(); + payload + .plugins + .iter() + .filter(|p| { + p.get("id") + .and_then(|v| v.as_str()) + .map(|id| id.to_lowercase().contains(&needle)) + .unwrap_or(false) + }) + .cloned() + .collect() + } else { + payload.plugins.clone() + } + } else { + payload.plugins.clone() + }; + // Return not-found error for show with missing target. + if is_show_action { + if let Some(name) = target { + if filtered_plugins.is_empty() { + let obj = json!({ + "kind": "plugin", + "action": action_str, + "status": "error", + "error_kind": "plugin_not_found", + "requested": name, + // #734: parity with skills show which always emits a message field + "message": format!("plugin '{}' not found", name), + // #760: hint so callers know how to enumerate available plugins + "hint": "Run `claw plugins list` to see available plugins.", + }); + println!("{}", serde_json::to_string_pretty(&obj)?); + // #789: exit 1 on not-found so automation can rely on exit code + std::process::exit(1); + } + } + } + let enabled_count = filtered_plugins + .iter() + .filter(|p| p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false)) + .count(); + let disabled_count = filtered_plugins.len().saturating_sub(enabled_count); + let mut obj = json!({ + "kind": "plugin", + "action": action_str, + "status": payload.status, + "summary": { + "total": filtered_plugins.len(), + "enabled": enabled_count, + "disabled": disabled_count, + "load_failures": payload.load_failures.len(), + }, + "config_load_error": payload.config_load_error, + "mcp_validation": payload.mcp_validation.json_value(), + "plugins": filtered_plugins, + "load_failures": payload.load_failures, + }); + // Only include operation-result fields for mutating actions (not list/show) + if action_str != "list" && !is_show_action { + obj["target"] = json!(target); + obj["reload_runtime"] = json!(payload.reload_runtime); + obj["message"] = json!(payload.message); + } + println!("{}", serde_json::to_string_pretty(&obj)?); + } + } + 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("exists") => { + let Some(target) = target else { + println!("Usage: /session exists "); + return Ok(false); + }; + let exists = session_reference_exists(target)?; + let handle = resolve_session_reference(target).ok(); + println!( + "Session exists\n Session {target}\n Exists {exists}{}", + handle + .as_ref() + .map(|handle| format!("\n File {}", handle.path.display())) + .unwrap_or_default() + ); + 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 exists , /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 payload = + plugins_command_payload_for(&cwd, action, target, ConfigWarningMode::EmitStderr)?; + println!("{}", payload.message); + if payload.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(()) + } +} + +pub(crate) fn sessions_dir() -> Result> { + Ok(current_session_store()?.sessions_dir().to_path_buf()) +} + +pub(crate) fn current_session_store() -> Result> { + let cwd = env::current_dir()?; + runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box) +} + +pub(crate) fn new_cli_session() -> Result> { + Ok(Session::new().with_workspace_root(env::current_dir()?)) +} + +pub(crate) fn create_managed_session_handle( + session_id: &str, +) -> Result> { + let handle = current_session_store()?.create_handle(session_id); + Ok(SessionHandle { + id: handle.id, + path: handle.path, + }) +} + +pub(crate) fn resolve_session_reference( + reference: &str, +) -> Result> { + let handle = current_session_store()? + .resolve_reference(reference) + .map_err(|e| Box::new(e) as Box)?; + Ok(SessionHandle { + id: handle.id, + path: handle.path, + }) +} + +pub(crate) fn session_reference_exists( + reference: &str, +) -> Result> { + Ok(current_session_store()?.session_exists(reference)) +} + +pub(crate) fn resolve_managed_session_path( + session_id: &str, +) -> Result> { + current_session_store()? + .resolve_managed_path(session_id) + .map_err(|e| Box::new(e) as Box) +} + +pub(crate) fn list_managed_sessions( +) -> Result, Box> { + let store = current_session_store()?; + let lifecycle = classify_session_lifecycle_for(store.workspace_root()); + Ok(store + .list_sessions() + .map_err(|e| Box::new(e) as Box)? + .into_iter() + .map(|session| ManagedSessionSummary { + id: session.id, + path: session.path, + created_at_ms: session.created_at_ms, + 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: lifecycle.clone(), + }) + .collect()) +} + +pub(crate) fn latest_managed_session() -> Result> +{ + let store = current_session_store()?; + let lifecycle = classify_session_lifecycle_for(store.workspace_root()); + let session = store + .latest_session() + .map_err(|e| Box::new(e) as Box)?; + Ok(ManagedSessionSummary { + id: session.id, + path: session.path, + created_at_ms: session.created_at_ms, + 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, + }) +} + +pub(crate) fn load_session_reference( + reference: &str, +) -> Result<(SessionHandle, Session), Box> { + load_session_reference_excluding(reference, None) +} + +pub(crate) fn delete_managed_session(path: &Path) -> Result<(), Box> { + if !path.exists() { + return Err(format!("session file does not exist: {}", path.display()).into()); + } + fs::remove_file(path)?; + Ok(()) +} + +pub(crate) fn session_exists_json( + target: &str, + active_session_id: &str, +) -> Result> { + let handle = create_managed_session_handle(target)?; + let resolved = resolve_session_reference(target).ok(); + let exists = resolved.is_some(); + let resolved_id = resolved + .as_ref() + .map_or(target, |handle| handle.id.as_str()); + Ok(serde_json::json!({ + "kind": "session_exists", + "action": "exists", + "status": "ok", + "session_id": resolved_id, + "session": target, + "requested": target, + "exists": exists, + "active": resolved_id == active_session_id, + "path": resolved + .as_ref() + .map(|handle| handle.path.display().to_string()), + "candidate_path": handle.path.display().to_string(), + })) +} + +pub(crate) fn run_resumed_session_command( + session_path: &Path, + session: &Session, + action: Option<&str>, + target: Option<&str>, +) -> Result> { + match action { + None | Some("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": "sessions", + "status": "ok", + "action": "list", + "sessions": session_ids, + "session_details": session_details_json(&sessions), + "active": active_id, + })), + }) + } + Some("exists") => { + let Some(target) = target else { + return Err("/session exists requires a session id.\nUsage: claw --resume /session exists ".into()); + }; + let value = session_exists_json(target, &session.session_id)?; + let exists = value + .get("exists") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Session exists\n Session {}\n Exists {}", + target, + if exists { "yes" } else { "no" } + )), + json: Some(value), + }) + } + Some("delete") => { + let Some(target) = target else { + return Err("/session delete requires a session id.\nUsage: claw --resume /session delete --force".into()); + }; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "delete: confirmation required; rerun with /session delete {target} --force" + )), + json: Some(serde_json::json!({ + "kind": "error", + "error": "confirmation required", + "hint": format!("rerun with /session delete {target} --force"), + "session_id": target, + })), + }) + } + Some("delete-force") => { + let Some(target) = target else { + return Err("/session delete requires a session id.\nUsage: claw --resume /session delete --force".into()); + }; + let handle = resolve_session_reference(target)?; + if handle.id == session.session_id || handle.path == session_path { + return Err(format!( + "delete: refusing to delete the active session '{}'. Resume or switch to another session first.", + handle.id + ) + .into()); + } + delete_managed_session(&handle.path)?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Session deleted\n Deleted session {}\n File {}", + handle.id, + handle.path.display(), + )), + json: Some(serde_json::json!({ + "kind": "session_delete", + "action": "delete", + "status": "ok", + "deleted": true, + "session_id": handle.id, + "path": handle.path.display().to_string(), + })), + }) + } + // #113: /session switch and /session fork require an interactive REPL — + // return structured JSON instead of a raw error so resume callers can + // detect the limitation programmatically. + Some(switch_or_fork @ ("switch" | "fork")) => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "/session {switch_or_fork} requires an interactive REPL.\nUsage: claw (then /session {switch_or_fork} )" + )), + json: Some(serde_json::json!({ + "kind": "error", + "error_kind": "unsupported_resumed_command", + "status": "error", + "action": switch_or_fork, + "error": format!("/session {switch_or_fork} requires an interactive REPL"), + "hint": format!("Start a new claw session and use /session {switch_or_fork} interactively"), + })), + }), + Some(other) => Err(format!("unsupported_resumed_command: /session {other} is not supported in resume mode.\nSupported: list, exists, delete").into()), + } +} + +pub(crate) fn render_session_list( + active_session_id: &str, +) -> Result> { + let sessions = list_managed_sessions()?; + let mut lines = vec![ + "Sessions".to_string(), + format!(" Directory {}", sessions_dir()?.display()), + ]; + if sessions.is_empty() { + lines.push(" No managed sessions saved yet.".to_string()); + return Ok(lines.join("\n")); + } + for session in sessions { + let marker = if session.id == active_session_id { + "● current" + } else { + "○ saved" + }; + let lineage = match ( + session.branch_name.as_deref(), + session.parent_session_id.as_deref(), + ) { + (Some(branch_name), Some(parent_session_id)) => { + format!(" branch={branch_name} from={parent_session_id}") + } + (None, Some(parent_session_id)) => format!(" from={parent_session_id}"), + (Some(branch_name), None) => format!(" branch={branch_name}"), + (None, None) => String::new(), + }; + lines.push(format!( + " {id:<20} {marker:<10} lifecycle={lifecycle} msgs={msgs:<4} modified={modified}{lineage} path={path}", + id = session.id, + lifecycle = session.lifecycle.signal(), + msgs = session.message_count, + modified = format_session_modified_age(session.modified_epoch_millis), + lineage = lineage, + path = session.path.display(), + )); + } + Ok(lines.join("\n")) +} + +pub(crate) fn format_session_modified_age(modified_epoch_millis: u128) -> String { + let now = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map_or(modified_epoch_millis, |duration| duration.as_millis()); + let delta_seconds = now + .saturating_sub(modified_epoch_millis) + .checked_div(1_000) + .unwrap_or_default(); + match delta_seconds { + 0..=4 => "just-now".to_string(), + 5..=59 => format!("{delta_seconds}s-ago"), + 60..=3_599 => format!("{}m-ago", delta_seconds / 60), + 3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600), + _ => format!("{}d-ago", delta_seconds / 86_400), + } +} + +pub(crate) fn print_status_snapshot( + model: &str, + model_flag_raw: Option<&str>, + permission_mode: PermissionModeProvenance, + output_format: CliOutputFormat, + allowed_tools: Option<&AllowedToolSet>, +) -> Result<(), Box> { + let usage = StatusUsage { + message_count: 0, + turns: 0, + latest: TokenUsage::default(), + cumulative: TokenUsage::default(), + estimated_tokens: 0, + }; + let context = status_context(None)?; + // #148: resolve model provenance. If user passed --model, source is + // "flag" with the raw input preserved. Otherwise probe env -> config + // -> default and record the winning source. + let provenance_result = match model_flag_raw { + Some(raw) => Ok(ModelProvenance::from_flag(raw, model)), + None => ModelProvenance::from_env_or_config_or_default(model), + }; + let provenance = match provenance_result { + Ok(provenance) => provenance, + Err(error) => match output_format { + CliOutputFormat::Json => { + return print_model_validation_warning_status( + &error, + usage, + permission_mode.mode.as_str(), + &context, + allowed_tools, + ); + } + CliOutputFormat::Text => return Err(error.into()), + }, + }; + let format_selection = current_output_format_selection(); + match output_format { + CliOutputFormat::Text => println!( + "{}", + format_status_report( + &provenance.resolved, + usage, + permission_mode.mode.as_str(), + &context, + Some(&provenance), + Some(&permission_mode), + ) + ), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&status_json_value( + Some(&provenance.resolved), + usage, + permission_mode.mode.as_str(), + &context, + Some(&provenance), + Some(&permission_mode), + allowed_tools, + Some(&format_selection), + ))? + ), + } + Ok(()) +} + +pub(crate) fn print_sandbox_status_snapshot( + output_format: CliOutputFormat, +) -> Result<(), Box> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader + .load() + .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); + let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); + match output_format { + CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&sandbox_json_value(&status))? + ), + } + Ok(()) +} + +pub(crate) fn print_help_topic( + topic: LocalHelpTopic, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let cwd = env::current_dir().unwrap_or_default(); + // For subsystem topics in JSON mode, delegate to the subsystem's usage JSON. + if output_format == CliOutputFormat::Json { + match topic { + LocalHelpTopic::Agents => { + let json = commands::handle_agents_slash_command_json(Some("--help"), &cwd) + .unwrap_or_else( + |_| serde_json::json!({"kind":"agents","action":"help","status":"error"}), + ); + println!("{}", serde_json::to_string_pretty(&json)?); + return Ok(()); + } + LocalHelpTopic::Skills => { + let json = commands::handle_skills_slash_command_json(Some("--help"), &cwd) + .unwrap_or_else( + |_| serde_json::json!({"kind":"skills","action":"help","status":"error"}), + ); + println!("{}", serde_json::to_string_pretty(&json)?); + return Ok(()); + } + _ => {} + } + } + match output_format { + CliOutputFormat::Text => println!("{}", render_help_topic(topic)), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&render_help_topic_json(topic))? + ), + } + Ok(()) +} + +pub(crate) fn print_acp_status( + output_format: CliOutputFormat, +) -> Result<(), Box> { + match output_format { + CliOutputFormat::Text => { + println!( + "ACP / Zed\n Status not implemented\n Launch `claw acp serve` reports status only; no editor daemon or JSON-RPC endpoint is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Message {}", + acp_status_message() + ); + } + CliOutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&acp_status_json())?); + } + } + Ok(()) +} + +pub(crate) fn render_memory_json() -> Result> { + let cwd = env::current_dir()?; + let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; + let files: Vec<_> = project_context + .instruction_files + .iter() + .map(|f| { + json!({ + "path": f.path.display().to_string(), + "lines": f.content.lines().count(), + "preview": f.content.lines().next().unwrap_or("").trim(), + }) + }) + .collect(); + Ok(json!({ + "kind": "memory", + "action": "list", + "status": "ok", + "cwd": cwd.display().to_string(), + "instruction_files": files.len(), + "files": files, + })) +} + +pub(crate) fn init_claude_md() -> Result> { + let cwd = env::current_dir()?; + Ok(initialize_repo(&cwd)?.render()) +} + +pub(crate) 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(()) +} + +pub(crate) fn render_diff_report() -> Result> { + render_diff_report_for(&env::current_dir()?) +} + +pub(crate) fn render_diff_json_for( + cwd: &Path, +) -> Result> { + let in_git_repo = std::process::Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(cwd) + .output() + .is_ok_and(|o| o.status.success()); + if !in_git_repo { + // #801: add error_kind, hint, message fields for envelope parity with other error paths + return Ok(serde_json::json!({ + "kind": "diff", + "action": "diff", + "status": "error", + "error_kind": "no_git_repo", + "result": "no_git_repo", + "message": format!("{} is not inside a git project", cwd.display()), + "hint": "Run `git init` to create a repository, or change to a directory that is inside a git project.", + "working_directory": cwd.display().to_string(), + "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"])?; + // #733: add changed_file_count so callers don't have to count diff hunks + let staged_files = + run_git_diff_command_in(cwd, &["diff", "--cached", "--name-only"]).unwrap_or_default(); + let unstaged_files = run_git_diff_command_in(cwd, &["diff", "--name-only"]).unwrap_or_default(); + let mut changed: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + for line in staged_files.lines().chain(unstaged_files.lines()) { + let t = line.trim(); + if !t.is_empty() { + changed.insert(t); + } + } + let changed_file_count = changed.len(); + Ok(serde_json::json!({ + "kind": "diff", + "action": "diff", + "status": "ok", + "working_directory": cwd.display().to_string(), + "result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" }, + "changed_file_count": changed_file_count, + "staged": staged.trim(), + "unstaged": unstaged.trim(), + })) +} + +pub(crate) fn run_git_diff_command_in( + cwd: &Path, + args: &[&str], +) -> Result> { + let output = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(format!("git {} failed: {stderr}", args.join(" ")).into()); + } + Ok(String::from_utf8(output.stdout)?) +} + +pub(crate) fn render_teleport_report(target: &str) -> Result> { + let cwd = env::current_dir()?; + + let file_list = Command::new("rg") + .args(["--files"]) + .current_dir(&cwd) + .output()?; + let file_matches = if file_list.status.success() { + String::from_utf8(file_list.stdout)? + .lines() + .filter(|line| line.contains(target)) + .take(10) + .map(ToOwned::to_owned) + .collect::>() + } else { + Vec::new() + }; + + let content_output = Command::new("rg") + .args(["-n", "-S", "--color", "never", target, "."]) + .current_dir(&cwd) + .output()?; + + let mut lines = vec![ + "Teleport".to_string(), + format!(" Target {target}"), + " Action search workspace files and content for the target".to_string(), + ]; + if !file_matches.is_empty() { + lines.push(String::new()); + lines.push("File matches".to_string()); + lines.extend(file_matches.into_iter().map(|path| format!(" {path}"))); + } + + if content_output.status.success() { + let matches = String::from_utf8(content_output.stdout)?; + if !matches.trim().is_empty() { + lines.push(String::new()); + lines.push("Content matches".to_string()); + lines.push(truncate_for_prompt(&matches, 4_000)); + } + } + + if lines.len() == 1 { + lines.push(" Result no matches found".to_string()); + } + + Ok(lines.join("\n")) +} + +pub(crate) fn render_last_tool_debug_report( + session: &Session, +) -> Result> { + let last_tool_use = session + .messages + .iter() + .rev() + .find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => { + Some((id.clone(), name.clone(), input.clone())) + } + _ => None, + }) + }) + .ok_or_else(|| "no prior tool call found in session".to_string())?; + + let tool_result = session.messages.iter().rev().find_map(|message| { + message.blocks.iter().rev().find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } if tool_use_id == &last_tool_use.0 => { + Some((tool_name.clone(), output.clone(), *is_error)) + } + _ => None, + }) + }); + + let mut lines = vec![ + "Debug tool call".to_string(), + " Action inspect the last recorded tool call and its result".to_string(), + format!(" Tool id {}", last_tool_use.0), + format!(" Tool name {}", last_tool_use.1), + " Input".to_string(), + indent_block(&last_tool_use.2, 4), + ]; + + match tool_result { + Some((tool_name, output, is_error)) => { + lines.push(" Result".to_string()); + lines.push(format!(" name {tool_name}")); + lines.push(format!( + " status {}", + if is_error { "error" } else { "ok" } + )); + lines.push(indent_block(&output, 4)); + } + None => lines.push(" Result missing tool result".to_string()), + } + + Ok(lines.join("\n")) +} + +pub(crate) fn 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 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 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 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 resolve_export_path( + requested_path: Option<&str>, + session: &Session, +) -> Result> { + let cwd = env::current_dir()?; + let file_name = + requested_path.map_or_else(|| default_export_filename(session), ToOwned::to_owned); + let final_name = if Path::new(&file_name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) + { + file_name + } else { + format!("{file_name}.txt") + }; + Ok(cwd.join(final_name)) +} + +pub(crate) fn run_export( + session_reference: &str, + output_path: Option<&Path>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + validate_export_output_path(output_path)?; + 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", + "action": "export", + "status": "ok", + "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", + "action": "export", + "status": "ok", + "session_id": handle.id, + "file": handle.path.display().to_string(), + "messages": session.messages.len(), + "markdown": markdown, + }))? + ), + } + Ok(()) +} + +pub(crate) fn build_system_prompt(model: &str) -> Result, Box> { + Ok(load_system_prompt( + env::current_dir()?, + DEFAULT_DATE, + env::consts::OS, + "unknown", + model_family_identity_for(model), + )?) +} + +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) +} + +#[allow(clippy::needless_pass_by_value)] +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_runtime( + session: Session, + session_id: &str, + model: String, + system_prompt: Vec, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + progress_reporter: Option, +) -> Result> { + let runtime_plugin_state = build_runtime_plugin_state()?; + build_runtime_with_plugin_state( + session, + session_id, + model, + system_prompt, + enable_tools, + emit_output, + allowed_tools, + permission_mode, + progress_reporter, + runtime_plugin_state, + ) +} + +pub(crate) fn resolve_cli_auth_source() -> Result> { + Ok(resolve_cli_auth_source_for_cwd()?) +} + +pub(crate) fn final_assistant_text(summary: &runtime::TurnSummary) -> String { + summary + .assistant_messages + .last() + .map(|message| { + message + .blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + }) + .unwrap_or_default() +} + +pub(crate) fn 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 => { + // #325: include structured command list in top-level help JSON + let commands: Vec = commands::slash_command_specs() + .iter() + .map(|spec| { + serde_json::json!({ + "name": spec.name, + "summary": spec.summary, + "resume_supported": spec.resume_supported, + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "help", + "action": "help", + "status": "ok", + "message": message, + "commands": commands, + "total_commands": commands.len(), + }))? + ); + } + } + Ok(()) +} diff --git a/rust/crates/rusty-claude-cli/src/cli_parse.rs b/rust/crates/rusty-claude-cli/src/cli_parse.rs new file mode 100644 index 0000000000..36dd0bd05a --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/cli_parse.rs @@ -0,0 +1,3113 @@ +use crate::*; + +use api::{ + detect_provider_kind, model_family_identity_for, 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, PluginsCommandResult, SkillSlashDispatch, + SlashCommand, +}; +use init::initialize_repo; +use log::debug; +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, load_system_prompt_with_context, pricing_for_model, resolve_expected_base, + resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState, + CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile, + ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig, + McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, + PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, + RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::net::TcpListener; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, UNIX_EPOCH}; +use tools::{ + canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry, + RuntimeToolDefinition, ToolSearchOutput, +}; + +/// #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. +pub(crate) fn classify_error_kind(message: &str) -> &'static str { + // Check specific patterns first (more specific before generic) + if message.starts_with("unknown_slash_command:") { + "unknown_slash_command" + } else if message.starts_with("command_not_found:") { + "command_not_found" + } else if message.contains("missing Anthropic credentials") { + "missing_credentials" + } else if message.contains("Manifest source files are missing") + || message.starts_with("missing_manifests:") + { + "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("no managed sessions found") { + "no_managed_sessions" + } else if message.contains("legacy session is missing workspace binding") { + // #780: must precede the generic "failed to restore session" arm — the full + // error message is "failed to restore session: legacy session is missing workspace + // binding: ...", so the specific arm must be checked first. + "legacy_session_no_workspace_binding" + } else if message.contains("Is a directory") || message.contains("os error 21") { + // #787: --resume given a directory path instead of a .jsonl file + "session_path_is_directory" + } else if message.contains("failed to restore session") { + "session_load_failed" + } else if message.contains("unsupported ACP invocation") { + "unsupported_acp_invocation" + } else if message.starts_with("missing_argument:") { + "missing_argument" + } else if message.contains("unsupported skills action") { + "unsupported_skills_action" + } else if message.starts_with("invalid_install_source:") { + "invalid_install_source" + } else if message.starts_with("invalid_cwd:") { + "invalid_cwd" + } else if message.starts_with("invalid_output_path:") { + "invalid_output_path" + } else if message.starts_with("invalid_output_format:") { + "invalid_output_format" + } else if message.starts_with("invalid_tool_name:") { + "invalid_tool_name" + } else if message.contains("unrecognized argument") || message.contains("unknown option") { + "cli_parse" + } else if message.starts_with("missing_flag_value:") { + "missing_flag_value" + } else if message.starts_with("invalid_permission_mode:") { + "invalid_permission_mode" + } else if message.starts_with("invalid_flag_value:") { + "invalid_flag_value" + } else if message.starts_with("invalid_model:") { + "invalid_model" + } 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")) + && (message.contains("401") + || message.contains("Unauthorized") + || message.contains("authentication_error")) + { + // #781: sub-classify auth failures so wrappers can distinguish from rate-limit / server errors + "api_auth_error" + } else if (message.contains("api failed") || message.contains("api returned")) + && (message.contains("429") + || message.contains("rate_limit") + || message.contains("rate limit")) + { + // #781: sub-classify rate-limit failures + "api_rate_limit_error" + } else if message.contains("api failed") || message.contains("api returned") { + "api_http_error" + } else if message.contains("mcpServers") { + "malformed_mcp_config" + } else if message.contains(".claw/settings.json") || message.contains(".claw.json") { + // #763: config file JSON parse / validation errors (e.g. unterminated string, type mismatch) + "config_parse_error" + } else if message.starts_with("empty prompt") { + "empty_prompt" + } else if message.starts_with("interactive_only:") || message.contains("stdin is not a TTY") { + "interactive_only" + } else if message.starts_with("unknown agents subcommand:") { + "unknown_agents_subcommand" + } else if message.starts_with("agent not found:") { + "agent_not_found" + } else if message.contains("is not installed") || message.starts_with("plugin_not_found:") { + "plugin_not_found" + } else if message.contains("plugin source") && message.contains("was not found") { + // #794: `plugins install /nonexistent/path` → "plugin source ... was not found" + "plugin_source_not_found" + } else if (message.contains("skill source") && message.contains("not found")) + || message.starts_with("skill '") + { + "skill_not_found" + } else if message.contains("Unsupported config section") { + "unsupported_config_section" + } else if message.contains("unknown_plugins_action") { + "unknown_plugins_action" + } else if message.starts_with("invalid_history_count:") || message.contains("invalid count") { + "invalid_history_count" + } else if message.starts_with("missing_prompt:") { + "missing_prompt" + } else if message.contains("has been removed.") { + // #765: removed subcommands (login, logout) — hint contains migration guidance + "removed_subcommand" + } else if message.starts_with("unknown subcommand:") { + // #785/#825: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?) + // Unified under command_not_found in #825. + "command_not_found" + } else if message.starts_with("unexpected extra arguments") + || message.starts_with("unexpected_extra_args:") + { + // #766: extra positionals after commands that take no arguments (e.g. claw diff) + // #784: export extra-positional errors use the typed prefix form + "unexpected_extra_args" + } else if message.starts_with("invalid_resume_argument:") { + // #768: --resume trailing arg is not a slash command + "invalid_resume_argument" + } else if message.starts_with("unknown_option:") { + "unknown_option" + } else if message.contains("is a slash command") + || message.starts_with("interactive_only:") + // #735: "slash command /X is interactive-only" emitted by interactive-only guard + || (message.starts_with("slash command") && message.contains("interactive-only")) + { + "interactive_only" + } else { + "unknown" + } +} + +/// #77: Split a multi-line error message into (`short_reason`, `optional_hint`). +/// +/// The `short_reason` is the first line (up to the first newline), and the hint +/// is the remaining text or `None` if there's no newline. This prevents the +/// runbook prose from being stuffed into the `error` field that downstream +/// parsers expect to be the short reason alone. +pub(crate) fn split_error_hint(message: &str) -> (String, Option) { + match message.split_once('\n') { + Some((short, hint)) => (short.to_string(), Some(hint.trim().to_string())), + None => (message.to_string(), None), + } +} + +/// 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. +pub(crate) fn merge_prompt_with_stdin(prompt: &str, stdin_content: Option<&str>) -> String { + let Some(raw) = stdin_content else { + return prompt.to_string(); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return prompt.to_string(); + } + if prompt.is_empty() { + return trimmed.to_string(); + } + format!("{prompt}\n\n{trimmed}") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CliAction { + DumpManifests { + output_format: CliOutputFormat, + manifests_dir: Option, + }, + BootstrapPlan { + output_format: CliOutputFormat, + }, + Agents { + args: Option, + output_format: CliOutputFormat, + }, + Mcp { + args: Option, + output_format: CliOutputFormat, + }, + Skills { + args: Option, + output_format: CliOutputFormat, + }, + Plugins { + action: Option, + target: Option, + output_format: CliOutputFormat, + }, + PrintSystemPrompt { + cwd: PathBuf, + date: String, + model: String, + output_format: CliOutputFormat, + }, + Version { + output_format: CliOutputFormat, + }, + SessionList { + output_format: CliOutputFormat, + }, + ResumeSession { + session_path: PathBuf, + commands: Vec, + output_format: CliOutputFormat, + allow_broad_cwd: bool, + }, + Status { + model: String, + // #148: raw `--model` flag input (pre-alias-resolution), if any. + // None means no flag was supplied; env/config/default fallback is + // resolved inside `print_status_snapshot`. + model_flag_raw: Option, + permission_mode: PermissionModeProvenance, + output_format: CliOutputFormat, + allowed_tools: Option, + }, + Sandbox { + output_format: CliOutputFormat, + }, + Prompt { + prompt: String, + model: String, + output_format: CliOutputFormat, + allowed_tools: Option, + permission_mode: PermissionMode, + compact: bool, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, + }, + Doctor { + output_format: CliOutputFormat, + permission_mode: PermissionModeProvenance, + }, + Acp { + output_format: CliOutputFormat, + }, + State { + output_format: CliOutputFormat, + }, + Init { + output_format: CliOutputFormat, + }, + Setup { + output_format: CliOutputFormat, + }, + // #146: `claw config` and `claw diff` are pure-local read-only + // introspection commands; wire them as standalone CLI subcommands. + Config { + section: Option, + output_format: CliOutputFormat, + }, + Models { + action: Option, + output_format: CliOutputFormat, + }, + Diff { + output_format: CliOutputFormat, + }, + Export { + session_reference: String, + output_path: Option, + output_format: CliOutputFormat, + }, + Repl { + model: String, + allowed_tools: Option, + permission_mode: PermissionMode, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, + }, + HelpTopic { + topic: LocalHelpTopic, + output_format: CliOutputFormat, + }, + // prompt-mode formatting is only supported for non-interactive runs + Help { + output_format: CliOutputFormat, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocalHelpTopic { + Status, + Sandbox, + Doctor, + Acp, + // #141: extend the local-help pattern to every subcommand so + // `claw --help` has one consistent contract. + Init, + State, + Resume, + Session, + Compact, + Export, + Version, + SystemPrompt, + DumpManifests, + BootstrapPlan, + // #720: subsystem help topics so `claw help agents` etc. route to usage JSON + Agents, + Skills, + Plugins, + Mcp, + Config, + Model, + Settings, + Diff, + Setup, +} + +#[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_selection = if cli_has_output_format_flag(args) { + OutputFormatSelection::default() + } else { + output_format_selection_from_env()? + }; + set_current_output_format_selection(&output_format_selection); + let mut output_format = output_format_selection.format; + 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; + + // #755: -p prompt text captured as single token; remaining args continue + // flag parsing. None until `-p ` is seen. + let mut short_p_prompt: Option = None; + let mut rest: Vec = Vec::new(); + let mut positional_after_separator = false; + 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_flag_value: missing value for --model.\nUsage: --model e.g. --model anthropic/claude-opus-4-7".to_string())?; + // #468: track duplicate --model flags + if model_flag_raw.is_some() { + push_duplicate_flag(&format!( + "--model (previous: {}, new: {})", + model_flag_raw.as_deref().unwrap_or(""), + value + )); + } + let resolved = resolve_model_alias_with_config(value); + debug!("Resolved --model '{}' -> '{}'", value, resolved); + validate_model_syntax(&resolved)?; + model = resolved; + model_flag_raw = Some(value.clone()); // #148 + index += 2; + } + + flag if flag.starts_with("--model=") => { + let value = &flag[8..]; + let resolved = resolve_model_alias_with_config(value); + debug!("Resolved --model='{}' -> '{}'", value, resolved); + validate_model_syntax(&resolved)?; + model = resolved; + model_flag_raw = Some(value.to_string()); // #148 + index += 1; + } + "--output-format" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing_flag_value: missing value for --output-format.\nUsage: --output-format text or --output-format json".to_string())?; + // #468: track duplicate --output-format flags + if output_format != CliOutputFormat::Text + || output_format_selection.format != CliOutputFormat::Text + { + push_duplicate_flag("--output-format (overwriting previous value)"); + } + output_format = apply_output_format_flag(&mut output_format_selection, value)?; + index += 2; + } + "--permission-mode" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing_flag_value: missing value for --permission-mode.\nUsage: --permission-mode read-only|workspace-write|danger-full-access".to_string())?; + // #468: track duplicate --permission-mode flags + if permission_mode_override.is_some() { + push_duplicate_flag("--permission-mode (overwriting previous value)"); + } + permission_mode_override = Some(parse_permission_mode_arg(value)?); + index += 2; + } + + flag if flag.starts_with("--output-format=") => { + output_format = + apply_output_format_flag(&mut output_format_selection, &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" | "--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_flag_value: missing value for --base-commit.\nUsage: --base-commit ".to_string())?; + // #122: validate that base-commit looks like a git SHA (hex, 7-64 chars) + if value.len() < 7 + || value.len() > 64 + || !value.chars().all(|c| c.is_ascii_hexdigit()) + { + return Err(format!( + "invalid_flag_value: --base-commit expects a hex SHA (7-64 chars), got '{}'.\nUsage: --base-commit ", + value + )); + } + 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_flag_value: missing value for --reasoning-effort.\nUsage: --reasoning-effort low|medium|high".to_string())?; + if !matches!(value.as_str(), "low" | "medium" | "high") { + return Err(format!( + "invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|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_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|high" + )); + } + reasoning_effort = Some(value.to_string()); + index += 1; + } + "--allow-broad-cwd" => { + allow_broad_cwd = true; + index += 1; + } + "--" => { + if rest.is_empty() { + positional_after_separator = true; + rest.extend(args[index + 1..].iter().cloned()); + } else { + rest.push("--".to_string()); + rest.extend(args[index + 1..].iter().cloned()); + } + break; + } + "-p" => { + // Claw Code compat: -p "prompt" = one-shot prompt. + // #755: consume exactly one token so subsequent flags like + // --model/--output-format are parsed normally instead of + // being swallowed into the prompt string (#117). + let next = args.get(index + 1).map(|s| s.as_str()); + match next { + None | Some("") => { + return Err("missing_prompt: -p requires a prompt string.\nUsage: claw -p or claw prompt ".to_string()); + } + Some(tok) if tok.starts_with('-') && tok != "--" => { + // Looks like a flag, not a prompt. Reject so the user + // knows to quote the literal text or use `--`. + return Err(format!( + "missing_prompt: -p requires a prompt string before flags; got `{tok}`.\nUsage: claw -p --model sonnet or claw -p -- {tok} (literal)" + )); + } + Some(tok) => { + // `--` sentinel: skip it and take the token after as literal + let (prompt_text, skip) = if tok == "--" { + match args.get(index + 2) { + Some(t) => (t.as_str(), 3usize), + None => return Err("missing_prompt: -p -- requires a prompt string after `--`.\nUsage: claw -p -- ".to_string()), + } + } else { + (tok, 2usize) + }; + if prompt_text.trim().is_empty() { + return Err("missing_prompt: -p requires a non-empty prompt string.\nUsage: claw -p or claw prompt ".to_string()); + } + short_p_prompt = Some(prompt_text.to_string()); + index += skip; + continue; + } + } + } + "--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; + } + // #457: --help after --resume should show resume help, not be consumed as session-id + "--help" | "-h" if rest.first().map(String::as_str) == Some("--resume") => { + wants_help = true; + 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(allowed_tools_missing_error)?; + if value.starts_with('-') || is_known_top_level_subcommand(value) { + return Err(allowed_tools_missing_error()); + } + allowed_tool_values.push(value.clone()); + index += 2; + } + flag if flag.starts_with("--allowedTools=") => { + let value = flag[15..].to_string(); + if value.trim().is_empty() { + return Err(allowed_tools_missing_error()); + } + allowed_tool_values.push(value); + index += 1; + } + flag if flag.starts_with("--allowed-tools=") => { + let value = flag[16..].to_string(); + if value.trim().is_empty() { + return Err(allowed_tools_missing_error()); + } + allowed_tool_values.push(value); + index += 1; + } + other if rest.is_empty() && other.starts_with('-') => { + if should_reject_unknown_option_like(other) { + return Err(format_unknown_option(other)); + } + rest.push(other.to_string()); + index += 1; + } + other => { + rest.push(other.to_string()); + index += 1; + } + } + } + + if wants_help { + // #684: --help before subcommand should still route to subcommand-specific + // help when the subcommand is one of the local-help-topic commands. + if let Some(action) = parse_local_help_action(&rest, output_format) { + return action; + } + // When --help was consumed before the subcommand, rest has no help flag. + // If rest is a simple local-help subcommand with no extra args, route there. + if !rest.is_empty() && rest[1..].iter().all(|a| is_help_flag(a)) { + let topic = match rest[0].as_str() { + "status" => Some(LocalHelpTopic::Status), + "sandbox" => Some(LocalHelpTopic::Sandbox), + "doctor" => Some(LocalHelpTopic::Doctor), + "acp" => Some(LocalHelpTopic::Acp), + "init" => Some(LocalHelpTopic::Init), + "setup" => Some(LocalHelpTopic::Setup), + "state" => Some(LocalHelpTopic::State), + "resume" => Some(LocalHelpTopic::Resume), + "session" => Some(LocalHelpTopic::Session), + "compact" => Some(LocalHelpTopic::Compact), + "--resume" => Some(LocalHelpTopic::Resume), + "export" => Some(LocalHelpTopic::Export), + "version" => Some(LocalHelpTopic::Version), + "system-prompt" => Some(LocalHelpTopic::SystemPrompt), + "dump-manifests" => Some(LocalHelpTopic::DumpManifests), + "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), + "agents" | "agent" => Some(LocalHelpTopic::Agents), + "skills" | "skill" => Some(LocalHelpTopic::Skills), + "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), + "mcp" => Some(LocalHelpTopic::Mcp), + "config" => Some(LocalHelpTopic::Config), + "model" | "models" => Some(LocalHelpTopic::Model), + "settings" => Some(LocalHelpTopic::Settings), + "diff" => Some(LocalHelpTopic::Diff), + _ => None, + }; + if let Some(topic) = topic { + return Ok(CliAction::HelpTopic { + topic, + output_format, + }); + } + } + return Ok(CliAction::Help { output_format }); + } + + if wants_version { + return Ok(CliAction::Version { output_format }); + } + + let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; + + // #755: -p consumed exactly one token; dispatch now that all flags are parsed + if let Some(prompt) = short_p_prompt { + return Ok(CliAction::Prompt { + prompt, + model: resolve_model_alias_with_config(&model), + output_format, + allowed_tools, + permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), + compact, + base_commit, + reasoning_effort, + allow_broad_cwd, + }); + } + + if positional_after_separator && !rest.is_empty() { + let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); + return Ok(CliAction::Prompt { + prompt: rest.join(" "), + model, + output_format, + allowed_tools, + permission_mode, + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }); + } + + if rest.is_empty() { + let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); + let stdin_is_terminal = std::io::stdin().is_terminal(); + if compact && stdin_is_terminal { + return Err(compact_missing_argument_error()); + } + // 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 !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, + base_commit, + reasoning_effort, + allow_broad_cwd, + }); + } + if compact { + return Err(compact_missing_argument_error()); + } + // Non-TTY stdin with no piped content: refuse to start the interactive + // REPL (it would block forever waiting for input that will never arrive). + // (#696: emit a typed error instead of hanging indefinitely) + // Skip this guard in test builds (parse_args tests run in non-TTY context). + #[cfg(not(test))] + // #746: newline before remediation so split_error_hint populates hint field + return Err("interactive_only: claw requires an interactive terminal.\nStdin is not a TTY and no prompt was provided — pipe a prompt with `echo 'task' | claw` or run `claw` in an interactive terminal.".into()); + } + return Ok(CliAction::Repl { + model, + allowed_tools, + permission_mode, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }); + } + if let Some(action) = parse_local_help_action(&rest, output_format) { + return action; + } + if rest.first().map(String::as_str) == Some("--resume") { + return parse_resume_args(&rest[1..], output_format, allow_broad_cwd); + } + if rest.first().map(String::as_str) == Some("resume") { + return parse_resume_args(&rest[1..], output_format, allow_broad_cwd); + } + // #696: `claw compact` is the bare name of the interactive `/compact` + // slash command, not a prompt. When extra args such as `--help` appear + // after the word `compact`, the generic prompt fallback used to send + // `compact --help` to provider startup and could hang under closed stdin / + // JSON output. Fail closed before any provider, prompt, TUI, or spinner + // startup. `claw --resume SESSION.jsonl /compact` remains the supported + // non-interactive session compaction path. + if rest.first().map(String::as_str) == Some("compact") { + return Err(compact_interactive_only_error()); + } + 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; + } + + // Keep config-backed defaults lazy so pure-local JSON surfaces (notably + // `claw --output-format json config`) can report config warnings + // structurally without an earlier default-resolution load writing prose + // warnings to stderr. + let permission_mode = || permission_mode_override.unwrap_or_else(default_permission_mode); + let permission_mode_provenance = || { + permission_mode_override + .map(PermissionModeProvenance::from_flag) + .unwrap_or_else(permission_mode_provenance_for_current_dir) + }; + + // #98: --compact is only meaningful for prompt mode. When a known non-prompt + // subcommand is being dispatched, reject --compact so callers don't silently + // lose the flag. + if compact + && rest + .first() + .map(|s| s.as_str()) + .is_some_and(|s| s != "prompt") + { + // Allow compact for the default prompt fallback (unknown tokens). + // Only reject for known top-level subcommands that don't use compact. + let first = rest[0].as_str(); + if is_known_top_level_subcommand(first) && first != "prompt" { + return Err(format!( + "invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"\" or echo \"\" | claw --compact" + )); + } + } + + 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. + // `plugin` (singular) and `marketplace` are aliases for `plugins`. + // All three must route to the same local handler so that no form + // falls through to the LLM/prompt path. + "plugins" | "plugin" | "marketplace" => { + let tail = &rest[1..]; + let action = tail.first().cloned(); + let target = tail.get(1).cloned(); + if tail.len() > 2 { + // #797: append \n usage hint so split_error_hint extracts it (parity with #791 config fix) + return Err(format!( + "unexpected extra arguments after `claw {} {}`: {}\nUsage: claw plugins [list|show |install |enable |disable |uninstall |update |help]", + rest[0], + 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 { + // #791: append \n hint so split_error_hint extracts it and hint is non-null + return Err(format!( + "unexpected extra arguments after `claw config {}`: {}\nUsage: claw config [env|hooks|model|plugins|mcp|settings]", + 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 { + // #3129: keep malformed `diff ... --output-format json` on the + // parser/error path, not the prompt/TUI fallback. The newline + // before Usage is part of the JSON hint contract. + return Err(unexpected_diff_args_error(&rest[1..])); + } + Ok(CliAction::Diff { output_format }) + } + // `claw permissions ` falls through to the LLM when called + // with a subcommand argument because parse_single_word_command_alias + // only intercepts the bare single-word form. Catch all multi-word + // forms here and return a structured guidance error so no network + // call or session is created. + "permissions" => Err( + "`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]" + .to_string(), + ), + // #767: `claw session bogus` bypassed parse_single_word_command_alias (rest.len()>1), + // had no match arm, and fell to CliAction::Prompt — reaching the credential gate + // instead of a structured error. Mirror the guard on `permissions`. + "session" => { + // #449: `claw session list` is a pure local filesystem read that + // requires no API credentials. Route directly to SessionList instead + // of falling through to the resume/auth path. + if rest.get(1).map(|s| s.as_str()) == Some("list") { + Ok(CliAction::SessionList { output_format }) + } else { + let action_hint = rest.get(1).map_or(String::new(), |a| format!(" (got: `{a}`)" )); + Err(format!( + "interactive_only: `claw session` is a slash command{action_hint}.\nUse `claw --resume SESSION.jsonl /session ` or start `claw` and run `/session [list|exists|switch|fork|delete]`." + )) + } + } + // #770: same fallthrough gap as #767 — these slash commands had no multi-arg match arm + // and fell to CliAction::Prompt reaching the credential gate when called with args. + "cost" => Err( + "interactive_only: `claw cost` is a slash command.\nUse `claw --resume SESSION.jsonl /cost` or start `claw` and run `/cost`." + .to_string(), + ), + "clear" => Err( + "interactive_only: `claw clear` is a slash command.\nUse `claw --resume SESSION.jsonl /clear [--confirm]` or start `claw` and run `/clear`." + .to_string(), + ), + "memory" => Err( + "interactive_only: `claw memory` is a slash command.\nStart `claw` and run `/memory` inside the REPL." + .to_string(), + ), + "ultraplan" => Err( + "interactive_only: `claw ultraplan` is a slash command.\nStart `claw` and run `/ultraplan` inside the REPL." + .to_string(), + ), + "model" | "models" => { + let tail = &rest[1..]; + let action = tail.first().cloned(); + if tail.len() > 1 { + return Err(format!( + "unexpected extra arguments after `claw {} {}`: {}\nUsage: claw {} [help] [--output-format json]", + rest[0], + tail[0], + tail[1..].join(" "), + rest[0] + )); + } + Ok(CliAction::Models { + action, + output_format, + }) + } + // #771: usage/stats/fork are slash-only verbs with no multi-arg match arms + "usage" => Err( + "interactive_only: `claw usage` is a slash command.\nUse `claw --resume SESSION.jsonl /usage` or start `claw` and run `/usage`." + .to_string(), + ), + "stats" => Err( + "interactive_only: `claw stats` is a slash command.\nUse `claw --resume SESSION.jsonl /stats` or start `claw` and run `/stats`." + .to_string(), + ), + "fork" => Err( + "interactive_only: `claw fork` is a slash command.\nStart `claw` and run `/session fork [branch-name]` inside the REPL." + .to_string(), + ), + "skills" => { + let args = join_optional_args(&rest[1..]); + if let Some(action) = args.as_deref() { + let first_word = action.split_whitespace().next().unwrap_or(action); + if matches!(first_word, "add") { + return Err(format!( + "unsupported skills action: {first_word}. Supported actions: list, show , install , uninstall , help, or [args]" + )); + } + } + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode: permission_mode(), + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }), + SkillSlashDispatch::Local => Ok(CliAction::Skills { + args, + output_format, + }), + } + } + "settings" => { + let tail = &rest[1..]; + if tail.is_empty() { + Ok(CliAction::Config { + section: Some("settings".to_string()), + output_format, + }) + } else if tail.len() == 1 && matches!(tail[0].as_str(), "help" | "--help" | "-h") { + Ok(CliAction::HelpTopic { + topic: LocalHelpTopic::Settings, + output_format, + }) + } else { + Err(format!( + "unexpected extra arguments after `claw settings`: {}\nUsage: claw settings [help] [--output-format json]", + tail.join(" ") + )) + } + } + "system-prompt" => parse_system_prompt_args(&rest[1..], model, output_format), + "acp" => parse_acp_args(&rest[1..], output_format), + "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), + "init" => { + // #771: extra positional args to `init` were silently ignored — now rejected + if rest.len() > 1 { + let extra = rest[1..].join(" "); + return Err(format!( + "unexpected extra arguments after `claw init`: {extra}\nUsage: claw init [--cwd ] [--date ] [--session ]" + )); + } + Ok(CliAction::Init { output_format }) + } + "setup" => { + if rest.len() > 1 { + let extra = rest[1..].join(" "); + return Err(format!( + "unexpected extra arguments after `claw setup`: {extra}\nUsage: claw setup" + )); + } + Ok(CliAction::Setup { output_format }) + } + "export" => parse_export_args(&rest[1..], output_format), + "prompt" => { + let mut read_stdin = false; + let prompt_parts = rest[1..] + .iter() + .filter_map(|arg| { + if matches!(arg.as_str(), "--stdin" | "--prompt-stdin") { + read_stdin = true; + None + } else { + Some(arg.as_str()) + } + }) + .collect::>(); + let positional_prompt = prompt_parts.join(" "); + let stdin_prompt = if read_stdin || positional_prompt.trim().is_empty() { + read_piped_stdin() + } else { + None + }; + let prompt = if read_stdin { + merge_prompt_with_stdin(&positional_prompt, stdin_prompt.as_deref()) + } else { + stdin_prompt + .as_deref() + .map(str::trim) + .unwrap_or(&positional_prompt) + .to_string() + }; + if prompt.trim().is_empty() { + // #750/#823/#423: provide error_kind-compatible prefix + \n for hint extraction. + return Err("missing_prompt: prompt subcommand requires a prompt string. +Usage: claw prompt or echo '' | claw prompt".to_string()); + } + Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode: 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_provenance(), + compact, + base_commit, + reasoning_effort, + allow_broad_cwd, + ), + other => { + if !compact + && !other.starts_with('-') + && looks_like_subcommand_typo(other) + && (rest.len() == 1 + || (output_format == CliOutputFormat::Json && model_flag_raw.is_none())) + { + // #825/#826: emit command_not_found before provider startup for + // command-shaped tokens that do not match known subcommands. + // Text-mode multi-word prompt shorthand remains available, but + // JSON-mode automation must not turn an unknown command into a + // credential-gated prompt request. + let mut message = format!("command_not_found: unknown subcommand: {other}."); + if let Some(suggestions) = suggest_similar_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() { + // #798: add \n hint so split_error_hint extracts it (was empty_prompt + null) + return Err( + "empty prompt: provide a subcommand or a non-empty prompt string.\nUsage: claw or claw -p . Run `claw --help` for the full list." + .to_string(), + ); + } + Ok(CliAction::Prompt { + prompt: joined, + model, + output_format, + allowed_tools, + permission_mode: permission_mode(), + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }) + } + } +} + +pub(crate) fn parse_local_help_action( + rest: &[String], + output_format: CliOutputFormat, +) -> Option> { + if rest.is_empty() { + return None; + } + if !rest.iter().any(|a| is_help_flag(a)) { + return None; + } + + let topic = match rest[0].as_str() { + "status" => LocalHelpTopic::Status, + "sandbox" => LocalHelpTopic::Sandbox, + "doctor" => LocalHelpTopic::Doctor, + "acp" => LocalHelpTopic::Acp, + "init" => LocalHelpTopic::Init, + "setup" => LocalHelpTopic::Setup, + "state" => LocalHelpTopic::State, + "export" => LocalHelpTopic::Export, + "version" => LocalHelpTopic::Version, + "system-prompt" => LocalHelpTopic::SystemPrompt, + "dump-manifests" => LocalHelpTopic::DumpManifests, + "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, + "resume" | "--resume" => LocalHelpTopic::Resume, + "session" => LocalHelpTopic::Session, + "compact" => LocalHelpTopic::Compact, + "model" | "models" => LocalHelpTopic::Model, + "settings" => LocalHelpTopic::Settings, + _ => return None, + }; + let has_non_help = rest[1..].iter().any(|a| !is_help_flag(a)); + if has_non_help { + return None; + } + Some(Ok(CliAction::HelpTopic { + topic, + output_format, + })) +} + +pub(crate) fn is_help_flag(value: &str) -> bool { + matches!(value, "--help" | "-h") +} + +pub(crate) fn parse_single_word_command_alias( + rest: &[String], + model: &str, + // #148: raw --model flag input for status provenance. None = no flag. + model_flag_raw: Option<&str>, + permission_mode_override: Option, + output_format: CliOutputFormat, + allowed_tools: Option, +) -> Option> { + if rest.is_empty() { + return None; + } + + // Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself + // or --help / -h as a suffix. Any other suffix args are unrecognized. + let verb = &rest[0]; + let is_diagnostic = matches!( + verb.as_str(), + "help" | "version" | "status" | "sandbox" | "doctor" | "setup" | "state" + ); + + if is_diagnostic && rest.len() > 1 { + // Diagnostic verb with trailing args: reject unrecognized suffix + let all_extra_are_help = rest[1..].iter().all(|a| is_help_flag(a)); + if all_extra_are_help { + // "doctor --help -h" is valid, routed to parse_local_help_action() instead + return None; + } + // #720: `claw help ` — when the verb is "help" and exactly one + // non-flag argument follows, try to route to the topic's handler. + if verb == "help" && rest.len() == 2 { + let topic_name = rest[1].as_str(); + let topic = match topic_name { + "status" => Some(LocalHelpTopic::Status), + "sandbox" => Some(LocalHelpTopic::Sandbox), + "doctor" => Some(LocalHelpTopic::Doctor), + "acp" => Some(LocalHelpTopic::Acp), + "init" => Some(LocalHelpTopic::Init), + "setup" => Some(LocalHelpTopic::Setup), + "state" => Some(LocalHelpTopic::State), + "export" => Some(LocalHelpTopic::Export), + "version" => Some(LocalHelpTopic::Version), + "system-prompt" => Some(LocalHelpTopic::SystemPrompt), + "dump-manifests" => Some(LocalHelpTopic::DumpManifests), + "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), + "resume" => Some(LocalHelpTopic::Resume), + "session" => Some(LocalHelpTopic::Session), + "compact" => Some(LocalHelpTopic::Compact), + "agents" | "agent" => Some(LocalHelpTopic::Agents), + "skills" | "skill" => Some(LocalHelpTopic::Skills), + "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), + "mcp" => Some(LocalHelpTopic::Mcp), + "config" => Some(LocalHelpTopic::Config), + "model" | "models" => Some(LocalHelpTopic::Model), + "settings" => Some(LocalHelpTopic::Settings), + "diff" => Some(LocalHelpTopic::Diff), + _ => None, + }; + if let Some(t) = topic { + return Some(Ok(CliAction::HelpTopic { + topic: t, + output_format, + })); + } + // Unknown topic: fall through to generic help. + return Some(Ok(CliAction::Help { output_format })); + } + // Unrecognized suffix like "--json" + let mut msg = format!( + "unrecognized argument `{}` for subcommand `{}`", + rest[1], verb + ); + // #152: common mistake — users type `--json` expecting JSON output. + // Hint at the correct flag so they don't have to re-read --help. + if rest[1] == "--json" { + msg.push_str("\nDid you mean `--output-format json`?"); + } else { + // #752: generic fallback hint so cli_parse errors always have non-null hint + msg.push_str(&format!("\nRun `claw {} --help` for usage.", verb)); + } + return Some(Err(msg)); + } + + // #720: `claw help ` — when `help` is the verb and a topic follows, + // try to route to the topic's help handler instead of erroring. + if rest.len() == 2 && rest[0] == "help" { + let topic_name = rest[1].as_str(); + let topic = match topic_name { + "status" => Some(LocalHelpTopic::Status), + "sandbox" => Some(LocalHelpTopic::Sandbox), + "doctor" => Some(LocalHelpTopic::Doctor), + "acp" => Some(LocalHelpTopic::Acp), + "init" => Some(LocalHelpTopic::Init), + "setup" => Some(LocalHelpTopic::Setup), + "state" => Some(LocalHelpTopic::State), + "export" => Some(LocalHelpTopic::Export), + "version" => Some(LocalHelpTopic::Version), + "system-prompt" => Some(LocalHelpTopic::SystemPrompt), + "dump-manifests" => Some(LocalHelpTopic::DumpManifests), + "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), + "resume" => Some(LocalHelpTopic::Resume), + "session" => Some(LocalHelpTopic::Session), + "compact" => Some(LocalHelpTopic::Compact), + "agents" | "agent" => Some(LocalHelpTopic::Agents), + "skills" | "skill" => Some(LocalHelpTopic::Skills), + "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), + "mcp" => Some(LocalHelpTopic::Mcp), + "config" => Some(LocalHelpTopic::Config), + "model" | "models" => Some(LocalHelpTopic::Model), + "settings" => Some(LocalHelpTopic::Settings), + "diff" => Some(LocalHelpTopic::Diff), + _ => None, + }; + if let Some(t) = topic { + return Some(Ok(CliAction::HelpTopic { + topic: t, + output_format, + })); + } + // Unknown topic falls through to the generic help action. + return Some(Ok(CliAction::Help { output_format })); + } + + // #453: fire guard for multi-word CLI subcommands too (claw cost list, claw model list, etc.) + // For slash commands that are commonly used as prompts (explain, cost, tokens, etc.), + // only fire the guard when there's exactly one token. + if rest.is_empty() { + return None; + } + // Known CLI subcommands that don't accept additional arguments + const CLI_SUBCOMMANDS: &[&str] = &[ + "help", "version", "status", "sandbox", "doctor", "state", "config", "diff", + ]; + if rest.len() > 1 && !CLI_SUBCOMMANDS.contains(&rest[0].as_str()) { + return None; + } + + match rest[0].as_str() { + "help" => Some(Ok(CliAction::Help { output_format })), + "version" => Some(Ok(CliAction::Version { output_format })), + "status" => Some(Ok(CliAction::Status { + model: model.to_string(), + model_flag_raw: model_flag_raw.map(str::to_string), // #148 + permission_mode: permission_mode_override + .map(PermissionModeProvenance::from_flag) + .unwrap_or_else(permission_mode_provenance_for_current_dir), + output_format, + allowed_tools, + })), + "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), + "doctor" => Some(Ok(CliAction::Doctor { + output_format, + permission_mode: permission_mode_override + .map(PermissionModeProvenance::from_flag) + .unwrap_or_else(permission_mode_provenance_for_current_dir), + })), + "setup" => Some(Ok(CliAction::Setup { output_format })), + "state" => Some(Ok(CliAction::State { output_format })), + // #146: let `config` and `diff` fall through to parse_subcommand + // where they are wired as pure-local introspection, instead of + // producing the "is a slash command" guidance. Zero-arg cases + // reach parse_subcommand too via this None. + "config" | "diff" => None, + other => bare_slash_command_guidance(other).map(Err), + } +} + +pub(crate) fn bare_slash_command_guidance(command_name: &str) -> Option { + if matches!( + command_name, + "dump-manifests" + | "bootstrap-plan" + | "agents" + | "mcp" + | "plugin" + | "plugins" + | "marketplace" + | "skills" + | "system-prompt" + | "init" + | "prompt" + | "export" + ) { + return None; + } + let slash_command = slash_command_specs() + .iter() + // #772: check both spec.name and spec.aliases for command-line invocations + .find(|spec| spec.name == command_name || spec.aliases.contains(&command_name))?; + let canonical_name = slash_command.name; + // #745: newline before remediation text so split_error_hint populates hint field + let guidance = if slash_command.resume_supported { + format!( + "`claw {command_name}` is a slash command.\nUse `claw --resume SESSION.jsonl /{canonical_name}` or start `claw` and run `/{canonical_name}`." + ) + } else { + format!( + "`claw {command_name}` is a slash command.\nStart `claw` and run `/{canonical_name}` inside the REPL." + ) + }; + // #772: help text still mentions the alias, but the remediation shows canonical form + Some(guidance) +} + +pub(crate) fn removed_auth_surface_error(command_name: &str) -> String { + // #765: two-line format so split_error_hint() extracts hint into JSON envelope + format!( + "`claw {command_name}` has been removed.\nSet ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead." + ) +} + +pub(crate) fn parse_acp_args( + args: &[String], + output_format: CliOutputFormat, +) -> Result { + match args { + [] => Ok(CliAction::Acp { output_format }), + [subcommand] if subcommand == "serve" => Ok(CliAction::Acp { output_format }), + _ => Err(String::from( + "unsupported_acp_invocation: unsupported ACP invocation. Use `claw acp` or `claw acp serve`.\nACP/Zed editor integration is not implemented yet; `claw acp serve` reports status only.", + )), + } +} + +pub(crate) fn join_optional_args(args: &[String]) -> Option { + let joined = args.join(" "); + let trimmed = joined.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] +pub(crate) fn parse_direct_slash_cli_action( + rest: &[String], + model: String, + output_format: CliOutputFormat, + allowed_tools: Option, + permission_mode: PermissionModeProvenance, + compact: bool, + base_commit: Option, + reasoning_effort: Option, + allow_broad_cwd: bool, +) -> Result { + let raw = rest.join(" "); + match SlashCommand::parse(&raw) { + Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }), + Ok(Some(SlashCommand::Status)) => Ok(CliAction::Status { + model, + model_flag_raw: None, + permission_mode, + output_format, + allowed_tools, + }), + Ok(Some(SlashCommand::Sandbox)) => Ok(CliAction::Sandbox { output_format }), + Ok(Some(SlashCommand::Diff)) => Ok(CliAction::Diff { output_format }), + Ok(Some(SlashCommand::Version)) => Ok(CliAction::Version { output_format }), + Ok(Some(SlashCommand::Doctor)) => Ok(CliAction::Doctor { + output_format, + permission_mode, + }), + Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { + args, + output_format, + }), + Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp { + args: match (action, target) { + (None, None) => None, + (Some(action), None) => Some(action), + (Some(action), Some(target)) => Some(format!("{action} {target}")), + (None, Some(target)) => Some(target), + }, + output_format, + }), + Ok(Some(SlashCommand::Skills { args })) => { + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode: permission_mode.mode, + compact, + base_commit, + reasoning_effort: reasoning_effort.clone(), + allow_broad_cwd, + }), + SkillSlashDispatch::Local => Ok(CliAction::Skills { + args, + output_format, + }), + } + } + Ok(Some(SlashCommand::Unknown(name))) => { + // #828: /approve and /deny are valid REPL-only slash commands that + // are not SlashCommand enum variants (they require an active tool + // call in the REPL to be meaningful). Emit interactive_only so + // machine consumers see the correct error_kind instead of + // unknown_slash_command. + if matches!(name.as_str(), "approve" | "yes" | "y" | "deny" | "no" | "n") { + Err(format!( + "interactive_only: /{name} requires an active tool call in the REPL.\nStart `claw` and use /{name} to approve or deny a pending tool execution." + )) + } else { + Err(format_unknown_direct_slash_command(&name)) + } + } + Ok(Some(command)) => Err({ + let _ = command; + let command_name = &rest[0]; + // #829: only suggest --resume when the command is actually + // resume-safe. Non-resume-safe commands (e.g. /commit, /pr) + // previously suggested --resume, which just re-triggered + // interactive_only on a second invocation. + let bare_name = command_name.trim_start_matches('/'); + let is_resume_safe = commands::resume_supported_slash_commands() + .iter() + .any(|spec| spec.name == bare_name); + if is_resume_safe { + format!( + // #738: newline before remediation so split_error_hint populates hint field + "interactive_only: slash command {command_name} requires a live session.\nStart `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}`.", + latest = LATEST_SESSION_REFERENCE, + ) + } else { + format!( + "interactive_only: slash command {command_name} requires a live REPL session.\nStart `claw` and run it there." + ) + } + }), + Ok(None) => Err(format!("unknown subcommand: {}", rest[0])), + Err(error) => Err(error.to_string()), + } +} + +pub(crate) fn format_unknown_option(option: &str) -> String { + if option == "--" { + return "end_of_flags: `--` terminates flag parsing. Pass literal prompt text after it, for example `claw -- \"-literal prompt\"`.\nRun `claw --help` for usage.".to_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 { + // #827: prefix with classifier-friendly token so classify_error_kind + // returns "unknown_slash_command" instead of the opaque fallback. + let mut message = + format!("unknown_slash_command: 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 { + // #827: prefix with classifier-friendly token so classify_error_kind + // can return "unknown_slash_command" instead of the opaque fallback. + let mut message = format!("unknown_slash_command: Unknown slash command: /{name}"); + if let Some(suggestions) = render_suggestion_line("Did you mean", &suggest_slash_commands(name)) + { + message.push('\n'); + message.push_str(&suggestions); + } + if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { + message.push('\n'); + message.push_str(note); + } + message.push_str("\n Help /help lists available slash commands"); + message +} + +pub(crate) fn omc_compatibility_note_for_unknown_slash_command(name: &str) -> Option<&'static str> { + name.starts_with("oh-my-claudecode:") + .then_some( + "Compatibility note: `/oh-my-claudecode:*` is a Claude Code/OMC plugin command. `claw` does not yet load plugin slash commands, Claude statusline stdin, or OMC session hooks.", + ) +} + +pub(crate) fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option { + (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),)) +} + +pub(crate) fn suggest_slash_commands(input: &str) -> Vec { + let mut candidates = slash_command_specs() + .iter() + .flat_map(|spec| { + std::iter::once(spec.name) + .chain(spec.aliases.iter().copied()) + .map(|name| format!("/{name}")) + .collect::>() + }) + .collect::>(); + candidates.sort(); + candidates.dedup(); + let candidate_refs = candidates.iter().map(String::as_str).collect::>(); + ranked_suggestions(input.trim_start_matches('/'), &candidate_refs) + .into_iter() + .map(str::to_string) + .collect() +} + +pub(crate) fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> { + ranked_suggestions(input, candidates).into_iter().next() +} + +pub(crate) fn suggest_similar_subcommand(input: &str) -> Option> { + const KNOWN_SUBCOMMANDS: &[&str] = &[ + "help", + "version", + "status", + "sandbox", + "doctor", + "setup", + "state", + "dump-manifests", + "bootstrap-plan", + "agents", + "mcp", + "skills", + "system-prompt", + "acp", + "init", + "export", + "prompt", + "list", + ]; + + let normalized_input = input.to_ascii_lowercase(); + let mut ranked = KNOWN_SUBCOMMANDS + .iter() + .filter_map(|candidate| { + let normalized_candidate = candidate.to_ascii_lowercase(); + let distance = levenshtein_distance(&normalized_input, &normalized_candidate); + let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4; + let substring_match = normalized_candidate.contains(&normalized_input) + || normalized_input.contains(&normalized_candidate); + ((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate)) + }) + .collect::>(); + ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); + ranked.dedup_by(|left, right| left.1 == right.1); + let suggestions = ranked + .into_iter() + .map(|(_, candidate)| candidate.to_string()) + .take(3) + .collect::>(); + (!suggestions.is_empty()).then_some(suggestions) +} + +pub(crate) fn common_prefix_len(left: &str, right: &str) -> usize { + left.chars() + .zip(right.chars()) + .take_while(|(l, r)| l == r) + .count() +} + +pub(crate) fn looks_like_subcommand_typo(input: &str) -> bool { + !input.is_empty() + && input + .chars() + .all(|ch| ch.is_ascii_alphabetic() || ch == '-') +} + +pub(crate) fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> { + let normalized_input = input.trim_start_matches('/').to_ascii_lowercase(); + let mut ranked = candidates + .iter() + .filter_map(|candidate| { + let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase(); + let distance = levenshtein_distance(&normalized_input, &normalized_candidate); + let prefix_bonus = usize::from( + !(normalized_candidate.starts_with(&normalized_input) + || normalized_input.starts_with(&normalized_candidate)), + ); + let score = distance + prefix_bonus; + (score <= 4).then_some((score, *candidate)) + }) + .collect::>(); + ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); + ranked + .into_iter() + .map(|(_, candidate)| candidate) + .take(3) + .collect() +} + +pub(crate) fn levenshtein_distance(left: &str, right: &str) -> usize { + if left.is_empty() { + return right.chars().count(); + } + if right.is_empty() { + return left.chars().count(); + } + + let right_chars = right.chars().collect::>(); + let mut previous = (0..=right_chars.len()).collect::>(); + let mut current = vec![0; right_chars.len() + 1]; + + for (left_index, left_char) in left.chars().enumerate() { + current[0] = left_index + 1; + for (right_index, right_char) in right_chars.iter().enumerate() { + let substitution_cost = usize::from(left_char != *right_char); + current[right_index + 1] = (previous[right_index + 1] + 1) + .min(current[right_index] + 1) + .min(previous[right_index] + substitution_cost); + } + previous.clone_from(¤t); + } + + previous[right_chars.len()] +} + +/// Validate model syntax at parse time. +/// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern. +/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars. +pub(crate) fn validate_model_syntax(model: &str) -> Result<(), String> { + let trimmed = model.trim(); + // Ollama models use names like "qwen3:8b" that don't match provider/model + // syntax. Skip strict validation when OLLAMA_HOST is configured. + if std::env::var_os("OLLAMA_HOST").is_some() { + if trimmed.is_empty() { + return Err("invalid model syntax: model string cannot be empty.\nUsage: --model e.g. --model qwen3:8b".to_string()); + } + return Ok(()); + } + if trimmed.is_empty() { + return Err("invalid model syntax: model string cannot be empty.\nUsage: --model e.g. --model anthropic/claude-opus-4-7".to_string()); + } + // Check for spaces (malformed) + if trimmed.contains(' ') { + return Err(format!( + "invalid model syntax: '{}' contains spaces.\nUse provider/model format (e.g., anthropic/claude-opus-4-7) or a known alias.", + trimmed + )); + } + if is_bare_provider_model(trimmed) { + return Ok(()); + } + if is_local_openai_model_syntax(trimmed) { + return Ok(()); + } + // Check provider/model format: provider_id/model_id + let parts: Vec<&str> = trimmed.split('/').collect(); + if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + // #154: hint if the model looks like it belongs to a different provider + let mut err_msg = format!( + "invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-7)", + trimmed + ); + if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") { + err_msg.push_str("\nDid you mean `openai/"); + err_msg.push_str(trimmed); + err_msg.push_str("`? (Requires OPENAI_API_KEY env var)"); + } else if trimmed.starts_with("qwen") && trimmed.contains(':') { + err_msg.push_str("\nFor a local Ollama model, set `OPENAI_BASE_URL=http://127.0.0.1:11434/v1` before using tagged names like `"); + err_msg.push_str(trimmed); + err_msg.push_str("`."); + } else if trimmed.starts_with("qwen") { + err_msg.push_str("\nDid you mean `qwen/"); + err_msg.push_str(trimmed); + err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)"); + } else if trimmed.starts_with("grok") { + err_msg.push_str("\nDid you mean `xai/"); + err_msg.push_str(trimmed); + err_msg.push_str("`? (Requires XAI_API_KEY env var)"); + } + return Err(err_msg); + } + Ok(()) +} + +pub(crate) fn parse_permission_mode_arg(value: &str) -> Result { + normalize_permission_mode(value) + .ok_or_else(|| { + format!( + "invalid_permission_mode: unsupported permission mode '{value}'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access" + ) + }) + .map(permission_mode_from_label) +} + +pub(crate) fn permission_mode_from_label(mode: &str) -> PermissionMode { + match mode { + "read-only" => PermissionMode::ReadOnly, + "workspace-write" => PermissionMode::WorkspaceWrite, + "danger-full-access" => PermissionMode::DangerFullAccess, + other => panic!("unsupported permission mode label: {other}"), + } +} + +pub(crate) fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode { + match mode { + ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly, + ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite, + ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess, + } +} + +pub(crate) fn default_permission_mode() -> PermissionMode { + permission_mode_provenance_for_current_dir().mode +} + +pub(crate) fn config_permission_mode_for_current_dir() -> Option { + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + loader + .load() + .ok()? + .permission_mode() + .map(permission_mode_from_resolved) +} + +pub(crate) fn provider_label(kind: ProviderKind) -> &'static str { + match kind { + ProviderKind::Anthropic => "anthropic", + ProviderKind::Xai => "xai", + ProviderKind::OpenAi => "openai", + } +} + +pub(crate) fn format_connected_line(model: &str) -> String { + let provider = provider_label(detect_provider_kind(model)); + format!("Connected: {model} via {provider}") +} + +pub(crate) fn parse_system_prompt_args( + args: &[String], + model: String, + output_format: CliOutputFormat, +) -> Result { + let mut cwd = env::current_dir().map_err(|error| error.to_string())?; + let mut date = DEFAULT_DATE.to_string(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--cwd" => { + let value = args.get(index + 1).ok_or_else(|| { + "missing_flag_value: missing value for --cwd.\nUsage: --cwd ".to_string() + })?; + cwd = PathBuf::from(value); + // #99: validate --cwd path exists and is a directory + if !cwd.exists() { + return Err(format!( + "invalid_cwd: path '{value}' does not exist.\nUsage: claw system-prompt --cwd " + )); + } + if !cwd.is_dir() { + return Err(format!( + "invalid_cwd: path '{value}' is not a directory.\nUsage: claw system-prompt --cwd " + )); + } + index += 2; + } + "--date" => { + let value = args.get(index + 1).ok_or_else(|| { + "missing_flag_value: missing value for --date.\nUsage: --date " + .to_string() + })?; + // #99: validate --date is a plausible date string (no newlines, reasonable length) + if value.contains('\n') || value.contains('\r') { + return Err(format!( + "invalid_flag_value: --date value contains invalid characters.\nUsage: --date " + )); + } + if value.len() > 20 { + return Err(format!( + "invalid_flag_value: --date value is too long ({len} chars, expected YYYY-MM-DD).\nUsage: --date ", + len = value.len() + )); + } + date.clone_from(value); + index += 2; + } + + other => { + // #152: hint `--output-format json` when user types `--json`. + // #790: use unknown_option: prefix + \n hint so classify_error_kind returns + // unknown_option and split_error_hint extracts the remediation text. + let hint = if other == "--json" { + "Did you mean `--output-format json`? Usage: claw system-prompt [--cwd ] [--date ] [--output-format text|json]".to_string() + } else { + "Usage: claw system-prompt [--cwd ] [--date ] [--output-format text|json]".to_string() + }; + return Err(format!( + "unknown_option: unknown system-prompt option: {other}.\n{hint}" + )); + } + } + } + + Ok(CliAction::PrintSystemPrompt { + cwd, + date, + model, + output_format, + }) +} + +pub(crate) fn parse_export_args( + args: &[String], + output_format: CliOutputFormat, +) -> Result { + let mut session_reference = LATEST_SESSION_REFERENCE.to_string(); + let mut output_path: Option = None; + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--session" => { + let value = args + .get(index + 1) + .ok_or_else(|| "missing_flag_value: missing value for --session.\nUsage: --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_flag_value: missing value for {}.\nUsage: claw export [PATH] [--session SESSION] [--output PATH]", 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_option: unknown export option: {other}.\nRun `claw export --help` for usage.")); + } + other if output_path.is_none() => { + output_path = Some(PathBuf::from(other)); + index += 1; + } + other => { + // #784: use typed prefix so classify_error_kind returns unexpected_extra_args + return Err(format!("unexpected_extra_args: unexpected export argument: {other}.\nUsage: claw export [PATH] [--session SESSION] [--output PATH]")); + } + } + } + + Ok(CliAction::Export { + session_reference, + output_path, + output_format, + }) +} + +pub(crate) fn parse_dump_manifests_args( + args: &[String], + output_format: CliOutputFormat, +) -> Result { + let mut manifests_dir: Option = None; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == "--manifests-dir" { + let value = args + .get(index + 1) + .ok_or_else(|| String::from("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir [--output-format json]"))?; + manifests_dir = Some(PathBuf::from(value)); + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--manifests-dir=") { + if value.is_empty() { + // #786: empty --manifests-dir= is also a missing value + return Err(String::from("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir [--output-format json]")); + } + manifests_dir = Some(PathBuf::from(value)); + index += 1; + continue; + } + return Err(format!("unknown_option: unknown dump-manifests option: {arg}.\nRun `claw dump-manifests --help` for usage.")); + } + + Ok(CliAction::DumpManifests { + output_format, + manifests_dir, + }) +} + +pub(crate) fn parse_resume_args( + args: &[String], + output_format: CliOutputFormat, + allow_broad_cwd: bool, +) -> Result { + let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() { + None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]), + Some(first) if looks_like_slash_command_token(first) => { + (PathBuf::from(LATEST_SESSION_REFERENCE), args) + } + Some(first) => (PathBuf::from(first), &args[1..]), + }; + let mut commands = Vec::new(); + let mut current_command = String::new(); + + for token in command_tokens { + if token.trim_start().starts_with('/') { + if resume_command_can_absorb_token(¤t_command, token) { + current_command.push(' '); + current_command.push_str(token); + continue; + } + if !current_command.is_empty() { + commands.push(current_command); + } + current_command = String::from(token.as_str()); + continue; + } + + if current_command.is_empty() { + // #768: typed prefix + \n hint so split_error_hint() extracts hint into JSON envelope + return Err(format!( + "invalid_resume_argument: `{token}` is not a slash command.\nUsage: claw --resume / (e.g. /compact, /status)" + )); + } + + current_command.push(' '); + current_command.push_str(token); + } + + if !current_command.is_empty() { + commands.push(current_command); + } + + Ok(CliAction::ResumeSession { + session_path, + commands, + output_format, + allow_broad_cwd, + }) +} + +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)) +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn resume_session( + session_path: &Path, + commands: &[String], + output_format: CliOutputFormat, +) { + let session_reference = session_path.display().to_string(); + let (handle, session) = match load_session_reference(&session_reference) { + Ok(loaded) => loaded, + Err(error) => { + if output_format == CliOutputFormat::Json { + // #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, inline_hint) = split_error_hint(&full_message); + // #787: fall back to kind-derived hint when message has no \n delimiter + let hint = + inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from)); + let sessions_dir = sessions_dir().ok().map(|path| path.display().to_string()); + // #819: JSON mode resume errors go to stdout for parity with other + // non-interactive command guards. + println!( + "{}", + serde_json::json!({ + "kind": kind, + "action": "restore", + "status": "error", + "error_kind": kind, + "error": short_reason, + "exit_code": 1, + "hint": hint, + "sessions_dir": sessions_dir, + }) + ); + } else { + eprintln!("failed to restore session: {error}"); + } + std::process::exit(1); + } + }; + let resolved_path = handle.path.clone(); + + if commands.is_empty() { + if output_format == CliOutputFormat::Json { + println!( + "{}", + serde_json::json!({ + "kind": "restored", + "action": "restore", + "status": "ok", + "session_id": session.session_id, + "path": handle.path.display().to_string(), + "message_count": session.messages.len(), + }) + ); + } else { + println!( + "Restored session from {} ({} messages).", + handle.path.display(), + session.messages.len() + ); + } + return; + } + + let mut session = session; + for raw_command in commands { + // Intercept spec commands that have no parse arm before calling + // SlashCommand::parse — they return Err(SlashCommandParseError) which + // formats as the confusing circular "Did you mean /X?" message. + // STUB_COMMANDS covers both completions-filtered stubs and parse-less + // spec entries; treat both as unsupported in resume mode. + { + let cmd_root = raw_command + .trim_start_matches('/') + .split_whitespace() + .next() + .unwrap_or(""); + if STUB_COMMANDS.contains(&cmd_root) { + if output_format == CliOutputFormat::Json { + println!( + "{}", + serde_json::json!({ + "kind": "unsupported_command", + "action": "resume", + "status": "error", + "error_kind": "unsupported_command", + "error": format!("/{cmd_root} is not yet implemented in this build"), + "hint": "This command is not available in the current build. Update claw or use a different command.", + "exit_code": 2, + "command": raw_command, + }) + ); + } else { + eprintln!("/{cmd_root} is not yet implemented in this build"); + } + std::process::exit(2); + } + } + let command = match SlashCommand::parse(raw_command) { + Ok(Some(command)) => command, + Ok(None) => { + if output_format == CliOutputFormat::Json { + println!( + "{}", + serde_json::json!({ + "kind": "unsupported_resumed_command", + "action": "resume", + "status": "error", + "error_kind": "unsupported_resumed_command", + "error": format!("unsupported resumed command: {raw_command}"), + "hint": "This command cannot be used with --resume. Use it in an interactive REPL session instead.", + "exit_code": 2, + "command": raw_command, + }) + ); + } else { + eprintln!("unsupported resumed command: {raw_command}"); + } + std::process::exit(2); + } + Err(error) => { + if output_format == CliOutputFormat::Json { + println!( + "{}", + serde_json::json!({ + "kind": "cli_parse", + "action": "resume", + "status": "error", + "error_kind": "cli_parse", + "error": error.to_string(), + "hint": "Run `claw --help` for usage.", + "exit_code": 2, + "command": raw_command, + }) + ); + } else { + eprintln!("{error}"); + } + std::process::exit(2); + } + }; + match run_resume_command(&resolved_path, &session, &command) { + Ok(ResumeCommandOutcome { + session: next_session, + message, + json, + }) => { + session = next_session; + if output_format == CliOutputFormat::Json { + if let Some(value) = json { + println!( + "{}", + serde_json::to_string_pretty(&value) + .expect("resume command json output") + ); + } else if let Some(message) = message { + println!("{message}"); + } + } else if let Some(message) = message { + println!("{message}"); + } + } + Err(error) => { + if output_format == CliOutputFormat::Json { + // #776: classify + split so wrappers get typed fields instead of + // hardcoded "resume_command_error" + prose in the error field + let full_error = error.to_string(); + let error_kind = classify_error_kind(&full_error); + let (short_reason, inline_hint) = split_error_hint(&full_error); + // #787: fall back to kind-derived hint when error has no \n delimiter + let hint = inline_hint + .or_else(|| fallback_hint_for_error_kind(error_kind).map(String::from)); + println!( + "{}", + serde_json::json!({ + "kind": error_kind, + "action": "resume", + "status": "error", + "error_kind": error_kind, + "error": short_reason, + "hint": hint, + "exit_code": 2, + "command": raw_command, + }) + ); + } else { + eprintln!("{error}"); + } + std::process::exit(2); + } + } + } +} + +#[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 { + let estimated_cost = usage.estimate_cost_usd(); + format!( + "Cost + Input tokens {} + Output tokens {} + Cache create {} + Cache read {} + Total tokens {} + Estimated cost {}", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + usage.total_tokens(), + format_usd(estimated_cost.total_cost_usd()), + ) +} + +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}" + ) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct PromptHistoryEntry { + pub(crate) timestamp_ms: u64, + pub(crate) text: String, +} + +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 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 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/settings.json, .claw.json, .gitignore, and CLAUDE.md in the current project + Output per-artifact created/updated/partial/deferred/skipped status (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::Resume => format!( + "Resume\n Usage claw resume [session-path|session-id|{LATEST_SESSION_REFERENCE}] [/slash-command ...] [--output-format ]\n Alias claw --resume [session-path|session-id|{LATEST_SESSION_REFERENCE}]\n Purpose restore or inspect a saved session without starting a new provider turn\n Output session restore or resume-safe command output; missing sessions return session_not_found\n Formats text (default), json\n Related /resume · /session list · claw --resume {LATEST_SESSION_REFERENCE} /status" + ), + LocalHelpTopic::Session => "Session + Usage claw session --help [--output-format ] + Purpose show /session command guidance without loading config, credentials, or a session + Actions list · exists · switch · fork · delete + Direct use run /session in the REPL or claw --resume SESSION.jsonl /session + Formats text (default), json + Related claw resume · claw export · .claw/sessions/" + .to_string(), + LocalHelpTopic::Compact => "Compact + Usage claw compact --help [--output-format ] + Purpose show compaction guidance without loading config, credentials, or a session + Direct use run /compact in the REPL or claw --resume SESSION.jsonl /compact + Output compaction removes older tool-detail messages when the selected session is large enough + Formats text (default), json + Related claw resume · /compact · /status" + .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(), + LocalHelpTopic::Agents => commands::handle_agents_slash_command( + Some("--help"), + &env::current_dir().unwrap_or_default(), + ) + .unwrap_or_else(|_| "agents help unavailable".to_string()), + LocalHelpTopic::Skills => commands::handle_skills_slash_command( + Some("--help"), + &env::current_dir().unwrap_or_default(), + ) + .unwrap_or_else(|_| "skills help unavailable".to_string()), + LocalHelpTopic::Plugins => "Plugins + Usage claw plugins [list|show |install |enable |disable |uninstall ] + Purpose manage lifecycle of plugins that extend tool and hook capabilities + Formats text (default), json + Related /plugins · claw plugins --help" + .to_string(), + LocalHelpTopic::Mcp => "MCP Servers + Usage claw mcp [list|show ] [--output-format ] + Purpose inspect configured MCP servers and their connection status + Formats text (default), json + Related /mcp · claw mcp list" + .to_string(), + LocalHelpTopic::Config => "Config + Usage claw config [section] [--output-format ] + Purpose show effective runtime configuration (model, hooks, plugins, env) + Formats text (default), json + Related /config · claw doctor" + .to_string(), + LocalHelpTopic::Model => "Models + Usage claw models [help] [--output-format ] + Aliases claw model + Purpose show bounded local model command guidance without entering the REPL + Output supported model-selection surfaces and current config model value + Formats text (default), json + Related /model · claw config model · claw status" + .to_string(), + LocalHelpTopic::Settings => "Settings + Usage claw settings [help] [--output-format ] + Purpose show effective settings/config using the local config envelope + Output same as claw config settings; no provider request or session resume required + Formats text (default), json + Related claw config · claw doctor" + .to_string(), + LocalHelpTopic::Diff => "Diff + Usage claw diff [--output-format ] + Purpose show the diff of changes relative to the expected base commit + Formats text (default), json + Related /diff · ROADMAP #148" + .to_string(), + LocalHelpTopic::Setup => "Setup + Usage claw setup + Aliases /setup (inside the REPL) + Purpose run the interactive provider setup wizard to configure API key, model, and base URL + Output writes provider settings to ~/.claw/settings.json (0600 permissions) + Related /model · /config · claw doctor" + .to_string(), + } +} + +pub(crate) fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str { + match topic { + LocalHelpTopic::Status => "status", + LocalHelpTopic::Sandbox => "sandbox", + LocalHelpTopic::Doctor => "doctor", + LocalHelpTopic::Acp => "acp", + LocalHelpTopic::Init => "init", + LocalHelpTopic::State => "state", + LocalHelpTopic::Resume => "resume", + LocalHelpTopic::Session => "session", + LocalHelpTopic::Compact => "compact", + LocalHelpTopic::Export => "export", + LocalHelpTopic::Version => "version", + LocalHelpTopic::SystemPrompt => "system-prompt", + LocalHelpTopic::DumpManifests => "dump-manifests", + LocalHelpTopic::BootstrapPlan => "bootstrap-plan", + LocalHelpTopic::Agents => "agents", + LocalHelpTopic::Skills => "skills", + LocalHelpTopic::Plugins => "plugins", + LocalHelpTopic::Mcp => "mcp", + LocalHelpTopic::Config => "config", + LocalHelpTopic::Model => "models", + LocalHelpTopic::Settings => "settings", + LocalHelpTopic::Diff => "diff", + LocalHelpTopic::Setup => "setup", + } +} + +pub(crate) fn render_export_help_json() -> serde_json::Value { + json!({ + "kind": "help", + "action": "help", + "status": "ok", + "topic": "export", + "command": "export", + "usage": "claw export [--session ] [--output ] [--output-format ]", + "purpose": "serialize a managed session to JSON for review, transfer, or archival", + "defaults": { + "session": LATEST_SESSION_REFERENCE, + "session_source": ".claw/sessions/", + "output": "derived from the selected session when omitted" + }, + "formats": ["text", "json"], + "options": [ + { + "name": "--session", + "value": "", + "default": LATEST_SESSION_REFERENCE, + "description": "managed session to export" + }, + { + "name": "--output", + "aliases": ["-o"], + "value": "", + "description": "write the exported transcript to this path" + }, + { + "name": "--output-format", + "value": "", + "values": ["text", "json"], + "default": "text", + "description": "format for the command result envelope" + }, + { + "name": "--help", + "aliases": ["-h"], + "description": "show help for the export command" + } + ], + "related": ["/session list", "claw --resume latest"] + }) +} + +pub(crate) fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value { + if topic == LocalHelpTopic::Export { + return render_export_help_json(); + } + if topic == LocalHelpTopic::Doctor { + return render_doctor_help_json(); + } + + // #683-#692: extract structured metadata from help prose for machine consumption + let (usage, purpose, output_desc, formats, related, aliases, local_only, requires_credentials) = + extract_help_metadata(topic); + let mut obj = serde_json::json!({ + "kind": "help", + "action": "help", + "status": "ok", + "topic": local_help_topic_command(topic), + "command": local_help_topic_command(topic), + "message": render_help_topic(topic), + "usage": usage, + "purpose": purpose, + "formats": formats, + "related": related, + "local_only": local_only, + "requires_credentials": requires_credentials, + }); + if let Some(desc) = output_desc { + obj["output_fields"] = serde_json::Value::String(desc); + } + if let Some(a) = aliases { + obj["aliases"] = serde_json::json!(a); + } + obj +} + +pub(crate) fn acp_status_message() -> &'static str { + "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` reports status only and does not launch a daemon or JSON-RPC endpoint. Use the normal terminal surfaces for now." +} + +pub(crate) fn acp_status_json() -> serde_json::Value { + json!({ + "schema_version": "1.0", + "kind": "acp", + "action": "status", + "status": "not_implemented", + "supported": false, + "message": acp_status_message(), + "launch_command": serde_json::Value::Null, + "protocol": { + "name": "ACP/Zed", + "json_rpc": false, + "daemon": false, + "endpoint": serde_json::Value::Null, + "serve_starts_daemon": false + }, + "contracts": { + "blocking_gates": [ + "task_packet_schema", + "session_control_schema", + "event_report_schema" + ], + "stable_status_surface": "claw acp [serve] --output-format json", + "unsupported_invocation_kind": "unsupported_acp_invocation" + }, + "aliases": ["acp", "--acp", "-acp"], + }) +} + +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.md, CLAW.md, AGENTS.md, or scoped 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!( + " source={} lines={} chars={} preview={}", + file.source(), + file.content.lines().count(), + file.char_count(), + preview + )); + } + } + Ok(lines.join( + " +", + )) +} + +pub(crate) fn normalize_permission_mode(mode: &str) -> Option<&'static str> { + match mode.trim() { + "default" | "plan" | "read-only" => Some("read-only"), + "acceptEdits" | "auto" | "workspace-write" => Some("workspace-write"), + "dontAsk" | "bypassPermissions" | "dangerFullAccess" | "danger-full-access" => { + Some("danger-full-access") + } + _ => None, + } +} + +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 parse_history_count(raw: Option<&str>) -> Result { + let Some(raw) = raw else { + return Ok(DEFAULT_HISTORY_LIMIT); + }; + // #776: use \n-delimited format so split_error_hint extracts hint into JSON envelopes + let parsed: usize = raw + .parse() + .map_err(|_| format!("invalid_history_count: '{raw}' is not a positive integer.\nUsage: /history [count] (default: {DEFAULT_HISTORY_LIMIT})"))?; + if parsed == 0 { + return Err(format!("invalid_history_count: count must be greater than 0.\nUsage: /history [count] (default: {DEFAULT_HISTORY_LIMIT})")); + } + Ok(parsed) +} + +pub(crate) fn format_history_timestamp(timestamp_ms: u64) -> String { + let secs = timestamp_ms / 1_000; + let subsec_ms = timestamp_ms % 1_000; + let days_since_epoch = secs / 86_400; + let seconds_of_day = secs % 86_400; + let hours = seconds_of_day / 3_600; + let minutes = (seconds_of_day % 3_600) / 60; + let seconds = seconds_of_day % 60; + + let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0)); + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z") +} + +// Computes civil (Gregorian) year/month/day from days since the Unix epoch +// (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm. +#[allow( + clippy::cast_sign_loss, + clippy::cast_possible_wrap, + clippy::cast_possible_truncation +)] +pub(crate) fn civil_from_days(days: i64) -> (i32, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { + z / 146_097 + } else { + (z - 146_096) / 146_097 + }; + let doe = (z - era * 146_097) as u64; // [0, 146_096] + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let y = y + i64::from(m <= 2); + (y as i32, m as u32, d as u32) +} + +pub(crate) fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String { + if entries.is_empty() { + return "Prompt history\n Result no prompts recorded yet".to_string(); + } + + let total = entries.len(); + let start = total.saturating_sub(limit); + let shown = &entries[start..]; + let mut lines = vec![ + "Prompt history".to_string(), + format!(" Total {total}"), + format!(" Showing {} most recent", shown.len()), + format!(" Reverse search Ctrl-R in the REPL"), + String::new(), + ]; + for (offset, entry) in shown.iter().enumerate() { + let absolute_index = start + offset + 1; + let timestamp = format_history_timestamp(entry.timestamp_ms); + let first_line = entry.text.lines().next().unwrap_or("").trim(); + let display = if first_line.chars().count() > 80 { + let truncated: String = first_line.chars().take(77).collect(); + format!("{truncated}...") + } else { + first_line.to_string() + }; + lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}")); + } + lines.join("\n") +} + +pub(crate) fn collect_session_prompt_history(session: &Session) -> Vec { + if !session.prompt_history.is_empty() { + return session + .prompt_history + .iter() + .map(|entry| PromptHistoryEntry { + timestamp_ms: entry.timestamp_ms, + text: entry.text.clone(), + }) + .collect(); + } + let timestamp_ms = session.updated_at_ms; + session + .messages + .iter() + .filter(|message| message.role == MessageRole::User) + .filter_map(|message| { + message.blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(PromptHistoryEntry { + timestamp_ms, + text: text.clone(), + }), + _ => None, + }) + }) + .collect() +} + +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 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 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) struct CliPermissionPrompter { + pub(crate) current_mode: PermissionMode, +} + +impl CliPermissionPrompter { + pub(crate) fn new(current_mode: PermissionMode) -> Self { + Self { current_mode } + } +} + +impl runtime::PermissionPrompter for CliPermissionPrompter { + fn decide( + &mut self, + request: &runtime::PermissionRequest, + ) -> runtime::PermissionPromptDecision { + println!(); + println!("Permission approval required"); + println!(" Tool {}", request.tool_name); + println!(" Current mode {}", self.current_mode.as_str()); + println!(" Required mode {}", request.required_mode.as_str()); + if let Some(reason) = &request.reason { + println!(" Reason {reason}"); + } + println!(" Input {}", request.input); + print!("Approve this tool call? [y/N]: "); + let _ = io::stdout().flush(); + + let mut response = String::new(); + match io::stdin().read_line(&mut response) { + Ok(_) => { + let normalized = response.trim().to_ascii_lowercase(); + if matches!(normalized.as_str(), "y" | "yes") { + runtime::PermissionPromptDecision::Allow + } else { + runtime::PermissionPromptDecision::Deny { + reason: format!( + "tool '{}' denied by user approval prompt", + request.tool_name + ), + } + } + } + Err(error) => runtime::PermissionPromptDecision::Deny { + reason: format!("permission approval failed: {error}"), + }, + } + } +} + +pub(crate) fn slash_command_completion_candidates_with_sessions( + model: &str, + active_session_id: Option<&str>, + recent_session_ids: Vec, +) -> Vec { + let mut completions = BTreeSet::new(); + + for spec in slash_command_specs() { + if STUB_COMMANDS.contains(&spec.name) { + continue; + } + completions.insert(format!("/{}", spec.name)); + for alias in spec.aliases { + if !STUB_COMMANDS.contains(alias) { + completions.insert(format!("/{alias}")); + } + } + } + + for candidate in [ + "/bughunter ", + "/clear --confirm", + "/config ", + "/config env", + "/config hooks", + "/config model", + "/config plugins", + "/mcp ", + "/mcp list", + "/mcp show ", + "/export ", + "/issue ", + "/model ", + "/model opus", + "/model sonnet", + "/model haiku", + "/permissions ", + "/permissions read-only", + "/permissions workspace-write", + "/permissions danger-full-access", + "/plugin list", + "/plugin install ", + "/plugin enable ", + "/plugin disable ", + "/plugin uninstall ", + "/plugin update ", + "/plugins list", + "/pr ", + "/resume ", + "/session list", + "/session switch ", + "/session fork ", + "/teleport ", + "/ultraplan ", + "/agents help", + "/mcp help", + "/skills help", + ] { + completions.insert(candidate.to_string()); + } + + if !model.trim().is_empty() { + completions.insert(format!("/model {}", resolve_model_alias(model))); + completions.insert(format!("/model {model}")); + } + + if let Some(active_session_id) = active_session_id.filter(|value| !value.trim().is_empty()) { + completions.insert(format!("/resume {active_session_id}")); + completions.insert(format!("/session switch {active_session_id}")); + } + + for session_id in recent_session_ids + .into_iter() + .filter(|value| !value.trim().is_empty()) + .take(10) + { + completions.insert(format!("/resume {session_id}")); + completions.insert(format!("/session switch {session_id}")); + } + + completions.into_iter().collect() +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 665ce632cf..7696b79cc6 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -70,49 +70,23 @@ use tools::{ RuntimeToolDefinition, ToolSearchOutput, }; -const DEFAULT_MODEL: &str = "anthropic/claude-opus-4-7"; - -/// #148: Model provenance for `claw status` JSON/text output. Records where -/// the resolved model string came from so claws don't have to re-read argv -/// to audit whether their `--model` flag was honored vs falling back to env -/// or config or default. -#[derive(Debug, Clone, PartialEq, Eq)] -enum ModelSource { - /// Explicit `--model` / `--model=` CLI flag. - Flag, - /// Runtime model environment variable (when no flag was passed). - Env, - /// `model` key in `.claw.json` / `.claw/settings.json` (when neither - /// flag nor env set it). - Config, - /// Compiled-in `DEFAULT_MODEL` fallback. - Default, -} +mod bootstrap; +use bootstrap::*; +mod cli_parse; +use cli_parse::*; +mod model_provenance; +use model_provenance::*; +mod preflight; +use preflight::*; +mod progress; +use progress::*; +mod provider_client; +use provider_client::*; +mod skill_dispatch; +use skill_dispatch::*; -impl ModelSource { - fn as_str(&self) -> &'static str { - match self { - ModelSource::Flag => "flag", - ModelSource::Env => "env", - ModelSource::Config => "config", - ModelSource::Default => "default", - } - } -} +const DEFAULT_MODEL: &str = "anthropic/claude-opus-4-7"; -#[derive(Debug, Clone, PartialEq, Eq)] -struct ModelProvenance { - /// Resolved model string (after alias expansion). - resolved: String, - /// Raw user input before alias resolution. None when source is Default. - raw: Option, - /// Where the resolved model string originated. - source: ModelSource, - /// Alias-expanded target when `raw` differs from `resolved`. - alias_resolved_to: Option, - /// Environment variable that supplied the model, when source is Env. - env_var: Option, -} #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionModeSource { Flag, @@ -166,86 +140,6 @@ struct EnvModel { value: String, } -impl ModelProvenance { - fn default_fallback() -> Self { - Self { - resolved: DEFAULT_MODEL.to_string(), - raw: None, - source: ModelSource::Default, - alias_resolved_to: None, - env_var: None, - } - } - - fn from_flag(raw: &str, resolved: &str) -> Self { - Self::from_resolved(raw, resolved, ModelSource::Flag, None) - } - - fn from_raw(raw: &str, source: ModelSource, env_var: Option<&str>) -> Self { - let resolved = resolve_model_alias_with_config(raw); - Self::from_resolved(raw, &resolved, source, env_var) - } - - fn from_resolved( - raw: &str, - resolved: &str, - source: ModelSource, - env_var: Option<&str>, - ) -> Self { - let raw_trimmed = raw.trim(); - let alias_resolved_to = (raw_trimmed != resolved).then(|| resolved.to_string()); - Self { - resolved: resolved.to_string(), - raw: Some(raw.to_string()), - source, - alias_resolved_to, - env_var: env_var.map(str::to_string), - } - } - - fn from_env_or_config_or_default(cli_model: &str) -> Result { - // Only called when no --model flag was passed. Probe env first, - // then config, else fall back to default. Mirrors the logic in - // resolve_repl_model() but captures the source. - if cli_model != DEFAULT_MODEL { - let provenance = Self::from_resolved(cli_model, cli_model, ModelSource::Flag, None); - provenance.validate()?; - return Ok(provenance); - } - if let Some(env_model) = env_model_for_runtime() { - let provenance = - Self::from_raw(&env_model.value, ModelSource::Env, Some(env_model.name)); - provenance.validate()?; - return Ok(provenance); - } - if let Some(config_model) = config_model_for_current_dir() { - let provenance = Self::from_raw(&config_model, ModelSource::Config, None); - provenance.validate()?; - return Ok(provenance); - } - Ok(Self::default_fallback()) - } - - fn validate(&self) -> Result<(), String> { - validate_model_syntax(&self.resolved).map_err(|error| { - let source = match self.source { - ModelSource::Flag => "--model", - ModelSource::Env => self.env_var.as_deref().unwrap_or("environment"), - ModelSource::Config => "config model", - ModelSource::Default => "default model", - }; - if let Some(raw) = &self.raw { - format!( - "invalid_model: {source} model `{raw}` is invalid after alias resolution to `{}`.\n{error}", - self.resolved - ) - } else { - error - } - }) - } -} - fn env_model_for_runtime() -> Option { ["CLAW_MODEL", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_MODEL"] .into_iter() @@ -258,34 +152,51 @@ fn env_model_for_runtime() -> Option { }) } -fn max_tokens_for_model(model: &str) -> u32 { - api::max_tokens_for_model(model) -} // Build-time constants injected by build.rs (fall back to static values when // build.rs hasn't run, e.g. in doc-test or unusual toolchain environments). const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") { Some(d) => d, None => "unknown", }; + const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; + const VERSION: &str = env!("CARGO_PKG_VERSION"); + const BUILD_TARGET: Option<&str> = option_env!("TARGET"); + const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); + const GIT_SHA_SHORT: Option<&str> = option_env!("GIT_SHA_SHORT"); + const GIT_DIRTY: Option<&str> = option_env!("GIT_DIRTY"); + const GIT_BRANCH: Option<&str> = option_env!("GIT_BRANCH"); + const GIT_COMMIT_DATE: Option<&str> = option_env!("GIT_COMMIT_DATE"); + const GIT_COMMIT_TIMESTAMP: Option<&str> = option_env!("GIT_COMMIT_TIMESTAMP"); + const RUSTC_VERSION: Option<&str> = option_env!("RUSTC_VERSION"); + const INTERNAL_PROGRESS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(3); + const POST_TOOL_STALL_TIMEOUT: Duration = Duration::from_secs(10); + const PRIMARY_SESSION_EXTENSION: &str = "jsonl"; + const LEGACY_SESSION_EXTENSION: &str = "json"; + const OFFICIAL_REPO_URL: &str = "https://github.com/ultraworkers/claw-code"; + const OFFICIAL_REPO_SLUG: &str = "ultraworkers/claw-code"; + const DEPRECATED_INSTALL_COMMAND: &str = "cargo install claw-code"; + const LATEST_SESSION_REFERENCE: &str = "latest"; + const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"]; + const CLI_OPTION_SUGGESTIONS: &[&str] = &[ "--help", "-h", @@ -429,161 +340,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.starts_with("unknown_slash_command:") { - "unknown_slash_command" - } else if message.starts_with("command_not_found:") { - "command_not_found" - } else if message.contains("missing Anthropic credentials") { - "missing_credentials" - } else if message.contains("Manifest source files are missing") - || message.starts_with("missing_manifests:") - { - "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("no managed sessions found") { - "no_managed_sessions" - } else if message.contains("legacy session is missing workspace binding") { - // #780: must precede the generic "failed to restore session" arm — the full - // error message is "failed to restore session: legacy session is missing workspace - // binding: ...", so the specific arm must be checked first. - "legacy_session_no_workspace_binding" - } else if message.contains("Is a directory") || message.contains("os error 21") { - // #787: --resume given a directory path instead of a .jsonl file - "session_path_is_directory" - } else if message.contains("failed to restore session") { - "session_load_failed" - } else if message.contains("unsupported ACP invocation") { - "unsupported_acp_invocation" - } else if message.starts_with("missing_argument:") { - "missing_argument" - } else if message.contains("unsupported skills action") { - "unsupported_skills_action" - } else if message.starts_with("invalid_install_source:") { - "invalid_install_source" - } else if message.starts_with("invalid_cwd:") { - "invalid_cwd" - } else if message.starts_with("invalid_output_path:") { - "invalid_output_path" - } else if message.starts_with("invalid_output_format:") { - "invalid_output_format" - } else if message.starts_with("invalid_tool_name:") { - "invalid_tool_name" - } else if message.contains("unrecognized argument") || message.contains("unknown option") { - "cli_parse" - } else if message.starts_with("missing_flag_value:") { - "missing_flag_value" - } else if message.starts_with("invalid_permission_mode:") { - "invalid_permission_mode" - } else if message.starts_with("invalid_flag_value:") { - "invalid_flag_value" - } else if message.starts_with("invalid_model:") { - "invalid_model" - } 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")) - && (message.contains("401") - || message.contains("Unauthorized") - || message.contains("authentication_error")) - { - // #781: sub-classify auth failures so wrappers can distinguish from rate-limit / server errors - "api_auth_error" - } else if (message.contains("api failed") || message.contains("api returned")) - && (message.contains("429") - || message.contains("rate_limit") - || message.contains("rate limit")) - { - // #781: sub-classify rate-limit failures - "api_rate_limit_error" - } else if message.contains("api failed") || message.contains("api returned") { - "api_http_error" - } else if message.contains("mcpServers") { - "malformed_mcp_config" - } else if message.contains(".claw/settings.json") || message.contains(".claw.json") { - // #763: config file JSON parse / validation errors (e.g. unterminated string, type mismatch) - "config_parse_error" - } else if message.starts_with("empty prompt") { - "empty_prompt" - } else if message.starts_with("interactive_only:") || message.contains("stdin is not a TTY") { - "interactive_only" - } else if message.starts_with("unknown agents subcommand:") { - "unknown_agents_subcommand" - } else if message.starts_with("agent not found:") { - "agent_not_found" - } else if message.contains("is not installed") || message.starts_with("plugin_not_found:") { - "plugin_not_found" - } else if message.contains("plugin source") && message.contains("was not found") { - // #794: `plugins install /nonexistent/path` → "plugin source ... was not found" - "plugin_source_not_found" - } else if (message.contains("skill source") && message.contains("not found")) - || message.starts_with("skill '") - { - "skill_not_found" - } else if message.contains("Unsupported config section") { - "unsupported_config_section" - } else if message.contains("unknown_plugins_action") { - "unknown_plugins_action" - } else if message.starts_with("invalid_history_count:") || message.contains("invalid count") { - "invalid_history_count" - } else if message.starts_with("missing_prompt:") { - "missing_prompt" - } else if message.contains("has been removed.") { - // #765: removed subcommands (login, logout) — hint contains migration guidance - "removed_subcommand" - } else if message.starts_with("unknown subcommand:") { - // #785/#825: typo/unknown top-level subcommand (e.g. `claw dump` → did you mean dump-manifests?) - // Unified under command_not_found in #825. - "command_not_found" - } else if message.starts_with("unexpected extra arguments") - || message.starts_with("unexpected_extra_args:") - { - // #766: extra positionals after commands that take no arguments (e.g. claw diff) - // #784: export extra-positional errors use the typed prefix form - "unexpected_extra_args" - } else if message.starts_with("invalid_resume_argument:") { - // #768: --resume trailing arg is not a slash command - "invalid_resume_argument" - } else if message.starts_with("unknown_option:") { - "unknown_option" - } else if message.contains("is a slash command") - || message.starts_with("interactive_only:") - // #735: "slash command /X is interactive-only" emitted by interactive-only guard - || (message.starts_with("slash command") && message.contains("interactive-only")) - { - "interactive_only" - } 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), - } -} - fn invalid_tool_name_details(message: &str) -> (Option, Vec, Value) { let tool_name = message .strip_prefix("invalid_tool_name: unsupported tool in --allowedTools:") @@ -898,423 +654,6 @@ fn apply_global_cwd(cwd: Option) -> Result<(), Box Option { - if io::stdin().is_terminal() { - return None; - } - let mut buffer = String::new(); - if io::stdin().read_to_string(&mut buffer).is_err() { - return None; - } - if buffer.trim().is_empty() { - return None; - } - 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(); - }; - let trimmed = raw.trim(); - if trimmed.is_empty() { - return prompt.to_string(); - } - if prompt.is_empty() { - return trimmed.to_string(); - } - format!("{prompt}\n\n{trimmed}") -} - -fn plugin_command_json( - action: &str, - target: Option<&str>, - result: &commands::PluginsCommandResult, - report: &plugins::PluginRegistryReport, -) -> Value { - let failures = report.failures(); - json!({ - "kind": "plugin", - "action": action, - "target": target, - "status": if failures.is_empty() { "ok" } else { "degraded" }, - "message": result.message, - "reload_runtime": result.reload_runtime, - "plugins": report.summaries().iter().map(plugin_summary_json).collect::>(), - "load_failures": failures.iter().map(plugin_load_failure_json).collect::>(), - }) -} - -fn plugin_summary_json(plugin: &plugins::PluginSummary) -> Value { - json!({ - "id": &plugin.metadata.id, - "name": &plugin.metadata.name, - "version": &plugin.metadata.version, - "description": &plugin.metadata.description, - "kind": plugin.metadata.kind.to_string(), - "source": &plugin.metadata.source, - // #730: path parity with agents (#728) and skills (#729) - "path": plugin.metadata.root.as_ref().map(|p| p.display().to_string()), - "enabled": plugin.enabled, - "lifecycle_state": plugin.lifecycle_state(), - "lifecycle": { - "configured": !plugin.lifecycle.is_empty(), - "init": { - "configured": !plugin.lifecycle.init.is_empty(), - "command_count": plugin.lifecycle.init.len(), - }, - "shutdown": { - "configured": !plugin.lifecycle.shutdown.is_empty(), - "command_count": plugin.lifecycle.shutdown.len(), - }, - }, - }) -} - -fn plugin_load_failure_json(failure: &plugins::PluginLoadFailure) -> Value { - json!({ - "plugin_root": failure.plugin_root.display().to_string(), - "kind": failure.kind.to_string(), - "source": &failure.source, - "lifecycle_state": "load_failed", - "error": failure.error().to_string(), - }) -} - -fn run() -> Result<(), Box> { - let args: Vec = env::args().skip(1).collect(); - // #824: suppress config deprecation prose warnings to stderr when JSON - // output mode is active. Scan the raw argv before parse_args so the - // suppression is in place before any settings file is loaded. - let json_mode = raw_args_request_json_output(&args); - if json_mode { - runtime::suppress_config_warnings_for_json_mode(); - } - let (args, cwd) = split_global_cwd_args(&args)?; - apply_global_cwd(cwd)?; - match parse_args(&args)? { - CliAction::DumpManifests { - output_format, - manifests_dir, - } => dump_manifests(manifests_dir.as_deref(), output_format)?, - CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?, - CliAction::Agents { - args, - output_format, - } => LiveCli::print_agents(args.as_deref(), output_format)?, - CliAction::Mcp { - args, - output_format, - } => LiveCli::print_mcp(args.as_deref(), output_format)?, - CliAction::Skills { - 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, - model, - output_format, - } => print_system_prompt(cwd, date, &model, output_format)?, - CliAction::Version { output_format } => print_version(output_format)?, - CliAction::ResumeSession { - session_path, - commands, - output_format, - allow_broad_cwd, - } => { - enforce_broad_cwd_policy(allow_broad_cwd, output_format)?; - resume_session(&session_path, &commands, output_format) - } - CliAction::Status { - model, - model_flag_raw, - permission_mode, - output_format, - allowed_tools, - } => print_status_snapshot( - &model, - model_flag_raw.as_deref(), - permission_mode, - output_format, - allowed_tools.as_ref(), - )?, - CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?, - CliAction::Prompt { - prompt, - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit, - reasoning_effort, - allow_broad_cwd, - } => { - 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 { - None - }; - let effective_prompt = merge_prompt_with_stdin(&prompt, stdin_context.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); - cli.run_turn_with_output(&effective_prompt, output_format, compact)?; - } - CliAction::Doctor { - output_format, - permission_mode, - } => run_doctor(output_format, permission_mode)?, - CliAction::Acp { output_format } => { - print_acp_status(output_format)?; - std::process::exit(2); - } - CliAction::SessionList { output_format } => run_session_list(output_format)?, - CliAction::State { output_format } => run_worker_state(output_format)?, - CliAction::Init { output_format } => run_init(output_format)?, - CliAction::Setup { output_format: _ } => run_setup()?, - // #146: dispatch pure-local introspection. Text mode uses existing - // render_config_report/render_diff_report; JSON mode uses the - // corresponding _json helpers already exposed for resume sessions. - CliAction::Config { - section, - output_format, - } => match output_format { - CliOutputFormat::Text => { - println!("{}", render_config_report(section.as_deref())?); - } - CliOutputFormat::Json => { - println!( - "{}", - serde_json::to_string_pretty(&render_config_json(section.as_deref())?)? - ); - } - }, - CliAction::Models { - action, - output_format, - } => print_models(action.as_deref(), output_format)?, - CliAction::Diff { output_format } => match output_format { - CliOutputFormat::Text => { - println!("{}", render_diff_report()?); - } - CliOutputFormat::Json => { - let cwd = friendly_cwd(env::current_dir()?); - println!( - "{}", - serde_json::to_string_pretty(&render_diff_json_for(&cwd)?)? - ); - } - }, - CliAction::Export { - session_reference, - output_path, - output_format, - } => run_export(&session_reference, output_path.as_deref(), output_format)?, - CliAction::Repl { - model, - allowed_tools, - permission_mode, - base_commit, - reasoning_effort, - allow_broad_cwd, - } => run_repl( - model, - allowed_tools, - permission_mode, - base_commit, - reasoning_effort, - allow_broad_cwd, - )?, - CliAction::HelpTopic { - topic, - output_format, - } => print_help_topic(topic, output_format)?, - CliAction::Help { output_format } => print_help(output_format)?, - } - Ok(()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum CliAction { - DumpManifests { - output_format: CliOutputFormat, - manifests_dir: Option, - }, - BootstrapPlan { - output_format: CliOutputFormat, - }, - Agents { - args: Option, - output_format: CliOutputFormat, - }, - Mcp { - args: Option, - output_format: CliOutputFormat, - }, - Skills { - args: Option, - output_format: CliOutputFormat, - }, - Plugins { - action: Option, - target: Option, - output_format: CliOutputFormat, - }, - PrintSystemPrompt { - cwd: PathBuf, - date: String, - model: String, - output_format: CliOutputFormat, - }, - Version { - output_format: CliOutputFormat, - }, - SessionList { - output_format: CliOutputFormat, - }, - ResumeSession { - session_path: PathBuf, - commands: Vec, - output_format: CliOutputFormat, - allow_broad_cwd: bool, - }, - Status { - model: String, - // #148: raw `--model` flag input (pre-alias-resolution), if any. - // None means no flag was supplied; env/config/default fallback is - // resolved inside `print_status_snapshot`. - model_flag_raw: Option, - permission_mode: PermissionModeProvenance, - output_format: CliOutputFormat, - allowed_tools: Option, - }, - Sandbox { - output_format: CliOutputFormat, - }, - Prompt { - prompt: String, - model: String, - output_format: CliOutputFormat, - allowed_tools: Option, - permission_mode: PermissionMode, - compact: bool, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, - }, - Doctor { - output_format: CliOutputFormat, - permission_mode: PermissionModeProvenance, - }, - Acp { - output_format: CliOutputFormat, - }, - State { - output_format: CliOutputFormat, - }, - Init { - output_format: CliOutputFormat, - }, - Setup { - output_format: CliOutputFormat, - }, - // #146: `claw config` and `claw diff` are pure-local read-only - // introspection commands; wire them as standalone CLI subcommands. - Config { - section: Option, - output_format: CliOutputFormat, - }, - Models { - action: Option, - output_format: CliOutputFormat, - }, - Diff { - output_format: CliOutputFormat, - }, - Export { - session_reference: String, - output_path: Option, - output_format: CliOutputFormat, - }, - Repl { - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, - }, - HelpTopic { - topic: LocalHelpTopic, - output_format: CliOutputFormat, - }, - // prompt-mode formatting is only supported for non-interactive runs - Help { - output_format: CliOutputFormat, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LocalHelpTopic { - Status, - Sandbox, - Doctor, - Acp, - // #141: extend the local-help pattern to every subcommand so - // `claw --help` has one consistent contract. - Init, - State, - Resume, - Session, - Compact, - Export, - Version, - SystemPrompt, - DumpManifests, - BootstrapPlan, - // #720: subsystem help topics so `claw help agents` etc. route to usage JSON - Agents, - Skills, - Plugins, - Mcp, - Config, - Model, - Settings, - Diff, - Setup, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CliOutputFormat { - Text, - Json, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OutputFormatSource { Default, @@ -1352,6 +691,7 @@ impl Default for OutputFormatSelection { } static OUTPUT_FORMAT_SELECTION: OnceLock> = OnceLock::new(); + // #468: duplicate global flag occurrences for provenance reporting static DUPLICATE_FLAGS: OnceLock>> = OnceLock::new(); @@ -1456,11429 +796,1283 @@ fn apply_output_format_flag( set_current_output_format_selection(selection); Ok(parsed) } -impl CliOutputFormat { - fn parse(value: &str) -> Result { - match value.trim() { - value if value.eq_ignore_ascii_case("text") => Ok(Self::Text), - value if value.eq_ignore_ascii_case("json") => Ok(Self::Json), - other => Err(format!( - "invalid_output_format: unsupported value for --output-format: {other}\nExpected: text, json\nHint: Use --output-format text or --output-format json." - )), - } - } - fn as_str(self) -> &'static str { - match self { - Self::Text => "text", - Self::Json => "json", - } +fn compact_interactive_only_error() -> String { + // #749: newline before remediation so split_error_hint populates hint field + "interactive_only: `claw compact` is an interactive/session command.\nStart `claw` and run `/compact`, or use `claw --resume SESSION.jsonl /compact` to compact an existing session." + .to_string() +} + +fn unexpected_diff_args_error(extra: &[String]) -> String { + format!( + "unexpected extra arguments after `claw diff`: {}\nUsage: claw diff", + extra.join(" ") + ) +} + +fn is_known_top_level_subcommand(value: &str) -> bool { + matches!( + value, + "help" + | "version" + | "status" + | "sandbox" + | "doctor" + | "state" + | "dump-manifests" + | "bootstrap-plan" + | "agents" + | "agent" + | "mcp" + | "skills" + | "skill" + | "plugins" + | "plugin" + | "marketplace" + | "system-prompt" + | "acp" + | "init" + | "export" + | "prompt" + | "resume" + | "session" + | "compact" + | "config" + | "model" + | "models" + | "settings" + | "diff" + ) +} + +fn is_bare_provider_model(model: &str) -> bool { + model.starts_with("claude-") || model.starts_with("gpt-") +} + +fn is_local_openai_model_syntax(model: &str) -> bool { + if let Some(rest) = model.strip_prefix("local/") { + return !rest.is_empty() && rest.split('/').all(|segment| !segment.is_empty()); } + std::env::var_os("OPENAI_BASE_URL").is_some() && (model.contains(':') || model.contains('.')) } -#[allow(clippy::too_many_lines)] -fn parse_args(args: &[String]) -> Result { - let mut model = DEFAULT_MODEL.to_string(); - // #148: when user passes --model/--model=, capture the raw input so we - // can attribute source: "flag" later. None means no flag was supplied. - let mut model_flag_raw: Option = None; - let mut output_format_selection = if cli_has_output_format_flag(args) { - OutputFormatSelection::default() - } else { - output_format_selection_from_env()? - }; - set_current_output_format_selection(&output_format_selection); - let mut output_format = output_format_selection.format; - 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; - - // #755: -p prompt text captured as single token; remaining args continue - // flag parsing. None until `-p ` is seen. - let mut short_p_prompt: Option = None; - let mut rest: Vec = Vec::new(); - let mut positional_after_separator = false; - let mut index = 0; +fn allowed_tools_missing_error() -> String { + "missing_argument: --allowedTools requires a tool list before subcommands or flags.\nUsage: --allowedTools [,...] e.g. --allowedTools read,glob".to_string() +} - 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_flag_value: missing value for --model.\nUsage: --model e.g. --model anthropic/claude-opus-4-7".to_string())?; - // #468: track duplicate --model flags - if model_flag_raw.is_some() { - push_duplicate_flag(&format!( - "--model (previous: {}, new: {})", - model_flag_raw.as_deref().unwrap_or(""), - value - )); - } - let resolved = resolve_model_alias_with_config(value); - debug!("Resolved --model '{}' -> '{}'", value, resolved); - validate_model_syntax(&resolved)?; - model = resolved; - model_flag_raw = Some(value.clone()); // #148 - index += 2; - } +fn compact_missing_argument_error() -> String { + "missing_argument: --compact requires prompt text, piped stdin, or a subcommand. argument: prompt or subcommand\nUsage: claw --compact or echo '' | claw --compact" + .to_string() +} - flag if flag.starts_with("--model=") => { - let value = &flag[8..]; - let resolved = resolve_model_alias_with_config(value); - debug!("Resolved --model='{}' -> '{}'", value, resolved); - validate_model_syntax(&resolved)?; - model = resolved; - model_flag_raw = Some(value.to_string()); // #148 - index += 1; - } - "--output-format" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing_flag_value: missing value for --output-format.\nUsage: --output-format text or --output-format json".to_string())?; - // #468: track duplicate --output-format flags - if output_format != CliOutputFormat::Text - || output_format_selection.format != CliOutputFormat::Text - { - push_duplicate_flag("--output-format (overwriting previous value)"); - } - output_format = apply_output_format_flag(&mut output_format_selection, value)?; - index += 2; - } - "--permission-mode" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing_flag_value: missing value for --permission-mode.\nUsage: --permission-mode read-only|workspace-write|danger-full-access".to_string())?; - // #468: track duplicate --permission-mode flags - if permission_mode_override.is_some() { - push_duplicate_flag("--permission-mode (overwriting previous value)"); - } - permission_mode_override = Some(parse_permission_mode_arg(value)?); - index += 2; - } +fn allowed_tool_aliases_json(registry: &GlobalToolRegistry) -> Value { + Value::Object( + registry + .allowed_tool_aliases() + .into_iter() + .map(|(alias, canonical)| (alias, Value::String(canonical))) + .collect(), + ) +} - flag if flag.starts_with("--output-format=") => { - output_format = - apply_output_format_flag(&mut output_format_selection, &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" | "--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_flag_value: missing value for --base-commit.\nUsage: --base-commit ".to_string())?; - // #122: validate that base-commit looks like a git SHA (hex, 7-64 chars) - if value.len() < 7 - || value.len() > 64 - || !value.chars().all(|c| c.is_ascii_hexdigit()) - { - return Err(format!( - "invalid_flag_value: --base-commit expects a hex SHA (7-64 chars), got '{}'.\nUsage: --base-commit ", - value - )); - } - 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_flag_value: missing value for --reasoning-effort.\nUsage: --reasoning-effort low|medium|high".to_string())?; - if !matches!(value.as_str(), "low" | "medium" | "high") { - return Err(format!( - "invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|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_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|high" - )); - } - reasoning_effort = Some(value.to_string()); - index += 1; - } - "--allow-broad-cwd" => { - allow_broad_cwd = true; - index += 1; - } - "--" => { - if rest.is_empty() { - positional_after_separator = true; - rest.extend(args[index + 1..].iter().cloned()); - } else { - rest.push("--".to_string()); - rest.extend(args[index + 1..].iter().cloned()); - } - break; - } - "-p" => { - // Claw Code compat: -p "prompt" = one-shot prompt. - // #755: consume exactly one token so subsequent flags like - // --model/--output-format are parsed normally instead of - // being swallowed into the prompt string (#117). - let next = args.get(index + 1).map(|s| s.as_str()); - match next { - None | Some("") => { - return Err("missing_prompt: -p requires a prompt string.\nUsage: claw -p or claw prompt ".to_string()); - } - Some(tok) if tok.starts_with('-') && tok != "--" => { - // Looks like a flag, not a prompt. Reject so the user - // knows to quote the literal text or use `--`. - return Err(format!( - "missing_prompt: -p requires a prompt string before flags; got `{tok}`.\nUsage: claw -p --model sonnet or claw -p -- {tok} (literal)" - )); - } - Some(tok) => { - // `--` sentinel: skip it and take the token after as literal - let (prompt_text, skip) = if tok == "--" { - match args.get(index + 2) { - Some(t) => (t.as_str(), 3usize), - None => return Err("missing_prompt: -p -- requires a prompt string after `--`.\nUsage: claw -p -- ".to_string()), - } - } else { - (tok, 2usize) - }; - if prompt_text.trim().is_empty() { - return Err("missing_prompt: -p requires a non-empty prompt string.\nUsage: claw -p or claw prompt ".to_string()); - } - short_p_prompt = Some(prompt_text.to_string()); - index += skip; - continue; - } - } - } - "--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; - } - // #457: --help after --resume should show resume help, not be consumed as session-id - "--help" | "-h" if rest.first().map(String::as_str) == Some("--resume") => { - wants_help = true; - 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(allowed_tools_missing_error)?; - if value.starts_with('-') || is_known_top_level_subcommand(value) { - return Err(allowed_tools_missing_error()); - } - allowed_tool_values.push(value.clone()); - index += 2; - } - flag if flag.starts_with("--allowedTools=") => { - let value = flag[15..].to_string(); - if value.trim().is_empty() { - return Err(allowed_tools_missing_error()); - } - allowed_tool_values.push(value); - index += 1; - } - flag if flag.starts_with("--allowed-tools=") => { - let value = flag[16..].to_string(); - if value.trim().is_empty() { - return Err(allowed_tools_missing_error()); - } - allowed_tool_values.push(value); - index += 1; - } - other if rest.is_empty() && other.starts_with('-') => { - if should_reject_unknown_option_like(other) { - return Err(format_unknown_option(other)); - } - rest.push(other.to_string()); - index += 1; - } - other => { - rest.push(other.to_string()); - index += 1; - } - } - } - - if wants_help { - // #684: --help before subcommand should still route to subcommand-specific - // help when the subcommand is one of the local-help-topic commands. - if let Some(action) = parse_local_help_action(&rest, output_format) { - return action; - } - // When --help was consumed before the subcommand, rest has no help flag. - // If rest is a simple local-help subcommand with no extra args, route there. - if !rest.is_empty() && rest[1..].iter().all(|a| is_help_flag(a)) { - let topic = match rest[0].as_str() { - "status" => Some(LocalHelpTopic::Status), - "sandbox" => Some(LocalHelpTopic::Sandbox), - "doctor" => Some(LocalHelpTopic::Doctor), - "acp" => Some(LocalHelpTopic::Acp), - "init" => Some(LocalHelpTopic::Init), - "setup" => Some(LocalHelpTopic::Setup), - "state" => Some(LocalHelpTopic::State), - "resume" => Some(LocalHelpTopic::Resume), - "session" => Some(LocalHelpTopic::Session), - "compact" => Some(LocalHelpTopic::Compact), - "--resume" => Some(LocalHelpTopic::Resume), - "export" => Some(LocalHelpTopic::Export), - "version" => Some(LocalHelpTopic::Version), - "system-prompt" => Some(LocalHelpTopic::SystemPrompt), - "dump-manifests" => Some(LocalHelpTopic::DumpManifests), - "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), - "agents" | "agent" => Some(LocalHelpTopic::Agents), - "skills" | "skill" => Some(LocalHelpTopic::Skills), - "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), - "mcp" => Some(LocalHelpTopic::Mcp), - "config" => Some(LocalHelpTopic::Config), - "model" | "models" => Some(LocalHelpTopic::Model), - "settings" => Some(LocalHelpTopic::Settings), - "diff" => Some(LocalHelpTopic::Diff), - _ => None, - }; - if let Some(topic) = topic { - return Ok(CliAction::HelpTopic { - topic, - output_format, - }); - } - } - return Ok(CliAction::Help { output_format }); +fn permission_mode_provenance_for_current_dir() -> PermissionModeProvenance { + if let Some(mode) = env::var("RUSTY_CLAUDE_PERMISSION_MODE") + .ok() + .as_deref() + .and_then(normalize_permission_mode) + .map(permission_mode_from_label) + { + return PermissionModeProvenance { + mode, + source: PermissionModeSource::Env, + env_var: Some("RUSTY_CLAUDE_PERMISSION_MODE"), + }; } - if wants_version { - return Ok(CliAction::Version { output_format }); + if let Some(mode) = config_permission_mode_for_current_dir() { + return PermissionModeProvenance { + mode, + source: PermissionModeSource::Config, + env_var: None, + }; } - let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; + PermissionModeProvenance::default_fallback() +} - // #755: -p consumed exactly one token; dispatch now that all flags are parsed - if let Some(prompt) = short_p_prompt { - return Ok(CliAction::Prompt { - prompt, - model: resolve_model_alias_with_config(&model), - output_format, - allowed_tools, - permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), - compact, - base_commit, - reasoning_effort, - allow_broad_cwd, - }); - } +fn print_model_validation_warning_status( + error: &str, + usage: StatusUsage, + permission_mode: &str, + context: &StatusContext, + allowed_tools: Option<&AllowedToolSet>, +) -> Result<(), Box> { + let kind = classify_error_kind(error); + let (short_reason, inline_hint) = split_error_hint(error); + let hint = inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from)); + let format_selection = current_output_format_selection(); + let mut value = status_json_value( + None, + usage, + permission_mode, + context, + None, + None, + allowed_tools, + Some(&format_selection), + ); + let object = value + .as_object_mut() + .expect("status_json_value should render an object"); + object.insert("status".to_string(), serde_json::json!("warn")); + object.insert("error_kind".to_string(), serde_json::json!(kind)); + object.insert( + "model_validation_error".to_string(), + serde_json::json!(short_reason), + ); + object.insert( + "model_validation_error_kind".to_string(), + serde_json::json!(kind), + ); + object.insert("model_validation_hint".to_string(), serde_json::json!(hint)); + println!("{}", serde_json::to_string_pretty(&value)?); + Ok(()) +} - if positional_after_separator && !rest.is_empty() { - let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); - return Ok(CliAction::Prompt { - prompt: rest.join(" "), - model, - output_format, - allowed_tools, - permission_mode, - compact, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }); - } +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum ConfigWarningMode { + EmitStderr, + SuppressStderr, +} - if rest.is_empty() { - let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); - let stdin_is_terminal = std::io::stdin().is_terminal(); - if compact && stdin_is_terminal { - return Err(compact_missing_argument_error()); - } - // 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 !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, - base_commit, - reasoning_effort, - allow_broad_cwd, - }); - } - if compact { - return Err(compact_missing_argument_error()); - } - // Non-TTY stdin with no piped content: refuse to start the interactive - // REPL (it would block forever waiting for input that will never arrive). - // (#696: emit a typed error instead of hanging indefinitely) - // Skip this guard in test builds (parse_args tests run in non-TTY context). - #[cfg(not(test))] - // #746: newline before remediation so split_error_hint populates hint field - return Err("interactive_only: claw requires an interactive terminal.\nStdin is not a TTY and no prompt was provided — pipe a prompt with `echo 'task' | claw` or run `claw` in an interactive terminal.".into()); - } - return Ok(CliAction::Repl { - model, - allowed_tools, - permission_mode, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }); +fn load_config_with_warning_mode( + loader: &ConfigLoader, + mode: ConfigWarningMode, +) -> Result { + match mode { + ConfigWarningMode::EmitStderr => loader.load(), + ConfigWarningMode::SuppressStderr => loader + .load_collecting_warnings() + .map(|(runtime_config, _warnings)| runtime_config), } - if let Some(action) = parse_local_help_action(&rest, output_format) { - return action; - } - if rest.first().map(String::as_str) == Some("--resume") { - return parse_resume_args(&rest[1..], output_format, allow_broad_cwd); - } - if rest.first().map(String::as_str) == Some("resume") { - return parse_resume_args(&rest[1..], output_format, allow_broad_cwd); - } - // #696: `claw compact` is the bare name of the interactive `/compact` - // slash command, not a prompt. When extra args such as `--help` appear - // after the word `compact`, the generic prompt fallback used to send - // `compact --help` to provider startup and could hang under closed stdin / - // JSON output. Fail closed before any provider, prompt, TUI, or spinner - // startup. `claw --resume SESSION.jsonl /compact` remains the supported - // non-interactive session compaction path. - if rest.first().map(String::as_str) == Some("compact") { - return Err(compact_interactive_only_error()); - } - 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; - } - - // Keep config-backed defaults lazy so pure-local JSON surfaces (notably - // `claw --output-format json config`) can report config warnings - // structurally without an earlier default-resolution load writing prose - // warnings to stderr. - let permission_mode = || permission_mode_override.unwrap_or_else(default_permission_mode); - let permission_mode_provenance = || { - permission_mode_override - .map(PermissionModeProvenance::from_flag) - .unwrap_or_else(permission_mode_provenance_for_current_dir) - }; +} - // #98: --compact is only meaningful for prompt mode. When a known non-prompt - // subcommand is being dispatched, reject --compact so callers don't silently - // lose the flag. - if compact - && rest - .first() - .map(|s| s.as_str()) - .is_some_and(|s| s != "prompt") - { - // Allow compact for the default prompt fallback (unknown tokens). - // Only reject for known top-level subcommands that don't use compact. - let first = rest[0].as_str(); - if is_known_top_level_subcommand(first) && first != "prompt" { - return Err(format!( - "invalid_flag_value: --compact is only supported with prompt mode.\nUsage: claw --compact \"\" or echo \"\" | claw --compact" - )); - } - } +/// Run the interactive setup wizard to configure provider, API key, and model. +fn run_setup() -> Result<(), Box> { + setup_wizard::run_setup_wizard() +} - 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. - // `plugin` (singular) and `marketplace` are aliases for `plugins`. - // All three must route to the same local handler so that no form - // falls through to the LLM/prompt path. - "plugins" | "plugin" | "marketplace" => { - let tail = &rest[1..]; - let action = tail.first().cloned(); - let target = tail.get(1).cloned(); - if tail.len() > 2 { - // #797: append \n usage hint so split_error_hint extracts it (parity with #791 config fix) - return Err(format!( - "unexpected extra arguments after `claw {} {}`: {}\nUsage: claw plugins [list|show |install |enable |disable |uninstall |update |help]", - rest[0], - 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 { - // #791: append \n hint so split_error_hint extracts it and hint is non-null - return Err(format!( - "unexpected extra arguments after `claw config {}`: {}\nUsage: claw config [env|hooks|model|plugins|mcp|settings]", - 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 { - // #3129: keep malformed `diff ... --output-format json` on the - // parser/error path, not the prompt/TUI fallback. The newline - // before Usage is part of the JSON hint contract. - return Err(unexpected_diff_args_error(&rest[1..])); - } - Ok(CliAction::Diff { output_format }) - } - // `claw permissions ` falls through to the LLM when called - // with a subcommand argument because parse_single_word_command_alias - // only intercepts the bare single-word form. Catch all multi-word - // forms here and return a structured guidance error so no network - // call or session is created. - "permissions" => Err( - "`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]" - .to_string(), - ), - // #767: `claw session bogus` bypassed parse_single_word_command_alias (rest.len()>1), - // had no match arm, and fell to CliAction::Prompt — reaching the credential gate - // instead of a structured error. Mirror the guard on `permissions`. - "session" => { - // #449: `claw session list` is a pure local filesystem read that - // requires no API credentials. Route directly to SessionList instead - // of falling through to the resume/auth path. - if rest.get(1).map(|s| s.as_str()) == Some("list") { - Ok(CliAction::SessionList { output_format }) - } else { - let action_hint = rest.get(1).map_or(String::new(), |a| format!(" (got: `{a}`)" )); - Err(format!( - "interactive_only: `claw session` is a slash command{action_hint}.\nUse `claw --resume SESSION.jsonl /session ` or start `claw` and run `/session [list|exists|switch|fork|delete]`." - )) - } - } - // #770: same fallthrough gap as #767 — these slash commands had no multi-arg match arm - // and fell to CliAction::Prompt reaching the credential gate when called with args. - "cost" => Err( - "interactive_only: `claw cost` is a slash command.\nUse `claw --resume SESSION.jsonl /cost` or start `claw` and run `/cost`." - .to_string(), - ), - "clear" => Err( - "interactive_only: `claw clear` is a slash command.\nUse `claw --resume SESSION.jsonl /clear [--confirm]` or start `claw` and run `/clear`." - .to_string(), - ), - "memory" => Err( - "interactive_only: `claw memory` is a slash command.\nStart `claw` and run `/memory` inside the REPL." - .to_string(), - ), - "ultraplan" => Err( - "interactive_only: `claw ultraplan` is a slash command.\nStart `claw` and run `/ultraplan` inside the REPL." - .to_string(), - ), - "model" | "models" => { - let tail = &rest[1..]; - let action = tail.first().cloned(); - if tail.len() > 1 { - return Err(format!( - "unexpected extra arguments after `claw {} {}`: {}\nUsage: claw {} [help] [--output-format json]", - rest[0], - tail[0], - tail[1..].join(" "), - rest[0] - )); - } - Ok(CliAction::Models { - action, - output_format, - }) - } - // #771: usage/stats/fork are slash-only verbs with no multi-arg match arms - "usage" => Err( - "interactive_only: `claw usage` is a slash command.\nUse `claw --resume SESSION.jsonl /usage` or start `claw` and run `/usage`." - .to_string(), - ), - "stats" => Err( - "interactive_only: `claw stats` is a slash command.\nUse `claw --resume SESSION.jsonl /stats` or start `claw` and run `/stats`." - .to_string(), - ), - "fork" => Err( - "interactive_only: `claw fork` is a slash command.\nStart `claw` and run `/session fork [branch-name]` inside the REPL." - .to_string(), - ), - "skills" => { - let args = join_optional_args(&rest[1..]); - if let Some(action) = args.as_deref() { - let first_word = action.split_whitespace().next().unwrap_or(action); - if matches!(first_word, "add") { - return Err(format!( - "unsupported skills action: {first_word}. Supported actions: list, show , install , uninstall , help, or [args]" - )); - } - } - match classify_skills_slash_command(args.as_deref()) { - SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { - prompt, - model, - output_format, - allowed_tools, - permission_mode: permission_mode(), - compact, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }), - SkillSlashDispatch::Local => Ok(CliAction::Skills { - args, - output_format, - }), - } - } - "settings" => { - let tail = &rest[1..]; - if tail.is_empty() { - Ok(CliAction::Config { - section: Some("settings".to_string()), - output_format, - }) - } else if tail.len() == 1 && matches!(tail[0].as_str(), "help" | "--help" | "-h") { - Ok(CliAction::HelpTopic { - topic: LocalHelpTopic::Settings, - output_format, - }) - } else { - Err(format!( - "unexpected extra arguments after `claw settings`: {}\nUsage: claw settings [help] [--output-format json]", - tail.join(" ") - )) - } - } - "system-prompt" => parse_system_prompt_args(&rest[1..], model, output_format), - "acp" => parse_acp_args(&rest[1..], output_format), - "login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())), - "init" => { - // #771: extra positional args to `init` were silently ignored — now rejected - if rest.len() > 1 { - let extra = rest[1..].join(" "); - return Err(format!( - "unexpected extra arguments after `claw init`: {extra}\nUsage: claw init [--cwd ] [--date ] [--session ]" - )); - } - Ok(CliAction::Init { output_format }) - } - "setup" => { - if rest.len() > 1 { - let extra = rest[1..].join(" "); - return Err(format!( - "unexpected extra arguments after `claw setup`: {extra}\nUsage: claw setup" +/// #466: validate provider BASE_URL env vars +fn check_base_url_health() -> DiagnosticCheck { + let base_url_vars = [ + ("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), + ("OPENAI_BASE_URL", "https://api.openai.com"), + ("XAI_BASE_URL", "https://api.x.ai"), + ("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com"), + ]; + let mut issues: Vec = Vec::new(); + let mut details: Vec = Vec::new(); + for (var_name, default_url) in &base_url_vars { + if let Ok(value) = env::var(var_name) { + let trimmed = value.trim(); + if trimmed.is_empty() { + issues.push(format!("{var_name} is empty")); + details.push(format!( + "{var_name} empty (will use default: {default_url})" )); - } - Ok(CliAction::Setup { output_format }) - } - "export" => parse_export_args(&rest[1..], output_format), - "prompt" => { - let mut read_stdin = false; - let prompt_parts = rest[1..] - .iter() - .filter_map(|arg| { - if matches!(arg.as_str(), "--stdin" | "--prompt-stdin") { - read_stdin = true; - None - } else { - Some(arg.as_str()) - } - }) - .collect::>(); - let positional_prompt = prompt_parts.join(" "); - let stdin_prompt = if read_stdin || positional_prompt.trim().is_empty() { - read_piped_stdin() - } else { - None - }; - let prompt = if read_stdin { - merge_prompt_with_stdin(&positional_prompt, stdin_prompt.as_deref()) + } else if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") { + issues.push(format!("{var_name}={trimmed} is not a valid HTTP(S) URL")); + details.push(format!("{var_name} invalid ({trimmed})")); } else { - stdin_prompt - .as_deref() - .map(str::trim) - .unwrap_or(&positional_prompt) - .to_string() - }; - if prompt.trim().is_empty() { - // #750/#823/#423: provide error_kind-compatible prefix + \n for hint extraction. - return Err("missing_prompt: prompt subcommand requires a prompt string. -Usage: claw prompt or echo '' | claw prompt".to_string()); - } - Ok(CliAction::Prompt { - prompt, - model, - output_format, - allowed_tools, - permission_mode: 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_provenance(), - compact, - base_commit, - reasoning_effort, - allow_broad_cwd, - ), - other => { - if !compact - && !other.starts_with('-') - && looks_like_subcommand_typo(other) - && (rest.len() == 1 - || (output_format == CliOutputFormat::Json && model_flag_raw.is_none())) - { - // #825/#826: emit command_not_found before provider startup for - // command-shaped tokens that do not match known subcommands. - // Text-mode multi-word prompt shorthand remains available, but - // JSON-mode automation must not turn an unknown command into a - // credential-gated prompt request. - let mut message = format!("command_not_found: unknown subcommand: {other}."); - if let Some(suggestions) = suggest_similar_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() { - // #798: add \n hint so split_error_hint extracts it (was empty_prompt + null) - return Err( - "empty prompt: provide a subcommand or a non-empty prompt string.\nUsage: claw or claw -p . Run `claw --help` for the full list." - .to_string(), - ); + details.push(format!("{var_name} {trimmed}")); } - Ok(CliAction::Prompt { - prompt: joined, - model, - output_format, - allowed_tools, - permission_mode: permission_mode(), - compact, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }) } } -} - -fn parse_local_help_action( - rest: &[String], - output_format: CliOutputFormat, -) -> Option> { - if rest.is_empty() { - return None; - } - if !rest.iter().any(|a| is_help_flag(a)) { - return None; - } - - let topic = match rest[0].as_str() { - "status" => LocalHelpTopic::Status, - "sandbox" => LocalHelpTopic::Sandbox, - "doctor" => LocalHelpTopic::Doctor, - "acp" => LocalHelpTopic::Acp, - "init" => LocalHelpTopic::Init, - "setup" => LocalHelpTopic::Setup, - "state" => LocalHelpTopic::State, - "export" => LocalHelpTopic::Export, - "version" => LocalHelpTopic::Version, - "system-prompt" => LocalHelpTopic::SystemPrompt, - "dump-manifests" => LocalHelpTopic::DumpManifests, - "bootstrap-plan" => LocalHelpTopic::BootstrapPlan, - "resume" | "--resume" => LocalHelpTopic::Resume, - "session" => LocalHelpTopic::Session, - "compact" => LocalHelpTopic::Compact, - "model" | "models" => LocalHelpTopic::Model, - "settings" => LocalHelpTopic::Settings, - _ => return None, - }; - let has_non_help = rest[1..].iter().any(|a| !is_help_flag(a)); - if has_non_help { - return None; + if issues.is_empty() { + DiagnosticCheck::new( + "Base URLs", + DiagnosticLevel::Ok, + "provider base URL env vars are valid or unset", + ) + .with_details(details) + } else { + DiagnosticCheck::new( + "Base URLs", + DiagnosticLevel::Warn, + format!("{} base URL issue(s) found", issues.len()), + ) + .with_details(details) + .with_hint("Fix the reported BASE_URL env vars or unset them to use provider defaults.") } - Some(Ok(CliAction::HelpTopic { - topic, - output_format, - })) -} - -fn is_help_flag(value: &str) -> bool { - matches!(value, "--help" | "-h") } -fn parse_single_word_command_alias( - rest: &[String], - model: &str, - // #148: raw --model flag input for status provenance. None = no flag. - model_flag_raw: Option<&str>, - permission_mode_override: Option, - output_format: CliOutputFormat, - allowed_tools: Option, -) -> Option> { - if rest.is_empty() { - return None; - } - - // Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself - // or --help / -h as a suffix. Any other suffix args are unrecognized. - let verb = &rest[0]; - let is_diagnostic = matches!( - verb.as_str(), - "help" | "version" | "status" | "sandbox" | "doctor" | "setup" | "state" +fn check_mcp_validation_health(summary: &McpValidationSummary) -> DiagnosticCheck { + let mut details = vec![ + format!("Total entries {}", summary.total_configured), + format!("Valid entries {}", summary.valid_count), + format!("Invalid entries {}", summary.invalid_count()), + ]; + details.extend( + summary + .invalid_servers + .iter() + .map(|server| format!("Invalid server {} ({})", server.name, server.reason)), ); - if is_diagnostic && rest.len() > 1 { - // Diagnostic verb with trailing args: reject unrecognized suffix - let all_extra_are_help = rest[1..].iter().all(|a| is_help_flag(a)); - if all_extra_are_help { - // "doctor --help -h" is valid, routed to parse_local_help_action() instead - return None; - } - // #720: `claw help ` — when the verb is "help" and exactly one - // non-flag argument follows, try to route to the topic's handler. - if verb == "help" && rest.len() == 2 { - let topic_name = rest[1].as_str(); - let topic = match topic_name { - "status" => Some(LocalHelpTopic::Status), - "sandbox" => Some(LocalHelpTopic::Sandbox), - "doctor" => Some(LocalHelpTopic::Doctor), - "acp" => Some(LocalHelpTopic::Acp), - "init" => Some(LocalHelpTopic::Init), - "setup" => Some(LocalHelpTopic::Setup), - "state" => Some(LocalHelpTopic::State), - "export" => Some(LocalHelpTopic::Export), - "version" => Some(LocalHelpTopic::Version), - "system-prompt" => Some(LocalHelpTopic::SystemPrompt), - "dump-manifests" => Some(LocalHelpTopic::DumpManifests), - "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), - "resume" => Some(LocalHelpTopic::Resume), - "session" => Some(LocalHelpTopic::Session), - "compact" => Some(LocalHelpTopic::Compact), - "agents" | "agent" => Some(LocalHelpTopic::Agents), - "skills" | "skill" => Some(LocalHelpTopic::Skills), - "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), - "mcp" => Some(LocalHelpTopic::Mcp), - "config" => Some(LocalHelpTopic::Config), - "model" | "models" => Some(LocalHelpTopic::Model), - "settings" => Some(LocalHelpTopic::Settings), - "diff" => Some(LocalHelpTopic::Diff), - _ => None, - }; - if let Some(t) = topic { - return Some(Ok(CliAction::HelpTopic { - topic: t, - output_format, - })); - } - // Unknown topic: fall through to generic help. - return Some(Ok(CliAction::Help { output_format })); - } - // Unrecognized suffix like "--json" - let mut msg = format!( - "unrecognized argument `{}` for subcommand `{}`", - rest[1], verb - ); - // #152: common mistake — users type `--json` expecting JSON output. - // Hint at the correct flag so they don't have to re-read --help. - if rest[1] == "--json" { - msg.push_str("\nDid you mean `--output-format json`?"); - } else { - // #752: generic fallback hint so cli_parse errors always have non-null hint - msg.push_str(&format!("\nRun `claw {} --help` for usage.", verb)); - } - return Some(Err(msg)); - } - - // #720: `claw help ` — when `help` is the verb and a topic follows, - // try to route to the topic's help handler instead of erroring. - if rest.len() == 2 && rest[0] == "help" { - let topic_name = rest[1].as_str(); - let topic = match topic_name { - "status" => Some(LocalHelpTopic::Status), - "sandbox" => Some(LocalHelpTopic::Sandbox), - "doctor" => Some(LocalHelpTopic::Doctor), - "acp" => Some(LocalHelpTopic::Acp), - "init" => Some(LocalHelpTopic::Init), - "setup" => Some(LocalHelpTopic::Setup), - "state" => Some(LocalHelpTopic::State), - "export" => Some(LocalHelpTopic::Export), - "version" => Some(LocalHelpTopic::Version), - "system-prompt" => Some(LocalHelpTopic::SystemPrompt), - "dump-manifests" => Some(LocalHelpTopic::DumpManifests), - "bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan), - "resume" => Some(LocalHelpTopic::Resume), - "session" => Some(LocalHelpTopic::Session), - "compact" => Some(LocalHelpTopic::Compact), - "agents" | "agent" => Some(LocalHelpTopic::Agents), - "skills" | "skill" => Some(LocalHelpTopic::Skills), - "plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins), - "mcp" => Some(LocalHelpTopic::Mcp), - "config" => Some(LocalHelpTopic::Config), - "model" | "models" => Some(LocalHelpTopic::Model), - "settings" => Some(LocalHelpTopic::Settings), - "diff" => Some(LocalHelpTopic::Diff), - _ => None, - }; - if let Some(t) = topic { - return Some(Ok(CliAction::HelpTopic { - topic: t, - output_format, - })); - } - // Unknown topic falls through to the generic help action. - return Some(Ok(CliAction::Help { output_format })); - } - - // #453: fire guard for multi-word CLI subcommands too (claw cost list, claw model list, etc.) - // For slash commands that are commonly used as prompts (explain, cost, tokens, etc.), - // only fire the guard when there's exactly one token. - if rest.is_empty() { - return None; - } - // Known CLI subcommands that don't accept additional arguments - const CLI_SUBCOMMANDS: &[&str] = &[ - "help", "version", "status", "sandbox", "doctor", "state", "config", "diff", - ]; - if rest.len() > 1 && !CLI_SUBCOMMANDS.contains(&rest[0].as_str()) { - return None; - } - - match rest[0].as_str() { - "help" => Some(Ok(CliAction::Help { output_format })), - "version" => Some(Ok(CliAction::Version { output_format })), - "status" => Some(Ok(CliAction::Status { - model: model.to_string(), - model_flag_raw: model_flag_raw.map(str::to_string), // #148 - permission_mode: permission_mode_override - .map(PermissionModeProvenance::from_flag) - .unwrap_or_else(permission_mode_provenance_for_current_dir), - output_format, - allowed_tools, - })), - "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), - "doctor" => Some(Ok(CliAction::Doctor { - output_format, - permission_mode: permission_mode_override - .map(PermissionModeProvenance::from_flag) - .unwrap_or_else(permission_mode_provenance_for_current_dir), - })), - "setup" => Some(Ok(CliAction::Setup { output_format })), - "state" => Some(Ok(CliAction::State { output_format })), - // #146: let `config` and `diff` fall through to parse_subcommand - // where they are wired as pure-local introspection, instead of - // producing the "is a slash command" guidance. Zero-arg cases - // reach parse_subcommand too via this None. - "config" | "diff" => None, - other => bare_slash_command_guidance(other).map(Err), - } -} - -fn bare_slash_command_guidance(command_name: &str) -> Option { - if matches!( - command_name, - "dump-manifests" - | "bootstrap-plan" - | "agents" - | "mcp" - | "plugin" - | "plugins" - | "marketplace" - | "skills" - | "system-prompt" - | "init" - | "prompt" - | "export" - ) { - return None; - } - let slash_command = slash_command_specs() - .iter() - // #772: check both spec.name and spec.aliases for command-line invocations - .find(|spec| spec.name == command_name || spec.aliases.contains(&command_name))?; - let canonical_name = slash_command.name; - // #745: newline before remediation text so split_error_hint populates hint field - let guidance = if slash_command.resume_supported { - format!( - "`claw {command_name}` is a slash command.\nUse `claw --resume SESSION.jsonl /{canonical_name}` or start `claw` and run `/{canonical_name}`." - ) - } else { - format!( - "`claw {command_name}` is a slash command.\nStart `claw` and run `/{canonical_name}` inside the REPL." - ) - }; - // #772: help text still mentions the alias, but the remediation shows canonical form - Some(guidance) -} - -fn compact_interactive_only_error() -> String { - // #749: newline before remediation so split_error_hint populates hint field - "interactive_only: `claw compact` is an interactive/session command.\nStart `claw` and run `/compact`, or use `claw --resume SESSION.jsonl /compact` to compact an existing session." - .to_string() -} - -fn removed_auth_surface_error(command_name: &str) -> String { - // #765: two-line format so split_error_hint() extracts hint into JSON envelope - format!( - "`claw {command_name}` has been removed.\nSet ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead." - ) -} - -fn unexpected_diff_args_error(extra: &[String]) -> String { - format!( - "unexpected extra arguments after `claw diff`: {}\nUsage: claw diff", - extra.join(" ") - ) -} - -fn parse_acp_args(args: &[String], output_format: CliOutputFormat) -> Result { - match args { - [] => Ok(CliAction::Acp { output_format }), - [subcommand] if subcommand == "serve" => Ok(CliAction::Acp { output_format }), - _ => Err(String::from( - "unsupported_acp_invocation: unsupported ACP invocation. Use `claw acp` or `claw acp serve`.\nACP/Zed editor integration is not implemented yet; `claw acp serve` reports status only.", - )), - } -} - -fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option { - let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default(); - let looks_like_skill_name = !bare_first_token.is_empty() - && !bare_first_token.starts_with('/') - && bare_first_token - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_'); - if !looks_like_skill_name { - return None; - } - match resolve_skill_invocation(cwd, Some(trimmed)) { - Ok(SkillSlashDispatch::Invoke(prompt)) => Some(prompt), - _ => None, - } -} - -fn join_optional_args(args: &[String]) -> Option { - let joined = args.join(" "); - let trimmed = joined.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) -} - -#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] -fn parse_direct_slash_cli_action( - rest: &[String], - model: String, - output_format: CliOutputFormat, - allowed_tools: Option, - permission_mode: PermissionModeProvenance, - compact: bool, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, -) -> Result { - let raw = rest.join(" "); - match SlashCommand::parse(&raw) { - Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }), - Ok(Some(SlashCommand::Status)) => Ok(CliAction::Status { - model, - model_flag_raw: None, - permission_mode, - output_format, - allowed_tools, - }), - Ok(Some(SlashCommand::Sandbox)) => Ok(CliAction::Sandbox { output_format }), - Ok(Some(SlashCommand::Diff)) => Ok(CliAction::Diff { output_format }), - Ok(Some(SlashCommand::Version)) => Ok(CliAction::Version { output_format }), - Ok(Some(SlashCommand::Doctor)) => Ok(CliAction::Doctor { - output_format, - permission_mode, - }), - Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { - args, - output_format, - }), - Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp { - args: match (action, target) { - (None, None) => None, - (Some(action), None) => Some(action), - (Some(action), Some(target)) => Some(format!("{action} {target}")), - (None, Some(target)) => Some(target), - }, - output_format, - }), - Ok(Some(SlashCommand::Skills { args })) => { - match classify_skills_slash_command(args.as_deref()) { - SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { - prompt, - model, - output_format, - allowed_tools, - permission_mode: permission_mode.mode, - compact, - base_commit, - reasoning_effort: reasoning_effort.clone(), - allow_broad_cwd, - }), - SkillSlashDispatch::Local => Ok(CliAction::Skills { - args, - output_format, - }), - } - } - Ok(Some(SlashCommand::Unknown(name))) => { - // #828: /approve and /deny are valid REPL-only slash commands that - // are not SlashCommand enum variants (they require an active tool - // call in the REPL to be meaningful). Emit interactive_only so - // machine consumers see the correct error_kind instead of - // unknown_slash_command. - if matches!(name.as_str(), "approve" | "yes" | "y" | "deny" | "no" | "n") { - Err(format!( - "interactive_only: /{name} requires an active tool call in the REPL.\nStart `claw` and use /{name} to approve or deny a pending tool execution." - )) - } else { - Err(format_unknown_direct_slash_command(&name)) - } - } - Ok(Some(command)) => Err({ - let _ = command; - let command_name = &rest[0]; - // #829: only suggest --resume when the command is actually - // resume-safe. Non-resume-safe commands (e.g. /commit, /pr) - // previously suggested --resume, which just re-triggered - // interactive_only on a second invocation. - let bare_name = command_name.trim_start_matches('/'); - let is_resume_safe = commands::resume_supported_slash_commands() - .iter() - .any(|spec| spec.name == bare_name); - if is_resume_safe { - format!( - // #738: newline before remediation so split_error_hint populates hint field - "interactive_only: slash command {command_name} requires a live session.\nStart `claw` and run it there, or use `claw --resume SESSION.jsonl {command_name}` / `claw --resume {latest} {command_name}`.", - latest = LATEST_SESSION_REFERENCE, - ) - } else { - format!( - "interactive_only: slash command {command_name} requires a live REPL session.\nStart `claw` and run it there." - ) - } - }), - Ok(None) => Err(format!("unknown subcommand: {}", rest[0])), - Err(error) => Err(error.to_string()), - } -} - -fn format_unknown_option(option: &str) -> String { - if option == "--" { - return "end_of_flags: `--` terminates flag parsing. Pass literal prompt text after it, for example `claw -- \"-literal prompt\"`.\nRun `claw --help` for usage.".to_string(); - } - let mut message = format!("unknown option: {option}"); - if let Some(suggestion) = suggest_closest_term(option, CLI_OPTION_SUGGESTIONS) { - message.push_str("\nDid you mean "); - message.push_str(suggestion); - message.push('?'); - } - message.push_str("\nRun `claw --help` for usage."); - message -} - -fn format_unknown_direct_slash_command(name: &str) -> String { - // #827: prefix with classifier-friendly token so classify_error_kind - // returns "unknown_slash_command" instead of the opaque fallback. - let mut message = - format!("unknown_slash_command: 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 -} - -fn format_unknown_slash_command(name: &str) -> String { - // #827: prefix with classifier-friendly token so classify_error_kind - // can return "unknown_slash_command" instead of the opaque fallback. - let mut message = format!("unknown_slash_command: 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 -} - -fn omc_compatibility_note_for_unknown_slash_command(name: &str) -> Option<&'static str> { - name.starts_with("oh-my-claudecode:") - .then_some( - "Compatibility note: `/oh-my-claudecode:*` is a Claude Code/OMC plugin command. `claw` does not yet load plugin slash commands, Claude statusline stdin, or OMC session hooks.", - ) -} - -fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option { - (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),)) -} - -fn suggest_slash_commands(input: &str) -> Vec { - let mut candidates = slash_command_specs() - .iter() - .flat_map(|spec| { - std::iter::once(spec.name) - .chain(spec.aliases.iter().copied()) - .map(|name| format!("/{name}")) - .collect::>() - }) - .collect::>(); - candidates.sort(); - candidates.dedup(); - let candidate_refs = candidates.iter().map(String::as_str).collect::>(); - ranked_suggestions(input.trim_start_matches('/'), &candidate_refs) - .into_iter() - .map(str::to_string) - .collect() -} - -fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> { - ranked_suggestions(input, candidates).into_iter().next() -} - -fn suggest_similar_subcommand(input: &str) -> Option> { - const KNOWN_SUBCOMMANDS: &[&str] = &[ - "help", - "version", - "status", - "sandbox", - "doctor", - "setup", - "state", - "dump-manifests", - "bootstrap-plan", - "agents", - "mcp", - "skills", - "system-prompt", - "acp", - "init", - "export", - "prompt", - "list", - ]; - - let normalized_input = input.to_ascii_lowercase(); - let mut ranked = KNOWN_SUBCOMMANDS - .iter() - .filter_map(|candidate| { - let normalized_candidate = candidate.to_ascii_lowercase(); - let distance = levenshtein_distance(&normalized_input, &normalized_candidate); - let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4; - let substring_match = normalized_candidate.contains(&normalized_input) - || normalized_input.contains(&normalized_candidate); - ((distance <= 2) || prefix_match || substring_match).then_some((distance, *candidate)) - }) - .collect::>(); - ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); - ranked.dedup_by(|left, right| left.1 == right.1); - let suggestions = ranked - .into_iter() - .map(|(_, candidate)| candidate.to_string()) - .take(3) - .collect::>(); - (!suggestions.is_empty()).then_some(suggestions) -} - -fn is_known_top_level_subcommand(value: &str) -> bool { - matches!( - value, - "help" - | "version" - | "status" - | "sandbox" - | "doctor" - | "state" - | "dump-manifests" - | "bootstrap-plan" - | "agents" - | "agent" - | "mcp" - | "skills" - | "skill" - | "plugins" - | "plugin" - | "marketplace" - | "system-prompt" - | "acp" - | "init" - | "export" - | "prompt" - | "resume" - | "session" - | "compact" - | "config" - | "model" - | "models" - | "settings" - | "diff" - ) -} - -fn common_prefix_len(left: &str, right: &str) -> usize { - left.chars() - .zip(right.chars()) - .take_while(|(l, r)| l == r) - .count() -} - -fn looks_like_subcommand_typo(input: &str) -> bool { - !input.is_empty() - && input - .chars() - .all(|ch| ch.is_ascii_alphabetic() || ch == '-') -} - -fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> { - let normalized_input = input.trim_start_matches('/').to_ascii_lowercase(); - let mut ranked = candidates - .iter() - .filter_map(|candidate| { - let normalized_candidate = candidate.trim_start_matches('/').to_ascii_lowercase(); - let distance = levenshtein_distance(&normalized_input, &normalized_candidate); - let prefix_bonus = usize::from( - !(normalized_candidate.starts_with(&normalized_input) - || normalized_input.starts_with(&normalized_candidate)), - ); - let score = distance + prefix_bonus; - (score <= 4).then_some((score, *candidate)) - }) - .collect::>(); - ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1))); - ranked - .into_iter() - .map(|(_, candidate)| candidate) - .take(3) - .collect() -} - -fn levenshtein_distance(left: &str, right: &str) -> usize { - if left.is_empty() { - return right.chars().count(); - } - if right.is_empty() { - return left.chars().count(); - } - - let right_chars = right.chars().collect::>(); - let mut previous = (0..=right_chars.len()).collect::>(); - let mut current = vec![0; right_chars.len() + 1]; - - for (left_index, left_char) in left.chars().enumerate() { - current[0] = left_index + 1; - for (right_index, right_char) in right_chars.iter().enumerate() { - let substitution_cost = usize::from(left_char != *right_char); - current[right_index + 1] = (previous[right_index + 1] + 1) - .min(current[right_index] + 1) - .min(previous[right_index] + substitution_cost); - } - previous.clone_from(¤t); - } - - previous[right_chars.len()] -} - -fn resolve_model_alias(model: &str) -> &str { - match model { - "opus" => "anthropic/claude-opus-4-7", - "sonnet" => "anthropic/claude-sonnet-4-6", - "haiku" => "anthropic/claude-haiku-4-5-20251213", - _ => model, - } -} - -/// Resolve a model name through user-defined config aliases first, then fall -/// back to the built-in alias table. This is the entry point used wherever a -/// user-supplied model string is about to be dispatched to a provider. -fn resolve_model_alias_with_config(model: &str) -> String { - let trimmed = model.trim(); - if let Some(resolved) = config_alias_for_current_dir(trimmed) { - return resolve_model_alias(&resolved).to_string(); - } - resolve_model_alias(trimmed).to_string() -} - -/// Validate model syntax at parse time. -/// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern. -/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars. -fn validate_model_syntax(model: &str) -> Result<(), String> { - let trimmed = model.trim(); - // Ollama models use names like "qwen3:8b" that don't match provider/model - // syntax. Skip strict validation when OLLAMA_HOST is configured. - if std::env::var_os("OLLAMA_HOST").is_some() { - if trimmed.is_empty() { - return Err("invalid model syntax: model string cannot be empty.\nUsage: --model e.g. --model qwen3:8b".to_string()); - } - return Ok(()); - } - if trimmed.is_empty() { - return Err("invalid model syntax: model string cannot be empty.\nUsage: --model e.g. --model anthropic/claude-opus-4-7".to_string()); - } - // Check for spaces (malformed) - if trimmed.contains(' ') { - return Err(format!( - "invalid model syntax: '{}' contains spaces.\nUse provider/model format (e.g., anthropic/claude-opus-4-7) or a known alias.", - trimmed - )); - } - if is_bare_provider_model(trimmed) { - return Ok(()); - } - if is_local_openai_model_syntax(trimmed) { - return Ok(()); - } - // Check provider/model format: provider_id/model_id - let parts: Vec<&str> = trimmed.split('/').collect(); - if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { - // #154: hint if the model looks like it belongs to a different provider - let mut err_msg = format!( - "invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-7)", - trimmed - ); - if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") { - err_msg.push_str("\nDid you mean `openai/"); - err_msg.push_str(trimmed); - err_msg.push_str("`? (Requires OPENAI_API_KEY env var)"); - } else if trimmed.starts_with("qwen") && trimmed.contains(':') { - err_msg.push_str("\nFor a local Ollama model, set `OPENAI_BASE_URL=http://127.0.0.1:11434/v1` before using tagged names like `"); - err_msg.push_str(trimmed); - err_msg.push_str("`."); - } else if trimmed.starts_with("qwen") { - err_msg.push_str("\nDid you mean `qwen/"); - err_msg.push_str(trimmed); - err_msg.push_str("`? (Requires DASHSCOPE_API_KEY env var)"); - } else if trimmed.starts_with("grok") { - err_msg.push_str("\nDid you mean `xai/"); - err_msg.push_str(trimmed); - err_msg.push_str("`? (Requires XAI_API_KEY env var)"); - } - return Err(err_msg); - } - Ok(()) -} - -fn is_bare_provider_model(model: &str) -> bool { - model.starts_with("claude-") || model.starts_with("gpt-") -} - -fn is_local_openai_model_syntax(model: &str) -> bool { - if let Some(rest) = model.strip_prefix("local/") { - return !rest.is_empty() && rest.split('/').all(|segment| !segment.is_empty()); - } - std::env::var_os("OPENAI_BASE_URL").is_some() && (model.contains(':') || model.contains('.')) -} - -fn config_alias_for_current_dir(alias: &str) -> Option { - if alias.is_empty() { - return None; - } - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - let config = loader.load().ok()?; - config.aliases().get(alias).cloned() -} - -fn normalize_allowed_tools(values: &[String]) -> Result, String> { - if values.is_empty() { - return Ok(None); - } - current_tool_registry()?.normalize_allowed_tools(values) -} - -fn allowed_tools_missing_error() -> String { - "missing_argument: --allowedTools requires a tool list before subcommands or flags.\nUsage: --allowedTools [,...] e.g. --allowedTools read,glob".to_string() -} - -fn compact_missing_argument_error() -> String { - "missing_argument: --compact requires prompt text, piped stdin, or a subcommand. argument: prompt or subcommand\nUsage: claw --compact or echo '' | claw --compact" - .to_string() -} - -fn allowed_tool_aliases_json(registry: &GlobalToolRegistry) -> Value { - Value::Object( - registry - .allowed_tool_aliases() - .into_iter() - .map(|(alias, canonical)| (alias, Value::String(canonical))) - .collect(), - ) -} - -fn current_tool_registry() -> Result { - let cwd = env::current_dir().map_err(|error| error.to_string())?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load().map_err(|error| error.to_string())?; - let state = build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) - .map_err(|error| error.to_string())?; - let registry = state.tool_registry.clone(); - if let Some(mcp_state) = state.mcp_state { - mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .shutdown() - .map_err(|error| error.to_string())?; - } - Ok(registry) -} - -fn parse_permission_mode_arg(value: &str) -> Result { - normalize_permission_mode(value) - .ok_or_else(|| { - format!( - "invalid_permission_mode: unsupported permission mode '{value}'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access" - ) - }) - .map(permission_mode_from_label) -} - -fn permission_mode_from_label(mode: &str) -> PermissionMode { - match mode { - "read-only" => PermissionMode::ReadOnly, - "workspace-write" => PermissionMode::WorkspaceWrite, - "danger-full-access" => PermissionMode::DangerFullAccess, - other => panic!("unsupported permission mode label: {other}"), - } -} - -fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode { - match mode { - ResolvedPermissionMode::ReadOnly => PermissionMode::ReadOnly, - ResolvedPermissionMode::WorkspaceWrite => PermissionMode::WorkspaceWrite, - ResolvedPermissionMode::DangerFullAccess => PermissionMode::DangerFullAccess, - } -} - -fn default_permission_mode() -> PermissionMode { - permission_mode_provenance_for_current_dir().mode -} - -fn permission_mode_provenance_for_current_dir() -> PermissionModeProvenance { - if let Some(mode) = env::var("RUSTY_CLAUDE_PERMISSION_MODE") - .ok() - .as_deref() - .and_then(normalize_permission_mode) - .map(permission_mode_from_label) - { - return PermissionModeProvenance { - mode, - source: PermissionModeSource::Env, - env_var: Some("RUSTY_CLAUDE_PERMISSION_MODE"), - }; - } - - if let Some(mode) = config_permission_mode_for_current_dir() { - return PermissionModeProvenance { - mode, - source: PermissionModeSource::Config, - env_var: None, - }; - } - - PermissionModeProvenance::default_fallback() -} - -fn config_permission_mode_for_current_dir() -> Option { - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - loader - .load() - .ok()? - .permission_mode() - .map(permission_mode_from_resolved) -} - -fn config_model_for_current_dir() -> Option { - let cwd = env::current_dir().ok()?; - let loader = ConfigLoader::default_for(&cwd); - loader.load().ok()?.model().map(ToOwned::to_owned) -} - -fn resolve_repl_model(cli_model: String) -> Result { - Ok(ModelProvenance::from_env_or_config_or_default(&cli_model)?.resolved) -} - -fn print_model_validation_warning_status( - error: &str, - usage: StatusUsage, - permission_mode: &str, - context: &StatusContext, - allowed_tools: Option<&AllowedToolSet>, -) -> Result<(), Box> { - let kind = classify_error_kind(error); - let (short_reason, inline_hint) = split_error_hint(error); - let hint = inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from)); - let format_selection = current_output_format_selection(); - let mut value = status_json_value( - None, - usage, - permission_mode, - context, - None, - None, - allowed_tools, - Some(&format_selection), - ); - let object = value - .as_object_mut() - .expect("status_json_value should render an object"); - object.insert("status".to_string(), serde_json::json!("warn")); - object.insert("error_kind".to_string(), serde_json::json!(kind)); - object.insert( - "model_validation_error".to_string(), - serde_json::json!(short_reason), - ); - object.insert( - "model_validation_error_kind".to_string(), - serde_json::json!(kind), - ); - object.insert("model_validation_hint".to_string(), serde_json::json!(hint)); - println!("{}", serde_json::to_string_pretty(&value)?); - Ok(()) -} - -fn provider_label(kind: ProviderKind) -> &'static str { - match kind { - ProviderKind::Anthropic => "anthropic", - ProviderKind::Xai => "xai", - ProviderKind::OpenAi => "openai", - } -} - -fn format_connected_line(model: &str) -> String { - let provider = provider_label(detect_provider_kind(model)); - format!("Connected: {model} via {provider}") -} - -fn filter_tool_specs( - tool_registry: &GlobalToolRegistry, - allowed_tools: Option<&AllowedToolSet>, -) -> Vec { - tool_registry.definitions(allowed_tools) -} - -fn parse_system_prompt_args( - args: &[String], - model: String, - output_format: CliOutputFormat, -) -> Result { - let mut cwd = env::current_dir().map_err(|error| error.to_string())?; - let mut date = DEFAULT_DATE.to_string(); - let mut index = 0; - - while index < args.len() { - match args[index].as_str() { - "--cwd" => { - let value = args.get(index + 1).ok_or_else(|| { - "missing_flag_value: missing value for --cwd.\nUsage: --cwd ".to_string() - })?; - cwd = PathBuf::from(value); - // #99: validate --cwd path exists and is a directory - if !cwd.exists() { - return Err(format!( - "invalid_cwd: path '{value}' does not exist.\nUsage: claw system-prompt --cwd " - )); - } - if !cwd.is_dir() { - return Err(format!( - "invalid_cwd: path '{value}' is not a directory.\nUsage: claw system-prompt --cwd " - )); - } - index += 2; - } - "--date" => { - let value = args.get(index + 1).ok_or_else(|| { - "missing_flag_value: missing value for --date.\nUsage: --date " - .to_string() - })?; - // #99: validate --date is a plausible date string (no newlines, reasonable length) - if value.contains('\n') || value.contains('\r') { - return Err(format!( - "invalid_flag_value: --date value contains invalid characters.\nUsage: --date " - )); - } - if value.len() > 20 { - return Err(format!( - "invalid_flag_value: --date value is too long ({len} chars, expected YYYY-MM-DD).\nUsage: --date ", - len = value.len() - )); - } - date.clone_from(value); - index += 2; - } - - other => { - // #152: hint `--output-format json` when user types `--json`. - // #790: use unknown_option: prefix + \n hint so classify_error_kind returns - // unknown_option and split_error_hint extracts the remediation text. - let hint = if other == "--json" { - "Did you mean `--output-format json`? Usage: claw system-prompt [--cwd ] [--date ] [--output-format text|json]".to_string() - } else { - "Usage: claw system-prompt [--cwd ] [--date ] [--output-format text|json]".to_string() - }; - return Err(format!( - "unknown_option: unknown system-prompt option: {other}.\n{hint}" - )); - } - } - } - - Ok(CliAction::PrintSystemPrompt { - cwd, - date, - model, - output_format, - }) -} - -fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result { - let mut session_reference = LATEST_SESSION_REFERENCE.to_string(); - let mut output_path: Option = None; - let mut index = 0; - - while index < args.len() { - match args[index].as_str() { - "--session" => { - let value = args - .get(index + 1) - .ok_or_else(|| "missing_flag_value: missing value for --session.\nUsage: --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_flag_value: missing value for {}.\nUsage: claw export [PATH] [--session SESSION] [--output PATH]", 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_option: unknown export option: {other}.\nRun `claw export --help` for usage.")); - } - other if output_path.is_none() => { - output_path = Some(PathBuf::from(other)); - index += 1; - } - other => { - // #784: use typed prefix so classify_error_kind returns unexpected_extra_args - return Err(format!("unexpected_extra_args: unexpected export argument: {other}.\nUsage: claw export [PATH] [--session SESSION] [--output PATH]")); - } - } - } - - Ok(CliAction::Export { - session_reference, - output_path, - output_format, - }) -} - -fn parse_dump_manifests_args( - args: &[String], - output_format: CliOutputFormat, -) -> Result { - let mut manifests_dir: Option = None; - let mut index = 0; - while index < args.len() { - let arg = &args[index]; - if arg == "--manifests-dir" { - let value = args - .get(index + 1) - .ok_or_else(|| String::from("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir [--output-format json]"))?; - manifests_dir = Some(PathBuf::from(value)); - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--manifests-dir=") { - if value.is_empty() { - // #786: empty --manifests-dir= is also a missing value - return Err(String::from("missing_flag_value: --manifests-dir requires a path.\nUsage: claw dump-manifests --manifests-dir [--output-format json]")); - } - manifests_dir = Some(PathBuf::from(value)); - index += 1; - continue; - } - return Err(format!("unknown_option: unknown dump-manifests option: {arg}.\nRun `claw dump-manifests --help` for usage.")); - } - - Ok(CliAction::DumpManifests { - output_format, - manifests_dir, - }) -} - -fn parse_resume_args( - args: &[String], - output_format: CliOutputFormat, - allow_broad_cwd: bool, -) -> Result { - let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() { - None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]), - Some(first) if looks_like_slash_command_token(first) => { - (PathBuf::from(LATEST_SESSION_REFERENCE), args) - } - Some(first) => (PathBuf::from(first), &args[1..]), - }; - let mut commands = Vec::new(); - let mut current_command = String::new(); - - for token in command_tokens { - if token.trim_start().starts_with('/') { - if resume_command_can_absorb_token(¤t_command, token) { - current_command.push(' '); - current_command.push_str(token); - continue; - } - if !current_command.is_empty() { - commands.push(current_command); - } - current_command = String::from(token.as_str()); - continue; - } - - if current_command.is_empty() { - // #768: typed prefix + \n hint so split_error_hint() extracts hint into JSON envelope - return Err(format!( - "invalid_resume_argument: `{token}` is not a slash command.\nUsage: claw --resume / (e.g. /compact, /status)" - )); - } - - current_command.push(' '); - current_command.push_str(token); - } - - if !current_command.is_empty() { - commands.push(current_command); - } - - Ok(CliAction::ResumeSession { - session_path, - commands, - output_format, - allow_broad_cwd, - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DiagnosticLevel { - Ok, - Warn, - Fail, -} - -impl DiagnosticLevel { - fn label(self) -> &'static str { - match self { - Self::Ok => "ok", - Self::Warn => "warn", - Self::Fail => "fail", - } - } - - fn is_failure(self) -> bool { - matches!(self, Self::Fail) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct DiagnosticCheck { - name: &'static str, - level: DiagnosticLevel, - summary: String, - details: Vec, - data: Map, - /// #778: stable remediation hint for warn/fail checks so automation can read - /// a structured field instead of parsing details_prose. - hint: Option, -} - -impl DiagnosticCheck { - fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into) -> Self { - Self { - name, - level, - summary: summary.into(), - details: Vec::new(), - data: Map::new(), - hint: None, - } - } - - fn with_details(mut self, details: Vec) -> Self { - self.details = details; - self - } - - fn with_data(mut self, data: Map) -> Self { - self.data = data; - self - } - - fn with_hint(mut self, hint: impl Into) -> Self { - let h = hint.into(); - if !h.is_empty() { - self.hint = Some(h); - } - self - } - - fn json_value(&self) -> Value { - // Derive a stable snake_case id from the check name for machine-readable keying (#704). - let id = self - .name - .to_ascii_lowercase() - .replace(' ', "_") - .replace('-', "_"); - let mut value = Map::from_iter([ - ("id".to_string(), Value::String(id.clone())), - ( - "name".to_string(), - Value::String(self.name.to_ascii_lowercase()), - ), - ( - "status".to_string(), - Value::String(self.level.label().to_string()), - ), - ("summary".to_string(), Value::String(self.summary.clone())), - ( - // #701 (complete): `details[]` is now the canonical structured form — - // `{key, value}` objects instead of padded prose strings. The legacy - // prose representation is preserved as `details_prose[]` for callers - // that still scrape the formatted strings. - "details_prose".to_string(), - Value::Array( - self.details - .iter() - .cloned() - .map(Value::String) - .collect::>(), - ), - ), - ( - // details[] is now structured {key,value} objects (was prose strings). - "details".to_string(), - Value::Array( - self.details - .iter() - .map(|s| { - // Split on first run of 2+ spaces to separate key from value. - let parts: Vec<&str> = s.splitn(2, " ").collect(); - if parts.len() == 2 { - let k = parts[0].trim().to_string(); - let v_str = parts[1].trim(); - let v: Value = if v_str == "true" { - Value::Bool(true) - } else if v_str == "false" { - Value::Bool(false) - } else if let Ok(n) = v_str.parse::() { - Value::Number(n.into()) - } else { - Value::String(v_str.to_string()) - }; - json!({"key": k, "value": v}) - } else { - json!({"key": s.trim(), "value": Value::Null}) - } - }) - .collect::>(), - ), - ), - ]); - // #778: include hint field so automation can read remediation without parsing prose - value.insert( - "hint".to_string(), - self.hint - .as_deref() - .map(|h| Value::String(h.to_string())) - .unwrap_or(Value::Null), - ); - value.extend(self.data.clone()); - Value::Object(value) - } -} - -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -enum ConfigWarningMode { - EmitStderr, - SuppressStderr, -} - -fn load_config_with_warning_mode( - loader: &ConfigLoader, - mode: ConfigWarningMode, -) -> Result { - match mode { - ConfigWarningMode::EmitStderr => loader.load(), - ConfigWarningMode::SuppressStderr => loader - .load_collecting_warnings() - .map(|(runtime_config, _warnings)| runtime_config), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct DoctorReport { - checks: Vec, -} - -impl DoctorReport { - fn counts(&self) -> (usize, usize, usize) { - ( - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Ok) - .count(), - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Warn) - .count(), - self.checks - .iter() - .filter(|check| check.level == DiagnosticLevel::Fail) - .count(), - ) - } - - fn has_failures(&self) -> bool { - self.checks.iter().any(|check| check.level.is_failure()) - } - - fn status(&self) -> &'static str { - let (_, warn_count, fail_count) = self.counts(); - if fail_count > 0 { - "fail" - } else if warn_count > 0 { - "warn" - } else { - "ok" - } - } - - fn render(&self) -> String { - let (ok_count, warn_count, fail_count) = self.counts(); - let mut lines = vec![ - "Doctor".to_string(), - format!( - "Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}" - ), - ]; - lines.extend(self.checks.iter().map(render_diagnostic_check)); - lines.join("\n\n") - } - - fn json_value(&self) -> Value { - let report = self.render(); - let (ok_count, warn_count, fail_count) = self.counts(); - let tool_registry = GlobalToolRegistry::builtin(); - json!({ - "kind": "doctor", - "action": "doctor", - "status": self.status(), - "message": report, - "report": report, - "has_failures": self.has_failures(), - "summary": { - "total": self.checks.len(), - "ok": ok_count, - "warnings": warn_count, - "failures": fail_count, - }, - "checks": self - .checks - .iter() - .map(DiagnosticCheck::json_value) - .collect::>(), - "allowed_tools": { - "available": tool_registry.canonical_allowed_tool_names(), - "aliases": allowed_tool_aliases_json(&tool_registry), - }, - }) - } -} - -fn render_diagnostic_check(check: &DiagnosticCheck) -> String { - let mut lines = vec![format!( - "{}\n Status {}\n Summary {}", - check.name, - check.level.label(), - check.summary - )]; - if !check.details.is_empty() { - lines.push(" Details".to_string()); - lines.extend(check.details.iter().map(|detail| format!(" - {detail}"))); - } - lines.join("\n") -} - -fn render_doctor_report( - config_warning_mode: ConfigWarningMode, - permission_mode: PermissionModeProvenance, -) -> Result> { - let cwd = friendly_cwd(env::current_dir()?); - let config_loader = ConfigLoader::default_for(&cwd); - let config = load_config_with_warning_mode(&config_loader, config_warning_mode); - let discovered_config = config_loader.discover(); - let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; - let (project_root, git_branch) = - parse_git_status_metadata(project_context.git_status.as_deref()); - let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); - let branch_freshness = BranchFreshness::from_git_status(project_context.git_status.as_deref()); - let stale_base_state = stale_base_state_for(&cwd, None); - let empty_config = runtime::RuntimeConfig::empty(); - let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config); - let boot_preflight = build_boot_preflight_snapshot( - &cwd, - project_root.as_deref(), - project_context.git_status.as_deref(), - config.as_ref().ok(), - config.as_ref().err().map(ToString::to_string).as_deref(), - ); - let memory_files = memory_file_summaries_for( - &cwd, - project_root.as_deref(), - &project_context.instruction_files, - ); - let mcp_validation = config - .as_ref() - .ok() - .map(|runtime_config| McpValidationSummary::from_collection(runtime_config.mcp())) - .unwrap_or_default(); - let hook_validation = config - .as_ref() - .ok() - .map(HookValidationSummary::from_config) - .unwrap_or_default(); - let context = StatusContext { - cwd: cwd.clone(), - session_path: None, - loaded_config_files: config - .as_ref() - .ok() - .map_or(0, |runtime_config| runtime_config.loaded_entries().len()), - discovered_config_files: discovered_config.len(), - memory_file_count: project_context.instruction_files.len(), - memory_files: memory_files.clone(), - unloaded_memory_files: unloaded_memory_candidates( - &cwd, - project_root.as_deref(), - &memory_files, - ), - project_root, - git_branch, - git_summary, - branch_freshness, - stale_base_state, - session_lifecycle: classify_session_lifecycle_for(&cwd), - boot_preflight, - sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd), - binary_provenance: binary_provenance_for(Some(&cwd)), - // Doctor path has its own config check; StatusContext here is only - // fed into health renderers that don't read config_load_error. - config_load_error: config.as_ref().err().map(ToString::to_string), - config_load_error_kind: None, - mcp_validation: mcp_validation.clone(), - - hook_validation: hook_validation.clone(), - duplicate_flags: Vec::new(), - }; - Ok(DoctorReport { - checks: vec![ - check_auth_health(), - check_base_url_health(), - check_config_health(&config_loader, config.as_ref()), - check_mcp_validation_health(&mcp_validation), - check_hook_validation_health(&hook_validation), - check_install_source_health(), - check_workspace_health(&context), - check_memory_health(&context), - check_boot_preflight_health(&context), - check_sandbox_health(&context.sandbox_status), - check_permission_health(permission_mode), - check_system_health(&cwd, config.as_ref().ok()), - ], - }) -} - -fn run_doctor( - output_format: CliOutputFormat, - permission_mode: PermissionModeProvenance, -) -> Result<(), Box> { - let report = render_doctor_report( - match output_format { - CliOutputFormat::Json => ConfigWarningMode::SuppressStderr, - CliOutputFormat::Text => ConfigWarningMode::EmitStderr, - }, - permission_mode, - )?; - let message = report.render(); - match output_format { - CliOutputFormat::Text => println!("{message}"), - CliOutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&report.json_value())?); - } - } - if report.has_failures() { - return Err("doctor found failing checks".into()); - } - Ok(()) -} - -/// Run the interactive setup wizard to configure provider, API key, and model. -fn run_setup() -> Result<(), Box> { - setup_wizard::run_setup_wizard() -} - -/// Starts a minimal Model Context Protocol server that exposes claw's -/// built-in tools over stdio. -/// -/// Tool descriptors come from [`tools::mvp_tool_specs`] and calls are -/// dispatched through [`tools::execute_tool`], so this server exposes exactly -/// Read `.claw/worker-state.json` from the current working directory and print it. -/// This is the file-based worker observability surface: `push_event()` in `worker_boot.rs` -/// atomically writes state transitions here so external observers (clawhip, orchestrators) -/// can poll current `WorkerStatus` without needing an HTTP route on the opencode binary. -fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box> { - let cwd = env::current_dir()?; - let state_path = cwd.join(".claw").join("worker-state.json"); - if !state_path.exists() { - // #139: this error used to say "run a worker first" without telling - // callers how to run one. "worker" is an internal concept (there is - // no `claw worker` subcommand), so claws/CI had no discoverable path - // from the error to a fix. Emit an actionable, structured error that - // names the two concrete commands that produce worker state. - // - // Format in both text and JSON modes is stable so scripts can match: - // error: no worker state file found at - // Hint: worker state is written by the interactive REPL or a non-interactive prompt. - // Run: claw # start the REPL (writes state on first turn) - // Or: claw prompt # run one non-interactive turn - // Then rerun: claw state [--output-format json] - return Err(format!( - "no worker state file found at {path}\n Hint: worker state is written by the interactive REPL or a non-interactive prompt.\n Run: claw # start the REPL (writes state on first turn)\n Or: claw prompt # run one non-interactive turn\n Then rerun: claw state [--output-format json]", - path = state_path.display() - ) - .into()); - } - let raw = std::fs::read_to_string(&state_path)?; - match output_format { - CliOutputFormat::Text => println!("{raw}"), - CliOutputFormat::Json => { - // Validate it parses as JSON before re-emitting - let _: serde_json::Value = serde_json::from_str(&raw)?; - println!("{raw}"); - } - } - Ok(()) -} - -/// the same surface the in-process agent loop uses. -fn run_mcp_serve() -> Result<(), Box> { - let tools = mvp_tool_specs() - .into_iter() - .map(|spec| McpTool { - name: spec.name.to_string(), - description: Some(spec.description.to_string()), - input_schema: Some(spec.input_schema), - annotations: None, - meta: None, - }) - .collect(); - - let spec = McpServerSpec { - server_name: "claw".to_string(), - server_version: VERSION.to_string(), - tools, - tool_handler: Box::new(execute_tool), - }; - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - runtime.block_on(async move { - let mut server = McpServer::new(spec); - server.run().await - })?; - Ok(()) -} - -#[allow(clippy::too_many_lines)] -fn check_auth_health() -> DiagnosticCheck { - let api_key_present = env::var("ANTHROPIC_API_KEY") - .ok() - .is_some_and(|value| !value.trim().is_empty()); - let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN") - .ok() - .is_some_and(|value| !value.trim().is_empty()); - let openai_key_present = env::var("OPENAI_API_KEY") - .ok() - .is_some_and(|value| !value.trim().is_empty()); - let any_auth_present = api_key_present || auth_token_present || openai_key_present; - let prompt_ready = any_auth_present; - let env_details = format!( - "Environment api_key={} auth_token={} openai_key={}", - if api_key_present { "present" } else { "absent" }, - if auth_token_present { - "present" - } else { - "absent" - }, - if openai_key_present { - "present" - } else { - "absent" - } - ); - - match load_oauth_credentials() { - Ok(Some(token_set)) => DiagnosticCheck::new( - "Auth", - if any_auth_present { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - if any_auth_present { - "supported auth env vars are configured; legacy saved OAuth is ignored" - } else { - "legacy saved OAuth credentials are present but unsupported" - }, - ) - .with_details(vec![ - env_details, - format!( - "Legacy OAuth expires_at={} refresh_token={} scopes={}", - token_set - .expires_at - .map_or_else(|| "".to_string(), |value| value.to_string()), - if token_set.refresh_token.is_some() { - "present" - } else { - "absent" - }, - if token_set.scopes.is_empty() { - "".to_string() - } else { - token_set.scopes.join(",") - } - ), - "Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed" - .to_string(), - ]) - .with_hint("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN env var. The saved OAuth token is no longer accepted.") - .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("openai_key_present".to_string(), json!(openai_key_present)), - ("prompt_ready".to_string(), json!(prompt_ready)), - ("prompt_blocked_reason".to_string(), if prompt_ready { Value::Null } else { json!("auth_missing") }), - - ("legacy_saved_oauth_present".to_string(), json!(true)), - ( - "legacy_saved_oauth_expires_at".to_string(), - json!(token_set.expires_at), - ), - ( - "legacy_refresh_token_present".to_string(), - json!(token_set.refresh_token.is_some()), - ), - ("legacy_scopes".to_string(), json!(token_set.scopes)), - ])), - Ok(None) => DiagnosticCheck::new( - "Auth", - if any_auth_present { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - if any_auth_present { - "supported auth env vars are configured" - } else { - "no supported auth env vars were found" - }, - ) - .with_details(vec![env_details]) - .with_hint(if !any_auth_present { "Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN to authenticate." } else { "" }) - .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("openai_key_present".to_string(), json!(openai_key_present)), - ("prompt_ready".to_string(), json!(prompt_ready)), - ("prompt_blocked_reason".to_string(), if prompt_ready { Value::Null } else { json!("auth_missing") }), - ("legacy_saved_oauth_present".to_string(), json!(false)), - ("legacy_saved_oauth_expires_at".to_string(), Value::Null), - ("legacy_refresh_token_present".to_string(), json!(false)), - ("legacy_scopes".to_string(), json!(Vec::::new())), - ])), - Err(error) => DiagnosticCheck::new( - "Auth", - DiagnosticLevel::Fail, - format!("failed to inspect legacy saved credentials: {error}"), - ) - .with_hint("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN env var to authenticate.") - .with_data(Map::from_iter([ - ("api_key_present".to_string(), json!(api_key_present)), - ("auth_token_present".to_string(), json!(auth_token_present)), - ("openai_key_present".to_string(), json!(openai_key_present)), - ("prompt_ready".to_string(), json!(prompt_ready)), - ("prompt_blocked_reason".to_string(), if prompt_ready { Value::Null } else { json!("auth_missing") }), - ("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())), - ])), - } -} - -/// #466: validate provider BASE_URL env vars -fn check_base_url_health() -> DiagnosticCheck { - let base_url_vars = [ - ("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), - ("OPENAI_BASE_URL", "https://api.openai.com"), - ("XAI_BASE_URL", "https://api.x.ai"), - ("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com"), - ]; - let mut issues: Vec = Vec::new(); - let mut details: Vec = Vec::new(); - for (var_name, default_url) in &base_url_vars { - if let Ok(value) = env::var(var_name) { - let trimmed = value.trim(); - if trimmed.is_empty() { - issues.push(format!("{var_name} is empty")); - details.push(format!( - "{var_name} empty (will use default: {default_url})" - )); - } else if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") { - issues.push(format!("{var_name}={trimmed} is not a valid HTTP(S) URL")); - details.push(format!("{var_name} invalid ({trimmed})")); - } else { - details.push(format!("{var_name} {trimmed}")); - } - } - } - if issues.is_empty() { - DiagnosticCheck::new( - "Base URLs", - DiagnosticLevel::Ok, - "provider base URL env vars are valid or unset", - ) - .with_details(details) - } else { - DiagnosticCheck::new( - "Base URLs", - DiagnosticLevel::Warn, - format!("{} base URL issue(s) found", issues.len()), - ) - .with_details(details) - .with_hint("Fix the reported BASE_URL env vars or unset them to use provider defaults.") - } -} - -fn check_config_health( - config_loader: &ConfigLoader, - config: Result<&runtime::RuntimeConfig, &runtime::ConfigError>, -) -> DiagnosticCheck { - let discovered = config_loader.discover(); - let discovered_count = discovered.len(); - // Separate candidate paths that actually exist from those that don't. - // Showing non-existent paths as "Discovered file" implies they loaded - // but something went wrong, which is confusing. We only surface paths - // that exist on disk as discovered; non-existent ones are silently - // omitted from the display (they are just the standard search locations). - let present_paths: Vec = discovered - .iter() - .filter(|e| e.path.exists()) - .map(|e| e.path.display().to_string()) - .collect(); - let discovered_paths = discovered - .iter() - .map(|entry| entry.path.display().to_string()) - .collect::>(); - match config { - Ok(runtime_config) => { - let loaded_entries = runtime_config.loaded_entries(); - let loaded_count = loaded_entries.len(); - let present_count = present_paths.len(); - let mut details = vec![format!( - "Config files loaded {}/{}", - loaded_count, present_count - )]; - if let Some(model) = runtime_config.model() { - details.push(format!("Resolved model {model}")); - } - details.push(format!( - "MCP servers {}", - runtime_config.mcp().valid_count() - )); - if runtime_config.mcp().invalid_count() > 0 { - details.push(format!( - "MCP invalid {}", - runtime_config.mcp().invalid_count() - )); - } - if present_paths.is_empty() { - details.push("Discovered files (defaults active)".to_string()); - } else { - details.extend( - present_paths - .iter() - .map(|path| format!("Discovered file {path}")), - ); - } - DiagnosticCheck::new( - "Config", - DiagnosticLevel::Ok, - if present_count == 0 { - "no config files present; defaults are active" - } else { - "runtime config loaded successfully" - }, - ) - .with_details(details) - .with_data(Map::from_iter([ - ("discovered_files".to_string(), json!(present_paths)), - ("discovered_files_count".to_string(), json!(present_count)), - ("loaded_config_files".to_string(), json!(loaded_count)), - ("resolved_model".to_string(), json!(runtime_config.model())), - ( - "mcp_servers".to_string(), - json!(runtime_config.mcp().valid_count()), - ), - ( - "mcp_invalid_servers".to_string(), - json!(runtime_config.mcp().invalid_count()), - ), - ( - "hook_invalid_entries".to_string(), - json!(runtime_config.hooks().invalid_count()), - ), - ])) - } - Err(error) => DiagnosticCheck::new( - "Config", - DiagnosticLevel::Fail, - format!("runtime config failed to load: {error}"), - ) - .with_details(if discovered_paths.is_empty() { - vec!["Discovered files ".to_string()] - } else { - discovered_paths - .iter() - .map(|path| format!("Discovered file {path}")) - .collect() - }) - .with_hint("Fix the JSON syntax error in the listed config file, then rerun `claw doctor`.") - .with_data(Map::from_iter([ - ("discovered_files".to_string(), json!(discovered_paths)), - ( - "discovered_files_count".to_string(), - json!(discovered_count), - ), - ("loaded_config_files".to_string(), json!(0)), - ("resolved_model".to_string(), Value::Null), - ("mcp_servers".to_string(), Value::Null), - ("load_error".to_string(), json!(error.to_string())), - ])), - } -} - -fn check_mcp_validation_health(summary: &McpValidationSummary) -> DiagnosticCheck { - let mut details = vec![ - format!("Total entries {}", summary.total_configured), - format!("Valid entries {}", summary.valid_count), - format!("Invalid entries {}", summary.invalid_count()), - ]; - details.extend( - summary - .invalid_servers - .iter() - .map(|server| format!("Invalid server {} ({})", server.name, server.reason)), - ); - - DiagnosticCheck::new( - "MCP validation", - if summary.has_invalid_servers() { - DiagnosticLevel::Warn - } else { - DiagnosticLevel::Ok - }, - if summary.has_invalid_servers() { - format!( - "{} MCP server entries are invalid; {} valid entries remain loaded", - summary.invalid_count(), - summary.valid_count - ) - } else { - format!("{} MCP server entries validated", summary.valid_count) - }, - ) - .with_hint(if summary.has_invalid_servers() { - "Inspect `claw mcp list --output-format json` invalid_servers and fix each rejected mcpServers entry." - } else { - "" - }) - .with_details(details) - .with_data(Map::from_iter([ - ( - "total_configured".to_string(), - json!(summary.total_configured), - ), - ("valid_count".to_string(), json!(summary.valid_count)), - ("invalid_count".to_string(), json!(summary.invalid_count())), - ( - "invalid_servers".to_string(), - Value::Array(invalid_mcp_servers_json(&summary.invalid_servers)), - ), - ])) -} - -fn check_hook_validation_health(summary: &HookValidationSummary) -> DiagnosticCheck { - let mut details = vec![ - format!("Valid entries {}", summary.valid_count), - format!("Invalid entries {}", summary.invalid_count()), - ]; - details.extend( - summary - .invalid_hooks - .iter() - .map(|hook| format!("Invalid hook {} ({})", hook.event, hook.reason)), - ); - - DiagnosticCheck::new( - "Hook validation", - if summary.has_invalid_hooks() { - DiagnosticLevel::Warn - } else { - DiagnosticLevel::Ok - }, - if summary.has_invalid_hooks() { - format!( - "{} hook entries are invalid; {} valid entries remain loaded", - summary.invalid_count(), - summary.valid_count - ) - } else { - format!("{} hook entries validated", summary.valid_count) - }, - ) - .with_hint(if summary.has_invalid_hooks() { - "Inspect `claw status --output-format json` hook_validation.invalid_hooks and fix each rejected hooks entry." - } else { - "" - }) - .with_details(details) - .with_data(Map::from_iter([ - ("valid_count".to_string(), json!(summary.valid_count)), - ("invalid_count".to_string(), json!(summary.invalid_count())), - ( - "invalid_hooks".to_string(), - Value::Array(invalid_hooks_json(&summary.invalid_hooks)), - ), - ])) -} - -fn check_permission_health(permission_mode: PermissionModeProvenance) -> DiagnosticCheck { - let mode = permission_mode.mode.as_str(); - let source = permission_mode.source.as_str(); - let explicit = permission_mode.source.is_explicit(); - let warning = matches!(permission_mode.mode, PermissionMode::DangerFullAccess) && !explicit; - let message = if warning { - "running with full access without explicit opt-in" - } else if matches!(permission_mode.mode, PermissionMode::DangerFullAccess) { - "danger-full-access was explicitly selected" - } else if matches!(permission_mode.mode, PermissionMode::WorkspaceWrite) && !explicit { - "default permission mode is workspace-write" - } else { - "permission mode is explicitly bounded below danger-full-access" - }; - let source_detail = permission_mode.env_var.map_or_else( - || source.to_string(), - |env_var| format!("{source}:{env_var}"), - ); - let specs = mvp_tool_specs(); - let tools_satisfied = specs - .iter() - .filter(|spec| permission_mode.mode >= spec.required_permission) - .map(|spec| spec.name) - .collect::>(); - let tools_gated = specs - .iter() - .filter(|spec| permission_mode.mode < spec.required_permission) - .map(|spec| spec.name) - .collect::>(); - - DiagnosticCheck::new( - "Permissions", - if warning { - DiagnosticLevel::Warn - } else { - DiagnosticLevel::Ok - }, - message, - ) - .with_details(vec![ - format!("Mode {mode}"), - format!("Source {source_detail}"), - format!("Explicit opt-in {explicit}"), - format!("Tools allowed {}", tools_satisfied.join(", ")), - format!("Tools gated {}", tools_gated.join(", ")), - ]) - .with_hint(if warning { - "Use the workspace-write default, or pass --permission-mode danger-full-access / --dangerously-skip-permissions only when full filesystem, network, and command access is intentional." - } else { - "Use --permission-mode read-only|workspace-write|danger-full-access to make the runtime permission boundary explicit." - }) - .with_data(Map::from_iter([ - ("mode".to_string(), json!(mode)), - ("source".to_string(), json!(source)), - ("source_explicit".to_string(), json!(explicit)), - ("env_var".to_string(), json!(permission_mode.env_var)), - ("message".to_string(), json!(message)), - ("tools_satisfied".to_string(), json!(tools_satisfied)), - ("tools_gated".to_string(), json!(tools_gated)), - ])) -} - -fn check_install_source_health() -> DiagnosticCheck { - DiagnosticCheck::new( - "Install source", - DiagnosticLevel::Ok, - format!( - "official source of truth is {OFFICIAL_REPO_SLUG}; avoid `{DEPRECATED_INSTALL_COMMAND}`" - ), - ) - .with_details(vec![ - format!("Official repo {OFFICIAL_REPO_URL}"), - "Recommended path build from this repo or use the upstream binary documented in README.md" - .to_string(), - format!( - "Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary" - ) - .to_string(), - ]) - .with_data(Map::from_iter([ - ("official_repo".to_string(), json!(OFFICIAL_REPO_URL)), - ( - "deprecated_install".to_string(), - json!(DEPRECATED_INSTALL_COMMAND), - ), - ( - "recommended_install".to_string(), - json!("build from source or follow the upstream binary instructions in README.md"), - ), - ])) -} - -fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { - let in_repo = context.project_root.is_some(); - let stale_base_warning = format_stale_base_warning(&context.stale_base_state); - DiagnosticCheck::new( - "Workspace", - if in_repo && stale_base_warning.is_none() { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - if in_repo { - format!( - "project root detected on branch {}", - context.git_branch.as_deref().unwrap_or("unknown") - ) - } else { - "current directory is not inside a git project".to_string() - }, - ) - .with_hint(if !in_repo { - "Run `git init` to initialise a repository, or `cd` into a git project." - } else if stale_base_warning.is_some() { - "Rebase or merge to bring the branch up to date with its base." - } else { - "" - }) - .with_details(vec![ - format!("Cwd {}", context.cwd.display()), - format!( - "Project root {}", - context - .project_root - .as_ref() - .map_or_else(|| "".to_string(), |path| path.display().to_string()) - ), - format!( - "Git branch {}", - context.git_branch.as_deref().unwrap_or("unknown") - ), - format!( - "Git state {}", - if context.project_root.is_some() { - context.git_summary.headline() - } else { - "no git repo".to_string() - } - ), - format!("Changed files {}", context.git_summary.changed_files), - format!( - "Memory files {} · config files loaded {}/{}", - context.memory_file_count, context.loaded_config_files, context.discovered_config_files - ), - format!( - "Loaded memory {}", - if context.memory_files.is_empty() { - "".to_string() - } else { - context - .memory_files - .iter() - .map(|file| format!("{}:{}", file.source, file.path)) - .collect::>() - .join(", ") - } - ), - format!( - "Stale base {}", - stale_base_warning.as_deref().unwrap_or("ok") - ), - ]) - .with_data(Map::from_iter([ - ("cwd".to_string(), json!(context.cwd.display().to_string())), - ( - "project_root".to_string(), - json!(context - .project_root - .as_ref() - .map(|path| path.display().to_string())), - ), - ("in_git_repo".to_string(), json!(in_repo)), - ("git_branch".to_string(), json!(context.git_branch)), - ( - "git_state".to_string(), - json!(if context.project_root.is_some() { - context.git_summary.headline() - } else { - "no_git_repo".to_string() - }), - ), - ( - "changed_files".to_string(), - json!(context.git_summary.changed_files), - ), - ( - "memory_file_count".to_string(), - json!(context.memory_file_count), - ), - ( - "memory_files".to_string(), - Value::Array(memory_files_json(&context.memory_files)), - ), - ( - "unloaded_memory_files".to_string(), - json!(context.unloaded_memory_files), - ), - ( - "loaded_config_files".to_string(), - json!(context.loaded_config_files), - ), - ( - "discovered_config_files".to_string(), - json!(context.discovered_config_files), - ), - ( - "stale_base".to_string(), - stale_base_json_value(&context.stale_base_state), - ), - ])) -} - -fn check_memory_health(context: &StatusContext) -> DiagnosticCheck { - let has_unloaded = !context.unloaded_memory_files.is_empty(); - let has_outside_project = context.memory_files.iter().any(|file| file.outside_project); - let mut details = vec![format!("Loaded files {}", context.memory_file_count)]; - details.extend(context.memory_files.iter().map(|file| { - format!( - "Loaded {} ({}, chars={})", - file.path, file.source, file.chars - ) - })); - details.extend( - context - .unloaded_memory_files - .iter() - .map(|path| format!("Unloaded {path}")), - ); - - DiagnosticCheck::new( - "Memory", - if has_unloaded || has_outside_project { - DiagnosticLevel::Warn - } else { - DiagnosticLevel::Ok - }, - if has_outside_project { - "memory files outside the current git project are loaded".to_string() - } else if has_unloaded { - "some workspace memory files exist but were not loaded".to_string() - } else { - format!("{} workspace memory files loaded", context.memory_file_count) - }, - ) - .with_hint(if has_outside_project { - "Inspect workspace.memory_files in `claw status --output-format json`; move unintended ancestor instructions inside the git project or run from the intended workspace root." - } else if has_unloaded { - "Move instructions into CLAUDE.md, CLAW.md, or AGENTS.md within the current workspace ancestry, or inspect workspace.memory_files in `claw status --output-format json`." - } else { - "" - }) - .with_details(details) - .with_data(Map::from_iter([ - ( - "memory_file_count".to_string(), - json!(context.memory_file_count), - ), - ( - "memory_files".to_string(), - Value::Array(memory_files_json(&context.memory_files)), - ), - ( - "unloaded_memory_files".to_string(), - json!(context.unloaded_memory_files), - ), - ])) -} - -fn check_boot_preflight_health(context: &StatusContext) -> DiagnosticCheck { - let preflight = &context.boot_preflight; - let missing_binaries = preflight - .required_binaries - .iter() - .filter(|binary| !binary.available) - .map(|binary| binary.name) - .collect::>(); - let socket_details = preflight - .control_sockets - .iter() - .map(|socket| { - format!( - "Control socket {} configured={} exists={} path={}", - socket.name, - socket.configured, - socket.exists, - socket.path.as_deref().unwrap_or("") - ) - }) - .collect::>(); - let mut details = vec![ - format!("Repo exists {}", preflight.repo_exists), - format!("Worktree exists {}", preflight.worktree_exists), - format!("Git dir exists {}", preflight.git_dir_exists), - format!("Branch behind {}", preflight.branch_freshness.behind), - format!( - "Trust allowlist {}", - preflight - .trust_gate_allowed - .map_or("unknown".to_string(), |v| v.to_string()) - ), - format!("Trusted roots {}", preflight.trusted_roots_count), - // #736: keep compound values readable but use " · " as intra-value separator - // so the two-space prose splitter yields key="MCP eligible" value="true · servers 0" - format!( - "MCP eligible {}", - format!( - "{} · servers {}", - preflight.mcp_startup_eligible, preflight.mcp_servers_configured - ) - ), - format!( - "Plugin eligible {}", - format!( - "{} · configured {}", - preflight.plugin_startup_eligible, preflight.plugins_configured - ) - ), - format!( - // #736: use two-space separator so the detail_entries prose splitter - // can extract key="Last failed boot" value="|" - "Last failed boot {}", - preflight - .last_failed_boot_reason - .as_deref() - .unwrap_or("") - ), - ]; - details.extend(preflight.required_binaries.iter().map(|binary| { - format!( - // #736: two-space separator → key="Required binary " value="available=true|false" - "Required binary {} available={}", - binary.name, binary.available - ) - })); - details.extend(socket_details); - DiagnosticCheck::new( - "Boot preflight", - if preflight.repo_exists && preflight.worktree_exists && missing_binaries.is_empty() { - DiagnosticLevel::Ok - } else { - DiagnosticLevel::Warn - }, - preflight.summary(), - ) - .with_details(details) - .with_hint( - // #778: stable remediation hint for automation - if !preflight.repo_exists || !preflight.worktree_exists { - "Ensure you are inside a git worktree (`git init` or `git worktree add`)." - } else if !missing_binaries.is_empty() { - "Install the listed missing required binaries." - } else { - "" - }, - ) - .with_data(Map::from_iter([( - "boot_preflight".to_string(), - preflight.json_value(), - )])) -} - -fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck { - let degraded = status.enabled && !status.active; - let mut details = vec![ - format!("Enabled {}", status.enabled), - format!("Active {}", status.active), - format!("Supported {}", status.supported), - format!("Filesystem mode {}", status.filesystem_mode.as_str()), - format!("Filesystem live {}", status.filesystem_active), - ]; - if let Some(reason) = &status.fallback_reason { - details.push(format!("Fallback reason {reason}")); - } - DiagnosticCheck::new( - "Sandbox", - if degraded { - DiagnosticLevel::Warn - } else { - DiagnosticLevel::Ok - }, - if degraded { - "sandbox was requested but is not currently active" - } else if status.active { - "sandbox protections are active" - } else { - "sandbox is not active for this session" - }, - ) - .with_details(details) - .with_hint( - // #778: stable remediation hint — sandbox degraded on non-Linux hosts is expected, not an error - if degraded && !status.supported { - "Sandbox namespace isolation requires Linux with `unshare`. On macOS/non-Linux hosts this warning is expected and can be ignored. Filesystem isolation is still active." - } else if degraded { - "Check that the `unshare` binary is available and the process has the required capabilities." - } else { - "" - }, - ) - .with_data(Map::from_iter([ - ("enabled".to_string(), json!(status.enabled)), - ("active".to_string(), json!(status.active)), - ("supported".to_string(), json!(status.supported)), - ( - "namespace_supported".to_string(), - json!(status.namespace_supported), - ), - ( - "namespace_active".to_string(), - json!(status.namespace_active), - ), - ( - "network_supported".to_string(), - json!(status.network_supported), - ), - ("network_active".to_string(), json!(status.network_active)), - ( - "filesystem_mode".to_string(), - json!(status.filesystem_mode.as_str()), - ), - ( - "filesystem_active".to_string(), - json!(status.filesystem_active), - ), - ("allowed_mounts".to_string(), json!(status.allowed_mounts)), - ("in_container".to_string(), json!(status.in_container)), - ( - "container_markers".to_string(), - json!(status.container_markers), - ), - ("fallback_reason".to_string(), json!(status.fallback_reason)), - ])) -} - -fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck { - let default_model = config.and_then(runtime::RuntimeConfig::model); - let mut details = vec![ - format!("OS {} {}", env::consts::OS, env::consts::ARCH), - format!("Working dir {}", cwd.display()), - format!("Version {}", VERSION), - format!("Build target {}", BUILD_TARGET.unwrap_or("")), - format!("Git SHA {}", GIT_SHA.unwrap_or("")), - format!( - "Output format env CLAW_OUTPUT_FORMAT={}", - env::var("CLAW_OUTPUT_FORMAT").unwrap_or_else(|_| "".to_string()) - ), - format!( - "Logging env CLAW_LOG={} RUST_LOG={}", - env::var("CLAW_LOG").unwrap_or_else(|_| "".to_string()), - env::var("RUST_LOG").unwrap_or_else(|_| "".to_string()) - ), - ]; - if let Some(model) = default_model { - details.push(format!("Default model {model}")); - } - let binary_provenance = binary_provenance_for(Some(cwd)); - details.push(format!( - "Binary provenance status={} workspace_match={}", - binary_provenance.status(), - binary_provenance - .workspace_match - .map_or_else(|| "unknown".to_string(), |matches| matches.to_string()) - )); - 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)), - ( - "binary_provenance".to_string(), - binary_provenance.json_value(), - ), - ("default_model".to_string(), json!(default_model)), - ( - "claw_output_format".to_string(), - json!(env::var("CLAW_OUTPUT_FORMAT").ok()), - ), - ("claw_log".to_string(), json!(env::var("CLAW_LOG").ok())), - ("rust_log".to_string(), json!(env::var("RUST_LOG").ok())), - ])) -} - -fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { - matches!( - SlashCommand::parse(current_command), - Ok(Some(SlashCommand::Export { path: None })) - ) && !looks_like_slash_command_token(token) -} - -fn looks_like_slash_command_token(token: &str) -> bool { - let trimmed = token.trim_start(); - let Some(name) = trimmed.strip_prefix('/').and_then(|value| { - value - .split_whitespace() - .next() - .map(str::trim) - .filter(|value| !value.is_empty()) - }) else { - return false; - }; - - slash_command_specs() - .iter() - .any(|spec| spec.name == name || spec.aliases.contains(&name)) -} - -fn dump_manifests( - manifests_dir: Option<&Path>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let workspace_dir = env::current_dir()?; - dump_manifests_at_path(&workspace_dir, manifests_dir, output_format) -} - -const DUMP_MANIFESTS_USAGE_HINT: &str = - "Usage: claw dump-manifests [--manifests-dir ] [--output-format json]"; - -// 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 discovery_root = manifests_dir.unwrap_or(workspace_dir); - let resolved_root = discovery_root - .canonicalize() - .unwrap_or_else(|_| discovery_root.to_path_buf()); - - if !resolved_root.exists() { - return Err(format!( - "missing_manifests: manifest discovery directory does not exist.\n looked in: {}\n {DUMP_MANIFESTS_USAGE_HINT}", - resolved_root.display(), - ) - .into()); - } - if !resolved_root.is_dir() { - return Err(format!( - "missing_manifests: manifest discovery path is not a directory.\n looked in: {}\n {DUMP_MANIFESTS_USAGE_HINT}", - resolved_root.display(), - ) - .into()); - } - - let manifest = build_rust_resolver_manifest(&resolved_root)?; - match output_format { - CliOutputFormat::Text => { - println!("Manifest Dump"); - println!(" Source rust-resolver"); - println!(" Workspace {}", resolved_root.display()); - println!(" Commands {}", manifest["commands"]); - println!(" Tools {}", manifest["tools"]); - println!(" Agents {}", manifest["agents"]); - println!(" Skills {}", manifest["skills"]); - println!(" Bootstrap phases {}", manifest["bootstrap_phases"]); - } - CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&manifest)?), - } - Ok(()) -} - -fn build_rust_resolver_manifest(workspace_dir: &Path) -> Result> { - let command_entries = slash_command_specs() - .iter() - .map(|spec| { - json!({ - "name": spec.name, - "aliases": spec.aliases, - "summary": spec.summary, - "argument_hint": spec.argument_hint, - "resume_supported": spec.resume_supported, - "implemented": !STUB_COMMANDS.contains(&spec.name), - }) - }) - .collect::>(); - - let tool_entries = mvp_tool_specs() - .into_iter() - .map(|spec| { - json!({ - "name": spec.name, - "description": spec.description, - "required_permission": spec.required_permission.as_str(), - "input_schema": spec.input_schema, - }) - }) - .collect::>(); - - let agent_report = handle_agents_slash_command_json(None, workspace_dir)?; - let skill_report = handle_skills_slash_command_json(None, workspace_dir)?; - let agents = agent_report - .get("agents") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let skills = skill_report - .get("skills") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let bootstrap = runtime::BootstrapPlan::claude_code_default() - .phases() - .iter() - .map(|phase| format!("{phase:?}")) - .collect::>(); - - Ok(json!({ - "kind": "dump-manifests", - "action": "dump", - "status": "ok", - "source": "rust-resolver", - "workspace": workspace_dir.display().to_string(), - "commands": command_entries.len(), - "tools": tool_entries.len(), - "agents": agents.len(), - "skills": skills.len(), - "bootstrap_phases": bootstrap.len(), - "command_manifests": command_entries, - "tool_manifests": tool_entries, - "agent_manifests": agents, - "skill_manifests": skills, - "bootstrap_manifest": bootstrap, - })) -} - -fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box> { - let phases = runtime::BootstrapPlan::claude_code_default(); - match output_format { - CliOutputFormat::Text => { - for phase in phases.phases() { - println!("- {phase:?}"); - } - } - CliOutputFormat::Json => { - // #412: emit structured phase objects with label and description - let phase_objects: Vec = phases - .phases() - .iter() - .enumerate() - .map(|(i, phase)| { - let (label, description) = bootstrap_phase_metadata(phase); - json!({ - "id": format!("{phase:?}"), - "label": label, - "description": description, - "order": i, - }) - }) - .collect(); - println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "bootstrap-plan", - "action": "show", - "status": "ok", - "total_phases": phases.phases().len(), - "phases": phase_objects, - }))? - ); - } - } - Ok(()) -} - -fn bootstrap_phase_metadata(phase: &runtime::BootstrapPhase) -> (&'static str, &'static str) { - use runtime::BootstrapPhase::*; - match phase { - CliEntry => ( - "CLI Entry", - "Command-line argument parsing and global flag resolution", - ), - FastPathVersion => ( - "Fast-Path Version", - "Short-circuit version/help requests before full startup", - ), - StartupProfiler => ( - "Startup Profiler", - "Instrument startup timing for diagnostics", - ), - SystemPromptFastPath => ( - "System Prompt Fast-Path", - "Serve system-prompt requests without provider init", - ), - ChromeMcpFastPath => ( - "Chrome MCP Fast-Path", - "Serve Chrome MCP requests without full runtime", - ), - DaemonWorkerFastPath => ( - "Daemon Worker Fast-Path", - "Handle daemon worker requests without full init", - ), - BridgeFastPath => ( - "Bridge Fast-Path", - "Bridge/sibling process communication without full init", - ), - DaemonFastPath => ( - "Daemon Fast-Path", - "Daemon lifecycle management without full runtime", - ), - BackgroundSessionFastPath => ( - "Background Session Fast-Path", - "Resume/list background sessions without full init", - ), - TemplateFastPath => ( - "Template Fast-Path", - "Template rendering without full runtime", - ), - EnvironmentRunnerFastPath => ( - "Environment Runner Fast-Path", - "Environment/runner dispatch without full init", - ), - MainRuntime => ( - "Main Runtime", - "Full interactive REPL or one-shot prompt execution", - ), - } -} - -fn print_system_prompt( - cwd: PathBuf, - date: String, - model: &str, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let (sections, project_context) = load_system_prompt_with_context( - cwd, - date, - env::consts::OS, - "unknown", - model_family_identity_for(model), - )?; - let (project_root, _) = - parse_git_status_metadata_for(&project_context.cwd, project_context.git_status.as_deref()); - let memory_files = memory_file_summaries_for( - &project_context.cwd, - project_root.as_deref(), - &project_context.instruction_files, - ); - let message = sections.join( - " - -", - ); - // #418: filter out the internal boundary sentinel from the sections array - // and expose the boundary index as a structured field. - let filtered_sections: Vec<&str> = sections - .iter() - .filter(|s| !s.contains("__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__")) - .map(|s| s.as_str()) - .collect(); - let boundary_index = sections - .iter() - .position(|s| s.contains("__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__")); - match output_format { - CliOutputFormat::Text => println!("{message}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "system-prompt", - "action": "show", - "status": "ok", - "message": message, - "sections": filtered_sections, - "boundary_index": boundary_index, - "memory_file_count": memory_files.len(), - "memory_files": memory_files_json(&memory_files), - }))? - ), - } - 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 { - let cwd = env::current_dir().ok(); - let binary_provenance = binary_provenance_for(cwd.as_deref()); - json!({ - "kind": "version", - "action": "show", - "status": "ok", - "human_readable": render_version_report(), - "version": VERSION, - "git_sha": binary_provenance.git_sha, - "git_sha_short": binary_provenance.git_sha_short, - "is_dirty": binary_provenance.is_dirty, - "branch": binary_provenance.branch, - "commit_date": binary_provenance.commit_date, - "commit_timestamp": binary_provenance.commit_timestamp, - "rustc_version": binary_provenance.rustc_version, - "target": binary_provenance.target, - "build_date": binary_provenance.build_date, - "executable_path": binary_provenance.executable_path, - "binary_provenance": binary_provenance.json_value(), - }) -} - -#[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, inline_hint) = split_error_hint(&full_message); - // #787: fall back to kind-derived hint when message has no \n delimiter - let hint = - inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from)); - let sessions_dir = sessions_dir().ok().map(|path| path.display().to_string()); - // #819: JSON mode resume errors go to stdout for parity with other - // non-interactive command guards. - println!( - "{}", - serde_json::json!({ - "kind": kind, - "action": "restore", - "status": "error", - "error_kind": kind, - "error": short_reason, - "exit_code": 1, - "hint": hint, - "sessions_dir": sessions_dir, - }) - ); - } else { - eprintln!("failed to restore session: {error}"); - } - std::process::exit(1); - } - }; - let resolved_path = handle.path.clone(); - - if commands.is_empty() { - if output_format == CliOutputFormat::Json { - println!( - "{}", - serde_json::json!({ - "kind": "restored", - "action": "restore", - "status": "ok", - "session_id": session.session_id, - "path": handle.path.display().to_string(), - "message_count": session.messages.len(), - }) - ); - } else { - println!( - "Restored session from {} ({} messages).", - handle.path.display(), - session.messages.len() - ); - } - return; - } - - let mut session = session; - for raw_command in commands { - // Intercept spec commands that have no parse arm before calling - // SlashCommand::parse — they return Err(SlashCommandParseError) which - // formats as the confusing circular "Did you mean /X?" message. - // STUB_COMMANDS covers both completions-filtered stubs and parse-less - // spec entries; treat both as unsupported in resume mode. - { - let cmd_root = raw_command - .trim_start_matches('/') - .split_whitespace() - .next() - .unwrap_or(""); - if STUB_COMMANDS.contains(&cmd_root) { - if output_format == CliOutputFormat::Json { - println!( - "{}", - serde_json::json!({ - "kind": "unsupported_command", - "action": "resume", - "status": "error", - "error_kind": "unsupported_command", - "error": format!("/{cmd_root} is not yet implemented in this build"), - "hint": "This command is not available in the current build. Update claw or use a different command.", - "exit_code": 2, - "command": raw_command, - }) - ); - } else { - eprintln!("/{cmd_root} is not yet implemented in this build"); - } - std::process::exit(2); - } - } - let command = match SlashCommand::parse(raw_command) { - Ok(Some(command)) => command, - Ok(None) => { - if output_format == CliOutputFormat::Json { - println!( - "{}", - serde_json::json!({ - "kind": "unsupported_resumed_command", - "action": "resume", - "status": "error", - "error_kind": "unsupported_resumed_command", - "error": format!("unsupported resumed command: {raw_command}"), - "hint": "This command cannot be used with --resume. Use it in an interactive REPL session instead.", - "exit_code": 2, - "command": raw_command, - }) - ); - } else { - eprintln!("unsupported resumed command: {raw_command}"); - } - std::process::exit(2); - } - Err(error) => { - if output_format == CliOutputFormat::Json { - println!( - "{}", - serde_json::json!({ - "kind": "cli_parse", - "action": "resume", - "status": "error", - "error_kind": "cli_parse", - "error": error.to_string(), - "hint": "Run `claw --help` for usage.", - "exit_code": 2, - "command": raw_command, - }) - ); - } else { - eprintln!("{error}"); - } - std::process::exit(2); - } - }; - match run_resume_command(&resolved_path, &session, &command) { - Ok(ResumeCommandOutcome { - session: next_session, - message, - json, - }) => { - session = next_session; - if output_format == CliOutputFormat::Json { - if let Some(value) = json { - println!( - "{}", - serde_json::to_string_pretty(&value) - .expect("resume command json output") - ); - } else if let Some(message) = message { - println!("{message}"); - } - } else if let Some(message) = message { - println!("{message}"); - } - } - Err(error) => { - if output_format == CliOutputFormat::Json { - // #776: classify + split so wrappers get typed fields instead of - // hardcoded "resume_command_error" + prose in the error field - let full_error = error.to_string(); - let error_kind = classify_error_kind(&full_error); - let (short_reason, inline_hint) = split_error_hint(&full_error); - // #787: fall back to kind-derived hint when error has no \n delimiter - let hint = inline_hint - .or_else(|| fallback_hint_for_error_kind(error_kind).map(String::from)); - println!( - "{}", - serde_json::json!({ - "kind": error_kind, - "action": "resume", - "status": "error", - "error_kind": error_kind, - "error": short_reason, - "hint": hint, - "exit_code": 2, - "command": raw_command, - }) - ); - } else { - eprintln!("{error}"); - } - std::process::exit(2); - } - } - } -} - -#[derive(Debug, Clone)] -struct ResumeCommandOutcome { - session: Session, - message: Option, - json: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct MemoryFileSummary { - path: String, - source: String, - chars: usize, - origin: String, - scope_path: String, - outside_project: bool, - contributes: bool, -} - -impl MemoryFileSummary { - fn json_value(&self) -> serde_json::Value { - json!({ - "path": self.path, - "source": self.source, - "chars": self.chars, - "origin": self.origin, - "scope_path": self.scope_path, - "outside_project": self.outside_project, - "contributes": self.contributes, - }) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct McpValidationSummary { - total_configured: usize, - valid_count: usize, - invalid_servers: Vec, -} - -impl McpValidationSummary { - fn from_collection(collection: &McpConfigCollection) -> Self { - Self { - total_configured: collection.total_configured(), - valid_count: collection.valid_count(), - invalid_servers: collection.invalid_servers().to_vec(), - } - } - - fn invalid_count(&self) -> usize { - self.invalid_servers.len() - } - - fn has_invalid_servers(&self) -> bool { - !self.invalid_servers.is_empty() - } - - fn json_value(&self) -> serde_json::Value { - json!({ - "total_configured": self.total_configured, - "valid_count": self.valid_count, - "invalid_count": self.invalid_count(), - "invalid_servers": invalid_mcp_servers_json(&self.invalid_servers), - }) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct HookValidationSummary { - valid_count: usize, - invalid_hooks: Vec, -} - -impl HookValidationSummary { - fn from_config(config: &runtime::RuntimeConfig) -> Self { - let hooks = config.hooks(); - Self { - valid_count: hooks.pre_tool_use_entries().len() - + hooks.post_tool_use_entries().len() - + hooks.post_tool_use_failure_entries().len(), - invalid_hooks: hooks.invalid_hooks().to_vec(), - } - } - - fn invalid_count(&self) -> usize { - self.invalid_hooks.len() - } - - fn has_invalid_hooks(&self) -> bool { - !self.invalid_hooks.is_empty() - } - - fn json_value(&self) -> serde_json::Value { - json!({ - "valid_count": self.valid_count, - "invalid_count": self.invalid_count(), - "invalid_hooks": invalid_hooks_json(&self.invalid_hooks), - }) - } -} - -fn invalid_hooks_json(invalid_hooks: &[RuntimeInvalidHookConfig]) -> Vec { - invalid_hooks - .iter() - .map(|hook| { - json!({ - "event": &hook.event, - "index": hook.index, - "hook_index": hook.hook_index, - "kind": &hook.kind, - "error_field": &hook.error_field, - "reason": &hook.reason, - "valid": false, - }) - }) - .collect() -} - -fn invalid_mcp_servers_json(invalid_servers: &[McpInvalidServerConfig]) -> Vec { - invalid_servers - .iter() - .map(|server| { - json!({ - "name": &server.name, - "scope": config_source_json_value(server.scope), - "path": server.path.display().to_string(), - "error_field": &server.error_field, - "reason": &server.reason, - "valid": false, - }) - }) - .collect() -} - -fn config_source_json_value(source: ConfigSource) -> serde_json::Value { - let id = match source { - ConfigSource::User => "user", - ConfigSource::Project => "project", - ConfigSource::Local => "local", - }; - json!({"id": id, "label": id}) -} - -fn memory_file_summaries_for( - cwd: &Path, - project_root: Option<&Path>, - files: &[ContextFile], -) -> Vec { - let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); - let project_root = - project_root.map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf())); - files - .iter() - .map(|file| { - let path = file - .path - .canonicalize() - .unwrap_or_else(|_| file.path.clone()); - let scope_path = memory_scope_path(&path); - let origin = memory_origin(&cwd, project_root.as_deref(), &scope_path); - let outside_project = project_root - .as_ref() - .is_some_and(|root| !path.starts_with(root)); - MemoryFileSummary { - path: file.path.display().to_string(), - source: file.source().to_string(), - origin: origin.to_string(), - scope_path: scope_path.display().to_string(), - chars: file.char_count(), - outside_project, - contributes: true, - } - }) - .collect() -} - -fn memory_scope_path(path: &Path) -> PathBuf { - let Some(parent) = path.parent() else { - return PathBuf::from("."); - }; - let parent_name = parent.file_name().and_then(|name| name.to_str()); - if matches!(parent_name, Some(".claw" | ".claude")) { - return parent.parent().unwrap_or(parent).to_path_buf(); - } - if matches!(parent_name, Some("rules" | "rules.local")) { - if let Some(grandparent) = parent.parent() { - if grandparent.file_name().and_then(|name| name.to_str()) == Some(".claw") { - return grandparent.parent().unwrap_or(grandparent).to_path_buf(); - } - } - } - parent.to_path_buf() -} - -fn memory_origin(cwd: &Path, project_root: Option<&Path>, scope_path: &Path) -> &'static str { - if scope_path == cwd { - return "workspace"; - } - if project_root.is_some_and(|root| !scope_path.starts_with(root)) { - return "outside_project"; - } - if let Some(home) = env::var_os("HOME").map(PathBuf::from) { - let home = home.canonicalize().unwrap_or(home); - if scope_path == home { - return "home"; - } - } - if cwd.parent().is_some_and(|parent| parent == scope_path) { - return "parent_dir"; - } - if cwd.starts_with(scope_path) { - return "ancestor"; - } - "workspace" -} - -fn memory_files_json(files: &[MemoryFileSummary]) -> Vec { - files.iter().map(MemoryFileSummary::json_value).collect() -} - -fn unloaded_memory_candidates( - cwd: &Path, - project_root: Option<&Path>, - files: &[MemoryFileSummary], -) -> Vec { - let mut loaded = files - .iter() - .map(|file| PathBuf::from(&file.path)) - .collect::>(); - loaded.sort(); - - let boundary = project_root.unwrap_or(cwd); - let mut missing = Vec::new(); - let mut cursor = Some(cwd); - while let Some(dir) = cursor { - for name in ["CLAW.md", "AGENTS.md"] { - let candidate = dir.join(name); - if candidate.is_file() && !loaded.iter().any(|path| path == &candidate) { - missing.push(candidate.display().to_string()); - } - } - if dir == boundary { - break; - } - cursor = dir.parent(); - } - missing.sort(); - missing.dedup(); - missing -} -#[derive(Debug, Clone)] -struct StatusContext { - cwd: PathBuf, - session_path: Option, - loaded_config_files: usize, - discovered_config_files: usize, - memory_file_count: usize, - memory_files: Vec, - unloaded_memory_files: Vec, - project_root: Option, - git_branch: Option, - git_summary: GitWorkspaceSummary, - branch_freshness: BranchFreshness, - stale_base_state: BaseCommitState, - session_lifecycle: SessionLifecycleSummary, - boot_preflight: BootPreflightSnapshot, - sandbox_status: runtime::SandboxStatus, - binary_provenance: BinaryProvenance, - /// #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, - /// #143: machine-readable kind for the config load error, derived from - /// `classify_error_kind`. Included in JSON output alongside the human - /// readable string so downstream claws can switch on the kind token - /// instead of regex-scraping the prose. - config_load_error_kind: Option<&'static str>, - mcp_validation: McpValidationSummary, - - hook_validation: HookValidationSummary, - /// #468: duplicate global flag occurrences for provenance reporting - duplicate_flags: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BinaryProvenance { - git_sha: Option, - git_sha_short: Option, - is_dirty: bool, - branch: Option, - commit_date: String, - commit_timestamp: i64, - rustc_version: String, - target: Option, - build_date: String, - executable_path: Option, - workspace_git_sha: Option, - workspace_match: Option, - hint: Option, -} - -impl BinaryProvenance { - fn status(&self) -> &'static str { - if self.git_sha.is_some() { - "known" - } else { - "unknown" - } - } - - fn json_value(&self) -> serde_json::Value { - json!({ - "status": self.status(), - "git_sha": self.git_sha, - "git_sha_short": self.git_sha_short, - "is_dirty": self.is_dirty, - "branch": self.branch, - "commit_date": self.commit_date, - "commit_timestamp": self.commit_timestamp, - "rustc_version": self.rustc_version, - "target": self.target, - "build_date": self.build_date, - "executable_path": self.executable_path, - "workspace_git_sha": self.workspace_git_sha, - "workspace_match": self.workspace_match, - "hint": self.hint, - }) - } -} - -fn known_build_metadata(value: Option<&str>) -> Option { - let value = value?.trim(); - if value.is_empty() || value == "unknown" { - None - } else { - Some(value.to_string()) - } -} - -fn parse_build_bool(value: Option<&str>) -> bool { - value - .map(str::trim) - .is_some_and(|value| value.eq_ignore_ascii_case("true") || value == "1") -} - -fn parse_build_timestamp(value: Option<&str>) -> i64 { - value - .and_then(|value| value.trim().parse::().ok()) - .unwrap_or(0) -} - -fn binary_provenance_for(cwd: Option<&Path>) -> BinaryProvenance { - let git_sha = known_build_metadata(GIT_SHA); - let git_sha_short = known_build_metadata(GIT_SHA_SHORT).or_else(|| { - git_sha - .as_ref() - .map(|sha| sha.chars().take(12).collect::()) - }); - let target = known_build_metadata(BUILD_TARGET); - let workspace_git_sha = cwd.and_then(|cwd| { - run_git_capture_in(cwd, &["rev-parse", "HEAD"]) - .map(|sha| sha.trim().to_string()) - .filter(|sha| !sha.is_empty()) - }); - let workspace_match = git_sha - .as_deref() - .zip(workspace_git_sha.as_deref()) - .map(|(binary, workspace)| binary == workspace); - let hint = if git_sha.is_none() { - Some( - "Build metadata did not include a git SHA; rebuild from a git checkout before filing provenance-sensitive dogfood reports." - .to_string(), - ) - } else if workspace_match == Some(false) { - Some( - "The running binary was built from a different commit than the current workspace HEAD; rebuild or switch binaries before attributing behavior to this checkout." - .to_string(), - ) - } else { - None - }; - BinaryProvenance { - git_sha, - git_sha_short, - is_dirty: parse_build_bool(GIT_DIRTY), - branch: known_build_metadata(GIT_BRANCH), - commit_date: known_build_metadata(GIT_COMMIT_DATE).unwrap_or_else(|| "unknown".to_string()), - commit_timestamp: parse_build_timestamp(GIT_COMMIT_TIMESTAMP), - rustc_version: known_build_metadata(RUSTC_VERSION).unwrap_or_else(|| "unknown".to_string()), - target, - build_date: DEFAULT_DATE.to_string(), - executable_path: env::current_exe() - .ok() - .map(|path| path.display().to_string()), - workspace_git_sha, - workspace_match, - hint, - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BranchFreshness { - upstream: Option, - ahead: u32, - behind: u32, - fresh: Option, -} - -impl BranchFreshness { - fn from_git_status(status: Option<&str>) -> Self { - let first_line = status - .and_then(|status| status.lines().next()) - .unwrap_or_default(); - let upstream = first_line - .split_once("...") - .and_then(|(_, rest)| rest.split([' ', '[']).next()) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned); - let mut ahead = 0; - let mut behind = 0; - if let Some((_, bracketed)) = first_line.split_once('[') { - let bracketed = bracketed.trim_end_matches(']'); - for part in bracketed.split(',').map(str::trim) { - if let Some(value) = part.strip_prefix("ahead ") { - ahead = value.parse().unwrap_or(0); - } else if let Some(value) = part.strip_prefix("behind ") { - behind = value.parse().unwrap_or(0); - } - } - } - let fresh = upstream.as_ref().map(|_| behind == 0); - Self { - upstream, - ahead, - behind, - fresh, - } - } - - fn json_value(&self) -> serde_json::Value { - json!({ - "upstream": self.upstream, - // #727: has_upstream disambiguates fresh:null-because-no-upstream - // from fresh:null-because-unavailable; automation should check - // has_upstream before branching on fresh. - "has_upstream": self.upstream.is_some(), - "ahead": self.ahead, - "behind": self.behind, - "fresh": self.fresh, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BinaryPreflight { - name: &'static str, - available: bool, -} - -impl BinaryPreflight { - fn json_value(&self) -> serde_json::Value { - json!({ - "name": self.name, - "available": self.available, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ControlSocketPreflight { - name: &'static str, - configured: bool, - exists: bool, - path: Option, -} - -impl ControlSocketPreflight { - fn json_value(&self) -> serde_json::Value { - json!({ - "name": self.name, - "configured": self.configured, - "exists": self.exists, - "path": self.path, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BootPreflightSnapshot { - repo_exists: bool, - worktree_exists: bool, - git_dir_exists: bool, - branch_freshness: BranchFreshness, - trust_gate_allowed: Option, - trusted_roots_count: usize, - required_binaries: Vec, - control_sockets: Vec, - mcp_startup_eligible: bool, - mcp_servers_configured: usize, - plugin_startup_eligible: bool, - plugins_configured: usize, - last_failed_boot_reason: Option, -} - -impl BootPreflightSnapshot { - fn json_value(&self) -> serde_json::Value { - json!({ - "repo": { - "exists": self.repo_exists, - "worktree_exists": self.worktree_exists, - "git_dir_exists": self.git_dir_exists, - }, - "branch_freshness": self.branch_freshness.json_value(), - "trust_gate": { - "allowlisted": self.trust_gate_allowed, - "trusted_roots_count": self.trusted_roots_count, - }, - "required_binaries": self.required_binaries.iter().map(BinaryPreflight::json_value).collect::>(), - "control_sockets": self.control_sockets.iter().map(ControlSocketPreflight::json_value).collect::>(), - "mcp_startup": { - "eligible": self.mcp_startup_eligible, - "servers_configured": self.mcp_servers_configured, - }, - "plugin_startup": { - "eligible": self.plugin_startup_eligible, - "plugins_configured": self.plugins_configured, - }, - "last_failed_boot_reason": self.last_failed_boot_reason, - }) - } - - fn summary(&self) -> String { - let trust = self - .trust_gate_allowed - .map(|value| { - if value { - "allowlisted" - } else { - "not allowlisted" - } - }) - .unwrap_or("unknown"); - let freshness = self - .branch_freshness - .fresh - .map(|fresh| if fresh { "fresh" } else { "behind" }) - .unwrap_or("no upstream"); - format!( - "repo={} worktree={} branch={} trust={} mcp={} plugins={} last_failed={}", - self.repo_exists, - self.worktree_exists, - freshness, - trust, - self.mcp_startup_eligible, - self.plugin_startup_eligible, - self.last_failed_boot_reason.as_deref().unwrap_or("none") - ) - } -} - -#[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, - /// #89: detected mid-operation git state (rebase, merge, cherry-pick, bisect) - operation: GitOperation, -} - -/// #89: mid-operation git states detected from branch header in `git status --short --branch`. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -enum GitOperation { - #[default] - None, - Rebase, - Merge, - CherryPick, - Bisect, -} - -impl GitOperation { - fn as_str(self) -> &'static str { - match self { - Self::None => "", - Self::Rebase => "rebase-in-progress", - Self::Merge => "merge-in-progress", - Self::CherryPick => "cherry-pick-in-progress", - Self::Bisect => "bisect-in-progress", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SessionLifecycleKind { - RunningProcess, - IdleShell, - SavedOnly, -} - -impl SessionLifecycleKind { - fn as_str(self) -> &'static str { - match self { - Self::RunningProcess => "running_process", - Self::IdleShell => "idle_shell", - Self::SavedOnly => "saved_only", - } - } - - fn human_label(self) -> &'static str { - match self { - Self::RunningProcess => "running process", - Self::IdleShell => "idle shell", - Self::SavedOnly => "saved only", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct SessionLifecycleSummary { - kind: SessionLifecycleKind, - pane_id: Option, - pane_command: Option, - pane_path: Option, - workspace_dirty: bool, - abandoned: bool, - // #326: all panes matching this workspace, not just the first one - all_panes: Vec, -} - -impl SessionLifecycleSummary { - fn signal(&self) -> String { - let mut parts = vec![self.kind.human_label().to_string()]; - if self.workspace_dirty { - parts.push("dirty worktree".to_string()); - } - if self.abandoned { - parts.push("abandoned?".to_string()); - } - if let Some(command) = self.pane_command.as_deref() { - parts.push(format!("cmd={command}")); - } - parts.join(" · ") - } - - fn json_value(&self) -> serde_json::Value { - json!({ - "kind": self.kind.as_str(), - "pane_id": self.pane_id, - "pane_command": self.pane_command, - "pane_path": self.pane_path.as_ref().map(|path| path.display().to_string()), - "workspace_dirty": self.workspace_dirty, - "abandoned": self.abandoned, - // #326: include all workspace panes in the JSON output - "panes": self.all_panes.iter().map(|p| { - json!({ - "pane_id": p.pane_id, - "pane_command": p.current_command, - "pane_path": p.current_path.display().to_string(), - }) - }).collect::>(), - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct TmuxPaneSnapshot { - pane_id: String, - current_command: String, - current_path: PathBuf, -} - -impl GitWorkspaceSummary { - fn is_clean(self) -> bool { - self.changed_files == 0 - } - - fn headline(self) -> String { - // #89: prefix with operation state when mid-operation - let op_prefix = if self.operation != GitOperation::None { - format!("{}, ", self.operation.as_str()) - } else { - String::new() - }; - if self.is_clean() { - if self.operation != GitOperation::None { - format!("{op_prefix}clean") - } else { - "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!( - "{op_prefix}dirty · {} files · {}", - self.changed_files, - details.join(", ") - ) - } - } -} - -fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary { - classify_session_lifecycle_from_panes(workspace, discover_tmux_panes()) -} - -fn classify_session_lifecycle_from_panes( - workspace: &Path, - panes: Vec, -) -> SessionLifecycleSummary { - let workspace_dirty = git_worktree_is_dirty(workspace); - let mut idle_shell: Option = None; - let mut all_workspace_panes: Vec = Vec::new(); - let mut running_pane: Option = None; - for pane in panes { - if !pane_path_matches_workspace(&pane.current_path, workspace) { - continue; - } - all_workspace_panes.push(pane.clone()); - if is_idle_shell_command(&pane.current_command) { - idle_shell.get_or_insert(pane); - } else if running_pane.is_none() { - running_pane = Some(pane); - } - } - - if let Some(pane) = running_pane { - 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, - all_panes: all_workspace_panes, - }; - } - - 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, - all_panes: all_workspace_panes, - } - } else { - SessionLifecycleSummary { - kind: SessionLifecycleKind::SavedOnly, - pane_id: None, - pane_command: None, - pane_path: None, - workspace_dirty, - abandoned: workspace_dirty, - all_panes: all_workspace_panes, - } - } -} - -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) -} - -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() -} - -fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool { - if pane_path == workspace || pane_path.starts_with(workspace) { - return true; - } - 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) -} - -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" - ) -} - -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()) -} - -#[cfg(test)] -fn format_unknown_slash_command_message(name: &str) -> String { - let suggestions = suggest_slash_commands(name); - let mut message = format!("unknown slash command: /{name}."); - if !suggestions.is_empty() { - message.push_str(" Did you mean "); - message.push_str(&suggestions.join(", ")); - message.push('?'); - } - if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { - message.push(' '); - message.push_str(note); - } - message.push_str(" Use /help to list available commands."); - message -} - -fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { - format!( - "Model - Current model {model} - Session messages {message_count} - Session turns {turns} - -Usage - Inspect current model with /model - Switch models with /model " - ) -} - -fn format_model_switch_report(previous: &str, next: &str, message_count: usize) -> String { - format!( - "Model updated - Previous {previous} - Current {next} - Preserved msgs {message_count}" - ) -} - -fn format_permissions_report(mode: &str) -> String { - let modes = [ - ("read-only", "Read/search tools only", mode == "read-only"), - ( - "workspace-write", - "Edit files inside the workspace", - mode == "workspace-write", - ), - ( - "danger-full-access", - "Unrestricted tool access", - mode == "danger-full-access", - ), - ] - .into_iter() - .map(|(name, description, is_current)| { - let marker = if is_current { - "● current" - } else { - "○ available" - }; - format!(" {name:<18} {marker:<11} {description}") - }) - .collect::>() - .join( - " -", - ); - - format!( - "Permissions - Active mode {mode} - Mode status live session default - -Modes -{modes} - -Usage - Inspect current mode with /permissions - Switch modes with /permissions " - ) -} - -fn format_permissions_switch_report(previous: &str, next: &str) -> String { - format!( - "Permissions updated - Result mode switched - Previous mode {previous} - Active mode {next} - Applies to subsequent tool calls - Usage /permissions to inspect current mode" - ) -} - -fn format_cost_report(usage: TokenUsage) -> String { - let estimated_cost = usage.estimate_cost_usd(); - format!( - "Cost - Input tokens {} - Output tokens {} - Cache create {} - Cache read {} - Total tokens {} - Estimated cost {}", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - usage.total_tokens(), - format_usd(estimated_cost.total_cost_usd()), - ) -} - -fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String { - format!( - "Session resumed - Session file {session_path} - Messages {message_count} - Turns {turns}" - ) -} - -fn render_resume_usage() -> String { - format!( - "Resume - Usage /resume - Auto-save .claw/sessions//.{PRIMARY_SESSION_EXTENSION} - Tip use /session list to inspect saved sessions" - ) -} - -fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String { - if skipped { - format!( - "Compact - Result skipped - Reason session below compaction threshold - Messages kept {resulting_messages}" - ) - } else { - format!( - "Compact - Result compacted - Messages removed {removed} - Messages kept {resulting_messages}" - ) - } -} - -fn format_auto_compaction_notice(removed: usize) -> String { - format!("[auto-compacted: removed {removed} messages]") -} - -fn parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { - parse_git_status_metadata_for( - &env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - status, - ) -} - -fn parse_git_status_branch(status: Option<&str>) -> Option { - let status = status?; - let first_line = status.lines().next()?; - let line = first_line.strip_prefix("## ")?; - if line.starts_with("HEAD") { - return Some("detached HEAD".to_string()); - } - let branch = line.split(['.', ' ']).next().unwrap_or_default().trim(); - if branch.is_empty() { - None - } else { - Some(branch.to_string()) - } -} - -fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary { - let mut summary = GitWorkspaceSummary::default(); - let Some(status) = status else { - return summary; - }; - - for line in status.lines() { - if line.starts_with("## ") { - // #89: detect mid-operation states from branch header - // git status --short --branch shows: - // "## HEAD (no branch, rebasing feature-branch)" - // "## main [merge-in-progress]" - // "## HEAD (no branch, cherry-pick-in-progress)" - // "## main (no branch, bisect-in-progress)" - let header = line.to_ascii_lowercase(); - if header.contains("rebasing") { - summary.operation = GitOperation::Rebase; - } else if header.contains("merge-in-progress") { - summary.operation = GitOperation::Merge; - } else if header.contains("cherry-pick-in-progress") { - summary.operation = GitOperation::CherryPick; - } else if header.contains("bisect-in-progress") { - summary.operation = GitOperation::Bisect; - } - continue; - } - if line.trim().is_empty() { - continue; - } - - summary.changed_files += 1; - let mut chars = line.chars(); - let index_status = chars.next().unwrap_or(' '); - let worktree_status = chars.next().unwrap_or(' '); - - if index_status == '?' && worktree_status == '?' { - summary.untracked_files += 1; - continue; - } - - if index_status != ' ' { - summary.staged_files += 1; - } - if worktree_status != ' ' { - summary.unstaged_files += 1; - } - if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A')) - || index_status == 'U' - || worktree_status == 'U' - { - summary.conflicted_files += 1; - } - } - - summary -} - -fn build_boot_preflight_snapshot( - cwd: &Path, - project_root: Option<&Path>, - git_status: Option<&str>, - runtime_config: Option<&runtime::RuntimeConfig>, - config_load_error: Option<&str>, -) -> BootPreflightSnapshot { - let branch_freshness = BranchFreshness::from_git_status(git_status); - let worktree_exists = run_git_bool(cwd, &["rev-parse", "--is-inside-work-tree"]); - let git_dir_exists = run_git_capture_in(cwd, &["rev-parse", "--git-dir"]) - .map(|path| { - let path = PathBuf::from(path.trim()); - if path.is_absolute() { - path - } else { - cwd.join(path) - } - }) - .is_some_and(|path| path.exists()); - let trusted_roots = runtime_config - .map(runtime::RuntimeConfig::trusted_roots) - .unwrap_or(&[]); - let trust_gate_allowed = runtime_config.map(|_| { - trusted_roots - .iter() - .any(|root| path_matches_trusted_root_local(cwd, root)) - }); - let plugin_configured = runtime_config - .map(|config| config.plugins().enabled_plugins().len()) - .unwrap_or_default(); - let mcp_configured = runtime_config - .map(|config| config.mcp().servers().len()) - .unwrap_or_default(); - let config_ok = config_load_error.is_none(); - BootPreflightSnapshot { - repo_exists: project_root.is_some_and(Path::exists), - worktree_exists, - git_dir_exists, - branch_freshness, - trust_gate_allowed, - trusted_roots_count: trusted_roots.len(), - required_binaries: vec![ - BinaryPreflight { - name: "claw", - available: env::current_exe().is_ok_and(|path| path.exists()), - }, - BinaryPreflight { - name: "git", - available: command_available("git"), - }, - BinaryPreflight { - name: "tmux", - available: command_available("tmux"), - }, - ], - control_sockets: vec![tmux_control_socket_preflight()], - mcp_startup_eligible: config_ok, - mcp_servers_configured: mcp_configured, - plugin_startup_eligible: config_ok, - plugins_configured: plugin_configured, - last_failed_boot_reason: last_failed_boot_reason(cwd), - } -} - -fn run_git_bool(cwd: &Path, args: &[&str]) -> bool { - Command::new("git") - .args(args) - .current_dir(cwd) - .output() - .is_ok_and(|output| output.status.success()) -} - -fn command_available(command: &str) -> bool { - Command::new(command) - .arg("--version") - .output() - .is_ok_and(|output| output.status.success()) -} - -fn tmux_control_socket_preflight() -> ControlSocketPreflight { - let path = env::var("TMUX") - .ok() - .and_then(|value| value.split(',').next().map(str::to_string)) - .filter(|value| !value.is_empty()); - let exists = path.as_ref().is_some_and(|path| Path::new(path).exists()); - ControlSocketPreflight { - name: "tmux", - configured: path.is_some(), - exists, - path, - } -} - -fn last_failed_boot_reason(cwd: &Path) -> Option { - env::var("CLAW_LAST_FAILED_BOOT_REASON") - .ok() - .filter(|value| !value.trim().is_empty()) - .or_else(|| { - fs::read_to_string(cwd.join(".claw").join("last-failed-boot.txt")) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }) -} - -fn path_matches_trusted_root_local(cwd: &Path, trusted_root: &str) -> bool { - let cwd = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); - let trusted_root = Path::new(trusted_root); - let trusted_root = if trusted_root.is_absolute() { - trusted_root.to_path_buf() - } else { - cwd.join(trusted_root) - }; - let trusted_root = fs::canonicalize(&trusted_root).unwrap_or(trusted_root); - cwd == trusted_root || cwd.starts_with(trusted_root) -} - -fn resolve_git_branch_for(cwd: &Path) -> Option { - let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?; - let branch = branch.trim(); - if !branch.is_empty() { - return Some(branch.to_string()); - } - - let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?; - let fallback = fallback.trim(); - if fallback.is_empty() { - None - } else if fallback == "HEAD" { - Some("detached HEAD".to_string()) - } else { - Some(fallback.to_string()) - } -} - -fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option { - let output = std::process::Command::new("git") - .args(args) - .current_dir(cwd) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -fn find_git_root_in(cwd: &Path) -> Result> { - let output = std::process::Command::new("git") - .args(["rev-parse", "--show-toplevel"]) - .current_dir(cwd) - .output()?; - if !output.status.success() { - return Err("not a git repository".into()); - } - let path = String::from_utf8(output.stdout)?.trim().to_string(); - if path.is_empty() { - return Err("empty git root".into()); - } - Ok(PathBuf::from(path)) -} - -fn parse_git_status_metadata_for( - cwd: &Path, - status: Option<&str>, -) -> (Option, Option) { - let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status)); - let project_root = find_git_root_in(cwd).ok(); - (project_root, branch) -} - -#[allow(clippy::too_many_lines)] -fn run_resume_command( - session_path: &Path, - session: &Session, - command: &SlashCommand, -) -> Result> { - let session_list_outcome = || -> Result> { - let sessions = list_managed_sessions().unwrap_or_default(); - let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); - let session_details = session_details_json(&sessions); - 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": "sessions", - "status": "ok", - "action": "list", - "sessions": session_ids, - "session_details": session_details, - "active": active_id, - })), - }) - }; - - match command { - SlashCommand::Help => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(render_repl_help()), - json: Some( - serde_json::json!({ "kind": "help", "action": "help", "status": "ok", "message": render_repl_help() }), - ), - }), - SlashCommand::Compact => { - let result = runtime::trident::trident_compact_session( - session, - CompactionConfig { - max_estimated_tokens: 0, - ..CompactionConfig::default() - }, - &runtime::trident::TridentConfig::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)?; - // #114: preserve the session_id from the file to avoid filename/meta-header - // divergence. /clear is "empty this session," not "fork to a new session." - let previous_session_id = session.session_id.clone(); - let mut cleared = new_cli_session()?; - cleared.session_id = previous_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 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": previous_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 - None, - )), - json: Some(status_json_value( - session.model.as_deref(), - StatusUsage { - message_count: session.messages.len(), - turns: tracker.turns(), - latest: tracker.current_turn_usage(), - cumulative: usage, - estimated_tokens: 0, - }, - default_permission_mode().as_str(), - &context, - None, // #148: resumed sessions don't have flag provenance - None, - None, - 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", - "action": "show", - "status": "ok", - "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(), - "estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.estimate_cost_usd().total_cost_usd(), - "pricing": "estimated-default", - })), - }) - } - 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", - "action": "export", - "status": "ok", - "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::to_value(handle_agents_slash_command_json(args.as_deref(), &cwd)?) - .unwrap_or(Value::Null), - ), - }) - } - SlashCommand::Skills { args } => { - if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { - // #779: use interactive_only: prefix + \n hint so #776 classify/split emits - // error_kind:interactive_only + non-null hint instead of unknown+null. - let skill_name = args.as_deref().unwrap_or(""); - return Err(format!( - "interactive_only: /skills {skill_name} invocation requires a live session.\nStart `claw` and run `/skills {skill_name}` inside the REPL, or use `claw -p ` with skill context." - ).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::Plugins { action, target } => { - // Only list is supported in resume mode (no runtime to reload) - match action.as_deref() { - Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => { - // #777: use interactive_only: prefix + \n hint so #776's classify/split - // emits error_kind:interactive_only + non-null hint instead of unknown+null. - // Orchestrators can now detect this and switch to a live REPL instead of retrying. - return Err(format!( - "interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command." - ).into()); - } - _ => {} - } - let cwd = env::current_dir()?; - let payload = plugins_command_payload_for( - &cwd, - action.as_deref(), - target.as_deref(), - ConfigWarningMode::EmitStderr, - )?; - let action_str = action.as_deref().unwrap_or("list"); - let enabled_count = payload - .plugins - .iter() - .filter(|p| p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false)) - .count(); - let disabled_count = payload.plugins.len().saturating_sub(enabled_count); - let mut json = serde_json::json!({ - "kind": "plugin", - "action": action_str, - "status": payload.status, - "summary": { - "total": payload.plugins.len(), - "enabled": enabled_count, - "disabled": disabled_count, - "load_failures": payload.load_failures.len(), - }, - "config_load_error": payload.config_load_error, - "mcp_validation": payload.mcp_validation.json_value(), - "plugins": payload.plugins, - "load_failures": payload.load_failures, - }); - if action_str != "list" { - json["target"] = serde_json::json!(target); - json["reload_runtime"] = serde_json::json!(payload.reload_runtime); - json["message"] = serde_json::json!(&payload.message); - } - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(payload.message), - json: Some(json), - }) - } - SlashCommand::Doctor => { - let report = render_doctor_report( - ConfigWarningMode::EmitStderr, - permission_mode_provenance_for_current_dir(), - )?; - 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", - "action": "show", - "status": "ok", - "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(), - "estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.estimate_cost_usd().total_cost_usd(), - "pricing": "estimated-default", - })), - }) - } - 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", - "action": "list", - "status": "ok", - "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/exists/delete can be served from the managed sessions directory - // in resume mode without starting an interactive REPL. Mutating delete remains - // opt-in through /session delete --force so JSON callers never hang on a prompt. - SlashCommand::Session { action, target } => { - run_resumed_session_command(session_path, session, action.as_deref(), target.as_deref()) - } - // #341: /tasks is resume-supported — return a no-op with structured JSON - SlashCommand::Tasks { args } => { - let args_str = args.as_deref().unwrap_or_default(); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "Tasks\n Note Background tasks are only available in the interactive REPL.\n Command /tasks {args_str}" - )), - json: Some(serde_json::json!({ - "kind": "tasks", - "action": "list", - "status": "ok", - "note": "Background tasks are only available in the interactive REPL.", - "args": args_str, - })), - }) - } - // #343: /model is resume-safe — returns model configuration - SlashCommand::Model { model } => { - let configured_model = config_model_for_current_dir(); - let resolved_config_model = configured_model - .as_deref() - .map(resolve_model_alias_with_config); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "Models\n Default {}\n Config model {}", - DEFAULT_MODEL, - configured_model.as_deref().unwrap_or("") - )), - json: Some(serde_json::json!({ - "kind": "models", - "action": "list", - "status": "ok", - "default_model": DEFAULT_MODEL, - "configured_model": configured_model, - "resolved_model": resolved_config_model, - "requested_model": model, - })), - }) - } - SlashCommand::Bughunter { .. } - | SlashCommand::Commit { .. } - | SlashCommand::Pr { .. } - | SlashCommand::Issue { .. } - | SlashCommand::Ultraplan { .. } - | SlashCommand::Teleport { .. } - | SlashCommand::DebugToolCall { .. } - | SlashCommand::Resume { .. } - | SlashCommand::Permissions { .. } - | 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::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 { .. } - | SlashCommand::Team { .. } - | SlashCommand::Setup => 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 => { - println!( - "{}", - serde_json::json!({ - "kind": "broad_cwd", - "action": "abort", - "status": "error", - "error_kind": "broad_cwd", - "error": message, - "hint": "Change to a more specific project directory, or use --cwd to set the workspace root.", - "exit_code": 1, - }) - ); - } - CliOutputFormat::Text => { - eprintln!("error: {message}"); - } - } - std::process::exit(1); - } -} - -fn stale_base_state_for(cwd: &Path, flag_value: Option<&str>) -> BaseCommitState { - let source = resolve_expected_base(flag_value, cwd); - check_base_commit(cwd, source.as_ref()) -} - -fn stale_base_json_value(state: &BaseCommitState) -> serde_json::Value { - match state { - BaseCommitState::Matches => json!({"status": "matches", "fresh": true}), - BaseCommitState::Diverged { expected, actual } => json!({ - "status": "diverged", - "fresh": false, - "expected": expected, - "actual": actual, - }), - BaseCommitState::NoExpectedBase => json!({"status": "no_expected_base", "fresh": null}), - BaseCommitState::NotAGitRepo => json!({"status": "not_git_repo", "fresh": null}), - } -} - -fn run_stale_base_preflight(flag_value: Option<&str>) { - let Ok(cwd) = env::current_dir() else { - return; - }; - let state = stale_base_state_for(&cwd, flag_value); - if let Some(warning) = format_stale_base_warning(&state) { - eprintln!("{warning}"); - } -} - -#[allow(clippy::needless_pass_by_value)] -fn run_repl( - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - base_commit: Option, - reasoning_effort: Option, - allow_broad_cwd: bool, -) -> Result<(), Box> { - enforce_broad_cwd_policy(allow_broad_cwd, CliOutputFormat::Text)?; - run_stale_base_preflight(base_commit.as_deref()); - let resolved_model = resolve_repl_model(model)?; - let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; - cli.set_reasoning_effort(reasoning_effort); - let mut editor = - input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); - println!("{}", cli.startup_banner()); - println!("{}", format_connected_line(&cli.model)); - - loop { - editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); - match editor.read_line()? { - input::ReadOutcome::Submit(input) => { - let trimmed = input.trim().to_string(); - if trimmed.is_empty() { - continue; - } - if matches!(trimmed.as_str(), "/exit" | "/quit") { - cli.persist_session()?; - break; - } - match SlashCommand::parse(&trimmed) { - Ok(Some(command)) => { - if cli.handle_repl_command(command)? { - cli.persist_session()?; - } - continue; - } - Ok(None) => {} - Err(error) => { - eprintln!("{error}"); - continue; - } - } - // Bare-word skill dispatch: if the first token of the input - // matches a known skill name, invoke it as `/skills ` - // rather than forwarding raw text to the LLM (ROADMAP #36). - let cwd = std::env::current_dir().unwrap_or_default(); - if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) { - editor.push_history(input); - cli.record_prompt_history(&trimmed); - cli.run_turn(&prompt)?; - continue; - } - editor.push_history(input); - cli.record_prompt_history(&trimmed); - cli.run_turn(&trimmed)?; - } - input::ReadOutcome::Cancel => {} - input::ReadOutcome::Exit => { - cli.persist_session()?; - break; - } - } - } - - Ok(()) -} - -#[derive(Debug, Clone)] -struct SessionHandle { - id: String, - path: PathBuf, -} - -#[derive(Debug, Clone)] -struct ManagedSessionSummary { - id: String, - path: PathBuf, - created_at_ms: u64, - updated_at_ms: u64, - modified_epoch_millis: u128, - message_count: usize, - parent_session_id: Option, - branch_name: Option, - lifecycle: SessionLifecycleSummary, -} - -struct LiveCli { - model: String, - allowed_tools: Option, - permission_mode: PermissionMode, - system_prompt: Vec, - runtime: BuiltRuntime, - session: SessionHandle, - prompt_history: Vec, -} - -#[derive(Debug, Clone)] -struct PromptHistoryEntry { - timestamp_ms: u64, - text: String, -} - -struct RuntimePluginState { - feature_config: runtime::RuntimeFeatureConfig, - tool_registry: GlobalToolRegistry, - plugin_registry: PluginRegistry, - mcp_state: Option>>, -} - -struct RuntimeMcpState { - runtime: tokio::runtime::Runtime, - manager: McpServerManager, - pending_servers: Vec, - degraded_report: Option, -} - -struct BuiltRuntime { - runtime: Option>, - plugin_registry: PluginRegistry, - plugins_active: bool, - mcp_state: Option>>, - mcp_active: bool, -} - -impl BuiltRuntime { - fn new( - runtime: ConversationRuntime, - plugin_registry: PluginRegistry, - mcp_state: Option>>, - ) -> Self { - Self { - runtime: Some(runtime), - plugin_registry, - plugins_active: true, - mcp_state, - mcp_active: true, - } - } - - fn with_hook_abort_signal(mut self, hook_abort_signal: runtime::HookAbortSignal) -> Self { - let runtime = self - .runtime - .take() - .expect("runtime should exist before installing hook abort signal"); - self.runtime = Some(runtime.with_hook_abort_signal(hook_abort_signal)); - self - } - - fn shutdown_plugins(&mut self) -> Result<(), Box> { - if self.plugins_active { - self.plugin_registry.shutdown()?; - self.plugins_active = false; - } - Ok(()) - } - - fn shutdown_mcp(&mut self) -> Result<(), Box> { - if self.mcp_active { - if let Some(mcp_state) = &self.mcp_state { - mcp_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .shutdown()?; - } - self.mcp_active = false; - } - Ok(()) - } -} - -impl Deref for BuiltRuntime { - type Target = ConversationRuntime; - - fn deref(&self) -> &Self::Target { - self.runtime - .as_ref() - .expect("runtime should exist while built runtime is alive") - } -} - -impl DerefMut for BuiltRuntime { - fn deref_mut(&mut self) -> &mut Self::Target { - self.runtime - .as_mut() - .expect("runtime should exist while built runtime is alive") - } -} - -impl Drop for BuiltRuntime { - fn drop(&mut self) { - let _ = self.shutdown_mcp(); - let _ = self.shutdown_plugins(); - } -} - -#[derive(Debug, Deserialize)] -struct ToolSearchRequest { - query: String, - max_results: Option, -} - -#[derive(Debug, Deserialize)] -struct McpToolRequest { - #[serde(rename = "qualifiedName")] - qualified_name: Option, - tool: Option, - arguments: Option, -} - -#[derive(Debug, Deserialize)] -struct ListMcpResourcesRequest { - server: Option, -} - -#[derive(Debug, Deserialize)] -struct ReadMcpResourceRequest { - server: String, - uri: String, -} - -impl RuntimeMcpState { - fn new( - runtime_config: &runtime::RuntimeConfig, - ) -> Result, Box> { - let mut manager = McpServerManager::from_runtime_config(runtime_config); - if manager.server_names().is_empty() && manager.unsupported_servers().is_empty() { - return Ok(None); - } - - let runtime = tokio::runtime::Runtime::new()?; - let discovery = runtime.block_on(manager.discover_tools_best_effort()); - let pending_servers = discovery - .failed_servers - .iter() - .map(|failure| failure.server_name.clone()) - .chain( - discovery - .unsupported_servers - .iter() - .map(|server| server.server_name.clone()), - ) - .collect::>() - .into_iter() - .collect::>(); - let available_tools = discovery - .tools - .iter() - .map(|tool| tool.qualified_name.clone()) - .collect::>(); - let failed_server_names = pending_servers.iter().cloned().collect::>(); - let working_servers = manager - .server_names() - .into_iter() - .filter(|server_name| !failed_server_names.contains(server_name)) - .collect::>(); - let failed_servers = - discovery - .failed_servers - .iter() - .map(|failure| runtime::McpFailedServer { - server_name: failure.server_name.clone(), - phase: runtime::McpLifecyclePhase::ToolDiscovery, - error: runtime::McpErrorSurface::new( - runtime::McpLifecyclePhase::ToolDiscovery, - Some(failure.server_name.clone()), - failure.error.clone(), - std::collections::BTreeMap::from([( - "required".to_string(), - failure.required.to_string(), - )]), - 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(), - ), - ("required".to_string(), server.required.to_string()), - ]), - false, - ), - } - })) - .collect::>(); - let degraded_report = (!failed_servers.is_empty()).then(|| { - runtime::McpDegradedReport::new( - working_servers, - failed_servers, - available_tools.clone(), - available_tools, - ) - }); - - Ok(Some(( - Self { - runtime, - manager, - pending_servers, - degraded_report, - }, - discovery, - ))) - } - - fn shutdown(&mut self) -> Result<(), Box> { - self.runtime.block_on(self.manager.shutdown())?; - Ok(()) - } - - fn pending_servers(&self) -> Option> { - (!self.pending_servers.is_empty()).then(|| self.pending_servers.clone()) - } - - fn degraded_report(&self) -> Option { - self.degraded_report.clone() - } - - fn server_names(&self) -> Vec { - self.manager.server_names() - } - - fn call_tool( - &mut self, - qualified_tool_name: &str, - arguments: Option, - ) -> Result { - let response = self - .runtime - .block_on(self.manager.call_tool(qualified_tool_name, arguments)) - .map_err(|error| ToolError::new(error.to_string()))?; - if let Some(error) = response.error { - return Err(ToolError::new(format!( - "MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})", - error.message, error.code - ))); - } - - let result = response.result.ok_or_else(|| { - ToolError::new(format!( - "MCP tool `{qualified_tool_name}` returned no result payload" - )) - })?; - serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string())) - } - - fn list_resources_for_server(&mut self, server_name: &str) -> Result { - let result = self - .runtime - .block_on(self.manager.list_resources(server_name)) - .map_err(|error| ToolError::new(error.to_string()))?; - serde_json::to_string_pretty(&json!({ - "server": server_name, - "resources": result.resources, - })) - .map_err(|error| ToolError::new(error.to_string())) - } - - fn list_resources_for_all_servers(&mut self) -> Result { - let mut resources = Vec::new(); - let mut failures = Vec::new(); - - for server_name in self.server_names() { - match self - .runtime - .block_on(self.manager.list_resources(&server_name)) - { - Ok(result) => resources.push(json!({ - "server": server_name, - "resources": result.resources, - })), - Err(error) => failures.push(json!({ - "server": server_name, - "error": error.to_string(), - })), - } - } - - if resources.is_empty() && !failures.is_empty() { - let message = failures - .iter() - .filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str)) - .collect::>() - .join("; "); - return Err(ToolError::new(message)); - } - - serde_json::to_string_pretty(&json!({ - "resources": resources, - "failures": failures, - })) - .map_err(|error| ToolError::new(error.to_string())) - } - - fn read_resource(&mut self, server_name: &str, uri: &str) -> Result { - let result = self - .runtime - .block_on(self.manager.read_resource(server_name, uri)) - .map_err(|error| ToolError::new(error.to_string()))?; - serde_json::to_string_pretty(&json!({ - "server": server_name, - "contents": result.contents, - })) - .map_err(|error| ToolError::new(error.to_string())) - } -} - -fn build_runtime_mcp_state( - runtime_config: &runtime::RuntimeConfig, -) -> Result> { - let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else { - return Ok((None, Vec::new())); - }; - - let mut runtime_tools = discovery - .tools - .iter() - .map(mcp_runtime_tool_definition) - .collect::>(); - if !mcp_state.server_names().is_empty() { - runtime_tools.extend(mcp_wrapper_tool_definitions()); - } - - Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools)) -} - -fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition { - RuntimeToolDefinition { - name: tool.qualified_name.clone(), - description: Some( - tool.tool - .description - .clone() - .unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)), - ), - input_schema: tool - .tool - .input_schema - .clone() - .unwrap_or_else(|| json!({ "type": "object", "additionalProperties": true })), - required_permission: permission_mode_for_mcp_tool(&tool.tool), - } -} - -fn mcp_wrapper_tool_definitions() -> Vec { - vec![ - RuntimeToolDefinition { - name: "MCPTool".to_string(), - description: Some( - "Call a configured MCP tool by its qualified name and JSON arguments.".to_string(), - ), - input_schema: json!({ - "type": "object", - "properties": { - "qualifiedName": { "type": "string" }, - "arguments": {} - }, - "required": ["qualifiedName"], - "additionalProperties": false - }), - required_permission: PermissionMode::DangerFullAccess, - }, - RuntimeToolDefinition { - name: "ListMcpResourcesTool".to_string(), - description: Some( - "List MCP resources from one configured server or from every connected server." - .to_string(), - ), - input_schema: json!({ - "type": "object", - "properties": { - "server": { "type": "string" } - }, - "additionalProperties": false - }), - required_permission: PermissionMode::ReadOnly, - }, - RuntimeToolDefinition { - name: "ReadMcpResourceTool".to_string(), - description: Some("Read a specific MCP resource from a configured server.".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "server": { "type": "string" }, - "uri": { "type": "string" } - }, - "required": ["server", "uri"], - "additionalProperties": false - }), - required_permission: PermissionMode::ReadOnly, - }, - ] -} - -fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode { - let read_only = mcp_annotation_flag(tool, "readOnlyHint"); - let destructive = mcp_annotation_flag(tool, "destructiveHint"); - let open_world = mcp_annotation_flag(tool, "openWorldHint"); - - if read_only && !destructive && !open_world { - PermissionMode::ReadOnly - } else if destructive || open_world { - PermissionMode::DangerFullAccess - } else { - PermissionMode::WorkspaceWrite - } -} - -fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool { - tool.annotations - .as_ref() - .and_then(|annotations| annotations.get(key)) - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) -} - -struct HookAbortMonitor { - stop_tx: Option>, - join_handle: Option>, -} - -impl HookAbortMonitor { - fn spawn(abort_signal: runtime::HookAbortSignal) -> Self { - Self::spawn_with_waiter(abort_signal, move |stop_rx, abort_signal| { - let Ok(runtime) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - else { - return; - }; - - runtime.block_on(async move { - let wait_for_stop = tokio::task::spawn_blocking(move || { - let _ = stop_rx.recv(); - }); - - tokio::select! { - result = tokio::signal::ctrl_c() => { - if result.is_ok() { - abort_signal.abort(); - } - } - _ = wait_for_stop => {} - } - }); - }) - } - - fn spawn_with_waiter(abort_signal: runtime::HookAbortSignal, wait_for_interrupt: F) -> Self - where - F: FnOnce(Receiver<()>, runtime::HookAbortSignal) + Send + 'static, - { - let (stop_tx, stop_rx) = mpsc::channel(); - let join_handle = thread::spawn(move || wait_for_interrupt(stop_rx, abort_signal)); - - Self { - stop_tx: Some(stop_tx), - join_handle: Some(join_handle), - } - } - - fn stop(mut self) { - if let Some(stop_tx) = self.stop_tx.take() { - let _ = stop_tx.send(()); - } - if let Some(join_handle) = self.join_handle.take() { - let _ = join_handle.join(); - } - } -} - -impl LiveCli { - fn new( - model: String, - enable_tools: bool, - allowed_tools: Option, - permission_mode: PermissionMode, - ) -> Result> { - let system_prompt = build_system_prompt(&model)?; - let session_state = new_cli_session()?; - let session = create_managed_session_handle(&session_state.session_id)?; - let runtime = build_runtime( - session_state.with_persistence_path(session.path.clone()), - &session.id, - model.clone(), - system_prompt.clone(), - enable_tools, - true, - allowed_tools.clone(), - permission_mode, - None, - )?; - let cli = Self { - model, - allowed_tools, - permission_mode, - system_prompt, - runtime, - session, - prompt_history: Vec::new(), - }; - cli.persist_session()?; - Ok(cli) - } - - fn set_reasoning_effort(&mut self, effort: Option) { - if let Some(rt) = self.runtime.runtime.as_mut() { - rt.api_client_mut().set_reasoning_effort(effort); - } - } - - fn startup_banner(&self) -> String { - let cwd = env::current_dir().map_or_else( - |_| "".to_string(), - |path| path.display().to_string(), - ); - let status = status_context(None).ok(); - let git_branch = status - .as_ref() - .and_then(|context| context.git_branch.as_deref()) - .unwrap_or("unknown"); - let workspace = status.as_ref().map_or_else( - || "unknown".to_string(), - |context| context.git_summary.headline(), - ); - let session_path = self.session.path.strip_prefix(Path::new(&cwd)).map_or_else( - |_| self.session.path.display().to_string(), - |path| path.display().to_string(), - ); - format!( - "\x1b[38;5;196m\ - ██████╗██╗ █████╗ ██╗ ██╗\n\ -██╔════╝██║ ██╔══██╗██║ ██║\n\ -██║ ██║ ███████║██║ █╗ ██║\n\ -██║ ██║ ██╔══██║██║███╗██║\n\ -╚██████╗███████╗██║ ██║╚███╔███╔╝\n\ - ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\x1b[0m \x1b[38;5;208mCode\x1b[0m 🦞\n\n\ - \x1b[2mModel\x1b[0m {}\n\ - \x1b[2mPermissions\x1b[0m {}\n\ - \x1b[2mBranch\x1b[0m {}\n\ - \x1b[2mWorkspace\x1b[0m {}\n\ - \x1b[2mDirectory\x1b[0m {}\n\ - \x1b[2mSession\x1b[0m {}\n\ - \x1b[2mAuto-save\x1b[0m {}\n\n\ - Type \x1b[1m/help\x1b[0m for commands · \x1b[1m/status\x1b[0m for live context · \x1b[2m/resume latest\x1b[0m jumps back to the newest session · \x1b[1m/diff\x1b[0m then \x1b[1m/commit\x1b[0m to ship · \x1b[2mTab\x1b[0m for workflow completions · \x1b[2mShift+Enter\x1b[0m for newline", - self.model, - self.permission_mode.as_str(), - git_branch, - workspace, - cwd, - self.session.id, - session_path, - ) - } - - fn repl_completion_candidates(&self) -> Result, Box> { - Ok(slash_command_completion_candidates_with_sessions( - &self.model, - Some(&self.session.id), - list_managed_sessions()? - .into_iter() - .map(|session| session.id) - .collect(), - )) - } - - fn prepare_turn_runtime( - &self, - emit_output: bool, - ) -> Result<(BuiltRuntime, HookAbortMonitor), Box> { - let hook_abort_signal = runtime::HookAbortSignal::new(); - let runtime = build_runtime( - self.runtime.session().clone(), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - emit_output, - self.allowed_tools.clone(), - self.permission_mode, - None, - )? - .with_hook_abort_signal(hook_abort_signal.clone()); - let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal); - - Ok((runtime, hook_abort_monitor)) - } - - fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box> { - self.runtime.shutdown_plugins()?; - self.runtime = runtime; - Ok(()) - } - - fn run_turn(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; - let mut spinner = Spinner::new(); - let mut stdout = io::stdout(); - spinner.tick( - "🦀 Thinking...", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - match result { - Ok(summary) => { - self.replace_runtime(runtime)?; - spinner.finish( - "✨ Done", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; - let final_text = final_assistant_text(&summary); - if !final_text.is_empty() { - println!("{final_text}"); - } - 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, - )?; - - // ============================================================================ - // Auto-compact retry on context window errors - // ============================================================================ - // When the model API returns a context_window_blocked error (because the request - // exceeds the model's context window), we automatically: - // 1. Compact the session (remove old messages to free up space) - // 2. Retry the original request with the compacted session - // 3. Report results to the user - // - // This eliminates the need for users to manually run /compact when they - // hit context limits - the recovery happens automatically. - // - // Detection: We look for "context_window" or "Context window" in the error - // message, which covers error types like: - // - "context_window_blocked" - // - "Context window blocked" - // - "This model's maximum context length is X tokens..." - // ============================================================================ - - let error_str = error.to_string(); - // Detect context window overflow. Some providers (e.g. OpenAI-compat backends) - // return 400 with "no parseable body" instead of a proper context_length_exceeded - // error when the request is too large to even parse — treat that as context overflow too. - // Also detect model-specific context error markers (e.g. llama.cpp returns - // "Context size has been exceeded." / "exceed_context_size_error" / "exceeds the available context size"). - let is_context_window = error_str.contains("context_window") - || error_str.contains("Context window") - || error_str.contains("no parseable body") - || error_str.contains("exceed_context_size") - || error_str.contains("exceeds the available context size") - || error_str - .to_ascii_lowercase() - .contains("context size has been exceeded"); - - // Also treat "assistant stream produced no content" and reqwest decode failures - // as recoverable errors that may benefit from auto-compaction. Some backends (e.g. - // llama.cpp) return a non-SSE HTTP 500 body when context overflows, causing - // reqwest to fail with "error decoding response body" — treat that as context overflow too. - let is_no_content = error_str.contains("assistant stream produced no content") - || error_str.contains("Failed to parse input at pos") - || error_str.contains("error decoding response body"); - - if is_context_window || is_no_content { - // If the error tells us the server's actual context window, adapt our - // auto-compaction threshold so future auto-compact-trigger checks are accurate. - if let Some(window) = extract_context_window_tokens_from_error(&error_str) { - // Set threshold at 70% of the reported window to leave headroom. - let threshold: u32 = (window as f64 * 0.7).round() as u32; - println!( - " Server context window: {} tokens — setting auto-compaction threshold to {}", - window, threshold - ); - runtime.set_auto_compaction_input_tokens_threshold(threshold); - } - - // A single compaction pass may not free enough context space. - // Progressive retry: each round preserves fewer recent messages (4→2→1→0), - // trading conversation continuity for a smaller payload until it fits. - // Max 4 rounds before giving up and surfacing the error to the user. - let max_compact_rounds = 4; - let preserve_schedule = [4, 2, 1, 0]; - - for round in 0..max_compact_rounds { - let preserve = preserve_schedule[round]; - println!( - " Auto-compacting session (round {}/{}, preserving {} recent messages)...", - round + 1, - max_compact_rounds, - preserve - ); - - // Run Trident pipeline then summary-based compaction - let result = runtime::trident::trident_compact_session( - runtime.session(), - CompactionConfig { - preserve_recent_messages: preserve, - max_estimated_tokens: 0, - }, - &runtime::trident::TridentConfig::default(), - ); - let removed = result.removed_message_count; - - if removed == 0 && round > 0 { - // No more messages to compact — further rounds won't help - println!(" No further compaction possible."); - break; - } - - if removed > 0 { - println!( - "{}", - format_compact_report( - removed, - result.compacted_session.messages.len(), - false - ) - ); - } - - // Without this, prepare_turn_runtime() reads from self.runtime.session() - // which still holds the ORIGINAL un-compacted session, so every retry round - // would send the same bloated request — compaction was wasted. - *self.runtime.session_mut() = result.compacted_session.clone(); - - // Build a new runtime with the compacted session and retry - let (mut new_runtime, hook_abort_monitor) = - self.prepare_turn_runtime(true)?; - drop(hook_abort_monitor); - - let mut rp = CliPermissionPrompter::new(self.permission_mode); - match new_runtime.run_turn(input, Some(&mut rp)) { - Ok(summary) => { - self.replace_runtime(new_runtime)?; - spinner.finish( - if round == 0 { - "✨ Done (after auto-compact)" - } else { - "✨ Done (after aggressive auto-compact)" - }, - 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()?; - return Ok(()); - } - Err(retry_error) => { - let retry_str = retry_error.to_string(); - let still_context_window = retry_str.contains("context_window") - || retry_str.contains("Context window") - || retry_str.contains("no parseable body") - || retry_str.contains("exceed_context_size") - || retry_str.contains("exceeds the available context size") - || retry_str - .to_ascii_lowercase() - .contains("context size has been exceeded"); - let still_no_content = retry_str - .contains("assistant stream produced no content") - || retry_str.contains("Failed to parse input at pos") - || retry_str.contains("error decoding response body"); - - if (still_context_window || still_no_content) - && round + 1 < max_compact_rounds - { - // If the retry error reveals the context window, adapt threshold. - if let Some(window) = - extract_context_window_tokens_from_error(&retry_str) - { - let threshold: u32 = (window as f64 * 0.7).round() as u32; - new_runtime - .set_auto_compaction_input_tokens_threshold(threshold); - } - - // The compacted session was still too large for the model's context. - // Shut down the old runtime, adopt the partially-compacted one, - // and loop — the next round will compact more aggressively. - runtime.shutdown_plugins()?; - runtime = new_runtime; - continue; - } - - // Not a context window error, or out of rounds - return Err(Box::new(retry_error)); - } - } - } - } - - // If not a context window error, return original error - Err(Box::new(error)) - } - } - } - - fn run_turn_with_output( - &mut self, - input: &str, - output_format: CliOutputFormat, - compact: bool, - ) -> Result<(), Box> { - match output_format { - CliOutputFormat::Json if compact => self.run_prompt_compact_json(input), - CliOutputFormat::Text if compact => self.run_prompt_compact(input), - CliOutputFormat::Text => self.run_turn(input), - CliOutputFormat::Json => self.run_prompt_json(input), - } - } - - fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - let final_text = final_assistant_text(&summary); - println!("{final_text}"); - Ok(()) - } - - fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!( - "{}", - json!({ - "message": final_assistant_text(&summary), - "compact": true, - "model": self.model, - "usage": { - "input_tokens": summary.usage.input_tokens, - "output_tokens": summary.usage.output_tokens, - "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, - "cache_read_input_tokens": summary.usage.cache_read_input_tokens, - }, - }) - ); - Ok(()) - } - - fn run_prompt_json(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); - hook_abort_monitor.stop(); - let summary = result?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!( - "{}", - json!({ - "message": final_assistant_text(&summary), - "model": self.model, - "iterations": summary.iterations, - "auto_compaction": summary.auto_compaction.map(|event| json!({ - "removed_messages": event.removed_message_count, - "notice": format_auto_compaction_notice(event.removed_message_count), - })), - "tool_uses": collect_tool_uses(&summary), - "tool_results": collect_tool_results(&summary), - "prompt_cache_events": collect_prompt_cache_events(&summary), - "usage": { - "input_tokens": summary.usage.input_tokens, - "output_tokens": summary.usage.output_tokens, - "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, - "cache_read_input_tokens": summary.usage.cache_read_input_tokens, - }, - "estimated_cost": format_usd( - summary.usage.estimate_cost_usd_with_pricing( - pricing_for_model(&self.model) - .unwrap_or_else(runtime::ModelPricing::default_sonnet_tier) - ).total_cost_usd() - ) - }) - ); - Ok(()) - } - - #[allow(clippy::too_many_lines)] - fn handle_repl_command( - &mut self, - command: SlashCommand, - ) -> Result> { - Ok(match command { - SlashCommand::Help => { - println!("{}", render_repl_help()); - false - } - SlashCommand::Status => { - self.print_status(); - false - } - SlashCommand::Bughunter { scope } => { - self.run_bughunter(scope.as_deref())?; - false - } - SlashCommand::Commit => { - self.run_commit(None)?; - false - } - SlashCommand::Pr { context } => { - self.run_pr(context.as_deref())?; - false - } - SlashCommand::Issue { context } => { - self.run_issue(context.as_deref())?; - false - } - SlashCommand::Ultraplan { task } => { - self.run_ultraplan(task.as_deref())?; - false - } - SlashCommand::Teleport { target } => { - Self::run_teleport(target.as_deref())?; - false - } - SlashCommand::DebugToolCall => { - self.run_debug_tool_call(None)?; - false - } - SlashCommand::Sandbox => { - Self::print_sandbox_status(); - false - } - SlashCommand::Compact => { - self.compact()?; - false - } - SlashCommand::Model { model } => self.set_model(model)?, - SlashCommand::Permissions { mode } => self.set_permissions(mode)?, - SlashCommand::Clear { confirm } => self.clear_session(confirm)?, - SlashCommand::Cost => { - self.print_cost(); - false - } - SlashCommand::Resume { session_path } => self.resume_session(session_path)?, - SlashCommand::Config { section } => { - Self::print_config(section.as_deref())?; - false - } - SlashCommand::Mcp { action, target } => { - let args = match (action.as_deref(), target.as_deref()) { - (None, None) => None, - (Some(action), None) => Some(action.to_string()), - (Some(action), Some(target)) => Some(format!("{action} {target}")), - (None, Some(target)) => Some(target.to_string()), - }; - Self::print_mcp(args.as_deref(), CliOutputFormat::Text)?; - false - } - SlashCommand::Memory => { - Self::print_memory()?; - false - } - SlashCommand::Init => { - run_init(CliOutputFormat::Text)?; - false - } - SlashCommand::Diff => { - Self::print_diff()?; - false - } - SlashCommand::Version => { - Self::print_version(CliOutputFormat::Text); - false - } - SlashCommand::Export { path } => { - self.export_session(path.as_deref())?; - false - } - SlashCommand::Session { action, target } => { - self.handle_session_command(action.as_deref(), target.as_deref())? - } - SlashCommand::Plugins { action, target } => { - self.handle_plugins_command(action.as_deref(), target.as_deref())? - } - SlashCommand::Agents { args } => { - if let Err(error) = Self::print_agents(args.as_deref(), CliOutputFormat::Text) { - eprintln!("{error}"); - } - false - } - SlashCommand::Skills { args } => { - match classify_skills_slash_command(args.as_deref()) { - SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?, - SkillSlashDispatch::Local => { - if let Err(error) = - Self::print_skills(args.as_deref(), CliOutputFormat::Text) - { - eprintln!("{error}"); - } - } - } - false - } - SlashCommand::Doctor => { - println!( - "{}", - render_doctor_report( - ConfigWarningMode::EmitStderr, - permission_mode_provenance_for_current_dir(), - )? - .render() - ); - false - } - SlashCommand::Setup => { - if let Err(e) = setup_wizard::run_setup_wizard() { - eprintln!("Setup wizard failed: {e}"); - } - 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 { .. } - | SlashCommand::Team { .. } => { - let cmd_name = command.slash_name(); - eprintln!("{cmd_name} is not yet implemented in this build."); - false - } - SlashCommand::Unknown(name) => { - eprintln!("{}", format_unknown_slash_command(&name)); - false - } - }) - } - - fn persist_session(&self) -> Result<(), Box> { - self.runtime.session().save_to_path(&self.session.path)?; - Ok(()) - } - - fn print_status(&self) { - let cumulative = self.runtime.usage().cumulative_usage(); - let latest = self.runtime.usage().current_turn_usage(); - println!( - "{}", - format_status_report( - &self.model, - StatusUsage { - message_count: self.runtime.session().messages.len(), - turns: self.runtime.usage().turns(), - latest, - cumulative, - estimated_tokens: self.runtime.estimated_tokens(), - }, - self.permission_mode.as_str(), - &status_context(Some(&self.session.path)).expect("status context should load"), - None, // #148: REPL /status doesn't carry flag provenance - None, - ) - ); - } - - fn record_prompt_history(&mut self, prompt: &str) { - let timestamp_ms = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(self.runtime.session().updated_at_ms, |duration| { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) - }); - let entry = PromptHistoryEntry { - timestamp_ms, - text: prompt.to_string(), - }; - self.prompt_history.push(entry); - if let Err(error) = self.runtime.session_mut().push_prompt_entry(prompt) { - eprintln!("warning: failed to persist prompt history: {error}"); - } - } - - fn print_prompt_history(&self, count: Option<&str>) { - let limit = match parse_history_count(count) { - Ok(limit) => limit, - Err(message) => { - eprintln!("{message}"); - return; - } - }; - let session_entries = &self.runtime.session().prompt_history; - let entries = if session_entries.is_empty() { - if self.prompt_history.is_empty() { - collect_session_prompt_history(self.runtime.session()) - } else { - self.prompt_history - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect() - } - } else { - session_entries - .iter() - .map(|entry| PromptHistoryEntry { - timestamp_ms: entry.timestamp_ms, - text: entry.text.clone(), - }) - .collect() - }; - println!("{}", render_prompt_history_report(&entries, limit)); - } - - fn print_sandbox_status() { - let cwd = env::current_dir().expect("current dir"); - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader - .load() - .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); - println!( - "{}", - format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd)) - ); - } - - fn set_model(&mut self, model: Option) -> Result> { - let Some(model) = model else { - println!( - "{}", - format_model_report( - &self.model, - self.runtime.session().messages.len(), - self.runtime.usage().turns(), - ) - ); - return Ok(false); - }; - - let model = resolve_model_alias_with_config(&model); - - if model == self.model { - println!( - "{}", - format_model_report( - &self.model, - self.runtime.session().messages.len(), - self.runtime.usage().turns(), - ) - ); - return Ok(false); - } - - let previous = self.model.clone(); - let session = self.runtime.session().clone(); - let message_count = session.messages.len(); - let runtime = build_runtime( - session, - &self.session.id, - model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.model.clone_from(&model); - println!( - "{}", - format_model_switch_report(&previous, &model, message_count) - ); - Ok(true) - } - - fn set_permissions( - &mut self, - mode: Option, - ) -> Result> { - let Some(mode) = mode else { - println!( - "{}", - format_permissions_report(self.permission_mode.as_str()) - ); - return Ok(false); - }; - - let normalized = normalize_permission_mode(&mode).ok_or_else(|| { - format!( - "invalid_flag_value: unsupported permission mode '{mode}'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access" - ) - })?; - - if normalized == self.permission_mode.as_str() { - println!("{}", format_permissions_report(normalized)); - return Ok(false); - } - - let previous = self.permission_mode.as_str().to_string(); - let session = self.runtime.session().clone(); - self.permission_mode = permission_mode_from_label(normalized); - let runtime = build_runtime( - session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - println!( - "{}", - format_permissions_switch_report(&previous, normalized) - ); - Ok(true) - } - - fn clear_session(&mut self, confirm: bool) -> Result> { - if !confirm { - println!( - "clear: confirmation required; run /clear --confirm to start a fresh session." - ); - return Ok(false); - } - - let previous_session = self.session.clone(); - let session_state = new_cli_session()?; - self.session = create_managed_session_handle(&session_state.session_id)?; - let runtime = build_runtime( - session_state.with_persistence_path(self.session.path.clone()), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - println!( - "Session cleared\n Mode fresh session\n Previous session {}\n Resume previous /resume {}\n Preserved model {}\n Permission mode {}\n New session {}\n Session file {}", - previous_session.id, - previous_session.id, - self.model, - self.permission_mode.as_str(), - self.session.id, - self.session.path.display(), - ); - Ok(true) - } - - fn print_cost(&self) { - let cumulative = self.runtime.usage().cumulative_usage(); - println!("{}", format_cost_report(cumulative)); - } - - fn resume_session( - &mut self, - session_path: Option, - ) -> Result> { - let Some(session_ref) = session_path else { - println!("{}", render_resume_usage()); - return Ok(false); - }; - - let (handle, session) = - load_session_reference_excluding(&session_ref, Some(&self.session.id))?; - let message_count = session.messages.len(); - let session_id = session.session_id.clone(); - let runtime = build_runtime( - session, - &handle.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.session = SessionHandle { - id: session_id, - path: handle.path, - }; - println!( - "{}", - format_resume_report( - &self.session.path.display().to_string(), - message_count, - self.runtime.usage().turns(), - ) - ); - Ok(true) - } - - fn print_config(section: Option<&str>) -> Result<(), Box> { - println!("{}", render_config_report(section)?); - Ok(()) - } - - fn print_memory() -> Result<(), Box> { - println!("{}", render_memory_report()?); - Ok(()) - } - - fn print_agents( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?), - CliOutputFormat::Json => { - let value = handle_agents_slash_command_json(args, &cwd)?; - // #789: parity with print_mcp/#788 print_skills — exit 1 when envelope - // reports an error so automation can rely on exit code instead of - // parsing the JSON status field. - let is_error = value.get("status").and_then(|v| v.as_str()) == Some("error"); - println!("{}", serde_json::to_string_pretty(&value)?); - if is_error { - std::process::exit(1); - } - } - } - Ok(()) - } - - fn print_mcp( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - // `claw mcp serve` starts a stdio MCP server exposing claw's built-in - // tools. All other `mcp` subcommands fall through to the existing - // configured-server reporter (`list`, `status`, ...). - if matches!(args.map(str::trim), Some("serve")) { - return run_mcp_serve(); - } - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)?), - CliOutputFormat::Json => { - let value = handle_mcp_slash_command_json(args, &cwd)?; - // Propagate ok:false → non-zero exit so automation callers - // can rely on exit code instead of inspecting the envelope. - // (#68: mcp error envelopes previously always exited 0.) - let is_error = value.get("ok").and_then(serde_json::Value::as_bool) == Some(false) - || value.get("status").and_then(serde_json::Value::as_str) == Some("error"); - println!("{}", serde_json::to_string_pretty(&value)?); - if is_error { - std::process::exit(1); - } - } - } - Ok(()) - } - - fn print_skills( - args: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - match output_format { - CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?), - CliOutputFormat::Json => { - let result = handle_skills_slash_command_json(args, &cwd)?; - let is_error = result.get("status").and_then(|v| v.as_str()) == Some("error"); - // #739: action:"help" with unexpected set is a usage response, not a fatal error; - // don't return Err which would emit a second error envelope from the generic path. - let is_help_action = result.get("action").and_then(|v| v.as_str()) == Some("help"); - println!("{}", serde_json::to_string_pretty(&result)?); - if is_error && !is_help_action { - // #788: the error JSON is already emitted above; returning Err here - // would cause the top-level handler to emit a second error envelope. - // Exit directly to signal failure without a duplicate envelope. - std::process::exit(1); - } - } - } - Ok(()) - } - - fn print_plugins( - action: Option<&str>, - target: Option<&str>, - output_format: CliOutputFormat, - ) -> Result<(), Box> { - let cwd = env::current_dir()?; - // #803: reject flag-shaped tokens in list filter for BOTH text and JSON modes. - // Previously the guard was JSON-only (#793); text mode silently returned empty success. - if action.as_deref() == Some("list") { - if let Some(filter) = target.as_deref() { - if filter.starts_with('-') { - if matches!(output_format, CliOutputFormat::Json) { - // ROADMAP #817: this is a handled local inventory parse error. - // Keep it on stdout in JSON mode so `plugins list --` matches the - // sibling JSON inventory/local surfaces instead of falling through - // to the top-level stderr error path. - let obj = json!({ - "type": "error", - "kind": "plugin", - "action": "list", - "status": "error", - "error_kind": "cli_parse", - "error": format!("unknown option for `claw plugins list`: {filter}"), - "message": format!("unknown option for `claw plugins list`: {filter}"), - "unexpected": filter, - "hint": "Usage: claw plugins list []\nFilters are id substrings, not flags.", - "exit_code": 1, - }); - println!("{}", serde_json::to_string_pretty(&obj)?); - std::process::exit(1); - } - return Err(format!( - "unknown option for `claw plugins list`: {filter}\nUsage: claw plugins list []\nFilters are id substrings, not flags." - ).into()); - } - } - } - let payload = plugins_command_payload_for( - &cwd, - action, - target, - match output_format { - CliOutputFormat::Json => ConfigWarningMode::SuppressStderr, - CliOutputFormat::Text => ConfigWarningMode::EmitStderr, - }, - )?; - match output_format { - CliOutputFormat::Text => { - // #806: text-mode show must return error when plugin not found (parity with JSON) - let action_str = action.unwrap_or("list"); - if matches!(action_str, "show" | "info" | "describe") { - if let Some(name) = target { - let needle = name.to_lowercase(); - let found = payload.plugins.iter().any(|p| { - p.get("id") - .and_then(|v| v.as_str()) - .map(|id| id.to_lowercase() == needle) - .unwrap_or(false) - }); - if !found { - return Err(format!( - "plugin_not_found: plugin '{}' not found\nRun `claw plugins list` to see available plugins.", - name - ).into()); - } - } - } - println!("{}", payload.message); - } - CliOutputFormat::Json => { - let action_str = action.unwrap_or("list"); - // #743/#420: plugins help must return a usage envelope matching agents/mcp/skills help shape. - if matches!(action_str, "help" | "-h" | "--help") { - let cwd_str = cwd.display().to_string(); - let obj = json!({ - "kind": "plugin", - "action": "help", - "status": "ok", - "unexpected": null, - "usage": { - "direct_cli": "claw plugins [list|show |install |enable |disable |uninstall |update |help]", - "slash_command": "/plugins [list|show |install |enable |disable |uninstall |update |help]", - }, - "cwd": cwd_str, - }); - println!("{}", serde_json::to_string_pretty(&obj)?); - return Ok(()); - } - // For show/info/describe, filter to the named plugin (exact match). - // For list with a target, treat target as a substring filter. - let is_show_action = matches!(action_str, "show" | "info" | "describe"); - let is_list_action = action_str == "list"; - let filtered_plugins: Vec<_> = if is_show_action { - if let Some(name) = target { - let needle = name.to_lowercase(); - payload - .plugins - .iter() - .filter(|p| { - p.get("id") - .and_then(|v| v.as_str()) - .map(|id| id.to_lowercase() == needle) - .unwrap_or(false) - }) - .cloned() - .collect() - } else { - payload.plugins.clone() - } - } else if is_list_action { - if let Some(filter) = target { - let needle = filter.to_lowercase(); - payload - .plugins - .iter() - .filter(|p| { - p.get("id") - .and_then(|v| v.as_str()) - .map(|id| id.to_lowercase().contains(&needle)) - .unwrap_or(false) - }) - .cloned() - .collect() - } else { - payload.plugins.clone() - } - } else { - payload.plugins.clone() - }; - // Return not-found error for show with missing target. - if is_show_action { - if let Some(name) = target { - if filtered_plugins.is_empty() { - let obj = json!({ - "kind": "plugin", - "action": action_str, - "status": "error", - "error_kind": "plugin_not_found", - "requested": name, - // #734: parity with skills show which always emits a message field - "message": format!("plugin '{}' not found", name), - // #760: hint so callers know how to enumerate available plugins - "hint": "Run `claw plugins list` to see available plugins.", - }); - println!("{}", serde_json::to_string_pretty(&obj)?); - // #789: exit 1 on not-found so automation can rely on exit code - std::process::exit(1); - } - } - } - let enabled_count = filtered_plugins - .iter() - .filter(|p| p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false)) - .count(); - let disabled_count = filtered_plugins.len().saturating_sub(enabled_count); - let mut obj = json!({ - "kind": "plugin", - "action": action_str, - "status": payload.status, - "summary": { - "total": filtered_plugins.len(), - "enabled": enabled_count, - "disabled": disabled_count, - "load_failures": payload.load_failures.len(), - }, - "config_load_error": payload.config_load_error, - "mcp_validation": payload.mcp_validation.json_value(), - "plugins": filtered_plugins, - "load_failures": payload.load_failures, - }); - // Only include operation-result fields for mutating actions (not list/show) - if action_str != "list" && !is_show_action { - obj["target"] = json!(target); - obj["reload_runtime"] = json!(payload.reload_runtime); - obj["message"] = json!(payload.message); - } - println!("{}", serde_json::to_string_pretty(&obj)?); - } - } - Ok(()) - } - - fn print_diff() -> Result<(), Box> { - println!("{}", render_diff_report()?); - Ok(()) - } - - fn print_version(output_format: CliOutputFormat) { - let _ = crate::print_version(output_format); - } - - fn export_session( - &self, - requested_path: Option<&str>, - ) -> Result<(), Box> { - let export_path = resolve_export_path(requested_path, self.runtime.session())?; - fs::write(&export_path, render_export_text(self.runtime.session()))?; - println!( - "Export\n Result wrote transcript\n File {}\n Messages {}", - export_path.display(), - self.runtime.session().messages.len(), - ); - Ok(()) - } - - #[allow(clippy::too_many_lines)] - fn handle_session_command( - &mut self, - action: Option<&str>, - target: Option<&str>, - ) -> Result> { - match action { - None | Some("list") => { - println!("{}", render_session_list(&self.session.id)?); - Ok(false) - } - Some("exists") => { - let Some(target) = target else { - println!("Usage: /session exists "); - return Ok(false); - }; - let exists = session_reference_exists(target)?; - let handle = resolve_session_reference(target).ok(); - println!( - "Session exists\n Session {target}\n Exists {exists}{}", - handle - .as_ref() - .map(|handle| format!("\n File {}", handle.path.display())) - .unwrap_or_default() - ); - 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 exists , /session switch , /session fork [branch-name], or /session delete [--force]." - ); - Ok(false) - } - } - } - - fn handle_plugins_command( - &mut self, - action: Option<&str>, - target: Option<&str>, - ) -> Result> { - let cwd = env::current_dir()?; - let payload = - plugins_command_payload_for(&cwd, action, target, ConfigWarningMode::EmitStderr)?; - println!("{}", payload.message); - if payload.reload_runtime { - self.reload_runtime_features()?; - } - Ok(false) - } - - fn reload_runtime_features(&mut self) -> Result<(), Box> { - let runtime = build_runtime( - self.runtime.session().clone(), - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.persist_session() - } - - fn compact(&mut self) -> Result<(), Box> { - let result = self.runtime.compact(CompactionConfig::default()); - let removed = result.removed_message_count; - let kept = result.compacted_session.messages.len(); - let skipped = removed == 0; - let runtime = build_runtime( - result.compacted_session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - true, - true, - self.allowed_tools.clone(), - self.permission_mode, - None, - )?; - self.replace_runtime(runtime)?; - self.persist_session()?; - println!("{}", format_compact_report(removed, kept, skipped)); - Ok(()) - } - - fn run_internal_prompt_text_with_progress( - &self, - prompt: &str, - enable_tools: bool, - progress: Option, - ) -> Result> { - let session = self.runtime.session().clone(); - let mut runtime = build_runtime( - session, - &self.session.id, - self.model.clone(), - self.system_prompt.clone(), - enable_tools, - false, - self.allowed_tools.clone(), - self.permission_mode, - progress, - )?; - let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; - let text = final_assistant_text(&summary).trim().to_string(); - runtime.shutdown_plugins()?; - Ok(text) - } - - fn run_internal_prompt_text( - &self, - prompt: &str, - enable_tools: bool, - ) -> Result> { - self.run_internal_prompt_text_with_progress(prompt, enable_tools, None) - } - - fn run_bughunter(&self, scope: Option<&str>) -> Result<(), Box> { - println!("{}", format_bughunter_report(scope)); - Ok(()) - } - - fn run_ultraplan(&self, task: Option<&str>) -> Result<(), Box> { - println!("{}", format_ultraplan_report(task)); - Ok(()) - } - - fn run_teleport(target: Option<&str>) -> Result<(), Box> { - let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else { - println!("Usage: /teleport "); - return Ok(()); - }; - - println!("{}", render_teleport_report(target)?); - Ok(()) - } - - fn run_debug_tool_call(&self, args: Option<&str>) -> Result<(), Box> { - validate_no_args("/debug-tool-call", args)?; - println!("{}", render_last_tool_debug_report(self.runtime.session())?); - Ok(()) - } - - fn run_commit(&mut self, args: Option<&str>) -> Result<(), Box> { - validate_no_args("/commit", args)?; - let status = git_output(&["status", "--short", "--branch"])?; - let summary = parse_git_workspace_summary(Some(&status)); - let branch = parse_git_status_branch(Some(&status)); - if summary.is_clean() { - println!("{}", format_commit_skipped_report()); - return Ok(()); - } - - println!( - "{}", - format_commit_preflight_report(branch.as_deref(), summary) - ); - Ok(()) - } - - fn run_pr(&self, context: Option<&str>) -> Result<(), Box> { - let branch = - resolve_git_branch_for(&env::current_dir()?).unwrap_or_else(|| "unknown".to_string()); - println!("{}", format_pr_report(&branch, context)); - Ok(()) - } - - fn run_issue(&self, context: Option<&str>) -> Result<(), Box> { - println!("{}", format_issue_report(context)); - Ok(()) - } -} - -fn sessions_dir() -> Result> { - Ok(current_session_store()?.sessions_dir().to_path_buf()) -} - -fn current_session_store() -> Result> { - let cwd = env::current_dir()?; - runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box) -} - -fn new_cli_session() -> Result> { - Ok(Session::new().with_workspace_root(env::current_dir()?)) -} - -fn create_managed_session_handle( - session_id: &str, -) -> Result> { - let handle = current_session_store()?.create_handle(session_id); - Ok(SessionHandle { - id: handle.id, - path: handle.path, - }) -} - -fn resolve_session_reference(reference: &str) -> Result> { - let handle = current_session_store()? - .resolve_reference(reference) - .map_err(|e| Box::new(e) as Box)?; - Ok(SessionHandle { - id: handle.id, - path: handle.path, - }) -} - -fn session_reference_exists(reference: &str) -> Result> { - Ok(current_session_store()?.session_exists(reference)) -} - -fn resolve_managed_session_path(session_id: &str) -> Result> { - current_session_store()? - .resolve_managed_path(session_id) - .map_err(|e| Box::new(e) as Box) -} - -fn list_managed_sessions() -> Result, Box> { - let store = current_session_store()?; - let lifecycle = classify_session_lifecycle_for(store.workspace_root()); - Ok(store - .list_sessions() - .map_err(|e| Box::new(e) as Box)? - .into_iter() - .map(|session| ManagedSessionSummary { - id: session.id, - path: session.path, - created_at_ms: session.created_at_ms, - 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: lifecycle.clone(), - }) - .collect()) -} - -fn latest_managed_session() -> Result> { - let store = current_session_store()?; - let lifecycle = classify_session_lifecycle_for(store.workspace_root()); - let session = store - .latest_session() - .map_err(|e| Box::new(e) as Box)?; - Ok(ManagedSessionSummary { - id: session.id, - path: session.path, - created_at_ms: session.created_at_ms, - 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, - }) -} - -fn load_session_reference( - reference: &str, -) -> Result<(SessionHandle, Session), Box> { - load_session_reference_excluding(reference, None) -} - -fn load_session_reference_excluding( - reference: &str, - exclude_id: Option<&str>, -) -> Result<(SessionHandle, Session), Box> { - let store = current_session_store()?; - let loaded = store - .load_session_excluding(reference, exclude_id) - .map_err(|e| Box::new(e) as Box)?; - Ok(( - SessionHandle { - id: loaded.handle.id, - path: loaded.handle.path, - }, - loaded.session, - )) -} - -fn delete_managed_session(path: &Path) -> Result<(), Box> { - if !path.exists() { - return Err(format!("session file does not exist: {}", path.display()).into()); - } - fs::remove_file(path)?; - Ok(()) -} - -fn confirm_session_deletion(session_id: &str) -> bool { - print!("Delete session '{session_id}'? This cannot be undone. [y/N]: "); - io::stdout().flush().unwrap_or(()); - let mut answer = String::new(); - if io::stdin().read_line(&mut answer).is_err() { - return false; - } - matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES") -} - -fn session_details_json(sessions: &[ManagedSessionSummary]) -> Vec { - sessions - .iter() - .map(|session| { - serde_json::json!({ - "id": session.id, - "path": session.path.display().to_string(), - "message_count": session.message_count, - "created_at_ms": session.created_at_ms, - "updated_at_ms": session.updated_at_ms, - "modified_epoch_millis": session.modified_epoch_millis, - "parent_session_id": session.parent_session_id, - "branch_name": session.branch_name, - "lifecycle": session.lifecycle.json_value(), - }) - }) - .collect() -} - -fn session_exists_json( - target: &str, - active_session_id: &str, -) -> Result> { - let handle = create_managed_session_handle(target)?; - let resolved = resolve_session_reference(target).ok(); - let exists = resolved.is_some(); - let resolved_id = resolved - .as_ref() - .map_or(target, |handle| handle.id.as_str()); - Ok(serde_json::json!({ - "kind": "session_exists", - "action": "exists", - "status": "ok", - "session_id": resolved_id, - "session": target, - "requested": target, - "exists": exists, - "active": resolved_id == active_session_id, - "path": resolved - .as_ref() - .map(|handle| handle.path.display().to_string()), - "candidate_path": handle.path.display().to_string(), - })) -} - -fn run_resumed_session_command( - session_path: &Path, - session: &Session, - action: Option<&str>, - target: Option<&str>, -) -> Result> { - match action { - None | Some("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": "sessions", - "status": "ok", - "action": "list", - "sessions": session_ids, - "session_details": session_details_json(&sessions), - "active": active_id, - })), - }) - } - Some("exists") => { - let Some(target) = target else { - return Err("/session exists requires a session id.\nUsage: claw --resume /session exists ".into()); - }; - let value = session_exists_json(target, &session.session_id)?; - let exists = value - .get("exists") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "Session exists\n Session {}\n Exists {}", - target, - if exists { "yes" } else { "no" } - )), - json: Some(value), - }) - } - Some("delete") => { - let Some(target) = target else { - return Err("/session delete requires a session id.\nUsage: claw --resume /session delete --force".into()); - }; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "delete: confirmation required; rerun with /session delete {target} --force" - )), - json: Some(serde_json::json!({ - "kind": "error", - "error": "confirmation required", - "hint": format!("rerun with /session delete {target} --force"), - "session_id": target, - })), - }) - } - Some("delete-force") => { - let Some(target) = target else { - return Err("/session delete requires a session id.\nUsage: claw --resume /session delete --force".into()); - }; - let handle = resolve_session_reference(target)?; - if handle.id == session.session_id || handle.path == session_path { - return Err(format!( - "delete: refusing to delete the active session '{}'. Resume or switch to another session first.", - handle.id - ) - .into()); - } - delete_managed_session(&handle.path)?; - Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "Session deleted\n Deleted session {}\n File {}", - handle.id, - handle.path.display(), - )), - json: Some(serde_json::json!({ - "kind": "session_delete", - "action": "delete", - "status": "ok", - "deleted": true, - "session_id": handle.id, - "path": handle.path.display().to_string(), - })), - }) - } - // #113: /session switch and /session fork require an interactive REPL — - // return structured JSON instead of a raw error so resume callers can - // detect the limitation programmatically. - Some(switch_or_fork @ ("switch" | "fork")) => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(format!( - "/session {switch_or_fork} requires an interactive REPL.\nUsage: claw (then /session {switch_or_fork} )" - )), - json: Some(serde_json::json!({ - "kind": "error", - "error_kind": "unsupported_resumed_command", - "status": "error", - "action": switch_or_fork, - "error": format!("/session {switch_or_fork} requires an interactive REPL"), - "hint": format!("Start a new claw session and use /session {switch_or_fork} interactively"), - })), - }), - Some(other) => Err(format!("unsupported_resumed_command: /session {other} is not supported in resume mode.\nSupported: list, exists, delete").into()), - } -} - -fn render_session_list(active_session_id: &str) -> Result> { - let sessions = list_managed_sessions()?; - let mut lines = vec![ - "Sessions".to_string(), - format!(" Directory {}", sessions_dir()?.display()), - ]; - if sessions.is_empty() { - lines.push(" No managed sessions saved yet.".to_string()); - return Ok(lines.join("\n")); - } - for session in sessions { - let marker = if session.id == active_session_id { - "● current" - } else { - "○ saved" - }; - let lineage = match ( - session.branch_name.as_deref(), - session.parent_session_id.as_deref(), - ) { - (Some(branch_name), Some(parent_session_id)) => { - format!(" branch={branch_name} from={parent_session_id}") - } - (None, Some(parent_session_id)) => format!(" from={parent_session_id}"), - (Some(branch_name), None) => format!(" branch={branch_name}"), - (None, None) => String::new(), - }; - lines.push(format!( - " {id:<20} {marker:<10} lifecycle={lifecycle} msgs={msgs:<4} modified={modified}{lineage} path={path}", - id = session.id, - lifecycle = session.lifecycle.signal(), - msgs = session.message_count, - modified = format_session_modified_age(session.modified_epoch_millis), - lineage = lineage, - path = session.path.display(), - )); - } - Ok(lines.join("\n")) -} - -/// #449: credentials-free session list that works without API keys. -/// `claw session list --output-format json` should work in CI/offline. -fn run_session_list(output_format: CliOutputFormat) -> Result<(), Box> { - let sessions = list_managed_sessions().unwrap_or_default(); - let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); - let session_details = session_details_json(&sessions); - match output_format { - CliOutputFormat::Text => { - let text = render_session_list("").unwrap_or_else(|e| format!("error: {e}")); - println!("{text}"); - } - CliOutputFormat::Json => { - println!( - "{}", - serde_json::json!({ - "kind": "sessions", - "status": "ok", - "action": "list", - "sessions": session_ids, - "session_details": session_details, - "active": serde_json::Value::Null, - }) - ); - } - } - Ok(()) -} - -fn format_session_modified_age(modified_epoch_millis: u128) -> String { - let now = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(modified_epoch_millis, |duration| duration.as_millis()); - let delta_seconds = now - .saturating_sub(modified_epoch_millis) - .checked_div(1_000) - .unwrap_or_default(); - match delta_seconds { - 0..=4 => "just-now".to_string(), - 5..=59 => format!("{delta_seconds}s-ago"), - 60..=3_599 => format!("{}m-ago", delta_seconds / 60), - 3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600), - _ => format!("{}d-ago", delta_seconds / 86_400), - } -} - -fn write_session_clear_backup( - session: &Session, - session_path: &Path, -) -> Result> { - let backup_path = session_clear_backup_path(session_path); - session.save_to_path(&backup_path)?; - Ok(backup_path) -} - -fn session_clear_backup_path(session_path: &Path) -> PathBuf { - let timestamp = std::time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .map_or(0, |duration| duration.as_millis()); - let file_name = session_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("session.jsonl"); - session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak")) -} - -fn render_repl_help() -> String { - [ - "REPL".to_string(), - " /exit Quit the REPL".to_string(), - " /quit Quit the REPL".to_string(), - " Up/Down Navigate prompt history".to_string(), - " Ctrl-R Reverse-search prompt history".to_string(), - " Tab Complete commands, modes, and recent sessions".to_string(), - " Ctrl-C Clear input (or exit on empty prompt)".to_string(), - " Shift+Enter/Ctrl+J Insert a newline".to_string(), - " Auto-save .claw/sessions//.jsonl" - .to_string(), - " Resume latest /resume latest".to_string(), - " Browse sessions /session list".to_string(), - " Show prompt history /history [count]".to_string(), - String::new(), - render_slash_command_help_filtered(STUB_COMMANDS), - ] - .join( - " -", - ) -} - -fn print_status_snapshot( - model: &str, - model_flag_raw: Option<&str>, - permission_mode: PermissionModeProvenance, - output_format: CliOutputFormat, - allowed_tools: Option<&AllowedToolSet>, -) -> Result<(), Box> { - let usage = StatusUsage { - message_count: 0, - turns: 0, - latest: TokenUsage::default(), - cumulative: TokenUsage::default(), - estimated_tokens: 0, - }; - let context = status_context(None)?; - // #148: resolve model provenance. If user passed --model, source is - // "flag" with the raw input preserved. Otherwise probe env -> config - // -> default and record the winning source. - let provenance_result = match model_flag_raw { - Some(raw) => Ok(ModelProvenance::from_flag(raw, model)), - None => ModelProvenance::from_env_or_config_or_default(model), - }; - let provenance = match provenance_result { - Ok(provenance) => provenance, - Err(error) => match output_format { - CliOutputFormat::Json => { - return print_model_validation_warning_status( - &error, - usage, - permission_mode.mode.as_str(), - &context, - allowed_tools, - ); - } - CliOutputFormat::Text => return Err(error.into()), - }, - }; - let format_selection = current_output_format_selection(); - match output_format { - CliOutputFormat::Text => println!( - "{}", - format_status_report( - &provenance.resolved, - usage, - permission_mode.mode.as_str(), - &context, - Some(&provenance), - Some(&permission_mode), - ) - ), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&status_json_value( - Some(&provenance.resolved), - usage, - permission_mode.mode.as_str(), - &context, - Some(&provenance), - Some(&permission_mode), - allowed_tools, - Some(&format_selection), - ))? - ), - } - Ok(()) -} - -fn status_json_value( - model: Option<&str>, - usage: StatusUsage, - permission_mode: &str, - context: &StatusContext, - // #148: optional provenance for `model` field. Surfaces `model_source` - // ("flag" | "env" | "config" | "default") and `model_raw` (user input - // before alias resolution, or null when source is "default"). Callers - // that don't have provenance (legacy resume paths) pass None, in which - // case both new fields are omitted. - provenance: Option<&ModelProvenance>, - permission_provenance: Option<&PermissionModeProvenance>, - allowed_tools: Option<&AllowedToolSet>, - format_selection: Option<&OutputFormatSelection>, -) -> serde_json::Value { - // #143: top-level `status` marker so claws can distinguish - // a clean run from a degraded run (config parse failed but other fields - // are still populated). `config_load_error` carries the parse-error string - // when present; it's a string rather than a typed object in Phase 1 and - // will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44). - // `config_load_error_kind` is the machine-readable kind token derived from - // `classify_error_kind` so downstream claws can switch on it directly. - let degraded = context.config_load_error.is_some(); - let model_source = provenance.map(|p| p.source.as_str()); - let model_raw = provenance.and_then(|p| p.raw.clone()); - let model_alias_resolved_to = provenance.and_then(|p| p.alias_resolved_to.clone()); - let model_env_var = provenance.and_then(|p| p.env_var.clone()); - let permission_mode_source = permission_provenance.map(|p| p.source.as_str()); - let permission_mode_env_var = permission_provenance.and_then(|p| p.env_var); - let tool_registry = GlobalToolRegistry::builtin(); - let available_tool_names = tool_registry.canonical_allowed_tool_names(); - let tool_aliases = allowed_tool_aliases_json(&tool_registry); - let output_format_selection = format_selection.cloned().unwrap_or_default(); - // #732: always emit an array (empty when unrestricted) so callers can do - // `.allowed_tools.entries | length > 0` without a null-check first. - let allowed_tool_entries = allowed_tools - .map(|tools| tools.iter().cloned().collect::>()) - .unwrap_or_default(); - json!({ - "kind": "status", - "action": "show", - "status": if degraded || context.mcp_validation.has_invalid_servers() || context.hook_validation.has_invalid_hooks() { "degraded" } else { "ok" }, - "config_load_error": context.config_load_error, - "config_load_error_kind": context.config_load_error_kind, - "mcp_validation": context.mcp_validation.json_value(), - "hook_validation": context.hook_validation.json_value(), - "duplicate_flags": context.duplicate_flags, - - "model": model, - "model_source": model_source, - "model_raw": model_raw, - "model_alias_resolved_to": model_alias_resolved_to, - "model_env_var": model_env_var, - "permission_mode": permission_mode, - "permission_mode_source": permission_mode_source, - "permission_mode_env_var": permission_mode_env_var, - "allowed_tools": { - "source": if allowed_tools.is_some() { "flag" } else { "default" }, - "restricted": allowed_tools.is_some(), - "entries": allowed_tool_entries, - "available": available_tool_names, - "aliases": tool_aliases, - }, - "format_source": output_format_selection.source.as_str(), - "format_raw": output_format_selection.raw, - "format_overridden": output_format_selection.overridden, - "binary_provenance": context.binary_provenance.json_value(), - "usage": { - "messages": usage.message_count, - "turns": usage.turns, - "latest_input": usage.latest.input_tokens, - "latest_output": usage.latest.output_tokens, - "latest_cache_creation_input": usage.latest.cache_creation_input_tokens, - "latest_cache_read_input": usage.latest.cache_read_input_tokens, - "latest_total": usage.latest.total_tokens(), - "cumulative_input": usage.cumulative.input_tokens, - "cumulative_output": usage.cumulative.output_tokens, - "cumulative_cache_creation_input": usage.cumulative.cache_creation_input_tokens, - "cumulative_cache_read_input": usage.cumulative.cache_read_input_tokens, - "cumulative_total": usage.cumulative.total_tokens(), - "estimated_cost_usd": format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.cumulative.estimate_cost_usd().total_cost_usd(), - "pricing": "estimated-default", - "estimated_tokens": usage.estimated_tokens, - }, - "lane_board": { - "schema": "task_registry_v1", - "status_json_supported": true, - "heartbeat_freshness_supported": true, - "states": ["active", "blocked", "finished"], - "freshness_states": ["healthy", "stalled", "transport_dead", "unknown"], - }, - "workspace": { - "cwd": context.cwd, - "project_root": context.project_root, - "git_branch": context.git_branch, - "git_state": if context.project_root.is_some() { context.git_summary.headline() } else { "no_git_repo".to_string() }, - // #408: changed_files counts ALL non-clean files (staged + unstaged + untracked + conflicted) - "changed_files": context.git_summary.changed_files, - "is_clean": context.git_summary.changed_files == 0, - "staged_files": context.git_summary.staged_files, - // #89: mid-operation git state (rebase, merge, cherry-pick, bisect) - "git_operation": if context.git_summary.operation != GitOperation::None { - Some(context.git_summary.operation.as_str()) - } else { - None::<&str> - }, - - "unstaged_files": context.git_summary.unstaged_files, - "untracked_files": context.git_summary.untracked_files, - "session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()), - "session_id": context.session_path.as_ref().and_then(|path| { - // Session files are named .jsonl directly under - // .claw/sessions/. Extract the stem (drop the .jsonl extension). - path.file_stem().map(|n| n.to_string_lossy().into_owned()) - }), - "session_lifecycle": context.session_lifecycle.json_value(), - "branch_freshness": context.branch_freshness.json_value(), - "boot_preflight": context.boot_preflight.json_value(), - "loaded_config_files": context.loaded_config_files, - "discovered_config_files": context.discovered_config_files, - "memory_file_count": context.memory_file_count, - "memory_files": memory_files_json(&context.memory_files), - "unloaded_memory_files": context.unloaded_memory_files, - "mcp_validation": context.mcp_validation.json_value(), - "hook_validation": context.hook_validation.json_value(), - }, - "sandbox": { - "enabled": context.sandbox_status.enabled, - "active": context.sandbox_status.active, - "supported": context.sandbox_status.supported, - "in_container": context.sandbox_status.in_container, - "requested_namespace": context.sandbox_status.requested.namespace_restrictions, - "active_namespace": context.sandbox_status.namespace_active, - "requested_network": context.sandbox_status.requested.network_isolation, - "active_network": context.sandbox_status.network_active, - "filesystem_mode": context.sandbox_status.filesystem_mode.as_str(), - "filesystem_active": context.sandbox_status.filesystem_active, - "allowed_mounts": context.sandbox_status.allowed_mounts, - "markers": context.sandbox_status.container_markers, - "fallback_reason": context.sandbox_status.fallback_reason, - } - }) -} - -/// #421: Strip macOS `/private` symlink prefix from paths so that -/// `status`, `doctor`, and `mcp list` JSON output matches the -/// user-visible invocation cwd instead of the canonicalized path. -fn friendly_cwd(path: PathBuf) -> PathBuf { - #[cfg(target_os = "macos")] - { - if let Ok(stripped) = path.strip_prefix("/private") { - if stripped.is_absolute() { - return stripped.to_path_buf(); - } - } - } - path -} - -fn status_context( - session_path: Option<&Path>, -) -> Result> { - let cwd = friendly_cwd(env::current_dir()?); - let loader = ConfigLoader::default_for(&cwd); - // #456: count only paths that exist on disk, matching check_config_health behavior. - let discovered_config_files = loader.discover().iter().filter(|e| e.path.exists()).count(); - // #143: degrade gracefully on config parse failure rather than hard-fail. - // `claw doctor` already does this; `claw status` now matches that contract - // so that one malformed `mcpServers.*` entry doesn't take down the whole - // health surface (workspace, git, model, permission, sandbox can still be - // reported independently). - let runtime_config = loader.load(); - let (loaded_config_files, sandbox_status, config_load_error, config_load_error_kind) = - match runtime_config.as_ref() { - Ok(cfg) => ( - cfg.loaded_entries().len(), - resolve_sandbox_status(cfg.sandbox(), &cwd), - None, - None, - ), - Err(err) => { - let err_string = err.to_string(); - let err_kind = classify_error_kind(&err_string); - ( - 0, - // Fall back to defaults for sandbox resolution so claws still see - // a populated sandbox section instead of a missing field. Defaults - // produce the same output as a runtime config with no sandbox - // overrides, which is the right degraded-mode shape: we cannot - // report what the user *intended*, only what is actually in effect. - resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd), - Some(err_string), - Some(err_kind), - ) - } - }; - let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; - let (project_root, git_branch) = - parse_git_status_metadata(project_context.git_status.as_deref()); - let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); - let branch_freshness = BranchFreshness::from_git_status(project_context.git_status.as_deref()); - let stale_base_state = stale_base_state_for(&cwd, None); - let boot_preflight = build_boot_preflight_snapshot( - &cwd, - project_root.as_deref(), - project_context.git_status.as_deref(), - runtime_config.as_ref().ok(), - config_load_error.as_deref(), - ); - let memory_files = memory_file_summaries_for( - &cwd, - project_root.as_deref(), - &project_context.instruction_files, - ); - let mcp_validation = runtime_config - .as_ref() - .ok() - .map(|runtime_config| McpValidationSummary::from_collection(runtime_config.mcp())) - .unwrap_or_default(); - let hook_validation = runtime_config - .as_ref() - .ok() - .map(HookValidationSummary::from_config) - .unwrap_or_default(); - Ok(StatusContext { - cwd: cwd.clone(), - session_path: session_path.map(Path::to_path_buf), - loaded_config_files, - discovered_config_files, - memory_file_count: project_context.instruction_files.len(), - memory_files: memory_files.clone(), - unloaded_memory_files: unloaded_memory_candidates( - &cwd, - project_root.as_deref(), - &memory_files, - ), - project_root, - git_branch, - git_summary, - branch_freshness, - stale_base_state, - session_lifecycle: classify_session_lifecycle_for(&cwd), - boot_preflight, - sandbox_status, - binary_provenance: binary_provenance_for(Some(&cwd)), - config_load_error, - config_load_error_kind, - mcp_validation, - - hook_validation, - duplicate_flags: take_duplicate_flags(), - }) -} - -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>, - permission_provenance: Option<&PermissionModeProvenance>, -) -> 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 => { - let env_suffix = p - .env_var - .as_deref() - .map_or(String::new(), |name| format!(" via {name}")); - format!( - "\n Model source {}{env_suffix} (raw: {raw}, alias: {model})", - p.source.as_str() - ) - } - Some(_) => { - let env_suffix = p - .env_var - .as_deref() - .map_or(String::new(), |name| format!(" via {name}")); - format!("\n Model source {}{env_suffix}", p.source.as_str()) - } - None => format!("\n Model source {}", p.source.as_str()), - }) - .unwrap_or_default(); - let permission_source_line = permission_provenance - .map(|p| { - let env_suffix = p - .env_var - .map_or(String::new(), |name| format!(" via {name}")); - format!("\n Permission source {}{env_suffix}", p.source.as_str()) - }) - .unwrap_or_default(); - blocks.extend([ - format!( - "{status_line} - Model {model}{model_source_line} - Permission mode {permission_mode}{permission_source_line} - Messages {} - Turns {} - Estimated tokens {}", - usage.message_count, usage.turns, usage.estimated_tokens, - ), - format!( - "Usage - Latest total {} - Cumulative input {} - Cumulative output {} - Cache create {} - Cache read {} - Cumulative total {} - Estimated cost {}", - usage.latest.total_tokens(), - usage.cumulative.input_tokens, - usage.cumulative.output_tokens, - usage.cumulative.cache_creation_input_tokens, - usage.cumulative.cache_read_input_tokens, - usage.cumulative.total_tokens(), - format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()), - ), - format!( - "Workspace - Cwd {} - Project root {} - Git branch {} - Git state {} - Changed files {} - Staged {} - Unstaged {} - Untracked {} - Session {} - Lifecycle {} - Branch fresh {} - Boot preflight {} - Config files loaded {}/{} - Memory files {} - Loaded memory {} - 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"), - if context.project_root.is_some() { - context.git_summary.headline() - } else { - "no_git_repo".to_string() - }, - 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 - .branch_freshness - .fresh - .map(|fresh| if fresh { "yes" } else { "behind" }) - .unwrap_or("no upstream"), - context.boot_preflight.summary(), - context.loaded_config_files, - context.discovered_config_files, - context.memory_file_count, - if context.memory_files.is_empty() { - "".to_string() - } else { - context - .memory_files - .iter() - .map(|file| format!("{}:{}", file.source, file.path)) - .collect::>() - .join(", ") - }, - ), - format_sandbox_report(&context.sandbox_status), - ]); - blocks.join("\n\n") -} - -fn format_sandbox_report(status: &runtime::SandboxStatus) -> String { - format!( - "Sandbox - Enabled {} - Active {} - Supported {} - In container {} - Requested ns {} - Active ns {} - Requested net {} - Active net {} - Filesystem mode {} - Filesystem active {} - Allowed mounts {} - Markers {} - Fallback reason {}", - status.enabled, - status.active, - status.supported, - status.in_container, - status.requested.namespace_restrictions, - status.namespace_active, - status.requested.network_isolation, - status.network_active, - status.filesystem_mode.as_str(), - status.filesystem_active, - if status.allowed_mounts.is_empty() { - "".to_string() - } else { - status.allowed_mounts.join(", ") - }, - if status.container_markers.is_empty() { - "".to_string() - } else { - status.container_markers.join(", ") - }, - status - .fallback_reason - .clone() - .unwrap_or_else(|| "".to_string()), - ) -} - -fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String { - format!( - "Commit - Result ready - Branch {} - Workspace {} - Changed files {} - Action create a git commit from the current workspace changes", - branch.unwrap_or("unknown"), - summary.headline(), - summary.changed_files, - ) -} - -fn format_commit_skipped_report() -> String { - "Commit - Result skipped - Reason no workspace changes - Action create a git commit from the current workspace changes - Next /status to inspect context · /diff to inspect repo changes" - .to_string() -} - -fn print_sandbox_status_snapshot( - output_format: CliOutputFormat, -) -> Result<(), Box> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader - .load() - .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); - let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); - match output_format { - CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&sandbox_json_value(&status))? - ), - } - Ok(()) -} - -fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value { - // Derive top-level status so automation can do a single field check - // instead of combining enabled/active/supported booleans. - // ok = not enabled (not requested), OR enabled and active - // warn = enabled and supported but not yet active (degraded), - // OR enabled but unsupported on this platform AND filesystem sandbox is active - // (#731: "not supported on macOS" is a degraded state, not a hard error; - // filesystem_active:true means partial containment is working) - // error = enabled but unsupported AND no filesystem sandbox either (nothing active) - let top_status = if !status.enabled { - "ok" - } else if status.active { - "ok" - } else if status.supported { - "warn" - } else if status.filesystem_active { - // Platform doesn't support namespace isolation but filesystem sandbox is active: - // this is a degraded/partial state, not a hard error. - "warn" - } else { - "error" - }; - json!({ - "kind": "sandbox", - "action": "status", - "status": top_status, - "enabled": status.enabled, - "requested": status.enabled, - "active": status.active, - "supported": status.supported, - "in_container": status.in_container, - "requested_namespace": status.requested.namespace_restrictions, - "active_namespace": status.namespace_active, - "requested_network": status.requested.network_isolation, - "active_network": status.network_active, - "filesystem_mode": status.filesystem_mode.as_str(), - "filesystem_active": status.filesystem_active, - "allowed_mounts": status.allowed_mounts, - "markers": status.container_markers, - "fallback_reason": status.fallback_reason, - "active_components": { - "namespace": status.namespace_active, - "network": status.network_active, - "filesystem": status.filesystem_active, - }, - }) -} - -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/settings.json, .claw.json, .gitignore, and CLAUDE.md in the current project - Output per-artifact created/updated/partial/deferred/skipped status (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::Resume => format!( - "Resume\n Usage claw resume [session-path|session-id|{LATEST_SESSION_REFERENCE}] [/slash-command ...] [--output-format ]\n Alias claw --resume [session-path|session-id|{LATEST_SESSION_REFERENCE}]\n Purpose restore or inspect a saved session without starting a new provider turn\n Output session restore or resume-safe command output; missing sessions return session_not_found\n Formats text (default), json\n Related /resume · /session list · claw --resume {LATEST_SESSION_REFERENCE} /status" - ), - LocalHelpTopic::Session => "Session - Usage claw session --help [--output-format ] - Purpose show /session command guidance without loading config, credentials, or a session - Actions list · exists · switch · fork · delete - Direct use run /session in the REPL or claw --resume SESSION.jsonl /session - Formats text (default), json - Related claw resume · claw export · .claw/sessions/" - .to_string(), - LocalHelpTopic::Compact => "Compact - Usage claw compact --help [--output-format ] - Purpose show compaction guidance without loading config, credentials, or a session - Direct use run /compact in the REPL or claw --resume SESSION.jsonl /compact - Output compaction removes older tool-detail messages when the selected session is large enough - Formats text (default), json - Related claw resume · /compact · /status" - .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(), - LocalHelpTopic::Agents => commands::handle_agents_slash_command( - Some("--help"), - &env::current_dir().unwrap_or_default(), - ) - .unwrap_or_else(|_| "agents help unavailable".to_string()), - LocalHelpTopic::Skills => commands::handle_skills_slash_command( - Some("--help"), - &env::current_dir().unwrap_or_default(), - ) - .unwrap_or_else(|_| "skills help unavailable".to_string()), - LocalHelpTopic::Plugins => "Plugins - Usage claw plugins [list|show |install |enable |disable |uninstall ] - Purpose manage lifecycle of plugins that extend tool and hook capabilities - Formats text (default), json - Related /plugins · claw plugins --help" - .to_string(), - LocalHelpTopic::Mcp => "MCP Servers - Usage claw mcp [list|show ] [--output-format ] - Purpose inspect configured MCP servers and their connection status - Formats text (default), json - Related /mcp · claw mcp list" - .to_string(), - LocalHelpTopic::Config => "Config - Usage claw config [section] [--output-format ] - Purpose show effective runtime configuration (model, hooks, plugins, env) - Formats text (default), json - Related /config · claw doctor" - .to_string(), - LocalHelpTopic::Model => "Models - Usage claw models [help] [--output-format ] - Aliases claw model - Purpose show bounded local model command guidance without entering the REPL - Output supported model-selection surfaces and current config model value - Formats text (default), json - Related /model · claw config model · claw status" - .to_string(), - LocalHelpTopic::Settings => "Settings - Usage claw settings [help] [--output-format ] - Purpose show effective settings/config using the local config envelope - Output same as claw config settings; no provider request or session resume required - Formats text (default), json - Related claw config · claw doctor" - .to_string(), - LocalHelpTopic::Diff => "Diff - Usage claw diff [--output-format ] - Purpose show the diff of changes relative to the expected base commit - Formats text (default), json - Related /diff · ROADMAP #148" - .to_string(), - LocalHelpTopic::Setup => "Setup - Usage claw setup - Aliases /setup (inside the REPL) - Purpose run the interactive provider setup wizard to configure API key, model, and base URL - Output writes provider settings to ~/.claw/settings.json (0600 permissions) - Related /model · /config · claw doctor" - .to_string(), - } -} - -fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str { - match topic { - LocalHelpTopic::Status => "status", - LocalHelpTopic::Sandbox => "sandbox", - LocalHelpTopic::Doctor => "doctor", - LocalHelpTopic::Acp => "acp", - LocalHelpTopic::Init => "init", - LocalHelpTopic::State => "state", - LocalHelpTopic::Resume => "resume", - LocalHelpTopic::Session => "session", - LocalHelpTopic::Compact => "compact", - LocalHelpTopic::Export => "export", - LocalHelpTopic::Version => "version", - LocalHelpTopic::SystemPrompt => "system-prompt", - LocalHelpTopic::DumpManifests => "dump-manifests", - LocalHelpTopic::BootstrapPlan => "bootstrap-plan", - LocalHelpTopic::Agents => "agents", - LocalHelpTopic::Skills => "skills", - LocalHelpTopic::Plugins => "plugins", - LocalHelpTopic::Mcp => "mcp", - LocalHelpTopic::Config => "config", - LocalHelpTopic::Model => "models", - LocalHelpTopic::Settings => "settings", - LocalHelpTopic::Diff => "diff", - LocalHelpTopic::Setup => "setup", - } -} - -fn print_models( - action: Option<&str>, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let help_requested = action.is_some_and(|value| matches!(value, "help" | "--help" | "-h")); - if help_requested { - return print_help_topic(LocalHelpTopic::Model, output_format); - } - if let Some(action) = action { - return Err(format!( - "unsupported_models_action: unsupported models action: {action}.\nUsage: claw models [help] [--output-format json]" - ) - .into()); - } - - let configured_model = config_model_for_current_dir(); - let resolved_config_model = configured_model - .as_deref() - .map(resolve_model_alias_with_config); - - match output_format { - CliOutputFormat::Text => { - println!("Models"); - println!(" Default {DEFAULT_MODEL}"); - println!(" Built-in aliases opus, sonnet, haiku"); - if let Some(raw) = configured_model.as_deref() { - println!( - " Config model {raw}{}", - resolved_config_model - .as_deref() - .filter(|resolved| *resolved != raw) - .map(|resolved| format!(" -> {resolved}")) - .unwrap_or_default() - ); - } else { - println!(" Config model "); - } - println!(" Usage claw --model prompt "); - } - CliOutputFormat::Json => { - println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "models", - "action": "list", - "status": "ok", - "default_model": DEFAULT_MODEL, - "aliases": [ - {"name": "opus", "model": resolve_model_alias("opus")}, - {"name": "sonnet", "model": resolve_model_alias("sonnet")}, - {"name": "haiku", "model": resolve_model_alias("haiku")} - ], - "configured_model": configured_model, - "resolved_configured_model": resolved_config_model, - "local_only": true, - "requires_credentials": false, - "requires_provider_request": false, - "message": "Use --model or configure a model in claw settings." - }))? - ); - } - } - Ok(()) -} - -fn render_export_help_json() -> serde_json::Value { - json!({ - "kind": "help", - "action": "help", - "status": "ok", - "topic": "export", - "command": "export", - "usage": "claw export [--session ] [--output ] [--output-format ]", - "purpose": "serialize a managed session to JSON for review, transfer, or archival", - "defaults": { - "session": LATEST_SESSION_REFERENCE, - "session_source": ".claw/sessions/", - "output": "derived from the selected session when omitted" - }, - "formats": ["text", "json"], - "options": [ - { - "name": "--session", - "value": "", - "default": LATEST_SESSION_REFERENCE, - "description": "managed session to export" - }, - { - "name": "--output", - "aliases": ["-o"], - "value": "", - "description": "write the exported transcript to this path" - }, - { - "name": "--output-format", - "value": "", - "values": ["text", "json"], - "default": "text", - "description": "format for the command result envelope" - }, - { - "name": "--help", - "aliases": ["-h"], - "description": "show help for the export command" - } - ], - "related": ["/session list", "claw --resume latest"] - }) -} - -fn render_doctor_help_json() -> serde_json::Value { - json!({ - "kind": "help", - "action": "help", - "status": "ok", - "topic": "doctor", - "command": "doctor", - "schema_version": "1.0", - "usage": "claw doctor [--output-format ]", - "purpose": "diagnose local auth, config, workspace memory, permissions, sandbox, boot preflight, and build metadata", - "formats": ["text", "json"], - "local_only": true, - "requires_credentials": false, - "requires_provider_request": false, - "requires_session_resume": false, - "mutates_workspace": false, - "output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks", "allowed_tools"], - "check_names": ["auth", "config", "mcp validation", "hook validation", "install source", "workspace", "memory", "boot preflight", "sandbox", "permissions", "system"], - "status_values": ["ok", "warn", "fail"], - "options": [ - { - "name": "--output-format", - "value": "", - "values": ["text", "json"], - "default": "text", - "description": "format for the doctor report or help envelope" - }, - { - "name": "--help", - "aliases": ["-h"], - "description": "show help for the doctor command without running diagnostics" - } - ], - "related": ["/doctor", "claw --resume latest /doctor"], - "message": render_help_topic(LocalHelpTopic::Doctor), - }) -} - -/// #683-#692: extract structured metadata from help prose -fn extract_help_metadata( - topic: LocalHelpTopic, -) -> ( - Option, // usage - Option, // purpose - Option, // output description - Option>, // formats - Option>, // related - Option>, // aliases - bool, // local_only - bool, // requires_credentials -) { - let text = render_help_topic(topic); - let mut usage = None; - let mut purpose = None; - let mut output_desc = None; - let formats = Some(vec!["text".to_string(), "json".to_string()]); - let mut related = None; - let mut aliases = None; - let local_only = matches!( - topic, - LocalHelpTopic::Status - | LocalHelpTopic::Sandbox - | LocalHelpTopic::Doctor - | LocalHelpTopic::Version - | LocalHelpTopic::State - | LocalHelpTopic::Init - | LocalHelpTopic::Export - | LocalHelpTopic::SystemPrompt - | LocalHelpTopic::DumpManifests - | LocalHelpTopic::BootstrapPlan - ); - for line in text.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("Usage") { - let value = rest.trim(); - if !value.is_empty() { - usage = Some(value.to_string()); - } - } else if let Some(rest) = trimmed.strip_prefix("Purpose") { - purpose = Some(rest.trim().to_string()); - } else if let Some(rest) = trimmed.strip_prefix("Output") { - output_desc = Some(rest.trim().to_string()); - } else if let Some(rest) = trimmed.strip_prefix("Aliases") { - let parts: Vec = rest - .split('·') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if !parts.is_empty() { - aliases = Some(parts); - } - } else if let Some(rest) = trimmed.strip_prefix("Related") { - let parts: Vec = rest - .split('·') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if !parts.is_empty() { - related = Some(parts); - } - } - } - ( - usage, - purpose, - output_desc, - formats, - related, - aliases, - local_only, - !local_only, - ) -} - -fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value { - if topic == LocalHelpTopic::Export { - return render_export_help_json(); - } - if topic == LocalHelpTopic::Doctor { - return render_doctor_help_json(); - } - - // #683-#692: extract structured metadata from help prose for machine consumption - let (usage, purpose, output_desc, formats, related, aliases, local_only, requires_credentials) = - extract_help_metadata(topic); - let mut obj = serde_json::json!({ - "kind": "help", - "action": "help", - "status": "ok", - "topic": local_help_topic_command(topic), - "command": local_help_topic_command(topic), - "message": render_help_topic(topic), - "usage": usage, - "purpose": purpose, - "formats": formats, - "related": related, - "local_only": local_only, - "requires_credentials": requires_credentials, - }); - if let Some(desc) = output_desc { - obj["output_fields"] = serde_json::Value::String(desc); - } - if let Some(a) = aliases { - obj["aliases"] = serde_json::json!(a); - } - obj -} - -fn print_help_topic( - topic: LocalHelpTopic, - output_format: CliOutputFormat, -) -> Result<(), Box> { - let cwd = env::current_dir().unwrap_or_default(); - // For subsystem topics in JSON mode, delegate to the subsystem's usage JSON. - if output_format == CliOutputFormat::Json { - match topic { - LocalHelpTopic::Agents => { - let json = commands::handle_agents_slash_command_json(Some("--help"), &cwd) - .unwrap_or_else( - |_| serde_json::json!({"kind":"agents","action":"help","status":"error"}), - ); - println!("{}", serde_json::to_string_pretty(&json)?); - return Ok(()); - } - LocalHelpTopic::Skills => { - let json = commands::handle_skills_slash_command_json(Some("--help"), &cwd) - .unwrap_or_else( - |_| serde_json::json!({"kind":"skills","action":"help","status":"error"}), - ); - println!("{}", serde_json::to_string_pretty(&json)?); - return Ok(()); - } - _ => {} - } - } - match output_format { - CliOutputFormat::Text => println!("{}", render_help_topic(topic)), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&render_help_topic_json(topic))? - ), - } - Ok(()) -} - -fn acp_status_message() -> &'static str { - "ACP/Zed editor integration is not implemented in claw-code yet. `claw acp serve` reports status only and does not launch a daemon or JSON-RPC endpoint. Use the normal terminal surfaces for now." -} - -fn acp_status_json() -> serde_json::Value { - json!({ - "schema_version": "1.0", - "kind": "acp", - "action": "status", - "status": "not_implemented", - "supported": false, - "message": acp_status_message(), - "launch_command": serde_json::Value::Null, - "protocol": { - "name": "ACP/Zed", - "json_rpc": false, - "daemon": false, - "endpoint": serde_json::Value::Null, - "serve_starts_daemon": false + DiagnosticCheck::new( + "MCP validation", + if summary.has_invalid_servers() { + DiagnosticLevel::Warn + } else { + DiagnosticLevel::Ok }, - "contracts": { - "blocking_gates": [ - "task_packet_schema", - "session_control_schema", - "event_report_schema" - ], - "stable_status_surface": "claw acp [serve] --output-format json", - "unsupported_invocation_kind": "unsupported_acp_invocation" + if summary.has_invalid_servers() { + format!( + "{} MCP server entries are invalid; {} valid entries remain loaded", + summary.invalid_count(), + summary.valid_count + ) + } else { + format!("{} MCP server entries validated", summary.valid_count) }, - "aliases": ["acp", "--acp", "-acp"], + ) + .with_hint(if summary.has_invalid_servers() { + "Inspect `claw mcp list --output-format json` invalid_servers and fix each rejected mcpServers entry." + } else { + "" }) + .with_details(details) + .with_data(Map::from_iter([ + ( + "total_configured".to_string(), + json!(summary.total_configured), + ), + ("valid_count".to_string(), json!(summary.valid_count)), + ("invalid_count".to_string(), json!(summary.invalid_count())), + ( + "invalid_servers".to_string(), + Value::Array(invalid_mcp_servers_json(&summary.invalid_servers)), + ), + ])) } -fn print_acp_status(output_format: CliOutputFormat) -> Result<(), Box> { - match output_format { - CliOutputFormat::Text => { - println!( - "ACP / Zed\n Status not implemented\n Launch `claw acp serve` reports status only; no editor daemon or JSON-RPC endpoint is available yet\n Today use `claw prompt`, the REPL, or `claw doctor` for local verification\n Message {}", - acp_status_message() - ); - } - CliOutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(&acp_status_json())?); - } - } - 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(), +fn check_hook_validation_health(summary: &HookValidationSummary) -> DiagnosticCheck { + let mut details = vec![ + format!("Valid entries {}", summary.valid_count), + format!("Invalid entries {}", summary.invalid_count()), ]; - 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() + details.extend( + summary + .invalid_hooks .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 rendered = match section { - "env" => runtime_config.get("env").map(|value| value.render()), - "hooks" => runtime_config.get("hooks").map(|value| value.render()), - "model" => runtime_config.get("model").map(|value| value.render()), - "plugins" => runtime_config - .get("plugins") - .or_else(|| runtime_config.get("enabledPlugins")) - .map(|value| value.render()), - "mcp" | "mcp_servers" | "mcpServers" => runtime_config - .get("mcp") - .or_else(|| runtime_config.get("mcp_servers")) - .or_else(|| runtime_config.get("mcpServers")) - .map(|value| value.render()), - "sandbox" => runtime_config.get("sandbox").map(|value| value.render()), - "permissions" => runtime_config - .get("permissions") - .map(|value| value.render()), - "skills" => runtime_config.get("skills").map(|value| value.render()), - "agents" => runtime_config.get("agents").map(|value| value.render()), - "settings" => Some(runtime_config.as_json().render()), - // #344: /config help shows available sections - "help" => { - lines.push("Available config sections:".to_string()); - lines.push(" env Environment variables".to_string()); - lines.push(" hooks Hook configuration".to_string()); - lines.push(" model Model configuration".to_string()); - lines.push(" plugins Plugin configuration".to_string()); - lines.push(" mcp MCP server configuration".to_string()); - lines.push(" sandbox Sandbox configuration".to_string()); - lines.push(" permissions Permission rules".to_string()); - lines.push(" skills Skills configuration".to_string()); - lines.push(" agents Agent configuration".to_string()); - lines.push(" settings Full merged settings".to_string()); - lines.push(format!(" Loaded keys: {}", runtime_config.merged().len())); - return Ok(lines.join("\n")); - } - other => { - lines.push(format!( - " Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, or settings." - )); - return Ok(lines.join( - " -", - )); - } - }; - lines.push(format!( - " {}", - rendered.unwrap_or_else(|| "".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); - // #773: keep deprecation warnings in the JSON envelope, and #407: include - // per-file status/reason/detail for every discovered config path. - let inspection = loader.inspect_collecting_warnings(); - if section.is_some() { - if let Some(error) = &inspection.load_error { - return Err(error.clone().into()); - } - } - let runtime_config = inspection - .runtime_config - .clone() - .unwrap_or_else(runtime::RuntimeConfig::empty); - let loaded_files = runtime_config.loaded_entries().len(); - let merged_keys = runtime_config.merged().len(); - // #415: expose actual merged key-value pairs, not just count - let merged_json_str = serde_json::json!(runtime_config - .merged() - .iter() - .map(|(k, v)| { (k.clone(), serde_json::Value::String(v.render())) }) - .collect::>()); - let files: Vec<_> = inspection - .files - .iter() - .map(config_file_report_json) - .collect(); + .map(|hook| format!("Invalid hook {} ({})", hook.event, hook.reason)), + ); - let warnings_json: Vec = inspection - .warnings - .iter() - .map(|w| serde_json::Value::String(w.clone())) - .collect(); - - let hook_validation = HookValidationSummary::from_config(&runtime_config); - let has_hook_issues = hook_validation.has_invalid_hooks(); - let status_value = if inspection.load_error.is_some() { - "error" - } else if has_hook_issues { - "degraded" + DiagnosticCheck::new( + "Hook validation", + if summary.has_invalid_hooks() { + DiagnosticLevel::Warn + } else { + DiagnosticLevel::Ok + }, + if summary.has_invalid_hooks() { + format!( + "{} hook entries are invalid; {} valid entries remain loaded", + summary.invalid_count(), + summary.valid_count + ) + } else { + format!("{} hook entries validated", summary.valid_count) + }, + ) + .with_hint(if summary.has_invalid_hooks() { + "Inspect `claw status --output-format json` hook_validation.invalid_hooks and fix each rejected hooks entry." } else { - "ok" - }; - let base = serde_json::json!({ - "kind": "config", - "action": if section.is_some() { "show" } else { "list" }, - "status": status_value, - "cwd": cwd.display().to_string(), - "loaded_files": loaded_files, - "merged_keys": merged_keys, - "merged_key_count": merged_keys, - "merged": merged_json_str, - "merged_keys_meaning": "count of top-level keys in the effective merged JSON object", - - "files": files, - "warnings": warnings_json, - "load_error": inspection.load_error.clone(), - "hook_validation": hook_validation.json_value(), - }); - - if let Some(section) = section { - let section_rendered: Option = match section { - "env" => runtime_config.get("env").map(|v| v.render()), - "hooks" => runtime_config.get("hooks").map(|v| v.render()), - "model" => runtime_config.get("model").map(|v| v.render()), - "plugins" => runtime_config - .get("plugins") - .or_else(|| runtime_config.get("enabledPlugins")) - .map(|v| v.render()), - // These sections are structurally present in config files but may not have - // dedicated runtime_config keys yet; return null section_value rather than error. - "mcp" | "mcp_servers" | "mcpServers" => runtime_config - .get("mcp") - .or_else(|| runtime_config.get("mcp_servers")) - .or_else(|| runtime_config.get("mcpServers")) - .map(|v| v.render()), - "sandbox" => runtime_config.get("sandbox").map(|v| v.render()), - "permissions" => runtime_config.get("permissions").map(|v| v.render()), - "skills" => runtime_config.get("skills").map(|v| v.render()), - "agents" => runtime_config.get("agents").map(|v| v.render()), - "settings" => Some(runtime_config.as_json().render()), - // #344: /config help returns structured section list - "help" => { - return Ok(serde_json::json!({ - "kind": "config", - "action": "help", - "status": "ok", - "section": "help", - "available_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents", "settings"], - "loaded_keys": runtime_config.merged().len(), - })); - } - other => { - // #741: populate hint field for unsupported section errors so callers reading - // .hint get actionable guidance instead of null - let hint = if matches!(other, "list" | "show" | "info") { - format!( - "'claw config {other}' is not a subcommand. To list all config: `claw config`. To inspect a section: `claw config
` where section is one of: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, settings." - ) - } else { - format!( - "'{other}' is not a config section. Supported: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, settings." - ) - }; - return Ok(serde_json::json!({ - "kind": "config", - "action": "show", - "status": "error", - "error_kind": "unsupported_config_section", - "section": other, - "ok": false, - "error": format!("Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, or settings."), - "hint": hint, - "supported_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents", "settings"], - "cwd": cwd.display().to_string(), - "loaded_files": loaded_files, - "files": base["files"].clone(), - })); - } - }; - // Parse the rendered JSON string back into serde_json::Value so that - // section_value is a real JSON object/array in the envelope, not a quoted string. - let section_value: serde_json::Value = section_rendered - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(serde_json::Value::Null); - let mut obj = base; - let map = obj.as_object_mut().expect("base is object"); - map.insert( - "section".to_string(), - serde_json::Value::String(section.to_string()), - ); - map.insert("section_value".to_string(), section_value); - return Ok(obj); - } - - Ok(base) -} - -fn config_file_report_json(file: &ConfigFileReport) -> serde_json::Value { - let source = match file.entry.source { - ConfigSource::User => "user", - ConfigSource::Project => "project", - ConfigSource::Local => "local", - }; - let mut object = serde_json::Map::new(); - object.insert( - "path".to_string(), - serde_json::Value::String(file.entry.path.display().to_string()), - ); - object.insert( - "source".to_string(), - serde_json::Value::String(source.to_string()), - ); - object.insert("loaded".to_string(), serde_json::Value::Bool(file.loaded)); - object.insert( - "precedence_rank".to_string(), - serde_json::Value::Number(serde_json::Number::from(file.precedence_rank)), - ); - object.insert( - "wins_for_keys".to_string(), - serde_json::Value::Array( - file.wins_for_keys - .iter() - .cloned() - .map(serde_json::Value::String) - .collect(), - ), - ); - object.insert( - "shadowed_keys".to_string(), - serde_json::Value::Array( - file.shadowed_keys - .iter() - .cloned() - .map(serde_json::Value::String) - .collect(), + "" + }) + .with_details(details) + .with_data(Map::from_iter([ + ("valid_count".to_string(), json!(summary.valid_count)), + ("invalid_count".to_string(), json!(summary.invalid_count())), + ( + "invalid_hooks".to_string(), + Value::Array(invalid_hooks_json(&summary.invalid_hooks)), ), - ); - object.insert( - "status".to_string(), - serde_json::Value::String(file.status.as_str().to_string()), - ); - if let Some(reason) = &file.reason { - object.insert( - "reason".to_string(), - serde_json::Value::String(reason.clone()), - ); - object.insert( - "skip_reason".to_string(), - serde_json::Value::String(reason.clone()), - ); - } - if let Some(detail) = &file.detail { - object.insert( - "detail".to_string(), - serde_json::Value::String(detail.clone()), - ); - } - serde_json::Value::Object(object) + ])) } -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.md, CLAW.md, AGENTS.md, or scoped instruction files discovered in the current directory ancestry." - .to_string(), - ); +fn check_permission_health(permission_mode: PermissionModeProvenance) -> DiagnosticCheck { + let mode = permission_mode.mode.as_str(); + let source = permission_mode.source.as_str(); + let explicit = permission_mode.source.is_explicit(); + let warning = matches!(permission_mode.mode, PermissionMode::DangerFullAccess) && !explicit; + let message = if warning { + "running with full access without explicit opt-in" + } else if matches!(permission_mode.mode, PermissionMode::DangerFullAccess) { + "danger-full-access was explicitly selected" + } else if matches!(permission_mode.mode, PermissionMode::WorkspaceWrite) && !explicit { + "default permission mode is workspace-write" } 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!( - " source={} lines={} chars={} preview={}", - file.source(), - file.content.lines().count(), - file.char_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 + "permission mode is explicitly bounded below danger-full-access" + }; + let source_detail = permission_mode.env_var.map_or_else( + || source.to_string(), + |env_var| format!("{source}:{env_var}"), + ); + let specs = mvp_tool_specs(); + let tools_satisfied = specs .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", - "action": "list", - "status": "ok", - "cwd": cwd.display().to_string(), - "instruction_files": files.len(), - "files": files, - })) -} + .filter(|spec| permission_mode.mode >= spec.required_permission) + .map(|spec| spec.name) + .collect::>(); + let tools_gated = specs + .iter() + .filter(|spec| permission_mode.mode < spec.required_permission) + .map(|spec| spec.name) + .collect::>(); -fn init_claude_md() -> Result> { - let cwd = env::current_dir()?; - Ok(initialize_repo(&cwd)?.render()) + DiagnosticCheck::new( + "Permissions", + if warning { + DiagnosticLevel::Warn + } else { + DiagnosticLevel::Ok + }, + message, + ) + .with_details(vec![ + format!("Mode {mode}"), + format!("Source {source_detail}"), + format!("Explicit opt-in {explicit}"), + format!("Tools allowed {}", tools_satisfied.join(", ")), + format!("Tools gated {}", tools_gated.join(", ")), + ]) + .with_hint(if warning { + "Use the workspace-write default, or pass --permission-mode danger-full-access / --dangerously-skip-permissions only when full filesystem, network, and command access is intentional." + } else { + "Use --permission-mode read-only|workspace-write|danger-full-access to make the runtime permission boundary explicit." + }) + .with_data(Map::from_iter([ + ("mode".to_string(), json!(mode)), + ("source".to_string(), json!(source)), + ("source_explicit".to_string(), json!(explicit)), + ("env_var".to_string(), json!(permission_mode.env_var)), + ("message".to_string(), json!(message)), + ("tools_satisfied".to_string(), json!(tools_satisfied)), + ("tools_gated".to_string(), json!(tools_gated)), + ])) } -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(()) -} +fn check_memory_health(context: &StatusContext) -> DiagnosticCheck { + let has_unloaded = !context.unloaded_memory_files.is_empty(); + let has_outside_project = context.memory_files.iter().any(|file| file.outside_project); + let mut details = vec![format!("Loaded files {}", context.memory_file_count)]; + details.extend(context.memory_files.iter().map(|file| { + format!( + "Loaded {} ({}, chars={})", + file.path, file.source, file.chars + ) + })); + details.extend( + context + .unloaded_memory_files + .iter() + .map(|path| format!("Unloaded {path}")), + ); -/// #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; - // Derive top-level status: "ok" when all artifacts succeeded (created or - // skipped = idempotent); no failure path exists today so always "ok". - let status = "ok"; - // #783/#436: already_initialized lets orchestrators detect the idempotent - // case without checking every status bucket; deferred session storage does - // not make the workspace uninitialized because it is created on first save. - let already_initialized = report.artifacts_with_status(InitStatus::Created).is_empty() - && report.artifacts_with_status(InitStatus::Updated).is_empty() - && report.artifacts_with_status(InitStatus::Partial).is_empty(); - let hint = if already_initialized { - "Workspace already initialised. Run `claw doctor` to verify health, or edit CLAUDE.md to customise guidance." + DiagnosticCheck::new( + "Memory", + if has_unloaded || has_outside_project { + DiagnosticLevel::Warn + } else { + DiagnosticLevel::Ok + }, + if has_outside_project { + "memory files outside the current git project are loaded".to_string() + } else if has_unloaded { + "some workspace memory files exist but were not loaded".to_string() + } else { + format!("{} workspace memory files loaded", context.memory_file_count) + }, + ) + .with_hint(if has_outside_project { + "Inspect workspace.memory_files in `claw status --output-format json`; move unintended ancestor instructions inside the git project or run from the intended workspace root." + } else if has_unloaded { + "Move instructions into CLAUDE.md, CLAW.md, or AGENTS.md within the current workspace ancestry, or inspect workspace.memory_files in `claw status --output-format json`." } else { - "Review and tailor CLAUDE.md to your project, then run `claw doctor` to verify the workspace." - }; - json!({ - "kind": "init", - "action": "init", - "status": status, - "already_initialized": already_initialized, - "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), - "partial": report.artifacts_with_status(InitStatus::Partial), - "deferred": report.artifacts_with_status(InitStatus::Deferred), - "artifacts": report.artifact_json_entries(), - "hint": hint, - "next_step": crate::init::InitReport::NEXT_STEP, - "message": message, + "" }) + .with_details(details) + .with_data(Map::from_iter([ + ( + "memory_file_count".to_string(), + json!(context.memory_file_count), + ), + ( + "memory_files".to_string(), + Value::Array(memory_files_json(&context.memory_files)), + ), + ( + "unloaded_memory_files".to_string(), + json!(context.unloaded_memory_files), + ), + ])) } -fn normalize_permission_mode(mode: &str) -> Option<&'static str> { - match mode.trim() { - "default" | "plan" | "read-only" => Some("read-only"), - "acceptEdits" | "auto" | "workspace-write" => Some("workspace-write"), - "dontAsk" | "bypassPermissions" | "dangerFullAccess" | "danger-full-access" => { - Some("danger-full-access") - } - _ => None, - } -} - -fn render_diff_report() -> Result> { - render_diff_report_for(&env::current_dir()?) -} +const DUMP_MANIFESTS_USAGE_HINT: &str = + "Usage: claw dump-manifests [--manifests-dir ] [--output-format json]"; -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() - .is_ok_and(|o| o.status.success()); - 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(), - ); - } +fn build_rust_resolver_manifest(workspace_dir: &Path) -> Result> { + let command_entries = slash_command_specs() + .iter() + .map(|spec| { + json!({ + "name": spec.name, + "aliases": spec.aliases, + "summary": spec.summary, + "argument_hint": spec.argument_hint, + "resume_supported": spec.resume_supported, + "implemented": !STUB_COMMANDS.contains(&spec.name), + }) + }) + .collect::>(); - 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())); - } + let tool_entries = mvp_tool_specs() + .into_iter() + .map(|spec| { + json!({ + "name": spec.name, + "description": spec.description, + "required_permission": spec.required_permission.as_str(), + "input_schema": spec.input_schema, + }) + }) + .collect::>(); - Ok(format!("Diff\n\n{}", sections.join("\n\n"))) -} + let agent_report = handle_agents_slash_command_json(None, workspace_dir)?; + let skill_report = handle_skills_slash_command_json(None, workspace_dir)?; + let agents = agent_report + .get("agents") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let skills = skill_report + .get("skills") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let bootstrap = runtime::BootstrapPlan::claude_code_default() + .phases() + .iter() + .map(|phase| format!("{phase:?}")) + .collect::>(); -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() - .is_ok_and(|o| o.status.success()); - if !in_git_repo { - // #801: add error_kind, hint, message fields for envelope parity with other error paths - return Ok(serde_json::json!({ - "kind": "diff", - "action": "diff", - "status": "error", - "error_kind": "no_git_repo", - "result": "no_git_repo", - "message": format!("{} is not inside a git project", cwd.display()), - "hint": "Run `git init` to create a repository, or change to a directory that is inside a git project.", - "working_directory": cwd.display().to_string(), - "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"])?; - // #733: add changed_file_count so callers don't have to count diff hunks - let staged_files = - run_git_diff_command_in(cwd, &["diff", "--cached", "--name-only"]).unwrap_or_default(); - let unstaged_files = run_git_diff_command_in(cwd, &["diff", "--name-only"]).unwrap_or_default(); - let mut changed: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); - for line in staged_files.lines().chain(unstaged_files.lines()) { - let t = line.trim(); - if !t.is_empty() { - changed.insert(t); - } - } - let changed_file_count = changed.len(); - Ok(serde_json::json!({ - "kind": "diff", - "action": "diff", + Ok(json!({ + "kind": "dump-manifests", + "action": "dump", "status": "ok", - "working_directory": cwd.display().to_string(), - "result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" }, - "changed_file_count": changed_file_count, - "staged": staged.trim(), - "unstaged": unstaged.trim(), + "source": "rust-resolver", + "workspace": workspace_dir.display().to_string(), + "commands": command_entries.len(), + "tools": tool_entries.len(), + "agents": agents.len(), + "skills": skills.len(), + "bootstrap_phases": bootstrap.len(), + "command_manifests": command_entries, + "tool_manifests": tool_entries, + "agent_manifests": agents, + "skill_manifests": skills, + "bootstrap_manifest": bootstrap, })) } -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()), +fn bootstrap_phase_metadata(phase: &runtime::BootstrapPhase) -> (&'static str, &'static str) { + use runtime::BootstrapPhase::*; + match phase { + CliEntry => ( + "CLI Entry", + "Command-line argument parsing and global flag resolution", + ), + FastPathVersion => ( + "Fast-Path Version", + "Short-circuit version/help requests before full startup", + ), + StartupProfiler => ( + "Startup Profiler", + "Instrument startup timing for diagnostics", + ), + SystemPromptFastPath => ( + "System Prompt Fast-Path", + "Serve system-prompt requests without provider init", + ), + ChromeMcpFastPath => ( + "Chrome MCP Fast-Path", + "Serve Chrome MCP requests without full runtime", + ), + DaemonWorkerFastPath => ( + "Daemon Worker Fast-Path", + "Handle daemon worker requests without full init", + ), + BridgeFastPath => ( + "Bridge Fast-Path", + "Bridge/sibling process communication without full init", + ), + DaemonFastPath => ( + "Daemon Fast-Path", + "Daemon lifecycle management without full runtime", + ), + BackgroundSessionFastPath => ( + "Background Session Fast-Path", + "Resume/list background sessions without full init", + ), + TemplateFastPath => ( + "Template Fast-Path", + "Template rendering without full runtime", + ), + EnvironmentRunnerFastPath => ( + "Environment Runner Fast-Path", + "Environment/runner dispatch without full init", + ), + MainRuntime => ( + "Main Runtime", + "Full interactive REPL or one-shot prompt execution", + ), } - - 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") +#[derive(Debug, Clone, PartialEq, Eq)] +struct MemoryFileSummary { + path: String, + source: String, + chars: usize, + origin: String, + scope_path: String, + outside_project: bool, + contributes: bool, } -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()); +impl MemoryFileSummary { + fn json_value(&self) -> serde_json::Value { + json!({ + "path": self.path, + "source": self.source, + "chars": self.chars, + "origin": self.origin, + "scope_path": self.scope_path, + "outside_project": self.outside_project, + "contributes": self.contributes, + }) } - 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") - ) +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct McpValidationSummary { + total_configured: usize, + valid_count: usize, + invalid_servers: Vec, } -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") - ) -} +impl McpValidationSummary { + fn from_collection(collection: &McpConfigCollection) -> Self { + Self { + total_configured: collection.total_configured(), + valid_count: collection.valid_count(), + invalid_servers: collection.invalid_servers().to_vec(), + } + } -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()); + fn invalid_count(&self) -> usize { + self.invalid_servers.len() } - 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()); + fn has_invalid_servers(&self) -> bool { + !self.invalid_servers.is_empty() } - Ok(()) -} -fn command_exists(name: &str) -> bool { - Command::new("which") - .arg(name) - .output() - .is_ok_and(|output| output.status.success()) + fn json_value(&self) -> serde_json::Value { + json!({ + "total_configured": self.total_configured, + "valid_count": self.valid_count, + "invalid_count": self.invalid_count(), + "invalid_servers": invalid_mcp_servers_json(&self.invalid_servers), + }) + } } -fn write_temp_text_file( - filename: &str, - contents: &str, -) -> Result> { - let path = env::temp_dir().join(filename); - fs::write(&path, contents)?; - Ok(path) +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct HookValidationSummary { + valid_count: usize, + invalid_hooks: Vec, } -const DEFAULT_HISTORY_LIMIT: usize = 20; - -fn parse_history_count(raw: Option<&str>) -> Result { - let Some(raw) = raw else { - return Ok(DEFAULT_HISTORY_LIMIT); - }; - // #776: use \n-delimited format so split_error_hint extracts hint into JSON envelopes - let parsed: usize = raw - .parse() - .map_err(|_| format!("invalid_history_count: '{raw}' is not a positive integer.\nUsage: /history [count] (default: {DEFAULT_HISTORY_LIMIT})"))?; - if parsed == 0 { - return Err(format!("invalid_history_count: count must be greater than 0.\nUsage: /history [count] (default: {DEFAULT_HISTORY_LIMIT})")); +impl HookValidationSummary { + fn from_config(config: &runtime::RuntimeConfig) -> Self { + let hooks = config.hooks(); + Self { + valid_count: hooks.pre_tool_use_entries().len() + + hooks.post_tool_use_entries().len() + + hooks.post_tool_use_failure_entries().len(), + invalid_hooks: hooks.invalid_hooks().to_vec(), + } } - 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 invalid_count(&self) -> usize { + self.invalid_hooks.len() + } -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(); + fn has_invalid_hooks(&self) -> bool { + !self.invalid_hooks.is_empty() } - 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}")); + fn json_value(&self) -> serde_json::Value { + json!({ + "valid_count": self.valid_count, + "invalid_count": self.invalid_count(), + "invalid_hooks": invalid_hooks_json(&self.invalid_hooks), + }) } - 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 +fn invalid_hooks_json(invalid_hooks: &[RuntimeInvalidHookConfig]) -> Vec { + invalid_hooks .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, + .map(|hook| { + json!({ + "event": &hook.event, + "index": hook.index, + "hook_index": hook.hook_index, + "kind": &hook.kind, + "error_field": &hook.error_field, + "reason": &hook.reason, + "valid": false, }) }) .collect() } -fn recent_user_context(session: &Session, limit: usize) -> String { - let requests = session - .messages +fn invalid_mcp_servers_json(invalid_servers: &[McpInvalidServerConfig]) -> Vec { + invalid_servers .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, + .map(|server| { + json!({ + "name": &server.name, + "scope": config_source_json_value(server.scope), + "path": server.path.display().to_string(), + "error_field": &server.error_field, + "reason": &server.reason, + "valid": false, }) }) - .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_SHORT.or(GIT_SHA).unwrap_or("unknown"); - let target = BUILD_TARGET.unwrap_or("unknown"); - let branch = GIT_BRANCH.unwrap_or("unknown"); - let dirty = GIT_DIRTY.unwrap_or("unknown"); - format!( - "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Branch {branch}\n Dirty {dirty}\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::Thinking { .. } => {} - ContentBlock::ToolUse { id, name, input } => { - lines.push(format!("[tool_use id={id} name={name}] {input}")); - } - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => { - lines.push(format!( - "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" - )); - } - } - } - lines.push(String::new()); - } - lines.join("\n") -} - -fn default_export_filename(session: &Session) -> String { - let stem = session - .messages - .iter() - .find_map(|message| match message.role { - MessageRole::User => message.blocks.iter().find_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }), - _ => None, - }) - .map_or("conversation", |text| { - text.lines().next().unwrap_or("conversation") - }) - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() { - ch.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::() - .split('-') - .filter(|part| !part.is_empty()) - .take(8) - .collect::>() - .join("-"); - let fallback = if stem.is_empty() { - "conversation" - } else { - &stem - }; - format!("{fallback}.txt") + .collect() } -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") +fn config_source_json_value(source: ConfigSource) -> serde_json::Value { + let id = match source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", }; - Ok(cwd.join(final_name)) + json!({"id": id, "label": id}) } -fn validate_export_output_path(path: Option<&Path>) -> Result<(), InvalidOutputPathError> { - let Some(path) = path else { - return Ok(()); - }; - let raw = path.to_string_lossy(); - if raw.trim().is_empty() { - return Err(InvalidOutputPathError::new( - raw.to_string(), - InvalidOutputPathReason::Empty, - )); - } - if matches!(fs::metadata(path), Ok(metadata) if metadata.is_dir()) { - return Err(InvalidOutputPathError::new( - raw.to_string(), - InvalidOutputPathReason::PathIsDirectory, - )); - } - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - match fs::metadata(parent) { - Ok(metadata) if metadata.is_dir() => {} - Ok(_) => { - return Err(InvalidOutputPathError::new( - raw.to_string(), - InvalidOutputPathReason::ParentNotADirectory, - )); - } - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return Err(InvalidOutputPathError::new( - raw.to_string(), - InvalidOutputPathReason::ParentNotFound, - )); - } - Err(_) => { - return Err(InvalidOutputPathError::new( - raw.to_string(), - InvalidOutputPathReason::ParentNotFound, - )); +fn memory_file_summaries_for( + cwd: &Path, + project_root: Option<&Path>, + files: &[ContextFile], +) -> Vec { + let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); + let project_root = + project_root.map(|path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf())); + files + .iter() + .map(|file| { + let path = file + .path + .canonicalize() + .unwrap_or_else(|_| file.path.clone()); + let scope_path = memory_scope_path(&path); + let origin = memory_origin(&cwd, project_root.as_deref(), &scope_path); + let outside_project = project_root + .as_ref() + .is_some_and(|root| !path.starts_with(root)); + MemoryFileSummary { + path: file.path.display().to_string(), + source: file.source().to_string(), + origin: origin.to_string(), + scope_path: scope_path.display().to_string(), + chars: file.char_count(), + outside_project, + contributes: true, } - } - } - Ok(()) + }) + .collect() } -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(" "), +fn memory_scope_path(path: &Path) -> PathBuf { + let Some(parent) = path.parent() else { + return PathBuf::from("."); }; - 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> { - validate_export_output_path(output_path)?; - 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", - "action": "export", - "status": "ok", - "message": report, - "session_id": handle.id, - "file": path.display().to_string(), - "messages": session.messages.len(), - }))? - ), - } - return Ok(()); + let parent_name = parent.file_name().and_then(|name| name.to_str()); + if matches!(parent_name, Some(".claw" | ".claude")) { + return parent.parent().unwrap_or(parent).to_path_buf(); } - - match output_format { - CliOutputFormat::Text => { - print!("{markdown}"); - if !markdown.ends_with('\n') { - println!(); + if matches!(parent_name, Some("rules" | "rules.local")) { + if let Some(grandparent) = parent.parent() { + if grandparent.file_name().and_then(|name| name.to_str()) == Some(".claw") { + return grandparent.parent().unwrap_or(grandparent).to_path_buf(); } } - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "export", - "action": "export", - "status": "ok", - "session_id": handle.id, - "file": handle.path.display().to_string(), - "messages": session.messages.len(), - "markdown": markdown, - }))? - ), } - Ok(()) + parent.to_path_buf() } -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 - )); +fn memory_origin(cwd: &Path, project_root: Option<&Path>, scope_path: &Path) -> &'static str { + if scope_path == cwd { + return "workspace"; } - if let Some(compaction) = &session.compaction { - lines.push(format!( - "- **Compactions**: {} (last removed {} messages)", - compaction.count, compaction.removed_message_count - )); + if project_root.is_some_and(|root| !scope_path.starts_with(root)) { + return "outside_project"; } - 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::Thinking { .. } => {} - ContentBlock::ToolUse { id, name, input } => { - lines.push(format!( - "**Tool call** `{name}` _(id `{}`)_", - short_tool_id(id) - )); - let summary = summarize_tool_payload_for_markdown(input); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => { - let status = if *is_error { "error" } else { "ok" }; - lines.push(format!( - "**Tool result** `{tool_name}` _(id `{}`, {status})_", - short_tool_id(tool_use_id) - )); - let summary = summarize_tool_payload_for_markdown(output); - if !summary.is_empty() { - lines.push(format!("> {summary}")); - } - lines.push(String::new()); - } - } - } - if let Some(usage) = message.usage { - lines.push(format!( - "_tokens: in={} out={} cache_create={} cache_read={}_", - usage.input_tokens, - usage.output_tokens, - usage.cache_creation_input_tokens, - usage.cache_read_input_tokens, - )); - lines.push(String::new()); + if let Some(home) = env::var_os("HOME").map(PathBuf::from) { + let home = home.canonicalize().unwrap_or(home); + if scope_path == home { + return "home"; } } - lines.join("\n") -} - -fn short_tool_id(id: &str) -> String { - let char_count = id.chars().count(); - if char_count <= 12 { - return id.to_string(); + if cwd.parent().is_some_and(|parent| parent == scope_path) { + return "parent_dir"; } - let prefix: String = id.chars().take(12).collect(); - format!("{prefix}…") -} - -fn build_system_prompt(model: &str) -> Result, Box> { - Ok(load_system_prompt( - env::current_dir()?, - DEFAULT_DATE, - env::consts::OS, - "unknown", - model_family_identity_for(model), - )?) -} - -struct PluginsCommandPayload { - message: String, - reload_runtime: bool, - status: &'static str, - config_load_error: Option, - mcp_validation: McpValidationSummary, - plugins: Vec, - load_failures: Vec, -} - -fn plugins_command_payload_for( - cwd: &Path, - action: Option<&str>, - target: Option<&str>, - config_warning_mode: ConfigWarningMode, -) -> Result> { - let loader = ConfigLoader::default_for(cwd); - let loaded_config = load_config_with_warning_mode(&loader, config_warning_mode); - let (runtime_config, config_load_error, mcp_validation) = match loaded_config { - Ok(runtime_config) => { - let mcp_validation = McpValidationSummary::from_collection(runtime_config.mcp()); - (runtime_config, None, mcp_validation) - } - Err(error) => ( - runtime::RuntimeConfig::empty(), - Some(error.to_string()), - McpValidationSummary::default(), - ), - }; - let mut manager = build_plugin_manager(cwd, &loader, &runtime_config); - let result = handle_plugins_slash_command(action, target, &mut manager)?; - let report = manager.installed_plugin_registry_report()?; - Ok(plugins_command_payload_from_result( - result, - config_load_error, - mcp_validation, - &report, - )) -} - -fn plugins_command_payload_from_result( - result: PluginsCommandResult, - config_load_error: Option, - mcp_validation: McpValidationSummary, - report: &plugins::PluginRegistryReport, -) -> PluginsCommandPayload { - let failures = report.failures(); - let status = if config_load_error.is_some() - || mcp_validation.has_invalid_servers() - || !failures.is_empty() - { - "degraded" - } else { - "ok" - }; - let message = match config_load_error.as_deref() { - Some(error) => format!( - "Config load error\n Status fail\n Summary runtime config failed to load; reporting partial plugins view\n Details {error}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}", - result.message - ), - None if mcp_validation.has_invalid_servers() => format!( - "MCP validation\n Status warn\n Summary {} MCP server entries are invalid; reporting plugins with valid MCP siblings only\n Hint Inspect `claw mcp list --output-format json` invalid_servers and fix each rejected mcpServers entry.\n\n{}", - mcp_validation.invalid_count(), - result.message - ), - None => result.message, - }; - PluginsCommandPayload { - message, - reload_runtime: result.reload_runtime, - status, - config_load_error, - mcp_validation, - plugins: report.summaries().iter().map(plugin_summary_json).collect(), - load_failures: failures.iter().map(plugin_load_failure_json).collect(), + if cwd.starts_with(scope_path) { + return "ancestor"; } + "workspace" } -fn build_runtime_plugin_state() -> Result> { - let cwd = env::current_dir()?; - let loader = ConfigLoader::default_for(&cwd); - let runtime_config = loader.load()?; - build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) -} - -fn build_runtime_plugin_state_with_loader( - cwd: &Path, - loader: &ConfigLoader, - runtime_config: &runtime::RuntimeConfig, -) -> Result> { - let plugin_manager = build_plugin_manager(cwd, loader, runtime_config); - let plugin_registry = plugin_manager.plugin_registry()?; - let plugin_hook_config = - runtime_hook_config_from_plugin_hooks(plugin_registry.aggregated_hooks()?); - let feature_config = runtime_config - .feature_config() - .clone() - .with_hooks(runtime_config.hooks().merged(&plugin_hook_config)); - let (mcp_state, runtime_tools) = build_runtime_mcp_state(runtime_config)?; - let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)? - .with_runtime_tools(runtime_tools)?; - Ok(RuntimePluginState { - feature_config, - tool_registry, - plugin_registry, - mcp_state, - }) +fn memory_files_json(files: &[MemoryFileSummary]) -> Vec { + files.iter().map(MemoryFileSummary::json_value).collect() } -fn build_plugin_manager( +fn unloaded_memory_candidates( 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() + project_root: Option<&Path>, + files: &[MemoryFileSummary], +) -> Vec { + let mut loaded = files .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) -} + .map(|file| PathBuf::from(&file.path)) + .collect::>(); + loaded.sort(); -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) + let boundary = project_root.unwrap_or(cwd); + let mut missing = Vec::new(); + let mut cursor = Some(cwd); + while let Some(dir) = cursor { + for name in ["CLAW.md", "AGENTS.md"] { + let candidate = dir.join(name); + if candidate.is_file() && !loaded.iter().any(|path| path == &candidate) { + missing.push(candidate.display().to_string()); + } + } + if dir == boundary { + break; + } + cursor = dir.parent(); } -} - -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, - ) + missing.sort(); + missing.dedup(); + missing } #[derive(Debug, Clone, PartialEq, Eq)] -struct InternalPromptProgressState { - command_label: &'static str, - task_label: String, - step: usize, - phase: String, - detail: Option, - saw_final_text: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InternalPromptProgressEvent { - Started, - Update, - Heartbeat, - Complete, - Failed, -} - -#[derive(Debug)] -struct InternalPromptProgressShared { - state: Mutex, - output_lock: Mutex<()>, - started_at: Instant, -} - -#[derive(Debug, Clone)] -struct InternalPromptProgressReporter { - shared: Arc, -} - -#[derive(Debug)] -struct InternalPromptProgressRun { - reporter: InternalPromptProgressReporter, - heartbeat_stop: Option>, - heartbeat_handle: Option>, +struct BinaryProvenance { + git_sha: Option, + git_sha_short: Option, + is_dirty: bool, + branch: Option, + commit_date: String, + commit_timestamp: i64, + rustc_version: String, + target: Option, + build_date: String, + executable_path: Option, + workspace_git_sha: Option, + workspace_match: Option, + hint: Option, } -impl InternalPromptProgressReporter { - fn ultraplan(task: &str) -> Self { - Self { - shared: Arc::new(InternalPromptProgressShared { - state: Mutex::new(InternalPromptProgressState { - command_label: "Ultraplan", - task_label: task.to_string(), - step: 0, - phase: "planning started".to_string(), - detail: Some(format!("task: {task}")), - saw_final_text: false, - }), - output_lock: Mutex::new(()), - started_at: Instant::now(), - }), +impl BinaryProvenance { + fn status(&self) -> &'static str { + if self.git_sha.is_some() { + "known" + } else { + "unknown" } } - fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { - let snapshot = self.snapshot(); - let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); - self.write_line(&line); + fn json_value(&self) -> serde_json::Value { + json!({ + "status": self.status(), + "git_sha": self.git_sha, + "git_sha_short": self.git_sha_short, + "is_dirty": self.is_dirty, + "branch": self.branch, + "commit_date": self.commit_date, + "commit_timestamp": self.commit_timestamp, + "rustc_version": self.rustc_version, + "target": self.target, + "build_date": self.build_date, + "executable_path": self.executable_path, + "workspace_git_sha": self.workspace_git_sha, + "workspace_match": self.workspace_match, + "hint": self.hint, + }) } +} - fn mark_model_phase(&self) { - let snapshot = { - let mut state = self - .shared - .state - .lock() - .expect("internal prompt progress state poisoned"); - state.step += 1; - state.phase = if state.step == 1 { - "analyzing request".to_string() - } else { - "reviewing findings".to_string() - }; - state.detail = Some(format!("task: {}", state.task_label)); - state.clone() - }; - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Update, - &snapshot, - self.elapsed(), - None, - )); +fn known_build_metadata(value: Option<&str>) -> Option { + let value = value?.trim(); + if value.is_empty() || value == "unknown" { + None + } else { + Some(value.to_string()) } +} - fn mark_tool_phase(&self, name: &str, input: &str) { - let detail = describe_tool_progress(name, input); - let snapshot = { - let mut state = self - .shared - .state - .lock() - .expect("internal prompt progress state poisoned"); - state.step += 1; - state.phase = format!("running {name}"); - state.detail = Some(detail); - state.clone() - }; - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Update, - &snapshot, - self.elapsed(), - None, - )); - } +fn parse_build_bool(value: Option<&str>) -> bool { + value + .map(str::trim) + .is_some_and(|value| value.eq_ignore_ascii_case("true") || value == "1") +} - fn mark_text_phase(&self, text: &str) { - let trimmed = text.trim(); - if trimmed.is_empty() { - return; - } - let detail = truncate_for_summary(first_visible_line(trimmed), 120); - let snapshot = { - let mut state = self - .shared - .state - .lock() - .expect("internal prompt progress state poisoned"); - if state.saw_final_text { - return; - } - state.saw_final_text = true; - state.step += 1; - state.phase = "drafting final plan".to_string(); - state.detail = (!detail.is_empty()).then_some(detail); - state.clone() - }; - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Update, - &snapshot, - self.elapsed(), - None, - )); +fn parse_build_timestamp(value: Option<&str>) -> i64 { + value + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0) +} + +fn binary_provenance_for(cwd: Option<&Path>) -> BinaryProvenance { + let git_sha = known_build_metadata(GIT_SHA); + let git_sha_short = known_build_metadata(GIT_SHA_SHORT).or_else(|| { + git_sha + .as_ref() + .map(|sha| sha.chars().take(12).collect::()) + }); + let target = known_build_metadata(BUILD_TARGET); + let workspace_git_sha = cwd.and_then(|cwd| { + run_git_capture_in(cwd, &["rev-parse", "HEAD"]) + .map(|sha| sha.trim().to_string()) + .filter(|sha| !sha.is_empty()) + }); + let workspace_match = git_sha + .as_deref() + .zip(workspace_git_sha.as_deref()) + .map(|(binary, workspace)| binary == workspace); + let hint = if git_sha.is_none() { + Some( + "Build metadata did not include a git SHA; rebuild from a git checkout before filing provenance-sensitive dogfood reports." + .to_string(), + ) + } else if workspace_match == Some(false) { + Some( + "The running binary was built from a different commit than the current workspace HEAD; rebuild or switch binaries before attributing behavior to this checkout." + .to_string(), + ) + } else { + None + }; + BinaryProvenance { + git_sha, + git_sha_short, + is_dirty: parse_build_bool(GIT_DIRTY), + branch: known_build_metadata(GIT_BRANCH), + commit_date: known_build_metadata(GIT_COMMIT_DATE).unwrap_or_else(|| "unknown".to_string()), + commit_timestamp: parse_build_timestamp(GIT_COMMIT_TIMESTAMP), + rustc_version: known_build_metadata(RUSTC_VERSION).unwrap_or_else(|| "unknown".to_string()), + target, + build_date: DEFAULT_DATE.to_string(), + executable_path: env::current_exe() + .ok() + .map(|path| path.display().to_string()), + workspace_git_sha, + workspace_match, + hint, } +} - fn emit_heartbeat(&self) { - let snapshot = self.snapshot(); - self.write_line(&format_internal_prompt_progress_line( - InternalPromptProgressEvent::Heartbeat, - &snapshot, - self.elapsed(), - None, - )); - } +/// #89: mid-operation git states detected from branch header in `git status --short --branch`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum GitOperation { + #[default] + None, + Rebase, + Merge, + CherryPick, + Bisect, +} - fn snapshot(&self) -> InternalPromptProgressState { - self.shared - .state - .lock() - .expect("internal prompt progress state poisoned") - .clone() +impl GitOperation { + fn as_str(self) -> &'static str { + match self { + Self::None => "", + Self::Rebase => "rebase-in-progress", + Self::Merge => "merge-in-progress", + Self::CherryPick => "cherry-pick-in-progress", + Self::Bisect => "bisect-in-progress", + } } +} - fn elapsed(&self) -> Duration { - self.shared.started_at.elapsed() - } +fn load_session_reference_excluding( + reference: &str, + exclude_id: Option<&str>, +) -> Result<(SessionHandle, Session), Box> { + let store = current_session_store()?; + let loaded = store + .load_session_excluding(reference, exclude_id) + .map_err(|e| Box::new(e) as Box)?; + Ok(( + SessionHandle { + id: loaded.handle.id, + path: loaded.handle.path, + }, + loaded.session, + )) +} - fn write_line(&self, line: &str) { - let _guard = self - .shared - .output_lock - .lock() - .expect("internal prompt progress output lock poisoned"); - let mut stdout = io::stdout(); - let _ = writeln!(stdout, "{line}"); - let _ = stdout.flush(); +/// #449: credentials-free session list that works without API keys. +/// `claw session list --output-format json` should work in CI/offline. +fn run_session_list(output_format: CliOutputFormat) -> Result<(), Box> { + let sessions = list_managed_sessions().unwrap_or_default(); + let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); + let session_details = session_details_json(&sessions); + match output_format { + CliOutputFormat::Text => { + let text = render_session_list("").unwrap_or_else(|e| format!("error: {e}")); + println!("{text}"); + } + CliOutputFormat::Json => { + println!( + "{}", + serde_json::json!({ + "kind": "sessions", + "status": "ok", + "action": "list", + "sessions": session_ids, + "session_details": session_details, + "active": serde_json::Value::Null, + }) + ); + } } + Ok(()) } -impl InternalPromptProgressRun { - fn start_ultraplan(task: &str) -> Self { - let reporter = InternalPromptProgressReporter::ultraplan(task); - reporter.emit(InternalPromptProgressEvent::Started, None); - - let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); - let heartbeat_reporter = reporter.clone(); - let heartbeat_handle = thread::spawn(move || loop { - match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { - Ok(()) | Err(RecvTimeoutError::Disconnected) => break, - Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), +/// #421: Strip macOS `/private` symlink prefix from paths so that +/// `status`, `doctor`, and `mcp list` JSON output matches the +/// user-visible invocation cwd instead of the canonicalized path. +fn friendly_cwd(path: PathBuf) -> PathBuf { + #[cfg(target_os = "macos")] + { + if let Ok(stripped) = path.strip_prefix("/private") { + if stripped.is_absolute() { + return stripped.to_path_buf(); } - }); - - Self { - reporter, - heartbeat_stop: Some(heartbeat_stop), - heartbeat_handle: Some(heartbeat_handle), } } + path +} - fn reporter(&self) -> InternalPromptProgressReporter { - self.reporter.clone() +fn print_models( + action: Option<&str>, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let help_requested = action.is_some_and(|value| matches!(value, "help" | "--help" | "-h")); + if help_requested { + return print_help_topic(LocalHelpTopic::Model, output_format); } - - fn finish_success(&mut self) { - self.stop_heartbeat(); - self.reporter - .emit(InternalPromptProgressEvent::Complete, None); + if let Some(action) = action { + return Err(format!( + "unsupported_models_action: unsupported models action: {action}.\nUsage: claw models [help] [--output-format json]" + ) + .into()); } - fn finish_failure(&mut self, error: &str) { - self.stop_heartbeat(); - self.reporter - .emit(InternalPromptProgressEvent::Failed, Some(error)); - } + let configured_model = config_model_for_current_dir(); + let resolved_config_model = configured_model + .as_deref() + .map(resolve_model_alias_with_config); - fn stop_heartbeat(&mut self) { - if let Some(sender) = self.heartbeat_stop.take() { - let _ = sender.send(()); + match output_format { + CliOutputFormat::Text => { + println!("Models"); + println!(" Default {DEFAULT_MODEL}"); + println!(" Built-in aliases opus, sonnet, haiku"); + if let Some(raw) = configured_model.as_deref() { + println!( + " Config model {raw}{}", + resolved_config_model + .as_deref() + .filter(|resolved| *resolved != raw) + .map(|resolved| format!(" -> {resolved}")) + .unwrap_or_default() + ); + } else { + println!(" Config model "); + } + println!(" Usage claw --model prompt "); } - if let Some(handle) = self.heartbeat_handle.take() { - let _ = handle.join(); + CliOutputFormat::Json => { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "models", + "action": "list", + "status": "ok", + "default_model": DEFAULT_MODEL, + "aliases": [ + {"name": "opus", "model": resolve_model_alias("opus")}, + {"name": "sonnet", "model": resolve_model_alias("sonnet")}, + {"name": "haiku", "model": resolve_model_alias("haiku")} + ], + "configured_model": configured_model, + "resolved_configured_model": resolved_config_model, + "local_only": true, + "requires_credentials": false, + "requires_provider_request": false, + "message": "Use --model or configure a model in claw settings." + }))? + ); } } + Ok(()) } -impl Drop for InternalPromptProgressRun { - fn drop(&mut self) { - self.stop_heartbeat(); - } -} - -fn format_internal_prompt_progress_line( - event: InternalPromptProgressEvent, - snapshot: &InternalPromptProgressState, - elapsed: Duration, - error: Option<&str>, -) -> String { - let elapsed_seconds = elapsed.as_secs(); - let step_label = if snapshot.step == 0 { - "current step pending".to_string() - } else { - format!("current step {}", snapshot.step) - }; - let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)]; - if let Some(detail) = snapshot - .detail - .as_deref() - .filter(|detail| !detail.is_empty()) - { - status_bits.push(detail.to_string()); - } - let status = status_bits.join(" · "); - match event { - InternalPromptProgressEvent::Started => { - format!( - "🧭 {} status · planning started · {status}", - snapshot.command_label - ) - } - InternalPromptProgressEvent::Update => { - format!("… {} status · {status}", snapshot.command_label) - } - InternalPromptProgressEvent::Heartbeat => format!( - "… {} heartbeat · {elapsed_seconds}s elapsed · {status}", - snapshot.command_label - ), - InternalPromptProgressEvent::Complete => format!( - "✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total", - snapshot.command_label, snapshot.step - ), - InternalPromptProgressEvent::Failed => format!( - "✘ {} status · failed · {elapsed_seconds}s elapsed · {}", - snapshot.command_label, - error.unwrap_or("unknown error") - ), - } +fn render_doctor_help_json() -> serde_json::Value { + json!({ + "kind": "help", + "action": "help", + "status": "ok", + "topic": "doctor", + "command": "doctor", + "schema_version": "1.0", + "usage": "claw doctor [--output-format ]", + "purpose": "diagnose local auth, config, workspace memory, permissions, sandbox, boot preflight, and build metadata", + "formats": ["text", "json"], + "local_only": true, + "requires_credentials": false, + "requires_provider_request": false, + "requires_session_resume": false, + "mutates_workspace": false, + "output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks", "allowed_tools"], + "check_names": ["auth", "config", "mcp validation", "hook validation", "install source", "workspace", "memory", "boot preflight", "sandbox", "permissions", "system"], + "status_values": ["ok", "warn", "fail"], + "options": [ + { + "name": "--output-format", + "value": "", + "values": ["text", "json"], + "default": "text", + "description": "format for the doctor report or help envelope" + }, + { + "name": "--help", + "aliases": ["-h"], + "description": "show help for the doctor command without running diagnostics" + } + ], + "related": ["/doctor", "claw --resume latest /doctor"], + "message": render_help_topic(LocalHelpTopic::Doctor), + }) } -fn describe_tool_progress(name: &str, input: &str) -> String { - let parsed: serde_json::Value = - serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); - match name { - "bash" | "Bash" => { - let command = parsed - .get("command") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - if command.is_empty() { - "running shell command".to_string() - } else { - format!("command {}", truncate_for_summary(command.trim(), 100)) +/// #683-#692: extract structured metadata from help prose +fn extract_help_metadata( + topic: LocalHelpTopic, +) -> ( + Option, // usage + Option, // purpose + Option, // output description + Option>, // formats + Option>, // related + Option>, // aliases + bool, // local_only + bool, // requires_credentials +) { + let text = render_help_topic(topic); + let mut usage = None; + let mut purpose = None; + let mut output_desc = None; + let formats = Some(vec!["text".to_string(), "json".to_string()]); + let mut related = None; + let mut aliases = None; + let local_only = matches!( + topic, + LocalHelpTopic::Status + | LocalHelpTopic::Sandbox + | LocalHelpTopic::Doctor + | LocalHelpTopic::Version + | LocalHelpTopic::State + | LocalHelpTopic::Init + | LocalHelpTopic::Export + | LocalHelpTopic::SystemPrompt + | LocalHelpTopic::DumpManifests + | LocalHelpTopic::BootstrapPlan + ); + for line in text.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("Usage") { + let value = rest.trim(); + if !value.is_empty() { + usage = Some(value.to_string()); } - } - "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), - "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), - "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)), - "glob_search" | "Glob" => { - let pattern = parsed - .get("pattern") - .and_then(|value| value.as_str()) - .unwrap_or("?"); - let scope = parsed - .get("path") - .and_then(|value| value.as_str()) - .unwrap_or("."); - format!("glob `{pattern}` in {scope}") - } - "grep_search" | "Grep" => { - let pattern = parsed - .get("pattern") - .and_then(|value| value.as_str()) - .unwrap_or("?"); - let scope = parsed - .get("path") - .and_then(|value| value.as_str()) - .unwrap_or("."); - format!("grep `{pattern}` in {scope}") - } - "web_search" | "WebSearch" => parsed - .get("query") - .and_then(|value| value.as_str()) - .map_or_else( - || "running web search".to_string(), - |query| format!("query {}", truncate_for_summary(query, 100)), - ), - _ => { - let summary = summarize_tool_payload(input); - if summary.is_empty() { - format!("running {name}") - } else { - format!("{name}: {summary}") + } else if let Some(rest) = trimmed.strip_prefix("Purpose") { + purpose = Some(rest.trim().to_string()); + } else if let Some(rest) = trimmed.strip_prefix("Output") { + output_desc = Some(rest.trim().to_string()); + } else if let Some(rest) = trimmed.strip_prefix("Aliases") { + let parts: Vec = rest + .split('·') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if !parts.is_empty() { + aliases = Some(parts); + } + } else if let Some(rest) = trimmed.strip_prefix("Related") { + let parts: Vec = rest + .split('·') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if !parts.is_empty() { + related = Some(parts); } } } -} - -#[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, + ( + usage, + purpose, + output_desc, + formats, + related, + aliases, + local_only, + !local_only, ) } -#[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(), +fn config_file_report_json(file: &ConfigFileReport) -> serde_json::Value { + let source = match file.entry.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + let mut object = serde_json::Map::new(); + object.insert( + "path".to_string(), + serde_json::Value::String(file.entry.path.display().to_string()), + ); + object.insert( + "source".to_string(), + serde_json::Value::String(source.to_string()), + ); + object.insert("loaded".to_string(), serde_json::Value::Bool(file.loaded)); + object.insert( + "precedence_rank".to_string(), + serde_json::Value::Number(serde_json::Number::from(file.precedence_rank)), + ); + object.insert( + "wins_for_keys".to_string(), + serde_json::Value::Array( + file.wins_for_keys + .iter() + .cloned() + .map(serde_json::Value::String) + .collect(), + ), + ); + object.insert( + "shadowed_keys".to_string(), + serde_json::Value::Array( + file.shadowed_keys + .iter() + .cloned() + .map(serde_json::Value::String) + .collect(), ), - policy, - system_prompt, - &feature_config, ); - if emit_output { - runtime = runtime.with_hook_progress_reporter(Box::new(CliHookProgressReporter)); + object.insert( + "status".to_string(), + serde_json::Value::String(file.status.as_str().to_string()), + ); + if let Some(reason) = &file.reason { + object.insert( + "reason".to_string(), + serde_json::Value::String(reason.clone()), + ); + object.insert( + "skip_reason".to_string(), + serde_json::Value::String(reason.clone()), + ); } - Ok(BuiltRuntime::new(runtime, plugin_registry, mcp_state)) -} - -struct CliHookProgressReporter; - -impl runtime::HookProgressReporter for CliHookProgressReporter { - fn on_event(&mut self, event: &runtime::HookProgressEvent) { - match event { - runtime::HookProgressEvent::Started { - event, - tool_name, - command, - } => eprintln!( - "[hook {event_name}] {tool_name}: {command}", - event_name = event.as_str() - ), - runtime::HookProgressEvent::Completed { - event, - tool_name, - command, - } => eprintln!( - "[hook done {event_name}] {tool_name}: {command}", - event_name = event.as_str() - ), - runtime::HookProgressEvent::Cancelled { - event, - tool_name, - command, - } => eprintln!( - "[hook cancelled {event_name}] {tool_name}: {command}", - event_name = event.as_str() - ), - } + if let Some(detail) = &file.detail { + object.insert( + "detail".to_string(), + serde_json::Value::String(detail.clone()), + ); } + serde_json::Value::Object(object) } -struct CliPermissionPrompter { - current_mode: PermissionMode, -} +const DEFAULT_HISTORY_LIMIT: usize = 20; -impl CliPermissionPrompter { - fn new(current_mode: PermissionMode) -> Self { - Self { current_mode } +fn validate_export_output_path(path: Option<&Path>) -> Result<(), InvalidOutputPathError> { + let Some(path) = path else { + return Ok(()); + }; + let raw = path.to_string_lossy(); + if raw.trim().is_empty() { + return Err(InvalidOutputPathError::new( + raw.to_string(), + InvalidOutputPathReason::Empty, + )); } -} - -impl runtime::PermissionPrompter for CliPermissionPrompter { - fn decide( - &mut self, - request: &runtime::PermissionRequest, - ) -> runtime::PermissionPromptDecision { - println!(); - println!("Permission approval required"); - println!(" Tool {}", request.tool_name); - println!(" Current mode {}", self.current_mode.as_str()); - println!(" Required mode {}", request.required_mode.as_str()); - if let Some(reason) = &request.reason { - println!(" Reason {reason}"); - } - println!(" Input {}", request.input); - print!("Approve this tool call? [y/N]: "); - let _ = io::stdout().flush(); - - let mut response = String::new(); - match io::stdin().read_line(&mut response) { - Ok(_) => { - let normalized = response.trim().to_ascii_lowercase(); - if matches!(normalized.as_str(), "y" | "yes") { - runtime::PermissionPromptDecision::Allow - } else { - runtime::PermissionPromptDecision::Deny { - reason: format!( - "tool '{}' denied by user approval prompt", - request.tool_name - ), - } - } - } - Err(error) => runtime::PermissionPromptDecision::Deny { - reason: format!("permission approval failed: {error}"), - }, - } + if matches!(fs::metadata(path), Ok(metadata) if metadata.is_dir()) { + return Err(InvalidOutputPathError::new( + raw.to_string(), + InvalidOutputPathReason::PathIsDirectory, + )); } -} - -// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct -// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI, -// OpenAI, or DashScope at construction time based on -// `detect_provider_kind(&model)`. The struct name is kept to avoid -// churning `BuiltRuntime` and every Deref/DerefMut site that references -// it. See ROADMAP #29 for the provider-dispatch routing fix. -struct AnthropicRuntimeClient { - runtime: tokio::runtime::Runtime, - client: ApiProviderClient, - session_id: String, - model: String, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - tool_registry: GlobalToolRegistry, - progress_reporter: Option, - reasoning_effort: Option, -} - -impl AnthropicRuntimeClient { - fn new( - session_id: &str, - model: String, - enable_tools: bool, - emit_output: bool, - allowed_tools: Option, - tool_registry: GlobalToolRegistry, - progress_reporter: Option, - ) -> Result> { - // Dispatch to the correct provider at construction time. - // `ApiProviderClient` (exposed by the api crate as - // `ProviderClient`) is an enum over Anthropic / xAI / OpenAI - // variants, where xAI and OpenAI both use the OpenAI-compat - // wire format under the hood. We consult - // `detect_provider_kind(&resolved_model)` so model-name prefix - // routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over - // env-var presence. - // - // For Anthropic we build the client directly instead of going - // through `ApiProviderClient::from_model_with_anthropic_auth` - // so we can explicitly apply `api::read_base_url()` — that - // reads `ANTHROPIC_BASE_URL` and is required for the local - // mock-server test harness - // (`crates/rusty-claude-cli/tests/compact_output.rs`) to point - // claw at its fake Anthropic endpoint. We also attach a - // session-scoped prompt cache on the Anthropic path; the - // prompt cache is Anthropic-only so non-Anthropic variants - // skip it. - let resolved_model = api::resolve_model_alias(&model); - let client = match detect_provider_kind(&resolved_model) { - ProviderKind::Anthropic => { - let auth = resolve_cli_auth_source()?; - let inner = AnthropicClient::from_auth(auth) - .with_base_url(api::read_base_url()) - .with_prompt_cache(PromptCache::new(session_id)); - ApiProviderClient::Anthropic(inner) - } - ProviderKind::Xai | ProviderKind::OpenAi => { - // The api crate's `ProviderClient::from_model_with_anthropic_auth` - // with `None` for the anthropic auth routes via - // `detect_provider_kind` and builds an - // `OpenAiCompatClient::from_env` with the matching - // `OpenAiCompatConfig` (openai / xai / dashscope). - // That reads the correct API-key env var and BASE_URL - // override internally, so this one call covers OpenAI, - // OpenRouter, xAI, DashScope, Ollama, and any other - // OpenAI-compat endpoint users configure via - // `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`. - ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + match fs::metadata(parent) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + return Err(InvalidOutputPathError::new( + raw.to_string(), + InvalidOutputPathReason::ParentNotADirectory, + )); } - }; - Ok(Self { - runtime: tokio::runtime::Runtime::new()?, - client, - session_id: session_id.to_string(), - model, - enable_tools, - emit_output, - allowed_tools, - tool_registry, - progress_reporter, - reasoning_effort: None, - }) - } - - fn set_reasoning_effort(&mut self, effort: Option) { - self.reasoning_effort = effort; - } -} - -fn resolve_cli_auth_source() -> Result> { - Ok(resolve_cli_auth_source_for_cwd()?) -} - -#[allow(clippy::result_large_err)] -fn resolve_cli_auth_source_for_cwd() -> Result { - resolve_startup_auth_source(|| Ok(None)) -} - -impl ApiClient for AnthropicRuntimeClient { - #[allow(clippy::too_many_lines)] - fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { - if let Some(progress_reporter) = &self.progress_reporter { - progress_reporter.mark_model_phase(); - } - let is_post_tool = request_ends_with_tool_result(&request); - let message_request = MessageRequest { - model: self.model.clone(), - max_tokens: max_tokens_for_model(&self.model), - messages: convert_messages(&request.messages), - system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), - tools: self - .enable_tools - .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())), - tool_choice: self.enable_tools.then_some(ToolChoice::Auto), - stream: true, - reasoning_effort: self.reasoning_effort.clone(), - ..Default::default() - }; - - self.runtime.block_on(async { - // When resuming after tool execution, apply a stall timeout on the - // first stream event. If the model does not respond within the - // deadline we drop the stalled connection and re-send the request as - // a continuation nudge (one retry only). - let max_attempts: usize = if is_post_tool { 2 } else { 1 }; - - for attempt in 1..=max_attempts { - let result = self - .consume_stream(&message_request, is_post_tool && attempt == 1) - .await; - match result { - Ok(events) => return Ok(events), - Err(error) - if error.to_string().contains("post-tool stall") - && attempt < max_attempts => - { - // Stalled after tool completion — nudge the model by - // re-sending the same request. - } - Err(error) => return Err(error), - } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(InvalidOutputPathError::new( + raw.to_string(), + InvalidOutputPathReason::ParentNotFound, + )); } - - Err(RuntimeError::new("post-tool continuation nudge exhausted")) - }) - } -} - -impl AnthropicRuntimeClient { - /// Consume a single streaming response, optionally applying a stall - /// timeout on the first event for post-tool continuations. - #[allow(clippy::too_many_lines)] - async fn consume_stream( - &self, - message_request: &MessageRequest, - apply_stall_timeout: bool, - ) -> Result, RuntimeError> { - let mut stream = self - .client - .stream_message(message_request) - .await - .map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })?; - let mut stdout = io::stdout(); - let mut sink = io::sink(); - let out: &mut dyn Write = if self.emit_output { - &mut stdout - } else { - &mut sink - }; - let renderer = TerminalRenderer::new(); - let mut markdown_stream = MarkdownStreamState::default(); - let mut events = Vec::new(); - let mut pending_tool: Option<(String, String, String)> = None; - // 累积 reasoning_content 到 Thinking 块(修复 DeepSeek V4 reasoning_content 协议 bug) - let mut pending_thinking: Option<(String, Option)> = None; - let mut block_has_thinking_summary = false; - let mut saw_stop = false; - let mut received_any_event = false; - - loop { - let next = if apply_stall_timeout && !received_any_event { - match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await { - Ok(inner) => inner.map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })?, - Err(_elapsed) => { - return Err(RuntimeError::new( - "post-tool stall: model did not respond within timeout", - )); - } - } - } else { - stream.next_event().await.map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })? - }; - - let Some(event) = next else { - break; - }; - received_any_event = true; - - match event { - ApiStreamEvent::MessageStart(start) => { - for block in start.message.content { - push_output_block( - block, - out, - &mut events, - &mut pending_tool, - true, - &mut block_has_thinking_summary, - )?; - } - } - ApiStreamEvent::ContentBlockStart(start) => { - // 特判 Thinking 块:初始化 pending_thinking(用于累积后续 ThinkingDelta) - if let OutputContentBlock::Thinking { - thinking, - signature, - } = &start.content_block - { - pending_thinking = Some((thinking.clone(), signature.clone())); - } - push_output_block( - start.content_block, - out, - &mut events, - &mut pending_tool, - true, - &mut block_has_thinking_summary, - )?; - } - ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { - ContentBlockDelta::TextDelta { text } => { - if !text.is_empty() { - if let Some(progress_reporter) = &self.progress_reporter { - progress_reporter.mark_text_phase(&text); - } - if let Some(rendered) = markdown_stream.push(&renderer, &text) { - write!(out, "{rendered}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - } - events.push(AssistantEvent::TextDelta(text)); - } - } - ContentBlockDelta::InputJsonDelta { partial_json } => { - if let Some((_, _, input)) = &mut pending_tool { - input.push_str(&partial_json); - } - } - ContentBlockDelta::ThinkingDelta { thinking } => { - if !block_has_thinking_summary { - render_thinking_block_summary(out, None, false)?; - block_has_thinking_summary = true; - } - // 累积 thinking 文本到 pending_thinking(让 session 持久化能拿到) - if let Some((t, _)) = &mut pending_thinking { - t.push_str(&thinking); - } - } - ContentBlockDelta::SignatureDelta { signature } => { - // 累积 signature 到 pending_thinking - if let Some((_, sig)) = &mut pending_thinking { - sig.get_or_insert_with(String::new).push_str(&signature); - } - } - }, - ApiStreamEvent::ContentBlockStop(_) => { - block_has_thinking_summary = false; - if let Some(rendered) = markdown_stream.flush(&renderer) { - write!(out, "{rendered}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - } - // 把累积的 thinking 转成 AssistantEvent::Thinking(让 build_assistant_message 写入 session) - if let Some((thinking, signature)) = pending_thinking.take() { - events.push(AssistantEvent::Thinking { - thinking, - signature, - }); - } - if let Some((id, name, input)) = pending_tool.take() { - if let Some(progress_reporter) = &self.progress_reporter { - progress_reporter.mark_tool_phase(&name, &input); - } - // Display tool call now that input is fully accumulated - writeln!(out, "\n{}", format_tool_call_start(&name, &input)) - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - events.push(AssistantEvent::ToolUse { id, name, input }); - } - } - ApiStreamEvent::MessageDelta(delta) => { - events.push(AssistantEvent::Usage(delta.usage.token_usage())); - } - ApiStreamEvent::MessageStop(_) => { - saw_stop = true; - if let Some(rendered) = markdown_stream.flush(&renderer) { - write!(out, "{rendered}") - .and_then(|()| out.flush()) - .map_err(|error| RuntimeError::new(error.to_string()))?; - } - events.push(AssistantEvent::MessageStop); - } + Err(_) => { + return Err(InvalidOutputPathError::new( + raw.to_string(), + InvalidOutputPathReason::ParentNotFound, + )); } } - - push_prompt_cache_record(&self.client, &mut events); - - if !saw_stop - && events.iter().any(|event| { - matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty()) - || matches!(event, AssistantEvent::ToolUse { .. }) - }) - { - events.push(AssistantEvent::MessageStop); - } - - if events - .iter() - .any(|event| matches!(event, AssistantEvent::MessageStop)) - { - return Ok(events); - } - - let response = self - .client - .send_message(&MessageRequest { - stream: false, - ..message_request.clone() - }) - .await - .map_err(|error| { - RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) - })?; - let mut events = response_to_events(response, out)?; - push_prompt_cache_record(&self.client, &mut events); - Ok(events) } + Ok(()) } -/// Returns `true` when the conversation ends with a tool-result message, -/// meaning the model is expected to continue after tool execution. -fn request_ends_with_tool_result(request: &ApiRequest) -> bool { - request - .messages - .last() - .is_some_and(|message| message.role == MessageRole::Tool) -} +const SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT: usize = 280; /// Extract the server-reported context window size from an error message. /// Returns `None` if no window size can be parsed. The server must @@ -12938,169 +2132,6 @@ fn extract_context_window_tokens_from_error(error_str: &str) -> Option { None } -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() - } -} - -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") -} - -fn final_assistant_text(summary: &runtime::TurnSummary) -> String { - summary - .assistant_messages - .last() - .map(|message| { - message - .blocks - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("") - }) - .unwrap_or_default() -} - -fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec { - summary - .assistant_messages - .iter() - .flat_map(|message| message.blocks.iter()) - .filter_map(|block| match block { - ContentBlock::ToolUse { id, name, input } => Some(json!({ - "id": id, - "name": name, - "input": input, - })), - _ => None, - }) - .collect() -} - -fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec { - summary - .tool_results - .iter() - .flat_map(|message| message.blocks.iter()) - .filter_map(|block| match block { - ContentBlock::ToolResult { - tool_use_id, - tool_name, - output, - is_error, - } => Some(json!({ - "tool_use_id": tool_use_id, - "tool_name": tool_name, - "output": output, - "is_error": is_error, - })), - _ => None, - }) - .collect() -} - -fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec { - summary - .prompt_cache_events - .iter() - .map(|event| { - json!({ - "unexpected": event.unexpected, - "reason": event.reason, - "previous_cache_read_input_tokens": event.previous_cache_read_input_tokens, - "current_cache_read_input_tokens": event.current_cache_read_input_tokens, - "token_drop": event.token_drop, - }) - }) - .collect() -} - /// Slash commands that are registered in the spec list but not yet implemented /// in this build. Used to filter both REPL completions and help output so the /// discovery surface only shows commands that actually work (ROADMAP #39). @@ -13217,1051 +2248,16 @@ const STUB_COMMANDS: &[&str] = &[ "agent", ]; -fn slash_command_completion_candidates_with_sessions( - model: &str, - active_session_id: Option<&str>, - recent_session_ids: Vec, -) -> Vec { - let mut completions = BTreeSet::new(); - - for spec in slash_command_specs() { - if STUB_COMMANDS.contains(&spec.name) { - continue; - } - completions.insert(format!("/{}", spec.name)); - for alias in spec.aliases { - if !STUB_COMMANDS.contains(alias) { - completions.insert(format!("/{alias}")); - } - } - } - - 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, - signature, - } => { - render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; - events.push(AssistantEvent::Thinking { - thinking, - signature, - }); - *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(&canonical_allowed_tool_name(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 permission_policy( - mode: PermissionMode, - feature_config: &runtime::RuntimeFeatureConfig, - tool_registry: &GlobalToolRegistry, -) -> Result { - Ok(tool_registry.permission_specs(None)?.into_iter().fold( - PermissionPolicy::new(mode).with_permission_rules(feature_config.permission_rules()), - |policy, (name, required_permission)| { - policy.with_tool_requirement(name, required_permission) - }, - )) -} +const READ_DISPLAY_MAX_LINES: usize = 80; -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() - .filter_map(|block| match block { - ContentBlock::Text { text } => { - Some(InputContentBlock::Text { text: text.clone() }) - } - ContentBlock::Thinking { - thinking, - signature, - } => { - // 保留 Thinking 块:OpenAI 兼容协议会把它转成 reasoning_content 字段 - // 回传给 DeepSeek V4(避免 400 "reasoning_content must be passed back" 错误) - Some(InputContentBlock::Thinking { - thinking: thinking.clone(), - signature: signature.clone(), - }) - } - ContentBlock::ToolUse { id, name, input } => Some(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, - .. - } => Some(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() -} +const READ_DISPLAY_MAX_CHARS: usize = 6_000; -#[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 [--stdin] [TEXT]" - )?; - writeln!( - out, - " Send one prompt and exit; reads stdin when TEXT is omitted" - )?; - writeln!( - out, - " claw [--model MODEL] [--output-format text|json] TEXT" - )?; - writeln!(out, " Shorthand non-interactive prompt mode")?; - writeln!( - out, - " Use `--` before TEXT when the prompt itself starts with '-' or '--'" - )?; - 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 (case-insensitive)" - )?; - writeln!( - out, - " CLAW_OUTPUT_FORMAT sets the default; flags override env" - )?; - writeln!( - out, - " Log env vars: CLAW_LOG or RUST_LOG" - )?; - writeln!( - out, - " --cwd PATH, -C PATH, --directory PATH Run as if launched from PATH" - )?; - 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-permissions Skip all permission checks" - )?; - writeln!( - out, - " --allowedTools TOOLS Restrict enabled tools by canonical snake_case name or alias" - )?; - writeln!(out, " Examples: read, glob, web_fetch, WebFetch; status JSON exposes aliases")?; - 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() - .filter(|spec| !STUB_COMMANDS.contains(&spec.name)) - .map(|spec| match spec.argument_hint { - Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), - None => format!("/{}", spec.name), - }) - .collect::>() - .join(", "); - writeln!(out, "Resume-safe commands: {resume_commands}")?; - writeln!(out)?; - writeln!(out, "Session shortcuts:")?; - writeln!( - out, - " REPL turns auto-save to .claw/sessions/.{PRIMARY_SESSION_EXTENSION}" - )?; - writeln!( - out, - " Use `{LATEST_SESSION_REFERENCE}` with --resume, /resume, or /session switch to target the newest saved session" - )?; - writeln!( - out, - " Use /session list in the REPL to browse managed sessions" - )?; - writeln!(out, "Examples:")?; - writeln!(out, " claw --model claude-opus \"summarize this repo\"")?; - writeln!( - out, - " claw --output-format json prompt \"explain src/main.rs\"" - )?; - writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?; - writeln!( - out, - " claw --allowedTools read,glob \"summarize Cargo.toml\"" - )?; - writeln!(out, " claw --resume {LATEST_SESSION_REFERENCE}")?; - writeln!( - out, - " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt" - )?; - writeln!(out, " claw agents")?; - writeln!(out, " claw mcp show my-server")?; - writeln!(out, " claw /skills")?; - writeln!(out, " claw doctor")?; - writeln!(out, " source of truth: {OFFICIAL_REPO_URL}")?; - writeln!( - out, - " do not run `{DEPRECATED_INSTALL_COMMAND}` — it installs a deprecated stub" - )?; - writeln!(out, " claw init")?; - writeln!(out, " claw export")?; - writeln!(out, " claw export conversation.md")?; - Ok(()) -} +const TOOL_OUTPUT_DISPLAY_MAX_LINES: usize = 60; -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 => { - // #325: include structured command list in top-level help JSON - let commands: Vec = commands::slash_command_specs() - .iter() - .map(|spec| { - serde_json::json!({ - "name": spec.name, - "summary": spec.summary, - "resume_supported": spec.resume_supported, - }) - }) - .collect(); - println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "help", - "action": "help", - "status": "ok", - "message": message, - "commands": commands, - "total_commands": commands.len(), - }))? - ); - } - } - Ok(()) -} +const TOOL_OUTPUT_DISPLAY_MAX_CHARS: usize = 4_000; #[cfg(test)] mod tests { diff --git a/rust/crates/rusty-claude-cli/src/model_provenance.rs b/rust/crates/rusty-claude-cli/src/model_provenance.rs new file mode 100644 index 0000000000..fcfd24a5db --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/model_provenance.rs @@ -0,0 +1,211 @@ +use crate::*; + +use api::{ + detect_provider_kind, model_family_identity_for, 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, PluginsCommandResult, SkillSlashDispatch, + SlashCommand, +}; +use init::initialize_repo; +use log::debug; +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, load_system_prompt_with_context, pricing_for_model, resolve_expected_base, + resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState, + CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile, + ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig, + McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, + PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, + RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::net::TcpListener; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, UNIX_EPOCH}; +use tools::{ + canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry, + RuntimeToolDefinition, ToolSearchOutput, +}; + +/// #148: Model provenance for `claw status` JSON/text output. Records where +/// the resolved model string came from so claws don't have to re-read argv +/// to audit whether their `--model` flag was honored vs falling back to env +/// or config or default. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ModelSource { + /// Explicit `--model` / `--model=` CLI flag. + Flag, + /// Runtime model environment variable (when no flag was passed). + Env, + /// `model` key in `.claw.json` / `.claw/settings.json` (when neither + /// flag nor env set it). + Config, + /// Compiled-in `DEFAULT_MODEL` fallback. + Default, +} + +impl ModelSource { + pub(crate) fn as_str(&self) -> &'static str { + match self { + ModelSource::Flag => "flag", + ModelSource::Env => "env", + ModelSource::Config => "config", + ModelSource::Default => "default", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ModelProvenance { + /// Resolved model string (after alias expansion). + pub(crate) resolved: String, + /// Raw user input before alias resolution. None when source is Default. + pub(crate) raw: Option, + /// Where the resolved model string originated. + pub(crate) source: ModelSource, + /// Alias-expanded target when `raw` differs from `resolved`. + pub(crate) alias_resolved_to: Option, + /// Environment variable that supplied the model, when source is Env. + pub(crate) env_var: Option, +} + +impl ModelProvenance { + pub(crate) fn default_fallback() -> Self { + Self { + resolved: DEFAULT_MODEL.to_string(), + raw: None, + source: ModelSource::Default, + alias_resolved_to: None, + env_var: None, + } + } + + pub(crate) fn from_flag(raw: &str, resolved: &str) -> Self { + Self::from_resolved(raw, resolved, ModelSource::Flag, None) + } + + pub(crate) fn from_raw(raw: &str, source: ModelSource, env_var: Option<&str>) -> Self { + let resolved = resolve_model_alias_with_config(raw); + Self::from_resolved(raw, &resolved, source, env_var) + } + + pub(crate) fn from_resolved( + raw: &str, + resolved: &str, + source: ModelSource, + env_var: Option<&str>, + ) -> Self { + let raw_trimmed = raw.trim(); + let alias_resolved_to = (raw_trimmed != resolved).then(|| resolved.to_string()); + Self { + resolved: resolved.to_string(), + raw: Some(raw.to_string()), + source, + alias_resolved_to, + env_var: env_var.map(str::to_string), + } + } + + pub(crate) fn from_env_or_config_or_default(cli_model: &str) -> Result { + // Only called when no --model flag was passed. Probe env first, + // then config, else fall back to default. Mirrors the logic in + // resolve_repl_model() but captures the source. + if cli_model != DEFAULT_MODEL { + let provenance = Self::from_resolved(cli_model, cli_model, ModelSource::Flag, None); + provenance.validate()?; + return Ok(provenance); + } + if let Some(env_model) = env_model_for_runtime() { + let provenance = + Self::from_raw(&env_model.value, ModelSource::Env, Some(env_model.name)); + provenance.validate()?; + return Ok(provenance); + } + if let Some(config_model) = config_model_for_current_dir() { + let provenance = Self::from_raw(&config_model, ModelSource::Config, None); + provenance.validate()?; + return Ok(provenance); + } + Ok(Self::default_fallback()) + } + + pub(crate) fn validate(&self) -> Result<(), String> { + validate_model_syntax(&self.resolved).map_err(|error| { + let source = match self.source { + ModelSource::Flag => "--model", + ModelSource::Env => self.env_var.as_deref().unwrap_or("environment"), + ModelSource::Config => "config model", + ModelSource::Default => "default model", + }; + if let Some(raw) = &self.raw { + format!( + "invalid_model: {source} model `{raw}` is invalid after alias resolution to `{}`.\n{error}", + self.resolved + ) + } else { + error + } + }) + } +} + +pub(crate) fn resolve_model_alias(model: &str) -> &str { + match model { + "opus" => "anthropic/claude-opus-4-7", + "sonnet" => "anthropic/claude-sonnet-4-6", + "haiku" => "anthropic/claude-haiku-4-5-20251213", + _ => model, + } +} + +/// Resolve a model name through user-defined config aliases first, then fall +/// back to the built-in alias table. This is the entry point used wherever a +/// user-supplied model string is about to be dispatched to a provider. +pub(crate) fn resolve_model_alias_with_config(model: &str) -> String { + let trimmed = model.trim(); + if let Some(resolved) = config_alias_for_current_dir(trimmed) { + return resolve_model_alias(&resolved).to_string(); + } + resolve_model_alias(trimmed).to_string() +} + +pub(crate) fn config_alias_for_current_dir(alias: &str) -> Option { + if alias.is_empty() { + return None; + } + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + let config = loader.load().ok()?; + config.aliases().get(alias).cloned() +} + +pub(crate) fn config_model_for_current_dir() -> Option { + let cwd = env::current_dir().ok()?; + let loader = ConfigLoader::default_for(&cwd); + loader.load().ok()?.model().map(ToOwned::to_owned) +} + +pub(crate) fn resolve_repl_model(cli_model: String) -> Result { + Ok(ModelProvenance::from_env_or_config_or_default(&cli_model)?.resolved) +} diff --git a/rust/crates/rusty-claude-cli/src/preflight.rs b/rust/crates/rusty-claude-cli/src/preflight.rs new file mode 100644 index 0000000000..12a6a0bf96 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/preflight.rs @@ -0,0 +1,3900 @@ +use crate::*; + +use api::{ + detect_provider_kind, model_family_identity_for, 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, PluginsCommandResult, SkillSlashDispatch, + SlashCommand, +}; +use init::initialize_repo; +use log::debug; +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, load_system_prompt_with_context, pricing_for_model, resolve_expected_base, + resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState, + CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile, + ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig, + McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, + PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, + RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::net::TcpListener; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, UNIX_EPOCH}; +use tools::{ + canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry, + RuntimeToolDefinition, ToolSearchOutput, +}; + +/// 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. +pub(crate) fn read_piped_stdin() -> Option { + if io::stdin().is_terminal() { + return None; + } + let mut buffer = String::new(); + if io::stdin().read_to_string(&mut buffer).is_err() { + return None; + } + if buffer.trim().is_empty() { + return None; + } + Some(buffer) +} + +pub(crate) fn plugin_command_json( + action: &str, + target: Option<&str>, + result: &commands::PluginsCommandResult, + report: &plugins::PluginRegistryReport, +) -> Value { + let failures = report.failures(); + json!({ + "kind": "plugin", + "action": action, + "target": target, + "status": if failures.is_empty() { "ok" } else { "degraded" }, + "message": result.message, + "reload_runtime": result.reload_runtime, + "plugins": report.summaries().iter().map(plugin_summary_json).collect::>(), + "load_failures": failures.iter().map(plugin_load_failure_json).collect::>(), + }) +} + +pub(crate) fn plugin_summary_json(plugin: &plugins::PluginSummary) -> Value { + json!({ + "id": &plugin.metadata.id, + "name": &plugin.metadata.name, + "version": &plugin.metadata.version, + "description": &plugin.metadata.description, + "kind": plugin.metadata.kind.to_string(), + "source": &plugin.metadata.source, + // #730: path parity with agents (#728) and skills (#729) + "path": plugin.metadata.root.as_ref().map(|p| p.display().to_string()), + "enabled": plugin.enabled, + "lifecycle_state": plugin.lifecycle_state(), + "lifecycle": { + "configured": !plugin.lifecycle.is_empty(), + "init": { + "configured": !plugin.lifecycle.init.is_empty(), + "command_count": plugin.lifecycle.init.len(), + }, + "shutdown": { + "configured": !plugin.lifecycle.shutdown.is_empty(), + "command_count": plugin.lifecycle.shutdown.len(), + }, + }, + }) +} + +pub(crate) fn plugin_load_failure_json(failure: &plugins::PluginLoadFailure) -> Value { + json!({ + "plugin_root": failure.plugin_root.display().to_string(), + "kind": failure.kind.to_string(), + "source": &failure.source, + "lifecycle_state": "load_failed", + "error": failure.error().to_string(), + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DiagnosticLevel { + Ok, + Warn, + Fail, +} + +impl DiagnosticLevel { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Warn => "warn", + Self::Fail => "fail", + } + } + + pub(crate) fn is_failure(self) -> bool { + matches!(self, Self::Fail) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DiagnosticCheck { + pub(crate) name: &'static str, + pub(crate) level: DiagnosticLevel, + pub(crate) summary: String, + pub(crate) details: Vec, + pub(crate) data: Map, + /// #778: stable remediation hint for warn/fail checks so automation can read + /// a structured field instead of parsing details_prose. + pub(crate) hint: Option, +} + +impl DiagnosticCheck { + pub(crate) fn new( + name: &'static str, + level: DiagnosticLevel, + summary: impl Into, + ) -> Self { + Self { + name, + level, + summary: summary.into(), + details: Vec::new(), + data: Map::new(), + hint: None, + } + } + + pub(crate) fn with_details(mut self, details: Vec) -> Self { + self.details = details; + self + } + + pub(crate) fn with_data(mut self, data: Map) -> Self { + self.data = data; + self + } + + pub(crate) fn with_hint(mut self, hint: impl Into) -> Self { + let h = hint.into(); + if !h.is_empty() { + self.hint = Some(h); + } + self + } + + pub(crate) fn json_value(&self) -> Value { + // Derive a stable snake_case id from the check name for machine-readable keying (#704). + let id = self + .name + .to_ascii_lowercase() + .replace(' ', "_") + .replace('-', "_"); + let mut value = Map::from_iter([ + ("id".to_string(), Value::String(id.clone())), + ( + "name".to_string(), + Value::String(self.name.to_ascii_lowercase()), + ), + ( + "status".to_string(), + Value::String(self.level.label().to_string()), + ), + ("summary".to_string(), Value::String(self.summary.clone())), + ( + // #701 (complete): `details[]` is now the canonical structured form — + // `{key, value}` objects instead of padded prose strings. The legacy + // prose representation is preserved as `details_prose[]` for callers + // that still scrape the formatted strings. + "details_prose".to_string(), + Value::Array( + self.details + .iter() + .cloned() + .map(Value::String) + .collect::>(), + ), + ), + ( + // details[] is now structured {key,value} objects (was prose strings). + "details".to_string(), + Value::Array( + self.details + .iter() + .map(|s| { + // Split on first run of 2+ spaces to separate key from value. + let parts: Vec<&str> = s.splitn(2, " ").collect(); + if parts.len() == 2 { + let k = parts[0].trim().to_string(); + let v_str = parts[1].trim(); + let v: Value = if v_str == "true" { + Value::Bool(true) + } else if v_str == "false" { + Value::Bool(false) + } else if let Ok(n) = v_str.parse::() { + Value::Number(n.into()) + } else { + Value::String(v_str.to_string()) + }; + json!({"key": k, "value": v}) + } else { + json!({"key": s.trim(), "value": Value::Null}) + } + }) + .collect::>(), + ), + ), + ]); + // #778: include hint field so automation can read remediation without parsing prose + value.insert( + "hint".to_string(), + self.hint + .as_deref() + .map(|h| Value::String(h.to_string())) + .unwrap_or(Value::Null), + ); + value.extend(self.data.clone()); + Value::Object(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DoctorReport { + pub(crate) checks: Vec, +} + +impl DoctorReport { + pub(crate) fn counts(&self) -> (usize, usize, usize) { + ( + self.checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Ok) + .count(), + self.checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Warn) + .count(), + self.checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Fail) + .count(), + ) + } + + pub(crate) fn has_failures(&self) -> bool { + self.checks.iter().any(|check| check.level.is_failure()) + } + + pub(crate) fn status(&self) -> &'static str { + let (_, warn_count, fail_count) = self.counts(); + if fail_count > 0 { + "fail" + } else if warn_count > 0 { + "warn" + } else { + "ok" + } + } + + pub(crate) fn render(&self) -> String { + let (ok_count, warn_count, fail_count) = self.counts(); + let mut lines = vec![ + "Doctor".to_string(), + format!( + "Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}" + ), + ]; + lines.extend(self.checks.iter().map(render_diagnostic_check)); + lines.join("\n\n") + } + + pub(crate) fn json_value(&self) -> Value { + let report = self.render(); + let (ok_count, warn_count, fail_count) = self.counts(); + let tool_registry = GlobalToolRegistry::builtin(); + json!({ + "kind": "doctor", + "action": "doctor", + "status": self.status(), + "message": report, + "report": report, + "has_failures": self.has_failures(), + "summary": { + "total": self.checks.len(), + "ok": ok_count, + "warnings": warn_count, + "failures": fail_count, + }, + "checks": self + .checks + .iter() + .map(DiagnosticCheck::json_value) + .collect::>(), + "allowed_tools": { + "available": tool_registry.canonical_allowed_tool_names(), + "aliases": allowed_tool_aliases_json(&tool_registry), + }, + }) + } +} + +pub(crate) fn render_diagnostic_check(check: &DiagnosticCheck) -> String { + let mut lines = vec![format!( + "{}\n Status {}\n Summary {}", + check.name, + check.level.label(), + check.summary + )]; + if !check.details.is_empty() { + lines.push(" Details".to_string()); + lines.extend(check.details.iter().map(|detail| format!(" - {detail}"))); + } + lines.join("\n") +} + +pub(crate) fn render_doctor_report( + config_warning_mode: ConfigWarningMode, + permission_mode: PermissionModeProvenance, +) -> Result> { + let cwd = friendly_cwd(env::current_dir()?); + let config_loader = ConfigLoader::default_for(&cwd); + let config = load_config_with_warning_mode(&config_loader, config_warning_mode); + let discovered_config = config_loader.discover(); + let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; + let (project_root, git_branch) = + parse_git_status_metadata(project_context.git_status.as_deref()); + let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); + let branch_freshness = BranchFreshness::from_git_status(project_context.git_status.as_deref()); + let stale_base_state = stale_base_state_for(&cwd, None); + let empty_config = runtime::RuntimeConfig::empty(); + let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config); + let boot_preflight = build_boot_preflight_snapshot( + &cwd, + project_root.as_deref(), + project_context.git_status.as_deref(), + config.as_ref().ok(), + config.as_ref().err().map(ToString::to_string).as_deref(), + ); + let memory_files = memory_file_summaries_for( + &cwd, + project_root.as_deref(), + &project_context.instruction_files, + ); + let mcp_validation = config + .as_ref() + .ok() + .map(|runtime_config| McpValidationSummary::from_collection(runtime_config.mcp())) + .unwrap_or_default(); + let hook_validation = config + .as_ref() + .ok() + .map(HookValidationSummary::from_config) + .unwrap_or_default(); + let context = StatusContext { + cwd: cwd.clone(), + session_path: None, + loaded_config_files: config + .as_ref() + .ok() + .map_or(0, |runtime_config| runtime_config.loaded_entries().len()), + discovered_config_files: discovered_config.len(), + memory_file_count: project_context.instruction_files.len(), + memory_files: memory_files.clone(), + unloaded_memory_files: unloaded_memory_candidates( + &cwd, + project_root.as_deref(), + &memory_files, + ), + project_root, + git_branch, + git_summary, + branch_freshness, + stale_base_state, + session_lifecycle: classify_session_lifecycle_for(&cwd), + boot_preflight, + sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd), + binary_provenance: binary_provenance_for(Some(&cwd)), + // Doctor path has its own config check; StatusContext here is only + // fed into health renderers that don't read config_load_error. + config_load_error: config.as_ref().err().map(ToString::to_string), + config_load_error_kind: None, + mcp_validation: mcp_validation.clone(), + + hook_validation: hook_validation.clone(), + duplicate_flags: Vec::new(), + }; + Ok(DoctorReport { + checks: vec![ + check_auth_health(), + check_base_url_health(), + check_config_health(&config_loader, config.as_ref()), + check_mcp_validation_health(&mcp_validation), + check_hook_validation_health(&hook_validation), + check_install_source_health(), + check_workspace_health(&context), + check_memory_health(&context), + check_boot_preflight_health(&context), + check_sandbox_health(&context.sandbox_status), + check_permission_health(permission_mode), + check_system_health(&cwd, config.as_ref().ok()), + ], + }) +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn check_auth_health() -> DiagnosticCheck { + let api_key_present = env::var("ANTHROPIC_API_KEY") + .ok() + .is_some_and(|value| !value.trim().is_empty()); + let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN") + .ok() + .is_some_and(|value| !value.trim().is_empty()); + let openai_key_present = env::var("OPENAI_API_KEY") + .ok() + .is_some_and(|value| !value.trim().is_empty()); + let any_auth_present = api_key_present || auth_token_present || openai_key_present; + let prompt_ready = any_auth_present; + let env_details = format!( + "Environment api_key={} auth_token={} openai_key={}", + if api_key_present { "present" } else { "absent" }, + if auth_token_present { + "present" + } else { + "absent" + }, + if openai_key_present { + "present" + } else { + "absent" + } + ); + + match load_oauth_credentials() { + Ok(Some(token_set)) => DiagnosticCheck::new( + "Auth", + if any_auth_present { + DiagnosticLevel::Ok + } else { + DiagnosticLevel::Warn + }, + if any_auth_present { + "supported auth env vars are configured; legacy saved OAuth is ignored" + } else { + "legacy saved OAuth credentials are present but unsupported" + }, + ) + .with_details(vec![ + env_details, + format!( + "Legacy OAuth expires_at={} refresh_token={} scopes={}", + token_set + .expires_at + .map_or_else(|| "".to_string(), |value| value.to_string()), + if token_set.refresh_token.is_some() { + "present" + } else { + "absent" + }, + if token_set.scopes.is_empty() { + "".to_string() + } else { + token_set.scopes.join(",") + } + ), + "Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed" + .to_string(), + ]) + .with_hint("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN env var. The saved OAuth token is no longer accepted.") + .with_data(Map::from_iter([ + ("api_key_present".to_string(), json!(api_key_present)), + ("auth_token_present".to_string(), json!(auth_token_present)), + ("openai_key_present".to_string(), json!(openai_key_present)), + ("prompt_ready".to_string(), json!(prompt_ready)), + ("prompt_blocked_reason".to_string(), if prompt_ready { Value::Null } else { json!("auth_missing") }), + + ("legacy_saved_oauth_present".to_string(), json!(true)), + ( + "legacy_saved_oauth_expires_at".to_string(), + json!(token_set.expires_at), + ), + ( + "legacy_refresh_token_present".to_string(), + json!(token_set.refresh_token.is_some()), + ), + ("legacy_scopes".to_string(), json!(token_set.scopes)), + ])), + Ok(None) => DiagnosticCheck::new( + "Auth", + if any_auth_present { + DiagnosticLevel::Ok + } else { + DiagnosticLevel::Warn + }, + if any_auth_present { + "supported auth env vars are configured" + } else { + "no supported auth env vars were found" + }, + ) + .with_details(vec![env_details]) + .with_hint(if !any_auth_present { "Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN to authenticate." } else { "" }) + .with_data(Map::from_iter([ + ("api_key_present".to_string(), json!(api_key_present)), + ("auth_token_present".to_string(), json!(auth_token_present)), + ("openai_key_present".to_string(), json!(openai_key_present)), + ("prompt_ready".to_string(), json!(prompt_ready)), + ("prompt_blocked_reason".to_string(), if prompt_ready { Value::Null } else { json!("auth_missing") }), + ("legacy_saved_oauth_present".to_string(), json!(false)), + ("legacy_saved_oauth_expires_at".to_string(), Value::Null), + ("legacy_refresh_token_present".to_string(), json!(false)), + ("legacy_scopes".to_string(), json!(Vec::::new())), + ])), + Err(error) => DiagnosticCheck::new( + "Auth", + DiagnosticLevel::Fail, + format!("failed to inspect legacy saved credentials: {error}"), + ) + .with_hint("Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN env var to authenticate.") + .with_data(Map::from_iter([ + ("api_key_present".to_string(), json!(api_key_present)), + ("auth_token_present".to_string(), json!(auth_token_present)), + ("openai_key_present".to_string(), json!(openai_key_present)), + ("prompt_ready".to_string(), json!(prompt_ready)), + ("prompt_blocked_reason".to_string(), if prompt_ready { Value::Null } else { json!("auth_missing") }), + ("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())), + ])), + } +} + +pub(crate) fn check_config_health( + config_loader: &ConfigLoader, + config: Result<&runtime::RuntimeConfig, &runtime::ConfigError>, +) -> DiagnosticCheck { + let discovered = config_loader.discover(); + let discovered_count = discovered.len(); + // Separate candidate paths that actually exist from those that don't. + // Showing non-existent paths as "Discovered file" implies they loaded + // but something went wrong, which is confusing. We only surface paths + // that exist on disk as discovered; non-existent ones are silently + // omitted from the display (they are just the standard search locations). + let present_paths: Vec = discovered + .iter() + .filter(|e| e.path.exists()) + .map(|e| e.path.display().to_string()) + .collect(); + let discovered_paths = discovered + .iter() + .map(|entry| entry.path.display().to_string()) + .collect::>(); + match config { + Ok(runtime_config) => { + let loaded_entries = runtime_config.loaded_entries(); + let loaded_count = loaded_entries.len(); + let present_count = present_paths.len(); + let mut details = vec![format!( + "Config files loaded {}/{}", + loaded_count, present_count + )]; + if let Some(model) = runtime_config.model() { + details.push(format!("Resolved model {model}")); + } + details.push(format!( + "MCP servers {}", + runtime_config.mcp().valid_count() + )); + if runtime_config.mcp().invalid_count() > 0 { + details.push(format!( + "MCP invalid {}", + runtime_config.mcp().invalid_count() + )); + } + if present_paths.is_empty() { + details.push("Discovered files (defaults active)".to_string()); + } else { + details.extend( + present_paths + .iter() + .map(|path| format!("Discovered file {path}")), + ); + } + DiagnosticCheck::new( + "Config", + DiagnosticLevel::Ok, + if present_count == 0 { + "no config files present; defaults are active" + } else { + "runtime config loaded successfully" + }, + ) + .with_details(details) + .with_data(Map::from_iter([ + ("discovered_files".to_string(), json!(present_paths)), + ("discovered_files_count".to_string(), json!(present_count)), + ("loaded_config_files".to_string(), json!(loaded_count)), + ("resolved_model".to_string(), json!(runtime_config.model())), + ( + "mcp_servers".to_string(), + json!(runtime_config.mcp().valid_count()), + ), + ( + "mcp_invalid_servers".to_string(), + json!(runtime_config.mcp().invalid_count()), + ), + ( + "hook_invalid_entries".to_string(), + json!(runtime_config.hooks().invalid_count()), + ), + ])) + } + Err(error) => DiagnosticCheck::new( + "Config", + DiagnosticLevel::Fail, + format!("runtime config failed to load: {error}"), + ) + .with_details(if discovered_paths.is_empty() { + vec!["Discovered files ".to_string()] + } else { + discovered_paths + .iter() + .map(|path| format!("Discovered file {path}")) + .collect() + }) + .with_hint("Fix the JSON syntax error in the listed config file, then rerun `claw doctor`.") + .with_data(Map::from_iter([ + ("discovered_files".to_string(), json!(discovered_paths)), + ( + "discovered_files_count".to_string(), + json!(discovered_count), + ), + ("loaded_config_files".to_string(), json!(0)), + ("resolved_model".to_string(), Value::Null), + ("mcp_servers".to_string(), Value::Null), + ("load_error".to_string(), json!(error.to_string())), + ])), + } +} + +pub(crate) fn check_install_source_health() -> DiagnosticCheck { + DiagnosticCheck::new( + "Install source", + DiagnosticLevel::Ok, + format!( + "official source of truth is {OFFICIAL_REPO_SLUG}; avoid `{DEPRECATED_INSTALL_COMMAND}`" + ), + ) + .with_details(vec![ + format!("Official repo {OFFICIAL_REPO_URL}"), + "Recommended path build from this repo or use the upstream binary documented in README.md" + .to_string(), + format!( + "Deprecated crate `{DEPRECATED_INSTALL_COMMAND}` installs a deprecated stub and does not provide the `claw` binary" + ) + .to_string(), + ]) + .with_data(Map::from_iter([ + ("official_repo".to_string(), json!(OFFICIAL_REPO_URL)), + ( + "deprecated_install".to_string(), + json!(DEPRECATED_INSTALL_COMMAND), + ), + ( + "recommended_install".to_string(), + json!("build from source or follow the upstream binary instructions in README.md"), + ), + ])) +} + +pub(crate) fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { + let in_repo = context.project_root.is_some(); + let stale_base_warning = format_stale_base_warning(&context.stale_base_state); + DiagnosticCheck::new( + "Workspace", + if in_repo && stale_base_warning.is_none() { + DiagnosticLevel::Ok + } else { + DiagnosticLevel::Warn + }, + if in_repo { + format!( + "project root detected on branch {}", + context.git_branch.as_deref().unwrap_or("unknown") + ) + } else { + "current directory is not inside a git project".to_string() + }, + ) + .with_hint(if !in_repo { + "Run `git init` to initialise a repository, or `cd` into a git project." + } else if stale_base_warning.is_some() { + "Rebase or merge to bring the branch up to date with its base." + } else { + "" + }) + .with_details(vec![ + format!("Cwd {}", context.cwd.display()), + format!( + "Project root {}", + context + .project_root + .as_ref() + .map_or_else(|| "".to_string(), |path| path.display().to_string()) + ), + format!( + "Git branch {}", + context.git_branch.as_deref().unwrap_or("unknown") + ), + format!( + "Git state {}", + if context.project_root.is_some() { + context.git_summary.headline() + } else { + "no git repo".to_string() + } + ), + format!("Changed files {}", context.git_summary.changed_files), + format!( + "Memory files {} · config files loaded {}/{}", + context.memory_file_count, context.loaded_config_files, context.discovered_config_files + ), + format!( + "Loaded memory {}", + if context.memory_files.is_empty() { + "".to_string() + } else { + context + .memory_files + .iter() + .map(|file| format!("{}:{}", file.source, file.path)) + .collect::>() + .join(", ") + } + ), + format!( + "Stale base {}", + stale_base_warning.as_deref().unwrap_or("ok") + ), + ]) + .with_data(Map::from_iter([ + ("cwd".to_string(), json!(context.cwd.display().to_string())), + ( + "project_root".to_string(), + json!(context + .project_root + .as_ref() + .map(|path| path.display().to_string())), + ), + ("in_git_repo".to_string(), json!(in_repo)), + ("git_branch".to_string(), json!(context.git_branch)), + ( + "git_state".to_string(), + json!(if context.project_root.is_some() { + context.git_summary.headline() + } else { + "no_git_repo".to_string() + }), + ), + ( + "changed_files".to_string(), + json!(context.git_summary.changed_files), + ), + ( + "memory_file_count".to_string(), + json!(context.memory_file_count), + ), + ( + "memory_files".to_string(), + Value::Array(memory_files_json(&context.memory_files)), + ), + ( + "unloaded_memory_files".to_string(), + json!(context.unloaded_memory_files), + ), + ( + "loaded_config_files".to_string(), + json!(context.loaded_config_files), + ), + ( + "discovered_config_files".to_string(), + json!(context.discovered_config_files), + ), + ( + "stale_base".to_string(), + stale_base_json_value(&context.stale_base_state), + ), + ])) +} + +pub(crate) fn check_boot_preflight_health(context: &StatusContext) -> DiagnosticCheck { + let preflight = &context.boot_preflight; + let missing_binaries = preflight + .required_binaries + .iter() + .filter(|binary| !binary.available) + .map(|binary| binary.name) + .collect::>(); + let socket_details = preflight + .control_sockets + .iter() + .map(|socket| { + format!( + "Control socket {} configured={} exists={} path={}", + socket.name, + socket.configured, + socket.exists, + socket.path.as_deref().unwrap_or("") + ) + }) + .collect::>(); + let mut details = vec![ + format!("Repo exists {}", preflight.repo_exists), + format!("Worktree exists {}", preflight.worktree_exists), + format!("Git dir exists {}", preflight.git_dir_exists), + format!("Branch behind {}", preflight.branch_freshness.behind), + format!( + "Trust allowlist {}", + preflight + .trust_gate_allowed + .map_or("unknown".to_string(), |v| v.to_string()) + ), + format!("Trusted roots {}", preflight.trusted_roots_count), + // #736: keep compound values readable but use " · " as intra-value separator + // so the two-space prose splitter yields key="MCP eligible" value="true · servers 0" + format!( + "MCP eligible {}", + format!( + "{} · servers {}", + preflight.mcp_startup_eligible, preflight.mcp_servers_configured + ) + ), + format!( + "Plugin eligible {}", + format!( + "{} · configured {}", + preflight.plugin_startup_eligible, preflight.plugins_configured + ) + ), + format!( + // #736: use two-space separator so the detail_entries prose splitter + // can extract key="Last failed boot" value="|" + "Last failed boot {}", + preflight + .last_failed_boot_reason + .as_deref() + .unwrap_or("") + ), + ]; + details.extend(preflight.required_binaries.iter().map(|binary| { + format!( + // #736: two-space separator → key="Required binary " value="available=true|false" + "Required binary {} available={}", + binary.name, binary.available + ) + })); + details.extend(socket_details); + DiagnosticCheck::new( + "Boot preflight", + if preflight.repo_exists && preflight.worktree_exists && missing_binaries.is_empty() { + DiagnosticLevel::Ok + } else { + DiagnosticLevel::Warn + }, + preflight.summary(), + ) + .with_details(details) + .with_hint( + // #778: stable remediation hint for automation + if !preflight.repo_exists || !preflight.worktree_exists { + "Ensure you are inside a git worktree (`git init` or `git worktree add`)." + } else if !missing_binaries.is_empty() { + "Install the listed missing required binaries." + } else { + "" + }, + ) + .with_data(Map::from_iter([( + "boot_preflight".to_string(), + preflight.json_value(), + )])) +} + +pub(crate) fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck { + let degraded = status.enabled && !status.active; + let mut details = vec![ + format!("Enabled {}", status.enabled), + format!("Active {}", status.active), + format!("Supported {}", status.supported), + format!("Filesystem mode {}", status.filesystem_mode.as_str()), + format!("Filesystem live {}", status.filesystem_active), + ]; + if let Some(reason) = &status.fallback_reason { + details.push(format!("Fallback reason {reason}")); + } + DiagnosticCheck::new( + "Sandbox", + if degraded { + DiagnosticLevel::Warn + } else { + DiagnosticLevel::Ok + }, + if degraded { + "sandbox was requested but is not currently active" + } else if status.active { + "sandbox protections are active" + } else { + "sandbox is not active for this session" + }, + ) + .with_details(details) + .with_hint( + // #778: stable remediation hint — sandbox degraded on non-Linux hosts is expected, not an error + if degraded && !status.supported { + "Sandbox namespace isolation requires Linux with `unshare`. On macOS/non-Linux hosts this warning is expected and can be ignored. Filesystem isolation is still active." + } else if degraded { + "Check that the `unshare` binary is available and the process has the required capabilities." + } else { + "" + }, + ) + .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)), + ])) +} + +pub(crate) fn check_system_health( + cwd: &Path, + config: Option<&runtime::RuntimeConfig>, +) -> DiagnosticCheck { + let default_model = config.and_then(runtime::RuntimeConfig::model); + let mut details = vec![ + format!("OS {} {}", env::consts::OS, env::consts::ARCH), + format!("Working dir {}", cwd.display()), + format!("Version {}", VERSION), + format!("Build target {}", BUILD_TARGET.unwrap_or("")), + format!("Git SHA {}", GIT_SHA.unwrap_or("")), + format!( + "Output format env CLAW_OUTPUT_FORMAT={}", + env::var("CLAW_OUTPUT_FORMAT").unwrap_or_else(|_| "".to_string()) + ), + format!( + "Logging env CLAW_LOG={} RUST_LOG={}", + env::var("CLAW_LOG").unwrap_or_else(|_| "".to_string()), + env::var("RUST_LOG").unwrap_or_else(|_| "".to_string()) + ), + ]; + if let Some(model) = default_model { + details.push(format!("Default model {model}")); + } + let binary_provenance = binary_provenance_for(Some(cwd)); + details.push(format!( + "Binary provenance status={} workspace_match={}", + binary_provenance.status(), + binary_provenance + .workspace_match + .map_or_else(|| "unknown".to_string(), |matches| matches.to_string()) + )); + 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)), + ( + "binary_provenance".to_string(), + binary_provenance.json_value(), + ), + ("default_model".to_string(), json!(default_model)), + ( + "claw_output_format".to_string(), + json!(env::var("CLAW_OUTPUT_FORMAT").ok()), + ), + ("claw_log".to_string(), json!(env::var("CLAW_LOG").ok())), + ("rust_log".to_string(), json!(env::var("RUST_LOG").ok())), + ])) +} + +pub(crate) fn version_json_value() -> serde_json::Value { + let cwd = env::current_dir().ok(); + let binary_provenance = binary_provenance_for(cwd.as_deref()); + json!({ + "kind": "version", + "action": "show", + "status": "ok", + "human_readable": render_version_report(), + "version": VERSION, + "git_sha": binary_provenance.git_sha, + "git_sha_short": binary_provenance.git_sha_short, + "is_dirty": binary_provenance.is_dirty, + "branch": binary_provenance.branch, + "commit_date": binary_provenance.commit_date, + "commit_timestamp": binary_provenance.commit_timestamp, + "rustc_version": binary_provenance.rustc_version, + "target": binary_provenance.target, + "build_date": binary_provenance.build_date, + "executable_path": binary_provenance.executable_path, + "binary_provenance": binary_provenance.json_value(), + }) +} + +#[derive(Debug, Clone)] +pub(crate) struct ResumeCommandOutcome { + pub(crate) session: Session, + pub(crate) message: Option, + pub(crate) 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) memory_files: Vec, + pub(crate) unloaded_memory_files: Vec, + pub(crate) project_root: Option, + pub(crate) git_branch: Option, + pub(crate) git_summary: GitWorkspaceSummary, + pub(crate) branch_freshness: BranchFreshness, + pub(crate) stale_base_state: BaseCommitState, + pub(crate) session_lifecycle: SessionLifecycleSummary, + pub(crate) boot_preflight: BootPreflightSnapshot, + pub(crate) sandbox_status: runtime::SandboxStatus, + pub(crate) binary_provenance: BinaryProvenance, + /// #143: when `.claw.json` (or another loaded config file) fails to parse, + /// we capture the parse error here and still populate every field that + /// doesn't depend on runtime config (workspace, git, sandbox defaults, + /// discovery counts). Top-level JSON output then reports + /// `status: "degraded"` so claws can distinguish "status ran but config + /// is broken" from "status ran cleanly". + pub(crate) config_load_error: Option, + /// #143: machine-readable kind for the config load error, derived from + /// `classify_error_kind`. Included in JSON output alongside the human + /// readable string so downstream claws can switch on the kind token + /// instead of regex-scraping the prose. + pub(crate) config_load_error_kind: Option<&'static str>, + pub(crate) mcp_validation: McpValidationSummary, + + pub(crate) hook_validation: HookValidationSummary, + /// #468: duplicate global flag occurrences for provenance reporting + pub(crate) duplicate_flags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BranchFreshness { + pub(crate) upstream: Option, + pub(crate) ahead: u32, + pub(crate) behind: u32, + pub(crate) fresh: Option, +} + +impl BranchFreshness { + pub(crate) fn from_git_status(status: Option<&str>) -> Self { + let first_line = status + .and_then(|status| status.lines().next()) + .unwrap_or_default(); + let upstream = first_line + .split_once("...") + .and_then(|(_, rest)| rest.split([' ', '[']).next()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let mut ahead = 0; + let mut behind = 0; + if let Some((_, bracketed)) = first_line.split_once('[') { + let bracketed = bracketed.trim_end_matches(']'); + for part in bracketed.split(',').map(str::trim) { + if let Some(value) = part.strip_prefix("ahead ") { + ahead = value.parse().unwrap_or(0); + } else if let Some(value) = part.strip_prefix("behind ") { + behind = value.parse().unwrap_or(0); + } + } + } + let fresh = upstream.as_ref().map(|_| behind == 0); + Self { + upstream, + ahead, + behind, + fresh, + } + } + + pub(crate) fn json_value(&self) -> serde_json::Value { + json!({ + "upstream": self.upstream, + // #727: has_upstream disambiguates fresh:null-because-no-upstream + // from fresh:null-because-unavailable; automation should check + // has_upstream before branching on fresh. + "has_upstream": self.upstream.is_some(), + "ahead": self.ahead, + "behind": self.behind, + "fresh": self.fresh, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BinaryPreflight { + pub(crate) name: &'static str, + pub(crate) available: bool, +} + +impl BinaryPreflight { + pub(crate) fn json_value(&self) -> serde_json::Value { + json!({ + "name": self.name, + "available": self.available, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ControlSocketPreflight { + pub(crate) name: &'static str, + pub(crate) configured: bool, + pub(crate) exists: bool, + pub(crate) path: Option, +} + +impl ControlSocketPreflight { + pub(crate) fn json_value(&self) -> serde_json::Value { + json!({ + "name": self.name, + "configured": self.configured, + "exists": self.exists, + "path": self.path, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BootPreflightSnapshot { + pub(crate) repo_exists: bool, + pub(crate) worktree_exists: bool, + pub(crate) git_dir_exists: bool, + pub(crate) branch_freshness: BranchFreshness, + pub(crate) trust_gate_allowed: Option, + pub(crate) trusted_roots_count: usize, + pub(crate) required_binaries: Vec, + pub(crate) control_sockets: Vec, + pub(crate) mcp_startup_eligible: bool, + pub(crate) mcp_servers_configured: usize, + pub(crate) plugin_startup_eligible: bool, + pub(crate) plugins_configured: usize, + pub(crate) last_failed_boot_reason: Option, +} + +impl BootPreflightSnapshot { + pub(crate) fn json_value(&self) -> serde_json::Value { + json!({ + "repo": { + "exists": self.repo_exists, + "worktree_exists": self.worktree_exists, + "git_dir_exists": self.git_dir_exists, + }, + "branch_freshness": self.branch_freshness.json_value(), + "trust_gate": { + "allowlisted": self.trust_gate_allowed, + "trusted_roots_count": self.trusted_roots_count, + }, + "required_binaries": self.required_binaries.iter().map(BinaryPreflight::json_value).collect::>(), + "control_sockets": self.control_sockets.iter().map(ControlSocketPreflight::json_value).collect::>(), + "mcp_startup": { + "eligible": self.mcp_startup_eligible, + "servers_configured": self.mcp_servers_configured, + }, + "plugin_startup": { + "eligible": self.plugin_startup_eligible, + "plugins_configured": self.plugins_configured, + }, + "last_failed_boot_reason": self.last_failed_boot_reason, + }) + } + + pub(crate) fn summary(&self) -> String { + let trust = self + .trust_gate_allowed + .map(|value| { + if value { + "allowlisted" + } else { + "not allowlisted" + } + }) + .unwrap_or("unknown"); + let freshness = self + .branch_freshness + .fresh + .map(|fresh| if fresh { "fresh" } else { "behind" }) + .unwrap_or("no upstream"); + format!( + "repo={} worktree={} branch={} trust={} mcp={} plugins={} last_failed={}", + self.repo_exists, + self.worktree_exists, + freshness, + trust, + self.mcp_startup_eligible, + self.plugin_startup_eligible, + self.last_failed_boot_reason.as_deref().unwrap_or("none") + ) + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct StatusUsage { + pub(crate) message_count: usize, + pub(crate) turns: u32, + pub(crate) latest: TokenUsage, + pub(crate) cumulative: TokenUsage, + pub(crate) estimated_tokens: usize, +} + +#[allow(clippy::struct_field_names)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct GitWorkspaceSummary { + pub(crate) changed_files: usize, + pub(crate) staged_files: usize, + pub(crate) unstaged_files: usize, + pub(crate) untracked_files: usize, + pub(crate) conflicted_files: usize, + /// #89: detected mid-operation git state (rebase, merge, cherry-pick, bisect) + pub(crate) operation: GitOperation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SessionLifecycleKind { + RunningProcess, + IdleShell, + SavedOnly, +} + +impl SessionLifecycleKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::RunningProcess => "running_process", + Self::IdleShell => "idle_shell", + Self::SavedOnly => "saved_only", + } + } + + pub(crate) fn human_label(self) -> &'static str { + match self { + Self::RunningProcess => "running process", + Self::IdleShell => "idle shell", + Self::SavedOnly => "saved only", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SessionLifecycleSummary { + pub(crate) kind: SessionLifecycleKind, + pub(crate) pane_id: Option, + pub(crate) pane_command: Option, + pub(crate) pane_path: Option, + pub(crate) workspace_dirty: bool, + pub(crate) abandoned: bool, + // #326: all panes matching this workspace, not just the first one + pub(crate) all_panes: Vec, +} + +impl SessionLifecycleSummary { + pub(crate) fn signal(&self) -> String { + let mut parts = vec![self.kind.human_label().to_string()]; + if self.workspace_dirty { + parts.push("dirty worktree".to_string()); + } + if self.abandoned { + parts.push("abandoned?".to_string()); + } + if let Some(command) = self.pane_command.as_deref() { + parts.push(format!("cmd={command}")); + } + parts.join(" · ") + } + + pub(crate) fn json_value(&self) -> serde_json::Value { + json!({ + "kind": self.kind.as_str(), + "pane_id": self.pane_id, + "pane_command": self.pane_command, + "pane_path": self.pane_path.as_ref().map(|path| path.display().to_string()), + "workspace_dirty": self.workspace_dirty, + "abandoned": self.abandoned, + // #326: include all workspace panes in the JSON output + "panes": self.all_panes.iter().map(|p| { + json!({ + "pane_id": p.pane_id, + "pane_command": p.current_command, + "pane_path": p.current_path.display().to_string(), + }) + }).collect::>(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TmuxPaneSnapshot { + pub(crate) pane_id: String, + pub(crate) current_command: String, + pub(crate) current_path: PathBuf, +} + +impl GitWorkspaceSummary { + pub(crate) fn is_clean(self) -> bool { + self.changed_files == 0 + } + + pub(crate) fn headline(self) -> String { + // #89: prefix with operation state when mid-operation + let op_prefix = if self.operation != GitOperation::None { + format!("{}, ", self.operation.as_str()) + } else { + String::new() + }; + if self.is_clean() { + if self.operation != GitOperation::None { + format!("{op_prefix}clean") + } else { + "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!( + "{op_prefix}dirty · {} files · {}", + self.changed_files, + details.join(", ") + ) + } + } +} + +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: Option = None; + let mut all_workspace_panes: Vec = Vec::new(); + let mut running_pane: Option = None; + for pane in panes { + if !pane_path_matches_workspace(&pane.current_path, workspace) { + continue; + } + all_workspace_panes.push(pane.clone()); + if is_idle_shell_command(&pane.current_command) { + idle_shell.get_or_insert(pane); + } else if running_pane.is_none() { + running_pane = Some(pane); + } + } + + if let Some(pane) = running_pane { + 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, + all_panes: all_workspace_panes, + }; + } + + 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, + all_panes: all_workspace_panes, + } + } else { + SessionLifecycleSummary { + kind: SessionLifecycleKind::SavedOnly, + pane_id: None, + pane_command: None, + pane_path: None, + workspace_dirty, + abandoned: workspace_dirty, + all_panes: all_workspace_panes, + } + } +} + +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 { + if pane_path == workspace || pane_path.starts_with(workspace) { + return true; + } + 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 parse_git_status_metadata(status: Option<&str>) -> (Option, Option) { + parse_git_status_metadata_for( + &env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + status, + ) +} + +pub(crate) fn parse_git_status_branch(status: Option<&str>) -> Option { + let status = status?; + let first_line = status.lines().next()?; + let line = first_line.strip_prefix("## ")?; + if line.starts_with("HEAD") { + return Some("detached HEAD".to_string()); + } + let branch = line.split(['.', ' ']).next().unwrap_or_default().trim(); + if branch.is_empty() { + None + } else { + Some(branch.to_string()) + } +} + +pub(crate) fn parse_git_workspace_summary(status: Option<&str>) -> GitWorkspaceSummary { + let mut summary = GitWorkspaceSummary::default(); + let Some(status) = status else { + return summary; + }; + + for line in status.lines() { + if line.starts_with("## ") { + // #89: detect mid-operation states from branch header + // git status --short --branch shows: + // "## HEAD (no branch, rebasing feature-branch)" + // "## main [merge-in-progress]" + // "## HEAD (no branch, cherry-pick-in-progress)" + // "## main (no branch, bisect-in-progress)" + let header = line.to_ascii_lowercase(); + if header.contains("rebasing") { + summary.operation = GitOperation::Rebase; + } else if header.contains("merge-in-progress") { + summary.operation = GitOperation::Merge; + } else if header.contains("cherry-pick-in-progress") { + summary.operation = GitOperation::CherryPick; + } else if header.contains("bisect-in-progress") { + summary.operation = GitOperation::Bisect; + } + continue; + } + if line.trim().is_empty() { + continue; + } + + summary.changed_files += 1; + let mut chars = line.chars(); + let index_status = chars.next().unwrap_or(' '); + let worktree_status = chars.next().unwrap_or(' '); + + if index_status == '?' && worktree_status == '?' { + summary.untracked_files += 1; + continue; + } + + if index_status != ' ' { + summary.staged_files += 1; + } + if worktree_status != ' ' { + summary.unstaged_files += 1; + } + if (matches!(index_status, 'U' | 'A') && matches!(worktree_status, 'U' | 'A')) + || index_status == 'U' + || worktree_status == 'U' + { + summary.conflicted_files += 1; + } + } + + summary +} + +pub(crate) fn build_boot_preflight_snapshot( + cwd: &Path, + project_root: Option<&Path>, + git_status: Option<&str>, + runtime_config: Option<&runtime::RuntimeConfig>, + config_load_error: Option<&str>, +) -> BootPreflightSnapshot { + let branch_freshness = BranchFreshness::from_git_status(git_status); + let worktree_exists = run_git_bool(cwd, &["rev-parse", "--is-inside-work-tree"]); + let git_dir_exists = run_git_capture_in(cwd, &["rev-parse", "--git-dir"]) + .map(|path| { + let path = PathBuf::from(path.trim()); + if path.is_absolute() { + path + } else { + cwd.join(path) + } + }) + .is_some_and(|path| path.exists()); + let trusted_roots = runtime_config + .map(runtime::RuntimeConfig::trusted_roots) + .unwrap_or(&[]); + let trust_gate_allowed = runtime_config.map(|_| { + trusted_roots + .iter() + .any(|root| path_matches_trusted_root_local(cwd, root)) + }); + let plugin_configured = runtime_config + .map(|config| config.plugins().enabled_plugins().len()) + .unwrap_or_default(); + let mcp_configured = runtime_config + .map(|config| config.mcp().servers().len()) + .unwrap_or_default(); + let config_ok = config_load_error.is_none(); + BootPreflightSnapshot { + repo_exists: project_root.is_some_and(Path::exists), + worktree_exists, + git_dir_exists, + branch_freshness, + trust_gate_allowed, + trusted_roots_count: trusted_roots.len(), + required_binaries: vec![ + BinaryPreflight { + name: "claw", + available: env::current_exe().is_ok_and(|path| path.exists()), + }, + BinaryPreflight { + name: "git", + available: command_available("git"), + }, + BinaryPreflight { + name: "tmux", + available: command_available("tmux"), + }, + ], + control_sockets: vec![tmux_control_socket_preflight()], + mcp_startup_eligible: config_ok, + mcp_servers_configured: mcp_configured, + plugin_startup_eligible: config_ok, + plugins_configured: plugin_configured, + last_failed_boot_reason: last_failed_boot_reason(cwd), + } +} + +pub(crate) fn run_git_bool(cwd: &Path, args: &[&str]) -> bool { + Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .is_ok_and(|output| output.status.success()) +} + +pub(crate) fn tmux_control_socket_preflight() -> ControlSocketPreflight { + let path = env::var("TMUX") + .ok() + .and_then(|value| value.split(',').next().map(str::to_string)) + .filter(|value| !value.is_empty()); + let exists = path.as_ref().is_some_and(|path| Path::new(path).exists()); + ControlSocketPreflight { + name: "tmux", + configured: path.is_some(), + exists, + path, + } +} + +pub(crate) fn last_failed_boot_reason(cwd: &Path) -> Option { + env::var("CLAW_LAST_FAILED_BOOT_REASON") + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + fs::read_to_string(cwd.join(".claw").join("last-failed-boot.txt")) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +pub(crate) fn path_matches_trusted_root_local(cwd: &Path, trusted_root: &str) -> bool { + let cwd = fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf()); + let trusted_root = Path::new(trusted_root); + let trusted_root = if trusted_root.is_absolute() { + trusted_root.to_path_buf() + } else { + cwd.join(trusted_root) + }; + let trusted_root = fs::canonicalize(&trusted_root).unwrap_or(trusted_root); + cwd == trusted_root || cwd.starts_with(trusted_root) +} + +pub(crate) fn resolve_git_branch_for(cwd: &Path) -> Option { + let branch = run_git_capture_in(cwd, &["branch", "--show-current"])?; + let branch = branch.trim(); + if !branch.is_empty() { + return Some(branch.to_string()); + } + + let fallback = run_git_capture_in(cwd, &["rev-parse", "--abbrev-ref", "HEAD"])?; + let fallback = fallback.trim(); + if fallback.is_empty() { + None + } else if fallback == "HEAD" { + Some("detached HEAD".to_string()) + } else { + Some(fallback.to_string()) + } +} + +pub(crate) fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Option { + let output = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() +} + +pub(crate) fn find_git_root_in(cwd: &Path) -> Result> { + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(cwd) + .output()?; + if !output.status.success() { + return Err("not a git repository".into()); + } + let path = String::from_utf8(output.stdout)?.trim().to_string(); + if path.is_empty() { + return Err("empty git root".into()); + } + Ok(PathBuf::from(path)) +} + +pub(crate) fn parse_git_status_metadata_for( + cwd: &Path, + status: Option<&str>, +) -> (Option, Option) { + let branch = resolve_git_branch_for(cwd).or_else(|| parse_git_status_branch(status)); + let project_root = find_git_root_in(cwd).ok(); + (project_root, branch) +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn run_resume_command( + session_path: &Path, + session: &Session, + command: &SlashCommand, +) -> Result> { + let session_list_outcome = || -> Result> { + let sessions = list_managed_sessions().unwrap_or_default(); + let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); + let session_details = session_details_json(&sessions); + 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": "sessions", + "status": "ok", + "action": "list", + "sessions": session_ids, + "session_details": session_details, + "active": active_id, + })), + }) + }; + + match command { + SlashCommand::Help => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_repl_help()), + json: Some( + serde_json::json!({ "kind": "help", "action": "help", "status": "ok", "message": render_repl_help() }), + ), + }), + SlashCommand::Compact => { + let result = runtime::trident::trident_compact_session( + session, + CompactionConfig { + max_estimated_tokens: 0, + ..CompactionConfig::default() + }, + &runtime::trident::TridentConfig::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)?; + // #114: preserve the session_id from the file to avoid filename/meta-header + // divergence. /clear is "empty this session," not "fork to a new session." + let previous_session_id = session.session_id.clone(); + let mut cleared = new_cli_session()?; + cleared.session_id = previous_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 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": previous_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 + None, + )), + json: Some(status_json_value( + session.model.as_deref(), + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &context, + None, // #148: resumed sessions don't have flag provenance + None, + None, + 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", + "action": "show", + "status": "ok", + "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(), + "estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.estimate_cost_usd().total_cost_usd(), + "pricing": "estimated-default", + })), + }) + } + 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", + "action": "export", + "status": "ok", + "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::to_value(handle_agents_slash_command_json(args.as_deref(), &cwd)?) + .unwrap_or(Value::Null), + ), + }) + } + SlashCommand::Skills { args } => { + if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { + // #779: use interactive_only: prefix + \n hint so #776 classify/split emits + // error_kind:interactive_only + non-null hint instead of unknown+null. + let skill_name = args.as_deref().unwrap_or(""); + return Err(format!( + "interactive_only: /skills {skill_name} invocation requires a live session.\nStart `claw` and run `/skills {skill_name}` inside the REPL, or use `claw -p ` with skill context." + ).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::Plugins { action, target } => { + // Only list is supported in resume mode (no runtime to reload) + match action.as_deref() { + Some(action @ ("install" | "uninstall" | "enable" | "disable" | "update")) => { + // #777: use interactive_only: prefix + \n hint so #776's classify/split + // emits error_kind:interactive_only + non-null hint instead of unknown+null. + // Orchestrators can now detect this and switch to a live REPL instead of retrying. + return Err(format!( + "interactive_only: /plugins {action} requires a live session to reload the plugin runtime.\nStart `claw` and run `/plugins {action}` inside the REPL, or use `claw plugins {action}` as a direct CLI command." + ).into()); + } + _ => {} + } + let cwd = env::current_dir()?; + let payload = plugins_command_payload_for( + &cwd, + action.as_deref(), + target.as_deref(), + ConfigWarningMode::EmitStderr, + )?; + let action_str = action.as_deref().unwrap_or("list"); + let enabled_count = payload + .plugins + .iter() + .filter(|p| p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false)) + .count(); + let disabled_count = payload.plugins.len().saturating_sub(enabled_count); + let mut json = serde_json::json!({ + "kind": "plugin", + "action": action_str, + "status": payload.status, + "summary": { + "total": payload.plugins.len(), + "enabled": enabled_count, + "disabled": disabled_count, + "load_failures": payload.load_failures.len(), + }, + "config_load_error": payload.config_load_error, + "mcp_validation": payload.mcp_validation.json_value(), + "plugins": payload.plugins, + "load_failures": payload.load_failures, + }); + if action_str != "list" { + json["target"] = serde_json::json!(target); + json["reload_runtime"] = serde_json::json!(payload.reload_runtime); + json["message"] = serde_json::json!(&payload.message); + } + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(payload.message), + json: Some(json), + }) + } + SlashCommand::Doctor => { + let report = render_doctor_report( + ConfigWarningMode::EmitStderr, + permission_mode_provenance_for_current_dir(), + )?; + 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", + "action": "show", + "status": "ok", + "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(), + "estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.estimate_cost_usd().total_cost_usd(), + "pricing": "estimated-default", + })), + }) + } + 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", + "action": "list", + "status": "ok", + "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/exists/delete can be served from the managed sessions directory + // in resume mode without starting an interactive REPL. Mutating delete remains + // opt-in through /session delete --force so JSON callers never hang on a prompt. + SlashCommand::Session { action, target } => { + run_resumed_session_command(session_path, session, action.as_deref(), target.as_deref()) + } + // #341: /tasks is resume-supported — return a no-op with structured JSON + SlashCommand::Tasks { args } => { + let args_str = args.as_deref().unwrap_or_default(); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Tasks\n Note Background tasks are only available in the interactive REPL.\n Command /tasks {args_str}" + )), + json: Some(serde_json::json!({ + "kind": "tasks", + "action": "list", + "status": "ok", + "note": "Background tasks are only available in the interactive REPL.", + "args": args_str, + })), + }) + } + // #343: /model is resume-safe — returns model configuration + SlashCommand::Model { model } => { + let configured_model = config_model_for_current_dir(); + let resolved_config_model = configured_model + .as_deref() + .map(resolve_model_alias_with_config); + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(format!( + "Models\n Default {}\n Config model {}", + DEFAULT_MODEL, + configured_model.as_deref().unwrap_or("") + )), + json: Some(serde_json::json!({ + "kind": "models", + "action": "list", + "status": "ok", + "default_model": DEFAULT_MODEL, + "configured_model": configured_model, + "resolved_model": resolved_config_model, + "requested_model": model, + })), + }) + } + SlashCommand::Bughunter { .. } + | SlashCommand::Commit { .. } + | SlashCommand::Pr { .. } + | SlashCommand::Issue { .. } + | SlashCommand::Ultraplan { .. } + | SlashCommand::Teleport { .. } + | SlashCommand::DebugToolCall { .. } + | SlashCommand::Resume { .. } + | SlashCommand::Permissions { .. } + | 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::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 { .. } + | SlashCommand::Team { .. } + | SlashCommand::Setup => 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. +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 stale_base_state_for(cwd: &Path, flag_value: Option<&str>) -> BaseCommitState { + let source = resolve_expected_base(flag_value, cwd); + check_base_commit(cwd, source.as_ref()) +} + +pub(crate) fn stale_base_json_value(state: &BaseCommitState) -> serde_json::Value { + match state { + BaseCommitState::Matches => json!({"status": "matches", "fresh": true}), + BaseCommitState::Diverged { expected, actual } => json!({ + "status": "diverged", + "fresh": false, + "expected": expected, + "actual": actual, + }), + BaseCommitState::NoExpectedBase => json!({"status": "no_expected_base", "fresh": null}), + BaseCommitState::NotAGitRepo => json!({"status": "not_git_repo", "fresh": null}), + } +} + +pub(crate) fn run_stale_base_preflight(flag_value: Option<&str>) { + let Ok(cwd) = env::current_dir() else { + return; + }; + let state = stale_base_state_for(&cwd, flag_value); + if let Some(warning) = format_stale_base_warning(&state) { + eprintln!("{warning}"); + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ManagedSessionSummary { + pub(crate) id: String, + pub(crate) path: PathBuf, + pub(crate) created_at_ms: u64, + pub(crate) updated_at_ms: u64, + pub(crate) modified_epoch_millis: u128, + pub(crate) message_count: usize, + pub(crate) parent_session_id: Option, + pub(crate) branch_name: Option, + pub(crate) lifecycle: SessionLifecycleSummary, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ToolSearchRequest { + pub(crate) query: String, + pub(crate) max_results: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct McpToolRequest { + #[serde(rename = "qualifiedName")] + pub(crate) qualified_name: Option, + pub(crate) tool: Option, + pub(crate) arguments: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ListMcpResourcesRequest { + pub(crate) server: Option, +} + +pub(crate) fn session_details_json(sessions: &[ManagedSessionSummary]) -> Vec { + sessions + .iter() + .map(|session| { + serde_json::json!({ + "id": session.id, + "path": session.path.display().to_string(), + "message_count": session.message_count, + "created_at_ms": session.created_at_ms, + "updated_at_ms": session.updated_at_ms, + "modified_epoch_millis": session.modified_epoch_millis, + "parent_session_id": session.parent_session_id, + "branch_name": session.branch_name, + "lifecycle": session.lifecycle.json_value(), + }) + }) + .collect() +} + +pub(crate) fn write_session_clear_backup( + session: &Session, + session_path: &Path, +) -> Result> { + let backup_path = session_clear_backup_path(session_path); + session.save_to_path(&backup_path)?; + Ok(backup_path) +} + +pub(crate) fn session_clear_backup_path(session_path: &Path) -> PathBuf { + let timestamp = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map_or(0, |duration| duration.as_millis()); + let file_name = session_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("session.jsonl"); + session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak")) +} + +pub(crate) fn status_json_value( + model: Option<&str>, + usage: StatusUsage, + permission_mode: &str, + context: &StatusContext, + // #148: optional provenance for `model` field. Surfaces `model_source` + // ("flag" | "env" | "config" | "default") and `model_raw` (user input + // before alias resolution, or null when source is "default"). Callers + // that don't have provenance (legacy resume paths) pass None, in which + // case both new fields are omitted. + provenance: Option<&ModelProvenance>, + permission_provenance: Option<&PermissionModeProvenance>, + allowed_tools: Option<&AllowedToolSet>, + format_selection: Option<&OutputFormatSelection>, +) -> serde_json::Value { + // #143: top-level `status` marker so claws can distinguish + // a clean run from a degraded run (config parse failed but other fields + // are still populated). `config_load_error` carries the parse-error string + // when present; it's a string rather than a typed object in Phase 1 and + // will join the typed-error taxonomy in Phase 2 (ROADMAP §4.44). + // `config_load_error_kind` is the machine-readable kind token derived from + // `classify_error_kind` so downstream claws can switch on it directly. + let degraded = context.config_load_error.is_some(); + let model_source = provenance.map(|p| p.source.as_str()); + let model_raw = provenance.and_then(|p| p.raw.clone()); + let model_alias_resolved_to = provenance.and_then(|p| p.alias_resolved_to.clone()); + let model_env_var = provenance.and_then(|p| p.env_var.clone()); + let permission_mode_source = permission_provenance.map(|p| p.source.as_str()); + let permission_mode_env_var = permission_provenance.and_then(|p| p.env_var); + let tool_registry = GlobalToolRegistry::builtin(); + let available_tool_names = tool_registry.canonical_allowed_tool_names(); + let tool_aliases = allowed_tool_aliases_json(&tool_registry); + let output_format_selection = format_selection.cloned().unwrap_or_default(); + // #732: always emit an array (empty when unrestricted) so callers can do + // `.allowed_tools.entries | length > 0` without a null-check first. + let allowed_tool_entries = allowed_tools + .map(|tools| tools.iter().cloned().collect::>()) + .unwrap_or_default(); + json!({ + "kind": "status", + "action": "show", + "status": if degraded || context.mcp_validation.has_invalid_servers() || context.hook_validation.has_invalid_hooks() { "degraded" } else { "ok" }, + "config_load_error": context.config_load_error, + "config_load_error_kind": context.config_load_error_kind, + "mcp_validation": context.mcp_validation.json_value(), + "hook_validation": context.hook_validation.json_value(), + "duplicate_flags": context.duplicate_flags, + + "model": model, + "model_source": model_source, + "model_raw": model_raw, + "model_alias_resolved_to": model_alias_resolved_to, + "model_env_var": model_env_var, + "permission_mode": permission_mode, + "permission_mode_source": permission_mode_source, + "permission_mode_env_var": permission_mode_env_var, + "allowed_tools": { + "source": if allowed_tools.is_some() { "flag" } else { "default" }, + "restricted": allowed_tools.is_some(), + "entries": allowed_tool_entries, + "available": available_tool_names, + "aliases": tool_aliases, + }, + "format_source": output_format_selection.source.as_str(), + "format_raw": output_format_selection.raw, + "format_overridden": output_format_selection.overridden, + "binary_provenance": context.binary_provenance.json_value(), + "usage": { + "messages": usage.message_count, + "turns": usage.turns, + "latest_input": usage.latest.input_tokens, + "latest_output": usage.latest.output_tokens, + "latest_cache_creation_input": usage.latest.cache_creation_input_tokens, + "latest_cache_read_input": usage.latest.cache_read_input_tokens, + "latest_total": usage.latest.total_tokens(), + "cumulative_input": usage.cumulative.input_tokens, + "cumulative_output": usage.cumulative.output_tokens, + "cumulative_cache_creation_input": usage.cumulative.cache_creation_input_tokens, + "cumulative_cache_read_input": usage.cumulative.cache_read_input_tokens, + "cumulative_total": usage.cumulative.total_tokens(), + "estimated_cost_usd": format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.cumulative.estimate_cost_usd().total_cost_usd(), + "pricing": "estimated-default", + "estimated_tokens": usage.estimated_tokens, + }, + "lane_board": { + "schema": "task_registry_v1", + "status_json_supported": true, + "heartbeat_freshness_supported": true, + "states": ["active", "blocked", "finished"], + "freshness_states": ["healthy", "stalled", "transport_dead", "unknown"], + }, + "workspace": { + "cwd": context.cwd, + "project_root": context.project_root, + "git_branch": context.git_branch, + "git_state": if context.project_root.is_some() { context.git_summary.headline() } else { "no_git_repo".to_string() }, + // #408: changed_files counts ALL non-clean files (staged + unstaged + untracked + conflicted) + "changed_files": context.git_summary.changed_files, + "is_clean": context.git_summary.changed_files == 0, + "staged_files": context.git_summary.staged_files, + // #89: mid-operation git state (rebase, merge, cherry-pick, bisect) + "git_operation": if context.git_summary.operation != GitOperation::None { + Some(context.git_summary.operation.as_str()) + } else { + None::<&str> + }, + + "unstaged_files": context.git_summary.unstaged_files, + "untracked_files": context.git_summary.untracked_files, + "session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()), + "session_id": context.session_path.as_ref().and_then(|path| { + // Session files are named .jsonl directly under + // .claw/sessions/. Extract the stem (drop the .jsonl extension). + path.file_stem().map(|n| n.to_string_lossy().into_owned()) + }), + "session_lifecycle": context.session_lifecycle.json_value(), + "branch_freshness": context.branch_freshness.json_value(), + "boot_preflight": context.boot_preflight.json_value(), + "loaded_config_files": context.loaded_config_files, + "discovered_config_files": context.discovered_config_files, + "memory_file_count": context.memory_file_count, + "memory_files": memory_files_json(&context.memory_files), + "unloaded_memory_files": context.unloaded_memory_files, + "mcp_validation": context.mcp_validation.json_value(), + "hook_validation": context.hook_validation.json_value(), + }, + "sandbox": { + "enabled": context.sandbox_status.enabled, + "active": context.sandbox_status.active, + "supported": context.sandbox_status.supported, + "in_container": context.sandbox_status.in_container, + "requested_namespace": context.sandbox_status.requested.namespace_restrictions, + "active_namespace": context.sandbox_status.namespace_active, + "requested_network": context.sandbox_status.requested.network_isolation, + "active_network": context.sandbox_status.network_active, + "filesystem_mode": context.sandbox_status.filesystem_mode.as_str(), + "filesystem_active": context.sandbox_status.filesystem_active, + "allowed_mounts": context.sandbox_status.allowed_mounts, + "markers": context.sandbox_status.container_markers, + "fallback_reason": context.sandbox_status.fallback_reason, + } + }) +} + +pub(crate) fn status_context( + session_path: Option<&Path>, +) -> Result> { + let cwd = friendly_cwd(env::current_dir()?); + let loader = ConfigLoader::default_for(&cwd); + // #456: count only paths that exist on disk, matching check_config_health behavior. + let discovered_config_files = loader.discover().iter().filter(|e| e.path.exists()).count(); + // #143: degrade gracefully on config parse failure rather than hard-fail. + // `claw doctor` already does this; `claw status` now matches that contract + // so that one malformed `mcpServers.*` entry doesn't take down the whole + // health surface (workspace, git, model, permission, sandbox can still be + // reported independently). + let runtime_config = loader.load(); + let (loaded_config_files, sandbox_status, config_load_error, config_load_error_kind) = + match runtime_config.as_ref() { + Ok(cfg) => ( + cfg.loaded_entries().len(), + resolve_sandbox_status(cfg.sandbox(), &cwd), + None, + None, + ), + Err(err) => { + let err_string = err.to_string(); + let err_kind = classify_error_kind(&err_string); + ( + 0, + // Fall back to defaults for sandbox resolution so claws still see + // a populated sandbox section instead of a missing field. Defaults + // produce the same output as a runtime config with no sandbox + // overrides, which is the right degraded-mode shape: we cannot + // report what the user *intended*, only what is actually in effect. + resolve_sandbox_status(&runtime::SandboxConfig::default(), &cwd), + Some(err_string), + Some(err_kind), + ) + } + }; + let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; + let (project_root, git_branch) = + parse_git_status_metadata(project_context.git_status.as_deref()); + let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); + let branch_freshness = BranchFreshness::from_git_status(project_context.git_status.as_deref()); + let stale_base_state = stale_base_state_for(&cwd, None); + let boot_preflight = build_boot_preflight_snapshot( + &cwd, + project_root.as_deref(), + project_context.git_status.as_deref(), + runtime_config.as_ref().ok(), + config_load_error.as_deref(), + ); + let memory_files = memory_file_summaries_for( + &cwd, + project_root.as_deref(), + &project_context.instruction_files, + ); + let mcp_validation = runtime_config + .as_ref() + .ok() + .map(|runtime_config| McpValidationSummary::from_collection(runtime_config.mcp())) + .unwrap_or_default(); + let hook_validation = runtime_config + .as_ref() + .ok() + .map(HookValidationSummary::from_config) + .unwrap_or_default(); + Ok(StatusContext { + cwd: cwd.clone(), + session_path: session_path.map(Path::to_path_buf), + loaded_config_files, + discovered_config_files, + memory_file_count: project_context.instruction_files.len(), + memory_files: memory_files.clone(), + unloaded_memory_files: unloaded_memory_candidates( + &cwd, + project_root.as_deref(), + &memory_files, + ), + project_root, + git_branch, + git_summary, + branch_freshness, + stale_base_state, + session_lifecycle: classify_session_lifecycle_for(&cwd), + boot_preflight, + sandbox_status, + binary_provenance: binary_provenance_for(Some(&cwd)), + config_load_error, + config_load_error_kind, + mcp_validation, + + hook_validation, + duplicate_flags: take_duplicate_flags(), + }) +} + +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>, + permission_provenance: Option<&PermissionModeProvenance>, +) -> 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 => { + let env_suffix = p + .env_var + .as_deref() + .map_or(String::new(), |name| format!(" via {name}")); + format!( + "\n Model source {}{env_suffix} (raw: {raw}, alias: {model})", + p.source.as_str() + ) + } + Some(_) => { + let env_suffix = p + .env_var + .as_deref() + .map_or(String::new(), |name| format!(" via {name}")); + format!("\n Model source {}{env_suffix}", p.source.as_str()) + } + None => format!("\n Model source {}", p.source.as_str()), + }) + .unwrap_or_default(); + let permission_source_line = permission_provenance + .map(|p| { + let env_suffix = p + .env_var + .map_or(String::new(), |name| format!(" via {name}")); + format!("\n Permission source {}{env_suffix}", p.source.as_str()) + }) + .unwrap_or_default(); + blocks.extend([ + format!( + "{status_line} + Model {model}{model_source_line} + Permission mode {permission_mode}{permission_source_line} + Messages {} + Turns {} + Estimated tokens {}", + usage.message_count, usage.turns, usage.estimated_tokens, + ), + format!( + "Usage + Latest total {} + Cumulative input {} + Cumulative output {} + Cache create {} + Cache read {} + Cumulative total {} + Estimated cost {}", + usage.latest.total_tokens(), + usage.cumulative.input_tokens, + usage.cumulative.output_tokens, + usage.cumulative.cache_creation_input_tokens, + usage.cumulative.cache_read_input_tokens, + usage.cumulative.total_tokens(), + format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()), + ), + format!( + "Workspace + Cwd {} + Project root {} + Git branch {} + Git state {} + Changed files {} + Staged {} + Unstaged {} + Untracked {} + Session {} + Lifecycle {} + Branch fresh {} + Boot preflight {} + Config files loaded {}/{} + Memory files {} + Loaded memory {} + 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"), + if context.project_root.is_some() { + context.git_summary.headline() + } else { + "no_git_repo".to_string() + }, + 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 + .branch_freshness + .fresh + .map(|fresh| if fresh { "yes" } else { "behind" }) + .unwrap_or("no upstream"), + context.boot_preflight.summary(), + context.loaded_config_files, + context.discovered_config_files, + context.memory_file_count, + if context.memory_files.is_empty() { + "".to_string() + } else { + context + .memory_files + .iter() + .map(|file| format!("{}:{}", file.source, file.path)) + .collect::>() + .join(", ") + }, + ), + 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 sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value { + // Derive top-level status so automation can do a single field check + // instead of combining enabled/active/supported booleans. + // ok = not enabled (not requested), OR enabled and active + // warn = enabled and supported but not yet active (degraded), + // OR enabled but unsupported on this platform AND filesystem sandbox is active + // (#731: "not supported on macOS" is a degraded state, not a hard error; + // filesystem_active:true means partial containment is working) + // error = enabled but unsupported AND no filesystem sandbox either (nothing active) + let top_status = if !status.enabled { + "ok" + } else if status.active { + "ok" + } else if status.supported { + "warn" + } else if status.filesystem_active { + // Platform doesn't support namespace isolation but filesystem sandbox is active: + // this is a degraded/partial state, not a hard error. + "warn" + } else { + "error" + }; + json!({ + "kind": "sandbox", + "action": "status", + "status": top_status, + "enabled": status.enabled, + "requested": status.enabled, + "active": status.active, + "supported": status.supported, + "in_container": status.in_container, + "requested_namespace": status.requested.namespace_restrictions, + "active_namespace": status.namespace_active, + "requested_network": status.requested.network_isolation, + "active_network": status.network_active, + "filesystem_mode": status.filesystem_mode.as_str(), + "filesystem_active": status.filesystem_active, + "allowed_mounts": status.allowed_mounts, + "markers": status.container_markers, + "fallback_reason": status.fallback_reason, + "active_components": { + "namespace": status.namespace_active, + "network": status.network_active, + "filesystem": status.filesystem_active, + }, + }) +} + +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 rendered = match section { + "env" => runtime_config.get("env").map(|value| value.render()), + "hooks" => runtime_config.get("hooks").map(|value| value.render()), + "model" => runtime_config.get("model").map(|value| value.render()), + "plugins" => runtime_config + .get("plugins") + .or_else(|| runtime_config.get("enabledPlugins")) + .map(|value| value.render()), + "mcp" | "mcp_servers" | "mcpServers" => runtime_config + .get("mcp") + .or_else(|| runtime_config.get("mcp_servers")) + .or_else(|| runtime_config.get("mcpServers")) + .map(|value| value.render()), + "sandbox" => runtime_config.get("sandbox").map(|value| value.render()), + "permissions" => runtime_config + .get("permissions") + .map(|value| value.render()), + "skills" => runtime_config.get("skills").map(|value| value.render()), + "agents" => runtime_config.get("agents").map(|value| value.render()), + "settings" => Some(runtime_config.as_json().render()), + // #344: /config help shows available sections + "help" => { + lines.push("Available config sections:".to_string()); + lines.push(" env Environment variables".to_string()); + lines.push(" hooks Hook configuration".to_string()); + lines.push(" model Model configuration".to_string()); + lines.push(" plugins Plugin configuration".to_string()); + lines.push(" mcp MCP server configuration".to_string()); + lines.push(" sandbox Sandbox configuration".to_string()); + lines.push(" permissions Permission rules".to_string()); + lines.push(" skills Skills configuration".to_string()); + lines.push(" agents Agent configuration".to_string()); + lines.push(" settings Full merged settings".to_string()); + lines.push(format!(" Loaded keys: {}", runtime_config.merged().len())); + return Ok(lines.join("\n")); + } + other => { + lines.push(format!( + " Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, or settings." + )); + return Ok(lines.join( + " +", + )); + } + }; + lines.push(format!( + " {}", + rendered.unwrap_or_else(|| "".to_string()) + )); + return Ok(lines.join( + " +", + )); + } + + lines.push("Merged JSON".to_string()); + lines.push(format!(" {}", runtime_config.as_json().render())); + Ok(lines.join( + " +", + )) +} + +pub(crate) fn render_config_json( + section: Option<&str>, +) -> Result> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + // #773: keep deprecation warnings in the JSON envelope, and #407: include + // per-file status/reason/detail for every discovered config path. + let inspection = loader.inspect_collecting_warnings(); + if section.is_some() { + if let Some(error) = &inspection.load_error { + return Err(error.clone().into()); + } + } + let runtime_config = inspection + .runtime_config + .clone() + .unwrap_or_else(runtime::RuntimeConfig::empty); + let loaded_files = runtime_config.loaded_entries().len(); + let merged_keys = runtime_config.merged().len(); + // #415: expose actual merged key-value pairs, not just count + let merged_json_str = serde_json::json!(runtime_config + .merged() + .iter() + .map(|(k, v)| { (k.clone(), serde_json::Value::String(v.render())) }) + .collect::>()); + let files: Vec<_> = inspection + .files + .iter() + .map(config_file_report_json) + .collect(); + + let warnings_json: Vec = inspection + .warnings + .iter() + .map(|w| serde_json::Value::String(w.clone())) + .collect(); + + let hook_validation = HookValidationSummary::from_config(&runtime_config); + let has_hook_issues = hook_validation.has_invalid_hooks(); + let status_value = if inspection.load_error.is_some() { + "error" + } else if has_hook_issues { + "degraded" + } else { + "ok" + }; + let base = serde_json::json!({ + "kind": "config", + "action": if section.is_some() { "show" } else { "list" }, + "status": status_value, + "cwd": cwd.display().to_string(), + "loaded_files": loaded_files, + "merged_keys": merged_keys, + "merged_key_count": merged_keys, + "merged": merged_json_str, + "merged_keys_meaning": "count of top-level keys in the effective merged JSON object", + + "files": files, + "warnings": warnings_json, + "load_error": inspection.load_error.clone(), + "hook_validation": hook_validation.json_value(), + }); + + if let Some(section) = section { + let section_rendered: Option = match section { + "env" => runtime_config.get("env").map(|v| v.render()), + "hooks" => runtime_config.get("hooks").map(|v| v.render()), + "model" => runtime_config.get("model").map(|v| v.render()), + "plugins" => runtime_config + .get("plugins") + .or_else(|| runtime_config.get("enabledPlugins")) + .map(|v| v.render()), + // These sections are structurally present in config files but may not have + // dedicated runtime_config keys yet; return null section_value rather than error. + "mcp" | "mcp_servers" | "mcpServers" => runtime_config + .get("mcp") + .or_else(|| runtime_config.get("mcp_servers")) + .or_else(|| runtime_config.get("mcpServers")) + .map(|v| v.render()), + "sandbox" => runtime_config.get("sandbox").map(|v| v.render()), + "permissions" => runtime_config.get("permissions").map(|v| v.render()), + "skills" => runtime_config.get("skills").map(|v| v.render()), + "agents" => runtime_config.get("agents").map(|v| v.render()), + "settings" => Some(runtime_config.as_json().render()), + // #344: /config help returns structured section list + "help" => { + return Ok(serde_json::json!({ + "kind": "config", + "action": "help", + "status": "ok", + "section": "help", + "available_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents", "settings"], + "loaded_keys": runtime_config.merged().len(), + })); + } + other => { + // #741: populate hint field for unsupported section errors so callers reading + // .hint get actionable guidance instead of null + let hint = if matches!(other, "list" | "show" | "info") { + format!( + "'claw config {other}' is not a subcommand. To list all config: `claw config`. To inspect a section: `claw config
` where section is one of: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, settings." + ) + } else { + format!( + "'{other}' is not a config section. Supported: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, settings." + ) + }; + return Ok(serde_json::json!({ + "kind": "config", + "action": "show", + "status": "error", + "error_kind": "unsupported_config_section", + "section": other, + "ok": false, + "error": format!("Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, agents, or settings."), + "hint": hint, + "supported_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents", "settings"], + "cwd": cwd.display().to_string(), + "loaded_files": loaded_files, + "files": base["files"].clone(), + })); + } + }; + // Parse the rendered JSON string back into serde_json::Value so that + // section_value is a real JSON object/array in the envelope, not a quoted string. + let section_value: serde_json::Value = section_rendered + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(serde_json::Value::Null); + let mut obj = base; + let map = obj.as_object_mut().expect("base is object"); + map.insert( + "section".to_string(), + serde_json::Value::String(section.to_string()), + ); + map.insert("section_value".to_string(), section_value); + return Ok(obj); + } + + Ok(base) +} + +/// #142: emit first-class structured fields alongside the legacy `message` +/// string so claws can detect per-artifact state without substring matching. +pub(crate) fn init_json_value( + report: &crate::init::InitReport, + message: &str, +) -> serde_json::Value { + use crate::init::InitStatus; + // Derive top-level status: "ok" when all artifacts succeeded (created or + // skipped = idempotent); no failure path exists today so always "ok". + let status = "ok"; + // #783/#436: already_initialized lets orchestrators detect the idempotent + // case without checking every status bucket; deferred session storage does + // not make the workspace uninitialized because it is created on first save. + let already_initialized = report.artifacts_with_status(InitStatus::Created).is_empty() + && report.artifacts_with_status(InitStatus::Updated).is_empty() + && report.artifacts_with_status(InitStatus::Partial).is_empty(); + let hint = if already_initialized { + "Workspace already initialised. Run `claw doctor` to verify health, or edit CLAUDE.md to customise guidance." + } else { + "Review and tailor CLAUDE.md to your project, then run `claw doctor` to verify the workspace." + }; + json!({ + "kind": "init", + "action": "init", + "status": status, + "already_initialized": already_initialized, + "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), + "partial": report.artifacts_with_status(InitStatus::Partial), + "deferred": report.artifacts_with_status(InitStatus::Deferred), + "artifacts": report.artifact_json_entries(), + "hint": hint, + "next_step": crate::init::InitReport::NEXT_STEP, + "message": message, + }) +} + +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 render_version_report() -> String { + let git_sha = GIT_SHA_SHORT.or(GIT_SHA).unwrap_or("unknown"); + let target = BUILD_TARGET.unwrap_or("unknown"); + let branch = GIT_BRANCH.unwrap_or("unknown"); + let dirty = GIT_DIRTY.unwrap_or("unknown"); + format!( + "Claw Code\n Version {VERSION}\n Git SHA {git_sha}\n Branch {branch}\n Dirty {dirty}\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::Thinking { .. } => {} + ContentBlock::ToolUse { id, name, input } => { + lines.push(format!("[tool_use id={id} name={name}] {input}")); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => { + lines.push(format!( + "[tool_result id={tool_use_id} name={tool_name} error={is_error}] {output}" + )); + } + } + } + lines.push(String::new()); + } + lines.join("\n") +} + +pub(crate) fn 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 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) struct PluginsCommandPayload { + pub(crate) message: String, + pub(crate) reload_runtime: bool, + pub(crate) status: &'static str, + pub(crate) config_load_error: Option, + pub(crate) mcp_validation: McpValidationSummary, + pub(crate) plugins: Vec, + pub(crate) load_failures: Vec, +} + +pub(crate) fn plugins_command_payload_for( + cwd: &Path, + action: Option<&str>, + target: Option<&str>, + config_warning_mode: ConfigWarningMode, +) -> Result> { + let loader = ConfigLoader::default_for(cwd); + let loaded_config = load_config_with_warning_mode(&loader, config_warning_mode); + let (runtime_config, config_load_error, mcp_validation) = match loaded_config { + Ok(runtime_config) => { + let mcp_validation = McpValidationSummary::from_collection(runtime_config.mcp()); + (runtime_config, None, mcp_validation) + } + Err(error) => ( + runtime::RuntimeConfig::empty(), + Some(error.to_string()), + McpValidationSummary::default(), + ), + }; + let mut manager = build_plugin_manager(cwd, &loader, &runtime_config); + let result = handle_plugins_slash_command(action, target, &mut manager)?; + let report = manager.installed_plugin_registry_report()?; + Ok(plugins_command_payload_from_result( + result, + config_load_error, + mcp_validation, + &report, + )) +} + +pub(crate) fn plugins_command_payload_from_result( + result: PluginsCommandResult, + config_load_error: Option, + mcp_validation: McpValidationSummary, + report: &plugins::PluginRegistryReport, +) -> PluginsCommandPayload { + let failures = report.failures(); + let status = if config_load_error.is_some() + || mcp_validation.has_invalid_servers() + || !failures.is_empty() + { + "degraded" + } else { + "ok" + }; + let message = match config_load_error.as_deref() { + Some(error) => format!( + "Config load error\n Status fail\n Summary runtime config failed to load; reporting partial plugins view\n Details {error}\n Hint `claw doctor` classifies config parse errors; fix the listed field and rerun\n\n{}", + result.message + ), + None if mcp_validation.has_invalid_servers() => format!( + "MCP validation\n Status warn\n Summary {} MCP server entries are invalid; reporting plugins with valid MCP siblings only\n Hint Inspect `claw mcp list --output-format json` invalid_servers and fix each rejected mcpServers entry.\n\n{}", + mcp_validation.invalid_count(), + result.message + ), + None => result.message, + }; + PluginsCommandPayload { + message, + reload_runtime: result.reload_runtime, + status, + config_load_error, + mcp_validation, + plugins: report.summaries().iter().map(plugin_summary_json).collect(), + load_failures: failures.iter().map(plugin_load_failure_json).collect(), + } +} + +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 describe_tool_progress(name: &str, input: &str) -> String { + let parsed: serde_json::Value = + serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); + match name { + "bash" | "Bash" => { + let command = parsed + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if command.is_empty() { + "running shell command".to_string() + } else { + format!("command {}", truncate_for_summary(command.trim(), 100)) + } + } + "read_file" | "Read" => format!("reading {}", extract_tool_path(&parsed)), + "write_file" | "Write" => format!("writing {}", extract_tool_path(&parsed)), + "edit_file" | "Edit" => format!("editing {}", extract_tool_path(&parsed)), + "glob_search" | "Glob" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("glob `{pattern}` in {scope}") + } + "grep_search" | "Grep" => { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("grep `{pattern}` in {scope}") + } + "web_search" | "WebSearch" => parsed + .get("query") + .and_then(|value| value.as_str()) + .map_or_else( + || "running web search".to_string(), + |query| format!("query {}", truncate_for_summary(query, 100)), + ), + _ => { + let summary = summarize_tool_payload(input); + if summary.is_empty() { + format!("running {name}") + } else { + format!("{name}: {summary}") + } + } + } +} + +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 extract_tool_path(parsed: &serde_json::Value) -> String { + parsed + .get("file_path") + .or_else(|| parsed.get("filePath")) + .or_else(|| parsed.get("path")) + .and_then(|value| value.as_str()) + .unwrap_or("?") + .to_string() +} + +pub(crate) fn format_search_start(label: &str, parsed: &serde_json::Value) -> String { + let pattern = parsed + .get("pattern") + .and_then(|value| value.as_str()) + .unwrap_or("?"); + let scope = parsed + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("."); + format!("{label} {pattern}\n\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 summarize_tool_payload(payload: &str) -> String { + let compact = match serde_json::from_str::(payload) { + Ok(value) => value.to_string(), + Err(_) => payload.trim().to_string(), + }; + truncate_for_summary(&compact, 96) +} + +pub(crate) fn truncate_for_summary(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let truncated = chars.by_ref().take(limit).collect::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +pub(crate) fn truncate_output_for_display( + content: &str, + max_lines: usize, + max_chars: usize, +) -> String { + let original = content.trim_end_matches('\n'); + if original.is_empty() { + return String::new(); + } + + let mut preview_lines = Vec::new(); + let mut used_chars = 0usize; + let mut truncated = false; + + for (index, line) in original.lines().enumerate() { + if index >= max_lines { + truncated = true; + break; + } + + let newline_cost = usize::from(!preview_lines.is_empty()); + let available = max_chars.saturating_sub(used_chars + newline_cost); + if available == 0 { + truncated = true; + break; + } + + let line_chars = line.chars().count(); + if line_chars > available { + preview_lines.push(line.chars().take(available).collect::()); + truncated = true; + break; + } + + preview_lines.push(line.to_string()); + used_chars += newline_cost + line_chars; + } + + let mut preview = preview_lines.join("\n"); + if truncated { + if !preview.is_empty() { + preview.push('\n'); + } + preview.push_str(DISPLAY_TRUNCATION_NOTICE); + } + preview +} diff --git a/rust/crates/rusty-claude-cli/src/progress.rs b/rust/crates/rusty-claude-cli/src/progress.rs new file mode 100644 index 0000000000..7d42056f3c --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/progress.rs @@ -0,0 +1,416 @@ +use crate::*; + +use api::{ + detect_provider_kind, model_family_identity_for, 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, PluginsCommandResult, SkillSlashDispatch, + SlashCommand, +}; +use init::initialize_repo; +use log::debug; +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, load_system_prompt_with_context, pricing_for_model, resolve_expected_base, + resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState, + CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile, + ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig, + McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, + PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, + RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::net::TcpListener; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, UNIX_EPOCH}; +use tools::{ + canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry, + RuntimeToolDefinition, ToolSearchOutput, +}; + +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(); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InternalPromptProgressState { + pub(crate) command_label: &'static str, + pub(crate) task_label: String, + pub(crate) step: usize, + pub(crate) phase: String, + pub(crate) detail: Option, + pub(crate) saw_final_text: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InternalPromptProgressEvent { + Started, + Update, + Heartbeat, + Complete, + Failed, +} + +#[derive(Debug)] +pub(crate) struct InternalPromptProgressShared { + pub(crate) state: Mutex, + pub(crate) output_lock: Mutex<()>, + pub(crate) started_at: Instant, +} + +#[derive(Debug, Clone)] +pub(crate) struct InternalPromptProgressReporter { + pub(crate) shared: Arc, +} + +#[derive(Debug)] +pub(crate) struct InternalPromptProgressRun { + pub(crate) reporter: InternalPromptProgressReporter, + pub(crate) heartbeat_stop: Option>, + pub(crate) heartbeat_handle: Option>, +} + +impl InternalPromptProgressReporter { + pub(crate) fn ultraplan(task: &str) -> Self { + Self { + shared: Arc::new(InternalPromptProgressShared { + state: Mutex::new(InternalPromptProgressState { + command_label: "Ultraplan", + task_label: task.to_string(), + step: 0, + phase: "planning started".to_string(), + detail: Some(format!("task: {task}")), + saw_final_text: false, + }), + output_lock: Mutex::new(()), + started_at: Instant::now(), + }), + } + } + + pub(crate) fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { + let snapshot = self.snapshot(); + let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); + self.write_line(&line); + } + + pub(crate) fn mark_model_phase(&self) { + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = if state.step == 1 { + "analyzing request".to_string() + } else { + "reviewing findings".to_string() + }; + state.detail = Some(format!("task: {}", state.task_label)); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + pub(crate) fn mark_tool_phase(&self, name: &str, input: &str) { + let detail = describe_tool_progress(name, input); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + state.step += 1; + state.phase = format!("running {name}"); + state.detail = Some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + pub(crate) fn mark_text_phase(&self, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let detail = truncate_for_summary(first_visible_line(trimmed), 120); + let snapshot = { + let mut state = self + .shared + .state + .lock() + .expect("internal prompt progress state poisoned"); + if state.saw_final_text { + return; + } + state.saw_final_text = true; + state.step += 1; + state.phase = "drafting final plan".to_string(); + state.detail = (!detail.is_empty()).then_some(detail); + state.clone() + }; + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Update, + &snapshot, + self.elapsed(), + None, + )); + } + + pub(crate) fn emit_heartbeat(&self) { + let snapshot = self.snapshot(); + self.write_line(&format_internal_prompt_progress_line( + InternalPromptProgressEvent::Heartbeat, + &snapshot, + self.elapsed(), + None, + )); + } + + pub(crate) fn snapshot(&self) -> InternalPromptProgressState { + self.shared + .state + .lock() + .expect("internal prompt progress state poisoned") + .clone() + } + + pub(crate) fn elapsed(&self) -> Duration { + self.shared.started_at.elapsed() + } + + pub(crate) fn write_line(&self, line: &str) { + let _guard = self + .shared + .output_lock + .lock() + .expect("internal prompt progress output lock poisoned"); + let mut stdout = io::stdout(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); + } +} + +impl InternalPromptProgressRun { + pub(crate) fn start_ultraplan(task: &str) -> Self { + let reporter = InternalPromptProgressReporter::ultraplan(task); + reporter.emit(InternalPromptProgressEvent::Started, None); + + let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); + let heartbeat_reporter = reporter.clone(); + let heartbeat_handle = thread::spawn(move || loop { + match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), + } + }); + + Self { + reporter, + heartbeat_stop: Some(heartbeat_stop), + heartbeat_handle: Some(heartbeat_handle), + } + } + + pub(crate) fn reporter(&self) -> InternalPromptProgressReporter { + self.reporter.clone() + } + + pub(crate) fn finish_success(&mut self) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Complete, None); + } + + pub(crate) fn finish_failure(&mut self, error: &str) { + self.stop_heartbeat(); + self.reporter + .emit(InternalPromptProgressEvent::Failed, Some(error)); + } + + pub(crate) fn stop_heartbeat(&mut self) { + if let Some(sender) = self.heartbeat_stop.take() { + let _ = sender.send(()); + } + if let Some(handle) = self.heartbeat_handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for InternalPromptProgressRun { + fn drop(&mut self) { + self.stop_heartbeat(); + } +} + +pub(crate) fn format_internal_prompt_progress_line( + event: InternalPromptProgressEvent, + snapshot: &InternalPromptProgressState, + elapsed: Duration, + error: Option<&str>, +) -> String { + let elapsed_seconds = elapsed.as_secs(); + let step_label = if snapshot.step == 0 { + "current step pending".to_string() + } else { + format!("current step {}", snapshot.step) + }; + let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)]; + if let Some(detail) = snapshot + .detail + .as_deref() + .filter(|detail| !detail.is_empty()) + { + status_bits.push(detail.to_string()); + } + let status = status_bits.join(" · "); + 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) struct CliHookProgressReporter; + +impl runtime::HookProgressReporter for CliHookProgressReporter { + fn on_event(&mut self, event: &runtime::HookProgressEvent) { + match event { + runtime::HookProgressEvent::Started { + event, + tool_name, + command, + } => eprintln!( + "[hook {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + runtime::HookProgressEvent::Completed { + event, + tool_name, + command, + } => eprintln!( + "[hook done {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + runtime::HookProgressEvent::Cancelled { + event, + tool_name, + command, + } => eprintln!( + "[hook cancelled {event_name}] {tool_name}: {command}", + event_name = event.as_str() + ), + } + } +} + +pub(crate) fn first_visible_line(text: &str) -> &str { + text.lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(text) +} diff --git a/rust/crates/rusty-claude-cli/src/provider_client.rs b/rust/crates/rusty-claude-cli/src/provider_client.rs new file mode 100644 index 0000000000..02e2e644ce --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/provider_client.rs @@ -0,0 +1,1696 @@ +use crate::*; + +use api::{ + detect_provider_kind, model_family_identity_for, 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, PluginsCommandResult, SkillSlashDispatch, + SlashCommand, +}; +use init::initialize_repo; +use log::debug; +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, load_system_prompt_with_context, pricing_for_model, resolve_expected_base, + resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState, + CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile, + ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig, + McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, + PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, + RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::net::TcpListener; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, UNIX_EPOCH}; +use tools::{ + canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry, + RuntimeToolDefinition, ToolSearchOutput, +}; + +pub(crate) fn max_tokens_for_model(model: &str) -> u32 { + api::max_tokens_for_model(model) +} + +pub(crate) fn normalize_allowed_tools(values: &[String]) -> Result, String> { + if values.is_empty() { + return Ok(None); + } + current_tool_registry()?.normalize_allowed_tools(values) +} + +pub(crate) fn current_tool_registry() -> Result { + let cwd = env::current_dir().map_err(|error| error.to_string())?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load().map_err(|error| error.to_string())?; + let state = build_runtime_plugin_state_with_loader(&cwd, &loader, &runtime_config) + .map_err(|error| error.to_string())?; + let registry = state.tool_registry.clone(); + if let Some(mcp_state) = state.mcp_state { + mcp_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .shutdown() + .map_err(|error| error.to_string())?; + } + Ok(registry) +} + +pub(crate) fn filter_tool_specs( + tool_registry: &GlobalToolRegistry, + allowed_tools: Option<&AllowedToolSet>, +) -> Vec { + tool_registry.definitions(allowed_tools) +} + +pub(crate) fn command_available(command: &str) -> bool { + Command::new(command) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) +} + +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::from([( + "required".to_string(), + failure.required.to_string(), + )]), + 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(), + ), + ("required".to_string(), server.required.to_string()), + ]), + false, + ), + } + })) + .collect::>(); + let degraded_report = (!failed_servers.is_empty()).then(|| { + runtime::McpDegradedReport::new( + working_servers, + failed_servers, + available_tools.clone(), + available_tools, + ) + }); + + Ok(Some(( + Self { + runtime, + manager, + pending_servers, + degraded_report, + }, + discovery, + ))) + } + + pub(crate) fn shutdown(&mut self) -> Result<(), Box> { + self.runtime.block_on(self.manager.shutdown())?; + Ok(()) + } + + pub(crate) fn pending_servers(&self) -> Option> { + (!self.pending_servers.is_empty()).then(|| self.pending_servers.clone()) + } + + pub(crate) fn degraded_report(&self) -> Option { + self.degraded_report.clone() + } + + pub(crate) fn server_names(&self) -> Vec { + self.manager.server_names() + } + + pub(crate) fn call_tool( + &mut self, + qualified_tool_name: &str, + arguments: Option, + ) -> Result { + let response = self + .runtime + .block_on(self.manager.call_tool(qualified_tool_name, arguments)) + .map_err(|error| ToolError::new(error.to_string()))?; + if let Some(error) = response.error { + return Err(ToolError::new(format!( + "MCP tool `{qualified_tool_name}` returned JSON-RPC error: {} ({})", + error.message, error.code + ))); + } + + let result = response.result.ok_or_else(|| { + ToolError::new(format!( + "MCP tool `{qualified_tool_name}` returned no result payload" + )) + })?; + serde_json::to_string_pretty(&result).map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn list_resources_for_server( + &mut self, + server_name: &str, + ) -> Result { + let result = self + .runtime + .block_on(self.manager.list_resources(server_name)) + .map_err(|error| ToolError::new(error.to_string()))?; + serde_json::to_string_pretty(&json!({ + "server": server_name, + "resources": result.resources, + })) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn list_resources_for_all_servers(&mut self) -> Result { + let mut resources = Vec::new(); + let mut failures = Vec::new(); + + for server_name in self.server_names() { + match self + .runtime + .block_on(self.manager.list_resources(&server_name)) + { + Ok(result) => resources.push(json!({ + "server": server_name, + "resources": result.resources, + })), + Err(error) => failures.push(json!({ + "server": server_name, + "error": error.to_string(), + })), + } + } + + if resources.is_empty() && !failures.is_empty() { + let message = failures + .iter() + .filter_map(|failure| failure.get("error").and_then(serde_json::Value::as_str)) + .collect::>() + .join("; "); + return Err(ToolError::new(message)); + } + + serde_json::to_string_pretty(&json!({ + "resources": resources, + "failures": failures, + })) + .map_err(|error| ToolError::new(error.to_string())) + } + + pub(crate) fn read_resource( + &mut self, + server_name: &str, + uri: &str, + ) -> Result { + let result = self + .runtime + .block_on(self.manager.read_resource(server_name, uri)) + .map_err(|error| ToolError::new(error.to_string()))?; + serde_json::to_string_pretty(&json!({ + "server": server_name, + "contents": result.contents, + })) + .map_err(|error| ToolError::new(error.to_string())) + } +} + +pub(crate) fn build_runtime_mcp_state( + runtime_config: &runtime::RuntimeConfig, +) -> Result> { + let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else { + return Ok((None, Vec::new())); + }; + + let mut runtime_tools = discovery + .tools + .iter() + .map(mcp_runtime_tool_definition) + .collect::>(); + if !mcp_state.server_names().is_empty() { + runtime_tools.extend(mcp_wrapper_tool_definitions()); + } + + Ok((Some(Arc::new(Mutex::new(mcp_state))), runtime_tools)) +} + +pub(crate) fn mcp_runtime_tool_definition(tool: &runtime::ManagedMcpTool) -> RuntimeToolDefinition { + RuntimeToolDefinition { + name: tool.qualified_name.clone(), + description: Some( + tool.tool + .description + .clone() + .unwrap_or_else(|| format!("Invoke MCP tool `{}`.", tool.qualified_name)), + ), + input_schema: tool + .tool + .input_schema + .clone() + .unwrap_or_else(|| json!({ "type": "object", "additionalProperties": true })), + required_permission: permission_mode_for_mcp_tool(&tool.tool), + } +} + +pub(crate) fn mcp_wrapper_tool_definitions() -> Vec { + vec![ + RuntimeToolDefinition { + name: "MCPTool".to_string(), + description: Some( + "Call a configured MCP tool by its qualified name and JSON arguments.".to_string(), + ), + input_schema: json!({ + "type": "object", + "properties": { + "qualifiedName": { "type": "string" }, + "arguments": {} + }, + "required": ["qualifiedName"], + "additionalProperties": false + }), + required_permission: PermissionMode::DangerFullAccess, + }, + RuntimeToolDefinition { + name: "ListMcpResourcesTool".to_string(), + description: Some( + "List MCP resources from one configured server or from every connected server." + .to_string(), + ), + input_schema: json!({ + "type": "object", + "properties": { + "server": { "type": "string" } + }, + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + RuntimeToolDefinition { + name: "ReadMcpResourceTool".to_string(), + description: Some("Read a specific MCP resource from a configured server.".to_string()), + input_schema: json!({ + "type": "object", + "properties": { + "server": { "type": "string" }, + "uri": { "type": "string" } + }, + "required": ["server", "uri"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ] +} + +pub(crate) fn permission_mode_for_mcp_tool(tool: &McpTool) -> PermissionMode { + let read_only = mcp_annotation_flag(tool, "readOnlyHint"); + let destructive = mcp_annotation_flag(tool, "destructiveHint"); + let open_world = mcp_annotation_flag(tool, "openWorldHint"); + + if read_only && !destructive && !open_world { + PermissionMode::ReadOnly + } else if destructive || open_world { + PermissionMode::DangerFullAccess + } else { + PermissionMode::WorkspaceWrite + } +} + +pub(crate) fn mcp_annotation_flag(tool: &McpTool, key: &str) -> bool { + tool.annotations + .as_ref() + .and_then(|annotations| annotations.get(key)) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +pub(crate) fn confirm_session_deletion(session_id: &str) -> bool { + print!("Delete session '{session_id}'? This cannot be undone. [y/N]: "); + io::stdout().flush().unwrap_or(()); + let mut answer = String::new(); + if io::stdin().read_line(&mut answer).is_err() { + return false; + } + matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES") +} + +pub(crate) fn command_exists(name: &str) -> bool { + Command::new("which") + .arg(name) + .output() + .is_ok_and(|output| output.status.success()) +} + +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::Thinking { .. } => {} + ContentBlock::ToolUse { id, name, input } => { + lines.push(format!( + "**Tool call** `{name}` _(id `{}`)_", + short_tool_id(id) + )); + let summary = summarize_tool_payload_for_markdown(input); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => { + let status = if *is_error { "error" } else { "ok" }; + lines.push(format!( + "**Tool result** `{tool_name}` _(id `{}`, {status})_", + short_tool_id(tool_use_id) + )); + let summary = summarize_tool_payload_for_markdown(output); + if !summary.is_empty() { + lines.push(format!("> {summary}")); + } + lines.push(String::new()); + } + } + } + if let Some(usage) = message.usage { + lines.push(format!( + "_tokens: in={} out={} cache_create={} cache_read={}_", + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cache_read_input_tokens, + )); + lines.push(String::new()); + } + } + lines.join("\n") +} + +pub(crate) fn 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 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)] +pub(crate) fn build_runtime_with_plugin_state( + mut session: Session, + session_id: &str, + model: String, + system_prompt: Vec, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + permission_mode: PermissionMode, + progress_reporter: Option, + runtime_plugin_state: RuntimePluginState, +) -> Result> { + // 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)) +} + +// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct +// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI, +// OpenAI, or DashScope at construction time based on +// `detect_provider_kind(&model)`. The struct name is kept to avoid +// churning `BuiltRuntime` and every Deref/DerefMut site that references +// it. See ROADMAP #29 for the provider-dispatch routing fix. +pub(crate) struct AnthropicRuntimeClient { + pub(crate) runtime: tokio::runtime::Runtime, + pub(crate) client: ApiProviderClient, + pub(crate) session_id: String, + pub(crate) model: String, + pub(crate) enable_tools: bool, + pub(crate) emit_output: bool, + pub(crate) allowed_tools: Option, + pub(crate) tool_registry: GlobalToolRegistry, + pub(crate) progress_reporter: Option, + pub(crate) reasoning_effort: Option, +} + +impl AnthropicRuntimeClient { + pub(crate) fn new( + session_id: &str, + model: String, + enable_tools: bool, + emit_output: bool, + allowed_tools: Option, + tool_registry: GlobalToolRegistry, + progress_reporter: Option, + ) -> Result> { + // Dispatch to the correct provider at construction time. + // `ApiProviderClient` (exposed by the api crate as + // `ProviderClient`) is an enum over Anthropic / xAI / OpenAI + // variants, where xAI and OpenAI both use the OpenAI-compat + // wire format under the hood. We consult + // `detect_provider_kind(&resolved_model)` so model-name prefix + // routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over + // env-var presence. + // + // For Anthropic we build the client directly instead of going + // through `ApiProviderClient::from_model_with_anthropic_auth` + // so we can explicitly apply `api::read_base_url()` — that + // reads `ANTHROPIC_BASE_URL` and is required for the local + // mock-server test harness + // (`crates/rusty-claude-cli/tests/compact_output.rs`) to point + // claw at its fake Anthropic endpoint. We also attach a + // session-scoped prompt cache on the Anthropic path; the + // prompt cache is Anthropic-only so non-Anthropic variants + // skip it. + let resolved_model = api::resolve_model_alias(&model); + let client = match detect_provider_kind(&resolved_model) { + ProviderKind::Anthropic => { + let auth = resolve_cli_auth_source()?; + let inner = AnthropicClient::from_auth(auth) + .with_base_url(api::read_base_url()) + .with_prompt_cache(PromptCache::new(session_id)); + ApiProviderClient::Anthropic(inner) + } + ProviderKind::Xai | ProviderKind::OpenAi => { + // The api crate's `ProviderClient::from_model_with_anthropic_auth` + // with `None` for the anthropic auth routes via + // `detect_provider_kind` and builds an + // `OpenAiCompatClient::from_env` with the matching + // `OpenAiCompatConfig` (openai / xai / dashscope). + // That reads the correct API-key env var and BASE_URL + // override internally, so this one call covers OpenAI, + // OpenRouter, xAI, DashScope, Ollama, and any other + // OpenAI-compat endpoint users configure via + // `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`. + ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)? + } + }; + Ok(Self { + runtime: tokio::runtime::Runtime::new()?, + client, + session_id: session_id.to_string(), + model, + enable_tools, + emit_output, + allowed_tools, + tool_registry, + progress_reporter, + reasoning_effort: None, + }) + } + + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + self.reasoning_effort = effort; + } +} + +impl ApiClient for AnthropicRuntimeClient { + #[allow(clippy::too_many_lines)] + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_model_phase(); + } + let is_post_tool = request_ends_with_tool_result(&request); + let message_request = MessageRequest { + model: self.model.clone(), + max_tokens: max_tokens_for_model(&self.model), + messages: convert_messages(&request.messages), + system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), + tools: self + .enable_tools + .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())), + tool_choice: self.enable_tools.then_some(ToolChoice::Auto), + stream: true, + reasoning_effort: self.reasoning_effort.clone(), + ..Default::default() + }; + + self.runtime.block_on(async { + // When resuming after tool execution, apply a stall timeout on the + // first stream event. If the model does not respond within the + // deadline we drop the stalled connection and re-send the request as + // a continuation nudge (one retry only). + let max_attempts: usize = if is_post_tool { 2 } else { 1 }; + + for attempt in 1..=max_attempts { + let result = self + .consume_stream(&message_request, is_post_tool && attempt == 1) + .await; + match result { + Ok(events) => return Ok(events), + Err(error) + if error.to_string().contains("post-tool stall") + && attempt < max_attempts => + { + // Stalled after tool completion — nudge the model by + // re-sending the same request. + } + Err(error) => return Err(error), + } + } + + Err(RuntimeError::new("post-tool continuation nudge exhausted")) + }) + } +} + +impl AnthropicRuntimeClient { + /// Consume a single streaming response, optionally applying a stall + /// timeout on the first event for post-tool continuations. + #[allow(clippy::too_many_lines)] + pub(crate) async fn consume_stream( + &self, + message_request: &MessageRequest, + apply_stall_timeout: bool, + ) -> Result, RuntimeError> { + let mut stream = self + .client + .stream_message(message_request) + .await + .map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?; + let mut stdout = io::stdout(); + let mut sink = io::sink(); + let out: &mut dyn Write = if self.emit_output { + &mut stdout + } else { + &mut sink + }; + let renderer = TerminalRenderer::new(); + let mut markdown_stream = MarkdownStreamState::default(); + let mut events = Vec::new(); + let mut pending_tool: Option<(String, String, String)> = None; + // 累积 reasoning_content 到 Thinking 块(修复 DeepSeek V4 reasoning_content 协议 bug) + let mut pending_thinking: Option<(String, Option)> = None; + let mut block_has_thinking_summary = false; + let mut saw_stop = false; + let mut received_any_event = false; + + loop { + let next = if apply_stall_timeout && !received_any_event { + match tokio::time::timeout(POST_TOOL_STALL_TIMEOUT, stream.next_event()).await { + Ok(inner) => inner.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?, + Err(_elapsed) => { + return Err(RuntimeError::new( + "post-tool stall: model did not respond within timeout", + )); + } + } + } else { + stream.next_event().await.map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })? + }; + + let Some(event) = next else { + break; + }; + received_any_event = true; + + match event { + ApiStreamEvent::MessageStart(start) => { + for block in start.message.content { + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + } + ApiStreamEvent::ContentBlockStart(start) => { + // 特判 Thinking 块:初始化 pending_thinking(用于累积后续 ThinkingDelta) + if let OutputContentBlock::Thinking { + thinking, + signature, + } = &start.content_block + { + pending_thinking = Some((thinking.clone(), signature.clone())); + } + push_output_block( + start.content_block, + out, + &mut events, + &mut pending_tool, + true, + &mut block_has_thinking_summary, + )?; + } + ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { + ContentBlockDelta::TextDelta { text } => { + if !text.is_empty() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_text_phase(&text); + } + if let Some(rendered) = markdown_stream.push(&renderer, &text) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + events.push(AssistantEvent::TextDelta(text)); + } + } + ContentBlockDelta::InputJsonDelta { partial_json } => { + if let Some((_, _, input)) = &mut pending_tool { + input.push_str(&partial_json); + } + } + ContentBlockDelta::ThinkingDelta { thinking } => { + if !block_has_thinking_summary { + render_thinking_block_summary(out, None, false)?; + block_has_thinking_summary = true; + } + // 累积 thinking 文本到 pending_thinking(让 session 持久化能拿到) + if let Some((t, _)) = &mut pending_thinking { + t.push_str(&thinking); + } + } + ContentBlockDelta::SignatureDelta { signature } => { + // 累积 signature 到 pending_thinking + if let Some((_, sig)) = &mut pending_thinking { + sig.get_or_insert_with(String::new).push_str(&signature); + } + } + }, + ApiStreamEvent::ContentBlockStop(_) => { + block_has_thinking_summary = false; + if let Some(rendered) = markdown_stream.flush(&renderer) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + // 把累积的 thinking 转成 AssistantEvent::Thinking(让 build_assistant_message 写入 session) + if let Some((thinking, signature)) = pending_thinking.take() { + events.push(AssistantEvent::Thinking { + thinking, + signature, + }); + } + if let Some((id, name, input)) = pending_tool.take() { + if let Some(progress_reporter) = &self.progress_reporter { + progress_reporter.mark_tool_phase(&name, &input); + } + // Display tool call now that input is fully accumulated + writeln!(out, "\n{}", format_tool_call_start(&name, &input)) + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + ApiStreamEvent::MessageDelta(delta) => { + events.push(AssistantEvent::Usage(delta.usage.token_usage())); + } + ApiStreamEvent::MessageStop(_) => { + saw_stop = true; + if let Some(rendered) = markdown_stream.flush(&renderer) { + write!(out, "{rendered}") + .and_then(|()| out.flush()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + } + events.push(AssistantEvent::MessageStop); + } + } + } + + push_prompt_cache_record(&self.client, &mut events); + + if !saw_stop + && events.iter().any(|event| { + matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty()) + || matches!(event, AssistantEvent::ToolUse { .. }) + }) + { + events.push(AssistantEvent::MessageStop); + } + + if events + .iter() + .any(|event| matches!(event, AssistantEvent::MessageStop)) + { + return Ok(events); + } + + let response = self + .client + .send_message(&MessageRequest { + stream: false, + ..message_request.clone() + }) + .await + .map_err(|error| { + RuntimeError::new(format_user_visible_api_error(&self.session_id, &error)) + })?; + let mut events = response_to_events(response, out)?; + push_prompt_cache_record(&self.client, &mut events); + Ok(events) + } +} + +/// Returns `true` when the conversation ends with a tool-result message, +/// meaning the model is expected to continue after tool execution. +pub(crate) fn request_ends_with_tool_result(request: &ApiRequest) -> bool { + request + .messages + .last() + .is_some_and(|message| message.role == MessageRole::Tool) +} + +pub(crate) fn 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 collect_tool_uses(summary: &runtime::TurnSummary) -> Vec { + summary + .assistant_messages + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + ContentBlock::ToolUse { id, name, input } => Some(json!({ + "id": id, + "name": name, + "input": input, + })), + _ => None, + }) + .collect() +} + +pub(crate) fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec { + summary + .tool_results + .iter() + .flat_map(|message| message.blocks.iter()) + .filter_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + tool_name, + output, + is_error, + } => Some(json!({ + "tool_use_id": tool_use_id, + "tool_name": tool_name, + "output": output, + "is_error": is_error, + })), + _ => None, + }) + .collect() +} + +pub(crate) fn collect_prompt_cache_events( + summary: &runtime::TurnSummary, +) -> Vec { + summary + .prompt_cache_events + .iter() + .map(|event| { + json!({ + "unexpected": event.unexpected, + "reason": event.reason, + "previous_cache_read_input_tokens": event.previous_cache_read_input_tokens, + "current_cache_read_input_tokens": event.current_cache_read_input_tokens, + "token_drop": event.token_drop, + }) + }) + .collect() +} + +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, + signature, + } => { + render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; + events.push(AssistantEvent::Thinking { + thinking, + signature, + }); + *block_has_thinking_summary = true; + } + OutputContentBlock::RedactedThinking { .. } => { + render_thinking_block_summary(out, None, true)?; + *block_has_thinking_summary = true; + } + } + Ok(()) +} + +pub(crate) fn response_to_events( + response: MessageResponse, + out: &mut (impl Write + ?Sized), +) -> Result, RuntimeError> { + let mut events = Vec::new(); + let mut pending_tool = None; + + for block in response.content { + let mut block_has_thinking_summary = false; + push_output_block( + block, + out, + &mut events, + &mut pending_tool, + false, + &mut block_has_thinking_summary, + )?; + if let Some((id, name, input)) = pending_tool.take() { + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + + events.push(AssistantEvent::Usage(response.usage.token_usage())); + events.push(AssistantEvent::MessageStop); + Ok(events) +} + +pub(crate) fn push_prompt_cache_record( + client: &ApiProviderClient, + events: &mut Vec, +) { + // `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)); + } + } +} + +pub(crate) fn prompt_cache_record_to_runtime_event( + record: api::PromptCacheRecord, +) -> Option { + let cache_break = record.cache_break?; + Some(PromptCacheEvent { + unexpected: cache_break.unexpected, + reason: cache_break.reason, + previous_cache_read_input_tokens: cache_break.previous_cache_read_input_tokens, + current_cache_read_input_tokens: cache_break.current_cache_read_input_tokens, + token_drop: cache_break.token_drop, + }) +} + +pub(crate) struct CliToolExecutor { + pub(crate) renderer: TerminalRenderer, + pub(crate) emit_output: bool, + pub(crate) allowed_tools: Option, + pub(crate) tool_registry: GlobalToolRegistry, + pub(crate) mcp_state: Option>>, +} + +impl CliToolExecutor { + pub(crate) fn new( + allowed_tools: Option, + emit_output: bool, + tool_registry: GlobalToolRegistry, + mcp_state: Option>>, + ) -> Self { + Self { + renderer: TerminalRenderer::new(), + emit_output, + allowed_tools, + tool_registry, + mcp_state, + } + } + + pub(crate) 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())) + } + + pub(crate) 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(&canonical_allowed_tool_name(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) + } + } + } +} + +pub(crate) fn permission_policy( + mode: PermissionMode, + feature_config: &runtime::RuntimeFeatureConfig, + tool_registry: &GlobalToolRegistry, +) -> Result { + Ok(tool_registry.permission_specs(None)?.into_iter().fold( + PermissionPolicy::new(mode).with_permission_rules(feature_config.permission_rules()), + |policy, (name, required_permission)| { + policy.with_tool_requirement(name, required_permission) + }, + )) +} + +pub(crate) fn convert_messages(messages: &[ConversationMessage]) -> Vec { + messages + .iter() + .filter_map(|message| { + let role = match message.role { + MessageRole::System | MessageRole::User | MessageRole::Tool => "user", + MessageRole::Assistant => "assistant", + }; + let content = message + .blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => { + Some(InputContentBlock::Text { text: text.clone() }) + } + ContentBlock::Thinking { + thinking, + signature, + } => { + // 保留 Thinking 块:OpenAI 兼容协议会把它转成 reasoning_content 字段 + // 回传给 DeepSeek V4(避免 400 "reasoning_content must be passed back" 错误) + Some(InputContentBlock::Thinking { + thinking: thinking.clone(), + signature: signature.clone(), + }) + } + ContentBlock::ToolUse { id, name, input } => Some(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, + .. + } => Some(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)] +pub(crate) fn print_help_to(out: &mut impl Write) -> io::Result<()> { + writeln!(out, "claw v{VERSION}")?; + writeln!(out)?; + writeln!(out, "Usage:")?; + writeln!( + out, + " claw [--model MODEL] [--allowedTools TOOL[,TOOL...]]" + )?; + writeln!(out, " Start the interactive REPL")?; + writeln!( + out, + " claw [--model MODEL] [--output-format text|json] prompt [--stdin] [TEXT]" + )?; + writeln!( + out, + " Send one prompt and exit; reads stdin when TEXT is omitted" + )?; + writeln!( + out, + " claw [--model MODEL] [--output-format text|json] TEXT" + )?; + writeln!(out, " Shorthand non-interactive prompt mode")?; + writeln!( + out, + " Use `--` before TEXT when the prompt itself starts with '-' or '--'" + )?; + 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 (case-insensitive)" + )?; + writeln!( + out, + " CLAW_OUTPUT_FORMAT sets the default; flags override env" + )?; + writeln!( + out, + " Log env vars: CLAW_LOG or RUST_LOG" + )?; + writeln!( + out, + " --cwd PATH, -C PATH, --directory PATH Run as if launched from PATH" + )?; + 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-permissions Skip all permission checks" + )?; + writeln!( + out, + " --allowedTools TOOLS Restrict enabled tools by canonical snake_case name or alias" + )?; + writeln!(out, " Examples: read, glob, web_fetch, WebFetch; status JSON exposes aliases")?; + 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() + .filter(|spec| !STUB_COMMANDS.contains(&spec.name)) + .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(()) +} diff --git a/rust/crates/rusty-claude-cli/src/skill_dispatch.rs b/rust/crates/rusty-claude-cli/src/skill_dispatch.rs new file mode 100644 index 0000000000..0f37813c78 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/skill_dispatch.rs @@ -0,0 +1,105 @@ +use crate::*; + +use api::{ + detect_provider_kind, model_family_identity_for, 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, PluginsCommandResult, SkillSlashDispatch, + SlashCommand, +}; +use init::initialize_repo; +use log::debug; +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, load_system_prompt_with_context, pricing_for_model, resolve_expected_base, + resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState, + CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile, + ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig, + McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, + PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, + RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, +}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::net::TcpListener; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, UNIX_EPOCH}; +use tools::{ + canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry, + RuntimeToolDefinition, ToolSearchOutput, +}; + +pub(crate) fn try_resolve_bare_skill_prompt(cwd: &Path, trimmed: &str) -> Option { + let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default(); + let looks_like_skill_name = !bare_first_token.is_empty() + && !bare_first_token.starts_with('/') + && bare_first_token + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_'); + if !looks_like_skill_name { + return None; + } + match resolve_skill_invocation(cwd, Some(trimmed)) { + Ok(SkillSlashDispatch::Invoke(prompt)) => Some(prompt), + _ => None, + } +} + +pub(crate) fn 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() + .is_ok_and(|o| o.status.success()); + 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"))) +} + +#[allow(clippy::result_large_err)] +pub(crate) fn resolve_cli_auth_source_for_cwd() -> Result { + resolve_startup_auth_source(|| Ok(None)) +}