Skip to main content

codewhale_tui/
lib.rs

1//! Codewhale TUI library — single-binary entry point.
2
3#![allow(clippy::uninlined_format_args)]
4
5use std::collections::{BTreeSet, HashMap};
6use std::io::{self, IsTerminal, Read, Write};
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use anyhow::{Context, Result, anyhow, bail};
13use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
14use clap_complete::{Shell, generate};
15use tempfile::NamedTempFile;
16use wait_timeout::ChildExt;
17
18use crate::dependencies::ExternalTool;
19
20use rust_i18n::i18n;
21i18n!("locales", fallback = ["en"]);
22
23mod acp_server;
24mod artifacts;
25mod audit;
26mod auto_reasoning;
27mod automation_manager;
28mod child_env;
29mod client;
30mod codex_model_cache;
31mod command_safety;
32mod commands;
33mod compaction;
34mod composer_history;
35mod composer_stash;
36mod config;
37mod config_persistence;
38mod config_ui;
39mod context_budget;
40mod context_report;
41mod continual_harness;
42mod core;
43mod cost_status;
44mod deepseek_theme;
45mod dependencies;
46mod doctor;
47mod elapsed;
48mod error_taxonomy;
49mod eval;
50mod execpolicy;
51mod external_credentials;
52mod fast_hash;
53mod features;
54mod fleet;
55mod goal_loop;
56mod hashing;
57mod hooks;
58mod image_attach;
59mod lane_control;
60mod llm_client;
61mod llm_response_cache;
62mod localization;
63mod logging;
64mod lsp;
65mod mcp;
66mod mcp_server;
67mod model_catalog;
68mod model_context;
69mod model_inventory;
70mod model_profile;
71mod model_registry;
72mod model_routing;
73mod models;
74mod models_dev_live;
75mod native_memory;
76mod network_policy;
77mod oauth;
78mod palette;
79mod plugins;
80mod prefix_cache;
81mod pricing;
82mod project_context;
83mod project_context_cache;
84mod prompt_zones;
85mod prompts;
86mod provider_lake;
87mod provider_readiness;
88mod purge;
89mod regex_cache;
90mod remote_control;
91mod remote_setup;
92pub mod repl;
93mod repo_law;
94mod request_manifest;
95mod request_tuning;
96mod resource_telemetry;
97mod retry_status;
98pub mod rlm;
99mod route_billing;
100mod route_budget;
101mod route_receipt;
102mod route_runtime;
103mod runtime_api;
104mod runtime_handoff;
105mod runtime_log;
106mod runtime_policy;
107mod runtime_threads;
108mod safe_label;
109mod sandbox;
110mod scorecard;
111#[allow(dead_code)]
112mod session_diagnostics;
113// Acceptance matrix for #2934 / #4397. Test-only: the table documents the
114// contract for reviewers and is enforced by the tests beside it, so it does
115// not need to exist in a shipped binary.
116#[cfg(test)]
117#[path = "main/tests.rs"]
118mod doctor_loader_tests;
119#[cfg(test)]
120mod session_control_acceptance;
121#[allow(dead_code)]
122mod session_manager;
123mod session_peek;
124mod session_projection;
125mod session_resume;
126pub mod session_tree;
127mod settings;
128mod shell_dispatcher;
129mod skill_state;
130mod skills;
131mod snapshot;
132mod startup_trace;
133mod task_manager;
134mod telemetry_notice;
135#[cfg(test)]
136mod test_support;
137mod tls;
138mod todo_snapshot;
139mod tool_history_repair;
140mod tool_inspection;
141mod tool_output_receipts;
142mod tools;
143mod tui;
144mod turn_route_plan;
145mod utils;
146mod vision;
147mod work_graph;
148mod worker_profile;
149mod working_set;
150mod workspace_discovery;
151mod workspace_trust;
152mod xai_oauth;
153
154use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
155use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
156use crate::features::{Feature, render_feature_table};
157use crate::llm_client::LlmClient;
158use crate::mcp::{
159    McpCommandAvailability, McpConfig, McpPool, McpServerConfig, McpServerOAuthConfig,
160    is_relative_stdio_path_arg,
161};
162use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
163use crate::session_manager::{SessionManager, create_saved_session, truncate_id};
164use crate::tui::history::{summarize_tool_args, summarize_tool_output};
165
166#[cfg(windows)]
167fn configure_windows_console_utf8() {
168    use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
169
170    const CP_UTF8: u32 = 65001;
171    unsafe {
172        let _ = SetConsoleCP(CP_UTF8);
173        let _ = SetConsoleOutputCP(CP_UTF8);
174    }
175}
176
177#[cfg(not(windows))]
178fn configure_windows_console_utf8() {}
179
180fn install_rustls_crypto_provider() {
181    crate::tls::ensure_rustls_crypto_provider();
182}
183
184#[derive(Parser, Debug)]
185#[command(
186    name = "codewhale-tui",
187    bin_name = "codewhale-tui",
188    author,
189    version = env!("DEEPSEEK_BUILD_VERSION"),
190    about = "Codewhale terminal coding agent",
191    long_about = "Terminal-native TUI and CLI for open-source and open-weight coding models.\n\nRun 'codewhale' to start.\n\nProvider routes include DeepSeek, Arcee, Hugging Face, OpenRouter, Xiaomi MiMo, local vLLM/SGLang/Ollama, and more."
192)]
193struct Cli {
194    /// Subcommand to run
195    #[command(subcommand)]
196    command: Option<Commands>,
197
198    #[command(flatten)]
199    feature_toggles: FeatureToggles,
200
201    /// Initial prompt to submit in the interactive TUI. Use `exec` for non-interactive runs.
202    #[arg(short, long, value_name = "PROMPT", num_args = 1..)]
203    prompt: Vec<String>,
204
205    /// Legacy compatibility alias for Act + Full Access.
206    #[arg(long, hide = true)]
207    yolo: bool,
208
209    /// Maximum number of concurrent sub-agents (1-128; default 64)
210    #[arg(long)]
211    max_subagents: Option<usize>,
212
213    /// Path to config file
214    #[arg(long)]
215    config: Option<PathBuf>,
216
217    /// Enable verbose logging
218    #[arg(short, long)]
219    verbose: bool,
220
221    /// Config profile name
222    #[arg(long)]
223    profile: Option<String>,
224
225    /// Workspace directory for file operations
226    #[arg(short, long)]
227    workspace: Option<PathBuf>,
228
229    /// Resume a previous session by ID or prefix
230    #[arg(short, long)]
231    resume: Option<String>,
232
233    /// Continue the most recent session in this workspace
234    #[arg(short = 'c', long = "continue")]
235    continue_session: bool,
236
237    /// Enable TUI mouse capture for internal scrolling, transcript selection,
238    /// and scrollbar dragging
239    /// (default off on Windows)
240    #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
241    mouse_capture: bool,
242
243    /// Disable TUI mouse capture so terminal-native text selection works
244    #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
245    no_mouse_capture: bool,
246
247    /// Skip onboarding screens
248    #[arg(long)]
249    skip_onboarding: bool,
250
251    /// Start account-owned web remote control for this interactive session.
252    #[arg(long, hide = true)]
253    remote_control: bool,
254
255    /// Start a fresh session, ignoring any crash-recovery checkpoint
256    #[arg(long = "fresh")]
257    fresh: bool,
258
259    /// Skip loading project-level config from $WORKSPACE/.codewhale/config.toml
260    #[arg(long = "no-project-config")]
261    no_project_config: bool,
262}
263
264#[derive(Subcommand, Debug, Clone)]
265#[allow(clippy::large_enum_variant)]
266enum Commands {
267    /// Run system diagnostics and check configuration
268    Doctor(DoctorArgs),
269    /// Summarize failure signals from a local JSONL session log without raw content
270    SessionDiagnostics(SessionDiagnosticsArgs),
271    /// Bootstrap MCP config and/or skills directories
272    Setup(SetupArgs),
273    /// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge)
274    RemoteSetup(remote_setup::RemoteSetupArgs),
275    /// Generate shell completions
276    Completions {
277        /// Shell to generate completions for
278        #[arg(value_enum)]
279        shell: Shell,
280    },
281    /// List saved sessions
282    Sessions {
283        /// Maximum number of sessions to display
284        #[arg(short, long, default_value = "20")]
285        limit: usize,
286        /// Search sessions by title
287        #[arg(short, long)]
288        search: Option<String>,
289    },
290    /// Create default AGENTS.md in current directory
291    Init,
292    /// Save an API key to the shared user config
293    Login {
294        /// API key to store (otherwise read from stdin)
295        #[arg(long)]
296        api_key: Option<String>,
297    },
298    /// Remove the saved API key
299    Logout,
300    /// Manage provider authentication flows.
301    Auth(TuiAuthArgs),
302    /// List available models from the configured API endpoint
303    Models(ModelsArgs),
304    /// Generate speech audio with Xiaomi MiMo TTS models
305    #[command(visible_alias = "tts")]
306    Speech(SpeechArgs),
307    /// Run a non-interactive prompt. Use --auto for agent-with-tools mode.
308    Exec(ExecArgs),
309    /// Manage local Agent Fleet runs and workers
310    Fleet(FleetArgs),
311    /// Internal model-free Workflow tool dispatcher used by Lane Runtime.
312    #[command(name = "workflow-tool", hide = true)]
313    WorkflowTool(WorkflowToolArgs),
314    /// Run a code review over a git diff
315    Review(ReviewArgs),
316    /// Open the TUI pre-seeded with a GitHub PR's title, body, and diff
317    Pr {
318        /// PR number
319        #[arg(value_name = "NUMBER")]
320        number: u32,
321        /// Repository in `owner/name` form. Defaults to the current
322        /// workspace's `gh` config (i.e. the repo gh thinks you're in).
323        #[arg(short = 'R', long)]
324        repo: Option<String>,
325        /// Skip `gh pr checkout` even if gh is available. By default
326        /// the working tree is left as-is — checkout is opt-in via
327        /// `--checkout` because dirty trees fail it loudly.
328        #[arg(long, default_value_t = false)]
329        checkout: bool,
330    },
331    /// Apply a patch file (or stdin) to the working tree
332    Apply(ApplyArgs),
333    /// Run the offline evaluation harness (no network/LLM calls)
334    Eval(EvalArgs),
335    /// Score a run's token/cache/cost from recorded turns; flag regressions vs a baseline
336    Scorecard(ScorecardArgs),
337    /// Manage MCP servers
338    Mcp {
339        #[command(subcommand)]
340        command: McpCommand,
341    },
342    /// Inspect feature flags
343    Features(FeaturesCli),
344    /// Run a command inside the sandbox
345    Sandbox(SandboxArgs),
346    /// Run a local server (e.g. MCP)
347    Serve(ServeArgs),
348    /// Resume a previous session by ID (use --last for most recent)
349    Resume {
350        /// Conversation/session id (UUID or prefix)
351        #[arg(value_name = "SESSION_ID")]
352        session_id: Option<String>,
353        /// Continue the most recent session in this workspace without a picker
354        #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
355        last: bool,
356    },
357    /// Fork a previous session by ID (use --last for most recent)
358    Fork {
359        /// Conversation/session id (UUID or prefix)
360        #[arg(value_name = "SESSION_ID")]
361        session_id: Option<String>,
362        /// Fork the most recent session in this workspace without a picker
363        #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
364        last: bool,
365    },
366}
367
368#[derive(Args, Debug, Clone)]
369#[command(after_help = "\
370Examples:
371  codewhale exec \"explain this function\"
372  codewhale exec --auto \"list crates/ with ls\"
373  codewhale exec --auto --output-format stream-json \"fix the failing test\"
374
375Plain `codewhale exec` is a one-shot model response. Use `--auto` for
376non-interactive agent-with-tools execution. `--auto` does not change the
377sandbox posture or elevate a denied tool. Use `--sandbox danger-full-access`
378or `--allow-sandbox-elevation` to explicitly authorize sandbox elevation.
379")]
380struct ExecArgs {
381    /// Override model for this run
382    #[arg(long)]
383    model: Option<String>,
384    /// Override the provider for this run (e.g. `deepseek`, `openrouter`).
385    /// Non-secret identifier only — credentials still resolve from the
386    /// environment/config. Fleet uses this to launch a worker on its
387    /// profile-pinned provider even when the parent session is on another
388    /// one (#4093).
389    #[arg(long)]
390    provider: Option<String>,
391    /// Override reasoning/thinking effort for this run.
392    /// Accepted values: auto, off, low, medium, high, max.
393    #[arg(long = "reasoning-effort", value_name = "EFFORT")]
394    reasoning_effort: Option<String>,
395    /// Enable agent-with-tools mode with automatic tool approvals. This does
396    /// not authorize sandbox elevation.
397    #[arg(long, default_value_t = false)]
398    auto: bool,
399    /// Sandbox policy for this exec run; independent from --auto.
400    #[arg(long, value_name = "POLICY")]
401    sandbox: Option<String>,
402    /// Explicitly allow a denied tool to retry with danger-full-access.
403    #[arg(long, default_value_t = false)]
404    allow_sandbox_elevation: bool,
405    /// Emit machine-readable JSON output
406    #[arg(long, default_value_t = false, conflicts_with = "output_format")]
407    json: bool,
408    /// Resume a previous session by ID or prefix
409    #[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["session_id", "continue_session"])]
410    resume: Option<String>,
411    /// Resume a previous session by ID or prefix
412    #[arg(long = "session-id", value_name = "SESSION_ID", conflicts_with_all = ["resume", "continue_session"])]
413    session_id: Option<String>,
414    /// Continue the most recent session for this workspace
415    #[arg(long = "continue", default_value_t = false, conflicts_with_all = ["resume", "session_id"])]
416    continue_session: bool,
417    /// Output format for exec mode
418    #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)]
419    output_format: ExecOutputFormat,
420    /// Comma-separated list of canonical tools to allow (all others denied).
421    /// Names are case-insensitive: Bash, File, Git, Run, etc.
422    #[arg(long, value_delimiter = ',')]
423    allowed_tools: Option<Vec<String>>,
424    /// Comma-separated list of tools to deny (deny wins over allow).
425    #[arg(long, value_delimiter = ',')]
426    disallowed_tools: Option<Vec<String>>,
427    /// Maximum number of model steps before the run ends. Omitted means unlimited.
428    #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
429    max_turns: Option<u32>,
430    /// Extra text appended to the system prompt for this run.
431    #[arg(long)]
432    append_system_prompt: Option<String>,
433    /// Internal Fleet worker authority envelope. Non-secret, versioned JSON.
434    #[arg(long, value_name = "JSON", hide = true)]
435    tool_authority_json: Option<String>,
436    /// Prompt to send to the model
437    #[arg(
438        value_name = "PROMPT",
439        required = true,
440        trailing_var_arg = true,
441        allow_hyphen_values = true
442    )]
443    prompt: Vec<String>,
444}
445
446#[derive(Args, Debug, Clone)]
447struct WorkflowToolArgs {
448    /// Authority provenance stamped by the public `workflow run` command.
449    #[arg(long, value_name = "SOURCE")]
450    approval_source: String,
451    /// Exact Workflow tool input serialized as one JSON object.
452    #[arg(long, value_name = "JSON")]
453    input_json: String,
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
457enum ExecOutputFormat {
458    Text,
459    #[value(name = "stream-json")]
460    StreamJson,
461}
462
463#[derive(Args, Debug, Clone)]
464struct TuiAuthArgs {
465    #[command(subcommand)]
466    command: TuiAuthCommand,
467}
468
469#[derive(Subcommand, Debug, Clone)]
470enum TuiAuthCommand {
471    /// Sign in to xAI/Grok with an SSH-friendly device code.
472    #[command(name = "xai-device")]
473    XaiDevice,
474}
475
476const CODEWHALE_TOOL_SURFACE_ENV: &str = "CODEWHALE_TOOL_SURFACE";
477const SHELL_ONLY_EXEC_TOOLS: &[&str] = &["bash"];
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480enum ExecToolSurface {
481    ShellOnly,
482}
483
484fn exec_tool_surface_from_env() -> Option<ExecToolSurface> {
485    std::env::var(CODEWHALE_TOOL_SURFACE_ENV)
486        .ok()
487        .and_then(|value| {
488            if should_warn_unknown_exec_tool_surface(&value) {
489                eprintln!(
490                    "warning: unrecognized {CODEWHALE_TOOL_SURFACE_ENV}; leaving exec tool surface unchanged. Use `shell-only`, `full`, or `native-tools`."
491                );
492            }
493            parse_exec_tool_surface(&value)
494        })
495}
496
497fn parse_exec_tool_surface(value: &str) -> Option<ExecToolSurface> {
498    match value.trim().to_ascii_lowercase().as_str() {
499        "shell-only" | "shell_only" | "shell" => Some(ExecToolSurface::ShellOnly),
500        "full" | "native-tools" | "native_tools" | "" => None,
501        _ => None,
502    }
503}
504
505fn should_warn_unknown_exec_tool_surface(value: &str) -> bool {
506    let normalized = value.trim().to_ascii_lowercase();
507    !matches!(
508        normalized.as_str(),
509        "" | "shell-only" | "shell_only" | "shell" | "full" | "native-tools" | "native_tools"
510    )
511}
512
513fn normalize_exec_tool_names(tools: &[String]) -> Vec<String> {
514    tools
515        .iter()
516        .map(|name| name.to_ascii_lowercase().trim().to_string())
517        .collect()
518}
519
520fn shell_only_exec_allowed_tools() -> Vec<String> {
521    SHELL_ONLY_EXEC_TOOLS
522        .iter()
523        .map(|name| (*name).to_string())
524        .collect()
525}
526
527fn resolve_exec_allowed_tools(
528    cli_allowed_tools: Option<&[String]>,
529    env_tool_surface: Option<ExecToolSurface>,
530) -> Option<Vec<String>> {
531    if let Some(tools) = cli_allowed_tools {
532        return Some(normalize_exec_tool_names(tools));
533    }
534
535    env_tool_surface.map(|ExecToolSurface::ShellOnly| shell_only_exec_allowed_tools())
536}
537
538#[derive(Args, Debug, Clone)]
539struct FleetArgs {
540    #[command(subcommand)]
541    command: FleetCommand,
542}
543
544#[derive(Subcommand, Debug, Clone)]
545enum FleetCommand {
546    /// Initialize the local fleet ledger for this workspace
547    Init,
548    /// Create a run from a task spec and start the foreground manager loop
549    Run(FleetRunArgs),
550    /// List durable Fleet runs from this workspace's ledger
551    List,
552    /// Show queued/running/completed/failed/stale fleet counts
553    Status,
554    /// Inspect one worker's status, heartbeat, latest event, and artifacts
555    Inspect {
556        /// Worker id printed by `codewhale fleet run`
557        worker_id: String,
558    },
559    /// Print bounded log artifacts for one worker
560    Logs {
561        /// Worker id printed by `codewhale fleet run`
562        worker_id: String,
563    },
564    /// List artifact refs for one worker
565    Artifacts {
566        /// Worker id printed by `codewhale fleet run`
567        worker_id: String,
568    },
569    /// Interrupt a running worker task and record a terminal cancellation
570    Interrupt {
571        /// Worker id printed by `codewhale fleet run`
572        worker_id: String,
573    },
574    /// Restart the latest task for a worker
575    Restart {
576        /// Worker id printed by `codewhale fleet run`
577        worker_id: String,
578    },
579    /// Resume a run from durable ledger state, reconciling orphaned/stale leases
580    Resume {
581        /// Run id printed by `codewhale fleet run`
582        run_id: String,
583        /// Seconds without heartbeat before a leased task is treated as stale
584        #[arg(long, default_value_t = 300)]
585        stale_after_seconds: u64,
586    },
587    /// Stop all queued and running fleet work
588    Stop {
589        /// Confirm stopping all queued and running fleet tasks
590        #[arg(long, required = true)]
591        all: bool,
592    },
593    /// Render a redacted fleet alert payload without sending it
594    AlertDryRun(FleetAlertDryRunArgs),
595}
596
597#[derive(Args, Debug, Clone)]
598struct FleetRunArgs {
599    /// JSON or TOML task spec to enqueue
600    #[arg(value_name = "TASK_SPEC")]
601    task_spec: PathBuf,
602    /// Maximum local workers to lease concurrently
603    #[arg(long, default_value_t = 4)]
604    max_workers: usize,
605    /// Seconds without heartbeat before a running task is counted stale
606    #[arg(long, default_value_t = 300)]
607    stale_after_seconds: u64,
608    /// Schedule once and return instead of staying in the manager loop
609    #[arg(long, hide = true, default_value_t = false)]
610    once: bool,
611}
612
613#[derive(Args, Debug, Clone)]
614struct FleetAlertDryRunArgs {
615    /// Alert event class to render
616    #[arg(long, value_enum)]
617    event: FleetAlertEventArg,
618    /// Fleet run id
619    #[arg(long)]
620    run_id: String,
621    /// Worker id, when the event belongs to one worker
622    #[arg(long)]
623    worker_id: Option<String>,
624    /// Task id, when the event belongs to one task
625    #[arg(long)]
626    task_id: Option<String>,
627    /// Short human-readable reason for the alert
628    #[arg(long, default_value = "manual fleet alert dry-run")]
629    reason: String,
630    /// Status label to include in the payload
631    #[arg(long)]
632    status: Option<String>,
633    /// Adapter payload shape to render
634    #[arg(long, value_enum, default_value_t = FleetAlertAdapterArg::Slack)]
635    adapter: FleetAlertAdapterArg,
636    /// Environment variable containing the Slack webhook URL
637    #[arg(long, default_value = "CODEWHALE_FLEET_SLACK_WEBHOOK")]
638    slack_webhook_env: String,
639    /// Environment variable containing the generic webhook URL
640    #[arg(long, default_value = "CODEWHALE_FLEET_WEBHOOK_URL")]
641    webhook_url_env: String,
642    /// Optional environment variable containing the generic webhook secret
643    #[arg(long)]
644    webhook_secret_env: Option<String>,
645    /// Environment variable containing the PagerDuty routing key
646    #[arg(long, default_value = "CODEWHALE_FLEET_PAGERDUTY_ROUTING_KEY")]
647    pagerduty_routing_key_env: String,
648    /// PagerDuty severity to render
649    #[arg(long, default_value = "error")]
650    pagerduty_severity: String,
651}
652
653#[derive(ValueEnum, Debug, Clone, Copy)]
654enum FleetAlertEventArg {
655    Stale,
656    RestartExhausted,
657    NeedsHuman,
658    BudgetExceeded,
659    VerifierFailed,
660    RunCompleted,
661}
662
663#[derive(ValueEnum, Debug, Clone, Copy)]
664enum FleetAlertAdapterArg {
665    Slack,
666    Webhook,
667    PagerDuty,
668}
669
670/// Spawn a tokio task that listens for terminating signals (SIGINT
671/// always; SIGTERM and SIGHUP on Unix) and, on receipt, restores the
672/// terminal modes and exits with the conventional 128 + signal code.
673/// Multiple deliveries are tolerated: once the cleanup runs, a second
674/// signal short-circuits to plain exit so a stuck cleanup can never
675/// trap a frustrated user pressing Ctrl+C repeatedly.
676///
677/// See the call site in `main` for the rationale (#1583).
678///
679/// Registration is synchronous, before the spawn: a `tokio::spawn`ed task does
680/// not run until the scheduler first polls it, so registering the signal
681/// streams *inside* it leaves a window — unbounded under load — where SIGINT
682/// still has its default disposition and kills the process outright. That is
683/// the very outcome this handler exists to prevent, and it produced a real
684/// terminated-by-signal exit (no code, no terminal restore, no `session_end`).
685/// After this function returns, the signals are armed.
686fn spawn_signal_cleanup_task() {
687    let signals = TerminatingSignals::register();
688    tokio::spawn(async move {
689        let exit_code = signals.wait().await;
690        // If we get here a fatal signal arrived. Restore the terminal
691        // and exit. A second signal during cleanup re-enters this
692        // path and aborts via `std::process::exit` directly.
693        static CLEANED_UP: std::sync::atomic::AtomicBool =
694            std::sync::atomic::AtomicBool::new(false);
695        if !CLEANED_UP.swap(true, std::sync::atomic::Ordering::SeqCst) {
696            #[cfg(unix)]
697            crate::tools::shell::abort_pending_persistent_process_groups_for_exit();
698            crate::tui::ui::emergency_restore_terminal();
699            // Nothing async survives the `exit` below, so this is the last
700            // chance to say how the session ended. `record_blocking` is one
701            // `O_APPEND` write with no lock: taking the compaction lock here
702            // would let a second Codewhale process sharing CODEWHALE_HOME hang
703            // Ctrl-C, and the second-signal short-circuit below has to stay
704            // reachable. A no-op unless this process was armed.
705            //
706            // The class is stated, not derived: `RunTerminationReason::Canceled`
707            // also exits 130, so `exit_code` cannot tell a signal from an
708            // Esc-cancelled turn.
709            record_signal_session_end();
710        }
711        std::process::exit(exit_code);
712    });
713}
714
715/// When this process's armed telemetry session began. Set once, at arming, and
716/// read from both the ordinary teardown and the signal path.
717static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
718    std::sync::OnceLock::new();
719
720/// Build `session_end` from what this process actually accumulated.
721///
722/// The exit class is read from the process-wide atomic and never derived from
723/// an exit code: `RunTerminationReason::Canceled` maps to 130, the same value
724/// the SIGINT path uses, so a code-based derivation would report every
725/// Esc-cancelled turn as a signal.
726///
727/// The cold-start bucket is `None` unless the interactive event loop actually
728/// began, which is what keeps it absent rather than invented on the surfaces
729/// that have no event loop.
730fn telemetry_session_end() -> codewhale_telemetry::Event {
731    let counters = codewhale_telemetry::session_counters();
732    codewhale_telemetry::Event::SessionEnd {
733        duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
734            TELEMETRY_SESSION_START
735                .get()
736                .map_or(0, |start| start.elapsed().as_secs()),
737        ),
738        exit_class: codewhale_telemetry::exit_class(),
739        cold_start_bucket: crate::startup_trace::cold_start_ms()
740            .map(codewhale_telemetry::ColdStartBucket::from_millis),
741        providers: counters.providers(),
742        counters: counters.counters(),
743        errors: counters.errors(),
744        turn_wall: counters.turn_wall(),
745    }
746}
747
748/// Close the session synchronously, from the signal handler.
749///
750/// A no-op unless this process was armed.
751fn record_signal_session_end() {
752    codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
753    codewhale_telemetry::record_blocking(telemetry_session_end());
754}
755
756/// Terminating-signal streams, registered up front and awaited later.
757///
758/// Splitting registration from the await is the point: the OS disposition
759/// changes when `register` returns, not when the waiting task is first polled.
760#[cfg(unix)]
761struct TerminatingSignals {
762    sigint: Option<tokio::signal::unix::Signal>,
763    sigterm: Option<tokio::signal::unix::Signal>,
764    sighup: Option<tokio::signal::unix::Signal>,
765}
766
767#[cfg(unix)]
768impl TerminatingSignals {
769    /// Install the handlers. Failing to install any individual stream is
770    /// non-fatal: we still want the others to work.
771    fn register() -> Self {
772        use tokio::signal::unix::{SignalKind, signal};
773        Self {
774            sigint: signal(SignalKind::interrupt()).ok(),
775            sigterm: signal(SignalKind::terminate()).ok(),
776            sighup: signal(SignalKind::hangup()).ok(),
777        }
778    }
779
780    /// Resolve with 128 + signal number for whichever arrives first. The
781    /// fallback never-resolving future keeps `select!` well-typed when a
782    /// stream failed to register.
783    async fn wait(mut self) -> i32 {
784        tokio::select! {
785            _ = async { match self.sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130,
786            _ = async { match self.sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143,
787            _ = async { match self.sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129,
788        }
789    }
790}
791
792/// Windows: `ctrl_c` covers both Ctrl+C and Ctrl+Break (CTRL_C_EVENT /
793/// CTRL_BREAK_EVENT). Console-close, logoff, and shutdown events are not
794/// currently routed through tokio.
795#[cfg(not(unix))]
796struct TerminatingSignals {
797    ctrl_c: Option<tokio::signal::windows::CtrlC>,
798}
799
800#[cfg(not(unix))]
801impl TerminatingSignals {
802    fn register() -> Self {
803        Self {
804            ctrl_c: tokio::signal::windows::ctrl_c().ok(),
805        }
806    }
807
808    async fn wait(mut self) -> i32 {
809        match self.ctrl_c.as_mut() {
810            Some(s) => {
811                s.recv().await;
812            }
813            None => std::future::pending::<()>().await,
814        }
815        130
816    }
817}
818
819fn join_prompt_parts(parts: &[String]) -> String {
820    parts.join(" ")
821}
822
823fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String {
824    explicit_model
825        .map(str::trim)
826        .filter(|model| !model.is_empty())
827        .map(ToOwned::to_owned)
828        .or_else(exec_model_env_override)
829        .unwrap_or_else(|| config.default_model())
830}
831
832fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> {
833    let provider_arg = provider_arg.trim();
834    if provider_arg.is_empty() {
835        return Ok(());
836    }
837    if config
838        .providers
839        .as_ref()
840        .and_then(|providers| providers.custom_provider_config(provider_arg))
841        .is_some()
842    {
843        config.provider = Some(provider_arg.to_string());
844        return Ok(());
845    }
846    if let Some(provider) = crate::config::ApiProvider::parse(provider_arg) {
847        config.provider = Some(provider.as_str().to_string());
848        return Ok(());
849    }
850    bail!(
851        "Unrecognized --provider {provider_arg:?}. Known providers: {} \
852         or a configured [providers.<name>] custom provider",
853        crate::config::ApiProvider::names_hint()
854    );
855}
856
857fn exec_model_env_override() -> Option<String> {
858    let read = || {
859        ["CODEWHALE_MODEL", "DEEPSEEK_MODEL"]
860            .into_iter()
861            .find_map(|key| {
862                std::env::var(key)
863                    .ok()
864                    .map(|model| model.trim().to_string())
865                    .filter(|model| !model.is_empty())
866            })
867    };
868    #[cfg(test)]
869    {
870        crate::test_support::with_test_env_lock(read)
871    }
872    #[cfg(not(test))]
873    {
874        read()
875    }
876}
877
878fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
879    (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
880}
881
882fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
883    if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
884        return Ok(Some(id.clone()));
885    }
886    if !args.continue_session {
887        return Ok(None);
888    }
889    latest_session_id_for_workspace(workspace)?.map_or_else(
890        || {
891            bail!(
892                "No saved sessions found for workspace {}. Use `codewhale sessions` to list sessions, or pass `codewhale exec --resume <SESSION_ID> ...`.",
893                workspace.display()
894            )
895        },
896        |id| Ok(Some(id)),
897    )
898}
899
900fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
901    let session_ref = exec_stream_session_ref(session_id);
902    SessionManager::default_location()
903        .context("could not open session manager for resume")?
904        .load_session_by_prefix(session_id)
905        .with_context(|| format!("could not load session {session_ref}"))
906}
907
908/// Select the route for `exec --resume` before any engine/client is built.
909///
910/// Precedence is intentionally field-aware:
911/// - no explicit `--provider` or `--model`: restore the saved provider/model;
912/// - explicit `--provider`: keep that route and use its configured/default model
913///   unless `--model` is also present;
914/// - explicit `--model` alone: restore the saved provider, then use that model.
915fn resolve_exec_resume_route(
916    config: &mut Config,
917    saved: &session_manager::SavedSession,
918    explicit_provider: bool,
919    explicit_model: Option<&str>,
920) -> Result<String> {
921    if !explicit_provider {
922        let saved_provider_identity = saved
923            .metadata
924            .model_provider_id
925            .as_deref()
926            .filter(|identity| !identity.trim().is_empty())
927            .unwrap_or(&saved.metadata.model_provider);
928        let identity = config
929            .resolve_persisted_provider_identity(
930                Some(&saved.metadata.model_provider),
931                saved.metadata.model_provider_id.as_deref(),
932            )
933            .map_err(anyhow::Error::msg)
934            .with_context(|| {
935                format!(
936                    "saved session provider '{}' is unavailable; Codewhale will not fall back",
937                    saved_provider_identity
938                )
939            })?;
940        config.scope_to_provider_identity(&identity);
941    }
942
943    if let Some(model) = explicit_model {
944        return Ok(resolve_exec_model(config, Some(model)));
945    }
946    if explicit_provider {
947        return Ok(resolve_exec_model(config, None));
948    }
949    Ok(saved.metadata.model.clone())
950}
951
952#[derive(Args, Debug, Clone, Default)]
953struct SetupArgs {
954    /// Initialize MCP configuration at the configured path
955    #[arg(long, default_value_t = false)]
956    mcp: bool,
957    /// Initialize skills directory and an example skill
958    #[arg(long, default_value_t = false)]
959    skills: bool,
960    /// Initialize tools directory with a self-describing example script
961    #[arg(long, default_value_t = false)]
962    tools: bool,
963    /// Initialize plugins directory with a self-describing example
964    #[arg(long, default_value_t = false)]
965    plugins: bool,
966    /// Initialize MCP config, skills, tools, and plugins
967    #[arg(long, default_value_t = false)]
968    all: bool,
969    /// Create a local workspace skills directory (./skills)
970    #[arg(long, default_value_t = false)]
971    local: bool,
972    /// Overwrite existing template files
973    #[arg(long, default_value_t = false)]
974    force: bool,
975    /// Print a compact, read-only status report (no network calls)
976    #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
977    status: bool,
978    /// Remove regenerable session checkpoints (latest + offline_queue)
979    #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
980    clean: bool,
981}
982
983#[derive(Args, Debug, Clone, Default)]
984struct DoctorArgs {
985    /// Emit machine-readable structural JSON output (always offline)
986    #[arg(long, default_value_t = false)]
987    json: bool,
988    /// Emit only the diagnostic context source map as JSON
989    #[arg(long, default_value_t = false, conflicts_with = "json")]
990    context_json: bool,
991    /// Opt in to probing a local provider endpoint (may start a local service)
992    #[arg(
993        long,
994        default_value_t = false,
995        conflicts_with_all = ["json", "context_json"]
996    )]
997    probe_local: bool,
998    /// Opt in to probing the configured hosted provider API
999    #[arg(
1000        long,
1001        default_value_t = false,
1002        conflicts_with_all = ["json", "context_json"]
1003    )]
1004    probe_api: bool,
1005    /// Opt in to contacting the release service for an update check
1006    #[arg(
1007        long,
1008        default_value_t = false,
1009        conflicts_with_all = ["json", "context_json"]
1010    )]
1011    check_updates: bool,
1012    /// Opt in to starting enabled MCP servers and checking process/protocol reachability
1013    #[arg(
1014        long,
1015        default_value_t = false,
1016        conflicts_with_all = ["json", "context_json"]
1017    )]
1018    probe_mcp: bool,
1019}
1020
1021#[derive(Args, Debug, Clone)]
1022struct SessionDiagnosticsArgs {
1023    /// JSONL session log to inspect
1024    #[arg(value_name = "JSONL")]
1025    path: PathBuf,
1026    /// Emit machine-readable JSON with redacted source handles
1027    #[arg(long, default_value_t = false)]
1028    json: bool,
1029}
1030
1031#[derive(Args, Debug, Clone)]
1032struct ScorecardArgs {
1033    /// JSON file with the recorded turns to score: an array of
1034    /// `{ "turn_id", "provider", "model", "billing_surface", "usage": {…} }`.
1035    /// `turn_end` hooks emit this route provenance plus `created_at`; persisted
1036    /// runtime exports may instead use `id`, `effective_provider`,
1037    /// `effective_model`, and `effective_billing_surface`.
1038    /// Shell-only hook rows marked `model_backed: false` are excluded. Legacy
1039    /// rows without provider remain readable but their cost is unavailable.
1040    #[arg(long, value_name = "FILE")]
1041    input: PathBuf,
1042    /// Optional baseline scorecard-metrics JSON to compare against. When set,
1043    /// the command exits non-zero if any metric regresses past the threshold.
1044    #[arg(long, value_name = "FILE")]
1045    baseline: Option<PathBuf>,
1046    /// Regression threshold, in percent increase over the baseline.
1047    #[arg(long, default_value_t = 5.0)]
1048    threshold: f64,
1049    /// Emit machine-readable JSON instead of the human summary.
1050    #[arg(long, default_value_t = false)]
1051    json: bool,
1052}
1053
1054#[derive(Args, Debug, Clone)]
1055struct EvalArgs {
1056    /// Intentionally fail a specific step (list, read, search, edit, patch, shell)
1057    #[arg(long, value_name = "STEP")]
1058    fail_step: Option<String>,
1059    /// Shell command to run during the exec step
1060    #[arg(long, default_value = "printf eval-harness")]
1061    shell_command: String,
1062    /// Token that must appear in shell output for validation
1063    #[arg(long, default_value = "eval-harness")]
1064    shell_expect_token: String,
1065    /// Maximum characters stored per step output summary
1066    #[arg(long, default_value_t = 240)]
1067    max_output_chars: usize,
1068    /// Emit machine-readable JSON output
1069    #[arg(long, default_value_t = false)]
1070    json: bool,
1071    /// Append one JSONL fixture line per step to `<DIR>/<scenario>.jsonl`.
1072    /// Mock LLM tests can later replay these fixtures.
1073    #[arg(long, value_name = "DIR")]
1074    record: Option<PathBuf>,
1075}
1076
1077#[derive(Args, Debug, Clone, Default)]
1078struct ModelsArgs {
1079    /// Print models as pretty JSON
1080    #[arg(long, default_value_t = false)]
1081    json: bool,
1082}
1083
1084#[derive(Args, Debug, Clone)]
1085struct SpeechArgs {
1086    /// Text to synthesize. This is sent as the assistant message content.
1087    #[arg(value_name = "TEXT")]
1088    text: String,
1089
1090    /// Output audio path. Defaults to `speech.<format>` in `--output-dir`,
1091    /// `[speech].output_dir`, or the current directory.
1092    #[arg(short, long, value_name = "FILE")]
1093    output: Option<PathBuf>,
1094
1095    /// Directory for the default `speech.<format>` output file when `-o`/`--output` is omitted.
1096    #[arg(long = "output-dir", value_name = "DIR")]
1097    output_dir: Option<PathBuf>,
1098
1099    /// TTS model. Defaults to built-in voices, or is inferred from --voice-prompt/--clone-voice.
1100    #[arg(long)]
1101    model: Option<String>,
1102
1103    /// Built-in voice ID, or a data:audio/...;base64,... URI for voice clone.
1104    #[arg(long)]
1105    voice: Option<String>,
1106
1107    /// Natural language style instruction; not spoken verbatim.
1108    #[arg(long)]
1109    instruction: Option<String>,
1110
1111    /// Voice design prompt. Implies mimo-v2.5-tts-voicedesign when --model is omitted.
1112    #[arg(long = "voice-prompt")]
1113    voice_prompt: Option<String>,
1114
1115    /// MP3/WAV sample used for voice cloning. Implies mimo-v2.5-tts-voiceclone when --model is omitted.
1116    #[arg(long = "clone-voice", value_name = "FILE")]
1117    clone_voice: Option<PathBuf>,
1118
1119    /// Output audio format requested from the API
1120    #[arg(long, default_value = "wav")]
1121    format: String,
1122
1123    /// Emit machine-readable JSON output
1124    #[arg(long, default_value_t = false)]
1125    json: bool,
1126}
1127
1128#[derive(Args, Debug, Default, Clone)]
1129struct FeatureToggles {
1130    /// Enable a feature (repeatable). Equivalent to `features.<name>=true`.
1131    #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1132    enable: Vec<String>,
1133
1134    /// Disable a feature (repeatable). Equivalent to `features.<name>=false`.
1135    #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1136    disable: Vec<String>,
1137}
1138
1139impl FeatureToggles {
1140    fn apply(&self, config: &mut Config) -> Result<()> {
1141        for feature in &self.enable {
1142            config.set_feature(feature, true)?;
1143        }
1144        for feature in &self.disable {
1145            config.set_feature(feature, false)?;
1146        }
1147        Ok(())
1148    }
1149}
1150
1151#[derive(Args, Debug, Clone)]
1152struct ReviewArgs {
1153    /// Review staged changes instead of the working tree
1154    #[arg(long, conflicts_with = "base")]
1155    staged: bool,
1156    /// Base ref to diff against (e.g. origin/main)
1157    #[arg(long)]
1158    base: Option<String>,
1159    /// Limit diff to a specific path
1160    #[arg(long)]
1161    path: Option<PathBuf>,
1162    /// Override model for this review
1163    #[arg(long)]
1164    model: Option<String>,
1165    /// Maximum diff characters to include
1166    #[arg(long, default_value_t = 200_000)]
1167    max_chars: usize,
1168    /// Write a durable pre-push review receipt after a successful review
1169    #[arg(long, default_value_t = false)]
1170    write_receipt: bool,
1171    /// Validate the current diff against a durable review receipt without calling a model
1172    #[arg(long, default_value_t = false)]
1173    check_receipt: bool,
1174    /// Override where the review receipt is written or read
1175    #[arg(long)]
1176    receipt_path: Option<PathBuf>,
1177    /// Emit machine-readable JSON output
1178    #[arg(long, default_value_t = false)]
1179    json: bool,
1180}
1181
1182#[derive(Args, Debug, Clone)]
1183struct ApplyArgs {
1184    /// Patch file to apply (defaults to stdin)
1185    #[arg(value_name = "PATCH_FILE")]
1186    patch_file: Option<PathBuf>,
1187}
1188
1189#[derive(Args, Debug, Clone)]
1190struct ServeArgs {
1191    /// Start MCP server over stdio
1192    #[arg(long)]
1193    mcp: bool,
1194    /// Start runtime HTTP/SSE API server
1195    #[arg(long)]
1196    http: bool,
1197    /// Start runtime HTTP/SSE API server with the built-in mobile control page
1198    #[arg(long)]
1199    mobile: bool,
1200    /// Start the embedded loopback-only browser client and open it
1201    #[arg(long)]
1202    web: bool,
1203    /// Show a QR code for the mobile URL in the terminal (requires --mobile)
1204    #[arg(long, requires = "mobile")]
1205    qr: bool,
1206    /// Start ACP server over stdio for editor clients such as Zed
1207    #[arg(long)]
1208    acp: bool,
1209    /// Bind host for HTTP server (default localhost; --mobile defaults to 0.0.0.0)
1210    #[arg(long)]
1211    host: Option<String>,
1212    /// Bind port for HTTP server
1213    #[arg(long, default_value_t = 7878)]
1214    port: u16,
1215    /// Background task worker count (1-8)
1216    #[arg(long, default_value_t = 2)]
1217    workers: usize,
1218    /// Additional CORS origin to allow (repeatable). Stacks on top of the
1219    /// built-in defaults (localhost:3000, localhost:1420, tauri://localhost).
1220    /// Also reads `CODEWHALE_CORS_ORIGINS` (comma-separated), then
1221    /// `DEEPSEEK_CORS_ORIGINS` as an alias, and `[runtime_api] cors_origins`
1222    /// from `config.toml`. Whalescale#255.
1223    #[arg(long = "cors-origin", value_name = "URL")]
1224    cors_origin: Vec<String>,
1225    /// Require this bearer token for `/v1/*` runtime API routes. Also reads
1226    /// `CODEWHALE_RUNTIME_TOKEN` when omitted, then `DEEPSEEK_RUNTIME_TOKEN`
1227    /// as an alias.
1228    #[arg(long = "auth-token", value_name = "TOKEN")]
1229    auth_token: Option<String>,
1230    /// Disable runtime API auth when no token is configured. Only use on a trusted loopback.
1231    #[arg(long = "insecure")]
1232    insecure_no_auth: bool,
1233}
1234
1235#[derive(Debug, Clone, PartialEq, Eq)]
1236struct ServeBindHost {
1237    host: String,
1238    mobile_rebound_to_lan: bool,
1239}
1240
1241fn resolve_serve_bind_host(mobile: bool, host: Option<String>) -> ServeBindHost {
1242    match (mobile, host) {
1243        (true, None) => ServeBindHost {
1244            host: "0.0.0.0".to_string(),
1245            mobile_rebound_to_lan: true,
1246        },
1247        (_, Some(host)) => ServeBindHost {
1248            host,
1249            mobile_rebound_to_lan: false,
1250        },
1251        (false, None) => ServeBindHost {
1252            host: "127.0.0.1".to_string(),
1253            mobile_rebound_to_lan: false,
1254        },
1255    }
1256}
1257
1258fn validate_serve_mode_selection(
1259    mcp: bool,
1260    http: bool,
1261    mobile: bool,
1262    web: bool,
1263    acp: bool,
1264) -> Result<bool> {
1265    if http && mobile {
1266        bail!("--http and --mobile are mutually exclusive; choose one");
1267    }
1268    if web && (http || mobile) {
1269        bail!("--web is mutually exclusive with --http and --mobile");
1270    }
1271    let http_selected = http || mobile || web;
1272    let selected_modes = [mcp, http_selected, acp]
1273        .into_iter()
1274        .filter(|selected| *selected)
1275        .count();
1276    if selected_modes != 1 {
1277        bail!("Choose exactly one server mode: --mcp, --http/--mobile/--web, or --acp");
1278    }
1279    Ok(http_selected)
1280}
1281
1282#[derive(Subcommand, Debug, Clone)]
1283enum McpCommand {
1284    /// List configured MCP servers
1285    List,
1286    /// Create a template MCP config at the configured path
1287    Init {
1288        /// Overwrite an existing MCP config file
1289        #[arg(long, default_value_t = false)]
1290        force: bool,
1291    },
1292    /// Connect to MCP servers and report status
1293    Connect {
1294        /// Optional server name to connect to
1295        #[arg(value_name = "SERVER")]
1296        server: Option<String>,
1297    },
1298    /// List tools discovered from MCP servers
1299    Tools {
1300        /// Optional server name to list tools for
1301        #[arg(value_name = "SERVER")]
1302        server: Option<String>,
1303    },
1304    /// Add an MCP server entry
1305    Add {
1306        /// Server name
1307        name: String,
1308        /// Command to launch stdio server
1309        #[arg(long, conflicts_with = "url")]
1310        command: Option<String>,
1311        /// URL for streamable HTTP/SSE server
1312        #[arg(long, conflicts_with = "command")]
1313        url: Option<String>,
1314        /// Explicit URL transport override. Use "sse" for legacy SSE endpoints.
1315        #[arg(long, requires = "url")]
1316        transport: Option<String>,
1317        /// Environment variable containing a bearer token for URL-based servers
1318        #[arg(long, requires = "url")]
1319        bearer_token_env_var: Option<String>,
1320        /// OAuth client ID for servers that do not support dynamic registration
1321        #[arg(long, requires = "url")]
1322        oauth_client_id: Option<String>,
1323        /// OAuth resource parameter to append to the authorization URL
1324        #[arg(long, requires = "url")]
1325        oauth_resource: Option<String>,
1326        /// OAuth scope to request during login. Repeat or comma-separate.
1327        #[arg(long = "scope", requires = "url", value_delimiter = ',')]
1328        scopes: Vec<String>,
1329        /// Arguments for command-based servers
1330        #[arg(long = "arg")]
1331        args: Vec<String>,
1332    },
1333    /// Authenticate to a URL-based MCP server using OAuth
1334    Login {
1335        /// Server name
1336        name: String,
1337        /// OAuth scope to request. Repeat or comma-separate; defaults to config/discovery.
1338        #[arg(long = "scope", value_delimiter = ',')]
1339        scopes: Vec<String>,
1340    },
1341    /// Delete stored OAuth credentials for a URL-based MCP server
1342    Logout {
1343        /// Server name
1344        name: String,
1345    },
1346    /// Remove an MCP server entry
1347    Remove {
1348        /// Server name
1349        name: String,
1350    },
1351    /// Enable an MCP server
1352    Enable {
1353        /// Server name
1354        name: String,
1355    },
1356    /// Disable an MCP server
1357    Disable {
1358        /// Server name
1359        name: String,
1360    },
1361    /// Validate MCP config and required servers
1362    Validate,
1363    /// Register this Codewhale binary as a local MCP stdio server.
1364    ///
1365    /// This adds a config entry that runs `codewhale serve --mcp` (stdio protocol).
1366    /// For the HTTP/SSE runtime API, use `codewhale serve --http` directly instead.
1367    #[command(
1368        name = "add-self",
1369        long_about = "Register this Codewhale binary as a local MCP stdio server.\n\nAdds a config entry to ~/.codewhale/mcp.json that launches `codewhale serve --mcp`\nvia the stdio transport. Other Codewhale sessions (or any MCP client) can then\ndiscover and call tools exposed by this server.\n\nUse `codewhale serve --http` instead if you need the HTTP/SSE runtime API."
1370    )]
1371    AddSelf {
1372        /// Server name in mcp.json (default: "codewhale")
1373        #[arg(long, default_value = "codewhale")]
1374        name: String,
1375        /// Workspace directory for the MCP server
1376        #[arg(long)]
1377        workspace: Option<String>,
1378    },
1379}
1380
1381#[derive(Args, Debug, Clone)]
1382struct FeaturesCli {
1383    #[command(subcommand)]
1384    command: FeaturesSubcommand,
1385}
1386
1387#[derive(Subcommand, Debug, Clone)]
1388enum FeaturesSubcommand {
1389    /// List known feature flags and their state
1390    List,
1391}
1392
1393#[derive(Args, Debug, Clone)]
1394struct SandboxArgs {
1395    #[command(subcommand)]
1396    command: SandboxCommand,
1397}
1398
1399#[derive(Subcommand, Debug, Clone)]
1400enum SandboxCommand {
1401    /// Run a command with sandboxing
1402    Run {
1403        /// Sandbox policy (danger-full-access, read-only, external-sandbox, workspace-write)
1404        #[arg(long, default_value = "workspace-write")]
1405        policy: String,
1406        /// Allow outbound network access
1407        #[arg(long)]
1408        network: bool,
1409        /// Additional writable roots (repeatable)
1410        #[arg(long, value_name = "PATH")]
1411        writable_root: Vec<PathBuf>,
1412        /// Exclude TMPDIR from writable paths
1413        #[arg(long)]
1414        exclude_tmpdir: bool,
1415        /// Exclude /tmp from writable paths
1416        #[arg(long)]
1417        exclude_slash_tmp: bool,
1418        /// Command working directory
1419        #[arg(long)]
1420        cwd: Option<PathBuf>,
1421        /// Timeout in milliseconds
1422        #[arg(long, default_value_t = 60_000)]
1423        timeout_ms: u64,
1424        /// Command and arguments to run
1425        #[arg(required = true, trailing_var_arg = true)]
1426        command: Vec<String>,
1427    },
1428}
1429
1430const CODEWHALE_MAIN_STACK_BYTES: usize = 16 * 1024 * 1024;
1431
1432/// Entry point for the single binary. Takes argv including binary name at 0,
1433/// parses with clap, and runs the TUI/runtime dispatch. Returns process exit
1434/// code for the caller to exit with.
1435pub fn run(args: Vec<String>) -> std::process::ExitCode {
1436    match run_with_args(args) {
1437        Ok(()) => std::process::ExitCode::SUCCESS,
1438        Err(err) => {
1439            eprintln!("error: {err}");
1440            for cause in err.chain().skip(1) {
1441                eprintln!("  caused by: {cause}");
1442            }
1443            std::process::ExitCode::FAILURE
1444        }
1445    }
1446}
1447
1448/// Internal implementation that mirrors the old `main()` but takes explicit
1449/// args instead of reading `std::env::args()`. Used by `run()` and tested
1450/// directly.
1451fn run_with_args(args: Vec<String>) -> Result<()> {
1452    // Match the dispatcher entrypoint: Unix shells and supervisors may inherit
1453    // SIGPIPE ignored, which turns short pipelines such as `codewhale doctor |
1454    // head` into BrokenPipe panics once this delegated TUI binary prints.
1455    #[cfg(unix)]
1456    unsafe {
1457        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1458    }
1459
1460    startup_trace::mark_process_start();
1461    configure_windows_console_utf8();
1462    install_rustls_crypto_provider();
1463
1464    // ── Process hardening (#2183) ─────────────────────────────────────────
1465    // MUST run before Tokio is booted and before any threads are spawned.
1466    // See crates/tui/src/sandbox/process_hardening.rs for ordering rationale.
1467    crate::sandbox::process_hardening::apply_process_hardening();
1468
1469    // Set up process panic hook before anything else — writes crash dumps
1470    // to ~/.deepseek/crashes/ even if the panic happens before tokio is up,
1471    // and restores the terminal so a panicked TUI doesn't leave the user's
1472    // shell stuck in alt-screen mode.
1473    let orig_hook = std::panic::take_hook();
1474    std::panic::set_hook(Box::new(move |panic_info| {
1475        // Restore the terminal first so the panic message itself, plus the
1476        // user's shell after exit, are visible. Best-effort — we may not be
1477        // in raw / alt-screen mode if the panic happens pre-TUI. Shared
1478        // with the signal handler installed below so both exit paths leave
1479        // the terminal in the same well-defined state.
1480        crate::tui::ui::emergency_restore_terminal();
1481
1482        let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1483            s.to_string()
1484        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1485            s.clone()
1486        } else {
1487            format!("{:?}", panic_info.payload())
1488        };
1489        let location = panic_info
1490            .location()
1491            .map(|loc| loc.to_string())
1492            .unwrap_or_else(|| "unknown".to_string());
1493        tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
1494
1495        // Telemetry, if and only if this process was armed. This hook is
1496        // installed before `Cli::parse()` and long before any config is
1497        // resolved, so it cannot consult a resolved value — but it can consult
1498        // a `OnceLock` that is by construction empty until resolution
1499        // completes. A user who never opted in panics without writing a byte
1500        // and without creating a directory.
1501        //
1502        // The site is allowlist-reduced and `msg` is deliberately not read: a
1503        // slicing panic embeds the entire string being sliced, and this tree
1504        // slices user and model text in dozens of places.
1505        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
1506        if let Some(site) = panic_info
1507            .location()
1508            .map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
1509        {
1510            codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
1511        }
1512        // Write crash dump best-effort
1513        if let Some(home) = crate::config::effective_home_dir() {
1514            let crash_dir = home.join(".deepseek").join("crashes");
1515            let _ = std::fs::create_dir_all(&crash_dir);
1516            use chrono::Utc;
1517            let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1518            let path = crash_dir.join(format!("{ts}-process-panic.log"));
1519            let contents =
1520                format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
1521            let _ = std::fs::write(&path, contents);
1522        }
1523        // Invoke the original hook (prints to stderr, etc.)
1524        orig_hook(panic_info);
1525    }));
1526
1527    // Parse and freeze every startup authority before Tokio or any other
1528    // worker thread exists. A workspace `.env` is intentionally a narrow
1529    // credential convenience surface: it must never redirect product state,
1530    // configuration, MCP, trust, sandbox, executable lookup, or plugin
1531    // discovery. Plugin discovery therefore runs first, and the loader below
1532    // admits only built-in provider credential names from a stable file read.
1533    let cli = match Cli::try_parse_from(args) {
1534        Ok(c) => c,
1535        Err(e) => {
1536            e.exit();
1537        }
1538    };
1539    // #5098: project-scope fleet agent profiles (`.codewhale/agents/*.toml`)
1540    // join the dispatch roster under the same trust decision as the rest of
1541    // project-level config — `--no-project-config` opts the layer out for
1542    // every roster read in this process.
1543    crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
1544    let workspace = resolve_workspace(&cli);
1545    let mut plugin_discovery = None;
1546    let mut plugin_registry = None;
1547    let (cli, command) = prepare_cli_startup(
1548        cli,
1549        || {
1550            let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
1551            plugin_registry = Some(discovery.registry_for_workspace(&workspace));
1552            plugin_discovery = Some(discovery);
1553        },
1554        warn_on_workspace_dotenv_result,
1555    );
1556    let plugin_discovery = plugin_discovery
1557        .expect("plugin discovery initialization must precede workspace dotenv loading");
1558    let plugin_registry = plugin_registry
1559        .expect("plugin discovery initialization must precede workspace dotenv loading");
1560
1561    // The interactive runtime intentionally carries a large state machine:
1562    // terminal rendering, modal dispatch, provider setup, and fleet/workflow
1563    // events all share one async owner. Debug builds retain enough stack
1564    // temporaries that nesting a modal event over the TUI loop can exceed the
1565    // platform main-thread default (8 MiB on macOS). Give that owner an
1566    // explicit stack while keeping process hardening and the global panic hook
1567    // above this boundary, before Tokio or any worker thread exists.
1568    let runtime_thread = std::thread::Builder::new()
1569        .name("codewhale-main".to_string())
1570        .stack_size(CODEWHALE_MAIN_STACK_BYTES)
1571        .spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
1572        .context("Failed to start the Codewhale runtime thread")?;
1573    match runtime_thread.join() {
1574        Ok(result) => result,
1575        Err(payload) => {
1576            let message = payload
1577                .downcast_ref::<&str>()
1578                .map(|value| (*value).to_string())
1579                .or_else(|| payload.downcast_ref::<String>().cloned())
1580                .unwrap_or_else(|| "unknown panic payload".to_string());
1581            Err(anyhow!("Codewhale runtime thread panicked: {message}"))
1582        }
1583    }
1584}
1585
1586fn run_async_main(
1587    cli: Cli,
1588    command: Option<Commands>,
1589    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1590    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1591) -> Result<()> {
1592    build_runtime()?.block_on(run_async_main_inner(
1593        cli,
1594        command,
1595        plugin_discovery,
1596        plugin_registry,
1597    ))
1598}
1599
1600/// Build the runtime that owns every async task in this binary.
1601///
1602/// `#[tokio::main]` used to expand here, which left every worker thread on
1603/// tokio's 2 MiB default while only the `codewhale-main` owner thread above
1604/// received `CODEWHALE_MAIN_STACK_BYTES`. The engine does not run on that owner
1605/// thread — `core::engine::spawn_engine` hands `Engine::run` to
1606/// `utils::spawn_supervised`, a bare `tokio::spawn` — so the explicit stack
1607/// never applied where the depth actually is.
1608///
1609/// A debug-build `agent` dispatch (turn_loop -> FuturesUnordered ->
1610/// execute_full_with_context -> AgentTool::execute -> spawn_subagent_from_input)
1611/// measured a stack high-water mark between 2.25 and 2.5 MiB and aborted the
1612/// whole process on the guard page. A Rust stack overflow is not a panic: it
1613/// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the
1614/// process dies with 134 mid-dispatch.
1615///
1616/// This is behavior-identical to the old `#[tokio::main]` expansion apart from
1617/// the stack size, and it makes the knob greppable.
1618pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
1619    tokio::runtime::Builder::new_multi_thread()
1620        .enable_all()
1621        .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
1622        .build()
1623        .context("Failed to build the Codewhale Tokio runtime")
1624}
1625
1626/// Which product surface this process is serving.
1627///
1628/// A function of the parsed subcommand, never of the executable: this one
1629/// binary serves at least five surfaces, so `current_exe()` would label all of
1630/// them the same.
1631fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
1632    use codewhale_telemetry::Surface;
1633    match command {
1634        None | Some(Commands::Resume { .. } | Commands::Fork { .. } | Commands::Pr { .. }) => {
1635            Surface::Tui
1636        }
1637        Some(Commands::Exec(_)) => Surface::Exec,
1638        Some(Commands::Serve(args)) => {
1639            if args.mcp {
1640                Surface::McpServer
1641            } else {
1642                Surface::Serve
1643            }
1644        }
1645        Some(_) => Surface::Cli,
1646    }
1647}
1648
1649/// How this session was started, for `session_start`.
1650fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
1651    use codewhale_telemetry::SessionSource;
1652    match command {
1653        None | Some(Commands::Pr { .. }) => SessionSource::Interactive,
1654        Some(Commands::Resume { .. }) => SessionSource::Resume,
1655        Some(Commands::Fork { .. }) => SessionSource::Fork,
1656        Some(Commands::Serve(_)) => SessionSource::Api,
1657        Some(_) => SessionSource::Unknown,
1658    }
1659}
1660
1661/// Read-only commands must not create telemetry state as a side effect.
1662fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
1663    matches!(
1664        command,
1665        Some(Commands::Doctor(_) | Commands::SessionDiagnostics(_) | Commands::Sessions { .. })
1666    ) || matches!(command, Some(Commands::Setup(args)) if args.status)
1667}
1668
1669/// Resolve the emit predicate and arm, once, before anything can record.
1670///
1671/// This is the read that v1 of the design was missing entirely:
1672/// `resolve_runtime_options` had no non-test caller in this crate, so neither
1673/// `telemetry = false` in the config file nor `CODEWHALE_TELEMETRY=0` was ever
1674/// consulted by a process that would have emitted.
1675///
1676/// `CliRuntimeOverrides::default()` is correct here. The dispatcher has already
1677/// applied the kill-switch floor and forwarded the *resolved* value through
1678/// `CODEWHALE_TELEMETRY`, which `EnvRuntimeOverrides::load()` picks up — and
1679/// re-reading `CODEWHALE_TELEMETRY` inside the telemetry crate would fork
1680/// `parse_bool`, the `DEEPSEEK_TELEMETRY` alias, and the floor into a second
1681/// source of truth.
1682fn arm_telemetry_with_setup(
1683    config_path: Option<PathBuf>,
1684    surface: codewhale_telemetry::Surface,
1685    source: codewhale_telemetry::SessionSource,
1686    setup_override: Option<&codewhale_config::SetupState>,
1687) {
1688    let Ok(store) = codewhale_config::ConfigStore::load(config_path) else {
1689        return;
1690    };
1691    let resolved = store
1692        .config
1693        .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1694    let setup = if let Some(setup) = setup_override {
1695        setup.clone()
1696    } else {
1697        let Some(setup) = codewhale_telemetry::load_setup_state_for_decision() else {
1698            // An existing unreadable privacy record may contain a decline.
1699            // Failing closed is safer than replacing it with default-on.
1700            return;
1701        };
1702        setup
1703    };
1704    let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
1705        codewhale_telemetry::decide(&resolved, &setup, surface)
1706    else {
1707        return;
1708    };
1709    codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
1710    let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
1711    codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
1712}
1713
1714fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
1715    if telemetry_command_is_read_only(command) {
1716        return;
1717    }
1718    arm_telemetry_with_setup(
1719        cli.config.clone(),
1720        telemetry_surface(command),
1721        telemetry_session_source(command),
1722        None,
1723    );
1724}
1725
1726/// Apply the choice made in the native TUI disclosure.
1727///
1728/// The in-memory setup state is authoritative for this process. In particular,
1729/// a Disable choice reaches `decide` as an opt-out even when neither durable
1730/// write landed, so the current launch cannot arm and any existing buffer is
1731/// wiped whenever the telemetry home remains reachable.
1732pub(crate) fn apply_tui_telemetry_decision(
1733    pending: &crate::telemetry_notice::PendingTelemetryNotice,
1734    setup: &codewhale_config::SetupState,
1735) {
1736    arm_telemetry_with_setup(
1737        pending.config_path.clone(),
1738        codewhale_telemetry::Surface::Tui,
1739        pending.session_source,
1740        Some(setup),
1741    );
1742}
1743
1744/// Close the armed session and flush, bounded.
1745async fn finish_telemetry(outcome: &Result<()>) {
1746    if !codewhale_telemetry::is_armed() {
1747        return;
1748    }
1749    // Only escalate: the panic hook and the signal path have already spoken if
1750    // they ran, and a stated class must not be overwritten by an inferred one.
1751    if outcome.is_err()
1752        && codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
1753    {
1754        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
1755    }
1756    codewhale_telemetry::record(telemetry_session_end());
1757    // `shutdown_blocking` parks a thread waiting on the writer, so it goes to
1758    // the blocking pool, and it is bounded there. The persistence actor's
1759    // unbounded `let _ = task.await` next door is not a pattern to copy here: a
1760    // hung TLS handshake would hold the process open past the last frame.
1761    let _ = tokio::time::timeout(
1762        codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
1763        tokio::task::spawn_blocking(|| {
1764            codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
1765        }),
1766    )
1767    .await;
1768}
1769
1770async fn run_async_main_inner(
1771    cli: Cli,
1772    command: Option<Commands>,
1773    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1774    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1775) -> Result<()> {
1776    // Install signal handlers that restore the terminal before the process
1777    // exits. Without this, Ctrl+C delivered while raw mode / kitty keyboard
1778    // enhancement / alt-screen are active (or in the brief windows around
1779    // startup and teardown where they're being toggled) leaves the user's shell
1780    // receiving raw CSI sequences like `^[[>5u` until they run `reset` (#1583).
1781    //
1782    // Once the TUI's raw mode is engaged the terminal driver delivers Ctrl+C as
1783    // the byte 0x03 rather than SIGINT, so the in-TUI key handler — not this
1784    // handler — is what processes user interrupts during normal operation. This
1785    // handler exists for the gaps: pre-TUI subcommands (--version, doctor,
1786    // login, …), the moments around enable_raw_mode / disable_raw_mode, the
1787    // external-editor suspend path, and SIGTERM / SIGHUP from the OS.
1788    //
1789    // It goes up before arming and before the notice: arming is the first
1790    // externally observable thing this process does (it creates the telemetry
1791    // buffer), and the notice is the first thing that can sit waiting on a
1792    // human. A Ctrl-C in either window must still restore the terminal and exit
1793    // 130 rather than kill the process outright. Recording a `session_end` from
1794    // the signal path is a no-op until `arm_telemetry` runs, so installing
1795    // ahead of it collects nothing.
1796    spawn_signal_cleanup_task();
1797
1798    // A due interactive disclosure belongs to the first native TUI frame. In
1799    // that one case arming is deferred until its decision event; every other
1800    // surface keeps the ordinary pre-dispatch predicate. This is what lets an
1801    // immediate Disable choice stop this very session without printing or
1802    // blocking on a shell questionnaire first.
1803    let surface = telemetry_surface(command.as_ref());
1804    let telemetry_notice_plan = if surface == codewhale_telemetry::Surface::Tui {
1805        crate::telemetry_notice::plan_if_due(
1806            cli.config.clone(),
1807            telemetry_session_source(command.as_ref()),
1808        )
1809    } else {
1810        crate::telemetry_notice::TelemetryNoticePlan::NotDue
1811    };
1812    let should_arm_before_dispatch = surface != codewhale_telemetry::Surface::Tui
1813        || telemetry_notice_plan.should_arm_before_tui();
1814    let pending_telemetry_notice = telemetry_notice_plan.into_pending();
1815    if should_arm_before_dispatch {
1816        arm_telemetry(&cli, command.as_ref());
1817    }
1818    let outcome = run_async_main_dispatch(
1819        cli,
1820        command,
1821        plugin_discovery,
1822        plugin_registry,
1823        pending_telemetry_notice,
1824    )
1825    .await;
1826    finish_telemetry(&outcome).await;
1827    outcome
1828}
1829
1830async fn run_async_main_dispatch(
1831    cli: Cli,
1832    command: Option<Commands>,
1833    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1834    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1835    mut pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
1836) -> Result<()> {
1837    logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
1838
1839    // Install any user prompt overrides from the config directory before an
1840    // engine can compose a system prompt. The override cells are
1841    // first-call-wins; doing this once here keeps every downstream turn
1842    // consistent. Missing files are a no-op (bundled defaults). See #3638.
1843    crate::prompts::load_prompt_overrides_from_config_home();
1844
1845    // Plugins own one read-only discovery snapshot per process. Initialize it
1846    // before the subcommand match so plain launch, resume, fork, exec, serve,
1847    // and every other runtime surface feed Skills and MCP from the same trust
1848    // decision (#3916, #4399). Discovery never enables, trusts, executes, or
1849    // persists a bundle.
1850
1851    // Handle subcommands first
1852    if let Some(command) = command {
1853        return match command {
1854            Commands::Doctor(args) => {
1855                let config = match load_doctor_config_from_cli(&cli, &args) {
1856                    Ok(config) => config,
1857                    Err(error) if args.json => return run_doctor_json_config_error(&error),
1858                    Err(_) => {
1859                        bail!(
1860                            "doctor configuration validation failed; details omitted because configuration errors may contain credential material"
1861                        )
1862                    }
1863                };
1864                let workspace = resolve_workspace(&cli);
1865                if args.context_json {
1866                    run_doctor_context_json(&config, &workspace)
1867                } else if args.json {
1868                    run_doctor_json(
1869                        &config,
1870                        &workspace,
1871                        cli.config.as_deref(),
1872                        plugin_registry.as_ref(),
1873                    )
1874                } else {
1875                    let probes = crate::doctor::DoctorProbeRequest {
1876                        check_updates: args.check_updates,
1877                        probe_api: args.probe_api,
1878                        probe_local: args.probe_local,
1879                        probe_mcp: args.probe_mcp,
1880                    };
1881                    run_doctor(
1882                        &config,
1883                        &workspace,
1884                        cli.config.as_deref(),
1885                        probes,
1886                        plugin_registry.as_ref(),
1887                    )
1888                    .await;
1889                    Ok(())
1890                }
1891            }
1892            Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
1893            Commands::Setup(args) => {
1894                let config = load_config_from_cli(&cli)?;
1895                let workspace = resolve_workspace(&cli);
1896                run_setup(&config, &workspace, args, plugin_registry.as_ref())
1897            }
1898            Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args),
1899            Commands::Completions { shell } => {
1900                generate_completions(shell);
1901                Ok(())
1902            }
1903            Commands::Sessions { limit, search } => list_sessions(limit, search),
1904            Commands::Init => init_project(),
1905            Commands::Login { api_key } => run_login(api_key),
1906            Commands::Logout => run_logout(),
1907            Commands::Auth(args) => match args.command {
1908                TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
1909            },
1910            Commands::Models(args) => {
1911                let config = load_config_from_cli(&cli)?;
1912                run_models(&config, args).await
1913            }
1914            Commands::Speech(args) => {
1915                let config = load_config_from_cli(&cli)?;
1916                run_speech(&config, args).await
1917            }
1918            Commands::Exec(args) => {
1919                let config = load_config_from_cli(&cli)?;
1920                let workspace = cli.workspace.clone().unwrap_or_else(|| {
1921                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1922                });
1923                let mut config = config.clone();
1924                // #4641: `--no-project-config` skips the workspace-specific
1925                // `[workspace]`/`[projects]` user-config overlay so a headless
1926                // launch (e.g. a future Verifiers harness) sees a reproducible
1927                // config surface that depends only on the explicit `--config`.
1928                if !cli.no_project_config {
1929                    merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
1930                }
1931                if let Some(sandbox) = args.sandbox.as_deref() {
1932                    let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
1933                    config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
1934                }
1935                // Honour CODEWHALE_BASE_URL / DEEPSEEK_BASE_URL forwarded by
1936                // the CLI dispatcher from --base-url.
1937                if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
1938                    .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
1939                {
1940                    let trimmed = env_url.trim();
1941                    if !trimmed.is_empty() {
1942                        config.base_url = Some(trimmed.to_string());
1943                    }
1944                }
1945                // Honour `--provider` (#4093): a Fleet worker whose profile pins
1946                // a provider launches on that provider even when the parent
1947                // session is on another one. This sets ONLY the non-secret
1948                // provider identity (`config.provider`); credentials/base URL
1949                // still resolve from the worker's own env/config, and for a
1950                // non-DeepSeek provider the legacy root `base_url` above is
1951                // ignored by `deepseek_base_url()`. Must precede model
1952                // resolution so an `auto`/default model resolves to the
1953                // overridden provider's default.
1954                let explicit_provider = args
1955                    .provider
1956                    .as_deref()
1957                    .map(str::trim)
1958                    .filter(|provider| !provider.is_empty());
1959                if let Some(provider_arg) = explicit_provider {
1960                    apply_exec_provider_override(&mut config, provider_arg)?;
1961                }
1962                if let Some(reasoning_arg) = args
1963                    .reasoning_effort
1964                    .as_deref()
1965                    .map(str::trim)
1966                    .filter(|value| !value.is_empty())
1967                {
1968                    config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
1969                    config.reasoning_effort_inferred_from_legacy_alias = false;
1970                }
1971                let prompt = join_prompt_parts(&args.prompt);
1972                let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
1973                validate_exec_tool_authority_resume(
1974                    args.tool_authority_json.as_deref(),
1975                    resume_session_id.is_some(),
1976                )?;
1977                let resume_session = resume_session_id
1978                    .as_deref()
1979                    .map(load_exec_resume_session)
1980                    .transpose()?;
1981                let explicit_model = args
1982                    .model
1983                    .as_deref()
1984                    .map(str::trim)
1985                    .filter(|model| !model.is_empty());
1986                let model = if let Some(saved) = resume_session.as_ref() {
1987                    resolve_exec_resume_route(
1988                        &mut config,
1989                        saved,
1990                        explicit_provider.is_some(),
1991                        explicit_model,
1992                    )?
1993                } else {
1994                    resolve_exec_model(&config, explicit_model)
1995                };
1996                let force_configured_route = should_force_configured_exec_route(
1997                    resume_session.is_some(),
1998                    explicit_provider,
1999                    explicit_model,
2000                );
2001                // The `deepseek` launcher forwards `--yolo` to this binary via
2002                // the DEEPSEEK_YOLO env var (which the config loader folds into
2003                // `config.yolo`), not as a CLI flag. Honour either source.
2004                let yolo = cli.yolo || config.yolo.unwrap_or(false);
2005                let env_tool_surface = exec_tool_surface_from_env();
2006                let needs_engine = args.auto
2007                    || yolo
2008                    || resume_session_id.is_some()
2009                    || args.output_format == ExecOutputFormat::StreamJson
2010                    || args.max_turns.is_some()
2011                    || args.allowed_tools.is_some()
2012                    || args.disallowed_tools.is_some()
2013                    || args.append_system_prompt.is_some()
2014                    || args.tool_authority_json.is_some()
2015                    || args.sandbox.is_some()
2016                    || args.allow_sandbox_elevation
2017                    || env_tool_surface.is_some();
2018                if needs_engine {
2019                    let provider = config.api_provider();
2020                    let max_subagents = cli.max_subagents.map_or_else(
2021                        || config.max_subagents_for_provider(provider),
2022                        |value| value.clamp(1, MAX_SUBAGENTS),
2023                    );
2024                    let auto_mode = args.auto || yolo;
2025                    let max_turns = exec_max_steps(args.max_turns);
2026                    let allowed_tools =
2027                        resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface);
2028                    let disallowed_tools = args
2029                        .disallowed_tools
2030                        .as_deref()
2031                        .map(normalize_exec_tool_names);
2032                    run_exec_agent(
2033                        &config,
2034                        &model,
2035                        &prompt,
2036                        workspace,
2037                        max_subagents,
2038                        auto_mode,
2039                        args.allow_sandbox_elevation,
2040                        args.sandbox.as_deref(),
2041                        auto_mode,
2042                        args.json,
2043                        resume_session,
2044                        force_configured_route,
2045                        args.output_format,
2046                        max_turns,
2047                        allowed_tools,
2048                        disallowed_tools,
2049                        args.append_system_prompt.clone(),
2050                        args.tool_authority_json.clone(),
2051                        std::sync::Arc::clone(&plugin_registry),
2052                    )
2053                    .await
2054                } else if args.json {
2055                    run_one_shot_json(&config, &model, &prompt, force_configured_route).await
2056                } else {
2057                    run_one_shot(&config, &model, &prompt, force_configured_route).await
2058                }
2059            }
2060            Commands::Fleet(args) => {
2061                let config = load_config_from_cli(&cli)?;
2062                let workspace = resolve_workspace(&cli);
2063                run_fleet_command(&workspace, &config, args).await
2064            }
2065            Commands::WorkflowTool(args) => {
2066                run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
2067            }
2068            Commands::Review(args) => {
2069                let config = load_config_from_cli(&cli)?;
2070                run_review(&config, args).await
2071            }
2072            Commands::Pr {
2073                number,
2074                repo,
2075                checkout,
2076            } => {
2077                let config = load_config_from_cli(&cli)?;
2078                run_pr(
2079                    &cli,
2080                    &config,
2081                    number,
2082                    repo.as_deref(),
2083                    checkout,
2084                    pending_telemetry_notice.take(),
2085                    Arc::clone(&plugin_registry),
2086                )
2087                .await
2088            }
2089            Commands::Apply(args) => run_apply(args),
2090            Commands::Eval(args) => run_eval(args),
2091            Commands::Scorecard(args) => run_scorecard(args),
2092            Commands::Mcp { command } => {
2093                let config = load_config_from_cli(&cli)?;
2094                let workspace = resolve_workspace(&cli);
2095                run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
2096            }
2097            Commands::Features(command) => {
2098                let config = load_config_from_cli(&cli)?;
2099                run_features_command(&config, command)
2100            }
2101            Commands::Sandbox(args) => run_sandbox_command(args),
2102            Commands::Serve(args) => {
2103                let workspace = cli.workspace.clone().unwrap_or_else(|| {
2104                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2105                });
2106                let http_selected = validate_serve_mode_selection(
2107                    args.mcp,
2108                    args.http,
2109                    args.mobile,
2110                    args.web,
2111                    args.acp,
2112                )?;
2113                if args.mcp {
2114                    tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
2115                } else if http_selected {
2116                    let (config, config_profile) =
2117                        load_config_from_cli_with_effective_profile(&cli)?;
2118                    let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
2119                    let bind_host = resolve_serve_bind_host(args.mobile, args.host);
2120                    if args.web && bind_host.host != "127.0.0.1" {
2121                        bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2122                    }
2123                    if bind_host.mobile_rebound_to_lan {
2124                        println!(
2125                            "WARNING: --mobile is binding to 0.0.0.0 so LAN devices can reach the mobile control page. Use --host 127.0.0.1 to keep mobile loopback-only."
2126                        );
2127                    }
2128                    runtime_api::run_http_server(
2129                        config,
2130                        workspace,
2131                        std::sync::Arc::clone(&plugin_discovery),
2132                        runtime_api::RuntimeApiOptions {
2133                            host: bind_host.host,
2134                            port: args.port,
2135                            workers: args.workers.clamp(1, 8),
2136                            cors_origins,
2137                            auth_token: args.auth_token,
2138                            insecure_no_auth: args.insecure_no_auth,
2139                            mobile: args.mobile,
2140                            web: args.web,
2141                            show_qr: args.qr,
2142                            config_path: cli.config.clone(),
2143                            config_profile,
2144                        },
2145                    )
2146                    .await
2147                } else if args.acp {
2148                    let config = load_config_from_cli(&cli)?;
2149                    let model = config.default_model();
2150                    acp_server::run_acp_server(config, model, workspace).await
2151                } else {
2152                    unreachable!("server mode count checked above")
2153                }
2154            }
2155            Commands::Resume { session_id, last } => {
2156                let config = load_config_from_cli(&cli)?;
2157                let workspace = resolve_workspace(&cli);
2158                let resume_id = resolve_session_id(session_id, last, &workspace)?;
2159                run_interactive(
2160                    &cli,
2161                    &config,
2162                    Some(resume_id),
2163                    None,
2164                    pending_telemetry_notice.take(),
2165                    std::sync::Arc::clone(&plugin_registry),
2166                )
2167                .await
2168            }
2169            Commands::Fork { session_id, last } => {
2170                let config = load_config_from_cli(&cli)?;
2171                let workspace = resolve_workspace(&cli);
2172                let new_session_id = fork_session(&config, session_id, last, &workspace)?;
2173                run_interactive(
2174                    &cli,
2175                    &config,
2176                    Some(new_session_id),
2177                    None,
2178                    pending_telemetry_notice.take(),
2179                    std::sync::Arc::clone(&plugin_registry),
2180                )
2181                .await
2182            }
2183        };
2184    }
2185
2186    // Top-level prompt mode: submit the initial prompt, then keep the TUI alive
2187    // for follow-up messages. Use `codewhale exec` for explicit non-interactive
2188    // one-shot behavior (#2370).
2189    let config = load_config_from_cli(&cli)?;
2190    if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
2191        return run_interactive(
2192            &cli,
2193            &config,
2194            None,
2195            Some(initial_input),
2196            pending_telemetry_notice.take(),
2197            std::sync::Arc::clone(&plugin_registry),
2198        )
2199        .await;
2200    }
2201
2202    // Handle session resume. Plain `codewhale` starts fresh: interrupted
2203    // snapshots are preserved for explicit resume, but never auto-attached.
2204    let mut startup_notice = None;
2205    let resume_session_id = if cli.continue_session {
2206        let workspace = resolve_workspace(&cli);
2207        recover_interrupted_checkpoint_for_resume(&workspace)
2208            .or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
2209    } else if let Some(id) = cli.resume.clone() {
2210        Some(id)
2211    } else if !cli.fresh {
2212        let workspace = resolve_workspace(&cli);
2213        preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
2214        // Opt-in auto-resume (#2934). Off by default, so the historical
2215        // "plain `codewhale` starts fresh" behaviour is unchanged unless the
2216        // user asked for something else. The decision never resumes an
2217        // archived, unreadable, or foreign-workspace session; every fallback
2218        // carries a receipt rather than silently starting blank.
2219        let (session_id, notice) = resolve_auto_resume(&workspace);
2220        startup_notice = notice;
2221        session_id
2222    } else {
2223        None
2224    };
2225
2226    // Default: Interactive TUI
2227    // --yolo starts in YOLO mode (auto-approve; shell enabled)
2228    run_interactive_with_notice(
2229        &cli,
2230        &config,
2231        resume_session_id,
2232        None,
2233        startup_notice,
2234        pending_telemetry_notice.take(),
2235        plugin_registry,
2236    )
2237    .await
2238}
2239
2240/// Resolve the opt-in auto-resume setting into a session id plus a receipt.
2241///
2242/// Deliberately scoped to the plain interactive launch. `codewhale "do X"`
2243/// (top-level prompt) and `codewhale exec` are not covered: silently prefixing
2244/// a one-shot task with a prior conversation would change what is sent to the
2245/// model, which is not a layout preference the user opted into.
2246fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
2247    use crate::session_resume::{ResumeRequest, decide_auto_resume};
2248
2249    let enabled = crate::settings::Settings::load_persisted()
2250        .map(|settings| settings.session_auto_resume)
2251        .unwrap_or(false);
2252    if !enabled {
2253        return (None, None);
2254    }
2255    let Ok(manager) = SessionManager::default_location() else {
2256        return (None, None);
2257    };
2258    let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
2259    (
2260        decision.session_id().map(str::to_string),
2261        decision.status_message(),
2262    )
2263}
2264
2265fn prepare_cli_startup(
2266    cli: Cli,
2267    initialize_plugins: impl FnOnce(),
2268    load_dotenv: impl FnOnce(),
2269) -> (Cli, Option<Commands>) {
2270    initialize_plugins();
2271    let command = cli.command.clone();
2272    let should_load_dotenv = match command.as_ref() {
2273        Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
2274        _ => true,
2275    };
2276    if should_load_dotenv {
2277        load_dotenv();
2278    }
2279    (cli, command)
2280}
2281
2282const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
2283
2284#[derive(Debug, Default)]
2285struct WorkspaceDotenvReport {
2286    path: PathBuf,
2287    loaded: BTreeSet<String>,
2288    ignored: BTreeSet<String>,
2289}
2290
2291/// Load the narrow, data-plane subset of a workspace `.env` before Tokio.
2292///
2293/// Repository content is not product authority. In particular, a committed
2294/// `.env` must not be able to redirect `CODEWHALE_HOME`, config/profile files,
2295/// MCP servers, plugin trust, executable lookup, sandbox/approval posture, or
2296/// network destinations. Shell-exported values and config/CLI arguments remain
2297/// the explicit surfaces for those controls.
2298fn warn_on_workspace_dotenv_result() {
2299    match load_workspace_dotenv_credentials() {
2300        Ok(Some(report)) if !report.ignored.is_empty() => {
2301            eprintln!(
2302                "Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
2303                report.path.display(),
2304                display_env_key_set(&report.ignored)
2305            );
2306        }
2307        Ok(_) => {}
2308        Err(error) => {
2309            // The error intentionally contains no file contents or parsed
2310            // values. A malformed or unsafe workspace file fails closed while
2311            // shell/config credentials remain available.
2312            eprintln!("Codewhale did not load workspace .env: {error}");
2313        }
2314    }
2315}
2316
2317fn display_env_key_set(keys: &BTreeSet<String>) -> String {
2318    const MAX_DISPLAYED: usize = 12;
2319    let mut labels = keys
2320        .iter()
2321        .take(MAX_DISPLAYED)
2322        .map(|key| {
2323            if key
2324                .chars()
2325                .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
2326            {
2327                key.as_str()
2328            } else {
2329                "<invalid-name>"
2330            }
2331        })
2332        .collect::<Vec<_>>();
2333    if keys.len() > MAX_DISPLAYED {
2334        labels.push("...");
2335    }
2336    labels.join(", ")
2337}
2338
2339fn load_workspace_dotenv_credentials() -> Result<Option<WorkspaceDotenvReport>> {
2340    let Some(path) = find_workspace_dotenv()? else {
2341        return Ok(None);
2342    };
2343    load_workspace_dotenv_credentials_from_path(&path).map(Some)
2344}
2345
2346fn find_workspace_dotenv() -> Result<Option<PathBuf>> {
2347    let cwd = std::env::current_dir().context("could not resolve the current workspace")?;
2348    let boundary = cwd
2349        .ancestors()
2350        .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
2351        .unwrap_or(cwd.as_path());
2352
2353    for ancestor in cwd.ancestors() {
2354        let candidate = ancestor.join(".env");
2355        match std::fs::symlink_metadata(&candidate) {
2356            Ok(_) => return Ok(Some(candidate)),
2357            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2358            Err(error) => {
2359                return Err(anyhow!(
2360                    "could not inspect {}: {error}",
2361                    candidate.display()
2362                ));
2363            }
2364        }
2365        if ancestor == boundary {
2366            break;
2367        }
2368    }
2369    Ok(None)
2370}
2371
2372fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
2373    let contents = read_stable_workspace_dotenv(path)?;
2374    let text = std::str::from_utf8(&contents)
2375        .map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
2376    if dotenv_has_variable_expansion(text) {
2377        bail!(
2378            "{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
2379            path.display()
2380        );
2381    }
2382
2383    let mut report = WorkspaceDotenvReport {
2384        path: path.to_path_buf(),
2385        ..WorkspaceDotenvReport::default()
2386    };
2387    let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
2388        .collect::<std::result::Result<Vec<_>, _>>()
2389        .map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
2390    for entry in entries {
2391        let (key, value) = entry;
2392        if !is_workspace_dotenv_credential_key(&key) {
2393            report.ignored.insert(key);
2394            continue;
2395        }
2396        if std::env::var_os(&key).is_some() {
2397            continue;
2398        }
2399
2400        // SAFETY: this loader runs synchronously in `main` before the runtime
2401        // owner or Tokio workers are spawned. No concurrent environment reader
2402        // exists inside Codewhale, and later startup code treats this process
2403        // environment as immutable.
2404        unsafe { std::env::set_var(&key, value) };
2405        report.loaded.insert(key);
2406    }
2407    Ok(report)
2408}
2409
2410fn is_workspace_dotenv_credential_key(key: &str) -> bool {
2411    codewhale_config::provider::providers_sorted_for_display()
2412        .into_iter()
2413        .any(|provider| provider.env_vars().contains(&key))
2414        || matches!(
2415            key,
2416            "DEEPSEEK_SEARCH_API_KEY"
2417                | "SOFYA_API_KEY"
2418                | "METASO_API_KEY"
2419                | "BAIDU_SEARCH_API_KEY"
2420                | "DEEPSEEK_SANDBOX_API_KEY"
2421        )
2422}
2423
2424fn dotenv_has_variable_expansion(contents: &str) -> bool {
2425    let mut escaped = false;
2426    let mut single_quoted = false;
2427    let mut double_quoted = false;
2428    let mut comment = false;
2429
2430    for ch in contents.chars() {
2431        if comment {
2432            // Reject expansion markers even in comments. This is deliberately
2433            // conservative, and ignoring other comment text prevents an
2434            // unmatched quote there from changing how the next line is read.
2435            if ch == '$' {
2436                return true;
2437            }
2438            if ch == '\n' {
2439                comment = false;
2440                escaped = false;
2441            }
2442            continue;
2443        }
2444        if single_quoted {
2445            if ch == '\'' {
2446                single_quoted = false;
2447            }
2448            continue;
2449        }
2450        if escaped {
2451            escaped = false;
2452            continue;
2453        }
2454        if ch == '\\' {
2455            escaped = true;
2456            continue;
2457        }
2458        if ch == '\'' && !double_quoted {
2459            single_quoted = true;
2460            continue;
2461        }
2462        if ch == '"' {
2463            double_quoted = !double_quoted;
2464            continue;
2465        }
2466        if ch == '#' && !double_quoted {
2467            comment = true;
2468            continue;
2469        }
2470        if ch == '$' {
2471            return true;
2472        }
2473    }
2474    false
2475}
2476
2477fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
2478    let mut file = open_workspace_dotenv_without_following_links(path)?;
2479    let metadata = file
2480        .metadata()
2481        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2482    if !metadata.is_file() {
2483        bail!("{} is not a regular file", path.display());
2484    }
2485    if workspace_dotenv_has_multiple_links(&file, &metadata)? {
2486        bail!(
2487            "{} has multiple filesystem links, not a unique workspace-owned file",
2488            path.display()
2489        );
2490    }
2491    if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
2492        bail!(
2493            "{} exceeds the {} byte workspace .env limit",
2494            path.display(),
2495            MAX_WORKSPACE_DOTENV_BYTES
2496        );
2497    }
2498
2499    let mut contents = Vec::with_capacity(metadata.len() as usize);
2500    (&mut file)
2501        .take(MAX_WORKSPACE_DOTENV_BYTES + 1)
2502        .read_to_end(&mut contents)
2503        .map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
2504    if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
2505        bail!(
2506            "{} exceeds the {} byte workspace .env limit",
2507            path.display(),
2508            MAX_WORKSPACE_DOTENV_BYTES
2509        );
2510    }
2511    Ok(contents)
2512}
2513
2514#[cfg(unix)]
2515fn workspace_dotenv_has_multiple_links(
2516    _file: &std::fs::File,
2517    metadata: &std::fs::Metadata,
2518) -> Result<bool> {
2519    use std::os::unix::fs::MetadataExt;
2520
2521    Ok(metadata.nlink() > 1)
2522}
2523
2524#[cfg(windows)]
2525fn workspace_dotenv_has_multiple_links(
2526    file: &std::fs::File,
2527    _metadata: &std::fs::Metadata,
2528) -> Result<bool> {
2529    use std::os::windows::io::AsRawHandle;
2530    use windows::Win32::Foundation::HANDLE;
2531    use windows::Win32::Storage::FileSystem::{
2532        BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
2533    };
2534
2535    let mut information = BY_HANDLE_FILE_INFORMATION::default();
2536    // SAFETY: `file` owns a live kernel handle for the already-open `.env`;
2537    // `information` remains writable for the duration of this synchronous
2538    // call. No path lookup or re-open occurs here.
2539    unsafe {
2540        GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
2541            .map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
2542    }
2543    Ok(information.nNumberOfLinks > 1)
2544}
2545
2546#[cfg(not(any(unix, windows)))]
2547fn workspace_dotenv_has_multiple_links(
2548    _file: &std::fs::File,
2549    _metadata: &std::fs::Metadata,
2550) -> Result<bool> {
2551    Ok(false)
2552}
2553
2554#[cfg(unix)]
2555fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2556    use std::os::unix::fs::OpenOptionsExt;
2557
2558    std::fs::OpenOptions::new()
2559        .read(true)
2560        // `O_NONBLOCK` is inert for regular files but prevents a FIFO named
2561        // `.env` from hanging startup before the metadata check can reject it.
2562        .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
2563        .open(path)
2564        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2565}
2566
2567#[cfg(windows)]
2568fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2569    use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
2570
2571    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
2572    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2573    let file = std::fs::OpenOptions::new()
2574        .read(true)
2575        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
2576        .open(path)
2577        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
2578    let metadata = file
2579        .metadata()
2580        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2581    if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
2582        bail!(
2583            "{} is a reparse point, not a workspace-owned file",
2584            path.display()
2585        );
2586    }
2587    Ok(file)
2588}
2589
2590#[cfg(not(any(unix, windows)))]
2591fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2592    let metadata = std::fs::symlink_metadata(path)
2593        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2594    if metadata.file_type().is_symlink() {
2595        bail!(
2596            "{} is a symbolic link, not a workspace-owned file",
2597            path.display()
2598        );
2599    }
2600    std::fs::File::open(path)
2601        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2602}
2603
2604/// Generate shell completions for the given shell
2605fn generate_completions(shell: Shell) {
2606    let mut cmd = Cli::command();
2607    let name = cmd.get_name().to_string();
2608    generate(shell, &mut cmd, name, &mut io::stdout());
2609}
2610
2611/// Run the offline evaluation harness (no network/LLM calls).
2612fn run_eval(args: EvalArgs) -> Result<()> {
2613    let fail_step = match args.fail_step.as_deref() {
2614        Some(value) => ScenarioStepKind::parse(value)
2615            .map(Some)
2616            .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
2617        None => None,
2618    };
2619
2620    let config = EvalHarnessConfig {
2621        fail_step,
2622        shell_command: args.shell_command,
2623        shell_expect_token: args.shell_expect_token,
2624        max_output_chars: args.max_output_chars,
2625        record_dir: args.record.clone(),
2626        ..EvalHarnessConfig::default()
2627    };
2628
2629    let harness = EvalHarness::new(config);
2630    let run = harness.run().context("evaluation harness failed")?;
2631    let report = run.to_report();
2632
2633    if args.json {
2634        let json = serde_json::to_string_pretty(&report)?;
2635        println!("{json}");
2636    } else {
2637        println!("Offline Eval Harness");
2638        println!("scenario: {}", report.scenario_name);
2639        println!("workspace: {}", report.workspace_root.display());
2640        println!("success: {}", report.metrics.success);
2641        println!("steps: {}", report.metrics.steps);
2642        println!("tool_errors: {}", report.metrics.tool_errors);
2643        println!("duration_ms: {}", report.metrics.duration.as_millis());
2644
2645        if !report.metrics.per_tool.is_empty() {
2646            println!("per_tool:");
2647            for (kind, stats) in &report.metrics.per_tool {
2648                println!(
2649                    "  {} invocations={} errors={} duration_ms={}",
2650                    kind.tool_name(),
2651                    stats.invocations,
2652                    stats.errors,
2653                    stats.total_duration.as_millis()
2654                );
2655            }
2656        }
2657
2658        let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
2659        if !failed_steps.is_empty() {
2660            println!("failed_steps:");
2661            for step in failed_steps {
2662                let error = step.error.as_deref().unwrap_or("unknown error");
2663                println!(
2664                    "  {} tool={} error={}",
2665                    step.kind.tool_name(),
2666                    step.tool_name,
2667                    error
2668                );
2669            }
2670        }
2671    }
2672
2673    if report.metrics.success {
2674        Ok(())
2675    } else {
2676        bail!("offline evaluation harness reported failure")
2677    }
2678}
2679
2680/// Score a run's token/cache/cost from recorded turns and (optionally) flag
2681/// regressions against a committed baseline. Offline: reads recorded usage from
2682/// a JSON file, reuses the pricing layer, never calls a model. Exits non-zero
2683/// when a baseline is supplied and a metric regresses past the threshold, so it
2684/// can be wired as a release gate (#3388).
2685fn run_scorecard(args: ScorecardArgs) -> Result<()> {
2686    use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
2687
2688    let raw = std::fs::read_to_string(&args.input)
2689        .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
2690    let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
2691        .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
2692
2693    let card = Scorecard::from_recorded_turns(&recorded);
2694
2695    let regressions = match &args.baseline {
2696        Some(path) => {
2697            let baseline_raw = std::fs::read_to_string(path)
2698                .with_context(|| format!("failed to read baseline {}", path.display()))?;
2699            let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
2700                .with_context(|| format!("failed to parse baseline {}", path.display()))?;
2701            card.metrics.regressions_against(&baseline, args.threshold)
2702        }
2703        None => Vec::new(),
2704    };
2705
2706    if args.json {
2707        let out = serde_json::json!({
2708            "per_turn": card.per_turn,
2709            "metrics": card.metrics,
2710            "regressions": regressions,
2711        });
2712        println!("{}", serde_json::to_string_pretty(&out)?);
2713    } else {
2714        print!("{}", card.to_summary());
2715        for r in &regressions {
2716            println!(
2717                "REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
2718                r.metric, r.baseline, r.current, r.pct_increase
2719            );
2720        }
2721    }
2722
2723    if regressions.is_empty() {
2724        Ok(())
2725    } else {
2726        bail!(
2727            "{} metric(s) regressed past the {:.1}% threshold",
2728            regressions.len(),
2729            args.threshold
2730        )
2731    }
2732}
2733
2734async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
2735    use crate::fleet::alerts::{
2736        FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
2737        FleetEnvSecretResolver,
2738    };
2739    use crate::fleet::control as fleet_control;
2740    use crate::fleet::executor::FleetExecutor;
2741    use crate::fleet::manager::{FleetManager, FleetStatusSnapshot, FleetWorkerInspection};
2742    use codewhale_lane::{ControlOperation, ControlSurface};
2743    use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
2744
2745    // Every label and every row below comes from the shared Fleet control
2746    // surface, so `codewhale fleet …` and `/fleet …` cannot drift in how they
2747    // describe the same durable ledger (#1888, #4022).
2748    fn print_status(status: &FleetStatusSnapshot) {
2749        println!("{}", fleet_control::render_fleet_status_snapshot(status));
2750    }
2751
2752    fn print_inspection(inspection: &FleetWorkerInspection) {
2753        println!("{}", fleet_control::render_inspection(inspection));
2754    }
2755
2756    fn print_artifacts(inspection: &FleetWorkerInspection) {
2757        println!("{}", fleet_control::render_artifacts(inspection));
2758    }
2759
2760    /// Print one shared control receipt on the CLI surface.
2761    fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
2762        if receipt.is_error() {
2763            eprintln!("{}", receipt.render());
2764            let detail = receipt
2765                .failure
2766                .as_ref()
2767                .map(ToString::to_string)
2768                .unwrap_or_else(|| receipt.outcome.as_str().to_string());
2769            bail!("{}: {detail}", receipt.operation_id);
2770        }
2771        println!("{}", receipt.render());
2772        Ok(())
2773    }
2774
2775    fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
2776        let mut printed = false;
2777        for artifact in inspection
2778            .artifacts
2779            .iter()
2780            .filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
2781        {
2782            let path = workspace.join(&artifact.path);
2783            println!("== {} ==", artifact.path.display());
2784            let contents = std::fs::read_to_string(&path)
2785                .with_context(|| format!("reading fleet log {}", path.display()))?;
2786            let preview: String = contents.chars().take(16 * 1024).collect();
2787            // Worker logs can contain captured terminal bytes (a child TUI's
2788            // mouse-tracking handshake, SGR, OSC). Printing them raw would
2789            // re-arm mouse reporting in the caller's shell and leave it
2790            // executing escape fragments after this command exits.
2791            let mut safe_preview = String::with_capacity(preview.len());
2792            crate::tui::osc8::strip_ansi_into(&preview, &mut safe_preview);
2793            print!("{safe_preview}");
2794            if contents.chars().count() > preview.chars().count() {
2795                println!("\n[truncated]");
2796            } else if !preview.ends_with('\n') {
2797                println!();
2798            }
2799            printed = true;
2800        }
2801        if !printed {
2802            println!("logs: none");
2803        }
2804        Ok(())
2805    }
2806
2807    fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
2808        match arg {
2809            FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
2810            FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
2811            FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
2812            FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
2813            FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
2814            FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
2815        }
2816    }
2817
2818    fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
2819        if let Some(status) = override_status {
2820            return status;
2821        }
2822        match class {
2823            FleetAlertEventClass::Stale => "stale",
2824            FleetAlertEventClass::RestartExhausted => "failed",
2825            FleetAlertEventClass::NeedsHuman => "needs_human",
2826            FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
2827            FleetAlertEventClass::VerifierFailed => "verifier_failed",
2828            FleetAlertEventClass::RunCompleted => "completed",
2829        }
2830        .to_string()
2831    }
2832
2833    fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
2834        match args.adapter {
2835            FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
2836                webhook_env: args.slack_webhook_env.clone(),
2837                channel: None,
2838            },
2839            FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
2840                url_env: args.webhook_url_env.clone(),
2841                secret_env: args.webhook_secret_env.clone(),
2842            },
2843            FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
2844                routing_key_env: args.pagerduty_routing_key_env.clone(),
2845                severity: args.pagerduty_severity.clone(),
2846            },
2847        }
2848    }
2849
2850    let fleet_config = config.fleet_config();
2851    let provider = config.api_provider();
2852    let max_subagents = config.max_subagents_for_provider(provider);
2853    let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout(
2854        workspace.to_path_buf(),
2855        max_subagents,
2856        config
2857            .max_admitted_subagents_for_provider(provider)
2858            .max(max_subagents),
2859        Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
2860        config.launch_concurrency_for_provider(provider),
2861        config.subagent_token_budget_for_provider(provider),
2862    );
2863    // Probe the durable ledger *before* opening the manager: FleetManager::open
2864    // creates `.codewhale/fleet.jsonl` as a side effect, so a later probe would
2865    // always find a ledger and the CLI would report availability differently
2866    // from the slash surface for the same workspace (#4022).
2867    let fleet_context = fleet_control::fleet_control_context(workspace);
2868    // Probing is not enough on its own: `FleetManager::open` *creates* the
2869    // ledger, and it used to run for every subcommand before this match. That
2870    // made `codewhale fleet status` in a ledgerless workspace print
2871    // "no_fleet_ledger" while simultaneously creating the file it said was
2872    // missing — and the next invocation then reported an empty ledger as if a
2873    // Fleet had existed all along. Refuse the control verbs here, before the
2874    // manager exists, so the CLI and `/fleet` agree and neither surface
2875    // conjures the store it is reporting on (#4022).
2876    if let Some(operation) = match &args.command {
2877        FleetCommand::List => Some(ControlOperation::FleetList),
2878        FleetCommand::Status => Some(ControlOperation::FleetStatus),
2879        FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
2880        FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
2881        _ => None,
2882    } {
2883        let descriptor = operation.descriptor();
2884        let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
2885        if !availability.is_available() {
2886            return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
2887                descriptor,
2888                ControlSurface::Cli,
2889                availability,
2890            ));
2891        }
2892    }
2893
2894    // The configured route is the operator: fleet workers without a
2895    // task/profile model pin inherit the session's active model.
2896    let manager = FleetManager::open(workspace)?
2897        .with_exec_config(fleet_config.exec.clone())
2898        .with_fleet_config(fleet_config)
2899        .with_sub_agent_manager(coordination_manager)
2900        .with_session_model(config.default_model())
2901        .with_route_config(config.clone());
2902    match args.command {
2903        FleetCommand::Init => {
2904            println!("fleet ledger: {}", manager.ledger_path().display());
2905            Ok(())
2906        }
2907        FleetCommand::Run(args) => {
2908            let max_workers = args.max_workers.clamp(1, 128);
2909            let manager =
2910                manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
2911            let report = manager.create_run_from_task_spec_path(&args.task_spec, max_workers)?;
2912            println!(
2913                "fleet run: {} tasks={} leased={} queued={}",
2914                report.run_id.0, report.task_count, report.leased, report.queued
2915            );
2916            for warning in &report.warnings {
2917                println!("warning: {warning}");
2918            }
2919            println!("workers:");
2920            for worker_id in &report.worker_ids {
2921                println!("  {worker_id}");
2922            }
2923            if args.once {
2924                print_status(&manager.run_status(&report.run_id)?);
2925                return Ok(());
2926            }
2927            println!(
2928                "manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
2929            );
2930            let mut executor = FleetExecutor::new(workspace);
2931            let codewhale_binary = fleet::executor::configured_codewhale_binary();
2932            let status = manager
2933                .run_to_completion(
2934                    &report.run_id,
2935                    max_workers,
2936                    &mut executor,
2937                    &codewhale_binary,
2938                    None,
2939                    Duration::from_secs(2),
2940                )
2941                .await?;
2942            print_status(&status);
2943            Ok(())
2944        }
2945        FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2946            ControlSurface::Cli,
2947            workspace,
2948            fleet_context,
2949            &manager,
2950            ControlOperation::FleetList,
2951            None,
2952        )),
2953        FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2954            ControlSurface::Cli,
2955            workspace,
2956            fleet_context,
2957            &manager,
2958            ControlOperation::FleetStatus,
2959            None,
2960        )),
2961        FleetCommand::Inspect { worker_id } => {
2962            print_inspection(&manager.inspect_worker(&worker_id)?);
2963            Ok(())
2964        }
2965        FleetCommand::Logs { worker_id } => {
2966            let inspection = manager.inspect_worker(&worker_id)?;
2967            print_logs(workspace, &inspection)
2968        }
2969        FleetCommand::Artifacts { worker_id } => {
2970            let inspection = manager.inspect_worker(&worker_id)?;
2971            print_artifacts(&inspection);
2972            Ok(())
2973        }
2974        FleetCommand::Interrupt { worker_id } => {
2975            emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2976                ControlSurface::Cli,
2977                workspace,
2978                fleet_context,
2979                &manager,
2980                ControlOperation::FleetInterrupt,
2981                Some(&worker_id),
2982            ))
2983        }
2984        FleetCommand::Restart { worker_id } => {
2985            let report = manager.restart_worker(&worker_id)?;
2986            print_inspection(&report.inspection);
2987            println!(
2988                "manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
2989                report.run_id.0
2990            );
2991            let mut executor = FleetExecutor::new(workspace);
2992            let codewhale_binary = fleet::executor::configured_codewhale_binary();
2993            let status = manager
2994                .run_to_completion(
2995                    &report.run_id,
2996                    report.max_workers,
2997                    &mut executor,
2998                    &codewhale_binary,
2999                    None,
3000                    Duration::from_secs(2),
3001                )
3002                .await?;
3003            print_status(&status);
3004            Ok(())
3005        }
3006        FleetCommand::Resume {
3007            run_id,
3008            stale_after_seconds,
3009        } => {
3010            let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
3011            emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3012                ControlSurface::Cli,
3013                workspace,
3014                fleet_context,
3015                &manager,
3016                ControlOperation::FleetResume,
3017                Some(&run_id),
3018            ))
3019        }
3020        FleetCommand::Stop { all } => {
3021            if !all {
3022                bail!("pass --all to stop all fleet work");
3023            }
3024            let stopped = manager.stop_all()?;
3025            println!("stopped: {stopped}");
3026            Ok(())
3027        }
3028        FleetCommand::AlertDryRun(args) => {
3029            let class = alert_event_class(args.event);
3030            let adapter = alert_adapter(&args);
3031            let event = FleetAlertEvent {
3032                class,
3033                run_id: FleetRunId::from(args.run_id.clone()),
3034                worker_id: args.worker_id.clone(),
3035                task_id: args.task_id.clone(),
3036                status: alert_status(class, args.status.clone()),
3037                reason: args.reason.clone(),
3038            };
3039            let dispatcher = FleetAlertDispatcher::new(
3040                FleetAlertConfig::dry_run_for_adapter(adapter),
3041                FleetEnvSecretResolver,
3042            );
3043            let deliveries = dispatcher.dispatch(&event)?;
3044            for delivery in deliveries {
3045                println!(
3046                    "{}",
3047                    serde_json::to_string_pretty(&delivery.redacted_payload)?
3048                );
3049            }
3050            Ok(())
3051        }
3052    }
3053}
3054
3055#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3056enum WriteStatus {
3057    Created,
3058    Overwritten,
3059    SkippedExists,
3060}
3061
3062fn ensure_parent_dir(path: &Path) -> Result<()> {
3063    if let Some(parent) = path.parent()
3064        && !parent.as_os_str().is_empty()
3065    {
3066        std::fs::create_dir_all(parent)
3067            .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
3068    }
3069    Ok(())
3070}
3071
3072fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
3073    ensure_parent_dir(path)?;
3074
3075    if path.exists() && !force {
3076        return Ok(WriteStatus::SkippedExists);
3077    }
3078
3079    let status = if path.exists() {
3080        WriteStatus::Overwritten
3081    } else {
3082        WriteStatus::Created
3083    };
3084
3085    std::fs::write(path, contents)
3086        .with_context(|| format!("Failed to write template at {}", path.display()))?;
3087
3088    Ok(status)
3089}
3090
3091fn mcp_template_json() -> Result<String> {
3092    let mut cfg = McpConfig::default();
3093    cfg.servers.insert(
3094        "example".to_string(),
3095        McpServerConfig {
3096            command: Some("node".to_string()),
3097            args: vec!["./path/to/your-mcp-server.js".to_string()],
3098            env: std::collections::HashMap::new(),
3099            cwd: None,
3100            url: None,
3101            transport: None,
3102            connect_timeout: None,
3103            execute_timeout: None,
3104            read_timeout: None,
3105            disabled: true,
3106            enabled: true,
3107            required: false,
3108            enabled_tools: Vec::new(),
3109            disabled_tools: Vec::new(),
3110            headers: std::collections::HashMap::new(),
3111            env_headers: std::collections::HashMap::new(),
3112            bearer_token_env_var: None,
3113            scopes: Vec::new(),
3114            oauth: None,
3115            oauth_resource: None,
3116            reviewed_plugin: None,
3117        },
3118    );
3119    serde_json::to_string_pretty(&cfg)
3120        .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
3121}
3122
3123fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
3124    let template = mcp_template_json()?;
3125    write_template_file(path, &template, force)
3126}
3127
3128fn skills_template(name: &str) -> String {
3129    format!(
3130        "\
3131---\n\
3132name: {name}\n\
3133description: Quick repo diagnostics and setup guidance\n\
3134allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
3135---\n\n\
3136When this skill is active:\n\
31371. Run the diagnostics tool to report workspace and sandbox status.\n\
31382. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
31393. Prefer small, validated changes and summarize what you verified.\n\
3140"
3141    )
3142}
3143
3144fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
3145    std::fs::create_dir_all(skills_dir)
3146        .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
3147
3148    let skill_name = "getting-started";
3149    let skill_path = skills_dir.join(skill_name).join("SKILL.md");
3150    ensure_parent_dir(&skill_path)?;
3151
3152    let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
3153    Ok((skill_path, status))
3154}
3155
3156fn tools_readme_template() -> &'static str {
3157    "# Local tools\n\n\
3158     Drop self-describing scripts here so they can be discovered by\n\
3159     `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
3160     When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
3161     `~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
3162     registered as model-visible tools.\n\n\
3163     Each script should start with a frontmatter-style header so the\n\
3164     description is visible without executing the file and the agent knows\n\
3165     the tool name, description, and input schema:\n\n\
3166     ```\n\
3167     # name: my-tool\n\
3168     # description: One-line summary of what this tool does\n\
3169     # usage: my-tool [args...]\n\
3170     ```\n\n\
3171     The directory is intentionally not auto-loaded into the agent's tool\n\
3172     catalog. Wire individual tools through MCP, hooks, or skills when you\n\
3173     want them available inside a session.\n"
3174}
3175
3176fn tools_example_script() -> &'static str {
3177    "#!/usr/bin/env sh\n\
3178     # name: example\n\
3179     # description: Print a confirmation that local tool discovery works\n\
3180     # usage: example [name]\n\
3181     printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n"
3182}
3183
3184fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
3185    std::fs::create_dir_all(tools_dir)
3186        .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
3187
3188    let readme_path = tools_dir.join("README.md");
3189    let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
3190
3191    let example_path = tools_dir.join("example.sh");
3192    let example_status = write_template_file(&example_path, tools_example_script(), force)?;
3193
3194    Ok((tools_dir.to_path_buf(), readme_status, example_status))
3195}
3196
3197fn plugins_readme_template() -> &'static str {
3198    "# Local plugins\n\n\
3199     Each Codewhale plugin bundle lives in its own subdirectory with a\n\
3200     versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
3201     under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
3202     untrusted, and disabled by default.\n\n\
3203     A v0.9.1 bundle layout looks like:\n\n\
3204     ```\n\
3205     plugins/\n\
3206       my-plugin/\n\
3207         plugin.toml\n\
3208         skills/\n\
3209           my-skill/SKILL.md\n\
3210     ```\n\n\
3211     Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
3212     Enablement opens a content- and capability-bound trust review;\n\
3213     confirm the displayed `/plugin trust` command to create an owner-only,\n\
3214     content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
3215     authentication must name environment sources; never store secret values\n\
3216     in `plugin.toml`.\n\n\
3217     v0.9.1 activates only declarative Skills and MCP servers through their\n\
3218     existing engines. Commands, agents, hooks, LSP, native extensions,\n\
3219     filesystem grants, and lifecycle mutation are inventoried but inactive.\n\
3220     There is no marketplace, install, update, ambient compatibility scan, or\n\
3221     automatic trust surface in this release.\n"
3222}
3223
3224fn plugin_example_manifest_template() -> &'static str {
3225    "schema_version = 1\n\n\
3226     [plugin]\n\
3227     name = \"example\"\n\
3228     version = \"0.1.0\"\n\
3229     description = \"Starter Codewhale plugin bundle\"\n\n\
3230     [skills]\n\
3231     path = \"skills\"\n"
3232}
3233
3234fn plugin_example_skill_template() -> &'static str {
3235    "---\n\
3236     name: hello\n\
3237     description: Explain that the example plugin bundle is active.\n\
3238     ---\n\n\
3239     Tell the user this instruction came from the namespaced\n\
3240     `example:hello` plugin skill. Do not perform side effects.\n"
3241}
3242
3243fn init_plugins_dir(
3244    plugins_dir: &Path,
3245    force: bool,
3246) -> Result<(
3247    PathBuf,
3248    PathBuf,
3249    PathBuf,
3250    WriteStatus,
3251    WriteStatus,
3252    WriteStatus,
3253)> {
3254    std::fs::create_dir_all(plugins_dir)
3255        .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
3256
3257    let readme_path = plugins_dir.join("README.md");
3258    let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
3259
3260    let manifest_path = plugins_dir.join("example").join("plugin.toml");
3261    ensure_parent_dir(&manifest_path)?;
3262    let manifest_status =
3263        write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
3264
3265    let skill_path = plugins_dir
3266        .join("example")
3267        .join("skills")
3268        .join("hello")
3269        .join("SKILL.md");
3270    ensure_parent_dir(&skill_path)?;
3271    let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
3272
3273    Ok((
3274        readme_path,
3275        manifest_path,
3276        skill_path,
3277        readme_status,
3278        manifest_status,
3279        skill_status,
3280    ))
3281}
3282
3283/// Resolve the user-supplied CORS origins for `codewhale serve --http`.
3284///
3285/// Sources, in priority order (later sources extend earlier ones):
3286/// 1. `--cors-origin URL` flags (repeatable)
3287/// 2. `CODEWHALE_CORS_ORIGINS` env var (comma-separated),
3288///    then `DEEPSEEK_CORS_ORIGINS` as an alias
3289/// 3. `[runtime_api] cors_origins = [...]` in `config.toml`
3290///
3291/// The runtime API always allows the built-in dev defaults
3292/// (localhost:3000, localhost:1420, tauri://localhost). User entries are
3293/// appended on top — empty strings are skipped, and duplicates are deduped
3294/// while preserving first-seen order. Whalescale#255 / #561.
3295fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
3296    let mut out: Vec<String> = Vec::new();
3297    let mut push = |raw: &str| {
3298        let trimmed = raw.trim();
3299        if trimmed.is_empty() {
3300            return;
3301        }
3302        if !out.iter().any(|existing| existing == trimmed) {
3303            out.push(trimmed.to_string());
3304        }
3305    };
3306    for o in flag_origins {
3307        push(o);
3308    }
3309    if let Ok(env_value) =
3310        std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
3311    {
3312        for piece in env_value.split(',') {
3313            push(piece);
3314        }
3315    }
3316    if let Some(rt) = &config.runtime_api
3317        && let Some(list) = &rt.cors_origins
3318    {
3319        for o in list {
3320            push(o);
3321        }
3322    }
3323    out
3324}
3325
3326fn deepseek_home_dir() -> PathBuf {
3327    codewhale_config::codewhale_home().unwrap_or_else(|_| {
3328        crate::config::effective_home_dir()
3329            .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
3330    })
3331}
3332
3333/// Resolve the default tools directory. Mirrors `default_skills_dir` shape.
3334fn default_tools_dir() -> PathBuf {
3335    deepseek_home_dir().join("tools")
3336}
3337
3338/// Resolve the default plugins directory.
3339fn default_plugins_dir() -> PathBuf {
3340    deepseek_home_dir().join("plugins")
3341}
3342
3343/// Default location for crash/offline-queue checkpoints managed by the TUI.
3344fn default_checkpoints_dir() -> PathBuf {
3345    deepseek_home_dir().join("sessions").join("checkpoints")
3346}
3347
3348#[derive(Debug, Clone, PartialEq, Eq)]
3349struct CleanPlan {
3350    targets: Vec<PathBuf>,
3351}
3352
3353fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
3354    // Every `*.json` file in the checkpoints directory is checkpoint state:
3355    // per-session crash checkpoints (`<session_id>.json`), the legacy
3356    // single-slot checkpoint (`latest.json`), and the offline input queue
3357    // (`offline_queue.json`). Non-JSON files and subdirectories are left
3358    // alone.
3359    let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
3360        .map(|entries| {
3361            entries
3362                .filter_map(|entry| entry.ok().map(|e| e.path()))
3363                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
3364                .collect()
3365        })
3366        .unwrap_or_default();
3367    targets.sort();
3368    CleanPlan { targets }
3369}
3370
3371fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
3372    let mut removed = Vec::with_capacity(plan.targets.len());
3373    for path in &plan.targets {
3374        std::fs::remove_file(path)
3375            .with_context(|| format!("Failed to remove {}", path.display()))?;
3376        removed.push(path.clone());
3377    }
3378    Ok(removed)
3379}
3380
3381fn run_setup(
3382    config: &Config,
3383    workspace: &Path,
3384    args: SetupArgs,
3385    plugins: &crate::plugins::PluginRegistry,
3386) -> Result<()> {
3387    if args.status {
3388        return run_setup_status(config, workspace, plugins);
3389    }
3390    if args.clean {
3391        return run_setup_clean(&default_checkpoints_dir(), args.force);
3392    }
3393
3394    use crate::palette;
3395    use colored::Colorize;
3396
3397    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3398    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3399
3400    let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
3401    let run_mcp = args.mcp || args.all || !any_explicit;
3402    let run_skills = args.skills || args.all || !any_explicit;
3403    let run_tools = args.tools || args.all;
3404    let run_plugins = args.plugins || args.all;
3405
3406    println!(
3407        "{}",
3408        "Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
3409    );
3410    println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
3411    println!("Workspace: {}", crate::utils::display_path(workspace));
3412
3413    if run_mcp {
3414        let mcp_path = config.mcp_config_path();
3415        let status = init_mcp_config(&mcp_path, args.force)?;
3416        match status {
3417            WriteStatus::Created => {
3418                println!("  ✓ Created MCP config at {}", mcp_path.display());
3419            }
3420            WriteStatus::Overwritten => {
3421                println!("  ✓ Overwrote MCP config at {}", mcp_path.display());
3422            }
3423            WriteStatus::SkippedExists => {
3424                println!("  · MCP config already exists at {}", mcp_path.display());
3425            }
3426        }
3427        println!(
3428            "    Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
3429        );
3430    }
3431
3432    if run_skills {
3433        let skills_dir = if args.local {
3434            workspace.join("skills")
3435        } else {
3436            config.skills_dir()
3437        };
3438        let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
3439        match status {
3440            WriteStatus::Created => {
3441                println!("  ✓ Created example skill at {}", skill_path.display());
3442            }
3443            WriteStatus::Overwritten => {
3444                println!("  ✓ Overwrote example skill at {}", skill_path.display());
3445            }
3446            WriteStatus::SkippedExists => {
3447                println!(
3448                    "  · Example skill already exists at {}",
3449                    skill_path.display()
3450                );
3451            }
3452        }
3453        if args.local {
3454            println!(
3455                "    Local skills dir enabled for this workspace: {}",
3456                crate::utils::display_path(&skills_dir)
3457            );
3458        } else {
3459            println!(
3460                "    Skills dir: {}",
3461                crate::utils::display_path(&skills_dir)
3462            );
3463        }
3464        println!("    Next: run the TUI and use `/skills` then `/skill getting-started`.");
3465    }
3466
3467    if run_tools {
3468        let tools_dir = default_tools_dir();
3469        let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
3470        report_write_status("Tools README", &dir.join("README.md"), readme_status);
3471        report_write_status("Example tool", &dir.join("example.sh"), example_status);
3472        println!("    Tools dir: {}", crate::utils::display_path(&dir));
3473        println!("    Next: drop scripts here; surface them via skills/MCP when ready.");
3474    }
3475
3476    if run_plugins {
3477        let plugins_dir = default_plugins_dir();
3478        let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
3479            init_plugins_dir(&plugins_dir, args.force)?;
3480        report_write_status("Plugins README", &readme_path, readme_status);
3481        report_write_status("Example plugin manifest", &manifest_path, manifest_status);
3482        report_write_status("Example plugin skill", &skill_path, skill_status);
3483        println!(
3484            "    Plugins dir: {}",
3485            crate::utils::display_path(&plugins_dir)
3486        );
3487        println!("    Next: run `/plugin validate`, review `example`, then trust and enable it.");
3488    }
3489
3490    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3491        config.prefer_bwrap.unwrap_or(false),
3492    );
3493    if let Some(kind) = sandbox {
3494        println!("  ✓ Sandbox available: {kind}");
3495    } else {
3496        println!("  · Sandbox not available on this platform (best-effort only).");
3497    }
3498
3499    Ok(())
3500}
3501
3502fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
3503    match status {
3504        WriteStatus::Created => {
3505            println!("  ✓ Created {label} at {}", path.display());
3506        }
3507        WriteStatus::Overwritten => {
3508            println!("  ✓ Overwrote {label} at {}", path.display());
3509        }
3510        WriteStatus::SkippedExists => {
3511            println!("  · {label} already exists at {}", path.display());
3512        }
3513    }
3514}
3515
3516/// Source of the resolved API key, used only by static doctor/setup reports.
3517///
3518/// These reports must not migrate a legacy secret store or acquire a
3519/// write-capable credential handle just to label a source.
3520#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3521enum ApiKeySource {
3522    ConfigDeclared,
3523    EnvDeclared,
3524    ExternalAuthDeclared,
3525    SecretStoreUnprobed,
3526    SecretStoreUnavailable,
3527    OAuth,
3528    ExternalConsent,
3529    NoAuth,
3530    LocalRuntime,
3531    Unknown,
3532}
3533
3534/// What structural diagnostics can truthfully say about credential
3535/// availability without consulting environment values or durable stores.
3536#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3537enum CredentialAvailability {
3538    Present,
3539    NotRequired,
3540    Unknown,
3541    NotProbed,
3542    Unavailable,
3543}
3544
3545impl CredentialAvailability {
3546    fn label(self) -> &'static str {
3547        match self {
3548            Self::Present => "present",
3549            Self::NotRequired => "not_required",
3550            Self::Unknown => "unknown",
3551            Self::NotProbed => "not_probed",
3552            Self::Unavailable => "unavailable",
3553        }
3554    }
3555
3556    fn certifies_ready(self) -> bool {
3557        matches!(self, Self::Present | Self::NotRequired)
3558    }
3559}
3560
3561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3562struct CredentialDiagnostic {
3563    source: ApiKeySource,
3564    availability: CredentialAvailability,
3565}
3566
3567impl CredentialDiagnostic {
3568    const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
3569        Self {
3570            source,
3571            availability,
3572        }
3573    }
3574}
3575
3576fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
3577    let provider = config.api_provider();
3578    let auth_mode = config.auth_mode_for_provider(provider);
3579    if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
3580        return CredentialDiagnostic::new(
3581            ApiKeySource::NoAuth,
3582            CredentialAvailability::NotRequired,
3583        );
3584    }
3585    if !crate::config::auth_mode_requires_api_key(auth_mode.as_deref())
3586        && (provider.is_self_hosted()
3587            || crate::config::base_url_uses_local_host(&config.deepseek_base_url()))
3588    {
3589        return CredentialDiagnostic::new(
3590            ApiKeySource::LocalRuntime,
3591            CredentialAvailability::NotRequired,
3592        );
3593    }
3594    let custom_endpoint = config.provider_uses_custom_endpoint(provider);
3595    if !custom_endpoint && provider == crate::config::ApiProvider::OpenaiCodex {
3596        return config
3597            .external_credential_consent_status(provider)
3598            .filter(|status| status.route_state == "active")
3599            .map_or_else(
3600                || {
3601                    CredentialDiagnostic::new(
3602                        ApiKeySource::OAuth,
3603                        CredentialAvailability::NotProbed,
3604                    )
3605                },
3606                |_| {
3607                    CredentialDiagnostic::new(
3608                        ApiKeySource::ExternalConsent,
3609                        CredentialAvailability::NotProbed,
3610                    )
3611                },
3612            );
3613    }
3614    if !custom_endpoint
3615        && provider == crate::config::ApiProvider::Xai
3616        && auth_mode
3617            .as_deref()
3618            .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3619    {
3620        return config
3621            .external_credential_consent_status(provider)
3622            .filter(|status| status.route_state == "active")
3623            .map_or_else(
3624                || {
3625                    CredentialDiagnostic::new(
3626                        ApiKeySource::OAuth,
3627                        CredentialAvailability::NotProbed,
3628                    )
3629                },
3630                |_| {
3631                    CredentialDiagnostic::new(
3632                        ApiKeySource::ExternalConsent,
3633                        CredentialAvailability::NotProbed,
3634                    )
3635                },
3636            );
3637    }
3638    let provider_config = config.provider_config();
3639    let provider_config_key_kind = provider_config
3640        .and_then(|entry| entry.api_key.as_deref())
3641        .map(crate::config::classify_config_api_key_value);
3642    let root_key_applies = matches!(
3643        provider,
3644        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
3645    ) || (provider == crate::config::ApiProvider::Custom
3646        && config.uses_legacy_literal_custom_route());
3647    let root_key_kind = root_key_applies
3648        .then_some(config.api_key.as_deref())
3649        .flatten()
3650        .map(crate::config::classify_config_api_key_value);
3651
3652    if matches!(
3653        provider_config_key_kind,
3654        Some(crate::config::ConfigApiKeyValueKind::Literal)
3655    ) || matches!(
3656        root_key_kind,
3657        Some(crate::config::ConfigApiKeyValueKind::Literal)
3658    ) {
3659        CredentialDiagnostic::new(
3660            ApiKeySource::ConfigDeclared,
3661            CredentialAvailability::Present,
3662        )
3663    } else if config
3664        .provider_config()
3665        .and_then(|entry| entry.api_key_env.as_deref())
3666        .is_some_and(|name| !name.trim().is_empty())
3667    {
3668        CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
3669    } else if config
3670        .provider_config()
3671        .and_then(|entry| entry.auth.as_ref())
3672        .is_some()
3673    {
3674        CredentialDiagnostic::new(
3675            ApiKeySource::ExternalAuthDeclared,
3676            CredentialAvailability::NotProbed,
3677        )
3678    } else if matches!(
3679        provider_config_key_kind,
3680        Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3681    ) || matches!(
3682        root_key_kind,
3683        Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3684    ) {
3685        if config.should_skip_secret_store_for_provider(provider) {
3686            return CredentialDiagnostic::new(
3687                ApiKeySource::SecretStoreUnavailable,
3688                CredentialAvailability::Unavailable,
3689            );
3690        }
3691        // The sentinel is a declaration that runtime resolution should use
3692        // the secret-store layer, never a literal key. Doctor does not read it.
3693        CredentialDiagnostic::new(
3694            ApiKeySource::SecretStoreUnprobed,
3695            CredentialAvailability::NotProbed,
3696        )
3697    } else if !config.should_skip_secret_store_for_provider(provider) {
3698        // No literal config declaration was found, but this route can continue
3699        // through the durable store and ambient provider environment. Ordinary
3700        // doctor deliberately does not inspect either source.
3701        CredentialDiagnostic::new(
3702            ApiKeySource::SecretStoreUnprobed,
3703            CredentialAvailability::NotProbed,
3704        )
3705    } else {
3706        CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
3707    }
3708}
3709
3710#[cfg(test)]
3711fn resolve_api_key_source(config: &Config) -> ApiKeySource {
3712    resolve_credential_diagnostic(config).source
3713}
3714
3715fn provider_config_table_key(provider: crate::config::ApiProvider) -> &'static str {
3716    provider
3717        .metadata()
3718        .map(|metadata| metadata.provider_config_key())
3719        .unwrap_or("deepseek_cn")
3720}
3721
3722fn count_dir_entries(dir: &Path) -> usize {
3723    std::fs::read_dir(dir)
3724        .map(|entries| entries.filter_map(std::result::Result::ok).count())
3725        .unwrap_or(0)
3726}
3727
3728fn skills_count_for(dir: &Path) -> usize {
3729    if !dir.exists() {
3730        return 0;
3731    }
3732    crate::skills::SkillRegistry::discover(dir).len()
3733}
3734
3735fn run_setup_status(
3736    config: &Config,
3737    workspace: &Path,
3738    plugins: &crate::plugins::PluginRegistry,
3739) -> Result<()> {
3740    use crate::palette;
3741    use colored::Colorize;
3742
3743    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3744    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3745
3746    println!(
3747        "{}",
3748        "Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
3749    );
3750    println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
3751    println!("workspace: {}", workspace.display());
3752
3753    let credential = resolve_credential_diagnostic(config);
3754    match credential.source {
3755        ApiKeySource::ConfigDeclared => println!(
3756            "  {} api_key: literal config value structurally present",
3757            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3758        ),
3759        ApiKeySource::EnvDeclared => println!(
3760            "  {} api_key: environment source declared (value not inspected)",
3761            "·".dimmed()
3762        ),
3763        ApiKeySource::ExternalAuthDeclared => println!(
3764            "  {} api_key: external auth source declared (value not inspected)",
3765            "·".dimmed()
3766        ),
3767        ApiKeySource::SecretStoreUnprobed => println!(
3768            "  {} api_key: secret store eligible (store not probed)",
3769            "·".dimmed()
3770        ),
3771        ApiKeySource::SecretStoreUnavailable => println!(
3772            "  {} api_key: secret-store sentinel declared, but this route cannot use that store",
3773            "!".truecolor(sky_r, sky_g, sky_b)
3774        ),
3775        ApiKeySource::OAuth => println!(
3776            "  {} oauth: Codewhale-owned route selected (token availability not probed)",
3777            "·".dimmed()
3778        ),
3779        ApiKeySource::ExternalConsent => println!(
3780            "  {} oauth: external read-only consent configured (credential file not probed)",
3781            "·".dimmed()
3782        ),
3783        ApiKeySource::NoAuth => println!(
3784            "  {} api_key: disabled for this route",
3785            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3786        ),
3787        ApiKeySource::LocalRuntime => println!(
3788            "  {} api_key: not required for this local runtime",
3789            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3790        ),
3791        ApiKeySource::Unknown => println!(
3792            "  {} api_key: unknown (credential environment and durable stores not inspected)",
3793            "·".dimmed()
3794        ),
3795    }
3796    println!(
3797        "  · credential availability: {}",
3798        credential.availability.label()
3799    );
3800    println!(
3801        "  · base_url: {}",
3802        crate::doctor::structural_url_authority(&config.deepseek_base_url())
3803    );
3804    let model = config
3805        .default_text_model
3806        .clone()
3807        .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
3808    println!("  · default_text_model: {model}");
3809    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
3810    println!("  · default_mode: {default_mode} ({default_mode_source})");
3811
3812    let mcp_path = config.mcp_config_path();
3813    let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
3814    let mcp_count =
3815        match crate::mcp::load_config_with_workspace_and_plugins(&mcp_path, workspace, plugins) {
3816            Ok(cfg) => cfg.servers.len(),
3817            Err(_) => 0,
3818        };
3819    let mcp_present = if mcp_path.exists() { "" } else { "  (missing)" };
3820    let project_mcp_present = if project_mcp_path.exists() {
3821        ""
3822    } else {
3823        "  (missing)"
3824    };
3825    println!(
3826        "  · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
3827        mcp_path.display(),
3828        project_mcp_path.display()
3829    );
3830
3831    let skills_dir = config.skills_dir();
3832    println!(
3833        "  · skills: {} at {}",
3834        skills_count_for(&skills_dir),
3835        crate::utils::display_path(&skills_dir)
3836    );
3837
3838    let tools_dir = default_tools_dir();
3839    let tools_present = if tools_dir.exists() {
3840        ""
3841    } else {
3842        "  (missing — run `setup --tools`)"
3843    };
3844    println!(
3845        "  · tools: {} entries at {}{tools_present}",
3846        if tools_dir.exists() {
3847            count_dir_entries(&tools_dir)
3848        } else {
3849            0
3850        },
3851        crate::utils::display_path(&tools_dir)
3852    );
3853
3854    let plugins_dir = default_plugins_dir();
3855    let plugins_present = if plugins_dir.exists() {
3856        ""
3857    } else {
3858        "  (missing — run `setup --plugins`)"
3859    };
3860    println!(
3861        "  · plugins: {} entries at {}{plugins_present}",
3862        if plugins_dir.exists() {
3863            count_dir_entries(&plugins_dir)
3864        } else {
3865            0
3866        },
3867        crate::utils::display_path(&plugins_dir)
3868    );
3869
3870    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3871        config.prefer_bwrap.unwrap_or(false),
3872    );
3873    match sandbox {
3874        Some(kind) => println!(
3875            "  {} sandbox: {kind}",
3876            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3877        ),
3878        None => println!(
3879            "  {} sandbox: unavailable (commands run best-effort)",
3880            "!".truecolor(sky_r, sky_g, sky_b)
3881        ),
3882    }
3883
3884    println!("  {} {}", "·".dimmed(), dotenv_status_line(workspace));
3885
3886    println!();
3887    println!("Run `codewhale doctor --json` for a machine-readable check.");
3888    Ok(())
3889}
3890
3891fn dotenv_status_line(workspace: &Path) -> String {
3892    let dotenv = workspace.join(".env");
3893    if dotenv.exists() {
3894        return format!(
3895            ".env present at {} (literal provider credentials only)",
3896            dotenv.display()
3897        );
3898    }
3899
3900    if workspace.join(".env.example").exists() {
3901        return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
3902    }
3903
3904    ".env not present in workspace".to_string()
3905}
3906
3907fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
3908    use colored::Colorize;
3909
3910    if !checkpoints_dir.exists() {
3911        println!(
3912            "Nothing to clean — checkpoints dir does not exist: {}",
3913            checkpoints_dir.display()
3914        );
3915        return Ok(());
3916    }
3917
3918    let plan = collect_clean_targets(checkpoints_dir);
3919    if plan.targets.is_empty() {
3920        println!(
3921            "Nothing to clean — no checkpoint files in {}",
3922            checkpoints_dir.display()
3923        );
3924        return Ok(());
3925    }
3926
3927    if !force {
3928        println!(
3929            "Would remove {} checkpoint file(s) (use --force to apply):",
3930            plan.targets.len()
3931        );
3932        for path in &plan.targets {
3933            println!("  · {}", path.display());
3934        }
3935        return Ok(());
3936    }
3937
3938    let removed = execute_clean_plan(&plan)?;
3939    println!("{}", "Cleaned checkpoints:".bold());
3940    for path in &removed {
3941        println!("  ✓ {}", path.display());
3942    }
3943    Ok(())
3944}
3945
3946fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
3947    let contents = std::fs::read_to_string(&args.path).with_context(|| {
3948        format!(
3949            "read session diagnostic JSONL from {}",
3950            crate::utils::display_path(&args.path)
3951        )
3952    })?;
3953    let summary = crate::session_diagnostics::analyze_session_failure_jsonl(&contents);
3954    if args.json {
3955        println!("{}", serde_json::to_string_pretty(&summary)?);
3956    } else {
3957        println!(
3958            "{}",
3959            crate::session_diagnostics::format_redacted_failure_summary(&summary)
3960        );
3961    }
3962    Ok(())
3963}
3964
3965/// Live API checks are explicit. Local endpoints have a separate opt-in because
3966/// an HTTP request can wake a desktop-managed daemon (notably Ollama.app).
3967fn doctor_should_probe_api(
3968    provider: crate::config::ApiProvider,
3969    base_url: &str,
3970    probes: crate::doctor::DoctorProbeRequest,
3971) -> bool {
3972    let local = provider.is_self_hosted() || crate::config::base_url_uses_local_host(base_url);
3973    probes.should_probe_api(local)
3974}
3975
3976/// Doctor must never turn credential inspection into a refresh/write path.
3977/// OAuth connectivity is exercised by an ordinary user request instead;
3978/// doctor limits itself to non-mutating readiness inspection.
3979fn doctor_should_probe_auth(config: &Config) -> bool {
3980    let provider = config.api_provider();
3981    if provider == crate::config::ApiProvider::OpenaiCodex
3982        && !config.provider_uses_custom_endpoint(provider)
3983    {
3984        return false;
3985    }
3986    let auth_mode = config.auth_mode_for_provider(provider);
3987    if provider == crate::config::ApiProvider::Xai
3988        && auth_mode
3989            .as_deref()
3990            .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3991    {
3992        return false;
3993    }
3994    !(provider == crate::config::ApiProvider::Moonshot
3995        && auth_mode
3996            .as_deref()
3997            .is_some_and(crate::config::auth_mode_uses_kimi_imported_token))
3998}
3999
4000/// Run system diagnostics
4001async fn run_doctor(
4002    config: &Config,
4003    workspace: &Path,
4004    config_path_override: Option<&Path>,
4005    probes: crate::doctor::DoctorProbeRequest,
4006    plugins: &crate::plugins::PluginRegistry,
4007) {
4008    use crate::palette;
4009    use colored::Colorize;
4010
4011    let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
4012    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
4013    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
4014    let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
4015
4016    println!(
4017        "{}",
4018        "codewhale Doctor"
4019            .truecolor(accent_r, accent_g, accent_b)
4020            .bold()
4021    );
4022    println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
4023    println!();
4024
4025    // Version info
4026    println!("{}", "Version Information:".bold());
4027    println!("  codewhale-tui: {}", env!("DEEPSEEK_BUILD_VERSION"));
4028    println!("  rust: {}", rustc_version());
4029    println!();
4030
4031    println!("{}", "Updates:".bold());
4032    crate::doctor::print_update_report(probes).await;
4033    println!();
4034
4035    // Configuration summary
4036    let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
4037        Ok(paths) => paths,
4038        Err(error) => {
4039            println!("{}", "Resolved User Paths:".bold());
4040            println!(
4041                "  {} unavailable: {error:#}",
4042                "✗".truecolor(red_r, red_g, red_b)
4043            );
4044            return;
4045        }
4046    };
4047    println!("{}", "Configuration:".bold());
4048    let config_path = &doctor_paths.config;
4049
4050    if config_path.exists() {
4051        println!(
4052            "  {} config.toml found at {}",
4053            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4054            crate::utils::display_path(config_path)
4055        );
4056        // Secret hygiene: name the keys, never the values. Plain-text config
4057        // is not a secret store.
4058        if let Ok(raw) = std::fs::read_to_string(config_path) {
4059            let flagged = crate::doctor::config_credential_shaped_keys(&raw);
4060            if !flagged.is_empty() {
4061                println!(
4062                    "  {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
4063                    "!".truecolor(sky_r, sky_g, sky_b),
4064                    flagged.join(", ")
4065                );
4066            }
4067        }
4068    } else {
4069        println!(
4070            "  {} config.toml not found at {} (using defaults/env)",
4071            "!".truecolor(sky_r, sky_g, sky_b),
4072            crate::utils::display_path(config_path)
4073        );
4074    }
4075    println!("  workspace: {}", crate::utils::display_path(workspace));
4076    println!("  {}", doctor_search_provider_line(config));
4077
4078    println!();
4079    println!("{}", "Resolved User Paths (read-only):".bold());
4080    for (label, path) in doctor_paths.entries() {
4081        println!("  · {label}: {}", crate::utils::display_path(path));
4082    }
4083
4084    let secret_backend = codewhale_secrets::diagnose_secret_backend();
4085    println!();
4086    println!("{}", "Secret Backend (structural only):".bold());
4087    for line in crate::doctor::secret_backend_human_lines(&secret_backend) {
4088        println!("  · {line}");
4089    }
4090
4091    // State root (v0.8.44)
4092    println!();
4093    println!("{}", "State Root:".bold());
4094    let (code_home, legacy_home) = doctor_state_roots();
4095    let active_root = if code_home.exists() {
4096        &code_home
4097    } else if legacy_home.exists() {
4098        &legacy_home
4099    } else {
4100        &code_home
4101    };
4102    println!("  active: {}", crate::utils::display_path(active_root));
4103    if active_root != &code_home {
4104        println!(
4105            "  note: legacy {} found; start Codewhale once to trigger safe migration where available.",
4106            crate::utils::display_path(&legacy_home)
4107        );
4108    }
4109    if legacy_home.exists() && code_home.exists() {
4110        println!(
4111            "  dual roots: {} (primary) + {} (legacy)",
4112            crate::utils::display_path(&code_home),
4113            crate::utils::display_path(&legacy_home)
4114        );
4115    }
4116    let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
4117    let session_recovery = doctor_session_recovery_report(
4118        &code_home,
4119        &legacy_home,
4120        codewhale_config::codewhale_home_is_explicit(),
4121    );
4122    print_doctor_legacy_state_report(
4123        &legacy_state_report,
4124        &session_recovery,
4125        (aqua_r, aqua_g, aqua_b),
4126        (sky_r, sky_g, sky_b),
4127    );
4128
4129    let (setup_state, setup_source) = doctor_setup_state(config, workspace);
4130    print_doctor_setup_report(
4131        config,
4132        workspace,
4133        &setup_state,
4134        setup_source,
4135        (aqua_r, aqua_g, aqua_b),
4136        (sky_r, sky_g, sky_b),
4137    );
4138
4139    // Check API keys
4140    println!();
4141    println!("{}", "API Keys:".bold());
4142
4143    // Per-provider state: env + config file only (no values printed).
4144    // Keep doctor/status prompt-free and credential-value-free even for
4145    // unsigned rebuilt binaries.
4146    for provider in crate::config::ApiProvider::all().iter().copied() {
4147        let slot = provider.as_str();
4148        let provider_config = config.provider_config_for(provider);
4149        let config_declared = provider_config.is_some_and(|entry| {
4150            entry.api_key.as_deref().is_some_and(|key| {
4151                crate::config::classify_config_api_key_value(key)
4152                    == crate::config::ConfigApiKeyValueKind::Literal
4153            })
4154        }) || (matches!(provider, crate::config::ApiProvider::Deepseek)
4155            && config.api_key.as_deref().is_some_and(|key| {
4156                crate::config::classify_config_api_key_value(key)
4157                    == crate::config::ConfigApiKeyValueKind::Literal
4158            }));
4159        let env_source_declared = provider_config
4160            .and_then(|entry| entry.api_key_env.as_deref())
4161            .is_some_and(|name| !name.trim().is_empty());
4162        let icon = if config_declared || env_source_declared {
4163            "·".truecolor(aqua_r, aqua_g, aqua_b)
4164        } else {
4165            "·".dimmed()
4166        };
4167        println!(
4168            "  {} {slot}: env_source={}, config_source={}",
4169            icon,
4170            if env_source_declared {
4171                "declared (value not inspected)"
4172            } else {
4173                "not inspected"
4174            },
4175            if config_declared {
4176                "declared (value not inspected)"
4177            } else {
4178                "not declared"
4179            }
4180        );
4181    }
4182    println!("  · credential precedence is unchanged; doctor does not inspect credential values");
4183    println!();
4184    println!(
4185        "{}",
4186        "External credential consent (configuration only):".bold()
4187    );
4188    for line in doctor_external_credential_consent_lines(config) {
4189        println!("  {line}");
4190    }
4191
4192    let credential = resolve_credential_diagnostic(config);
4193    let source_label = match credential.source {
4194        ApiKeySource::ConfigDeclared => "literal config value structurally present",
4195        ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
4196        ApiKeySource::ExternalAuthDeclared => {
4197            "external auth source declared; credential not resolved"
4198        }
4199        ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
4200        ApiKeySource::SecretStoreUnavailable => {
4201            "secret-store sentinel declared, but this route cannot use that store"
4202        }
4203        ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
4204        ApiKeySource::ExternalConsent => "external consent configured; token file not read",
4205        ApiKeySource::NoAuth => "no-auth route",
4206        ApiKeySource::LocalRuntime => "local runtime; credentials not required",
4207        ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
4208    };
4209    println!(
4210        "  {} active provider credential source: {source_label}",
4211        "·".dimmed()
4212    );
4213    println!(
4214        "  · active provider credential availability: {}",
4215        credential.availability.label()
4216    );
4217
4218    // API connectivity test
4219    println!();
4220    println!("{}", "API Connectivity:".bold());
4221    let api_target = doctor_api_target(config);
4222    // Configured-vs-active honesty (DGF-01): doctor describes the route a
4223    // session launched NOW would resolve. It cannot see inside an already
4224    // running session, which keeps the route it resolved at its own launch.
4225    println!(
4226        "  · scope: configured route — what a session launched now would use; a running session keeps the route it resolved at launch (its TUI header shows the live route)"
4227    );
4228    println!("  · provider: {}", api_target.provider);
4229    println!(
4230        "  · base_url: {}",
4231        crate::doctor::structural_url_authority(&api_target.base_url)
4232    );
4233    match api_target.resolution {
4234        DoctorModelResolution::Resolved => {
4235            println!("  · model: {} (resolved)", api_target.model);
4236        }
4237        DoctorModelResolution::ConfiguredOnly => {
4238            println!(
4239                "  · model: {} (configured; route resolution unavailable)",
4240                api_target.model
4241            );
4242        }
4243    }
4244    let tls_status = doctor_tls_status(config);
4245    if !tls_status.certificate_verification {
4246        println!("  ! {}", tls_status.message);
4247        println!("    Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
4248    }
4249    let strict_tool_mode = doctor_strict_tool_mode_status(config);
4250    let strict_icon = match strict_tool_mode.status {
4251        "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
4252        "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
4253        _ => "·".dimmed(),
4254    };
4255    println!(
4256        "  {} strict_tool_mode: {}",
4257        strict_icon, strict_tool_mode.message
4258    );
4259    if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
4260        println!(
4261            "    Use the {} endpoint for DeepSeek strict schemas.",
4262            crate::doctor::structural_url_authority(recommended)
4263        );
4264    }
4265    let capability = crate::config::provider_capability(config.api_provider(), &api_target.model);
4266    if let Some(alias) = capability.alias_deprecation.as_ref() {
4267        println!(
4268            "  ! model alias {} retires {}; switch to {}",
4269            alias.alias, alias.retirement_date, alias.replacement
4270        );
4271    }
4272    let live_api_requested =
4273        doctor_should_probe_api(config.api_provider(), &api_target.base_url, probes);
4274    let endpoint_is_local = config.api_provider().is_self_hosted()
4275        || crate::config::base_url_uses_local_host(&api_target.base_url);
4276    if doctor_should_probe_auth(config) && live_api_requested {
4277        print!("  {} Testing connection...", "·".dimmed());
4278        use std::io::Write;
4279        std::io::stdout().flush().ok();
4280
4281        // Resolve a credential through the diagnostic-only store first, then
4282        // probe with an in-memory clone. Constructing the normal client from
4283        // the original config could otherwise trigger its legacy secret-store
4284        // migration while a user merely asks doctor to test connectivity.
4285        let connectivity_result = match config.with_read_only_api_key_for_diagnostic() {
4286            Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
4287            Err(error) => Err(error),
4288        };
4289        match connectivity_result {
4290            Ok(()) => {
4291                println!(
4292                    "\r  {} API connection successful",
4293                    "✓".truecolor(aqua_r, aqua_g, aqua_b)
4294                );
4295            }
4296            Err(e) => {
4297                let error_msg = e.to_string();
4298                println!(
4299                    "\r  {} API connection failed",
4300                    "✗".truecolor(red_r, red_g, red_b)
4301                );
4302                if error_msg.contains("401") || error_msg.contains("Unauthorized") {
4303                    println!(
4304                        "    Invalid API key. Check `codewhale auth status`, DEEPSEEK_API_KEY, or config.toml"
4305                    );
4306                } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
4307                    println!(
4308                        "    API key lacks permissions. Verify key is active at platform.deepseek.com"
4309                    );
4310                } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
4311                    for line in doctor_timeout_recovery_lines(config) {
4312                        println!("    {line}");
4313                    }
4314                } else if error_msg.contains("dns") || error_msg.contains("resolve") {
4315                    println!("    DNS resolution failed. Check your network connection");
4316                } else if error_msg.contains("connect") {
4317                    println!("    Connection failed. Check firewall settings or try again");
4318                } else {
4319                    println!(
4320                        "    Error details omitted because provider failures can contain credential material."
4321                    );
4322                }
4323            }
4324        }
4325    } else if !doctor_should_probe_auth(config) {
4326        println!(
4327            "  {} Live OAuth connectivity not checked by non-mutating doctor",
4328            "·".dimmed()
4329        );
4330        println!(
4331            "    Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
4332        );
4333    } else {
4334        if endpoint_is_local {
4335            println!(
4336                "  {} Live connectivity not checked for this local endpoint",
4337                "·".dimmed()
4338            );
4339            println!(
4340                "    Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
4341            );
4342        } else {
4343            println!(
4344                "  {} Live hosted connectivity not checked (offline default)",
4345                "·".dimmed()
4346            );
4347            println!("    Run `codewhale doctor --probe-api` to opt in.");
4348        }
4349    }
4350
4351    // MCP configuration
4352    println!();
4353    println!("{}", "MCP Servers (configuration only):".bold());
4354    println!("  · Static check only; no server process was started.");
4355    let features = config.features();
4356    if features.enabled(Feature::Mcp) {
4357        println!(
4358            "  {} MCP feature flag enabled",
4359            "✓".truecolor(aqua_r, aqua_g, aqua_b)
4360        );
4361    } else {
4362        println!(
4363            "  {} MCP feature flag disabled",
4364            "!".truecolor(sky_r, sky_g, sky_b)
4365        );
4366    }
4367
4368    let mcp_config_path = config.mcp_config_path();
4369    let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
4370    if mcp_config_path.exists() {
4371        println!(
4372            "  {} MCP config found at {}",
4373            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4374            crate::utils::display_path(&mcp_config_path)
4375        );
4376    } else {
4377        println!(
4378            "  {} MCP config not found at {}",
4379            "·".dimmed(),
4380            crate::utils::display_path(&mcp_config_path)
4381        );
4382    }
4383    if project_mcp_config_path.exists() {
4384        println!(
4385            "  {} Project MCP config found at {}",
4386            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4387            crate::utils::display_path(&project_mcp_config_path)
4388        );
4389    } else {
4390        println!(
4391            "  {} Project MCP config not found at {}",
4392            "·".dimmed(),
4393            crate::utils::display_path(&project_mcp_config_path)
4394        );
4395    }
4396
4397    match crate::mcp::load_config_with_workspace_and_plugins(&mcp_config_path, workspace, plugins) {
4398        Ok(cfg) if cfg.servers.is_empty() => {
4399            println!("  {} 0 merged server(s) configured", "·".dimmed());
4400            if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
4401                println!("    Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
4402            }
4403        }
4404        Ok(cfg) => {
4405            println!(
4406                "  {} {} merged server(s) configured",
4407                "·".dimmed(),
4408                cfg.servers.len()
4409            );
4410            for (name, server) in &cfg.servers {
4411                let status = doctor_check_mcp_server(server);
4412                let icon = match &status {
4413                    McpServerDoctorStatus::Ok(detail) => {
4414                        format!(
4415                            "  {} {name}: configuration valid; {}",
4416                            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4417                            detail
4418                        )
4419                    }
4420                    McpServerDoctorStatus::Warning(detail) => {
4421                        format!(
4422                            "  {} {name}: configuration warning; {}",
4423                            "!".truecolor(sky_r, sky_g, sky_b),
4424                            detail
4425                        )
4426                    }
4427                    McpServerDoctorStatus::Error(detail) => {
4428                        format!(
4429                            "  {} {name}: configuration invalid; {}",
4430                            "✗".truecolor(red_r, red_g, red_b),
4431                            detail
4432                        )
4433                    }
4434                };
4435                println!("{icon}");
4436                if !server.is_enabled() {
4437                    println!("      disabled; live health not checked");
4438                } else {
4439                    println!(
4440                        "      process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
4441                    );
4442                }
4443            }
4444            if probes.should_probe_mcp() {
4445                println!();
4446                println!(
4447                    "  {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
4448                    "!".truecolor(sky_r, sky_g, sky_b)
4449                );
4450                match crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
4451                    &mcp_config_path,
4452                    workspace,
4453                    std::sync::Arc::new(plugins.clone()),
4454                ) {
4455                    Ok(mut pool) => {
4456                        let errors = pool.connect_all().await;
4457                        let failed = errors
4458                            .iter()
4459                            .map(|(name, _)| name.as_str())
4460                            .collect::<std::collections::BTreeSet<_>>();
4461                        for (name, server) in &cfg.servers {
4462                            if !server.is_enabled() {
4463                                continue;
4464                            }
4465                            if failed.contains(name.as_str()) {
4466                                println!(
4467                                    "      {} {name}: process/protocol unreachable; error details omitted",
4468                                    "✗".truecolor(red_r, red_g, red_b)
4469                                );
4470                            } else {
4471                                println!(
4472                                    "      {} {name}: process reachable and protocol initialized; backend tool health not checked",
4473                                    "✓".truecolor(aqua_r, aqua_g, aqua_b)
4474                                );
4475                            }
4476                        }
4477                    }
4478                    Err(_) => println!(
4479                        "      {} live MCP probe could not load merged configuration; details omitted",
4480                        "✗".truecolor(red_r, red_g, red_b)
4481                    ),
4482                }
4483            } else {
4484                println!(
4485                    "    Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
4486                );
4487            }
4488        }
4489        Err(_) => {
4490            println!(
4491                "  {} MCP configuration could not be loaded; details omitted",
4492                "✗".truecolor(red_r, red_g, red_b)
4493            );
4494        }
4495    }
4496
4497    // Skills configuration
4498    println!();
4499    println!("{}", "Skills:".bold());
4500    let global_skills_dir = config.skills_dir();
4501    let agents_skills_dir = workspace.join(".agents").join("skills");
4502    let local_skills_dir = workspace.join("skills");
4503    let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
4504    // #432: cross-tool skill discovery dirs. Presence is reported here
4505    // even though they sit lower in the precedence chain so users can
4506    // see at a glance whether a `.opencode/skills/`, `.claude/skills/`,
4507    // `.cursor/skills/`, or global agentskills.io directory is contributing
4508    // to the merged catalogue.
4509    let opencode_skills_dir = workspace.join(".opencode").join("skills");
4510    let claude_skills_dir = workspace.join(".claude").join("skills");
4511    let selected_skills_dir = if agents_skills_dir.exists() {
4512        agents_skills_dir.clone()
4513    } else if local_skills_dir.exists() {
4514        local_skills_dir.clone()
4515    } else if config.skills_dir.is_none()
4516        && let Some(global_agents) = agents_global_skills_dir.as_ref()
4517        && global_agents.exists()
4518    {
4519        global_agents.clone()
4520    } else {
4521        global_skills_dir.clone()
4522    };
4523
4524    let describe_dir = |dir: &Path| -> usize {
4525        std::fs::read_dir(dir)
4526            .map(|entries| entries.filter_map(std::result::Result::ok).count())
4527            .unwrap_or(0)
4528    };
4529
4530    if local_skills_dir.exists() {
4531        println!(
4532            "  {} local skills dir found at {} ({} items)",
4533            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4534            crate::utils::display_path(&local_skills_dir),
4535            describe_dir(&local_skills_dir)
4536        );
4537    } else {
4538        println!(
4539            "  {} local skills dir not found at {}",
4540            "·".dimmed(),
4541            crate::utils::display_path(&local_skills_dir)
4542        );
4543    }
4544
4545    if agents_skills_dir.exists() {
4546        println!(
4547            "  {} .agents skills dir found at {} ({} items)",
4548            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4549            crate::utils::display_path(&agents_skills_dir),
4550            describe_dir(&agents_skills_dir)
4551        );
4552    } else {
4553        println!(
4554            "  {} .agents skills dir not found at {}",
4555            "·".dimmed(),
4556            crate::utils::display_path(&agents_skills_dir)
4557        );
4558    }
4559
4560    if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
4561        if agents_global_skills_dir.exists() {
4562            println!(
4563                "  {} global .agents skills dir found at {} ({} items)",
4564                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4565                crate::utils::display_path(agents_global_skills_dir),
4566                describe_dir(agents_global_skills_dir)
4567            );
4568        } else {
4569            println!(
4570                "  {} global .agents skills dir not found at {}",
4571                "·".dimmed(),
4572                crate::utils::display_path(agents_global_skills_dir)
4573            );
4574        }
4575    }
4576
4577    if global_skills_dir.exists() {
4578        println!(
4579            "  {} global skills dir found at {} ({} items)",
4580            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4581            crate::utils::display_path(&global_skills_dir),
4582            describe_dir(&global_skills_dir)
4583        );
4584    } else {
4585        println!(
4586            "  {} global skills dir not found at {}",
4587            "·".dimmed(),
4588            crate::utils::display_path(&global_skills_dir)
4589        );
4590    }
4591
4592    // #432: only print interop dirs when they're populated — empty
4593    // .opencode/.claude folders are common and would just clutter
4594    // the report with false-positive "absent" lines.
4595    if opencode_skills_dir.exists() {
4596        println!(
4597            "  {} .opencode skills dir found at {} ({} items)",
4598            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4599            crate::utils::display_path(&opencode_skills_dir),
4600            describe_dir(&opencode_skills_dir)
4601        );
4602    }
4603    if claude_skills_dir.exists() {
4604        println!(
4605            "  {} .claude skills dir found at {} ({} items)",
4606            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4607            crate::utils::display_path(&claude_skills_dir),
4608            describe_dir(&claude_skills_dir)
4609        );
4610    }
4611
4612    println!(
4613        "  {} selected skills dir: {}",
4614        "·".dimmed(),
4615        crate::utils::display_path(&selected_skills_dir)
4616    );
4617    if !agents_skills_dir.exists()
4618        && !local_skills_dir.exists()
4619        && !agents_global_skills_dir
4620            .as_ref()
4621            .is_some_and(|dir| dir.exists())
4622        && !global_skills_dir.exists()
4623    {
4624        println!("    Run `codewhale setup --skills` (or add --local for ./skills).");
4625    }
4626
4627    // Tools directory
4628    println!();
4629    println!("{}", "Tools:".bold());
4630    let tools_dir = default_tools_dir();
4631    if tools_dir.exists() {
4632        let count = count_dir_entries(&tools_dir);
4633        println!(
4634            "  {} tools dir found at {} ({} items)",
4635            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4636            crate::utils::display_path(&tools_dir),
4637            count
4638        );
4639    } else {
4640        println!(
4641            "  {} tools dir not found at {}",
4642            "·".dimmed(),
4643            crate::utils::display_path(&tools_dir)
4644        );
4645        println!("    Run `codewhale setup --tools` to scaffold a starter dir.");
4646    }
4647
4648    // Plugins directory
4649    println!();
4650    println!("{}", "Plugins:".bold());
4651    let plugins_dir = default_plugins_dir();
4652    if plugins_dir.exists() {
4653        let count = count_dir_entries(&plugins_dir);
4654        println!(
4655            "  {} plugins dir found at {} ({} items)",
4656            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4657            crate::utils::display_path(&plugins_dir),
4658            count
4659        );
4660    } else {
4661        println!(
4662            "  {} plugins dir not found at {}",
4663            "·".dimmed(),
4664            crate::utils::display_path(&plugins_dir)
4665        );
4666        println!("    Run `codewhale setup --plugins` to scaffold a starter dir.");
4667    }
4668
4669    // Storage surfaces (#422 / #440 / #500)
4670    println!();
4671    println!("{}", "Storage:".bold());
4672    if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
4673        let (present, count) = if spillover_root.is_dir() {
4674            (true, count_dir_entries(&spillover_root))
4675        } else {
4676            (false, 0)
4677        };
4678        if present {
4679            println!(
4680                "  {} tool-output spillover at {} ({} file{})",
4681                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4682                crate::utils::display_path(&spillover_root),
4683                count,
4684                if count == 1 { "" } else { "s" }
4685            );
4686        } else {
4687            println!(
4688                "  {} tool-output spillover dir not yet created at {}",
4689                "·".dimmed(),
4690                crate::utils::display_path(&spillover_root)
4691            );
4692        }
4693    }
4694    let stash = crate::composer_stash::diagnostic_stash_report();
4695    if let Some(stash_path) = stash.path.as_ref() {
4696        if let Some(error) = stash.error.as_deref() {
4697            println!(
4698                "  {} composer stash was not inspected at {}: {error}",
4699                "!".truecolor(sky_r, sky_g, sky_b),
4700                crate::utils::display_path(stash_path),
4701            );
4702        } else if stash.present {
4703            println!(
4704                "  {} composer stash at {} ({} parked draft{})",
4705                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4706                crate::utils::display_path(stash_path),
4707                stash.count,
4708                if stash.count == 1 { "" } else { "s" }
4709            );
4710        } else {
4711            println!(
4712                "  {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
4713                "·".dimmed()
4714            );
4715        }
4716    } else if let Some(error) = stash.error.as_deref() {
4717        println!(
4718            "  {} composer stash was not inspected: {error}",
4719            "!".truecolor(sky_r, sky_g, sky_b),
4720        );
4721    }
4722
4723    // Tool dependencies — probe external binaries that individual
4724    // tools rely on (Python for code_execution, pdftotext for PDF
4725    // reading) so users see explicit ✓/✗ rather than the tool failing
4726    // at execution time with "program not found". New in v0.8.31.
4727    println!();
4728    println!("{}", "Tool Dependencies:".bold());
4729
4730    match crate::dependencies::resolve_python_interpreter() {
4731        Some(name) => println!(
4732            "  {} Python: {} → code_execution tool registered",
4733            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4734            name
4735        ),
4736        None => {
4737            println!(
4738                "  {} Python: not found (tried {:?})",
4739                "✗".truecolor(red_r, red_g, red_b),
4740                crate::dependencies::PYTHON_CANDIDATES,
4741            );
4742            println!("    code_execution tool is NOT advertised to the model on this install.");
4743            println!("    Install Python 3 and ensure one of those names is on PATH:");
4744            match std::env::consts::OS {
4745                "macos" => {
4746                    println!("      brew install python@3.12   (or download from python.org)")
4747                }
4748                "linux" => println!(
4749                    "      sudo apt install python3    (Debian/Ubuntu) — or your distro's equivalent"
4750                ),
4751                "windows" => {
4752                    println!("      winget install Python.Python.3   (or download from python.org)")
4753                }
4754                other => println!("      install Python 3 for {other} from python.org"),
4755            }
4756        }
4757    }
4758
4759    match crate::dependencies::resolve_node() {
4760        Some(_) => println!(
4761            "  {} Node.js: present → js_execution tool registered",
4762            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4763        ),
4764        None => {
4765            println!(
4766                "  {} Node.js: not found (tried `node`)",
4767                "✗".truecolor(red_r, red_g, red_b),
4768            );
4769            println!("    js_execution tool is NOT advertised to the model on this install.");
4770            println!("    Install Node 18+ and ensure `node` is on PATH:");
4771            match std::env::consts::OS {
4772                "macos" => println!("      brew install node   (or download from nodejs.org)"),
4773                "linux" => println!(
4774                    "      sudo apt install nodejs    (Debian/Ubuntu) — or your distro's equivalent"
4775                ),
4776                "windows" => {
4777                    println!("      winget install OpenJS.NodeJS   (or download from nodejs.org)")
4778                }
4779                other => println!("      install Node.js for {other} from nodejs.org"),
4780            }
4781        }
4782    }
4783
4784    match crate::dependencies::resolve_pandoc() {
4785        Some(_) => println!(
4786            "  {} pandoc: present → pandoc_convert tool registered",
4787            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4788        ),
4789        None => {
4790            println!("  {} pandoc: not found (optional)", "·".dimmed(),);
4791            println!(
4792                "    pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:"
4793            );
4794            match std::env::consts::OS {
4795                "macos" => println!("      brew install pandoc"),
4796                "linux" => println!(
4797                    "      sudo apt install pandoc    (Debian/Ubuntu) — or your distro's equivalent"
4798                ),
4799                "windows" => {
4800                    println!("      winget install JohnMacFarlane.Pandoc")
4801                }
4802                other => println!("      install pandoc for {other} from pandoc.org"),
4803            }
4804        }
4805    }
4806
4807    match crate::dependencies::resolve_tesseract() {
4808        Some(_) => {
4809            if cfg!(target_os = "macos") {
4810                println!(
4811                    "  {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
4812                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4813                );
4814            } else {
4815                println!(
4816                    "  {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
4817                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4818                );
4819            }
4820        }
4821        None => {
4822            if cfg!(target_os = "macos") {
4823                println!(
4824                    "  {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
4825                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4826                );
4827                println!(
4828                    "    tesseract not found (optional; install only for alternate OCR packs)."
4829                );
4830            } else {
4831                println!("  {} tesseract: not found (optional)", "·".dimmed(),);
4832                println!(
4833                    "    image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
4834                );
4835                match std::env::consts::OS {
4836                    "macos" => println!("      brew install tesseract"),
4837                    "linux" => println!(
4838                        "      sudo apt install tesseract-ocr    (Debian/Ubuntu) — or your distro's equivalent"
4839                    ),
4840                    "windows" => println!("      winget install UB-Mannheim.TesseractOCR"),
4841                    other => {
4842                        println!("      install tesseract for {other} from tesseract-ocr.github.io")
4843                    }
4844                }
4845            }
4846        }
4847    }
4848
4849    // PDF text extraction is an optional integration. Codewhale itself stays
4850    // a single required executable; file and web tools report a typed
4851    // failed `binary_unavailable` result when Poppler is not installed.
4852    match crate::dependencies::resolve_pdftotext() {
4853        Some(_) => println!(
4854            "  {} pdftotext: available → PDF text extraction enabled",
4855            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4856        ),
4857        None => {
4858            println!(
4859                "  {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
4860                "·".dimmed(),
4861            );
4862            match std::env::consts::OS {
4863                "macos" => println!("    Install via: brew install poppler"),
4864                "linux" => {
4865                    println!("    Install via: sudo apt install poppler-utils   (Debian/Ubuntu)")
4866                }
4867                "windows" => println!(
4868                    "    Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
4869                ),
4870                _ => {}
4871            }
4872        }
4873    }
4874
4875    // Terminal-quirk overrides currently active. Mirrors the env
4876    // signals checked by `Settings::apply_env_overrides` so users
4877    // can see at a glance which a11y/compat overrides fired.
4878    println!();
4879    println!("{}", "Terminal Quirks:".bold());
4880    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
4881    let term_program_lc = term_program.to_ascii_lowercase();
4882    let mut any_quirk = false;
4883    if matches!(term_program.as_str(), "vscode" | "ghostty") {
4884        println!(
4885            "  {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
4886            "•".truecolor(sky_r, sky_g, sky_b),
4887            term_program
4888        );
4889        any_quirk = true;
4890    }
4891    if term_program == "Termius"
4892        || std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
4893        || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
4894    {
4895        println!(
4896            "  {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
4897            "•".truecolor(sky_r, sky_g, sky_b)
4898        );
4899        any_quirk = true;
4900    }
4901    if term_program_lc.contains("ptyxis")
4902        || std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
4903    {
4904        println!(
4905            "  {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
4906            "•".truecolor(sky_r, sky_g, sky_b)
4907        );
4908        any_quirk = true;
4909    }
4910    if crate::settings::detected_legacy_windows_console_host() {
4911        println!(
4912            "  {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
4913            "•".truecolor(sky_r, sky_g, sky_b)
4914        );
4915        any_quirk = true;
4916    }
4917    if !any_quirk {
4918        println!(
4919            "  {} no env-driven terminal-quirk overrides active",
4920            "·".dimmed()
4921        );
4922    }
4923
4924    // Platform and sandbox checks
4925    println!();
4926    println!("{}", "Platform:".bold());
4927    println!("  OS: {}", std::env::consts::OS);
4928    println!("  Arch: {}", std::env::consts::ARCH);
4929
4930    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
4931        config.prefer_bwrap.unwrap_or(false),
4932    );
4933    if let Some(kind) = sandbox {
4934        println!(
4935            "  {} sandbox available: {}",
4936            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4937            kind
4938        );
4939    } else {
4940        println!(
4941            "  {} sandbox not available (commands run best-effort)",
4942            "!".truecolor(sky_r, sky_g, sky_b)
4943        );
4944    }
4945
4946    println!();
4947    println!(
4948        "{}",
4949        "All checks complete!"
4950            .truecolor(aqua_r, aqua_g, aqua_b)
4951            .bold()
4952    );
4953}
4954
4955const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
4956    "sessions",
4957    "tasks",
4958    "skills",
4959    "slop_ledger",
4960    "trophies",
4961    "catalog",
4962    "review-receipts",
4963    "config.toml",
4964    "settings.toml",
4965    "mcp.json",
4966];
4967const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
4968const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
4969
4970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4971enum DoctorLegacyStateStatus {
4972    PrimaryOnly,
4973    LegacyOnly,
4974    Both,
4975    Absent,
4976}
4977
4978impl DoctorLegacyStateStatus {
4979    fn as_str(self) -> &'static str {
4980        match self {
4981            Self::PrimaryOnly => "primary_only",
4982            Self::LegacyOnly => "legacy_only",
4983            Self::Both => "both",
4984            Self::Absent => "absent",
4985        }
4986    }
4987}
4988
4989#[derive(Debug, Clone)]
4990struct DoctorLegacyStateEntry {
4991    name: &'static str,
4992    primary_path: PathBuf,
4993    legacy_path: PathBuf,
4994    primary_present: bool,
4995    legacy_present: bool,
4996    status: DoctorLegacyStateStatus,
4997}
4998
4999#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5000enum DoctorSessionRecoveryStatus {
5001    Isolated,
5002    NoLegacySessions,
5003    MigrationPending,
5004    MigrationIncomplete,
5005    MigrationComplete,
5006    ScanFailed,
5007}
5008
5009impl DoctorSessionRecoveryStatus {
5010    fn as_str(self) -> &'static str {
5011        match self {
5012            Self::Isolated => "isolated",
5013            Self::NoLegacySessions => "no_legacy_sessions",
5014            Self::MigrationPending => "migration_pending",
5015            Self::MigrationIncomplete => "migration_incomplete",
5016            Self::MigrationComplete => "migration_complete",
5017            Self::ScanFailed => "scan_failed",
5018        }
5019    }
5020}
5021
5022#[derive(Debug, Clone)]
5023struct DoctorRecoverableSessionEntry {
5024    name: PathBuf,
5025    source_path: PathBuf,
5026    destination_path: PathBuf,
5027}
5028
5029#[derive(Debug, Clone)]
5030struct DoctorSessionRecoveryReport {
5031    status: DoctorSessionRecoveryStatus,
5032    primary_sessions_path: PathBuf,
5033    legacy_sessions_path: PathBuf,
5034    codewhale_home_is_explicit: bool,
5035    legacy_session_file_count: usize,
5036    already_present_file_count: usize,
5037    recoverable_file_count: usize,
5038    /// Bounded filename/path sample; the total is `recoverable_file_count`.
5039    recoverable: Vec<DoctorRecoverableSessionEntry>,
5040    error: Option<String>,
5041}
5042
5043impl DoctorSessionRecoveryReport {
5044    fn needs_attention(&self) -> bool {
5045        matches!(
5046            self.status,
5047            DoctorSessionRecoveryStatus::MigrationPending
5048                | DoctorSessionRecoveryStatus::MigrationIncomplete
5049                | DoctorSessionRecoveryStatus::ScanFailed
5050        )
5051    }
5052}
5053
5054fn doctor_legacy_state_status(
5055    primary_present: bool,
5056    legacy_present: bool,
5057) -> DoctorLegacyStateStatus {
5058    match (primary_present, legacy_present) {
5059        (true, false) => DoctorLegacyStateStatus::PrimaryOnly,
5060        (false, true) => DoctorLegacyStateStatus::LegacyOnly,
5061        (true, true) => DoctorLegacyStateStatus::Both,
5062        (false, false) => DoctorLegacyStateStatus::Absent,
5063    }
5064}
5065
5066fn doctor_state_roots() -> (PathBuf, PathBuf) {
5067    let code_home =
5068        codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
5069    let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
5070        code_home.join(codewhale_config::LEGACY_APP_DIR)
5071    } else {
5072        codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
5073    };
5074    (code_home, legacy_home)
5075}
5076
5077fn doctor_legacy_state_report(
5078    primary_root: &Path,
5079    legacy_root: &Path,
5080) -> Vec<DoctorLegacyStateEntry> {
5081    DOCTOR_LEGACY_STATE_ITEMS
5082        .iter()
5083        .copied()
5084        .map(|name| {
5085            let primary_path = primary_root.join(name);
5086            let legacy_path = legacy_root.join(name);
5087            let primary_present = primary_path.exists();
5088            let legacy_present = legacy_path.exists();
5089            let status = doctor_legacy_state_status(primary_present, legacy_present);
5090            DoctorLegacyStateEntry {
5091                name,
5092                primary_path,
5093                legacy_path,
5094                primary_present,
5095                legacy_present,
5096                status,
5097            }
5098        })
5099        .collect()
5100}
5101
5102/// Compare legacy and primary session filenames without opening session files.
5103///
5104/// This is deliberately separate from `SessionManager::default_location()`:
5105/// constructing the manager can trigger the additive legacy migration, while
5106/// doctor must remain a read-only diagnostic. Session history is stored as
5107/// top-level JSON files. Directories (including `checkpoints`) and symlinks
5108/// observed during the scan are ignored, so the diagnostic does not
5109/// intentionally traverse checkpoint internals or link targets. These checks
5110/// are best-effort observations, not a race-free no-follow guarantee.
5111/// A matching filename is only a regular-file counterpart check: doctor does
5112/// not parse or compare session descriptors.
5113fn doctor_session_recovery_report(
5114    primary_root: &Path,
5115    legacy_root: &Path,
5116    codewhale_home_is_explicit: bool,
5117) -> DoctorSessionRecoveryReport {
5118    let primary_sessions_path = primary_root.join("sessions");
5119    let legacy_sessions_path = legacy_root.join("sessions");
5120    let mut report = DoctorSessionRecoveryReport {
5121        status: DoctorSessionRecoveryStatus::NoLegacySessions,
5122        primary_sessions_path,
5123        legacy_sessions_path,
5124        codewhale_home_is_explicit,
5125        legacy_session_file_count: 0,
5126        already_present_file_count: 0,
5127        recoverable_file_count: 0,
5128        recoverable: Vec::new(),
5129        error: None,
5130    };
5131
5132    if codewhale_home_is_explicit {
5133        report.status = DoctorSessionRecoveryStatus::Isolated;
5134        return report;
5135    }
5136
5137    let legacy_root_is_present =
5138        match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
5139            Ok(present) => present,
5140            Err(error) => {
5141                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5142                report.error = Some(error);
5143                return report;
5144            }
5145        };
5146    if !legacy_root_is_present {
5147        return report;
5148    }
5149    if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
5150        report.status = DoctorSessionRecoveryStatus::ScanFailed;
5151        report.error = Some(error);
5152        return report;
5153    }
5154
5155    let legacy_sessions_are_present = match doctor_session_directory_is_safe(
5156        &report.legacy_sessions_path,
5157        "legacy sessions root",
5158    ) {
5159        Ok(present) => present,
5160        Err(error) => {
5161            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5162            report.error = Some(error);
5163            return report;
5164        }
5165    };
5166    if !legacy_sessions_are_present {
5167        return report;
5168    }
5169    let primary_sessions_are_present = match doctor_session_directory_is_safe(
5170        &report.primary_sessions_path,
5171        "primary sessions root",
5172    ) {
5173        Ok(present) => present,
5174        Err(error) => {
5175            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5176            report.error = Some(error);
5177            return report;
5178        }
5179    };
5180
5181    let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
5182        Ok(entries) => entries,
5183        Err(err) => {
5184            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5185            report.error = Some(format!(
5186                "could not inspect legacy session filenames at {}: {err}",
5187                crate::utils::display_path(&report.legacy_sessions_path)
5188            ));
5189            return report;
5190        }
5191    };
5192
5193    for entry in entries {
5194        let entry = match entry {
5195            Ok(entry) => entry,
5196            Err(err) => {
5197                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5198                report.error = Some(format!(
5199                    "could not inspect an entry under {}: {err}",
5200                    crate::utils::display_path(&report.legacy_sessions_path)
5201                ));
5202                return report;
5203            }
5204        };
5205        let file_type = match entry.file_type() {
5206            Ok(file_type) => file_type,
5207            Err(err) => {
5208                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5209                report.error = Some(format!(
5210                    "could not inspect legacy session entry metadata under {}: {err}",
5211                    crate::utils::display_path(&report.legacy_sessions_path)
5212                ));
5213                return report;
5214            }
5215        };
5216        if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
5217            continue;
5218        }
5219
5220        report.legacy_session_file_count += 1;
5221        let name = PathBuf::from(entry.file_name());
5222        let destination_path = report.primary_sessions_path.join(&name);
5223        match std::fs::symlink_metadata(&destination_path) {
5224            Ok(metadata) if metadata.file_type().is_file() => {
5225                report.already_present_file_count += 1;
5226            }
5227            Ok(metadata) => {
5228                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5229                let shape = if metadata.file_type().is_symlink() {
5230                    "destination session entry is a symlink"
5231                } else {
5232                    "destination session entry is not a regular file"
5233                };
5234                report.error = Some(format!(
5235                    "could not inspect destination session metadata at {}: {shape}",
5236                    crate::utils::display_path(&destination_path)
5237                ));
5238                return report;
5239            }
5240            Err(err) if err.kind() == io::ErrorKind::NotFound => {
5241                report.recoverable_file_count += 1;
5242                record_doctor_recoverable_session(
5243                    &mut report.recoverable,
5244                    DoctorRecoverableSessionEntry {
5245                        source_path: entry.path(),
5246                        destination_path,
5247                        name,
5248                    },
5249                );
5250            }
5251            Err(err) => {
5252                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5253                report.error = Some(format!(
5254                    "could not inspect destination metadata at {}: {err}",
5255                    crate::utils::display_path(&destination_path)
5256                ));
5257                return report;
5258            }
5259        }
5260    }
5261
5262    report.status = if report.legacy_session_file_count == 0 {
5263        DoctorSessionRecoveryStatus::NoLegacySessions
5264    } else if report.recoverable_file_count == 0 {
5265        DoctorSessionRecoveryStatus::MigrationComplete
5266    } else if primary_sessions_are_present {
5267        DoctorSessionRecoveryStatus::MigrationIncomplete
5268    } else {
5269        DoctorSessionRecoveryStatus::MigrationPending
5270    };
5271    report
5272}
5273
5274/// Validate a session-state directory from observed metadata.
5275///
5276/// `doctor` only compares top-level filenames. It rejects a state-root or
5277/// sessions-root symlink observed during inspection rather than using it for a
5278/// recovery suggestion. This is a best-effort observation, not a race-free
5279/// no-follow guarantee. Missing paths are normal on a fresh install and are
5280/// reported as `false`.
5281fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
5282    let metadata = match std::fs::symlink_metadata(path) {
5283        Ok(metadata) => metadata,
5284        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
5285        Err(error) => {
5286            return Err(format!(
5287                "could not inspect {label} at {}: {error}",
5288                crate::utils::display_path(path)
5289            ));
5290        }
5291    };
5292    if metadata.file_type().is_symlink() {
5293        return Err(format!(
5294            "could not inspect {label} at {}: path is a symlink",
5295            crate::utils::display_path(path)
5296        ));
5297    }
5298    if !metadata.file_type().is_dir() {
5299        return Err(format!(
5300            "could not inspect {label} at {}: path is not a directory",
5301            crate::utils::display_path(path)
5302        ));
5303    }
5304    Ok(true)
5305}
5306
5307/// Keep the report bounded while preserving a deterministic, lexical sample.
5308/// `read_dir` order is platform- and filesystem-dependent, so retaining the
5309/// first entries encountered would make the JSON and human receipts drift.
5310fn record_doctor_recoverable_session(
5311    recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
5312    entry: DoctorRecoverableSessionEntry,
5313) {
5314    let insert_at = recoverable
5315        .binary_search_by(|existing| existing.name.cmp(&entry.name))
5316        .unwrap_or_else(|index| index);
5317    if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
5318        && insert_at == recoverable.len()
5319    {
5320        return;
5321    }
5322    recoverable.insert(insert_at, entry);
5323    if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
5324        recoverable.pop();
5325    }
5326}
5327
5328fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
5329    entry.name != "sessions"
5330        && matches!(
5331            entry.status,
5332            DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
5333        )
5334}
5335
5336fn print_doctor_legacy_state_report(
5337    report: &[DoctorLegacyStateEntry],
5338    session_recovery: &DoctorSessionRecoveryReport,
5339    ok_rgb: (u8, u8, u8),
5340    warn_rgb: (u8, u8, u8),
5341) {
5342    use colored::Colorize;
5343
5344    let attention: Vec<_> = report
5345        .iter()
5346        .filter(|entry| legacy_state_needs_attention(entry))
5347        .collect();
5348    if attention.is_empty()
5349        && !session_recovery.needs_attention()
5350        && session_recovery.status != DoctorSessionRecoveryStatus::Isolated
5351    {
5352        println!(
5353            "  {} legacy state: no known .deepseek entries need migration",
5354            "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5355        );
5356    } else if !attention.is_empty() {
5357        println!(
5358            "  {} legacy state needs review:",
5359            "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5360        );
5361        for entry in attention {
5362            match entry.status {
5363                DoctorLegacyStateStatus::LegacyOnly => {
5364                    println!(
5365                        "    {} {} exists but {} is missing",
5366                        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5367                        crate::utils::display_path(&entry.legacy_path),
5368                        crate::utils::display_path(&entry.primary_path),
5369                    );
5370                }
5371                DoctorLegacyStateStatus::Both => {
5372                    println!(
5373                        "    {} {} exists alongside primary {}; legacy data may still need review",
5374                        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5375                        crate::utils::display_path(&entry.legacy_path),
5376                        crate::utils::display_path(&entry.primary_path),
5377                    );
5378                }
5379                DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
5380            }
5381        }
5382        println!(
5383            "    Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
5384        );
5385    }
5386
5387    print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
5388}
5389
5390fn print_doctor_session_recovery_report(
5391    report: &DoctorSessionRecoveryReport,
5392    ok_rgb: (u8, u8, u8),
5393    warn_rgb: (u8, u8, u8),
5394) {
5395    use colored::Colorize;
5396
5397    match report.status {
5398        DoctorSessionRecoveryStatus::Isolated => {
5399            println!(
5400                "  {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
5401                "·".dimmed()
5402            );
5403            println!(
5404                "    This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
5405            );
5406        }
5407        DoctorSessionRecoveryStatus::NoLegacySessions => {
5408            println!(
5409                "  {} legacy sessions: no top-level session JSON files found",
5410                "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5411            );
5412        }
5413        DoctorSessionRecoveryStatus::MigrationComplete => {
5414            println!(
5415                "  {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
5416                "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
5417                report.legacy_session_file_count,
5418                crate::utils::display_path(&report.primary_sessions_path),
5419            );
5420        }
5421        DoctorSessionRecoveryStatus::MigrationPending
5422        | DoctorSessionRecoveryStatus::MigrationIncomplete => {
5423            let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
5424                "migration is incomplete"
5425            } else {
5426                "migration has not completed"
5427            };
5428            println!(
5429                "  {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
5430                "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5431                report.recoverable_file_count,
5432                crate::utils::display_path(&report.primary_sessions_path),
5433            );
5434            for entry in report
5435                .recoverable
5436                .iter()
5437                .take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
5438            {
5439                println!(
5440                    "    {} {} -> {}",
5441                    "·".dimmed(),
5442                    crate::utils::display_path(&entry.source_path),
5443                    crate::utils::display_path(&entry.destination_path),
5444                );
5445            }
5446            if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
5447                println!(
5448                    "    · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
5449                    report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
5450                );
5451            }
5452            println!("    Safe recovery:");
5453            println!(
5454                "      1. Back up {} and {} (if present).",
5455                crate::utils::display_path(&report.legacy_sessions_path),
5456                crate::utils::display_path(&report.primary_sessions_path),
5457            );
5458            println!(
5459                "      2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
5460            );
5461            println!(
5462                "      3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
5463            );
5464        }
5465        DoctorSessionRecoveryStatus::ScanFailed => {
5466            println!(
5467                "  {} legacy sessions: recovery diagnostic could not complete",
5468                "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5469            );
5470            if let Some(error) = report.error.as_deref() {
5471                println!("    {error}");
5472            }
5473            println!(
5474                "    Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
5475            );
5476        }
5477    }
5478    if report.status != DoctorSessionRecoveryStatus::Isolated {
5479        println!(
5480            "    Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
5481        );
5482    }
5483}
5484
5485fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
5486    use serde_json::json;
5487
5488    let recoverable: Vec<_> = report
5489        .recoverable
5490        .iter()
5491        .take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
5492        .map(|entry| {
5493            json!({
5494                "name": entry.name.display().to_string(),
5495                "source_path": entry.source_path.display().to_string(),
5496                "destination_path": entry.destination_path.display().to_string(),
5497            })
5498        })
5499        .collect();
5500
5501    json!({
5502        "status": report.status.as_str(),
5503        "needs_attention": report.needs_attention(),
5504        "read_only": true,
5505        "chat_contents_read": false,
5506        "checkpoint_internals_scanned": false,
5507        "session_descriptors_compared": false,
5508        "counterpart_check": "top_level_filename_and_regular_file_only",
5509        "codewhale_home_is_explicit": report.codewhale_home_is_explicit,
5510        "legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
5511        "primary_sessions_path": report.primary_sessions_path.display().to_string(),
5512        "legacy_session_file_count": report.legacy_session_file_count,
5513        "already_present_file_count": report.already_present_file_count,
5514        "recoverable_file_count": report.recoverable_file_count,
5515        "recoverable_files": recoverable,
5516        "recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
5517        "error": report.error,
5518        "recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
5519            Some("codewhale sessions")
5520        } else {
5521            None
5522        },
5523    })
5524}
5525
5526fn doctor_legacy_state_json(
5527    primary_root: &Path,
5528    legacy_root: &Path,
5529    report: &[DoctorLegacyStateEntry],
5530    session_recovery: &DoctorSessionRecoveryReport,
5531) -> serde_json::Value {
5532    use serde_json::json;
5533
5534    let legacy_only = report
5535        .iter()
5536        .filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
5537        .count();
5538    let both = report
5539        .iter()
5540        .filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
5541        .count();
5542    let entries: Vec<_> = report
5543        .iter()
5544        .map(|entry| {
5545            json!({
5546                "name": entry.name,
5547                "primary_path": entry.primary_path.display().to_string(),
5548                "legacy_path": entry.legacy_path.display().to_string(),
5549                "primary_present": entry.primary_present,
5550                "legacy_present": entry.legacy_present,
5551                "status": entry.status.as_str(),
5552            })
5553        })
5554        .collect();
5555
5556    json!({
5557        "primary_root": primary_root.display().to_string(),
5558        "legacy_root": legacy_root.display().to_string(),
5559        "needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
5560        "legacy_only_count": legacy_only,
5561        "dual_present_count": both,
5562        "session_recovery": doctor_session_recovery_json(session_recovery),
5563        "entries": entries,
5564    })
5565}
5566
5567fn doctor_setup_state(
5568    config: &Config,
5569    workspace: &Path,
5570) -> (codewhale_config::SetupState, &'static str) {
5571    if let Ok(Some(state)) = codewhale_config::SetupState::load() {
5572        return (state, "persisted");
5573    }
5574
5575    (
5576        codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
5577            config, workspace,
5578        )),
5579        "derived",
5580    )
5581}
5582
5583fn doctor_inherited_setup_facts(
5584    config: &Config,
5585    workspace: &Path,
5586) -> codewhale_config::InheritedConfigFacts {
5587    let user_constitution = codewhale_config::UserConstitution::load().ok();
5588    let user_constitution_validity = user_constitution.as_ref().map_or(
5589        codewhale_config::ConstitutionValidity::Unknown,
5590        codewhale_config::UserConstitutionLoad::validity,
5591    );
5592    let has_user_constitution = user_constitution
5593        .as_ref()
5594        .is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
5595    let has_expert_override = codewhale_config::codewhale_home()
5596        .ok()
5597        .map(|home| home.join(Path::new(crate::prompts::CONSTITUTION_OVERRIDE_FILE)))
5598        .is_some_and(|path| path.exists());
5599
5600    codewhale_config::InheritedConfigFacts {
5601        language: None,
5602        has_provider_route: !config.default_model().trim().is_empty(),
5603        has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
5604        trust_chosen: !crate::tui::onboarding::needs_trust(workspace),
5605        has_expert_override,
5606        has_user_constitution,
5607        user_constitution_validity,
5608    }
5609}
5610
5611fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
5612    resolve_credential_diagnostic(config)
5613        .availability
5614        .certifies_ready()
5615}
5616
5617fn print_doctor_setup_report(
5618    config: &Config,
5619    workspace: &Path,
5620    state: &codewhale_config::SetupState,
5621    source: &str,
5622    ok_rgb: (u8, u8, u8),
5623    warn_rgb: (u8, u8, u8),
5624) {
5625    use colored::Colorize;
5626
5627    let credential = resolve_credential_diagnostic(config);
5628    let credential_ready = credential.availability.certifies_ready();
5629    let first_run_ready = state.first_run_ready() && credential_ready;
5630    let update_ready =
5631        state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION) && credential_ready;
5632    let operate_ready = state.operate_ready() && credential_ready;
5633    let first_run_icon = if first_run_ready {
5634        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5635    } else {
5636        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5637    };
5638    let update_icon = if update_ready {
5639        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5640    } else {
5641        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5642    };
5643    let operate_icon = if operate_ready {
5644        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5645    } else {
5646        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5647    };
5648
5649    println!();
5650    println!("{}", "Setup State:".bold());
5651    println!("  · source: {source}");
5652    println!(
5653        "  · credential: source={}, availability={}",
5654        doctor_api_key_source_label(credential.source),
5655        credential.availability.label()
5656    );
5657    println!(
5658        "  {first_run_icon} first-run: {}",
5659        doctor_ready_label(first_run_ready)
5660    );
5661    println!(
5662        "  {update_icon} update checkpoint {}: {}",
5663        crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
5664        doctor_ready_label(update_ready)
5665    );
5666    println!(
5667        "  {operate_icon} operate/fleet: {}",
5668        doctor_ready_label(operate_ready)
5669    );
5670    println!(
5671        "  · constitution autonomy: {} (guidance only)",
5672        doctor_constitution_autonomy_preference_id()
5673    );
5674    println!(
5675        "  · runtime posture: {}",
5676        doctor_runtime_posture_line(config, workspace)
5677    );
5678    let consistency = doctor_setup_consistency(state, source);
5679    if consistency["status"] == "inconsistent" {
5680        let issues = consistency["issues"]
5681            .as_array()
5682            .map(|issues| {
5683                issues
5684                    .iter()
5685                    .filter_map(serde_json::Value::as_str)
5686                    .collect::<Vec<_>>()
5687                    .join(", ")
5688            })
5689            .unwrap_or_default();
5690        println!(
5691            "  {} consistency: half-applied setup detected ({issues}) — {}",
5692            "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5693            consistency["repair"].as_str().unwrap_or("/setup"),
5694        );
5695    }
5696    println!(
5697        "  · next actions: /constitution (standing law), /setup report (readiness), /setup provider or /provider setup <name> (provider credentials), /model (route), /config (runtime posture), /setup fleet (Operate/Fleet readiness), /fleet setup (explicit profile authoring), /setup hotbar (optional shortcuts), /setup tools (Tools/MCP readiness), /setup remote (remote runtime on-ramp), /setup persistence (path review)"
5698    );
5699    for step in codewhale_config::SetupStep::ALL {
5700        let entry = state.steps.get(&step);
5701        let required = entry.is_some_and(|entry| entry.required);
5702        let version = entry.and_then(|entry| entry.version.as_deref());
5703        let result = entry.and_then(|entry| entry.result.as_deref());
5704        let required_label = if required { "required" } else { "optional" };
5705        let version_label = version.unwrap_or("unversioned");
5706        let result_label = result.unwrap_or("no result");
5707        println!(
5708            "    · {}: {} ({required_label}, {version_label}, {result_label})",
5709            setup_step_id(step),
5710            setup_status_id(state.status(step))
5711        );
5712    }
5713}
5714
5715fn doctor_ready_label(ready: bool) -> &'static str {
5716    if ready { "ready" } else { "needs action" }
5717}
5718
5719/// Detect half-applied setup persistence (#3410).
5720///
5721/// The setup transaction writes `constitution.json` and `setup_state.json`
5722/// together, so a persisted state that points at a user-global constitution
5723/// which is missing or unusable on disk means a write was interrupted or a
5724/// file was removed out-of-band. Stale `.tmp*` files in `$CODEWHALE_HOME`
5725/// are the other fingerprint of an interrupted atomic write.
5726fn doctor_setup_consistency(
5727    state: &codewhale_config::SetupState,
5728    source: &str,
5729) -> serde_json::Value {
5730    use serde_json::json;
5731
5732    let mut issues: Vec<&'static str> = Vec::new();
5733
5734    if source == "persisted"
5735        && matches!(
5736            state.constitution_source,
5737            codewhale_config::ConstitutionSource::UserGlobal
5738        )
5739    {
5740        match codewhale_config::UserConstitution::load() {
5741            Ok(codewhale_config::UserConstitutionLoad::Missing) => {
5742                issues.push("setup_state_points_at_missing_user_constitution");
5743            }
5744            Ok(codewhale_config::UserConstitutionLoad::Empty) => {
5745                issues.push("user_constitution_empty");
5746            }
5747            Ok(codewhale_config::UserConstitutionLoad::Invalid(_)) => {
5748                issues.push("user_constitution_invalid");
5749            }
5750            Ok(codewhale_config::UserConstitutionLoad::Unreadable(_)) | Err(_) => {
5751                issues.push("user_constitution_unreadable");
5752            }
5753            Ok(codewhale_config::UserConstitutionLoad::Loaded(_)) => {}
5754        }
5755    }
5756
5757    if doctor_home_has_stale_setup_temp_files() {
5758        issues.push("stale_setup_temp_files_in_codewhale_home");
5759    }
5760
5761    json!({
5762        "status": if issues.is_empty() { "consistent" } else { "inconsistent" },
5763        "issues": issues,
5764        "repair": "/constitution to rebuild standing law, /setup to re-run the checkpoint",
5765    })
5766}
5767
5768fn doctor_home_has_stale_setup_temp_files() -> bool {
5769    let Ok(home) = codewhale_config::codewhale_home() else {
5770        return false;
5771    };
5772    let Ok(entries) = std::fs::read_dir(&home) else {
5773        return false;
5774    };
5775    entries.flatten().any(|entry| {
5776        entry.file_name().to_string_lossy().starts_with(".tmp")
5777            && entry.file_type().is_ok_and(|kind| kind.is_file())
5778    })
5779}
5780
5781fn doctor_constitution_autonomy_preference() -> codewhale_config::AutonomyPreference {
5782    codewhale_config::UserConstitution::load()
5783        .ok()
5784        .and_then(|load| {
5785            load.constitution()
5786                .map(|constitution| constitution.autonomy_preference)
5787        })
5788        .unwrap_or(codewhale_config::AutonomyPreference::Unspecified)
5789}
5790
5791fn doctor_constitution_autonomy_preference_id() -> &'static str {
5792    autonomy_preference_id(doctor_constitution_autonomy_preference())
5793}
5794
5795fn autonomy_preference_id(preference: codewhale_config::AutonomyPreference) -> &'static str {
5796    match preference {
5797        codewhale_config::AutonomyPreference::Unspecified => "unspecified",
5798        codewhale_config::AutonomyPreference::Cautious => "cautious",
5799        codewhale_config::AutonomyPreference::Balanced => "balanced",
5800        codewhale_config::AutonomyPreference::Autonomous => "autonomous",
5801    }
5802}
5803
5804fn doctor_runtime_default_mode() -> (String, &'static str) {
5805    match crate::settings::Settings::load_read_only() {
5806        Ok(settings) => (settings.default_mode, "settings"),
5807        Err(_) => (crate::settings::Settings::default().default_mode, "default"),
5808    }
5809}
5810
5811/// TUI settings posture used when `config.approval_policy` is unset.
5812/// Doctor must surface this separately so a saved Full Access baseline is not
5813/// misreported as the config default `approval_policy=on-request`.
5814fn doctor_runtime_permission_posture() -> (String, &'static str) {
5815    match crate::settings::Settings::load_read_only() {
5816        Ok(settings) => match settings.permission_posture {
5817            Some(posture) => (posture, "settings"),
5818            None => ("unset".to_string(), "default"),
5819        },
5820        Err(_) => ("unset".to_string(), "default"),
5821    }
5822}
5823
5824fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
5825    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
5826    let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
5827    let approval = config.approval_policy.as_deref().unwrap_or("on-request");
5828    let approval_source = if config.approval_policy.is_some() {
5829        "config"
5830    } else {
5831        "default"
5832    };
5833    let allow_shell = config.interactive_allow_shell();
5834    let allow_shell_source = if config.allow_shell.is_some() {
5835        "config"
5836    } else {
5837        "interactive default"
5838    };
5839    let sandbox = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
5840    let sandbox_source = if config.sandbox_mode.is_some() {
5841        "config"
5842    } else {
5843        "default"
5844    };
5845    let network = config
5846        .network
5847        .as_ref()
5848        .map_or("prompt", |policy| policy.default.as_str());
5849    let network_source = if config.network.is_some() {
5850        "config"
5851    } else {
5852        "default"
5853    };
5854    let trust = if crate::tui::onboarding::needs_trust(workspace) {
5855        "workspace not elevated"
5856    } else {
5857        "workspace trusted"
5858    };
5859
5860    format!(
5861        "default_mode={default_mode} ({default_mode_source}), permission_posture={permission_posture} ({permission_posture_source}), approval_policy={approval} ({approval_source}), allow_shell={allow_shell} ({allow_shell_source}), sandbox={sandbox} ({sandbox_source}), network.default={network} ({network_source}), trust={trust}"
5862    )
5863}
5864
5865fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
5866    use serde_json::json;
5867
5868    let provider = config.api_provider();
5869    // Doctor reports configured routing posture only. In particular it must
5870    // never consume an external-file grant merely to label Fleet readiness.
5871    let credential = resolve_credential_diagnostic(config);
5872    let has_credentials_or_local = credential.availability.certifies_ready();
5873    let subagents_enabled = config.subagents_enabled_for_provider(provider);
5874    let disabled_reason = if subagents_enabled {
5875        None
5876    } else {
5877        Some(
5878            config
5879                .subagents_disabled_reason()
5880                .unwrap_or("disabled for active provider"),
5881        )
5882    };
5883    let max_subagents = config.max_subagents_for_provider(provider);
5884    let launch_concurrency = config.launch_concurrency_for_provider(provider);
5885    let max_admitted = config.max_admitted_subagents_for_provider(provider);
5886    let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
5887    let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
5888    let mut built_in_members = 0usize;
5889    let mut config_members = 0usize;
5890    let mut personal_members = 0usize;
5891    let mut workspace_members = 0usize;
5892    for member in roster.members() {
5893        match member.origin {
5894            crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
5895            crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
5896            crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
5897            crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
5898        }
5899    }
5900    let roster_members = roster.members().len();
5901    let custom_members = config_members + personal_members + workspace_members;
5902    let roster_ready = roster_members > 0;
5903    let runtime_ready =
5904        subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
5905
5906    json!({
5907        "ready": has_credentials_or_local && runtime_ready && roster_ready,
5908        "provider": {
5909            "id": config.provider_identity_for(provider),
5910            "auth": {
5911                "present_or_local": has_credentials_or_local,
5912                "source": doctor_api_key_source_label(credential.source),
5913                "availability": credential.availability.label(),
5914            },
5915        },
5916        "worker_runtime": {
5917            "ready": runtime_ready,
5918            "enabled": subagents_enabled,
5919            "disabled_reason": disabled_reason,
5920            "max_subagents": max_subagents,
5921            "launch_concurrency": launch_concurrency,
5922            "max_admitted": max_admitted,
5923            "max_spawn_depth": max_spawn_depth,
5924            "host_enforced_workflow_receipts": true,
5925        },
5926        "roster": {
5927            "ready": roster_ready,
5928            "total": roster_members,
5929            "built_in": built_in_members,
5930            "config": config_members,
5931            "personal": personal_members,
5932            "workspace": workspace_members,
5933            "custom": custom_members,
5934            "starter_roster_available": built_in_members > 0,
5935            "readiness_rule": "built-in starter roster or custom roster",
5936        },
5937        "concurrency": {
5938            "launch_concurrency": launch_concurrency,
5939            "max_subagents": max_subagents,
5940            "max_admitted": max_admitted,
5941            "plan_limit_probed": false,
5942        },
5943    })
5944}
5945
5946fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
5947    use serde_json::json;
5948
5949    let provider = config.api_provider();
5950    let credential = resolve_credential_diagnostic(config);
5951    let auth_present_or_local = credential.availability.certifies_ready();
5952    let credential_help = provider.credential_help();
5953    let credential_url = credential_help
5954        .credential_url
5955        .map(crate::doctor::structural_url_authority);
5956    let credential_docs_url = credential_help
5957        .docs_url
5958        .map(crate::doctor::structural_url_authority);
5959
5960    json!({
5961        "provider": {
5962            "id": config.provider_identity_for(provider),
5963            "display": provider.display_name(),
5964        },
5965        "model": {
5966            "resolved": config.default_model(),
5967        },
5968        "auth": {
5969            "present_or_local": auth_present_or_local,
5970            "source": doctor_api_key_source_label(credential.source),
5971            "availability": credential.availability.label(),
5972            "env_vars": provider.env_vars(),
5973            "credential_mode": credential_help.acquisition.as_str(),
5974            "credential_url": credential_url,
5975            "credential_docs_url": credential_docs_url,
5976            "credential_guidance": credential_help.guidance,
5977            "oauth_only": credential_help.acquisition
5978                == codewhale_config::provider::CredentialAcquisition::OAuth,
5979        },
5980        "health": {
5981            "live_validation": false,
5982            "next_action": if auth_present_or_local {
5983                "/model"
5984            } else {
5985                "/setup provider or /provider setup <name>"
5986            },
5987        },
5988    })
5989}
5990
5991fn doctor_external_credential_consent_statuses(
5992    config: &Config,
5993) -> Vec<codewhale_config::ExternalCredentialConsentStatus> {
5994    [
5995        crate::config::ApiProvider::OpenaiCodex,
5996        crate::config::ApiProvider::Xai,
5997    ]
5998    .into_iter()
5999    .filter_map(|provider| config.external_credential_consent_status(provider))
6000    .collect()
6001}
6002
6003fn doctor_external_credential_consent_lines(config: &Config) -> Vec<String> {
6004    doctor_external_credential_consent_statuses(config)
6005        .into_iter()
6006        .flat_map(|status| {
6007            let mut lines = vec![
6008                format!(
6009                    "{}: access={}, provider={}, source={}, owner={}, path={}, version={}, state={}, ambient_path_changed={}",
6010                    status.provider,
6011                    status.access.as_str(),
6012                    status.provider,
6013                    status.source.as_str(),
6014                    status.owner,
6015                    codewhale_config::quote_os_path(&status.path),
6016                    status.consent_version,
6017                    status.route_state,
6018                    status.ambient_path_changed,
6019                ),
6020                format!("  semantics: {}", status.semantics),
6021                format!("  revoke: {}", status.revoke_command),
6022            ];
6023            if let Some(warning) = status.ambient_path_warning() {
6024                lines.push(format!("  {warning}"));
6025            }
6026            lines
6027        })
6028        .collect()
6029}
6030
6031fn doctor_external_credential_consent_json(config: &Config) -> serde_json::Value {
6032    serde_json::Value::Array(
6033        doctor_external_credential_consent_statuses(config)
6034            .into_iter()
6035            .map(|status| {
6036                serde_json::json!({
6037                    "provider": status.provider,
6038                    "access": status.access.as_str(),
6039                    "source": status.source.as_str(),
6040                    "owner": status.owner,
6041                    "path": codewhale_config::quote_os_path(&status.path),
6042                    "consent_version": status.consent_version,
6043                    "scope_valid": status.scope_valid,
6044                    "ambient_path_changed": status.ambient_path_changed,
6045                    "ambient_path_warning": status.ambient_path_warning(),
6046                    "route_state": status.route_state,
6047                    "semantics": status.semantics,
6048                    "revoke_command": status.revoke_command,
6049                })
6050            })
6051            .collect(),
6052    )
6053}
6054
6055fn doctor_setup_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6056    use serde_json::json;
6057
6058    let (state, source) = doctor_setup_state(config, workspace);
6059    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
6060    let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
6061    let approval_policy = config.approval_policy.as_deref().unwrap_or("on-request");
6062    let approval_policy_source = if config.approval_policy.is_some() {
6063        "config"
6064    } else {
6065        "default"
6066    };
6067    let allow_shell = config.interactive_allow_shell();
6068    let allow_shell_source = if config.allow_shell.is_some() {
6069        "config"
6070    } else {
6071        "interactive_default"
6072    };
6073    let sandbox_mode = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6074    let sandbox_mode_source = if config.sandbox_mode.is_some() {
6075        "config"
6076    } else {
6077        "default"
6078    };
6079    let network_default = config
6080        .network
6081        .as_ref()
6082        .map_or("prompt", |policy| policy.default.as_str());
6083    let network_source = if config.network.is_some() {
6084        "config"
6085    } else {
6086        "default"
6087    };
6088    let workspace_trusted = !crate::tui::onboarding::needs_trust(workspace);
6089    let credential = resolve_credential_diagnostic(config);
6090    let credential_ready = credential.availability.certifies_ready();
6091    let steps: Vec<_> = codewhale_config::SetupStep::ALL
6092        .into_iter()
6093        .map(|step| {
6094            let entry = state.steps.get(&step);
6095            json!({
6096                "step": setup_step_id(step),
6097                "status": setup_status_id(state.status(step)),
6098                "required": entry.is_some_and(|entry| entry.required),
6099                "version": entry.and_then(|entry| entry.version.clone()),
6100                "result": entry.and_then(|entry| entry.result.clone()),
6101            })
6102        })
6103        .collect();
6104
6105    json!({
6106        "source": source,
6107        "schema_version": state.schema_version,
6108        "inherited": state.inherited,
6109        "checkpoint_version": crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
6110        "first_run_ready": state.first_run_ready() && credential_ready,
6111        "update_ready": state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION)
6112            && credential_ready,
6113        "operate_ready": state.operate_ready() && credential_ready,
6114        "credential": {
6115            "ready": credential_ready,
6116            "source": doctor_api_key_source_label(credential.source),
6117            "availability": credential.availability.label(),
6118        },
6119        "constitution": {
6120            "choice": constitution_choice_id(state.constitution_choice),
6121            "source": constitution_source_id(state.constitution_source),
6122            "validity": constitution_validity_id(state.constitution_validity),
6123            "checkpoint_completed_for": state.constitution_checkpoint_completed_for.clone(),
6124            "language": state.constitution_language.clone(),
6125            "preview_hash_present": state.constitution_preview_hash.is_some(),
6126            "preview_version": state.constitution_preview_version,
6127            "autonomy_preference": doctor_constitution_autonomy_preference_id(),
6128        },
6129        "runtime_posture_source": runtime_posture_source_id(state.runtime_posture_source),
6130        "runtime_posture": {
6131            "source": runtime_posture_source_id(state.runtime_posture_source),
6132            "default_mode": {
6133                "value": default_mode,
6134                "source": default_mode_source,
6135            },
6136            "permission_posture": {
6137                "value": permission_posture,
6138                "source": permission_posture_source,
6139            },
6140            "approval_policy": {
6141                "value": approval_policy,
6142                "source": approval_policy_source,
6143            },
6144            "allow_shell": {
6145                "value": allow_shell,
6146                "source": allow_shell_source,
6147            },
6148            "sandbox_mode": {
6149                "value": sandbox_mode,
6150                "source": sandbox_mode_source,
6151            },
6152            "network_default": {
6153                "value": network_default,
6154                "source": network_source,
6155            },
6156            "workspace_trust": {
6157                "trusted": workspace_trusted,
6158                "source": "workspace",
6159            },
6160        },
6161        "provider_model": doctor_provider_model_report_json(config),
6162        "operate_fleet": doctor_operate_fleet_report_json(config, workspace),
6163        "consistency": doctor_setup_consistency(&state, source),
6164        "next_actions": {
6165            "constitution": "/constitution",
6166            "setup_report": "/setup report",
6167            "provider_model": "/setup provider, /provider setup <name>, or /model",
6168            "runtime_posture": "/config",
6169            "operate_fleet": "/setup fleet (readiness), /fleet setup (explicit profile authoring)",
6170            "hotbar": "/setup hotbar",
6171            "tools_mcp": "/setup tools",
6172            "remote_runtime": "/setup remote",
6173            "persistence": "/setup persistence",
6174        },
6175        "steps": steps,
6176    })
6177}
6178
6179fn setup_step_id(step: codewhale_config::SetupStep) -> &'static str {
6180    match step {
6181        codewhale_config::SetupStep::Language => "language",
6182        codewhale_config::SetupStep::ProviderModel => "provider_model",
6183        codewhale_config::SetupStep::TrustSandbox => "trust_sandbox",
6184        codewhale_config::SetupStep::ToolsMcp => "tools_mcp",
6185        codewhale_config::SetupStep::Hotbar => "hotbar",
6186        codewhale_config::SetupStep::RemoteRuntime => "remote_runtime",
6187        codewhale_config::SetupStep::Persistence => "persistence",
6188        codewhale_config::SetupStep::Constitution => "constitution",
6189        codewhale_config::SetupStep::OperateFleet => "operate_fleet",
6190        codewhale_config::SetupStep::Verification => "verification",
6191    }
6192}
6193
6194fn setup_status_id(status: codewhale_config::StepStatus) -> &'static str {
6195    match status {
6196        codewhale_config::StepStatus::NotStarted => "not_started",
6197        codewhale_config::StepStatus::Recommended => "recommended",
6198        codewhale_config::StepStatus::Optional => "optional",
6199        codewhale_config::StepStatus::Deferred => "deferred",
6200        codewhale_config::StepStatus::InProgress => "in_progress",
6201        codewhale_config::StepStatus::Verified => "verified",
6202        codewhale_config::StepStatus::NeedsAction => "needs_action",
6203        codewhale_config::StepStatus::Failed => "failed",
6204        codewhale_config::StepStatus::Skipped => "skipped",
6205    }
6206}
6207
6208fn constitution_choice_id(choice: codewhale_config::ConstitutionChoice) -> &'static str {
6209    match choice {
6210        codewhale_config::ConstitutionChoice::Unset => "unset",
6211        codewhale_config::ConstitutionChoice::Bundled => "bundled",
6212        codewhale_config::ConstitutionChoice::GuidedCustom => "guided_custom",
6213        codewhale_config::ConstitutionChoice::ExpertOverride => "expert_override",
6214        codewhale_config::ConstitutionChoice::Deferred => "deferred",
6215    }
6216}
6217
6218fn constitution_source_id(source: codewhale_config::ConstitutionSource) -> &'static str {
6219    match source {
6220        codewhale_config::ConstitutionSource::Bundled => "bundled",
6221        codewhale_config::ConstitutionSource::UserGlobal => "user_global",
6222        codewhale_config::ConstitutionSource::ExpertOverride => "expert_override",
6223    }
6224}
6225
6226fn constitution_validity_id(validity: codewhale_config::ConstitutionValidity) -> &'static str {
6227    match validity {
6228        codewhale_config::ConstitutionValidity::Unknown => "unknown",
6229        codewhale_config::ConstitutionValidity::Valid => "valid",
6230        codewhale_config::ConstitutionValidity::Invalid => "invalid",
6231        codewhale_config::ConstitutionValidity::Empty => "empty",
6232        codewhale_config::ConstitutionValidity::Unreadable => "unreadable",
6233    }
6234}
6235
6236fn runtime_posture_source_id(source: codewhale_config::RuntimePostureSource) -> &'static str {
6237    match source {
6238        codewhale_config::RuntimePostureSource::Unset => "unset",
6239        codewhale_config::RuntimePostureSource::Inherited => "inherited",
6240        codewhale_config::RuntimePostureSource::Confirmed => "confirmed",
6241    }
6242}
6243
6244/// Emit a bounded, secret-redacted JSON failure when configuration cannot be
6245/// loaded or validated. Invalid configuration must not be forced through the
6246/// normal doctor report because its route/capability facts would be misleading.
6247fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
6248    let safe_message = error
6249        .downcast_ref::<crate::config::SafeConfigDiagnostic>()
6250        .map(ToString::to_string);
6251    let report = serde_json::json!({
6252        "status": "error",
6253        "error": {
6254            "kind": "config_validation",
6255            "message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
6256        },
6257    });
6258    println!("{}", serde_json::to_string_pretty(&report)?);
6259
6260    // Keep stderr generic: the actionable, redacted error is already on
6261    // stdout, and Rust's Result termination must never redisclose a secret.
6262    bail!("doctor configuration validation failed; see JSON output")
6263}
6264
6265/// Machine-readable counterpart to `run_doctor`. This report is always
6266/// structural and offline; live probe flags conflict with `--json`.
6267fn run_doctor_json(
6268    config: &Config,
6269    workspace: &Path,
6270    config_path_override: Option<&Path>,
6271    plugins: &crate::plugins::PluginRegistry,
6272) -> Result<()> {
6273    use serde_json::json;
6274
6275    let doctor_paths = crate::doctor::DoctorPathReport::resolve(config_path_override)?;
6276    let config_path = &doctor_paths.config;
6277    let secret_backend = codewhale_secrets::diagnose_secret_backend();
6278
6279    let credential = resolve_credential_diagnostic(config);
6280
6281    let mcp_config_path = config.mcp_config_path();
6282    let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
6283    let mcp_present = mcp_config_path.exists();
6284    let project_mcp_present = project_mcp_config_path.exists();
6285    let mcp_summary = match crate::mcp::load_config_with_workspace_and_plugins(
6286        &mcp_config_path,
6287        workspace,
6288        plugins,
6289    ) {
6290        Ok(cfg) => {
6291            let servers: Vec<serde_json::Value> = cfg
6292                .servers
6293                .iter()
6294                .map(|(name, server)| doctor_mcp_server_json(name, server))
6295                .collect();
6296            json!({
6297                "config_path": mcp_config_path.display().to_string(),
6298                "present": mcp_present,
6299                "project_config_path": project_mcp_config_path.display().to_string(),
6300                "project_present": project_mcp_present,
6301                "probe_scope": "configuration",
6302                "live_health_checked": false,
6303                "servers": servers,
6304            })
6305        }
6306        Err(_) => json!({
6307            "config_path": mcp_config_path.display().to_string(),
6308            "present": mcp_present,
6309            "project_config_path": project_mcp_config_path.display().to_string(),
6310            "project_present": project_mcp_present,
6311            "probe_scope": "configuration",
6312            "live_health_checked": false,
6313            "servers": [],
6314            "error": "configuration_unavailable_details_omitted",
6315        }),
6316    };
6317
6318    let global_skills_dir = config.skills_dir();
6319    let agents_skills_dir = workspace.join(".agents").join("skills");
6320    let local_skills_dir = workspace.join("skills");
6321    let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
6322    // #432: cross-tool skill discovery dirs surface in the JSON
6323    // report so external dashboards can see whether any
6324    // `.opencode/skills/`, `.claude/skills/`, `.cursor/skills/`, or
6325    // global agentskills.io content is contributing to the merged catalogue.
6326    let opencode_skills_dir = workspace.join(".opencode").join("skills");
6327    let claude_skills_dir = workspace.join(".claude").join("skills");
6328    let selected_skills_dir = if agents_skills_dir.exists() {
6329        agents_skills_dir.clone()
6330    } else if local_skills_dir.exists() {
6331        local_skills_dir.clone()
6332    } else if config.skills_dir.is_none()
6333        && let Some(global_agents) = agents_global_skills_dir.as_ref()
6334        && global_agents.exists()
6335    {
6336        global_agents.clone()
6337    } else {
6338        global_skills_dir.clone()
6339    };
6340    let agents_global_summary = agents_global_skills_dir
6341        .as_ref()
6342        .map(|path| {
6343            json!({
6344                "path": path.display().to_string(),
6345                "present": path.exists(),
6346                "count": skills_count_for(path),
6347            })
6348        })
6349        .unwrap_or_else(|| {
6350            json!({
6351                "path": null,
6352                "present": false,
6353                "count": 0,
6354            })
6355        });
6356
6357    let tools_dir = default_tools_dir();
6358    let plugins_dir = default_plugins_dir();
6359
6360    // Memory feature state (#489). Operators ask "is memory on?" and
6361    // "where does it live?" — surface both here so the question can be
6362    // answered without booting the TUI. Both inputs are checked: the
6363    // config flag and the env-var override that the runtime would
6364    // honour. (The dedicated `Config::memory_enabled()` accessor lives
6365    // on the memory-MVP branch (#518); this duplicates the same logic
6366    // until the two PRs land and it can be replaced with a single
6367    // method call.)
6368    let memory_path = config.memory_path();
6369    let memory_enabled_env = std::env::var("CODEWHALE_MEMORY")
6370        .or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
6371        .ok()
6372        .map(|raw| {
6373            matches!(
6374                raw.trim().to_ascii_lowercase().as_str(),
6375                "1" | "on" | "true" | "yes" | "y" | "enabled"
6376            )
6377        })
6378        .unwrap_or(false);
6379    let memory_summary = json!({
6380        // The MVP feature is opt-in by default; this defaults to false
6381        // on branches without the [memory] section in `Config`.
6382        "enabled": memory_enabled_env,
6383        "path": memory_path.display().to_string(),
6384        "file_present": memory_path.exists(),
6385    });
6386    let api_target = doctor_api_target(config);
6387    let strict_tool_mode = doctor_strict_tool_mode_status(config);
6388    let tls_status = doctor_tls_status(config);
6389    let (code_home, legacy_home) = doctor_state_roots();
6390    let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
6391    let session_recovery = doctor_session_recovery_report(
6392        &code_home,
6393        &legacy_home,
6394        codewhale_config::codewhale_home_is_explicit(),
6395    );
6396
6397    let stash = crate::composer_stash::diagnostic_stash_report();
6398    let report = json!({
6399        "version": env!("CARGO_PKG_VERSION"),
6400        "config_path": config_path.display().to_string(),
6401        "config_present": config_path.exists(),
6402        "paths": doctor_paths,
6403        "secret_backend": secret_backend,
6404        "workspace": workspace.display().to_string(),
6405        "legacy_state": doctor_legacy_state_json(
6406            &code_home,
6407            &legacy_home,
6408            &legacy_state_report,
6409            &session_recovery,
6410        ),
6411        "setup": doctor_setup_report_json(config, workspace),
6412        "api_key": {
6413            "source": doctor_api_key_source_label(credential.source),
6414            "availability": credential.availability.label(),
6415        },
6416        "external_credentials": doctor_external_credential_consent_json(config),
6417        "base_url": crate::doctor::structural_url_authority(&api_target.base_url),
6418        "default_text_model": api_target.model,
6419        // DGF-01: this report describes the route a session launched now
6420        // would resolve; a running session keeps its launch-time route.
6421        "route_scope": "configured_at_launch",
6422        "model_resolution": match api_target.resolution {
6423            DoctorModelResolution::Resolved => "resolved",
6424            DoctorModelResolution::ConfiguredOnly => "configured_unresolved",
6425        },
6426        "route": doctor_route_report(config),
6427        "strict_tool_mode": doctor_strict_tool_mode_report_json(&strict_tool_mode),
6428        "tls": {
6429            "certificate_verification": tls_status.certificate_verification,
6430            "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
6431            "provider": tls_status.provider,
6432            "message": tls_status.message,
6433        },
6434        "search_provider": doctor_search_provider_json(config),
6435        "memory": memory_summary,
6436        "mcp": mcp_summary,
6437        "skills": {
6438            "selected": selected_skills_dir.display().to_string(),
6439            "global": {
6440                "path": global_skills_dir.display().to_string(),
6441                "present": global_skills_dir.exists(),
6442                "count": skills_count_for(&global_skills_dir),
6443            },
6444            "agents": {
6445                "path": agents_skills_dir.display().to_string(),
6446                "present": agents_skills_dir.exists(),
6447                "count": skills_count_for(&agents_skills_dir),
6448            },
6449            "agents_global": agents_global_summary,
6450            "local": {
6451                "path": local_skills_dir.display().to_string(),
6452                "present": local_skills_dir.exists(),
6453                "count": skills_count_for(&local_skills_dir),
6454            },
6455            "opencode": {
6456                "path": opencode_skills_dir.display().to_string(),
6457                "present": opencode_skills_dir.exists(),
6458                "count": skills_count_for(&opencode_skills_dir),
6459            },
6460            "claude": {
6461                "path": claude_skills_dir.display().to_string(),
6462                "present": claude_skills_dir.exists(),
6463                "count": skills_count_for(&claude_skills_dir),
6464            },
6465        },
6466        "tools": {
6467            "path": tools_dir.display().to_string(),
6468            "present": tools_dir.exists(),
6469            "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
6470        },
6471        "plugins": {
6472            "path": plugins_dir.display().to_string(),
6473            "present": plugins_dir.exists(),
6474            "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
6475        },
6476        "storage": {
6477            "spillover": {
6478                "path": crate::tools::truncate::spillover_root()
6479                    .map(|p| p.display().to_string())
6480                    .unwrap_or_default(),
6481                "present": crate::tools::truncate::spillover_root()
6482                    .is_some_and(|p| p.is_dir()),
6483                "count": crate::tools::truncate::spillover_root()
6484                    .filter(|p| p.is_dir())
6485                    .map(|p| count_dir_entries(&p))
6486                    .unwrap_or(0),
6487            },
6488            "stash": {
6489                "path": stash
6490                    .path
6491                    .as_ref()
6492                    .map(|path| path.display().to_string())
6493                    .unwrap_or_default(),
6494                "present": stash.present,
6495                "count": stash.count,
6496                "error": stash.error,
6497            },
6498        },
6499        "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
6500            config.prefer_bwrap.unwrap_or(false),
6501        ) {
6502            Some(kind) => json!({"available": true, "kind": kind.to_string()}),
6503            None => json!({"available": false, "kind": null}),
6504        },
6505        "platform": {
6506            "os": std::env::consts::OS,
6507            "arch": std::env::consts::ARCH,
6508        },
6509        "api_connectivity": {
6510            "checked": false,
6511            "status": "not_probed",
6512            "note": "JSON doctor is offline; use `codewhale doctor --probe-api` or `--probe-local` for an explicit live check.",
6513        },
6514        "capability": provider_capability_report(config),
6515    });
6516
6517    println!("{}", serde_json::to_string_pretty(&report)?);
6518    Ok(())
6519}
6520
6521fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
6522    let report = crate::context_report::build_headless_context_report(config, workspace);
6523    println!("{}", crate::context_report::context_report_json(&report));
6524    Ok(())
6525}
6526
6527/// Build the `capability` section for the machine-readable doctor report.
6528///
6529/// Returns a JSON value with the resolved provider, resolved model, context
6530/// window, max output, thinking support, cache telemetry support, and request
6531/// payload mode.
6532fn provider_capability_report(config: &Config) -> serde_json::Value {
6533    use serde_json::json;
6534
6535    let provider = config.api_provider();
6536    let configured_model = config.default_model();
6537    let route_result =
6538        crate::route_runtime::resolve_runtime_route(config, provider, Some(&configured_model));
6539    let route_error = route_result
6540        .is_err()
6541        .then_some("route_resolution_failed_details_omitted");
6542    let route = route_result.ok();
6543    let resolved_model = route
6544        .as_ref()
6545        .map_or(configured_model.as_str(), |route| route.model.as_str());
6546    let cap = crate::config::provider_capability(provider, resolved_model);
6547    let route_profile = route.as_ref().map(|route| {
6548        crate::model_profile::resolved_capability_profile_for_route(
6549            provider,
6550            resolved_model,
6551            route.candidate.capabilities(),
6552            route.candidate.limits(),
6553        )
6554    });
6555    let context_window = route
6556        .as_ref()
6557        .map_or(cap.context_window, |route| route.context_window.tokens);
6558    let context_window_source = route.as_ref().map_or(
6559        crate::route_runtime::ContextWindowSource::Fallback.label(),
6560        |route| route.context_window.source.label(),
6561    );
6562    // `null` when neither the resolved route nor the compatibility matrix
6563    // publishes an output ceiling — doctor must not invent one.
6564    let max_output = route_profile
6565        .as_ref()
6566        .and_then(|profile| profile.max_output)
6567        .or(cap.max_output);
6568    let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
6569        crate::config::is_exact_kimi_code_k3_route(
6570            provider,
6571            &route.candidate.endpoint().base_url,
6572            route.candidate.wire_model_id().as_str(),
6573        )
6574    });
6575    let thinking_supported = is_exact_kimi_code_k3
6576        || route_profile
6577            .as_ref()
6578            .map_or(cap.thinking_supported, |profile| {
6579                profile.supports_reasoning()
6580            });
6581    let cache_telemetry_supported = route_profile
6582        .as_ref()
6583        .map_or(cap.cache_telemetry_supported, |profile| {
6584            profile.prompt_caching.is_supported()
6585        });
6586    let request_payload_mode = route_profile
6587        .as_ref()
6588        .map_or(cap.request_payload_mode, |profile| {
6589            profile.request_payload_mode
6590        });
6591    let alias_deprecation = config.active_deepseek_alias_deprecation();
6592
6593    json!({
6594        "resolved_provider": config.provider_identity_for(provider),
6595        "resolved_model": resolved_model,
6596        "context_window": context_window,
6597        "context_window_source": context_window_source,
6598        "max_output": max_output,
6599        "thinking_supported": thinking_supported,
6600        "cache_telemetry_supported": cache_telemetry_supported,
6601        "request_payload_mode": serde_json::to_value(request_payload_mode).unwrap_or_default(),
6602        "route_error": route_error,
6603        "alias_deprecation": alias_deprecation,
6604    })
6605}
6606
6607fn doctor_route_report(config: &Config) -> serde_json::Value {
6608    use serde_json::json;
6609
6610    let target = doctor_api_target(config);
6611    let provider = config.api_provider();
6612    let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
6613    let route_result =
6614        crate::route_runtime::resolve_runtime_route(config, provider, Some(&target.model));
6615    let route_error = route_result
6616        .is_err()
6617        .then_some("route_resolution_failed_details_omitted");
6618    let context_window = route_result
6619        .ok()
6620        .map(|route| {
6621        json!({
6622            "tokens": route.context_window.tokens,
6623            "source": route.context_window.source.label(),
6624        })
6625    })
6626    .unwrap_or_else(|| {
6627        json!({
6628            "tokens": crate::config::provider_capability(provider, &target.model).context_window,
6629            "source": crate::route_runtime::ContextWindowSource::Fallback.label(),
6630        })
6631    });
6632
6633    let route_identity =
6634        crate::config::moonshot_k3_route_display_name(&target.base_url, &target.model);
6635    let credential = resolve_credential_diagnostic(config);
6636
6637    json!({
6638        "provider": target.provider,
6639        "provider_source": doctor_provider_source(config),
6640        "provider_config_table": doctor_provider_config_table(config, provider),
6641        "model": target.model,
6642        "route_identity": route_identity,
6643        "wire_protocol": doctor_wire_protocol(provider),
6644        "base_url": {
6645            "redacted": redacted_base_url,
6646            "class": doctor_base_url_class(provider, &target.base_url),
6647            "fingerprint": crate::utils::redacted_identifier_for_log(&target.base_url),
6648        },
6649        "auth": {
6650            "scheme": doctor_auth_scheme(config),
6651            "source": doctor_api_key_source_label(credential.source),
6652            "availability": credential.availability.label(),
6653        },
6654        "context_window": context_window,
6655        "route_error": route_error,
6656    })
6657}
6658
6659fn doctor_provider_config_table(config: &Config, provider: crate::config::ApiProvider) -> String {
6660    if provider != crate::config::ApiProvider::Custom {
6661        return provider_config_table_key(provider).to_string();
6662    }
6663    if config.uses_legacy_literal_custom_route() {
6664        "root (legacy literal custom)".to_string()
6665    } else {
6666        format!("providers.{}", config.provider_identity_for(provider))
6667    }
6668}
6669
6670fn doctor_provider_source(config: &Config) -> &'static str {
6671    if config
6672        .provider
6673        .as_ref()
6674        .is_some_and(|provider| !provider.trim().is_empty())
6675    {
6676        "config"
6677    } else {
6678        "default"
6679    }
6680}
6681
6682fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str {
6683    let policy = provider
6684        .metadata()
6685        .map(|metadata| metadata.wire_policy())
6686        .unwrap_or(codewhale_config::provider::WirePolicy::Fixed(
6687            codewhale_config::provider::WireFormat::ChatCompletions,
6688        ));
6689    match policy.fixed() {
6690        Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions",
6691        Some(codewhale_config::provider::WireFormat::Responses) => "responses",
6692        Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages",
6693        None => "model_aware",
6694    }
6695}
6696
6697fn doctor_base_url_class(provider: crate::config::ApiProvider, base_url: &str) -> &'static str {
6698    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
6699    if normalized.starts_with("http://localhost")
6700        || normalized.starts_with("http://127.0.0.1")
6701        || normalized.starts_with("http://[::1]")
6702    {
6703        return "local";
6704    }
6705    if normalized
6706        == provider
6707            .default_base_url()
6708            .trim_end_matches('/')
6709            .to_ascii_lowercase()
6710    {
6711        "default"
6712    } else {
6713        "custom"
6714    }
6715}
6716
6717fn doctor_auth_scheme(config: &Config) -> &'static str {
6718    let provider = config.api_provider();
6719    if crate::config::auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
6720    {
6721        "none"
6722    } else if provider == crate::config::ApiProvider::Anthropic {
6723        "x-api-key"
6724    } else if provider == crate::config::ApiProvider::XiaomiMimo
6725        && doctor_xiaomi_mimo_base_url_uses_token_plan(&config.deepseek_base_url())
6726    {
6727        "api-key"
6728    } else if provider == crate::config::ApiProvider::XiaomiMimo {
6729        // The alternate MiMo scheme depends on a credential prefix. Ordinary
6730        // doctor does not read credentials merely to make this label precise.
6731        "unknown"
6732    } else if matches!(
6733        provider,
6734        crate::config::ApiProvider::Sglang
6735            | crate::config::ApiProvider::Vllm
6736            | crate::config::ApiProvider::Ollama
6737    ) {
6738        "optional_bearer"
6739    } else {
6740        "bearer"
6741    }
6742}
6743
6744fn doctor_xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
6745    let normalized = base_url.trim_end_matches('/');
6746    [
6747        crate::config::XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
6748        crate::config::XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
6749        crate::config::XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
6750    ]
6751    .iter()
6752    .any(|candidate| normalized.eq_ignore_ascii_case(candidate.trim_end_matches('/')))
6753}
6754
6755fn doctor_api_key_source_label(source: ApiKeySource) -> &'static str {
6756    match source {
6757        ApiKeySource::ConfigDeclared => "config_declared",
6758        ApiKeySource::EnvDeclared => "env_declared",
6759        ApiKeySource::ExternalAuthDeclared => "external_auth_declared",
6760        ApiKeySource::SecretStoreUnprobed => "secret_store_unprobed",
6761        ApiKeySource::SecretStoreUnavailable => "secret_store_unavailable",
6762        ApiKeySource::OAuth => "oauth_unprobed",
6763        ApiKeySource::ExternalConsent => "external_consent",
6764        ApiKeySource::NoAuth => "none",
6765        ApiKeySource::LocalRuntime => "local_runtime",
6766        ApiKeySource::Unknown => "unknown",
6767    }
6768}
6769
6770fn doctor_search_provider_line(config: &Config) -> String {
6771    let search_provider = config.search_provider_resolution();
6772    let switch_hint = if matches!(
6773        (search_provider.provider, search_provider.source),
6774        (
6775            crate::config::SearchProvider::Firecrawl,
6776            crate::config::SearchProviderSource::Default
6777        )
6778    ) {
6779        "; set [search] provider = \"baidu\" | \"metaso\" | \"volcengine\" for China"
6780    } else {
6781        ""
6782    };
6783
6784    format!(
6785        "search_provider: {} (source: {}{})",
6786        search_provider.provider.as_str(),
6787        search_provider.source.as_str(),
6788        switch_hint
6789    )
6790}
6791
6792fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
6793    use serde_json::json;
6794
6795    let search_provider = config.search_provider_resolution();
6796    json!({
6797        "provider": search_provider.provider.as_str(),
6798        "source": search_provider.source.as_str(),
6799    })
6800}
6801
6802/// Whether the model in a [`DoctorApiTarget`] is the wire id the engine
6803/// resolver produced, or only the raw configured value because resolution
6804/// failed. Doctor never prints resolution error details — the JSON route
6805/// report already redacts them for the same reason.
6806#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6807enum DoctorModelResolution {
6808    Resolved,
6809    ConfiguredOnly,
6810}
6811
6812#[derive(Debug, Clone, PartialEq, Eq)]
6813struct DoctorApiTarget {
6814    provider: String,
6815    base_url: String,
6816    model: String,
6817    resolution: DoctorModelResolution,
6818}
6819
6820#[derive(Debug, Clone, PartialEq, Eq)]
6821struct DoctorStrictToolModeStatus {
6822    enabled: bool,
6823    status: &'static str,
6824    function_strict_sent: bool,
6825    message: String,
6826    recommended_base_url: Option<String>,
6827}
6828
6829fn doctor_api_target(config: &Config) -> DoctorApiTarget {
6830    let provider = config.api_provider();
6831    // Report the model through the same resolver the live client uses at
6832    // session launch (`client.rs` → `resolve_runtime_route`), so doctor's
6833    // answer matches what a session started now would actually serve —
6834    // saved provider models, alias normalization, and roster preference
6835    // included — instead of re-deriving a config default that can diverge
6836    // from the engine (DGF-01, dogfood 2026-08-02).
6837    let (model, resolution) =
6838        match crate::route_runtime::resolve_runtime_route(config, provider, None) {
6839            Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved),
6840            Err(_) => (
6841                config.default_model(),
6842                DoctorModelResolution::ConfiguredOnly,
6843            ),
6844        };
6845    DoctorApiTarget {
6846        provider: config.provider_identity_for(provider),
6847        base_url: config.deepseek_base_url(),
6848        model,
6849        resolution,
6850    }
6851}
6852
6853fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus {
6854    if !config.strict_tool_mode.unwrap_or(false) {
6855        return DoctorStrictToolModeStatus {
6856            enabled: false,
6857            status: "disabled",
6858            function_strict_sent: false,
6859            message: "disabled".to_string(),
6860            recommended_base_url: None,
6861        };
6862    }
6863
6864    let target = doctor_api_target(config);
6865    match known_deepseek_base_url_kind(&target.base_url) {
6866        Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus {
6867            enabled: true,
6868            status: "ready",
6869            function_strict_sent: true,
6870            message: "enabled; DeepSeek strict schemas use the beta endpoint".to_string(),
6871            recommended_base_url: None,
6872        },
6873        Some(DeepSeekBaseUrlKind::NonBeta) => {
6874            let recommended = recommended_strict_base_url(config, &target.base_url);
6875            DoctorStrictToolModeStatus {
6876                enabled: true,
6877                status: "fallback_non_beta",
6878                function_strict_sent: false,
6879                message:
6880                    "enabled, but function.strict is stripped for this non-beta DeepSeek endpoint"
6881                        .to_string(),
6882                recommended_base_url: Some(recommended.to_string()),
6883            }
6884        }
6885        None => DoctorStrictToolModeStatus {
6886            enabled: true,
6887            status: "custom_endpoint",
6888            function_strict_sent: true,
6889            message: "enabled; function.strict will be sent to this custom endpoint".to_string(),
6890            recommended_base_url: None,
6891        },
6892    }
6893}
6894
6895fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> serde_json::Value {
6896    serde_json::json!({
6897        "enabled": status.enabled,
6898        "status": status.status,
6899        "function_strict_sent": status.function_strict_sent,
6900        "message": status.message,
6901        "recommended_base_url": status
6902            .recommended_base_url
6903            .as_deref()
6904            .map(crate::doctor::structural_url_authority),
6905    })
6906}
6907
6908#[derive(Debug, Clone, PartialEq, Eq)]
6909struct DoctorTlsStatus {
6910    certificate_verification: bool,
6911    insecure_skip_tls_verify: bool,
6912    provider: String,
6913    message: String,
6914}
6915
6916fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
6917    let provider = config.provider_identity_for(config.api_provider());
6918    let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
6919    let message = if insecure_skip_tls_verify {
6920        format!(
6921            "TLS certificate verification cannot be disabled for provider {provider}; use SSL_CERT_FILE with a trusted custom CA bundle"
6922        )
6923    } else {
6924        "TLS certificate verification enabled".to_string()
6925    };
6926    DoctorTlsStatus {
6927        certificate_verification: true,
6928        insecure_skip_tls_verify,
6929        provider,
6930        message,
6931    }
6932}
6933
6934#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6935enum DeepSeekBaseUrlKind {
6936    Beta,
6937    NonBeta,
6938}
6939
6940fn known_deepseek_base_url_kind(base_url: &str) -> Option<DeepSeekBaseUrlKind> {
6941    let normalized = base_url.trim_end_matches('/');
6942    if normalized.eq_ignore_ascii_case("https://api.deepseek.com/beta")
6943        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/beta")
6944    {
6945        Some(DeepSeekBaseUrlKind::Beta)
6946    } else if normalized.eq_ignore_ascii_case("https://api.deepseek.com")
6947        || normalized.eq_ignore_ascii_case("https://api.deepseek.com/v1")
6948        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com")
6949        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/v1")
6950    {
6951        Some(DeepSeekBaseUrlKind::NonBeta)
6952    } else {
6953        None
6954    }
6955}
6956
6957fn recommended_strict_base_url(_config: &Config, _base_url: &str) -> &'static str {
6958    crate::config::DEFAULT_DEEPSEEK_BASE_URL
6959}
6960
6961fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
6962    let target = doctor_api_target(config);
6963    let mut lines = vec![format!(
6964        "Connection timed out while reaching {}.",
6965        crate::doctor::structural_url_authority(&target.base_url)
6966    )];
6967
6968    match config.api_provider() {
6969        crate::config::ApiProvider::Deepseek
6970            if target.base_url.contains("api.deepseek.com")
6971                && !target.base_url.contains("api.deepseeki.com") =>
6972        {
6973            lines.push(
6974                "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.codewhale/config.toml and rerun `codewhale doctor`."
6975                    .to_string(),
6976            );
6977        }
6978        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
6979            lines.push(
6980                "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
6981                    .to_string(),
6982            );
6983        }
6984        _ => {
6985            lines.push(
6986                "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
6987                    .to_string(),
6988            );
6989        }
6990    }
6991
6992    lines.push(
6993        "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
6994            .to_string(),
6995    );
6996    lines
6997}
6998
6999fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
7000    match command.command {
7001        FeaturesSubcommand::List => {
7002            print!("{}", render_feature_table(&config.features()));
7003            Ok(())
7004        }
7005    }
7006}
7007
7008async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
7009    use crate::client::DeepSeekClient;
7010
7011    let client = DeepSeekClient::new(config)?;
7012    let mut models = client.list_models().await?;
7013    models.sort_by(|a, b| a.id.cmp(&b.id));
7014
7015    if args.json {
7016        println!("{}", serde_json::to_string_pretty(&models)?);
7017        return Ok(());
7018    }
7019
7020    if models.is_empty() {
7021        println!("No models returned by the API.");
7022        return Ok(());
7023    }
7024
7025    let default_model = config.default_model();
7026
7027    println!("Available models (default: {default_model})");
7028    for model in models {
7029        let marker = if model.id == default_model { "*" } else { " " };
7030        if let Some(owner) = model.owned_by {
7031            println!("{marker} {} ({owner})", model.id);
7032        } else {
7033            println!("{marker} {}", model.id);
7034        }
7035    }
7036
7037    Ok(())
7038}
7039
7040async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
7041    use crate::client::{DeepSeekClient, SpeechSynthesisRequest};
7042    use crate::config::ApiProvider;
7043    use crate::tools::speech::{
7044        DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions,
7045        default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri,
7046        infer_speech_model, normalize_speech_format,
7047    };
7048
7049    let SpeechArgs {
7050        text,
7051        output,
7052        output_dir,
7053        model,
7054        voice,
7055        instruction,
7056        voice_prompt,
7057        clone_voice,
7058        format,
7059        json: json_output,
7060    } = args;
7061
7062    if config.api_provider() != ApiProvider::XiaomiMimo {
7063        bail!(
7064            "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
7065            config.api_provider().as_str()
7066        );
7067    }
7068
7069    if text.trim().is_empty() {
7070        bail!("Speech text cannot be empty");
7071    }
7072    let voice_is_data_uri = voice
7073        .as_deref()
7074        .map(str::trim)
7075        .is_some_and(|value| value.starts_with("data:audio/"));
7076    if clone_voice.is_some() && voice.is_some() {
7077        bail!("Use either --clone-voice or --voice for cloned voice data, not both");
7078    }
7079    let model = infer_speech_model(
7080        model.as_deref(),
7081        clone_voice.is_some() || voice_is_data_uri,
7082        voice_prompt.is_some(),
7083    );
7084    let model_lower = model.to_ascii_lowercase();
7085    if !model_lower.contains("tts") {
7086        bail!(
7087            "speech requires a TTS model (examples: {}); got {model}",
7088            SPEECH_MODEL_EXAMPLES.join(", ")
7089        );
7090    }
7091    let is_voice_design = model_lower.contains("voicedesign");
7092    let is_voice_clone = model_lower.contains("voiceclone");
7093
7094    let instruction = combine_speech_instructions(instruction, voice_prompt);
7095    if is_voice_design
7096        && instruction
7097            .as_deref()
7098            .is_none_or(|value| value.trim().is_empty())
7099    {
7100        bail!(
7101            "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice"
7102        );
7103    }
7104
7105    let voice = if let Some(clone_path) = clone_voice {
7106        Some(encode_voice_clone_sample_data_uri(&clone_path)?)
7107    } else if is_voice_design {
7108        None
7109    } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) {
7110        Some(value)
7111    } else if is_voice_clone {
7112        bail!("mimo-v2.5-tts-voiceclone requires --clone-voice <mp3|wav> or --voice <data-uri>");
7113    } else {
7114        Some(DEFAULT_VOICE.to_string())
7115    };
7116    let format = normalize_speech_format(&format).with_context(|| {
7117        format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)")
7118    })?;
7119    let output = output.unwrap_or_else(|| {
7120        output_dir
7121            .or_else(|| config.speech_output_dir())
7122            .unwrap_or_default()
7123            .join(default_speech_output_name(&format))
7124    });
7125
7126    let client = DeepSeekClient::new(config)?;
7127    let response = client
7128        .synthesize_speech(SpeechSynthesisRequest {
7129            model: model.clone(),
7130            text,
7131            instruction,
7132            audio_format: format.clone(),
7133            voice,
7134        })
7135        .await?;
7136
7137    if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
7138        std::fs::create_dir_all(parent)
7139            .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
7140    }
7141    std::fs::write(&output, &response.audio_bytes)
7142        .with_context(|| format!("Failed to write audio file {}", output.display()))?;
7143
7144    if json_output {
7145        println!(
7146            "{}",
7147            serde_json::to_string_pretty(&serde_json::json!({
7148                "mode": "speech",
7149                "success": true,
7150                "model": response.model,
7151                "format": response.audio_format,
7152                "output": output.display().to_string(),
7153                "bytes": response.audio_bytes.len(),
7154                "voice": response.voice.as_deref().map(describe_speech_voice),
7155                "transcript": response.transcript,
7156            }))?
7157        );
7158    } else {
7159        println!(
7160            "Generated speech: {} ({} bytes, model: {}, format: {})",
7161            output.display(),
7162            response.audio_bytes.len(),
7163            response.model,
7164            response.audio_format
7165        );
7166    }
7167
7168    Ok(())
7169}
7170
7171#[cfg(test)]
7172mod speech_cli_tests {
7173    use super::*;
7174    use crate::tools::speech::{
7175        default_speech_output_name, infer_speech_model, normalize_speech_format,
7176    };
7177
7178    #[test]
7179    fn normalizes_documented_speech_formats() {
7180        assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav"));
7181        assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16"));
7182        assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16"));
7183        assert_eq!(normalize_speech_format("flac"), None);
7184    }
7185
7186    #[test]
7187    fn default_speech_output_tracks_requested_format() {
7188        assert_eq!(
7189            PathBuf::from(default_speech_output_name("mp3")),
7190            PathBuf::from("speech.mp3")
7191        );
7192        assert_eq!(
7193            PathBuf::from("audio").join(default_speech_output_name("pcm")),
7194            PathBuf::from("audio").join("speech.pcm16")
7195        );
7196    }
7197
7198    #[test]
7199    fn speech_command_parses_cli_passthrough_smoke() {
7200        let cli = Cli::try_parse_from([
7201            "codewhale-tui",
7202            "speech",
7203            "hello",
7204            "--model",
7205            "tts",
7206            "--format",
7207            "pcm",
7208            "--output-dir",
7209            "audio",
7210            "--voice",
7211            "Mia",
7212        ])
7213        .expect("speech command parses");
7214
7215        let Some(Commands::Speech(args)) = cli.command else {
7216            panic!("expected speech command");
7217        };
7218        assert_eq!(args.text, "hello");
7219        assert_eq!(
7220            infer_speech_model(args.model.as_deref(), false, false),
7221            "mimo-v2.5-tts"
7222        );
7223        assert_eq!(
7224            normalize_speech_format(&args.format).as_deref(),
7225            Some("pcm16")
7226        );
7227        assert_eq!(args.output_dir, Some(PathBuf::from("audio")));
7228        assert_eq!(args.voice.as_deref(), Some("Mia"));
7229    }
7230}
7231
7232/// Test API connectivity by making a minimal request
7233async fn test_api_connectivity(config: &Config) -> Result<()> {
7234    use crate::client::DeepSeekClient;
7235    use crate::models::{ContentBlock, Message, MessageRequest};
7236
7237    let client = DeepSeekClient::new(config)?;
7238    let model = client.model().to_string();
7239
7240    // Minimal request: single word prompt, 1 max token
7241    let request = MessageRequest {
7242        model: model.clone(),
7243        messages: vec![Message {
7244            role: "user".to_string(),
7245            content: vec![ContentBlock::Text {
7246                text: "hi".to_string(),
7247                cache_control: None,
7248            }],
7249        }],
7250        max_tokens: 1,
7251        system: None,
7252        tools: None,
7253        tool_choice: None,
7254        metadata: None,
7255        thinking: None,
7256        // This is a one-token transport probe, not a reasoning task.
7257        reasoning_effort: Some("off".to_string()),
7258        stream: Some(false),
7259        temperature: None,
7260        top_p: None,
7261    };
7262
7263    // Use tokio timeout to catch hanging requests
7264    let timeout_duration = std::time::Duration::from_secs(15);
7265    match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
7266        Ok(Ok(_response)) => Ok(()),
7267        Ok(Err(e)) => Err(e),
7268        Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
7269    }
7270}
7271
7272fn rustc_version() -> String {
7273    let Some(mut cmd) = crate::dependencies::RustC::command() else {
7274        return "unknown".to_string();
7275    };
7276    let Ok(output) = cmd.arg("--version").output() else {
7277        return "unknown".to_string();
7278    };
7279    String::from_utf8(output.stdout)
7280        .map(|s| s.trim().to_string())
7281        .unwrap_or_else(|_| "unknown".to_string())
7282}
7283
7284/// List saved sessions
7285fn sessions_resume_command() -> &'static str {
7286    "codewhale resume"
7287}
7288
7289fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
7290    use crate::palette;
7291    use colored::Colorize;
7292    use session_manager::{SessionManager, format_session_line};
7293
7294    let (action_r, action_g, action_b) = palette::WHALE_ACTION_RGB;
7295    let (human_r, human_g, human_b) = palette::WHALE_HUMAN_RGB;
7296    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7297    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7298
7299    let manager = SessionManager::default_location()?;
7300
7301    let sessions = if let Some(query) = search {
7302        manager.search_sessions(&query)?
7303    } else {
7304        manager.list_sessions()?
7305    };
7306
7307    if sessions.is_empty() {
7308        println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
7309        println!(
7310            "Start a new session with: {}",
7311            "codewhale".truecolor(human_r, human_g, human_b)
7312        );
7313        return Ok(());
7314    }
7315
7316    println!(
7317        "{}",
7318        "Saved Sessions"
7319            .truecolor(action_r, action_g, action_b)
7320            .bold()
7321    );
7322    println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
7323    println!();
7324
7325    for (i, session) in sessions.iter().take(limit).enumerate() {
7326        let line = format_session_line(session);
7327        if i == 0 {
7328            println!("  {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
7329        } else {
7330            println!("    {line}");
7331        }
7332    }
7333
7334    let total = sessions.len();
7335    if total > limit {
7336        println!();
7337        println!(
7338            "  {} more session(s). Use --limit to show more.",
7339            total - limit
7340        );
7341    }
7342
7343    println!();
7344    println!(
7345        "Resume with: {} {}",
7346        sessions_resume_command().truecolor(action_r, action_g, action_b),
7347        "<session-id>".dimmed()
7348    );
7349    println!(
7350        "Continue latest in this workspace: {}",
7351        "codewhale --continue".truecolor(action_r, action_g, action_b)
7352    );
7353
7354    Ok(())
7355}
7356
7357/// Initialize a new project with AGENTS.md
7358fn init_project() -> Result<()> {
7359    use crate::palette;
7360    use colored::Colorize;
7361    use project_context::create_default_agents_md;
7362
7363    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7364    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7365    let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
7366
7367    let workspace = std::env::current_dir()?;
7368    let agents_path = workspace.join("AGENTS.md");
7369
7370    if agents_path.exists() {
7371        println!(
7372            "{} AGENTS.md already exists at {}",
7373            "!".truecolor(sky_r, sky_g, sky_b),
7374            agents_path.display()
7375        );
7376        return Ok(());
7377    }
7378
7379    match create_default_agents_md(&workspace) {
7380        Ok(path) => {
7381            println!(
7382                "{} Created {}",
7383                "✓".truecolor(aqua_r, aqua_g, aqua_b),
7384                path.display()
7385            );
7386            println!();
7387            println!("Edit this file to customize how the AI agent works with your project.");
7388            println!("The instructions will be loaded automatically when you run codewhale.");
7389        }
7390        Err(e) => {
7391            println!(
7392                "{} Failed to create AGENTS.md: {}",
7393                "✗".truecolor(red_r, red_g, red_b),
7394                e
7395            );
7396        }
7397    }
7398
7399    Ok(())
7400}
7401
7402fn resolve_workspace(cli: &Cli) -> PathBuf {
7403    cli.workspace
7404        .clone()
7405        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
7406}
7407
7408fn load_config_from_cli(cli: &Cli) -> Result<Config> {
7409    load_config_from_cli_with_effective_profile(cli).map(|(config, _)| config)
7410}
7411
7412/// Doctor is a structural report unless the user explicitly asks it to probe
7413/// a provider endpoint. Keep credential-bearing environment values out of the
7414/// regular diagnostic configuration so an unrelated renderer or error path
7415/// cannot disclose them.
7416fn load_doctor_config_from_cli(cli: &Cli, args: &DoctorArgs) -> Result<Config> {
7417    if args.probe_api || args.probe_local {
7418        return load_config_from_cli(cli);
7419    }
7420    load_structural_config_from_cli(cli)
7421}
7422
7423fn load_structural_config_from_cli(cli: &Cli) -> Result<Config> {
7424    let profile = effective_config_profile(cli);
7425    let mut config = Config::load_structural(cli.config.clone(), profile.as_deref())?;
7426    if let Ok(settings) = crate::settings::Settings::load_read_only() {
7427        apply_saved_reasoning_preference(&mut config, &settings);
7428    }
7429    cli.feature_toggles.apply(&mut config)?;
7430    Ok(config)
7431}
7432
7433fn effective_config_profile(cli: &Cli) -> Option<String> {
7434    cli.profile
7435        .clone()
7436        .or_else(|| std::env::var("CODEWHALE_PROFILE").ok())
7437        .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok())
7438}
7439
7440fn load_config_from_cli_with_effective_profile(cli: &Cli) -> Result<(Config, Option<String>)> {
7441    let profile = effective_config_profile(cli);
7442    let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
7443    // Config loading is shared by diagnostics and mutating runtimes. Read the
7444    // saved preference without migrating or creating state here; interactive
7445    // startup performs any permitted migration later through `Settings::load`.
7446    if let Ok(settings) = crate::settings::Settings::load_read_only() {
7447        apply_saved_reasoning_preference(&mut config, &settings);
7448    }
7449    cli.feature_toggles.apply(&mut config)?;
7450    Ok((config, profile))
7451}
7452
7453/// Apply the same reasoning-preference precedence as interactive `App`
7454/// construction to non-TUI runtimes.
7455///
7456/// `/model` and the config editor persist this preference in `settings.toml`.
7457/// Exec, review, workflow, ACP, and runtime-thread launches all begin with a
7458/// `Config`, so copying the saved value here keeps those entry points from
7459/// silently falling back to a route classifier or an older config.toml value.
7460fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::settings::Settings) {
7461    let Some(reasoning_effort) = settings.reasoning_effort.as_ref() else {
7462        return;
7463    };
7464    config.reasoning_effort = Some(reasoning_effort.clone());
7465    config.reasoning_effort_inferred_from_legacy_alias = false;
7466}
7467
7468fn read_api_key_from_stdin() -> Result<String> {
7469    let mut stdin = io::stdin();
7470    if stdin.is_terminal() {
7471        bail!("No API key provided. Pass --api-key or pipe one via stdin.");
7472    }
7473    let mut buffer = String::new();
7474    stdin.read_to_string(&mut buffer)?;
7475    let api_key = buffer.trim().to_string();
7476    if api_key.is_empty() {
7477        bail!("No API key provided via stdin.");
7478    }
7479    Ok(api_key)
7480}
7481
7482fn run_login(api_key: Option<String>) -> Result<()> {
7483    let api_key = match api_key {
7484        Some(key) => key,
7485        None => read_api_key_from_stdin()?,
7486    };
7487    let saved = config::save_api_key(&api_key)?;
7488    println!("Saved API key to {}", saved.describe());
7489    Ok(())
7490}
7491
7492fn run_logout() -> Result<()> {
7493    config::clear_api_key()?;
7494    println!("Cleared saved API key.");
7495    Ok(())
7496}
7497
7498async fn run_xai_device_auth(config_path: Option<&Path>) -> Result<()> {
7499    let pending = xai_oauth::device_code_login().await?;
7500    let activation = xai_oauth::activate_device_login(pending, config_path, None)?;
7501    println!(
7502        "xAI OAuth is ready; activated {} via {}",
7503        codewhale_config::quote_os_path(&activation.auth_path),
7504        codewhale_config::quote_os_path(&activation.config_path)
7505    );
7506    Ok(())
7507}
7508
7509fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
7510    if last {
7511        return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
7512            anyhow!(
7513                "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume <SESSION_ID>` to resume one explicitly.",
7514                workspace.display()
7515            )
7516        });
7517    }
7518    if let Some(id) = session_id {
7519        return Ok(id);
7520    }
7521    pick_session_id()
7522}
7523
7524fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
7525    let manager = SessionManager::default_location()?;
7526    Ok(manager
7527        .get_latest_session_for_workspace(workspace)?
7528        .map(|session| session.id))
7529}
7530
7531fn fork_session(
7532    config: &Config,
7533    session_id: Option<String>,
7534    last: bool,
7535    workspace: &Path,
7536) -> Result<String> {
7537    let manager = SessionManager::default_location()?;
7538    let saved = if last {
7539        let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
7540            bail!(
7541                "No saved sessions found for workspace {}.",
7542                workspace.display()
7543            );
7544        };
7545        manager.load_session(&meta.id)?
7546    } else {
7547        let id = resolve_session_id(session_id, false, workspace)?;
7548        manager.load_session_by_prefix(&id)?
7549    };
7550    let saved_provider_identity = saved
7551        .metadata
7552        .model_provider_id
7553        .as_deref()
7554        .filter(|identity| !identity.trim().is_empty())
7555        .unwrap_or(&saved.metadata.model_provider);
7556    let provider_identity = config
7557        .resolve_persisted_provider_identity(
7558            Some(&saved.metadata.model_provider),
7559            saved.metadata.model_provider_id.as_deref(),
7560        )
7561        .map_err(anyhow::Error::msg)
7562        .with_context(|| {
7563            format!(
7564                "saved session provider '{}' is unavailable; fork will not fall back",
7565                saved_provider_identity
7566            )
7567        })?;
7568
7569    let system_prompt = saved
7570        .system_prompt
7571        .as_ref()
7572        .map(|text| SystemPrompt::Text(text.clone()));
7573    let mut forked = create_saved_session(
7574        &saved.messages,
7575        &saved.metadata.model,
7576        &saved.metadata.workspace,
7577        saved.metadata.total_tokens,
7578        system_prompt.as_ref(),
7579    );
7580    forked.metadata.set_model_provider_route(
7581        provider_identity.provider.as_str(),
7582        provider_identity.persisted_id(),
7583    );
7584    forked.metadata.copy_cost_from(&saved.metadata);
7585    forked.metadata.mark_forked_from(&saved.metadata);
7586    manager.save_session(&forked)?;
7587
7588    let source_title = saved.metadata.title.trim();
7589    let source_label = if source_title.is_empty() {
7590        "session".to_string()
7591    } else {
7592        format!("\"{source_title}\"")
7593    };
7594    println!(
7595        "Forked {source_label} ({source_id}) → new session {new_id}",
7596        source_id = truncate_id(&saved.metadata.id),
7597        new_id = truncate_id(&forked.metadata.id),
7598    );
7599
7600    Ok(forked.metadata.id)
7601}
7602
7603fn pick_session_id() -> Result<String> {
7604    let manager = SessionManager::default_location()?;
7605    let sessions = manager.list_sessions()?;
7606    if sessions.is_empty() {
7607        bail!("No saved sessions found.");
7608    }
7609
7610    println!("Select a session to resume:");
7611    for (idx, session) in sessions.iter().enumerate() {
7612        println!("  {:>2}. {} ({})", idx + 1, session.title, session.id);
7613    }
7614    print!("Enter a number (or press Enter to cancel): ");
7615    io::stdout().flush()?;
7616
7617    let mut input = String::new();
7618    io::stdin().read_line(&mut input)?;
7619    let input = input.trim();
7620    if input.is_empty() {
7621        bail!("No session selected.");
7622    }
7623    let idx: usize = input
7624        .parse()
7625        .map_err(|_| anyhow::anyhow!("Invalid input"))?;
7626    let session = sessions
7627        .get(idx.saturating_sub(1))
7628        .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
7629    Ok(session.id.clone())
7630}
7631
7632async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
7633    use crate::client::DeepSeekClient;
7634
7635    let diff = collect_diff(&args)?;
7636    if diff.trim().is_empty() {
7637        bail!("No diff to review.");
7638    }
7639    validate_review_receipt_args(&args)?;
7640    if args.check_receipt {
7641        return run_review_receipt_check(&diff, &args);
7642    }
7643
7644    let model = resolve_review_model(config, args.model.as_deref());
7645    let route = resolve_cli_exec_route(config, &model, &diff, args.model.is_none()).await?;
7646    let execution_config = config_for_cli_route(config, &route);
7647    let route_provider = execution_config.provider_identity_for(route.provider);
7648    let model = route.model.clone();
7649    let user_prompt =
7650        format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
7651    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
7652        cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
7653    });
7654
7655    let system = SystemPrompt::Text(
7656        "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
7657Provide findings ordered by severity with file references, then open questions, then a brief summary."
7658            .to_string(),
7659    );
7660    let client = DeepSeekClient::new(&execution_config)?;
7661    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
7662    let request = MessageRequest {
7663        model: model.clone(),
7664        messages: vec![Message {
7665            role: "user".to_string(),
7666            content: vec![ContentBlock::Text {
7667                text: user_prompt,
7668                cache_control: None,
7669            }],
7670        }],
7671        max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
7672            request_route.provider,
7673            &request_route.model,
7674            None,
7675        ),
7676        system: Some(system),
7677        tools: None,
7678        tool_choice: None,
7679        metadata: None,
7680        thinking: None,
7681        reasoning_effort,
7682        stream: Some(false),
7683        temperature: None,
7684        top_p: None,
7685    };
7686
7687    let response = client.create_message(request).await?;
7688    let review_stop_reason = response.stop_reason.clone();
7689    let review_incomplete = crate::models::is_incomplete_stop_reason(review_stop_reason.as_deref());
7690    let mut output = String::new();
7691    for block in response.content {
7692        if let ContentBlock::Text { text, .. } = block {
7693            output.push_str(&text);
7694        }
7695    }
7696    // A truncated review must not become a receipt or a success. The partial
7697    // text is still printed for diagnostics below.
7698    let receipt = if args.write_receipt && !review_incomplete {
7699        let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
7700        let receipt = crate::tools::review::build_review_receipt(
7701            review_target_label(&args),
7702            &diff,
7703            &route_provider,
7704            &model,
7705            &parsed_output,
7706            &output,
7707            Vec::new(),
7708        );
7709        let path =
7710            crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref())?;
7711        Some((path, receipt))
7712    } else {
7713        None
7714    };
7715    let review_error = review_incomplete.then(|| {
7716        format!(
7717            "Model response incomplete: provider stop reason `{}`; the partial review was not accepted.",
7718            crate::models::stop_reason_detail(review_stop_reason.as_deref())
7719        )
7720    });
7721    if args.json {
7722        println!(
7723            "{}",
7724            serde_json::to_string_pretty(&serde_json::json!({
7725                "mode": "review",
7726                "provider": route_provider,
7727                "model": model,
7728                "success": !review_incomplete,
7729                "content": output,
7730                "stop_reason": review_stop_reason,
7731                "error": review_error,
7732                "receipt_path": receipt
7733                    .as_ref()
7734                    .map(|(path, _)| path.display().to_string()),
7735                "receipt": receipt.as_ref().map(|(_, receipt)| receipt),
7736            }))?
7737        );
7738        if let Some(error) = review_error {
7739            anyhow::bail!(error);
7740        }
7741    } else {
7742        println!("{output}");
7743        if let Some((path, _)) = receipt {
7744            eprintln!("Review receipt written: {}", path.display());
7745        }
7746        if let Some(error) = review_error {
7747            anyhow::bail!(error);
7748        }
7749    }
7750    Ok(())
7751}
7752
7753fn resolve_review_model(config: &Config, explicit_model: Option<&str>) -> String {
7754    explicit_model
7755        .map(str::trim)
7756        .filter(|model| !model.is_empty())
7757        .map(str::to_string)
7758        .unwrap_or_else(|| config.default_model())
7759}
7760
7761fn validate_review_receipt_args(args: &ReviewArgs) -> Result<()> {
7762    if args.receipt_path.is_some() && !args.write_receipt && !args.check_receipt {
7763        bail!("--receipt-path requires --write-receipt or --check-receipt");
7764    }
7765    if args.write_receipt && args.check_receipt {
7766        bail!("--write-receipt and --check-receipt are mutually exclusive");
7767    }
7768    Ok(())
7769}
7770
7771fn run_review_receipt_check(diff: &str, args: &ReviewArgs) -> Result<()> {
7772    let (path, receipt) = if let Some(path) = args.receipt_path.as_ref() {
7773        (
7774            path.clone(),
7775            crate::tools::review::read_review_receipt(path)
7776                .with_context(|| format!("failed to read review receipt {}", path.display()))?,
7777        )
7778    } else {
7779        crate::tools::review::latest_review_receipt_for_diff(diff)?.ok_or_else(|| {
7780            anyhow!(
7781                "No review receipt found for the current diff. Run `codewhale review --write-receipt` first, or pass --receipt-path."
7782            )
7783        })?
7784    };
7785    let validation =
7786        crate::tools::review::validate_review_receipt_for_diff(diff, &receipt, Some(path.clone()));
7787
7788    if args.json {
7789        println!(
7790            "{}",
7791            serde_json::to_string_pretty(&serde_json::json!({
7792                "mode": "review_receipt_check",
7793                "success": validation.passed,
7794                "validation": review_receipt_validation_public_json(&validation),
7795            }))?
7796        );
7797    } else if validation.passed {
7798        println!("Review receipt valid: {}", path.display());
7799    }
7800
7801    if !validation.passed {
7802        bail!("Review receipt check failed: {}", validation.reason);
7803    }
7804    Ok(())
7805}
7806
7807fn review_receipt_validation_public_json(
7808    validation: &crate::tools::review::ReviewReceiptValidation,
7809) -> serde_json::Value {
7810    let unresolved_risk = validation.unresolved_risk.as_ref();
7811    serde_json::json!({
7812        "passed": validation.passed,
7813        "status": review_receipt_validation_status(validation),
7814        "diff_fingerprint": validation.diff_fingerprint.as_str(),
7815        "receipt_fingerprint": validation.receipt_fingerprint.as_deref(),
7816        "unresolved": unresolved_risk.is_some_and(|risk| risk.unresolved),
7817        "risk_level": unresolved_risk.map(|risk| risk.level.as_str()),
7818    })
7819}
7820
7821fn review_receipt_validation_status(
7822    validation: &crate::tools::review::ReviewReceiptValidation,
7823) -> &'static str {
7824    if validation.passed {
7825        "valid"
7826    } else if validation
7827        .receipt_fingerprint
7828        .as_deref()
7829        .is_some_and(|fingerprint| fingerprint != validation.diff_fingerprint.as_str())
7830    {
7831        "diff_mismatch"
7832    } else if validation
7833        .unresolved_risk
7834        .as_ref()
7835        .is_some_and(|risk| risk.unresolved)
7836    {
7837        "unresolved_risk"
7838    } else if validation
7839        .reason
7840        .starts_with("unsupported review receipt schema version")
7841    {
7842        "unsupported_schema"
7843    } else if validation.reason.starts_with("review receipt check ") {
7844        "check_failed"
7845    } else {
7846        "invalid"
7847    }
7848}
7849
7850/// `codewhale pr <N>` (#451) — fetch a GitHub PR via `gh`, format
7851/// title + body + diff as the composer's first message, and launch
7852/// the interactive TUI. Falls back gracefully if `gh` is missing.
7853async fn run_pr(
7854    cli: &Cli,
7855    config: &Config,
7856    number: u32,
7857    repo: Option<&str>,
7858    checkout: bool,
7859    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
7860    plugin_registry: Arc<crate::plugins::PluginRegistry>,
7861) -> Result<()> {
7862    if !is_command_available("gh") {
7863        bail!(
7864            "`gh` CLI not found on PATH. Install GitHub CLI \
7865             (https://cli.github.com) and authenticate (`gh auth login`) \
7866             so `codewhale pr <N>` can fetch PR metadata and the diff."
7867        );
7868    }
7869
7870    let view = run_gh_pr_view(number, repo)?;
7871    let diff = run_gh_pr_diff(number, repo)?;
7872
7873    if checkout {
7874        match run_gh_pr_checkout(number, repo) {
7875            Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
7876            Err(err) => eprintln!(
7877                "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
7878            ),
7879        }
7880    }
7881
7882    let prompt = format_pr_prompt(number, &view, &diff);
7883    let resume_session_id = if cli.continue_session {
7884        let workspace = resolve_workspace(cli);
7885        latest_session_id_for_workspace(&workspace).ok().flatten()
7886    } else {
7887        cli.resume.clone()
7888    };
7889    run_interactive(
7890        cli,
7891        config,
7892        resume_session_id,
7893        Some(tui::InitialInput::Prefill(prompt)),
7894        pending_telemetry_notice,
7895        plugin_registry,
7896    )
7897    .await
7898}
7899
7900/// Return true if `name` resolves to an executable on the current `PATH`.
7901///
7902/// Walks `$PATH` directly instead of probing with `--version`. The
7903/// previous implementation invoked `Command::new(name).arg("--version")`,
7904/// which fails on the Ubuntu CI runner because `/bin/sh` is `dash` —
7905/// `dash --version` exits with status 2 ("invalid option") even though
7906/// `sh` is plainly on PATH. macOS happens to ship bash as `sh`, which
7907/// does honor `--version`, so the bug was invisible locally and only
7908/// surfaced in CI logs.
7909///
7910/// Windows: also checks the `.exe` extension when `name` doesn't have
7911/// one, matching the platform's PATHEXT lookup behavior for the common
7912/// case.
7913fn is_command_available(name: &str) -> bool {
7914    let Some(path) = std::env::var_os("PATH") else {
7915        return false;
7916    };
7917    for dir in std::env::split_paths(&path) {
7918        let candidate = dir.join(name);
7919        if candidate.is_file() {
7920            return true;
7921        }
7922        #[cfg(windows)]
7923        {
7924            // PATHEXT gives `.exe`/`.cmd`/`.bat` etc. priority — we only
7925            // probe `.exe` because that's the case that actually trips
7926            // up the negative case (`gh` resolves as `gh.exe`).
7927            if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
7928                return true;
7929            }
7930        }
7931    }
7932    false
7933}
7934
7935#[derive(Debug, Clone, Default)]
7936struct GhPullRequest {
7937    title: String,
7938    body: String,
7939    base: String,
7940    head: String,
7941    url: String,
7942}
7943
7944fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
7945    let mut cmd = crate::dependencies::Gh::command()
7946        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7947    cmd.arg("pr").arg("view").arg(number.to_string());
7948    if let Some(r) = repo {
7949        cmd.arg("--repo").arg(r);
7950    }
7951    cmd.arg("--json")
7952        .arg("title,body,baseRefName,headRefName,url");
7953    let output = cmd
7954        .output()
7955        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
7956    if !output.status.success() {
7957        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
7958        bail!("gh pr view #{number} failed: {stderr}");
7959    }
7960    let raw = String::from_utf8_lossy(&output.stdout).to_string();
7961    let value: serde_json::Value = serde_json::from_str(&raw)
7962        .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
7963    let pick = |key: &str| {
7964        value
7965            .get(key)
7966            .and_then(serde_json::Value::as_str)
7967            .unwrap_or_default()
7968            .to_string()
7969    };
7970    Ok(GhPullRequest {
7971        title: pick("title"),
7972        body: pick("body"),
7973        base: pick("baseRefName"),
7974        head: pick("headRefName"),
7975        url: pick("url"),
7976    })
7977}
7978
7979fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
7980    let mut cmd = crate::dependencies::Gh::command()
7981        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7982    cmd.arg("pr").arg("diff").arg(number.to_string());
7983    if let Some(r) = repo {
7984        cmd.arg("--repo").arg(r);
7985    }
7986    let output = cmd
7987        .output()
7988        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
7989    if !output.status.success() {
7990        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
7991        bail!("gh pr diff #{number} failed: {stderr}");
7992    }
7993    Ok(String::from_utf8_lossy(&output.stdout).to_string())
7994}
7995
7996fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
7997    let mut cmd = crate::dependencies::Gh::command()
7998        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7999    cmd.arg("pr").arg("checkout").arg(number.to_string());
8000    if let Some(r) = repo {
8001        cmd.arg("--repo").arg(r);
8002    }
8003    let output = cmd
8004        .output()
8005        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
8006    if !output.status.success() {
8007        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8008        bail!("gh pr checkout #{number} failed: {stderr}");
8009    }
8010    Ok(())
8011}
8012
8013/// Format the PR review prompt that lands in the composer. Caps the
8014/// diff at 200 KiB so a massive PR doesn't blow the model's context
8015/// window before the user even hits Enter — they can always ask the
8016/// model to fetch more via `gh pr diff #N` from inside the session.
8017fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
8018    const MAX_DIFF_BYTES: usize = 200 * 1024;
8019    let diff_section = if diff.len() > MAX_DIFF_BYTES {
8020        let cut = (0..=MAX_DIFF_BYTES)
8021            .rev()
8022            .find(|&i| diff.is_char_boundary(i))
8023            .unwrap_or(0);
8024        format!(
8025            "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
8026            &diff[..cut],
8027            MAX_DIFF_BYTES / 1024
8028        )
8029    } else {
8030        diff.to_string()
8031    };
8032    let body = if view.body.trim().is_empty() {
8033        "(no description)".to_string()
8034    } else {
8035        view.body.trim().to_string()
8036    };
8037    let title = if view.title.trim().is_empty() {
8038        format!("(PR #{number})")
8039    } else {
8040        view.title.trim().to_string()
8041    };
8042    let branches = match (view.base.is_empty(), view.head.is_empty()) {
8043        (false, false) => format!("{} ← {}", view.base, view.head),
8044        (false, true) => view.base.clone(),
8045        (true, false) => view.head.clone(),
8046        _ => "(unknown)".to_string(),
8047    };
8048    format!(
8049        "Review PR #{number} — {title}\n\
8050         \n\
8051         URL: {url}\n\
8052         Branches: {branches}\n\
8053         \n\
8054         ## Description\n\
8055         \n\
8056         {body}\n\
8057         \n\
8058         ## Diff\n\
8059         \n\
8060         ```diff\n\
8061         {diff_section}\n\
8062         ```\n",
8063        url = if view.url.is_empty() {
8064            "(unavailable)"
8065        } else {
8066            view.url.as_str()
8067        },
8068    )
8069}
8070
8071fn collect_diff(args: &ReviewArgs) -> Result<String> {
8072    let mut cmd = crate::dependencies::Git::command()
8073        .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
8074    cmd.arg("diff");
8075    if args.staged {
8076        cmd.arg("--cached");
8077    }
8078    if let Some(base) = &args.base {
8079        cmd.arg(format!("{base}...HEAD"));
8080    }
8081    if let Some(path) = &args.path {
8082        cmd.arg("--").arg(path);
8083    }
8084
8085    let output = cmd
8086        .output()
8087        .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
8088    if !output.status.success() {
8089        let stderr = String::from_utf8_lossy(&output.stderr);
8090        bail!("git diff failed: {}", stderr.trim());
8091    }
8092    let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
8093    if diff.len() > args.max_chars {
8094        diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
8095    }
8096    Ok(diff)
8097}
8098
8099fn review_target_label(args: &ReviewArgs) -> String {
8100    let mut label = if args.staged {
8101        "staged".to_string()
8102    } else if let Some(base) = args
8103        .base
8104        .as_deref()
8105        .map(str::trim)
8106        .filter(|base| !base.is_empty())
8107    {
8108        format!("base:{base}")
8109    } else {
8110        "working-tree".to_string()
8111    };
8112    if let Some(path) = &args.path {
8113        label.push(' ');
8114        label.push_str(path.to_string_lossy().as_ref());
8115    }
8116    label
8117}
8118
8119fn run_apply(args: ApplyArgs) -> Result<()> {
8120    let patch = if let Some(path) = args.patch_file {
8121        std::fs::read_to_string(&path)
8122            .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
8123    } else {
8124        read_patch_from_stdin()?
8125    };
8126    if patch.trim().is_empty() {
8127        bail!("Patch is empty.");
8128    }
8129
8130    let mut tmp = NamedTempFile::new()?;
8131    tmp.write_all(patch.as_bytes())?;
8132    let tmp_path = tmp.path().to_path_buf();
8133
8134    let output = crate::dependencies::Git::command()
8135        .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?
8136        .arg("apply")
8137        .arg("--whitespace=nowarn")
8138        .arg(&tmp_path)
8139        .output()
8140        .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?;
8141
8142    if !output.status.success() {
8143        let stderr = String::from_utf8_lossy(&output.stderr);
8144        bail!("git apply failed: {}", stderr.trim());
8145    }
8146    println!("Applied patch successfully.");
8147    Ok(())
8148}
8149
8150fn read_patch_from_stdin() -> Result<String> {
8151    let mut stdin = io::stdin();
8152    if stdin.is_terminal() {
8153        bail!("No patch file provided and stdin is empty.");
8154    }
8155    let mut buffer = String::new();
8156    stdin.read_to_string(&mut buffer)?;
8157    Ok(buffer)
8158}
8159
8160async fn run_mcp_command(
8161    config: &Config,
8162    workspace: &Path,
8163    command: McpCommand,
8164    plugins: &crate::plugins::PluginRegistry,
8165) -> Result<()> {
8166    let config_path = config.mcp_config_path();
8167    match command {
8168        McpCommand::Init { force } => {
8169            let status = init_mcp_config(&config_path, force)?;
8170            match status {
8171                WriteStatus::Created => {
8172                    println!("Created MCP config at {}", config_path.display());
8173                }
8174                WriteStatus::Overwritten => {
8175                    println!("Overwrote MCP config at {}", config_path.display());
8176                }
8177                WriteStatus::SkippedExists => {
8178                    println!(
8179                        "MCP config already exists at {} (use --force to overwrite)",
8180                        config_path.display()
8181                    );
8182                }
8183            }
8184            println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`.");
8185            Ok(())
8186        }
8187        McpCommand::List => {
8188            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8189                &config_path,
8190                workspace,
8191                plugins,
8192            )?;
8193            if cfg.servers.is_empty() {
8194                println!(
8195                    "No MCP servers configured in {} or {}",
8196                    config_path.display(),
8197                    crate::mcp::workspace_mcp_config_path(workspace).display()
8198                );
8199                return Ok(());
8200            }
8201            println!("MCP servers ({}):", cfg.servers.len());
8202            for (name, server) in cfg.servers {
8203                let status = if server.enabled && !server.disabled {
8204                    "enabled"
8205                } else {
8206                    "disabled"
8207                };
8208                let auth_status = crate::mcp::oauth::auth_status_for_server(&name, &server).await;
8209                let auth = if auth_status == crate::mcp::oauth::McpAuthStatus::Unsupported {
8210                    String::new()
8211                } else {
8212                    format!(
8213                        " auth={}",
8214                        auth_status
8215                            .to_string()
8216                            .to_ascii_lowercase()
8217                            .replace(' ', "-")
8218                    )
8219                };
8220                let args = if server.args.is_empty() {
8221                    "".to_string()
8222                } else {
8223                    format!(" {}", server.args.join(" "))
8224                };
8225                let cmd_str = if let Some(cmd) = server.command {
8226                    format!("{cmd}{args}")
8227                } else if let Some(url) = server.url {
8228                    url
8229                } else {
8230                    "unknown".to_string()
8231                };
8232                let required = if server.required { " required" } else { "" };
8233                println!("  - {name} [{status}{required}{auth}] {cmd_str}");
8234            }
8235            Ok(())
8236        }
8237        McpCommand::Connect { server } => {
8238            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8239                &config_path,
8240                workspace,
8241                std::sync::Arc::new(plugins.clone()),
8242            )?;
8243            if let Some(name) = server {
8244                if let Err(err) = pool.get_or_connect(&name).await {
8245                    if crate::mcp::oauth::error_looks_auth_required(&err) {
8246                        let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8247                        return Err(err).context(hint);
8248                    }
8249                    return Err(err);
8250                }
8251                println!("Connected to MCP server: {name}");
8252            } else {
8253                let errors = pool.connect_all().await;
8254                if errors.is_empty() {
8255                    println!("Connected to all configured MCP servers.");
8256                } else {
8257                    for (name, err) in errors {
8258                        eprintln!("Failed to connect {name}: {err:#}");
8259                        if crate::mcp::oauth::error_looks_auth_required(&err) {
8260                            eprintln!("  {}", crate::mcp::oauth::auth_required_login_hint(&name));
8261                        }
8262                    }
8263                }
8264            }
8265            Ok(())
8266        }
8267        McpCommand::Tools { server } => {
8268            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8269                &config_path,
8270                workspace,
8271                std::sync::Arc::new(plugins.clone()),
8272            )?;
8273            if let Some(name) = server {
8274                let conn = match pool.get_or_connect(&name).await {
8275                    Ok(conn) => conn,
8276                    Err(err) => {
8277                        if crate::mcp::oauth::error_looks_auth_required(&err) {
8278                            let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8279                            return Err(err).context(hint);
8280                        }
8281                        return Err(err);
8282                    }
8283                };
8284                if conn.tools().is_empty() {
8285                    println!("No tools found for MCP server: {name}");
8286                } else {
8287                    println!("Tools for {name}:");
8288                    for tool in conn.tools() {
8289                        println!(
8290                            "  - {}{}",
8291                            tool.name,
8292                            crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8293                        );
8294                    }
8295                }
8296            } else {
8297                let errors = pool.connect_all().await;
8298                for (name, err) in errors {
8299                    eprintln!("Failed to connect {name}: {err:#}");
8300                    if crate::mcp::oauth::error_looks_auth_required(&err) {
8301                        eprintln!("  {}", crate::mcp::oauth::auth_required_login_hint(&name));
8302                    }
8303                }
8304                let tools = pool.all_tools();
8305                if tools.is_empty() {
8306                    println!("No MCP tools discovered.");
8307                } else {
8308                    println!("MCP tools:");
8309                    for (name, tool) in tools {
8310                        println!(
8311                            "  - {}{}",
8312                            name,
8313                            crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8314                        );
8315                    }
8316                }
8317            }
8318            Ok(())
8319        }
8320        McpCommand::Add {
8321            name,
8322            command,
8323            url,
8324            transport,
8325            bearer_token_env_var,
8326            oauth_client_id,
8327            oauth_resource,
8328            scopes,
8329            args,
8330        } => {
8331            if command.is_none() && url.is_none() {
8332                bail!("Provide either --command or --url for `mcp add`.");
8333            }
8334            if let Some(transport) = transport.as_deref()
8335                && !transport.trim().eq_ignore_ascii_case("sse")
8336            {
8337                bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
8338            }
8339            let added_server = McpServerConfig {
8340                command,
8341                args,
8342                env: std::collections::HashMap::new(),
8343                cwd: None,
8344                url,
8345                transport,
8346                connect_timeout: None,
8347                execute_timeout: None,
8348                read_timeout: None,
8349                disabled: false,
8350                enabled: true,
8351                required: false,
8352                enabled_tools: Vec::new(),
8353                disabled_tools: Vec::new(),
8354                headers: std::collections::HashMap::new(),
8355                env_headers: std::collections::HashMap::new(),
8356                bearer_token_env_var,
8357                scopes,
8358                oauth: oauth_client_id.map(|client_id| McpServerOAuthConfig {
8359                    client_id: Some(client_id),
8360                }),
8361                oauth_resource,
8362                reviewed_plugin: None,
8363            };
8364            let can_suggest_oauth = added_server.url.is_some()
8365                && added_server.bearer_token_env_var.is_none()
8366                && added_server
8367                    .headers
8368                    .keys()
8369                    .all(|key| !key.trim().eq_ignore_ascii_case("authorization"))
8370                && added_server
8371                    .env_headers
8372                    .keys()
8373                    .all(|key| !key.trim().eq_ignore_ascii_case("authorization"));
8374            let mut cfg = load_mcp_config(&config_path)?;
8375            cfg.servers.insert(name.clone(), added_server.clone());
8376            save_mcp_config(&config_path, &cfg)?;
8377            println!("Added MCP server '{name}' in {}", config_path.display());
8378            if can_suggest_oauth
8379                && crate::mcp::oauth::oauth_login_support(&added_server)
8380                    .await
8381                    .is_ok_and(|support| support.is_some())
8382            {
8383                println!(
8384                    "OAuth is available for '{name}'. Run `codewhale mcp login {name}` to authenticate."
8385                );
8386            }
8387            Ok(())
8388        }
8389        McpCommand::Login { name, scopes } => {
8390            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8391                &config_path,
8392                workspace,
8393                plugins,
8394            )?;
8395            let server = cfg
8396                .servers
8397                .get(&name)
8398                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8399            let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
8400            crate::mcp::oauth::perform_oauth_login_for_server(
8401                &name,
8402                server,
8403                explicit_scopes,
8404                config.mcp_oauth_callback_port,
8405                config.mcp_oauth_callback_url.as_deref(),
8406            )
8407            .await?;
8408            println!("Stored OAuth credentials for MCP server '{name}'.");
8409            Ok(())
8410        }
8411        McpCommand::Logout { name } => {
8412            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8413                &config_path,
8414                workspace,
8415                plugins,
8416            )?;
8417            let server = cfg
8418                .servers
8419                .get(&name)
8420                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8421            if crate::mcp::oauth::delete_oauth_tokens_for_server(&name, server)? {
8422                println!("Deleted stored OAuth credentials for MCP server '{name}'.");
8423            } else {
8424                println!("No stored OAuth credentials found for MCP server '{name}'.");
8425            }
8426            Ok(())
8427        }
8428        McpCommand::Remove { name } => {
8429            let mut cfg = load_mcp_config(&config_path)?;
8430            if cfg.servers.remove(&name).is_none() {
8431                bail!("MCP server '{name}' not found");
8432            }
8433            save_mcp_config(&config_path, &cfg)?;
8434            println!("Removed MCP server '{name}'");
8435            Ok(())
8436        }
8437        McpCommand::Enable { name } => {
8438            let mut cfg = load_mcp_config(&config_path)?;
8439            let server = cfg
8440                .servers
8441                .get_mut(&name)
8442                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8443            server.enabled = true;
8444            server.disabled = false;
8445            save_mcp_config(&config_path, &cfg)?;
8446            println!("Enabled MCP server '{name}'");
8447            Ok(())
8448        }
8449        McpCommand::Disable { name } => {
8450            let mut cfg = load_mcp_config(&config_path)?;
8451            let server = cfg
8452                .servers
8453                .get_mut(&name)
8454                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8455            server.enabled = false;
8456            server.disabled = true;
8457            save_mcp_config(&config_path, &cfg)?;
8458            println!("Disabled MCP server '{name}'");
8459            Ok(())
8460        }
8461        McpCommand::Validate => {
8462            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8463                &config_path,
8464                workspace,
8465                std::sync::Arc::new(plugins.clone()),
8466            )?;
8467            let errors = pool.connect_all().await;
8468            if errors.is_empty() {
8469                println!("MCP config is valid. All enabled servers connected.");
8470                return Ok(());
8471            }
8472            eprintln!("MCP validation failed:");
8473            for (name, err) in errors {
8474                eprintln!("  - {name}: {err:#}");
8475            }
8476            bail!("one or more MCP servers failed validation");
8477        }
8478        McpCommand::AddSelf { name, workspace } => {
8479            let exe_path = std::env::current_exe()
8480                .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
8481            let exe_str = exe_path.to_string_lossy().to_string();
8482
8483            let mut args = vec!["serve".to_string(), "--mcp".to_string()];
8484            if let Some(ref ws) = workspace {
8485                args.push("--workspace".to_string());
8486                args.push(ws.clone());
8487            }
8488
8489            let mut cfg = load_mcp_config(&config_path)?;
8490            if cfg.servers.contains_key(&name) {
8491                bail!(
8492                    "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.",
8493                    config_path.display()
8494                );
8495            }
8496            cfg.servers.insert(
8497                name.clone(),
8498                McpServerConfig {
8499                    command: Some(exe_str.clone()),
8500                    args,
8501                    env: std::collections::HashMap::new(),
8502                    cwd: None,
8503                    url: None,
8504                    transport: None,
8505                    connect_timeout: None,
8506                    execute_timeout: None,
8507                    read_timeout: None,
8508                    disabled: false,
8509                    enabled: true,
8510                    required: false,
8511                    enabled_tools: Vec::new(),
8512                    disabled_tools: Vec::new(),
8513                    headers: std::collections::HashMap::new(),
8514                    env_headers: std::collections::HashMap::new(),
8515                    bearer_token_env_var: None,
8516                    scopes: Vec::new(),
8517                    oauth: None,
8518                    oauth_resource: None,
8519                    reviewed_plugin: None,
8520                },
8521            );
8522            save_mcp_config(&config_path, &cfg)?;
8523            println!(
8524                "Registered Codewhale as MCP server '{name}' in {}",
8525                config_path.display()
8526            );
8527            println!("  command: {exe_str}");
8528            println!(
8529                "  args:    serve --mcp{}",
8530                workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
8531            );
8532            println!();
8533            println!("Tip: Use `codewhale mcp validate` to test the connection.");
8534            println!("     Use `codewhale serve --http` for the HTTP/SSE runtime API instead.");
8535            Ok(())
8536        }
8537    }
8538}
8539
8540fn load_mcp_config(path: &Path) -> Result<McpConfig> {
8541    if !path.exists() {
8542        return Ok(McpConfig::default());
8543    }
8544    let contents = std::fs::read_to_string(path)
8545        .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
8546    let cfg: McpConfig = serde_json::from_str(&contents).map_err(|_| {
8547        anyhow::anyhow!(
8548            "Failed to parse MCP config {}; file contents were omitted",
8549            codewhale_config::quote_os_path(path)
8550        )
8551    })?;
8552    Ok(cfg)
8553}
8554
8555/// Diagnostic status for an MCP server entry.
8556#[derive(Debug)]
8557enum McpServerDoctorStatus {
8558    Ok(String),
8559    Warning(String),
8560    Error(String),
8561}
8562
8563impl McpServerDoctorStatus {
8564    fn legacy_status(&self) -> &'static str {
8565        match self {
8566            Self::Ok(_) => "ok",
8567            Self::Warning(_) => "warning",
8568            Self::Error(_) => "error",
8569        }
8570    }
8571
8572    fn configuration_status(&self) -> &'static str {
8573        match self {
8574            Self::Ok(_) => "valid",
8575            Self::Warning(_) => "warning",
8576            Self::Error(_) => "invalid",
8577        }
8578    }
8579
8580    fn detail(&self) -> &str {
8581        match self {
8582            Self::Ok(detail) | Self::Warning(detail) | Self::Error(detail) => detail,
8583        }
8584    }
8585}
8586
8587/// Inspect command availability without starting the configured MCP server.
8588fn doctor_mcp_command_status(server: &McpServerConfig) -> McpCommandAvailability {
8589    if server.url.is_some() {
8590        return McpCommandAvailability::NotApplicable;
8591    }
8592    match server.command.as_deref() {
8593        Some("") => McpCommandAvailability::Missing,
8594        Some(_) | None => McpCommandAvailability::NotChecked,
8595    }
8596}
8597
8598fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::Value {
8599    use serde_json::json;
8600
8601    let status = doctor_check_mcp_server(server);
8602    json!({
8603        "name": name,
8604        "enabled": server.enabled && !server.disabled,
8605        // Compatibility field retained for existing doctor JSON consumers.
8606        // Its scope is now explicit in `checks.configuration` below.
8607        "status": status.legacy_status(),
8608        "detail": status.detail(),
8609        "transport": if server.url.is_some() { "http" } else { "stdio" },
8610        "endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
8611        "command_configured": server.command.is_some(),
8612        "args_count": server.args.len(),
8613        "env_count": server.env.len(),
8614        "headers_count": server.headers.len(),
8615        "env_headers_count": server.env_headers.len(),
8616        "check_scope": "configuration",
8617        "checks": {
8618            "configuration": {
8619                "status": status.configuration_status(),
8620                "detail": status.detail(),
8621            },
8622            "command": {
8623                "status": doctor_mcp_command_status(server).as_str(),
8624            },
8625            "process_reachable": {
8626                "status": "not_checked",
8627            },
8628            "protocol_initialized": {
8629                "status": "not_checked",
8630            },
8631            "backend_tool_health": {
8632                "status": "not_checked",
8633            },
8634        },
8635    })
8636}
8637
8638/// Check an MCP server config entry for common issues.
8639fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
8640    // No command or URL — incomplete entry.
8641    if server.command.is_none() && server.url.is_none() {
8642        return McpServerDoctorStatus::Error("no command or url configured".to_string());
8643    }
8644
8645    // URL-based server: omit userinfo, query, and fragment entirely.
8646    if let Some(ref url) = server.url {
8647        let authority = crate::doctor::structural_url_authority(url);
8648        return if authority.starts_with("unparseable") {
8649            McpServerDoctorStatus::Warning(
8650                "HTTP/SSE server URL is invalid; configured value omitted".to_string(),
8651            )
8652        } else {
8653            McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {authority}"))
8654        };
8655    }
8656
8657    // Command-based: validate command path exists.
8658    let cmd = server.command.as_deref().unwrap_or("");
8659    if cmd.is_empty() {
8660        return McpServerDoctorStatus::Error("empty command".to_string());
8661    }
8662
8663    if server.cwd.is_none() {
8664        if is_relative_stdio_path_arg(cmd) {
8665            return McpServerDoctorStatus::Warning(
8666                "stdio server uses a relative command without cwd; command value omitted"
8667                    .to_string(),
8668            );
8669        }
8670        if server
8671            .args
8672            .iter()
8673            .any(|arg| is_relative_stdio_path_arg(arg))
8674        {
8675            return McpServerDoctorStatus::Warning(
8676                "stdio server uses a relative path argument without cwd; argument values omitted"
8677                    .to_string(),
8678            );
8679        }
8680    }
8681
8682    McpServerDoctorStatus::Ok(format!(
8683        "stdio server configured (command omitted; {} argument(s), {} environment binding(s))",
8684        server.args.len(),
8685        server.env.len()
8686    ))
8687}
8688
8689fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
8690    if let Some(parent) = path.parent() {
8691        std::fs::create_dir_all(parent).with_context(|| {
8692            format!("Failed to create MCP config directory {}", parent.display())
8693        })?;
8694    }
8695    let rendered = serde_json::to_string_pretty(cfg)
8696        .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
8697    crate::utils::write_atomic(path, rendered.as_bytes())
8698        .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
8699    Ok(())
8700}
8701
8702fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
8703    use crate::sandbox::{CommandSpec, SandboxManager};
8704
8705    let SandboxCommand::Run {
8706        policy,
8707        network,
8708        writable_root,
8709        exclude_tmpdir,
8710        exclude_slash_tmp,
8711        cwd,
8712        timeout_ms,
8713        command,
8714    } = args.command;
8715
8716    let policy = parse_sandbox_policy(
8717        &policy,
8718        network,
8719        writable_root,
8720        exclude_tmpdir,
8721        exclude_slash_tmp,
8722    )?;
8723    let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
8724    let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
8725
8726    let (program, args) = command
8727        .split_first()
8728        .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
8729    let spec =
8730        CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
8731    let manager = SandboxManager::new();
8732    let exec_env = manager.prepare(&spec);
8733
8734    let mut cmd = Command::new(exec_env.program());
8735    cmd.args(exec_env.args())
8736        .current_dir(&exec_env.cwd)
8737        .stdout(Stdio::piped())
8738        .stderr(Stdio::piped());
8739    child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
8740
8741    let mut child = cmd
8742        .spawn()
8743        .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
8744    let stdout_handle = child
8745        .stdout
8746        .take()
8747        .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
8748    let stderr_handle = child
8749        .stderr
8750        .take()
8751        .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
8752
8753    let timeout = exec_env.timeout;
8754    let stdout_thread = std::thread::spawn(move || {
8755        let mut reader = stdout_handle;
8756        let mut buf = Vec::new();
8757        let _ = reader.read_to_end(&mut buf);
8758        buf
8759    });
8760    let stderr_thread = std::thread::spawn(move || {
8761        let mut reader = stderr_handle;
8762        let mut buf = Vec::new();
8763        let _ = reader.read_to_end(&mut buf);
8764        buf
8765    });
8766
8767    if let Some(status) = child.wait_timeout(timeout)? {
8768        let stdout = stdout_thread.join().unwrap_or_default();
8769        let stderr = stderr_thread.join().unwrap_or_default();
8770        let stderr_str = String::from_utf8_lossy(&stderr);
8771        let exit_code = status.code().unwrap_or(-1);
8772        let sandbox_type = exec_env.sandbox_type;
8773        let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
8774
8775        if !stdout.is_empty() {
8776            print!("{}", String::from_utf8_lossy(&stdout));
8777        }
8778        if !stderr.is_empty() {
8779            eprint!("{stderr_str}");
8780        }
8781        if sandbox_denied {
8782            eprintln!(
8783                "{}",
8784                SandboxManager::denial_message(sandbox_type, &stderr_str)
8785            );
8786        }
8787
8788        if !status.success() {
8789            bail!("Command failed with exit code {exit_code}");
8790        }
8791    } else {
8792        let _ = child.kill();
8793        let _ = child.wait();
8794        bail!("Command timed out after {}ms", timeout.as_millis());
8795    }
8796    Ok(())
8797}
8798
8799fn parse_sandbox_policy(
8800    policy: &str,
8801    network: bool,
8802    writable_root: Vec<PathBuf>,
8803    exclude_tmpdir: bool,
8804    exclude_slash_tmp: bool,
8805) -> Result<crate::sandbox::SandboxPolicy> {
8806    use crate::sandbox::SandboxPolicy;
8807
8808    match policy {
8809        "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
8810        "read-only" => Ok(SandboxPolicy::ReadOnly),
8811        "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
8812            network_access: network,
8813        }),
8814        "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
8815            writable_roots: writable_root,
8816            network_access: network,
8817            exclude_tmpdir,
8818            exclude_slash_tmp,
8819        }),
8820        other => bail!("Unknown sandbox policy: {other}"),
8821    }
8822}
8823
8824fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
8825    true
8826}
8827
8828fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
8829    let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
8830    let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
8831    let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
8832    should_use_mouse_capture_with(
8833        cli,
8834        config,
8835        use_alt_screen,
8836        terminal_emulator.as_deref(),
8837        wt_session.as_deref(),
8838        conemu_pid.as_deref(),
8839    )
8840}
8841
8842fn should_use_mouse_capture_with(
8843    cli: &Cli,
8844    config: &Config,
8845    use_alt_screen: bool,
8846    terminal_emulator: Option<&str>,
8847    wt_session: Option<&str>,
8848    conemu_pid: Option<&str>,
8849) -> bool {
8850    if !use_alt_screen || cli.no_mouse_capture {
8851        return false;
8852    }
8853    if cli.mouse_capture {
8854        return true;
8855    }
8856    config
8857        .tui
8858        .as_ref()
8859        .and_then(|tui| tui.mouse_capture)
8860        .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
8861}
8862
8863/// Whether to enable terminal mouse capture by default for this platform/host.
8864///
8865/// On Windows the default depends on the host: Windows Terminal (which sets
8866/// `WT_SESSION`) and ConEmu/Cmder (which set `ConEmuPID`) handle mouse-mode
8867/// reporting cleanly, so default-on there gives users in-app text selection
8868/// and keeps the application's selection clamped to the transcript area
8869/// (#1169). Legacy conhost (CMD without either env var) stays default-off
8870/// because its mouse-mode reporting can leak SGR escape sequences as raw
8871/// text into the composer (#878 / #898).
8872///
8873/// Off elsewhere only for JetBrains' JediTerm, which advertises mouse
8874/// support but forwards the same SGR escape sequences as raw input. The
8875/// user can still opt back in with `[tui] mouse_capture = true` in
8876/// `~/.codewhale/config.toml` or `--mouse-capture`.
8877fn default_mouse_capture_enabled(
8878    terminal_emulator: Option<&str>,
8879    wt_session: Option<&str>,
8880    conemu_pid: Option<&str>,
8881) -> bool {
8882    if cfg!(windows) {
8883        return wt_session.is_some() || conemu_pid.is_some();
8884    }
8885    if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
8886        return false;
8887    }
8888    true
8889}
8890
8891/// A loadable crash-recovery checkpoint candidate: session content, file
8892/// age, and which slot it came from (per-session file or the legacy single
8893/// slot).
8894struct RecentCheckpoint {
8895    session: session_manager::SavedSession,
8896    age: std::time::Duration,
8897    source: session_manager::CheckpointSource,
8898}
8899
8900const CHECKPOINT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
8901
8902/// Load all recent crash-recovery checkpoints, pruning stale ones first.
8903///
8904/// Candidates are the per-session checkpoint files plus the legacy
8905/// single-slot `checkpoints/latest.json` (compatibility read). Files older
8906/// than 24 hours are removed; unreadable files are skipped. The result is
8907/// sorted most recent first.
8908fn load_recent_checkpoints(manager: &session_manager::SessionManager) -> Vec<RecentCheckpoint> {
8909    let refs = manager.list_checkpoints().unwrap_or_default();
8910    let mut recent = Vec::new();
8911    for checkpoint_ref in refs {
8912        let Ok(age) = std::time::SystemTime::now().duration_since(checkpoint_ref.modified) else {
8913            continue;
8914        };
8915        if age > CHECKPOINT_MAX_AGE {
8916            let _ = match &checkpoint_ref.source {
8917                session_manager::CheckpointSource::Session(id) => {
8918                    manager.clear_session_checkpoint(id)
8919                }
8920                session_manager::CheckpointSource::Legacy => manager.clear_legacy_checkpoint(),
8921            };
8922            continue;
8923        }
8924        let loaded = match &checkpoint_ref.source {
8925            session_manager::CheckpointSource::Session(id) => manager.load_session_checkpoint(id),
8926            session_manager::CheckpointSource::Legacy => manager.load_legacy_checkpoint(),
8927        };
8928        let Ok(Some(session)) = loaded else {
8929            continue;
8930        };
8931        recent.push(RecentCheckpoint {
8932            session,
8933            age,
8934            source: checkpoint_ref.source,
8935        });
8936    }
8937    // `list_checkpoints` sorts newest-first already; keep it explicit here so
8938    // selection does not silently depend on the manager's ordering.
8939    recent.sort_by_key(|c| c.age);
8940    recent
8941}
8942
8943fn checkpoint_age_label(age: std::time::Duration) -> String {
8944    if age.as_secs() < 60 {
8945        format!("{}s ago", age.as_secs())
8946    } else if age.as_secs() < 3600 {
8947        format!("{}m ago", age.as_secs() / 60)
8948    } else {
8949        format!("{}h ago", age.as_secs() / 3600)
8950    }
8951}
8952
8953/// Check for a crash-recovery checkpoint and return the session ID if explicit
8954/// recovery was requested *and* the checkpoint belongs to the current
8955/// workspace.
8956///
8957/// Candidates are all per-session checkpoint files plus the legacy
8958/// single-slot `checkpoints/latest.json`; each must be younger than 24 hours
8959/// **and its workspace must match the resolved launch workspace after
8960/// canonicalisation** — the newest matching candidate wins. If no candidate
8961/// matches, a one-line notice points at `codewhale sessions`, and nothing is
8962/// auto-loaded: another workspace's checkpoint file is never touched (it may
8963/// belong to a live session there).
8964fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
8965    let manager = session_manager::SessionManager::default_location().ok()?;
8966    let candidates = load_recent_checkpoints(&manager);
8967    if candidates.is_empty() {
8968        return None;
8969    }
8970
8971    // Refuse to silently restore a session from another workspace. Compare
8972    // against the resolved launch workspace, not the shell cwd, so callers
8973    // using `--workspace` cannot accidentally recover a checkpoint from the
8974    // directory their shell happened to be in.
8975    let (matching, mismatched): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|candidate| {
8976        session_manager::workspace_scope_matches(
8977            &candidate.session.metadata.workspace,
8978            launch_workspace,
8979        )
8980    });
8981
8982    let Some(best) = matching.into_iter().next() else {
8983        if let Some(newest) = mismatched.first() {
8984            eprintln!(
8985                "Note: an interrupted session from another workspace ({}) is \
8986                 available. Run `codewhale sessions` to list saved sessions. Starting \
8987                 fresh in {}.",
8988                newest.session.metadata.workspace.display(),
8989                launch_workspace.display(),
8990            );
8991        }
8992        return None;
8993    };
8994
8995    let session_id = best.session.metadata.id.clone();
8996
8997    // Persist the checkpoint as a regular session so the TUI can load it by
8998    // id — unless a newer regular session file for the same id already
8999    // exists (e.g. `--continue` ran before and the session advanced since).
9000    // A stale checkpoint must never overwrite newer durable session state.
9001    if !saved_session_is_newer(&manager, &best.session)
9002        && manager.save_session(&best.session).is_err()
9003    {
9004        return None;
9005    }
9006
9007    match &best.source {
9008        session_manager::CheckpointSource::Session(id) => {
9009            // Consume the per-session checkpoint now that it is recovered.
9010            let _ = manager.clear_session_checkpoint(id);
9011        }
9012        session_manager::CheckpointSource::Legacy => {
9013            // Migrate the legacy slot to a per-session file (never
9014            // overwriting an existing one) and leave `latest.json` in place
9015            // so an older binary can still find it; its writer is already
9016            // gone and the file ages out within 24 hours.
9017            let _ = manager.write_session_checkpoint_if_absent(&best.session);
9018        }
9019    }
9020
9021    let age_str = checkpoint_age_label(best.age);
9022    eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
9023
9024    Some(session_id)
9025}
9026
9027/// Whether a regular session file for the checkpoint's id already exists and
9028/// is at least as recent as the checkpoint. When it is, persisting the
9029/// checkpoint over it would replace newer durable state with older in-flight
9030/// state.
9031fn saved_session_is_newer(
9032    manager: &session_manager::SessionManager,
9033    checkpoint: &session_manager::SavedSession,
9034) -> bool {
9035    manager
9036        .load_session(&checkpoint.metadata.id)
9037        .is_ok_and(|existing| existing.metadata.updated_at >= checkpoint.metadata.updated_at)
9038}
9039
9040/// Preserve an interrupted checkpoint on a normal fresh launch without
9041/// attaching it to the new TUI instance. This keeps "open another codewhale in
9042/// the same folder" from re-entering the previous in-flight session while still
9043/// leaving an explicit resume path.
9044///
9045/// Only the newest recent checkpoint drives the notice. The legacy
9046/// single-slot file is persisted as a regular session and consumed (today's
9047/// behavior for that slot); per-session checkpoint files are persisted but
9048/// left in place — they may belong to a live session in another terminal,
9049/// and `--continue` reads them directly.
9050fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
9051    let Some(manager) = session_manager::SessionManager::default_location().ok() else {
9052        return;
9053    };
9054    let Some(newest) = load_recent_checkpoints(&manager).into_iter().next() else {
9055        return;
9056    };
9057
9058    let session_workspace = newest.session.metadata.workspace.clone();
9059    // #4479: removed save_session call — checkpoint should not be auto-promoted to session
9060    if newest.source == session_manager::CheckpointSource::Legacy {
9061        // Migrate legacy single-slot checkpoint to per-session format
9062        // before clearing the legacy file, or the data is unrecoverable.
9063        let _ = manager.save_checkpoint(&newest.session);
9064        let _ = manager.clear_legacy_checkpoint();
9065    }
9066
9067    let age_str = checkpoint_age_label(newest.age);
9068    if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
9069        eprintln!(
9070            "Found an in-flight session snapshot ({age_str}). Starting a new \
9071             session. Run `codewhale --continue` to resume it."
9072        );
9073    } else {
9074        eprintln!(
9075            "Note: an interrupted session from another workspace ({}) is \
9076             available. Run `codewhale sessions` to list saved sessions. Starting \
9077             fresh in {}.",
9078            session_workspace.display(),
9079            launch_workspace.display(),
9080        );
9081    }
9082}
9083
9084/// Load project-level config from `$WORKSPACE/.codewhale/config.toml`, with
9085/// legacy `$WORKSPACE/.deepseek/config.toml` fallback, then apply its fields as
9086/// overrides on top of the global config (#485).
9087/// Only explicitly set fields in the project file are applied; everything
9088/// else falls back to the global value.
9089#[cfg(test)]
9090fn merge_project_config(config: &mut Config, workspace: &Path) {
9091    merge_project_config_with_approval_baseline(config, workspace, None);
9092}
9093
9094/// Apply project config while evaluating approval tightening against the
9095/// user's effective interactive baseline. `Config::approval_policy` remains
9096/// authoritative when present; the saved TUI posture is used only when the
9097/// root config leaves approval unset.
9098fn merge_project_config_with_approval_baseline(
9099    config: &mut Config,
9100    workspace: &Path,
9101    saved_permission_posture: Option<&str>,
9102) {
9103    // When the workspace is the user's home directory, the project-scope
9104    // config file is also the global config file. Skip the merge to avoid
9105    // redundant processing and a misleading "project-scope config key
9106    // ignored" warning on every launch from ~.
9107    if let Some(home) = effective_home_dir()
9108        && let (Ok(w), Ok(h)) = (
9109            std::fs::canonicalize(workspace),
9110            std::fs::canonicalize(&home),
9111        )
9112        && w == h
9113    {
9114        return;
9115    }
9116
9117    // v0.8.44: prefer .codewhale/config.toml, fall back to .deepseek/
9118    let path = workspace
9119        .join(codewhale_config::CODEWHALE_APP_DIR)
9120        .join("config.toml");
9121    let raw = match read_project_config_file(&path) {
9122        Ok(Some(r)) => r,
9123        Ok(None) => {
9124            let legacy = workspace
9125                .join(codewhale_config::LEGACY_APP_DIR)
9126                .join("config.toml");
9127            match read_project_config_file(&legacy) {
9128                Ok(Some(r)) => r,
9129                Ok(None) => return,
9130                Err(err) => {
9131                    eprintln!(
9132                        "warning: failed to read project-scope config {}: {err}",
9133                        legacy.display()
9134                    );
9135                    return;
9136                }
9137            }
9138        }
9139        Err(err) => {
9140            eprintln!(
9141                "warning: failed to read project-scope config {}: {err}",
9142                path.display()
9143            );
9144            return;
9145        }
9146    };
9147    let project: toml::Value = match toml::from_str(&raw) {
9148        Ok(v) => v,
9149        Err(_) => return,
9150    };
9151    let table = match project.as_table() {
9152        Some(t) => t,
9153        None => return,
9154    };
9155
9156    // #417: dangerous keys are denied at project scope. A malicious
9157    // `<workspace>/.deepseek/config.toml` could otherwise:
9158    // * `api_key` / `base_url` / `provider` — exfiltrate prompts to a
9159    //   look-alike endpoint by swapping the user's credentials and
9160    //   target host with project-controlled values.
9161    // * `mcp_config_path` — point the loader at an MCP config that
9162    //   spawns arbitrary stdio servers under the user's identity.
9163    // * `mcp_oauth_callback_*` — choose local OAuth redirect listener
9164    //   behavior for user-owned MCP credentials.
9165    //
9166    // The overlay path is non-interactive; users can't visually
9167    // confirm a rogue project config is hijacking these. We surface
9168    // a stderr warning on first encounter so a user who *did* expect
9169    // the override has a chance to notice the deny instead of silent
9170    // discard.
9171    const DENY_AT_PROJECT_SCOPE: &[&str] = &[
9172        "api_key",
9173        "base_url",
9174        "provider",
9175        "mcp_config_path",
9176        "mcp_oauth_callback_port",
9177        "mcp_oauth_callback_url",
9178    ];
9179    for key in DENY_AT_PROJECT_SCOPE {
9180        if table.contains_key(*key) {
9181            eprintln!(
9182                "warning: project-scope config key `{key}` is ignored — \
9183                 set it in `~/.codewhale/config.toml` instead. \
9184                 (See #417 for the deny-list rationale.)"
9185            );
9186        }
9187    }
9188
9189    // String fields a project may legitimately override (model,
9190    // approval/sandbox tightening, notes path, reasoning effort).
9191    for (key, field) in [
9192        ("model", &mut config.default_text_model),
9193        ("reasoning_effort", &mut config.reasoning_effort),
9194        ("notes_path", &mut config.notes_path),
9195    ] {
9196        if let Some(v) = table.get(key).and_then(toml::Value::as_str)
9197            && !v.is_empty()
9198        {
9199            *field = Some(v.to_string());
9200        }
9201    }
9202
9203    if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
9204        && !v.is_empty()
9205    {
9206        let saved_approval_baseline =
9207            crate::config::approval_policy_baseline_from_permission_posture(
9208                saved_permission_posture,
9209            );
9210        let approval_baseline = config
9211            .approval_policy
9212            .as_deref()
9213            .or(saved_approval_baseline);
9214        if codewhale_config::project_approval_policy_is_allowed(approval_baseline, v) {
9215            config.approval_policy = Some(v.to_string());
9216        } else {
9217            eprintln!(
9218                "warning: project-scope `approval_policy = \"{v}\"` is ignored — \
9219                 project config can only tighten the user's approval policy. \
9220                 (See #417.)"
9221            );
9222        }
9223    }
9224
9225    if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
9226        && !v.is_empty()
9227    {
9228        if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
9229            config.sandbox_mode = Some(v.to_string());
9230        } else {
9231            eprintln!(
9232                "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
9233                 project config can only tighten the user's sandbox mode. \
9234                 (See #417.)"
9235            );
9236        }
9237    }
9238
9239    // Numeric / bool fields that benefit from per-project overrides.
9240    if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
9241        && v > 0
9242    {
9243        config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
9244    }
9245    if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
9246        if v {
9247            eprintln!(
9248                "warning: project-scope `allow_shell = true` is ignored — \
9249                 enable shell from user config for this workspace instead. \
9250                 (See #417.)"
9251            );
9252        } else {
9253            config.allow_shell = Some(false);
9254        }
9255    }
9256
9257    if table.contains_key("instructions") {
9258        eprintln!(
9259            "warning: project-scope `instructions` is ignored — \
9260             configure instruction files from user config instead. \
9261             (See #417.)"
9262        );
9263    }
9264}
9265
9266fn read_project_config_file(path: &Path) -> io::Result<Option<String>> {
9267    let metadata = match std::fs::symlink_metadata(path) {
9268        Ok(metadata) => metadata,
9269        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
9270        Err(err) => return Err(err),
9271    };
9272    let file_type = metadata.file_type();
9273    if file_type.is_symlink() {
9274        return Err(io::Error::new(
9275            io::ErrorKind::InvalidInput,
9276            "project-scope config must not be a symlink",
9277        ));
9278    }
9279    if !file_type.is_file() {
9280        return Ok(None);
9281    }
9282
9283    let mut file = open_project_config_file(path)?;
9284    let mut raw = String::new();
9285    file.read_to_string(&mut raw)?;
9286    Ok(Some(raw))
9287}
9288
9289#[cfg(unix)]
9290fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9291    use std::os::unix::fs::OpenOptionsExt;
9292
9293    std::fs::OpenOptions::new()
9294        .read(true)
9295        .custom_flags(libc::O_NOFOLLOW)
9296        .open(path)
9297}
9298
9299#[cfg(not(unix))]
9300fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9301    std::fs::File::open(path)
9302}
9303
9304fn merge_user_workspace_config(
9305    config: &mut Config,
9306    config_path: Option<PathBuf>,
9307    workspace: &Path,
9308) {
9309    if config.managed_config_path.is_some() || config.requirements_path.is_some() {
9310        return;
9311    }
9312    let allow_shell_before = config.allow_shell;
9313    let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
9314        || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
9315    let path = match crate::config::resolve_load_config_path(config_path) {
9316        Ok(Some(path)) => path,
9317        Ok(None) => return,
9318        Err(error) => {
9319            tracing::error!(
9320                error = %error,
9321                "failed to resolve workspace config overlay; refusing to substitute another file"
9322            );
9323            return;
9324        }
9325    };
9326    let raw = match std::fs::read_to_string(&path) {
9327        Ok(raw) => raw,
9328        Err(error) => {
9329            eprintln!(
9330                "warning: could not read user config at {}: {error}. \
9331                 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9332                 revert to defaults for this session. Fix or remove the file to \
9333                 restore them.",
9334                path.display()
9335            );
9336            return;
9337        }
9338    };
9339    let doc = match toml::from_str::<toml::Value>(&raw) {
9340        Ok(doc) => doc,
9341        Err(error) => {
9342            eprintln!(
9343                "warning: could not parse user config at {}: {error}. \
9344                 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9345                 revert to defaults for this session. Fix the TOML syntax to \
9346                 restore them.",
9347                path.display()
9348            );
9349            return;
9350        }
9351    };
9352    merge_user_workspace_config_from_doc(config, &doc, workspace);
9353    if allow_shell_from_env {
9354        config.allow_shell = allow_shell_before;
9355    }
9356}
9357
9358fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
9359    for table_name in ["workspace", "projects"] {
9360        let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
9361            continue;
9362        };
9363        for (raw_path, entry) in entries {
9364            if !workspace_config_path_matches(raw_path, workspace) {
9365                continue;
9366            }
9367            if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
9368                config.allow_shell = Some(allow_shell);
9369            }
9370        }
9371    }
9372}
9373
9374fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
9375    let configured = crate::config::expand_path(raw_path);
9376    let configured = configured.canonicalize().unwrap_or(configured);
9377    let workspace = workspace
9378        .canonicalize()
9379        .unwrap_or_else(|_| workspace.to_path_buf());
9380    paths_equal_for_config(&configured, &workspace)
9381}
9382
9383#[cfg(windows)]
9384fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9385    normalize_windows_config_path_for_compare(left)
9386        == normalize_windows_config_path_for_compare(right)
9387}
9388
9389#[cfg(not(windows))]
9390fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9391    left == right
9392}
9393
9394#[cfg(windows)]
9395fn normalize_windows_config_path_for_compare(path: &Path) -> String {
9396    normalize_windows_config_path_str(&path.to_string_lossy())
9397}
9398
9399#[cfg(any(windows, test))]
9400fn normalize_windows_config_path_str(path: &str) -> String {
9401    let mut normalized = path.replace('/', "\\");
9402    if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") {
9403        normalized = format!("\\\\{rest}");
9404    } else if let Some(rest) = normalized.strip_prefix(r"\\?\") {
9405        normalized = rest.to_string();
9406    }
9407    while normalized.len() > 3 && normalized.ends_with('\\') {
9408        normalized.pop();
9409    }
9410    normalized.to_ascii_lowercase()
9411}
9412
9413fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool {
9414    yolo || config.interactive_allow_shell()
9415}
9416
9417async fn run_interactive(
9418    cli: &Cli,
9419    config: &Config,
9420    resume_session_id: Option<String>,
9421    initial_input: Option<tui::InitialInput>,
9422    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9423    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9424) -> Result<()> {
9425    run_interactive_with_notice(
9426        cli,
9427        config,
9428        resume_session_id,
9429        initial_input,
9430        None,
9431        pending_telemetry_notice,
9432        plugin_registry,
9433    )
9434    .await
9435}
9436
9437/// As [`run_interactive`], but carrying a one-line startup receipt to show in
9438/// the transcript — used by auto-resume to explain why it did or did not
9439/// reattach to a previous session (#2934).
9440async fn run_interactive_with_notice(
9441    cli: &Cli,
9442    config: &Config,
9443    resume_session_id: Option<String>,
9444    initial_input: Option<tui::InitialInput>,
9445    startup_notice: Option<String>,
9446    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9447    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9448) -> Result<()> {
9449    let initial_input = if cli.remote_control {
9450        Some(tui::InitialInput::RemoteControl)
9451    } else {
9452        initial_input
9453    };
9454    let workspace = cli
9455        .workspace
9456        .clone()
9457        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9458
9459    // Merge project-level config from $WORKSPACE/.codewhale/config.toml
9460    // or legacy $WORKSPACE/.deepseek/config.toml
9461    // unless --no-project-config was passed (#485).
9462    let mut merged_config = config.clone();
9463    merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
9464    if !cli.no_project_config {
9465        let saved_permission_posture = crate::settings::Settings::load_persisted()
9466            .ok()
9467            .and_then(|settings| settings.permission_posture);
9468        merge_project_config_with_approval_baseline(
9469            &mut merged_config,
9470            &workspace,
9471            saved_permission_posture.as_deref(),
9472        );
9473    }
9474    let config = &merged_config;
9475
9476    if !cli.skip_onboarding {
9477        match crate::config::ensure_config_file_exists(cli.config.clone()) {
9478            Ok(Some(path)) => logging::info(format!(
9479                "Created first-run config file at {}",
9480                path.display()
9481            )),
9482            Ok(None) => {}
9483            Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
9484        }
9485    }
9486
9487    // v0.8.44: migrate config from ~/.deepseek/ to ~/.codewhale/ on first
9488    // launch. Non-fatal — existing installs keep working either way.
9489    match codewhale_config::migrate_config_if_needed() {
9490        Ok(Some(migration)) => {
9491            eprintln!("{}", migration.user_notice());
9492        }
9493        Ok(None) => {}
9494        Err(err) => logging::warn(format!("Config migration skipped: {err}")),
9495    }
9496
9497    let model = config.default_model();
9498    let provider = config.api_provider();
9499    let max_subagents = cli.max_subagents.map_or_else(
9500        || config.max_subagents_for_provider(provider),
9501        |value| value.clamp(1, MAX_SUBAGENTS),
9502    );
9503    let use_alt_screen = should_use_alt_screen(cli, config);
9504    let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
9505    let use_bracketed_paste = crate::settings::Settings::load()
9506        .map(|s| s.effective_bracketed_paste())
9507        .unwrap_or_else(|_| !crate::settings::detected_legacy_windows_console_host());
9508
9509    // Auto-install bundled system skills (e.g. skill-creator) on first launch.
9510    // Errors are non-fatal: log a warning and continue.
9511    let skills_dir = config.skills_dir();
9512    if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
9513        logging::warn(format!("Failed to install system skills: {e}"));
9514    }
9515
9516    startup_trace::mark("interactive_config");
9517
9518    // Seed ProviderLake from the secret-free Models.dev disk cache before any
9519    // picker/inventory read, then kick a best-effort background refresh (#4187).
9520    // Failures are quiet: bundled catalog rows always remain available.
9521    crate::models_dev_live::maybe_load_persisted_cache();
9522    crate::models_dev_live::spawn_background_refresh();
9523    // Best-effort per-provider catalog refresh: fetches the active provider's
9524    // own /v1/models endpoint and merges live rows into the provider lake
9525    // alongside the Models.dev snapshot. Currently active for TelecomJS, whose
9526    // model list is not covered by the Models.dev catalog.
9527    crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
9528
9529    // Boot janitors — snapshot prune (7-day default), spillover prune
9530    // (#422), and managed-session cleanup (v0.8.44) — are best-effort disk
9531    // hygiene. On a large ~/.codewhale they were the dominant startup cost
9532    // (a git object walk plus thousands of stat/read calls), so they run on
9533    // a blocking worker while the TUI brings up its first frame (#3757).
9534    // All three were already documented as non-fatal.
9535    let snapshots = config.snapshots_config();
9536    let janitor_snapshots_enabled = snapshots.enabled;
9537    let janitor_max_age = snapshots.max_age();
9538    let janitor_workspace = workspace.clone();
9539    // Session cleanup races session restore: skip it entirely when a session
9540    // is being resumed/continued this launch (the just-resumed session could
9541    // be pruned before its first save bumps `updated_at`). It runs next
9542    // clean launch. When we do run it, exclude the explicit resume id too.
9543    let janitor_resume_id = resume_session_id.clone();
9544    let janitor_skip_session_cleanup = resume_session_id.is_some() || cli.continue_session;
9545    tokio::task::spawn_blocking(move || {
9546        if janitor_snapshots_enabled {
9547            session_manager::prune_workspace_snapshots(&janitor_workspace, janitor_max_age);
9548        }
9549
9550        match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
9551            Ok(0) => {}
9552            Ok(n) => tracing::debug!(
9553                target: "spillover",
9554                "boot prune removed {n} spillover file(s)"
9555            ),
9556            Err(err) => tracing::warn!(
9557                target: "spillover",
9558                ?err,
9559                "spillover prune skipped on boot"
9560            ),
9561        }
9562
9563        if !janitor_skip_session_cleanup
9564            && let Ok(manager) = session_manager::SessionManager::default_location()
9565        {
9566            let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
9567        }
9568    });
9569
9570    // The `deepseek` launcher forwards `--yolo` to this binary via the
9571    // DEEPSEEK_YOLO env var (config.yolo), not as a CLI flag. Honour either.
9572    let yolo = cli.yolo || config.yolo.unwrap_or(false);
9573
9574    tui::run_tui(
9575        config,
9576        tui::TuiOptions {
9577            model,
9578            workspace,
9579            config_path: cli.config.clone(),
9580            config_profile: effective_config_profile(cli),
9581            allow_shell: interactive_tui_allow_shell(yolo, config),
9582            use_alt_screen,
9583            use_mouse_capture,
9584            use_bracketed_paste,
9585            skills_dir,
9586            memory_path: config.memory_path(),
9587            notes_path: config.notes_path(),
9588            mcp_config_path: config.mcp_config_path(),
9589            use_memory: config.memory_enabled(),
9590            start_in_agent_mode: yolo,
9591            skip_onboarding: cli.skip_onboarding,
9592            yolo, // YOLO mode auto-approves all tool executions
9593            resume_session_id,
9594            initial_input,
9595            startup_notice,
9596            max_subagents,
9597        },
9598        plugin_registry,
9599        pending_telemetry_notice,
9600    )
9601    .await
9602}
9603
9604#[derive(Debug)]
9605struct CliAutoRoute {
9606    provider: crate::config::ApiProvider,
9607    model: String,
9608    reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
9609    /// Whether the runtime should continue resolving reasoning per prompt.
9610    ///
9611    /// This is independent from `auto_model`: an Auto model can carry a fixed
9612    /// saved effort, while a fixed Fleet model can still request Auto effort.
9613    auto_controls_reasoning: bool,
9614    auto_model: bool,
9615}
9616
9617fn cli_reasoning_effort_value(
9618    config: &Config,
9619    model: &str,
9620    effort: crate::tui::app::ReasoningEffort,
9621) -> Option<String> {
9622    effort
9623        .api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
9624        .map(str::to_string)
9625}
9626
9627fn cli_reasoning_effort_value_for_prompt(
9628    config: &Config,
9629    model: &str,
9630    effort: crate::tui::app::ReasoningEffort,
9631    prompt: &str,
9632) -> Option<String> {
9633    let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
9634        crate::auto_reasoning::select(false, prompt)
9635    } else {
9636        effort
9637    };
9638    cli_reasoning_effort_value(config, model, resolved)
9639}
9640
9641fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
9642    let trimmed = value.trim();
9643    if trimmed.is_empty() {
9644        return Ok(None);
9645    }
9646    if matches!(
9647        trimmed.to_ascii_lowercase().as_str(),
9648        "inherit" | "parent" | "same" | "current" | "default" | "unset"
9649    ) {
9650        return Ok(None);
9651    }
9652    crate::tui::app::ReasoningEffort::parse_strict(trimmed)
9653        .map(|effort| Some(effort.as_setting().to_string()))
9654        .map_err(anyhow::Error::msg)
9655}
9656
9657fn config_for_cli_route(config: &Config, route: &CliAutoRoute) -> Config {
9658    let mut execution_config = config.clone();
9659    execution_config.provider = Some(config.provider_identity_for(route.provider));
9660    execution_config.set_provider_model_override(route.provider, Some(route.model.clone()));
9661    if matches!(
9662        route.provider,
9663        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
9664    ) {
9665        execution_config.default_text_model = Some(route.model.clone());
9666    }
9667    execution_config
9668}
9669
9670async fn resolve_cli_auto_route(
9671    config: &Config,
9672    model: &str,
9673    prompt: &str,
9674) -> Result<CliAutoRoute> {
9675    if model.trim().eq_ignore_ascii_case("auto") {
9676        let selection =
9677            model_routing::resolve_auto_route_with_inventory(config, prompt, "", "auto", "auto")
9678                .await?;
9679        let preference = config
9680            .reasoning_effort()
9681            .filter(|_| config.reasoning_effort_is_explicit())
9682            .map(crate::tui::app::ReasoningEffort::from_setting);
9683        let (reasoning_effort, auto_controls_reasoning) =
9684            model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
9685        Ok(CliAutoRoute {
9686            provider: selection.provider,
9687            model: selection.model,
9688            reasoning_effort,
9689            auto_controls_reasoning,
9690            auto_model: true,
9691        })
9692    } else {
9693        if let Some(selection) = model_routing::resolve_explicit_route_with_inventory(config, model)
9694        {
9695            let auto_controls_reasoning = matches!(
9696                selection.reasoning_effort,
9697                Some(crate::tui::app::ReasoningEffort::Auto)
9698            );
9699            return Ok(CliAutoRoute {
9700                provider: selection.provider,
9701                model: selection.model,
9702                reasoning_effort: selection.reasoning_effort,
9703                auto_controls_reasoning,
9704                auto_model: false,
9705            });
9706        }
9707
9708        let candidate_providers = model_routing::explicit_route_candidate_providers(config, model);
9709        if !candidate_providers.is_empty() && !candidate_providers.contains(&config.api_provider())
9710        {
9711            let providers = candidate_providers
9712                .iter()
9713                .map(|provider| provider.as_str())
9714                .collect::<Vec<_>>()
9715                .join(", ");
9716            bail!(
9717                "model `{model}` is available from configured provider route(s): {providers}. \
9718                 Pass `--provider <provider>` with `--model {model}` to choose one explicitly. \
9719                 In the TUI, use `/provider`, `/model`, or `/setup` to resolve the route before sending."
9720            );
9721        }
9722
9723        // When --model is not `auto`, fall back to the reasoning_effort
9724        // declared in the user's config.toml. The previous hard-coded `None`
9725        // silently dropped the user's setting on every non-auto-route exec
9726        // call, which (for example) prevented vllm + Qwen3 users from
9727        // disabling thinking via `reasoning_effort = "off"` and caused
9728        // 30+ second SSE idle timeouts on trivial prompts.
9729        let reasoning_effort = config
9730            .reasoning_effort()
9731            .map(crate::tui::app::ReasoningEffort::from_setting);
9732        Ok(CliAutoRoute {
9733            provider: config.api_provider(),
9734            model: model.to_string(),
9735            auto_controls_reasoning: matches!(
9736                reasoning_effort,
9737                Some(crate::tui::app::ReasoningEffort::Auto)
9738            ),
9739            reasoning_effort,
9740            auto_model: false,
9741        })
9742    }
9743}
9744
9745async fn resolve_cli_exec_route(
9746    config: &Config,
9747    model: &str,
9748    prompt: &str,
9749    force_configured_route: bool,
9750) -> Result<CliAutoRoute> {
9751    if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
9752        let reasoning_effort = config
9753            .reasoning_effort()
9754            .map(crate::tui::app::ReasoningEffort::from_setting);
9755        return Ok(CliAutoRoute {
9756            provider: config.api_provider(),
9757            model: model.to_string(),
9758            auto_controls_reasoning: matches!(
9759                reasoning_effort,
9760                Some(crate::tui::app::ReasoningEffort::Auto)
9761            ),
9762            reasoning_effort,
9763            auto_model: false,
9764        });
9765    }
9766    resolve_cli_auto_route(config, model, prompt).await
9767}
9768
9769fn should_force_configured_exec_route(
9770    resuming: bool,
9771    explicit_provider: Option<&str>,
9772    explicit_model: Option<&str>,
9773) -> bool {
9774    // A configured/default model belongs to the configured provider route.
9775    // Cross-provider inventory inference is reserved for an explicit model
9776    // override without an explicit provider. Resume remains route-authoritative
9777    // even when its model is overridden because it restores the saved provider.
9778    resuming || explicit_provider.is_some() || explicit_model.is_none()
9779}
9780
9781async fn run_one_shot(
9782    config: &Config,
9783    model: &str,
9784    prompt: &str,
9785    force_configured_route: bool,
9786) -> Result<()> {
9787    use crate::client::DeepSeekClient;
9788    use crate::models::{
9789        ContentBlock, Message, MessageRequest, is_incomplete_stop_reason, stop_reason_detail,
9790    };
9791
9792    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
9793    let execution_config = config_for_cli_route(config, &route);
9794    let client = DeepSeekClient::new(&execution_config)?;
9795    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
9796        cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt)
9797    });
9798    let model = route.model;
9799    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
9800
9801    let request = MessageRequest {
9802        model,
9803        messages: vec![Message {
9804            role: "user".to_string(),
9805            content: vec![ContentBlock::Text {
9806                text: prompt.to_string(),
9807                cache_control: None,
9808            }],
9809        }],
9810        max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
9811            request_route.provider,
9812            &request_route.model,
9813            None,
9814        ),
9815        system: None,
9816        tools: None,
9817        tool_choice: None,
9818        metadata: None,
9819        thinking: None,
9820        reasoning_effort,
9821        stream: Some(false),
9822        temperature: None,
9823        top_p: None,
9824    };
9825
9826    let response = client.create_message(request).await?;
9827    let stop_reason = response.stop_reason.clone();
9828
9829    for block in response.content {
9830        if let ContentBlock::Text { text, .. } = block {
9831            println!("{text}");
9832        }
9833    }
9834
9835    if is_incomplete_stop_reason(stop_reason.as_deref()) {
9836        anyhow::bail!(
9837            "Model response incomplete: provider stop reason `{}`; the partial response was printed but the command did not succeed.",
9838            stop_reason_detail(stop_reason.as_deref())
9839        );
9840    }
9841
9842    Ok(())
9843}
9844
9845async fn run_one_shot_json(
9846    config: &Config,
9847    model: &str,
9848    prompt: &str,
9849    force_configured_route: bool,
9850) -> Result<()> {
9851    use crate::client::DeepSeekClient;
9852    use crate::models::{
9853        ContentBlock, Message, MessageRequest, SystemPrompt, is_incomplete_stop_reason,
9854        stop_reason_detail,
9855    };
9856
9857    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
9858    let execution_config = config_for_cli_route(config, &route);
9859    let provider = execution_config.provider_identity_for(route.provider);
9860    let client = DeepSeekClient::new(&execution_config)?;
9861    let model = route.model.clone();
9862    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
9863        cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt)
9864    });
9865    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
9866    let request = MessageRequest {
9867        model: model.clone(),
9868        messages: vec![Message {
9869            role: "user".to_string(),
9870            content: vec![ContentBlock::Text {
9871                text: prompt.to_string(),
9872                cache_control: None,
9873            }],
9874        }],
9875        max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
9876            request_route.provider,
9877            &request_route.model,
9878            None,
9879        ),
9880        system: Some(SystemPrompt::Text(
9881            "You are a coding assistant. Give concise, actionable responses.".to_string(),
9882        )),
9883        tools: None,
9884        tool_choice: None,
9885        metadata: None,
9886        thinking: None,
9887        reasoning_effort,
9888        stream: Some(false),
9889        temperature: None,
9890        top_p: None,
9891    };
9892
9893    let response = client.create_message(request).await?;
9894    let stop_reason = response.stop_reason.clone();
9895    let usage = response.usage.clone();
9896    let mut output = String::new();
9897    for block in response.content {
9898        if let ContentBlock::Text { text, .. } = block {
9899            output.push_str(&text);
9900        }
9901    }
9902    println!(
9903        "{}",
9904        serde_json::to_string_pretty(&one_shot_exec_json_receipt(
9905            provider,
9906            model,
9907            output,
9908            stop_reason.clone(),
9909            usage,
9910        ))?
9911    );
9912    if is_incomplete_stop_reason(stop_reason.as_deref()) {
9913        anyhow::bail!(
9914            "Model response incomplete: provider stop reason `{}`; the JSON receipt records success=false.",
9915            stop_reason_detail(stop_reason.as_deref())
9916        );
9917    }
9918    Ok(())
9919}
9920
9921fn one_shot_exec_json_receipt(
9922    provider: String,
9923    model: String,
9924    output: String,
9925    stop_reason: Option<String>,
9926    usage: crate::models::Usage,
9927) -> serde_json::Value {
9928    let incomplete = crate::models::is_incomplete_stop_reason(stop_reason.as_deref());
9929    let error = incomplete.then(|| {
9930        format!(
9931            "Model response incomplete: provider stop reason `{}`.",
9932            crate::models::stop_reason_detail(stop_reason.as_deref())
9933        )
9934    });
9935    serde_json::json!({
9936        "mode": "one-shot",
9937        "provider": provider,
9938        "model": model,
9939        "success": !incomplete,
9940        "output": output,
9941        "stop_reason": stop_reason,
9942        "usage": usage,
9943        "error": error,
9944    })
9945}
9946
9947fn exec_stream_provider_route(
9948    identity: &crate::config::ProviderIdentity,
9949) -> (String, Option<String>) {
9950    let provider = identity.provider.as_str().to_string();
9951    let provider_id = if identity.provider == crate::config::ApiProvider::Custom {
9952        identity.exact_id.clone()
9953    } else {
9954        None
9955    };
9956    (provider, provider_id)
9957}
9958
9959#[derive(serde::Serialize)]
9960struct ExecStreamMeta {
9961    receipt_kind: &'static str,
9962    provider: String,
9963    /// Exact configured provider-table id, when one selected the route.
9964    /// `None` deliberately distinguishes the legacy idless root custom route
9965    /// from literal `[providers.custom]`, whose exact id is `"custom"`.
9966    #[serde(skip_serializing_if = "Option::is_none")]
9967    provider_id: Option<String>,
9968    model: String,
9969    route_source: String,
9970    #[serde(skip_serializing_if = "Option::is_none")]
9971    input_tokens: Option<u32>,
9972    #[serde(skip_serializing_if = "Option::is_none")]
9973    output_tokens: Option<u32>,
9974    #[serde(skip_serializing_if = "Option::is_none")]
9975    prompt_cache_hit_tokens: Option<u32>,
9976    #[serde(skip_serializing_if = "Option::is_none")]
9977    prompt_cache_miss_tokens: Option<u32>,
9978    #[serde(skip_serializing_if = "Option::is_none")]
9979    prompt_cache_write_tokens: Option<u32>,
9980    #[serde(skip_serializing_if = "Option::is_none")]
9981    reasoning_tokens: Option<u32>,
9982    duration_ms: u64,
9983    #[serde(skip_serializing_if = "Option::is_none")]
9984    retry_count: Option<u32>,
9985    approval_posture: String,
9986    sandbox_posture: String,
9987    #[serde(skip_serializing_if = "Option::is_none")]
9988    binary_sha256: Option<String>,
9989    #[serde(skip_serializing_if = "Option::is_none")]
9990    config_sha256: Option<String>,
9991    prompt_sha256: String,
9992    #[serde(skip_serializing_if = "Option::is_none")]
9993    tool_catalog_sha256: Option<String>,
9994    input_analysis: ExecStreamInputAnalysis,
9995    visible_final_answer_chars: usize,
9996    session_id: String,
9997    resume_command: String,
9998    workspace: String,
9999    message_count: usize,
10000    #[serde(skip_serializing_if = "Option::is_none")]
10001    status: Option<String>,
10002    #[serde(skip_serializing_if = "Option::is_none")]
10003    termination_reason: Option<String>,
10004    #[serde(skip_serializing_if = "Option::is_none")]
10005    error_category: Option<String>,
10006    #[serde(skip_serializing_if = "Option::is_none")]
10007    error: Option<String>,
10008}
10009
10010#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)]
10011struct ExecStreamInputAnalysis {
10012    estimated_request_tokens: usize,
10013    estimated_message_content_tokens: usize,
10014    estimated_system_tokens: usize,
10015    estimated_framing_tokens: usize,
10016    user_message_count: usize,
10017    assistant_message_count: usize,
10018    tool_message_count: usize,
10019    tool_use_count: usize,
10020    tool_result_count: usize,
10021    text_chars: usize,
10022    thinking_chars: usize,
10023    tool_use_input_chars: usize,
10024    tool_result_chars: usize,
10025    text_estimated_tokens: usize,
10026    thinking_estimated_tokens: usize,
10027    tool_use_input_estimated_tokens: usize,
10028    tool_result_estimated_tokens: usize,
10029}
10030
10031#[derive(serde::Serialize)]
10032#[serde(tag = "type")]
10033// Keep receipts flat for stable JSONL consumers. Boxing the whole tool_result
10034// payload would introduce a nested object and break the stream schema.
10035#[allow(clippy::large_enum_variant)]
10036enum ExecStreamEvent {
10037    #[serde(rename = "content")]
10038    Content { content: String },
10039    #[serde(rename = "tool_use")]
10040    ToolUse {
10041        name: String,
10042        id: String,
10043        input: serde_json::Value,
10044        started_at: String,
10045    },
10046    #[serde(rename = "tool_result")]
10047    ToolResult {
10048        id: String,
10049        name: String,
10050        output: String,
10051        status: String,
10052        started_at: String,
10053        completed_at: String,
10054        duration_ms: u64,
10055        side_effect_status: String,
10056        #[serde(skip_serializing_if = "Option::is_none")]
10057        error_category: Option<String>,
10058        #[serde(skip_serializing_if = "Option::is_none")]
10059        truncated: Option<bool>,
10060        #[serde(skip_serializing_if = "Option::is_none")]
10061        artifact: Option<serde_json::Value>,
10062        #[serde(skip_serializing_if = "Option::is_none")]
10063        result_metadata: Option<serde_json::Value>,
10064    },
10065    /// A sub-agent was launched, and the model it was launched on.
10066    ///
10067    /// Without this, a delegated child is invisible to anything reading the
10068    /// stream: a parent turn on one route could spawn children billed on
10069    /// another and the only place it surfaced was the invoice. That is not
10070    /// hypothetical — the `Fast` loadout re-priced scout children onto a
10071    /// cheaper sibling until it was fixed, and nothing in the output said so.
10072    #[serde(rename = "agent_spawned")]
10073    AgentSpawned {
10074        id: String,
10075        model: String,
10076        spawn_depth: u32,
10077        #[serde(skip_serializing_if = "Option::is_none")]
10078        parent_run_id: Option<String>,
10079        /// Why the child got this route, when the spawn path resolved one.
10080        #[serde(skip_serializing_if = "Option::is_none")]
10081        route_source: Option<String>,
10082    },
10083    #[serde(rename = "sandbox_denied")]
10084    SandboxDenied {
10085        tool_id: String,
10086        tool_name: String,
10087        reason: String,
10088        outcome: String,
10089    },
10090    #[serde(rename = "workflow_event")]
10091    WorkflowEvent {
10092        run_id: String,
10093        event: serde_json::Value,
10094    },
10095    #[serde(rename = "session_capture")]
10096    SessionCapture { content: String },
10097    #[serde(rename = "service_released")]
10098    #[cfg(unix)]
10099    ServiceReleased {
10100        task_id: String,
10101        pid: u32,
10102        process_group_id: u32,
10103        ownership: String,
10104    },
10105    /// Per-model-call usage receipt. Field names mirror the terminal
10106    /// `metadata` receipt (`prompt_cache_hit_tokens` is the provider's
10107    /// cache-read count, `prompt_cache_write_tokens` the cache-creation
10108    /// count). Optional fields are omitted — never emitted as null or zero —
10109    /// when the provider does not report them; the whole event is skipped
10110    /// for model calls whose provider reported no usage at all.
10111    #[serde(rename = "turn_usage")]
10112    TurnUsage {
10113        /// 1-based index of the model call within this exec run.
10114        turn: u32,
10115        input_tokens: u32,
10116        output_tokens: u32,
10117        #[serde(skip_serializing_if = "Option::is_none")]
10118        reasoning_tokens: Option<u32>,
10119        #[serde(skip_serializing_if = "Option::is_none")]
10120        prompt_cache_hit_tokens: Option<u32>,
10121        #[serde(skip_serializing_if = "Option::is_none")]
10122        prompt_cache_miss_tokens: Option<u32>,
10123        #[serde(skip_serializing_if = "Option::is_none")]
10124        prompt_cache_write_tokens: Option<u32>,
10125        #[serde(skip_serializing_if = "Option::is_none")]
10126        reasoning_replay_tokens: Option<u32>,
10127        duration_ms: u64,
10128    },
10129    #[serde(rename = "metadata")]
10130    Metadata { meta: Box<ExecStreamMeta> },
10131    #[serde(rename = "done")]
10132    Done,
10133    #[serde(rename = "error")]
10134    Error { error: String },
10135}
10136
10137fn exec_sandbox_elevation_authorized(
10138    allow_sandbox_elevation: bool,
10139    explicit_sandbox: Option<&str>,
10140) -> bool {
10141    allow_sandbox_elevation
10142        || explicit_sandbox.is_some_and(|policy| policy.eq_ignore_ascii_case("danger-full-access"))
10143}
10144
10145fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> {
10146    println!("{}", serde_json::to_string(&exec_stream_value(event)?)?);
10147    Ok(())
10148}
10149
10150/// Process exit code `codewhale exec` uses when a turn ends on a retryable
10151/// infrastructure failure (provider/transport) rather than a genuine task
10152/// failure. 75 is `EX_TEMPFAIL` from sysexits.h — "temporary failure; the
10153/// invocation is expected to succeed on retry" — so bench harnesses and
10154/// supervisors can distinguish retryable infra exits from genuine task
10155/// failures (exit 1) without parsing the stream-json metadata.
10156const EXEC_EXIT_RETRYABLE_INFRA: i32 = 75; // EX_TEMPFAIL
10157
10158/// Map a terminal exec error category to the process exit code.
10159///
10160/// `network` / `timeout` mean the provider connection dropped or stalled
10161/// after every in-session retry budget was exhausted: the task itself
10162/// neither passed nor failed, and re-running the same command is safe.
10163/// `rate_limit` is deliberately NOT mapped to the retryable code — the same
10164/// category also covers quota exhaustion, which a blind retry would hammer.
10165fn exec_failure_exit_code(error_category: Option<&str>) -> i32 {
10166    match error_category {
10167        Some("network" | "timeout") => EXEC_EXIT_RETRYABLE_INFRA,
10168        _ => 1,
10169    }
10170}
10171
10172/// Should a mid-turn engine error event force the final exec summary into
10173/// failure? Only non-recoverable envelopes do. Recoverable warnings (stream
10174/// stall notices, transient retry noise) are emitted on the stream for
10175/// visibility, but the terminal `TurnComplete` event carries the
10176/// authoritative turn outcome — a warning must never fail a run whose turn
10177/// later completes.
10178fn exec_error_event_is_fatal(envelope: &crate::error_taxonomy::ErrorEnvelope) -> bool {
10179    !envelope.recoverable
10180}
10181
10182fn exec_stream_value(event: &ExecStreamEvent) -> Result<serde_json::Value> {
10183    let mut value = serde_json::to_value(event)?;
10184    if let Some(object) = value.as_object_mut() {
10185        object.insert("schema_version".to_string(), serde_json::json!(1));
10186        object.insert(
10187            "schema".to_string(),
10188            serde_json::json!("codewhale.exec-stream"),
10189        );
10190    }
10191    Ok(value)
10192}
10193
10194fn tool_error_receipt_category(error: &crate::tools::spec::ToolError) -> &'static str {
10195    use crate::tools::spec::ToolError;
10196    match error {
10197        ToolError::InvalidInput { .. } => "invalid_input",
10198        ToolError::MissingField { .. } => "missing_field",
10199        ToolError::PathEscape { .. } => "path_escape",
10200        ToolError::ExecutionFailed { .. } => "execution_failed",
10201        ToolError::Timeout { .. } => "timeout",
10202        ToolError::Cancelled { .. } => "cancelled",
10203        ToolError::NotAvailable { .. } => "not_available",
10204        ToolError::PermissionDenied { .. } => "permission_denied",
10205    }
10206}
10207
10208fn tool_artifact_receipt(metadata: Option<&serde_json::Value>) -> Option<serde_json::Value> {
10209    let object = metadata?.as_object()?;
10210    let mut artifact = serde_json::Map::new();
10211    for key in [
10212        "artifact_id",
10213        "artifact_path",
10214        "artifact_relative_path",
10215        "artifact_byte_size",
10216        "spillover_path",
10217        "content_digest",
10218        "original_byte_count",
10219        "retained_head_bytes",
10220        "retained_tail_bytes",
10221    ] {
10222        if let Some(value) = object.get(key) {
10223            artifact.insert(key.to_string(), value.clone());
10224        }
10225    }
10226    (!artifact.is_empty()).then_some(serde_json::Value::Object(artifact))
10227}
10228
10229fn current_binary_sha256() -> Option<String> {
10230    let bytes = std::fs::read(std::env::current_exe().ok()?).ok()?;
10231    Some(format!("sha256:{}", crate::hashing::sha256_hex(&bytes)))
10232}
10233
10234async fn run_workflow_tool_command(
10235    cli: &Cli,
10236    args: WorkflowToolArgs,
10237    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10238) -> Result<()> {
10239    match run_workflow_tool_command_inner(cli, args, plugin_registry).await {
10240        Ok(()) => Ok(()),
10241        Err(error) => {
10242            let _ = emit_exec_stream_event(&ExecStreamEvent::Error {
10243                error: format!("{error:#}"),
10244            });
10245            exit_workflow_tool_failure();
10246        }
10247    }
10248}
10249
10250async fn run_workflow_tool_command_inner(
10251    cli: &Cli,
10252    args: WorkflowToolArgs,
10253    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10254) -> Result<()> {
10255    use crate::tools::spec::ToolSpec;
10256
10257    if args.approval_source != "explicit-workflow-command" {
10258        bail!("workflow-tool requires --approval-source explicit-workflow-command");
10259    }
10260    let input: serde_json::Value = serde_json::from_str(&args.input_json)
10261        .context("--input-json must be a valid Workflow tool input object")?;
10262    if !input.is_object() {
10263        bail!("--input-json must be a JSON object");
10264    }
10265    if !input
10266        .get("action")
10267        .and_then(serde_json::Value::as_str)
10268        .is_some_and(|action| action.eq_ignore_ascii_case("run"))
10269    {
10270        bail!("workflow-tool accepts only action=run");
10271    }
10272
10273    let workspace = resolve_workspace(cli);
10274    let mut config = load_config_from_cli(cli)?;
10275    merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
10276    if let Ok(env_url) =
10277        std::env::var("CODEWHALE_BASE_URL").or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
10278    {
10279        let trimmed = env_url.trim();
10280        if !trimmed.is_empty() {
10281            config.base_url = Some(trimmed.to_string());
10282        }
10283    }
10284
10285    let model = resolve_exec_model(&config, None);
10286    let route = resolve_cli_exec_route(
10287        &config,
10288        &model,
10289        "Run a checked-in Workflow through the host runtime",
10290        true,
10291    )
10292    .await?;
10293    let execution_config = config_for_cli_route(&config, &route);
10294    let route_identity = execution_config
10295        .active_provider_identity(route.provider)
10296        .map_err(anyhow::Error::msg)
10297        .context("workflow terminal route lost its exact provider identity")?;
10298    let (route_provider, route_provider_id) = exec_stream_provider_route(&route_identity);
10299    let workflow_input_sha256 = format!(
10300        "sha256:{}",
10301        crate::hashing::sha256_hex(&serde_json::to_vec(&input)?)
10302    );
10303    let tool_id = format!("workflow_host_{}", &uuid::Uuid::new_v4().to_string()[..8]);
10304    let tool_started = Instant::now();
10305    let tool_started_at = chrono::Utc::now().to_rfc3339();
10306
10307    emit_exec_stream_event(&ExecStreamEvent::ToolUse {
10308        name: "workflow".to_string(),
10309        id: tool_id.clone(),
10310        input: input.clone(),
10311        started_at: tool_started_at.clone(),
10312    })?;
10313
10314    let (event_tx, event_rx) = tokio::sync::mpsc::channel(1024);
10315    let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
10316    let event_forwarder = tokio::spawn(forward_direct_workflow_events(event_rx, stop_rx));
10317    let (tool, context) = match build_direct_workflow_tool(
10318        &execution_config,
10319        &route,
10320        &workspace,
10321        event_tx,
10322        plugin_registry,
10323    )
10324    .await
10325    {
10326        Ok(built) => built,
10327        Err(err) => {
10328            let _ = stop_tx.send(());
10329            let _ = event_forwarder.await;
10330            exit_workflow_tool_error(&tool_id, err.to_string());
10331        }
10332    };
10333
10334    let result = tool.execute(input, &context).await;
10335    drop(tool);
10336    let _ = stop_tx.send(());
10337    event_forwarder
10338        .await
10339        .context("workflow event forwarder task failed")??;
10340
10341    let result = match result {
10342        Ok(result) => result,
10343        Err(err) => {
10344            let error = err.to_string();
10345            exit_workflow_tool_error(&tool_id, error);
10346        }
10347    };
10348
10349    let workflow_status =
10350        direct_workflow_status(&result.content).unwrap_or_else(|| "unknown".to_string());
10351    let completed = result.success && workflow_status == "completed";
10352    emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10353        id: tool_id,
10354        name: "workflow".to_string(),
10355        output: result.content.clone(),
10356        status: if completed { "success" } else { "error" }.to_string(),
10357        started_at: tool_started_at,
10358        completed_at: chrono::Utc::now().to_rfc3339(),
10359        duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10360        side_effect_status: result
10361            .metadata
10362            .as_ref()
10363            .and_then(|metadata| metadata.get("side_effect_status"))
10364            .and_then(serde_json::Value::as_str)
10365            .unwrap_or("unknown")
10366            .to_string(),
10367        error_category: (!completed).then(|| "tool_error".to_string()),
10368        truncated: result
10369            .metadata
10370            .as_ref()
10371            .and_then(|metadata| metadata.get("truncated"))
10372            .and_then(serde_json::Value::as_bool),
10373        artifact: tool_artifact_receipt(result.metadata.as_ref()),
10374        result_metadata: result.metadata.clone(),
10375    })?;
10376    emit_exec_stream_event(&ExecStreamEvent::Metadata {
10377        meta: Box::new(ExecStreamMeta {
10378            receipt_kind: "terminal",
10379            provider: route_provider,
10380            provider_id: route_provider_id,
10381            // No parent/operator model call occurs on this host-owned path;
10382            // child model/provider usage remains attributable in typed task
10383            // receipts rather than being misreported as one root model.
10384            model: "host-workflow".to_string(),
10385            route_source: "host_workflow".to_string(),
10386            input_tokens: None,
10387            output_tokens: None,
10388            prompt_cache_hit_tokens: None,
10389            prompt_cache_miss_tokens: None,
10390            prompt_cache_write_tokens: None,
10391            reasoning_tokens: None,
10392            duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10393            retry_count: None,
10394            approval_posture: "explicit_workflow_command".to_string(),
10395            sandbox_posture: "configured".to_string(),
10396            binary_sha256: current_binary_sha256(),
10397            config_sha256: None,
10398            prompt_sha256: workflow_input_sha256,
10399            tool_catalog_sha256: None,
10400            input_analysis: ExecStreamInputAnalysis::default(),
10401            visible_final_answer_chars: result.content.chars().count(),
10402            session_id: String::new(),
10403            resume_command: String::new(),
10404            workspace: workspace.display().to_string(),
10405            message_count: 0,
10406            status: Some(workflow_status.clone()),
10407            termination_reason: Some(if completed { "resolved" } else { "tool_error" }.to_string()),
10408            error_category: (!completed).then(|| "tool".to_string()),
10409            error: (!completed)
10410                .then(|| format!("workflow run ended with terminal status {workflow_status}")),
10411        }),
10412    })?;
10413    if !completed {
10414        let error = format!("workflow run ended with terminal status {workflow_status}");
10415        emit_exec_stream_event(&ExecStreamEvent::Error {
10416            error: error.clone(),
10417        })?;
10418        exit_workflow_tool_failure();
10419    }
10420    emit_exec_stream_event(&ExecStreamEvent::Done)?;
10421    Ok(())
10422}
10423
10424fn exit_workflow_tool_failure() -> ! {
10425    let _ = io::stdout().flush();
10426    std::process::exit(1)
10427}
10428
10429fn exit_workflow_tool_error(tool_id: &str, error: String) -> ! {
10430    let now = chrono::Utc::now().to_rfc3339();
10431    let _ = emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10432        id: tool_id.to_string(),
10433        name: "workflow".to_string(),
10434        output: error.clone(),
10435        status: "error".to_string(),
10436        started_at: now.clone(),
10437        completed_at: now,
10438        duration_ms: 0,
10439        side_effect_status: "unknown".to_string(),
10440        error_category: Some("execution_failed".to_string()),
10441        truncated: None,
10442        artifact: None,
10443        result_metadata: None,
10444    });
10445    let _ = emit_exec_stream_event(&ExecStreamEvent::Error { error });
10446    exit_workflow_tool_failure()
10447}
10448
10449async fn initialize_direct_workflow_mcp_pool(
10450    config: &Config,
10451    workspace: &Path,
10452    network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
10453    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10454) -> Option<(
10455    std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
10456    Vec<(String, String)>,
10457)> {
10458    if !config.features().enabled(Feature::Mcp) {
10459        return None;
10460    }
10461    let mut pool = crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
10462        &config.mcp_config_path(),
10463        workspace,
10464        plugin_registry,
10465    )
10466    .unwrap_or_else(|error| {
10467        tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
10468        crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
10469    });
10470    if let Some(policy) = network_policy {
10471        pool = pool.with_network_policy(policy);
10472    }
10473    let failures = pool
10474        .connect_all()
10475        .await
10476        .into_iter()
10477        .map(|(server, error)| (server, format!("{error:#}")))
10478        .collect();
10479    Some((std::sync::Arc::new(tokio::sync::Mutex::new(pool)), failures))
10480}
10481
10482async fn build_direct_workflow_tool(
10483    config: &Config,
10484    route: &CliAutoRoute,
10485    workspace: &Path,
10486    event_tx: tokio::sync::mpsc::Sender<crate::core::events::Event>,
10487    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10488) -> Result<(
10489    crate::tools::workflow::WorkflowTool,
10490    crate::tools::ToolContext,
10491)> {
10492    use std::sync::Arc;
10493
10494    use crate::client::DeepSeekClient;
10495    use crate::core::authority::shell_policy_for_mode;
10496    use crate::fleet::roster::FleetRoster;
10497    use crate::tools::AgentToolSurfaceOptions;
10498    use crate::tools::goal::new_shared_goal_state;
10499    use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager_with_timeout};
10500    use crate::tools::todo::new_shared_todo_list;
10501    use crate::tui::app::AppMode;
10502
10503    let provider = config.api_provider();
10504    if !config.subagents_enabled_for_provider(provider) {
10505        bail!(
10506            "Workflow dispatch requires sub-agents for provider {} ({})",
10507            provider.as_str(),
10508            config
10509                .subagents_disabled_reason()
10510                .unwrap_or("provider-specific sub-agent configuration disabled it")
10511        );
10512    }
10513
10514    let yolo = config.yolo.unwrap_or(false);
10515    let mode = if yolo {
10516        AppMode::Yolo
10517    } else {
10518        AppMode::Operate
10519    };
10520    let allow_shell = yolo || config.allow_shell();
10521    let shell_policy = shell_policy_for_mode(mode, allow_shell);
10522    let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
10523    let mut context = crate::tools::ToolContext::with_auto_approve(
10524        workspace.to_path_buf(),
10525        yolo,
10526        config.notes_path(),
10527        config.mcp_config_path(),
10528        yolo,
10529    )
10530    .with_features(config.features())
10531    .with_skills_config(
10532        config.skills_dir(),
10533        config.skills_config().scan_codewhale_only(),
10534    )
10535    .with_plugin_registry(std::sync::Arc::clone(&plugin_registry))
10536    .with_shell_policy(shell_policy)
10537    .with_trusted_external_paths(trusted.paths().to_vec())
10538    .with_elevated_sandbox_policy(crate::core::authority::sandbox_policy_for_turn(
10539        mode,
10540        if yolo {
10541            crate::tui::approval::ApprovalMode::Bypass
10542        } else {
10543            crate::tui::approval::ApprovalMode::Suggest
10544        },
10545        config.sandbox_mode.as_deref(),
10546        workspace,
10547    ));
10548    let network_policy = config.network.clone().map(|network| {
10549        crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
10550    });
10551    if let Some(policy) = network_policy.as_ref() {
10552        context = context.with_network_policy(policy.clone());
10553    }
10554    if config.memory_enabled() {
10555        context.memory_path = Some(config.memory_path());
10556    }
10557    context.search_provider = config.search_provider();
10558    context.search_api_key = config
10559        .search
10560        .as_ref()
10561        .and_then(|search| search.api_key.clone());
10562    context.search_base_url = config
10563        .search
10564        .as_ref()
10565        .and_then(|search| search.base_url.clone());
10566    if let Some(backend) = crate::sandbox::backend::create_backend(config)? {
10567        context = context.with_sandbox_backend(Arc::from(backend));
10568    }
10569
10570    let max_subagents = config.max_subagents_for_provider(provider);
10571    let manager = new_shared_subagent_manager_with_timeout(
10572        workspace.to_path_buf(),
10573        max_subagents,
10574        config
10575            .max_admitted_subagents_for_provider(provider)
10576            .max(max_subagents),
10577        Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
10578        config.launch_concurrency_for_provider(provider),
10579        config.subagent_token_budget_for_provider(provider),
10580    );
10581    let roster = Arc::new(FleetRoster::load(&config.fleet_config(), workspace));
10582    let mut role_models = roster.model_overrides();
10583    role_models.extend(config.subagent_model_overrides());
10584
10585    let features = config.features();
10586    let mut surface = AgentToolSurfaceOptions::new(shell_policy);
10587    surface.apply_patch_enabled = features.enabled(Feature::ApplyPatch);
10588    surface.web_search_enabled = features.enabled(Feature::WebSearch);
10589    surface.memory_tool_enabled = config.memory_enabled();
10590    surface.vision_config = features
10591        .enabled(Feature::VisionModel)
10592        .then(|| config.vision_model_config())
10593        .flatten();
10594    surface.speech_output_dir = config.speech_output_dir();
10595    surface.goal_state = Some(new_shared_goal_state());
10596
10597    let client = DeepSeekClient::new(config)?;
10598    // A FIXED model with `reasoning_effort = auto` (the shape a Fleet worker
10599    // subprocess launches with: `--model <exact> --reasoning-effort auto`) is
10600    // still Auto. Deriving the auto flag from `route.auto_model` alone left it
10601    // raw AND non-auto: the runtime carried the literal string `"auto"` while
10602    // nothing was allowed to resolve it. Auto is a reasoning decision, not a
10603    // model decision — it does not require `--model auto`.
10604    let reasoning_effort_auto = route.auto_controls_reasoning;
10605    let reasoning_effort = route
10606        .reasoning_effort
10607        .and_then(|effort| cli_reasoning_effort_value(config, &route.model, effort));
10608    let mcp_pool = if let Some((pool, failures)) =
10609        initialize_direct_workflow_mcp_pool(config, workspace, network_policy, plugin_registry)
10610            .await
10611    {
10612        for (server, error) in failures {
10613            tracing::warn!(
10614                server = %server,
10615                error = %error,
10616                "direct Workflow runtime could not connect MCP server"
10617            );
10618        }
10619        Some(pool)
10620    } else {
10621        None
10622    };
10623    let runtime = SubAgentRuntime::new(
10624        client,
10625        route.model.clone(),
10626        context.clone(),
10627        allow_shell,
10628        Some(event_tx),
10629        manager.clone(),
10630    )
10631    .with_locale_tag(
10632        crate::localization::resolve_locale(
10633            &crate::settings::Settings::load_persisted()
10634                .unwrap_or_default()
10635                .locale,
10636        )
10637        .tag(),
10638    )
10639    .with_role_models(role_models)
10640    .with_api_config(config.clone())
10641    .with_fleet_roster(roster)
10642    .with_auto_model(route.auto_model)
10643    .with_reasoning_effort(reasoning_effort, reasoning_effort_auto)
10644    .with_agent_tool_surface_options(surface)
10645    .with_max_spawn_depth(config.subagent_max_spawn_depth_for_provider(provider))
10646    .with_step_api_timeout(Duration::from_secs(
10647        config.subagent_api_timeout_secs_for_provider(provider),
10648    ))
10649    .with_speech_output_dir(config.speech_output_dir())
10650    .with_mcp_pool(mcp_pool)
10651    .with_todos(new_shared_todo_list())
10652    .with_parent_mode(mode);
10653
10654    Ok((
10655        crate::tools::workflow::WorkflowTool::new(manager, runtime).with_explicit_cli_approval(),
10656        context,
10657    ))
10658}
10659
10660async fn forward_direct_workflow_events(
10661    mut event_rx: tokio::sync::mpsc::Receiver<crate::core::events::Event>,
10662    mut stop_rx: tokio::sync::oneshot::Receiver<()>,
10663) -> Result<()> {
10664    loop {
10665        tokio::select! {
10666            biased;
10667            event = event_rx.recv() => match event {
10668                Some(event) => emit_direct_workflow_event(event)?,
10669                None => return Ok(()),
10670            },
10671            _ = &mut stop_rx => {
10672                while let Ok(event) = event_rx.try_recv() {
10673                    emit_direct_workflow_event(event)?;
10674                }
10675                return Ok(());
10676            }
10677        }
10678    }
10679}
10680
10681fn emit_direct_workflow_event(event: crate::core::events::Event) -> Result<()> {
10682    if let crate::core::events::Event::WorkflowUi { run_id, event } = event {
10683        emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
10684    }
10685    Ok(())
10686}
10687
10688fn direct_workflow_status(content: &str) -> Option<String> {
10689    serde_json::from_str::<serde_json::Value>(content)
10690        .ok()?
10691        .get("status")?
10692        .as_str()
10693        .map(str::to_ascii_lowercase)
10694}
10695
10696fn exec_stream_input_analysis(
10697    messages: &[Message],
10698    system: Option<&SystemPrompt>,
10699) -> ExecStreamInputAnalysis {
10700    let mut analysis = ExecStreamInputAnalysis {
10701        estimated_request_tokens: crate::compaction::estimate_input_tokens_conservative(
10702            messages, system,
10703        ),
10704        estimated_message_content_tokens: crate::compaction::estimate_tokens(messages),
10705        estimated_system_tokens: exec_stream_estimate_system_tokens(system),
10706        estimated_framing_tokens: messages.len().saturating_mul(12).saturating_add(48),
10707        ..ExecStreamInputAnalysis::default()
10708    };
10709
10710    for message in messages {
10711        match message.role.as_str() {
10712            "user" => analysis.user_message_count += 1,
10713            "assistant" => analysis.assistant_message_count += 1,
10714            "tool" => analysis.tool_message_count += 1,
10715            _ => {}
10716        }
10717
10718        for block in &message.content {
10719            match block {
10720                ContentBlock::Text { text, .. } => {
10721                    exec_stream_add_text_estimate(
10722                        text,
10723                        &mut analysis.text_chars,
10724                        &mut analysis.text_estimated_tokens,
10725                    );
10726                }
10727                ContentBlock::Thinking { thinking, .. } => {
10728                    exec_stream_add_text_estimate(
10729                        thinking,
10730                        &mut analysis.thinking_chars,
10731                        &mut analysis.thinking_estimated_tokens,
10732                    );
10733                }
10734                ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
10735                    analysis.tool_use_count += 1;
10736                    exec_stream_add_json_estimate(
10737                        input,
10738                        &mut analysis.tool_use_input_chars,
10739                        &mut analysis.tool_use_input_estimated_tokens,
10740                    );
10741                }
10742                ContentBlock::ToolResult {
10743                    content,
10744                    content_blocks,
10745                    ..
10746                } => {
10747                    analysis.tool_result_count += 1;
10748                    exec_stream_add_text_estimate(
10749                        content,
10750                        &mut analysis.tool_result_chars,
10751                        &mut analysis.tool_result_estimated_tokens,
10752                    );
10753                    if let Some(blocks) = content_blocks {
10754                        exec_stream_add_json_estimate(
10755                            blocks,
10756                            &mut analysis.tool_result_chars,
10757                            &mut analysis.tool_result_estimated_tokens,
10758                        );
10759                    }
10760                }
10761                ContentBlock::ToolSearchToolResult { content, .. }
10762                | ContentBlock::CodeExecutionToolResult { content, .. } => {
10763                    analysis.tool_result_count += 1;
10764                    exec_stream_add_json_estimate(
10765                        content,
10766                        &mut analysis.tool_result_chars,
10767                        &mut analysis.tool_result_estimated_tokens,
10768                    );
10769                }
10770                ContentBlock::ImageUrl { .. } => {}
10771            }
10772        }
10773    }
10774
10775    analysis
10776}
10777
10778fn exec_stream_add_text_estimate(text: &str, chars: &mut usize, tokens: &mut usize) {
10779    *chars = chars.saturating_add(text.chars().count());
10780    *tokens = tokens.saturating_add(crate::compaction::estimate_text_tokens_conservative(text));
10781}
10782
10783fn exec_stream_add_json_estimate<T: serde::Serialize>(
10784    value: &T,
10785    chars: &mut usize,
10786    tokens: &mut usize,
10787) {
10788    let text = serde_json::to_string(value).unwrap_or_default();
10789    exec_stream_add_text_estimate(&text, chars, tokens);
10790}
10791
10792fn exec_stream_estimate_system_tokens(system: Option<&SystemPrompt>) -> usize {
10793    match system {
10794        Some(SystemPrompt::Text(text)) => {
10795            crate::compaction::estimate_text_tokens_conservative(text)
10796        }
10797        Some(SystemPrompt::Blocks(blocks)) => blocks
10798            .iter()
10799            .map(|block| crate::compaction::estimate_text_tokens_conservative(&block.text))
10800            .sum(),
10801        None => 0,
10802    }
10803}
10804
10805fn exec_saved_session_line(session_id: &str) -> String {
10806    format!("session: {}", truncate_id(session_id))
10807}
10808
10809fn exec_resumed_session_line(session_id: &str) -> String {
10810    format!("resumed session: {}", truncate_id(session_id))
10811}
10812
10813fn exec_stream_session_ref(session_id: &str) -> String {
10814    crate::utils::redacted_identifier_for_log(session_id)
10815}
10816
10817fn exec_stream_resume_hint(session_id: &str) -> String {
10818    if session_id.trim().is_empty() {
10819        String::new()
10820    } else {
10821        "codewhale exec --resume <redacted-session-id>".to_string()
10822    }
10823}
10824
10825#[derive(Clone, Copy)]
10826struct PersistedProviderRoute<'a> {
10827    kind: &'a str,
10828    id: Option<&'a str>,
10829}
10830
10831fn persist_exec_session(
10832    messages: &[Message],
10833    model: &str,
10834    provider_route: PersistedProviderRoute<'_>,
10835    workspace: &Path,
10836    system_prompt: &Option<SystemPrompt>,
10837    session_id: Option<&str>,
10838    total_tokens: u64,
10839) -> Result<String> {
10840    let manager =
10841        SessionManager::default_location().context("could not open session manager for save")?;
10842    let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) {
10843        match manager.load_session(id) {
10844            Ok(existing) => session_manager::update_session(
10845                existing,
10846                messages,
10847                total_tokens,
10848                system_prompt.as_ref(),
10849            ),
10850            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
10851                session_manager::create_saved_session_with_id_and_mode(
10852                    id.to_string(),
10853                    messages,
10854                    model,
10855                    workspace,
10856                    total_tokens,
10857                    system_prompt.as_ref(),
10858                    Some("exec"),
10859                )
10860            }
10861            Err(err) => return Err(err).context("could not load existing exec session"),
10862        }
10863    } else {
10864        session_manager::create_saved_session_with_mode(
10865            messages,
10866            model,
10867            workspace,
10868            total_tokens,
10869            system_prompt.as_ref(),
10870            Some("exec"),
10871        )
10872    };
10873    stamp_exec_session_metadata(
10874        &mut saved,
10875        model,
10876        provider_route.kind,
10877        provider_route.id,
10878        workspace,
10879    );
10880    let id = saved.metadata.id.clone();
10881    manager
10882        .save_session(&saved)
10883        .context("could not save exec session")?;
10884    Ok(id)
10885}
10886
10887fn stamp_exec_session_metadata(
10888    saved: &mut session_manager::SavedSession,
10889    model: &str,
10890    model_provider_kind: &str,
10891    model_provider_id: Option<&str>,
10892    workspace: &Path,
10893) {
10894    saved.metadata.model = model.to_string();
10895    saved
10896        .metadata
10897        .set_model_provider_route(model_provider_kind, model_provider_id);
10898    saved.metadata.workspace = workspace.to_path_buf();
10899    saved.metadata.mode = Some("exec".to_string());
10900}
10901
10902#[derive(serde::Serialize)]
10903struct ExecToolEntry {
10904    name: String,
10905    success: bool,
10906    output: String,
10907}
10908
10909#[derive(serde::Serialize)]
10910struct ExecOutcome {
10911    kind: String,
10912    outcome: String,
10913    tool_name: String,
10914    reason: String,
10915}
10916
10917#[derive(serde::Serialize, Default)]
10918struct ExecSummary {
10919    mode: String,
10920    provider: String,
10921    model: String,
10922    prompt: String,
10923    output: String,
10924    tools: Vec<ExecToolEntry>,
10925    outcomes: Vec<ExecOutcome>,
10926    status: Option<String>,
10927    termination_reason: Option<String>,
10928    error_category: Option<String>,
10929    error: Option<String>,
10930    #[serde(skip_serializing_if = "Vec::is_empty")]
10931    released_services: Vec<crate::tools::shell::PersistentServiceReceipt>,
10932}
10933
10934fn validate_exec_tool_authority_resume(
10935    tool_authority_json: Option<&str>,
10936    resuming: bool,
10937) -> Result<()> {
10938    if tool_authority_json.is_some() && resuming {
10939        bail!(
10940            "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue"
10941        );
10942    }
10943    Ok(())
10944}
10945
10946fn exec_network_policy(
10947    config: &Config,
10948    outer_network_access: Option<bool>,
10949) -> Option<crate::network_policy::NetworkPolicyDecider> {
10950    // Fleet caps are an outer authority boundary: user configuration may
10951    // narrow them further, but it may never widen an explicit network denial.
10952    if outer_network_access == Some(false) {
10953        return Some(crate::network_policy::NetworkPolicyDecider::new(
10954            crate::network_policy::NetworkPolicy {
10955                default: crate::network_policy::DecisionToml::Deny,
10956                ..crate::network_policy::NetworkPolicy::default()
10957            },
10958            None,
10959        ));
10960    }
10961    config.network.clone().map(|toml_cfg| {
10962        crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
10963    })
10964}
10965
10966fn apply_fleet_engine_feature_caps(
10967    features: &mut crate::features::Features,
10968    fleet_authority_active: bool,
10969    outer_network_access: Option<bool>,
10970    shell_authority: crate::tools::spec::ToolShellAuthority,
10971) {
10972    if fleet_authority_active {
10973        features.disable(crate::features::Feature::Subagents);
10974        features.disable(crate::features::Feature::Mcp);
10975        if shell_authority != crate::tools::spec::ToolShellAuthority::ReadOnly {
10976            features.disable(crate::features::Feature::ShellTool);
10977        }
10978    }
10979    if outer_network_access == Some(false) {
10980        features.disable(crate::features::Feature::WebSearch);
10981    }
10982}
10983
10984/// Resolve the optional headless safety budget without imposing a hidden
10985/// default. Benchmarks and other long-running exec callers continue until the
10986/// model finishes unless they opt into a finite `--max-turns` value.
10987fn exec_max_steps(max_turns: Option<u32>) -> u32 {
10988    max_turns.unwrap_or(u32::MAX)
10989}
10990
10991#[allow(clippy::too_many_arguments)]
10992async fn run_exec_agent(
10993    config: &Config,
10994    model: &str,
10995    prompt: &str,
10996    workspace: PathBuf,
10997    max_subagents: usize,
10998    auto_approve: bool,
10999    allow_sandbox_elevation: bool,
11000    explicit_sandbox: Option<&str>,
11001    trust_mode: bool,
11002    json_output: bool,
11003    resume_session: Option<session_manager::SavedSession>,
11004    force_configured_route: bool,
11005    output_format: ExecOutputFormat,
11006    max_turns: u32,
11007    allowed_tools: Option<Vec<String>>,
11008    disallowed_tools: Option<Vec<String>>,
11009    append_system_prompt: Option<String>,
11010    tool_authority_json: Option<String>,
11011    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
11012) -> Result<()> {
11013    use crate::compaction::CompactionConfig;
11014    use crate::core::engine::{EngineConfig, spawn_engine};
11015    use crate::core::events::Event;
11016    use crate::core::ops::Op;
11017    use crate::tools::plan::new_shared_plan_state;
11018    use crate::tools::todo::new_shared_todo_list;
11019    use crate::tui::app::AppMode;
11020
11021    validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
11022    let fleet_authority = tool_authority_json
11023        .as_deref()
11024        .map(crate::tools::spec::ToolAuthorityEnvelope::from_json)
11025        .transpose()
11026        .map_err(anyhow::Error::msg)?;
11027    let fleet_authority_active = fleet_authority.is_some();
11028    let outer_network_access = fleet_authority
11029        .as_ref()
11030        .and_then(|authority| authority.network_access);
11031    let outer_shell_authority = fleet_authority
11032        .as_ref()
11033        .map(|authority| authority.shell)
11034        .unwrap_or_default();
11035    if let Some(envelope) = fleet_authority {
11036        crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?;
11037    }
11038
11039    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
11040    let execution_config = config_for_cli_route(config, &route);
11041    let auto_model = route.auto_model;
11042    let effective_provider = route.provider;
11043    let effective_model = route.model;
11044    let validated_route = crate::route_runtime::resolve_runtime_route(
11045        &execution_config,
11046        effective_provider,
11047        Some(&effective_model),
11048    )
11049    .map_err(anyhow::Error::msg)?
11050    .validate()
11051    .map_err(anyhow::Error::msg)?;
11052    let effective_provider_name = validated_route.identity.key.clone();
11053    let effective_provider_id = validated_route.identity.exact_id.clone();
11054    let (effective_provider_kind, effective_stream_provider_id) =
11055        exec_stream_provider_route(&validated_route.identity);
11056    let route_source = if auto_model {
11057        "auto_resolver"
11058    } else {
11059        "explicit_or_configured"
11060    }
11061    .to_string();
11062    let exec_started = Instant::now();
11063    let prompt_sha256 = format!("sha256:{}", crate::hashing::sha256_hex(prompt.as_bytes()));
11064    let binary_sha256 = current_binary_sha256();
11065    let approval_posture = if auto_approve { "auto_tools" } else { "ask" }.to_string();
11066    let sandbox_posture = explicit_sandbox.unwrap_or("configured_default").to_string();
11067    let active_route_limits =
11068        crate::route_budget::known_route_limits(validated_route.candidate.limits());
11069    let max_subagents = if max_subagents == config.max_subagents_for_provider(config.api_provider())
11070    {
11071        execution_config
11072            .max_subagents_for_provider(effective_provider)
11073            .clamp(1, MAX_SUBAGENTS)
11074    } else {
11075        max_subagents
11076    };
11077    // A FIXED model with `--reasoning-effort auto` (the exact shape a Fleet
11078    // worker subprocess launches with: `--model <exact> --reasoning-effort
11079    // auto`) is still Auto. `auto_model` is a *model* decision and is false
11080    // here, so deriving the auto flag from it left this path both raw and
11081    // non-auto: the literal string `"auto"` travelled to the engine while the
11082    // receipt claimed no Auto was in play.
11083    let reasoning_effort_auto = route.auto_controls_reasoning;
11084    // Resolve Auto against this run's prompt at the CLI boundary, exactly like
11085    // `run_one_shot`/`run_one_shot_json` and the interactive launch path do,
11086    // so the tier the engine (and the receipt below) sees is concrete.
11087    let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| {
11088        cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt)
11089    });
11090
11091    let settings = crate::settings::Settings::load().unwrap_or_default();
11092    let auto_compact_enabled = if crate::settings::Settings::auto_compact_explicitly_configured() {
11093        settings.auto_compact
11094    } else {
11095        crate::route_budget::auto_compact_default_for_route(
11096            effective_provider,
11097            &effective_model,
11098            active_route_limits,
11099        )
11100    };
11101    let compaction = CompactionConfig {
11102        enabled: auto_compact_enabled,
11103        model: effective_model.clone(),
11104        effective_context_window: Some(crate::route_budget::route_context_window_tokens(
11105            effective_provider,
11106            &effective_model,
11107            active_route_limits,
11108        )),
11109        token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
11110            effective_provider,
11111            &effective_model,
11112            active_route_limits,
11113            settings.auto_compact_threshold_percent,
11114        ),
11115        ..Default::default()
11116    };
11117
11118    let network_policy = exec_network_policy(&execution_config, outer_network_access);
11119
11120    let lsp_config = (!fleet_authority_active)
11121        .then(|| {
11122            execution_config
11123                .lsp
11124                .clone()
11125                .map(crate::config::LspConfigToml::into_runtime)
11126        })
11127        .flatten();
11128    let mut engine_features = execution_config.features();
11129    apply_fleet_engine_feature_caps(
11130        &mut engine_features,
11131        fleet_authority_active,
11132        outer_network_access,
11133        outer_shell_authority,
11134    );
11135    if crate::core::allowlist_is_native_file_and_shell_only(allowed_tools.as_deref()) {
11136        engine_features.disable(crate::features::Feature::Mcp);
11137    }
11138    let engine_plugin_registry = if fleet_authority_active {
11139        std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
11140    } else {
11141        plugin_registry
11142    };
11143    let exec_allow_shell = crate::tools::spec::fleet_exec_shell_enabled(
11144        fleet_authority_active,
11145        outer_shell_authority,
11146        disallowed_tools.as_deref(),
11147    ) || (!fleet_authority_active
11148        && (auto_approve || execution_config.allow_shell()));
11149    let persist_services_enabled = cfg!(unix)
11150        && !fleet_authority_active
11151        && exec_allow_shell
11152        && explicit_sandbox
11153            .is_some_and(|sandbox| sandbox.eq_ignore_ascii_case("danger-full-access"));
11154    let exec_shell_manager = crate::tools::shell::new_shared_shell_manager(workspace.clone());
11155    let runtime_services = crate::tools::spec::RuntimeToolServices {
11156        shell_manager: Some(exec_shell_manager.clone()),
11157        persist_services_enabled,
11158        ..crate::tools::spec::RuntimeToolServices::default()
11159    };
11160
11161    let engine_config = EngineConfig {
11162        model: effective_model.clone(),
11163        active_route_limits,
11164        workspace: workspace.clone(),
11165        subagent_state_root: None,
11166        plugin_registry: Some(engine_plugin_registry),
11167        allow_shell: exec_allow_shell,
11168        trust_mode,
11169        notes_path: execution_config.notes_path(),
11170        mcp_config_path: execution_config.mcp_config_path(),
11171        skills_dir: execution_config.skills_dir(),
11172        skills_scan_codewhale_only: execution_config.skills_config().scan_codewhale_only(),
11173        instructions: {
11174            let mut instrs: Vec<crate::prompts::InstructionSource> = execution_config
11175                .instructions_paths()
11176                .into_iter()
11177                .map(Into::into)
11178                .collect();
11179            if let Some(ref extra) = append_system_prompt {
11180                instrs.push(crate::prompts::InstructionSource::Inline {
11181                    name: "cli:append-system-prompt".into(),
11182                    content: extra.clone(),
11183                });
11184            }
11185            instrs
11186        },
11187        project_context_pack_enabled: execution_config.project_context_pack_enabled(),
11188        translation_enabled: false,
11189        max_steps: max_turns,
11190        max_subagents,
11191        max_admitted_subagents: execution_config
11192            .max_admitted_subagents_for_provider(effective_provider)
11193            .max(max_subagents),
11194        launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider),
11195        subagents_enabled: !fleet_authority_active
11196            && execution_config.subagents_enabled_for_provider(effective_provider),
11197        features: engine_features,
11198        auto_review_policy: execution_config.auto_review_policy(),
11199        compaction: compaction.clone(),
11200        todos: new_shared_todo_list(),
11201        plan_state: new_shared_plan_state(),
11202        goal_state: crate::tools::goal::new_shared_goal_state(),
11203        max_spawn_depth: if fleet_authority_active {
11204            0
11205        } else {
11206            execution_config.subagent_max_spawn_depth_for_provider(effective_provider)
11207        },
11208        subagent_token_budget: execution_config
11209            .subagent_token_budget_for_provider(effective_provider),
11210        network_policy,
11211        snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled,
11212        snapshots_max_workspace_bytes: execution_config
11213            .snapshots_config()
11214            .max_workspace_gb
11215            .saturating_mul(1024 * 1024 * 1024),
11216        lsp_config,
11217        runtime_services,
11218        subagent_model_overrides: execution_config.subagent_model_overrides(),
11219        fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
11220            &execution_config.fleet_config(),
11221            &workspace,
11222        )),
11223        subagent_api_timeout: std::time::Duration::from_secs(
11224            execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
11225        ),
11226        stream_chunk_timeout: std::time::Duration::from_secs(
11227            execution_config.stream_chunk_timeout_secs(),
11228        ),
11229        subagent_heartbeat_timeout: std::time::Duration::from_secs(
11230            execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
11231        ),
11232        prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
11233        memory_enabled: execution_config.memory_enabled(),
11234        memory_path: execution_config.memory_path(),
11235        speech_output_dir: execution_config.speech_output_dir(),
11236        vision_config: execution_config.vision_model_config(),
11237        strict_tool_mode: execution_config.strict_tool_mode.unwrap_or(false),
11238        goal_objective: None,
11239        goal_token_budget: None,
11240        goal_status: crate::tools::goal::GoalStatus::Active,
11241        goal_max_continuations: execution_config.goal_max_continuations(),
11242        allowed_tools: allowed_tools.clone(),
11243        disallowed_tools: disallowed_tools.clone(),
11244        max_tool_calls: None,
11245        hook_executor: None,
11246        locale_tag: crate::localization::resolve_locale(&settings.locale)
11247            .tag()
11248            .to_string(),
11249        workshop: config.workshop.clone(),
11250        search_provider: execution_config.search_provider(),
11251        search_api_key: execution_config
11252            .search
11253            .as_ref()
11254            .and_then(|s| s.api_key.clone()),
11255        search_base_url: execution_config
11256            .search
11257            .as_ref()
11258            .and_then(|s| s.base_url.clone()),
11259        tools_always_load: if fleet_authority_active {
11260            std::collections::HashSet::new()
11261        } else {
11262            execution_config.tools_always_load()
11263        },
11264        tools: if fleet_authority_active {
11265            None
11266        } else {
11267            execution_config.tools.clone()
11268        },
11269        verbosity: execution_config.verbosity.clone(),
11270        workspace_follow_symlinks: settings.workspace_follow_symlinks,
11271        exec_policy_engine: execution_config.exec_policy_engine.clone(),
11272        terminal_chrome_enabled: false,
11273        advisor_config: execution_config
11274            .advisor
11275            .as_ref()
11276            .map(crate::tools::subagent::AdvisorConfig::from_toml)
11277            .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
11278    };
11279
11280    let engine_handle = spawn_engine(engine_config, &execution_config);
11281    let mode = if auto_approve {
11282        AppMode::Yolo
11283    } else {
11284        AppMode::Agent
11285    };
11286
11287    let resuming_session = resume_session.is_some();
11288    let mut loaded_session_id = None;
11289    if let Some(saved) = resume_session {
11290        let saved_id = saved.metadata.id.clone();
11291        if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text {
11292            eprintln!(
11293                "Warning: session {} was created in a different workspace ({}). Resuming anyway.",
11294                truncate_id(&saved_id),
11295                saved.metadata.workspace.display(),
11296            );
11297        }
11298
11299        engine_handle
11300            .send(Op::SyncSession {
11301                session_id: Some(saved_id.clone()),
11302                messages: saved.messages,
11303                system_prompt: saved.system_prompt.map(SystemPrompt::Text),
11304                system_prompt_override: false,
11305                model: saved.metadata.model,
11306                workspace: saved.metadata.workspace,
11307                mode,
11308            })
11309            .await?;
11310        loaded_session_id = Some(saved_id.clone());
11311        if output_format == ExecOutputFormat::Text && !json_output {
11312            eprintln!("{}", exec_resumed_session_line(&saved_id));
11313        }
11314    }
11315
11316    engine_handle
11317        .send(Op::SendMessage {
11318            content: prompt.to_string(),
11319            mode,
11320            route: Box::new(validated_route.into_resolved()),
11321            compaction: Box::new(compaction.clone()),
11322            goal_objective: None,
11323            goal_token_budget: None,
11324            goal_status: crate::tools::goal::GoalStatus::Active,
11325            allowed_tools: allowed_tools.clone(),
11326            dynamic_tools: Vec::new(),
11327            hook_executor: None,
11328            reasoning_effort: effective_reasoning_effort,
11329            reasoning_effort_auto,
11330            auto_model,
11331            allow_shell: auto_approve || execution_config.allow_shell(),
11332            trust_mode,
11333            auto_approve,
11334            translation_enabled: false,
11335            approval_mode: if auto_approve {
11336                crate::tui::approval::ApprovalMode::Bypass
11337            } else {
11338                execution_config
11339                    .approval_policy
11340                    .as_deref()
11341                    .and_then(crate::tui::approval::ApprovalMode::from_config_value)
11342                    .unwrap_or_default()
11343            },
11344            verbosity: execution_config.verbosity.clone(),
11345            provenance: crate::core::ops::UserInputProvenance::ExternalUser,
11346        })
11347        .await?;
11348
11349    let mut summary = ExecSummary {
11350        mode: "agent".to_string(),
11351        provider: effective_provider_name.clone(),
11352        model: effective_model.clone(),
11353        prompt: prompt.to_string(),
11354        ..ExecSummary::default()
11355    };
11356    let can_elevate_sandbox =
11357        exec_sandbox_elevation_authorized(allow_sandbox_elevation, explicit_sandbox);
11358    let mut sandbox_denied = false;
11359    let mut approval_required = false;
11360    let mut tool_error_seen = false;
11361    let mut last_error_category = None;
11362    let mut reported_sandbox_contract = false;
11363
11364    let should_persist_session = resuming_session || output_format == ExecOutputFormat::StreamJson;
11365    let mut latest_session_id = loaded_session_id;
11366    let mut latest_messages: Vec<Message> = Vec::new();
11367    let mut latest_system_prompt: Option<SystemPrompt> = None;
11368    let mut latest_model = effective_model;
11369    let mut latest_workspace = workspace.clone();
11370    let mut tool_starts: HashMap<String, (Instant, String)> = HashMap::new();
11371    let mut turn_usage_seq: u32 = 0;
11372
11373    let mut stdout = io::stdout();
11374    let mut ends_with_newline = false;
11375    loop {
11376        let event = {
11377            let mut rx = engine_handle.rx_event.write().await;
11378            rx.recv().await
11379        };
11380
11381        let Some(event) = event else {
11382            break;
11383        };
11384
11385        match event {
11386            Event::MessageDelta { content, .. } => {
11387                summary.output.push_str(&content);
11388                if output_format == ExecOutputFormat::StreamJson {
11389                    emit_exec_stream_event(&ExecStreamEvent::Content { content })?;
11390                } else if !json_output {
11391                    print!("{content}");
11392                    stdout.flush()?;
11393                }
11394                ends_with_newline = summary.output.ends_with('\n');
11395            }
11396            Event::MessageComplete { .. }
11397                if output_format == ExecOutputFormat::Text
11398                    && !json_output
11399                    && !ends_with_newline =>
11400            {
11401                println!();
11402            }
11403            Event::ThinkingDelta { .. } => {
11404                // Exec stream-json intentionally omits reasoning deltas; the
11405                // TUI transcript retains its existing Activity Detail surface.
11406            }
11407            Event::ToolCallStarted { id, name, input } => {
11408                let started_at = chrono::Utc::now().to_rfc3339();
11409                tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
11410                if output_format == ExecOutputFormat::StreamJson {
11411                    emit_exec_stream_event(&ExecStreamEvent::ToolUse {
11412                        name,
11413                        id,
11414                        input,
11415                        started_at,
11416                    })?;
11417                } else if !json_output {
11418                    let summary = summarize_tool_args(&input);
11419                    if let Some(summary) = summary {
11420                        eprintln!("tool: {name} ({summary})");
11421                    } else {
11422                        eprintln!("tool: {name}");
11423                    }
11424                }
11425            }
11426            Event::ToolCallComplete {
11427                id, name, result, ..
11428            } => {
11429                let (duration_ms, started_at) = tool_starts
11430                    .remove(&id)
11431                    .map(|(started, timestamp)| {
11432                        (
11433                            u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
11434                            timestamp,
11435                        )
11436                    })
11437                    .unwrap_or_else(|| (0, chrono::Utc::now().to_rfc3339()));
11438                let receipt_name = name.clone();
11439                match result {
11440                    Ok(output) => {
11441                        tool_error_seen |= !output.success;
11442                        summary.tools.push(ExecToolEntry {
11443                            name: name.clone(),
11444                            success: output.success,
11445                            output: output.content.clone(),
11446                        });
11447                        if output_format == ExecOutputFormat::StreamJson {
11448                            emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11449                                id,
11450                                name: receipt_name,
11451                                output: output.content,
11452                                status: if output.success {
11453                                    "success".to_string()
11454                                } else {
11455                                    "error".to_string()
11456                                },
11457                                started_at,
11458                                completed_at: chrono::Utc::now().to_rfc3339(),
11459                                duration_ms,
11460                                side_effect_status: output
11461                                    .metadata
11462                                    .as_ref()
11463                                    .and_then(|metadata| metadata.get("side_effect_status"))
11464                                    .and_then(serde_json::Value::as_str)
11465                                    .unwrap_or("unknown")
11466                                    .to_string(),
11467                                error_category: (!output.success).then(|| {
11468                                    output
11469                                        .metadata
11470                                        .as_ref()
11471                                        .and_then(|metadata| metadata.get("error_category"))
11472                                        .and_then(serde_json::Value::as_str)
11473                                        .unwrap_or("tool_reported_failure")
11474                                        .to_string()
11475                                }),
11476                                truncated: output
11477                                    .metadata
11478                                    .as_ref()
11479                                    .and_then(|metadata| metadata.get("truncated"))
11480                                    .and_then(serde_json::Value::as_bool),
11481                                artifact: tool_artifact_receipt(output.metadata.as_ref()),
11482                                result_metadata: output.metadata,
11483                            })?;
11484                        } else if !json_output {
11485                            if name == "exec_shell" && !output.content.trim().is_empty() {
11486                                eprintln!("tool {name} completed");
11487                                eprintln!(
11488                                    "--- stdout/stderr ---\n{}\n---------------------",
11489                                    output.content
11490                                );
11491                            } else {
11492                                eprintln!(
11493                                    "tool {name} completed: {}",
11494                                    summarize_tool_output(&output.content)
11495                                );
11496                            }
11497                        }
11498                    }
11499                    Err(err) => {
11500                        tool_error_seen = true;
11501                        let error_text = err.to_string();
11502                        summary.tools.push(ExecToolEntry {
11503                            name: name.clone(),
11504                            success: false,
11505                            output: error_text.clone(),
11506                        });
11507                        if output_format == ExecOutputFormat::StreamJson {
11508                            emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11509                                id,
11510                                name: receipt_name,
11511                                output: error_text,
11512                                status: "error".to_string(),
11513                                started_at,
11514                                completed_at: chrono::Utc::now().to_rfc3339(),
11515                                duration_ms,
11516                                side_effect_status: "not_started_or_unknown".to_string(),
11517                                error_category: Some(tool_error_receipt_category(&err).to_string()),
11518                                truncated: None,
11519                                artifact: None,
11520                                result_metadata: None,
11521                            })?;
11522                        } else if !json_output {
11523                            eprintln!("tool {name} failed: {err}");
11524                        }
11525                    }
11526                }
11527            }
11528            Event::AgentSpawned { id, prompt, .. }
11529                if output_format == ExecOutputFormat::Text && !json_output =>
11530            {
11531                eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
11532            }
11533            Event::AgentProgress { id, status, .. }
11534                if output_format == ExecOutputFormat::Text && !json_output =>
11535            {
11536                eprintln!("sub-agent {id}: {status}");
11537            }
11538            Event::AgentComplete { id, result }
11539                if output_format == ExecOutputFormat::Text && !json_output =>
11540            {
11541                eprintln!(
11542                    "sub-agent {id} completed: {}",
11543                    summarize_tool_output(&result)
11544                );
11545            }
11546            Event::AgentSpawned {
11547                id,
11548                parent_run_id,
11549                spawn_depth,
11550                model,
11551                route_source,
11552                ..
11553            } if output_format == ExecOutputFormat::StreamJson => {
11554                emit_exec_stream_event(&ExecStreamEvent::AgentSpawned {
11555                    id,
11556                    model,
11557                    spawn_depth,
11558                    parent_run_id,
11559                    route_source,
11560                })?;
11561            }
11562            Event::AgentSpawned { .. }
11563            | Event::AgentProgress { .. }
11564            | Event::AgentComplete { .. } => {}
11565            Event::WorkflowUi { run_id, event }
11566                if output_format == ExecOutputFormat::StreamJson =>
11567            {
11568                emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11569            }
11570            Event::ApprovalRequired { id, .. } => {
11571                if auto_approve {
11572                    let _ = engine_handle.approve_tool_call(id).await;
11573                } else {
11574                    approval_required = true;
11575                    let _ = engine_handle.deny_tool_call(id).await;
11576                }
11577            }
11578            Event::ElevationRequired {
11579                tool_id,
11580                tool_name,
11581                denial_reason,
11582                ..
11583            } => {
11584                if can_elevate_sandbox {
11585                    let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
11586                    let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
11587                } else {
11588                    sandbox_denied = true;
11589                    approval_required = true;
11590                    summary.outcomes.push(ExecOutcome {
11591                        kind: "sandbox_denied".to_string(),
11592                        outcome: "approval_required".to_string(),
11593                        tool_name: tool_name.clone(),
11594                        reason: denial_reason.clone(),
11595                    });
11596                    if !reported_sandbox_contract {
11597                        eprintln!(
11598                            "sandbox denied {tool_name}: {denial_reason}; --auto approves tools but does not elevate sandbox access — use --sandbox danger-full-access or --allow-sandbox-elevation to opt in"
11599                        );
11600                        reported_sandbox_contract = true;
11601                    }
11602                    if output_format == ExecOutputFormat::StreamJson {
11603                        emit_exec_stream_event(&ExecStreamEvent::SandboxDenied {
11604                            tool_id: tool_id.clone(),
11605                            tool_name,
11606                            reason: denial_reason,
11607                            outcome: "approval_required".to_string(),
11608                        })?;
11609                    }
11610                    let _ = engine_handle.deny_tool_call(tool_id).await;
11611                }
11612            }
11613            Event::Error {
11614                envelope,
11615                recoverable: _,
11616            } => {
11617                // Only a non-recoverable envelope may force the run summary
11618                // into failure. Recoverable warnings (stream-stall notices,
11619                // transient retry noise) are still streamed for visibility,
11620                // but the terminal TurnComplete event carries the
11621                // authoritative turn outcome — letting a warning set
11622                // `summary.error` here would exit an otherwise-successful
11623                // `exec` run non-zero.
11624                if exec_error_event_is_fatal(&envelope) {
11625                    last_error_category = Some(envelope.category);
11626                    summary.error_category = Some(envelope.category.to_string());
11627                    summary.error = Some(envelope.message.clone());
11628                }
11629                if output_format == ExecOutputFormat::StreamJson {
11630                    emit_exec_stream_event(&ExecStreamEvent::Error {
11631                        error: envelope.message,
11632                    })?;
11633                } else if !json_output {
11634                    eprintln!("error: {}", envelope.message);
11635                }
11636            }
11637            Event::TurnUsage { usage, duration_ms } => {
11638                if output_format == ExecOutputFormat::StreamJson {
11639                    turn_usage_seq = turn_usage_seq.saturating_add(1);
11640                    emit_exec_stream_event(&ExecStreamEvent::TurnUsage {
11641                        turn: turn_usage_seq,
11642                        input_tokens: usage.input_tokens,
11643                        output_tokens: usage.output_tokens,
11644                        reasoning_tokens: usage.reasoning_tokens,
11645                        prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11646                        prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11647                        prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11648                        reasoning_replay_tokens: usage.reasoning_replay_tokens,
11649                        duration_ms,
11650                    })?;
11651                }
11652            }
11653            Event::TurnComplete {
11654                status,
11655                error,
11656                usage,
11657                tool_catalog,
11658                ..
11659            } => {
11660                let (terminal_status, terminal_error) = (status, error);
11661                #[cfg(unix)]
11662                let (mut terminal_status, mut terminal_error) = (terminal_status, terminal_error);
11663                if matches!(
11664                    terminal_status,
11665                    crate::core::events::TurnOutcomeStatus::Completed
11666                ) && terminal_error.is_none()
11667                {
11668                    #[cfg(unix)]
11669                    match exec_shell_manager.lock() {
11670                        Ok(mut manager) => match manager.commit_persistent_services() {
11671                            Ok(receipts) => {
11672                                for receipt in &receipts {
11673                                    if output_format == ExecOutputFormat::StreamJson {
11674                                        emit_exec_stream_event(
11675                                            &ExecStreamEvent::ServiceReleased {
11676                                                task_id: receipt.task_id.clone(),
11677                                                pid: receipt.pid,
11678                                                process_group_id: receipt.process_group_id,
11679                                                ownership: receipt.ownership.clone(),
11680                                            },
11681                                        )?;
11682                                    } else if !json_output {
11683                                        eprintln!(
11684                                            "persistent service released: {} pid={} pgid={} ownership={}",
11685                                            receipt.task_id,
11686                                            receipt.pid,
11687                                            receipt.process_group_id,
11688                                            receipt.ownership
11689                                        );
11690                                    }
11691                                }
11692                                summary.released_services.extend(receipts);
11693                            }
11694                            Err(error) => {
11695                                manager.abort_persistent_services();
11696                                terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11697                                terminal_error = Some(format!(
11698                                    "Persistent service ownership transfer failed: {error}"
11699                                ));
11700                            }
11701                        },
11702                        Err(_) => {
11703                            terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11704                            terminal_error = Some(
11705                                "Persistent service ownership transfer failed: shell manager lock poisoned"
11706                                    .to_string(),
11707                            );
11708                        }
11709                    }
11710                } else if let Ok(mut manager) = exec_shell_manager.lock() {
11711                    manager.abort_persistent_services();
11712                }
11713                summary.status = Some(format!("{terminal_status:?}").to_lowercase());
11714                if terminal_error.is_some() {
11715                    summary.error = terminal_error;
11716                }
11717                if sandbox_denied
11718                    && summary.error.is_none()
11719                    && matches!(
11720                        terminal_status,
11721                        crate::core::events::TurnOutcomeStatus::Failed
11722                    )
11723                {
11724                    summary.error = Some(
11725                        "exec turn failed after sandbox denial; explicit sandbox elevation was not authorized"
11726                            .to_string(),
11727                    );
11728                }
11729                if last_error_category.is_none() {
11730                    last_error_category = summary
11731                        .error
11732                        .as_deref()
11733                        .map(crate::error_taxonomy::classify_error_message);
11734                    summary.error_category =
11735                        last_error_category.map(|category| category.to_string());
11736                }
11737                let termination_reason = crate::core::termination::classify_turn_termination(
11738                    terminal_status,
11739                    last_error_category,
11740                    tool_error_seen,
11741                    approval_required,
11742                );
11743                summary.termination_reason = Some(termination_reason.as_str().to_string());
11744                // State the exit class here rather than inferring it later
11745                // from the process exit code: `Canceled` exits 130, the same
11746                // value the SIGINT path uses, so a code-based derivation would
11747                // report every Esc-cancelled turn as a signal. A no-op unless
11748                // this process was armed.
11749                if !termination_reason.is_success() {
11750                    codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
11751                }
11752                let saved_session_id = if should_persist_session && !latest_messages.is_empty() {
11753                    match persist_exec_session(
11754                        &latest_messages,
11755                        &latest_model,
11756                        PersistedProviderRoute {
11757                            kind: effective_provider.as_str(),
11758                            id: effective_provider_id.as_deref(),
11759                        },
11760                        &latest_workspace,
11761                        &latest_system_prompt,
11762                        latest_session_id.as_deref(),
11763                        u64::from(usage.input_tokens) + u64::from(usage.output_tokens),
11764                    ) {
11765                        Ok(id) => {
11766                            if output_format == ExecOutputFormat::Text && !json_output {
11767                                eprintln!("{}", exec_saved_session_line(&id));
11768                            }
11769                            Some(id)
11770                        }
11771                        Err(err) => {
11772                            if output_format == ExecOutputFormat::Text && !json_output {
11773                                eprintln!("warning: failed to save exec session: {err}");
11774                            }
11775                            latest_session_id.clone()
11776                        }
11777                    }
11778                } else {
11779                    latest_session_id.clone()
11780                };
11781                if output_format == ExecOutputFormat::StreamJson {
11782                    if let Some(id) = saved_session_id.as_ref() {
11783                        emit_exec_stream_event(&ExecStreamEvent::SessionCapture {
11784                            content: exec_stream_session_ref(id),
11785                        })?;
11786                    }
11787                    emit_exec_stream_event(&ExecStreamEvent::Metadata {
11788                        meta: Box::new(ExecStreamMeta {
11789                            receipt_kind: "terminal",
11790                            provider: effective_provider_kind.clone(),
11791                            provider_id: effective_stream_provider_id.clone(),
11792                            model: latest_model.clone(),
11793                            route_source: route_source.clone(),
11794                            input_tokens: Some(usage.input_tokens),
11795                            output_tokens: Some(usage.output_tokens),
11796                            prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11797                            prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11798                            prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11799                            reasoning_tokens: usage.reasoning_tokens,
11800                            duration_ms: u64::try_from(exec_started.elapsed().as_millis())
11801                                .unwrap_or(u64::MAX),
11802                            retry_count: None,
11803                            approval_posture: approval_posture.clone(),
11804                            sandbox_posture: sandbox_posture.clone(),
11805                            binary_sha256: binary_sha256.clone(),
11806                            config_sha256: None,
11807                            prompt_sha256: prompt_sha256.clone(),
11808                            tool_catalog_sha256: tool_catalog.as_ref().and_then(|catalog| {
11809                                serde_json::to_vec(catalog).ok().map(|bytes| {
11810                                    format!("sha256:{}", crate::hashing::sha256_hex(&bytes))
11811                                })
11812                            }),
11813                            input_analysis: exec_stream_input_analysis(
11814                                &latest_messages,
11815                                latest_system_prompt.as_ref(),
11816                            ),
11817                            visible_final_answer_chars: summary.output.chars().count(),
11818                            resume_command: saved_session_id
11819                                .as_deref()
11820                                .map(exec_stream_resume_hint)
11821                                .unwrap_or_default(),
11822                            session_id: saved_session_id
11823                                .as_deref()
11824                                .map(exec_stream_session_ref)
11825                                .unwrap_or_default(),
11826                            workspace: latest_workspace.display().to_string(),
11827                            message_count: latest_messages.len(),
11828                            status: summary.status.clone(),
11829                            termination_reason: summary.termination_reason.clone(),
11830                            error_category: summary.error_category.clone(),
11831                            error: summary.error.clone(),
11832                        }),
11833                    })?;
11834                    emit_exec_stream_event(&ExecStreamEvent::Done)?;
11835                }
11836                let _ = engine_handle.send(Op::Shutdown).await;
11837                break;
11838            }
11839            Event::SessionUpdated {
11840                session_id,
11841                messages,
11842                system_prompt,
11843                model,
11844                workspace,
11845            } => {
11846                latest_session_id = Some(session_id);
11847                latest_messages = messages;
11848                latest_system_prompt = system_prompt;
11849                latest_model = model;
11850                latest_workspace = workspace;
11851            }
11852            // #3027: surface the engine's max-steps notice in text mode so a
11853            // --max-turns run that stops early says why instead of going quiet.
11854            Event::Status { message }
11855                if output_format == ExecOutputFormat::Text
11856                    && !json_output
11857                    && message.contains("Maximum model steps") =>
11858            {
11859                eprintln!("{message}");
11860            }
11861            _ => {}
11862        }
11863    }
11864
11865    if summary.status.is_none() {
11866        if let Ok(mut manager) = exec_shell_manager.lock() {
11867            manager.abort_persistent_services();
11868        }
11869        let error = summary.error.clone().unwrap_or_else(|| {
11870            "Engine event channel closed before a terminal turn receipt".to_string()
11871        });
11872        let category = last_error_category
11873            .unwrap_or_else(|| crate::error_taxonomy::classify_error_message(&error));
11874        let termination_reason = crate::core::termination::classify_turn_termination(
11875            crate::core::events::TurnOutcomeStatus::Failed,
11876            Some(category),
11877            tool_error_seen,
11878            approval_required,
11879        );
11880        summary.status = Some("failed".to_string());
11881        summary.error_category = Some(category.to_string());
11882        summary.termination_reason = Some(termination_reason.as_str().to_string());
11883        summary.error = Some(error.clone());
11884        if output_format == ExecOutputFormat::StreamJson {
11885            emit_exec_stream_event(&ExecStreamEvent::Error { error })?;
11886        }
11887    }
11888
11889    if json_output {
11890        println!("{}", serde_json::to_string_pretty(&summary)?);
11891    }
11892
11893    if let Some(error) = summary.error.as_ref()
11894        && !error.trim().is_empty()
11895    {
11896        // Distinguish retryable infrastructure failures (provider/transport,
11897        // after all in-session retries are exhausted) from genuine task
11898        // failures so supervisors and bench harnesses can tell them apart at
11899        // the process level without parsing the stream. Genuine failures
11900        // keep the historical `bail!` → exit 1 path.
11901        let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
11902        if exit_code != 1 {
11903            eprintln!("Error: exec turn failed: {error}");
11904            let _ = io::stdout().flush();
11905            std::process::exit(exit_code);
11906        }
11907        bail!("exec turn failed: {error}");
11908    }
11909
11910    if matches!(
11911        summary.status.as_deref(),
11912        Some("failed" | "canceled" | "interrupted")
11913    ) {
11914        let status = summary.status.as_deref().unwrap_or("unknown");
11915        bail!("exec turn ended with status {status}");
11916    }
11917
11918    Ok(())
11919}
11920
11921#[cfg(test)]
11922mod serve_bind_host_tests {
11923    use super::*;
11924
11925    #[test]
11926    fn http_defaults_to_loopback() {
11927        assert_eq!(
11928            resolve_serve_bind_host(false, None),
11929            ServeBindHost {
11930                host: "127.0.0.1".to_string(),
11931                mobile_rebound_to_lan: false,
11932            }
11933        );
11934    }
11935
11936    #[test]
11937    fn mobile_default_rebinds_to_lan_with_warning_flag() {
11938        assert_eq!(
11939            resolve_serve_bind_host(true, None),
11940            ServeBindHost {
11941                host: "0.0.0.0".to_string(),
11942                mobile_rebound_to_lan: true,
11943            }
11944        );
11945    }
11946
11947    #[test]
11948    fn mobile_respects_explicit_loopback_host() {
11949        assert_eq!(
11950            resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
11951            ServeBindHost {
11952                host: "127.0.0.1".to_string(),
11953                mobile_rebound_to_lan: false,
11954            }
11955        );
11956    }
11957
11958    #[test]
11959    fn http_and_mobile_are_mutually_exclusive() {
11960        let err = validate_serve_mode_selection(false, true, true, false, false).unwrap_err();
11961        assert!(
11962            err.to_string()
11963                .contains("--http and --mobile are mutually exclusive")
11964        );
11965    }
11966
11967    #[test]
11968    fn web_is_a_distinct_loopback_runtime_mode() {
11969        assert!(validate_serve_mode_selection(false, false, false, true, false).unwrap());
11970        let err = validate_serve_mode_selection(false, true, false, true, false).unwrap_err();
11971        assert!(err.to_string().contains("--web is mutually exclusive"));
11972        assert_eq!(
11973            resolve_serve_bind_host(false, None),
11974            ServeBindHost {
11975                host: "127.0.0.1".to_string(),
11976                mobile_rebound_to_lan: false,
11977            }
11978        );
11979    }
11980}
11981
11982#[cfg(test)]
11983#[path = "tests/exec_exit_semantics.rs"]
11984mod exec_exit_semantics_tests;
11985#[cfg(test)]
11986mod doctor_legacy_state_tests {
11987    use super::*;
11988    use std::env;
11989    use std::ffi::OsString;
11990    use std::fs;
11991    use tempfile::TempDir;
11992
11993    struct EnvVarRestore {
11994        key: &'static str,
11995        previous: Option<OsString>,
11996    }
11997
11998    impl EnvVarRestore {
11999        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
12000            let previous = env::var_os(key);
12001            unsafe {
12002                env::set_var(key, value);
12003            }
12004            Self { key, previous }
12005        }
12006    }
12007
12008    impl Drop for EnvVarRestore {
12009        fn drop(&mut self) {
12010            unsafe {
12011                match &self.previous {
12012                    Some(value) => env::set_var(self.key, value),
12013                    None => env::remove_var(self.key),
12014                }
12015            }
12016        }
12017    }
12018
12019    fn roots(tmp: &TempDir) -> (PathBuf, PathBuf) {
12020        (tmp.path().join(".codewhale"), tmp.path().join(".deepseek"))
12021    }
12022
12023    fn entry<'a>(report: &'a [DoctorLegacyStateEntry], name: &str) -> &'a DoctorLegacyStateEntry {
12024        report
12025            .iter()
12026            .find(|entry| entry.name == name)
12027            .expect("legacy state entry should exist")
12028    }
12029
12030    #[test]
12031    fn doctor_legacy_state_report_marks_unmigrated_legacy_entries() {
12032        let tmp = TempDir::new().expect("tempdir");
12033        let (primary_root, legacy_root) = roots(&tmp);
12034        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12035        fs::create_dir_all(legacy_root.join("tasks")).expect("legacy tasks");
12036        fs::create_dir_all(&primary_root).expect("primary root");
12037        fs::write(legacy_root.join("config.toml"), "api_key = 'old'").expect("legacy config");
12038
12039        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12040        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12041
12042        assert_eq!(
12043            entry(&report, "sessions").status,
12044            DoctorLegacyStateStatus::LegacyOnly
12045        );
12046        assert_eq!(
12047            entry(&report, "config.toml").status,
12048            DoctorLegacyStateStatus::LegacyOnly
12049        );
12050        assert_eq!(
12051            entry(&report, "skills").status,
12052            DoctorLegacyStateStatus::Absent
12053        );
12054
12055        let json =
12056            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12057        assert_eq!(json["needs_attention"], true);
12058        assert_eq!(json["legacy_only_count"], 3);
12059        assert_eq!(json["dual_present_count"], 0);
12060    }
12061
12062    #[test]
12063    fn doctor_legacy_state_report_marks_dual_present_entries() {
12064        let tmp = TempDir::new().expect("tempdir");
12065        let (primary_root, legacy_root) = roots(&tmp);
12066        fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12067        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12068        fs::write(primary_root.join("mcp.json"), "{}").expect("primary mcp");
12069        fs::write(legacy_root.join("mcp.json"), "{}").expect("legacy mcp");
12070
12071        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12072        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12073
12074        assert_eq!(
12075            entry(&report, "sessions").status,
12076            DoctorLegacyStateStatus::Both
12077        );
12078        assert_eq!(
12079            entry(&report, "mcp.json").status,
12080            DoctorLegacyStateStatus::Both
12081        );
12082
12083        let json =
12084            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12085        assert_eq!(json["needs_attention"], true);
12086        assert_eq!(json["legacy_only_count"], 0);
12087        assert_eq!(json["dual_present_count"], 2);
12088    }
12089
12090    #[test]
12091    fn doctor_legacy_state_report_is_clear_when_only_primary_exists() {
12092        let tmp = TempDir::new().expect("tempdir");
12093        let (primary_root, legacy_root) = roots(&tmp);
12094        fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12095        fs::write(primary_root.join("settings.toml"), "default_mode = 'ask'")
12096            .expect("primary settings");
12097
12098        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12099        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12100
12101        assert_eq!(
12102            entry(&report, "sessions").status,
12103            DoctorLegacyStateStatus::PrimaryOnly
12104        );
12105        assert!(!report.iter().any(legacy_state_needs_attention));
12106
12107        let json =
12108            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12109        assert_eq!(json["needs_attention"], false);
12110        assert_eq!(json["legacy_only_count"], 0);
12111        assert_eq!(json["dual_present_count"], 0);
12112    }
12113
12114    #[test]
12115    fn doctor_legacy_state_report_is_clear_when_neither_root_exists() {
12116        let tmp = TempDir::new().expect("tempdir");
12117        let (primary_root, legacy_root) = roots(&tmp);
12118
12119        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12120        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12121
12122        assert!(
12123            report
12124                .iter()
12125                .all(|entry| entry.status == DoctorLegacyStateStatus::Absent)
12126        );
12127        assert!(!report.iter().any(legacy_state_needs_attention));
12128
12129        let json =
12130            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12131        assert_eq!(json["needs_attention"], false);
12132        assert_eq!(json["legacy_only_count"], 0);
12133        assert_eq!(json["dual_present_count"], 0);
12134    }
12135
12136    #[test]
12137    fn doctor_reports_incomplete_session_migration_without_mutating_files() {
12138        let tmp = TempDir::new().expect("tempdir");
12139        let (primary_root, legacy_root) = roots(&tmp);
12140        let primary_sessions = primary_root.join("sessions");
12141        let legacy_sessions = legacy_root.join("sessions");
12142        fs::create_dir_all(&primary_sessions).expect("primary sessions");
12143        fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
12144        fs::write(primary_sessions.join("already-there.json"), b"primary")
12145            .expect("primary session");
12146        fs::write(legacy_sessions.join("already-there.json"), b"legacy")
12147            .expect("legacy matching session");
12148        fs::write(
12149            legacy_sessions.join("recover-me.json"),
12150            b"not parsed by doctor",
12151        )
12152        .expect("legacy recoverable session");
12153        fs::write(
12154            legacy_sessions.join("checkpoints").join("latest.json"),
12155            b"checkpoint not inspected",
12156        )
12157        .expect("legacy checkpoint");
12158
12159        let legacy_before = fs::read(legacy_sessions.join("recover-me.json"))
12160            .expect("read legacy fixture before diagnostic");
12161        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12162
12163        assert_eq!(
12164            report.status,
12165            DoctorSessionRecoveryStatus::MigrationIncomplete
12166        );
12167        assert_eq!(report.legacy_session_file_count, 2);
12168        assert_eq!(report.already_present_file_count, 1);
12169        assert_eq!(report.recoverable_file_count, 1);
12170        assert_eq!(report.recoverable.len(), 1);
12171        assert_eq!(report.recoverable[0].name, PathBuf::from("recover-me.json"));
12172        assert!(
12173            !primary_sessions.join("recover-me.json").exists(),
12174            "doctor must not copy a recoverable session"
12175        );
12176        assert_eq!(
12177            fs::read(legacy_sessions.join("recover-me.json"))
12178                .expect("legacy file remains after diagnostic"),
12179            legacy_before,
12180            "doctor must not rewrite or delete the legacy source"
12181        );
12182
12183        let json = doctor_session_recovery_json(&report);
12184        assert_eq!(json["needs_attention"], true);
12185        assert_eq!(json["read_only"], true);
12186        assert_eq!(json["chat_contents_read"], false);
12187        assert_eq!(json["checkpoint_internals_scanned"], false);
12188        assert_eq!(json["recoverable_file_count"], 1);
12189        assert_eq!(json["recovery_command"], "codewhale sessions");
12190        assert_eq!(json["recoverable_files"][0]["name"], "recover-me.json");
12191        let serialized = json.to_string();
12192        assert!(
12193            !serialized.contains("not parsed by doctor"),
12194            "the report must not expose session contents"
12195        );
12196        assert!(
12197            !serialized.contains("checkpoint not inspected"),
12198            "the report must not expose checkpoint contents"
12199        );
12200    }
12201
12202    #[test]
12203    fn doctor_treats_preserved_legacy_sessions_as_complete_by_filename() {
12204        let tmp = TempDir::new().expect("tempdir");
12205        let (primary_root, legacy_root) = roots(&tmp);
12206        let primary_sessions = primary_root.join("sessions");
12207        let legacy_sessions = legacy_root.join("sessions");
12208        fs::create_dir_all(&primary_sessions).expect("primary sessions");
12209        fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12210        fs::write(primary_sessions.join("same-name.json"), b"primary").expect("primary session");
12211        fs::write(legacy_sessions.join("same-name.json"), b"legacy").expect("legacy session");
12212
12213        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12214
12215        assert_eq!(
12216            report.status,
12217            DoctorSessionRecoveryStatus::MigrationComplete
12218        );
12219        assert!(!report.needs_attention());
12220        assert_eq!(report.recoverable_file_count, 0);
12221        assert!(report.recoverable.is_empty());
12222        assert_eq!(report.already_present_file_count, 1);
12223        let json = doctor_session_recovery_json(&report);
12224        assert_eq!(json["session_descriptors_compared"], false);
12225        assert_eq!(
12226            json["counterpart_check"],
12227            "top_level_filename_and_regular_file_only"
12228        );
12229    }
12230
12231    #[test]
12232    fn doctor_bounds_recoverable_session_filename_samples() {
12233        let tmp = TempDir::new().expect("tempdir");
12234        let (primary_root, legacy_root) = roots(&tmp);
12235        let legacy_sessions = legacy_root.join("sessions");
12236        fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12237        for index in 0..DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
12238            fs::write(
12239                legacy_sessions.join(format!("late-{index:03}.json")),
12240                b"fixture",
12241            )
12242            .expect("legacy session fixture");
12243        }
12244        fs::write(legacy_sessions.join("early-000.json"), b"fixture")
12245            .expect("earliest legacy session fixture");
12246        fs::write(legacy_sessions.join("early-001.json"), b"fixture")
12247            .expect("second earliest legacy session fixture");
12248        let total = DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT + 2;
12249
12250        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12251        let json = doctor_session_recovery_json(&report);
12252
12253        assert_eq!(report.recoverable_file_count, total);
12254        assert_eq!(
12255            report.recoverable.len(),
12256            DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
12257        );
12258        assert_eq!(
12259            json["recoverable_files"].as_array().map(Vec::len),
12260            Some(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
12261        );
12262        assert_eq!(
12263            report.recoverable.first().map(|entry| entry.name.as_path()),
12264            Some(Path::new("early-000.json")),
12265            "the bounded sample must not depend on read_dir order"
12266        );
12267        assert_eq!(
12268            report.recoverable.last().map(|entry| entry.name.as_path()),
12269            Some(Path::new("late-097.json")),
12270            "the bounded sample must retain the lexical prefix"
12271        );
12272        assert_eq!(json["recoverable_files_truncated"], true);
12273    }
12274
12275    #[test]
12276    fn doctor_session_recovery_fails_closed_on_an_unreadable_path_shape() {
12277        let tmp = TempDir::new().expect("tempdir");
12278        let (primary_root, legacy_root) = roots(&tmp);
12279        fs::create_dir_all(&legacy_root).expect("legacy root");
12280        fs::write(legacy_root.join("sessions"), b"not a directory")
12281            .expect("invalid legacy sessions path");
12282
12283        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12284
12285        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12286        assert!(report.needs_attention());
12287        assert!(report.error.as_deref().is_some_and(|error| {
12288            error.contains("legacy sessions root") && error.contains("not a directory")
12289        }));
12290    }
12291
12292    #[test]
12293    fn doctor_session_recovery_rejects_a_non_directory_legacy_state_root() {
12294        let tmp = TempDir::new().expect("tempdir");
12295        let (primary_root, legacy_root) = roots(&tmp);
12296        fs::write(&legacy_root, b"not a state directory").expect("invalid legacy root");
12297
12298        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12299
12300        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12301        assert!(report.error.as_deref().is_some_and(|error| {
12302            error.contains("legacy state root") && error.contains("not a directory")
12303        }));
12304    }
12305
12306    #[test]
12307    fn doctor_session_recovery_rejects_a_non_directory_primary_state_root() {
12308        let tmp = TempDir::new().expect("tempdir");
12309        let (primary_root, legacy_root) = roots(&tmp);
12310        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12311        fs::write(&primary_root, b"not a state directory").expect("invalid primary root");
12312
12313        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12314
12315        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12316        assert!(report.error.as_deref().is_some_and(|error| {
12317            error.contains("primary state root") && error.contains("not a directory")
12318        }));
12319    }
12320
12321    #[test]
12322    fn doctor_session_recovery_rejects_a_non_directory_primary_sessions_root() {
12323        let tmp = TempDir::new().expect("tempdir");
12324        let (primary_root, legacy_root) = roots(&tmp);
12325        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12326        fs::create_dir_all(&primary_root).expect("primary root");
12327        fs::write(primary_root.join("sessions"), b"not a sessions directory")
12328            .expect("invalid primary sessions path");
12329
12330        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12331
12332        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12333        assert!(report.error.as_deref().is_some_and(|error| {
12334            error.contains("primary sessions root") && error.contains("not a directory")
12335        }));
12336    }
12337
12338    #[cfg(unix)]
12339    #[test]
12340    fn doctor_session_recovery_rejects_a_symlinked_legacy_sessions_root() {
12341        use std::os::unix::fs::symlink;
12342
12343        let tmp = TempDir::new().expect("tempdir");
12344        let (primary_root, legacy_root) = roots(&tmp);
12345        let external_sessions = tmp.path().join("external-sessions");
12346        fs::create_dir_all(&external_sessions).expect("external sessions");
12347        fs::write(
12348            external_sessions.join("must-not-be-enumerated.json"),
12349            b"session contents must stay unread",
12350        )
12351        .expect("external session fixture");
12352        fs::create_dir_all(&legacy_root).expect("legacy root");
12353        symlink(&external_sessions, legacy_root.join("sessions"))
12354            .expect("symlinked legacy sessions root");
12355
12356        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12357
12358        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12359        assert!(report.needs_attention());
12360        assert_eq!(report.legacy_session_file_count, 0);
12361        assert!(report.recoverable.is_empty());
12362        assert!(
12363            report
12364                .error
12365                .as_deref()
12366                .is_some_and(|error| error.contains("legacy sessions root")
12367                    && error.contains("path is a symlink"))
12368        );
12369    }
12370
12371    #[cfg(unix)]
12372    #[test]
12373    fn doctor_session_recovery_rejects_symlinked_primary_root_and_sessions_root() {
12374        use std::os::unix::fs::symlink;
12375
12376        let tmp = TempDir::new().expect("tempdir");
12377        let (primary_root, legacy_root) = roots(&tmp);
12378        let external_primary = tmp.path().join("external-primary");
12379        fs::create_dir_all(external_primary.join("sessions")).expect("external primary");
12380        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12381        symlink(&external_primary, &primary_root).expect("symlinked primary root");
12382
12383        let root_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12384        assert_eq!(root_report.status, DoctorSessionRecoveryStatus::ScanFailed);
12385        assert!(root_report.error.as_deref().is_some_and(|error| {
12386            error.contains("primary state root") && error.contains("path is a symlink")
12387        }));
12388
12389        fs::remove_file(&primary_root).expect("remove primary root symlink");
12390        fs::create_dir_all(&primary_root).expect("primary root");
12391        symlink(&external_primary, primary_root.join("sessions"))
12392            .expect("symlinked primary sessions root");
12393
12394        let sessions_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12395        assert_eq!(
12396            sessions_report.status,
12397            DoctorSessionRecoveryStatus::ScanFailed
12398        );
12399        assert!(sessions_report.error.as_deref().is_some_and(|error| {
12400            error.contains("primary sessions root") && error.contains("path is a symlink")
12401        }));
12402    }
12403
12404    #[test]
12405    fn explicit_codewhale_home_skips_session_recovery_scan() {
12406        let tmp = TempDir::new().expect("tempdir");
12407        let (primary_root, legacy_root) = roots(&tmp);
12408        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12409        fs::write(legacy_root.join("sessions").join("ambient.json"), b"legacy")
12410            .expect("legacy session");
12411
12412        let report = doctor_session_recovery_report(&primary_root, &legacy_root, true);
12413
12414        assert_eq!(report.status, DoctorSessionRecoveryStatus::Isolated);
12415        assert!(report.codewhale_home_is_explicit);
12416        assert_eq!(report.legacy_session_file_count, 0);
12417        assert_eq!(report.recoverable_file_count, 0);
12418        assert!(report.recoverable.is_empty());
12419        assert!(!report.needs_attention());
12420    }
12421
12422    #[test]
12423    fn doctor_state_roots_ignore_ambient_legacy_home_when_codewhale_home_is_explicit() {
12424        let _env_lock = crate::test_support::lock_test_env();
12425        let tmp = TempDir::new().expect("tempdir");
12426        let explicit_home = tmp.path().join("isolated-codewhale");
12427        let ambient_legacy = tmp.path().join(".deepseek");
12428        fs::create_dir_all(&ambient_legacy).expect("ambient legacy root");
12429        fs::write(
12430            ambient_legacy.join("config.toml"),
12431            "provider = 'deepseek'\n",
12432        )
12433        .expect("ambient legacy config");
12434        let _home = EnvVarRestore::set("HOME", tmp.path());
12435        let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
12436
12437        let (primary_root, legacy_root) = doctor_state_roots();
12438        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12439        let session_recovery = doctor_session_recovery_report(
12440            &primary_root,
12441            &legacy_root,
12442            codewhale_config::codewhale_home_is_explicit(),
12443        );
12444
12445        assert_eq!(primary_root, explicit_home);
12446        assert_eq!(
12447            legacy_root,
12448            primary_root.join(codewhale_config::LEGACY_APP_DIR)
12449        );
12450        assert!(
12451            report
12452                .iter()
12453                .all(|entry| entry.status == DoctorLegacyStateStatus::Absent),
12454            "doctor must not report ambient legacy state when CODEWHALE_HOME is explicit"
12455        );
12456        assert!(!report.iter().any(legacy_state_needs_attention));
12457        assert_eq!(
12458            session_recovery.status,
12459            DoctorSessionRecoveryStatus::Isolated
12460        );
12461        assert!(session_recovery.recoverable.is_empty());
12462    }
12463}
12464
12465#[cfg(test)]
12466mod doctor_setup_state_tests {
12467    use super::*;
12468    use std::fs;
12469    use tempfile::TempDir;
12470
12471    fn prepare_env(tmp: &TempDir) -> (crate::test_support::EnvVarGuard, PathBuf) {
12472        let codewhale_home = tmp.path().join(".codewhale");
12473        fs::create_dir_all(&codewhale_home).expect("codewhale home");
12474        (
12475            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str()),
12476            codewhale_home,
12477        )
12478    }
12479
12480    fn provider_step(report: &serde_json::Value) -> &serde_json::Value {
12481        report["steps"]
12482            .as_array()
12483            .expect("steps array")
12484            .iter()
12485            .find(|step| step["step"] == "provider_model")
12486            .expect("provider/model step")
12487    }
12488
12489    #[test]
12490    fn doctor_setup_consistency_flags_missing_user_constitution() {
12491        let _guard = crate::test_support::lock_test_env();
12492        let tmp = TempDir::new().expect("tempdir");
12493        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12494        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12495        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12496        let workspace = tmp.path().join("workspace");
12497        fs::create_dir_all(&workspace).expect("workspace");
12498
12499        let state = codewhale_config::SetupState {
12500            constitution_source: codewhale_config::ConstitutionSource::UserGlobal,
12501            ..Default::default()
12502        };
12503        state.save().expect("persist setup state");
12504
12505        let report = doctor_setup_report_json(&Config::default(), &workspace);
12506
12507        assert_eq!(report["source"], "persisted");
12508        assert_eq!(report["consistency"]["status"], "inconsistent");
12509        let issues = report["consistency"]["issues"].to_string();
12510        assert!(
12511            issues.contains("setup_state_points_at_missing_user_constitution"),
12512            "{issues}"
12513        );
12514    }
12515
12516    #[test]
12517    fn doctor_setup_consistency_flags_stale_temp_files() {
12518        let _guard = crate::test_support::lock_test_env();
12519        let tmp = TempDir::new().expect("tempdir");
12520        let (_home_guard, codewhale_home) = prepare_env(&tmp);
12521        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12522        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12523        let workspace = tmp.path().join("workspace");
12524        fs::create_dir_all(&workspace).expect("workspace");
12525        fs::write(codewhale_home.join(".tmpAbC123"), b"orphaned atomic write")
12526            .expect("stale temp file");
12527
12528        let report = doctor_setup_report_json(&Config::default(), &workspace);
12529
12530        assert_eq!(report["consistency"]["status"], "inconsistent");
12531        let issues = report["consistency"]["issues"].to_string();
12532        assert!(
12533            issues.contains("stale_setup_temp_files_in_codewhale_home"),
12534            "{issues}"
12535        );
12536    }
12537
12538    #[test]
12539    fn doctor_setup_consistency_reports_consistent_for_clean_home() {
12540        let _guard = crate::test_support::lock_test_env();
12541        let tmp = TempDir::new().expect("tempdir");
12542        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12543        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12544        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12545        let workspace = tmp.path().join("workspace");
12546        fs::create_dir_all(&workspace).expect("workspace");
12547
12548        let report = doctor_setup_report_json(&Config::default(), &workspace);
12549
12550        assert_eq!(report["consistency"]["status"], "consistent");
12551        assert_eq!(
12552            report["consistency"]["issues"]
12553                .as_array()
12554                .map(Vec::len)
12555                .unwrap_or_default(),
12556            0
12557        );
12558    }
12559
12560    #[test]
12561    fn doctor_setup_report_json_derives_state_without_sidecar() {
12562        let _guard = crate::test_support::lock_test_env();
12563        let tmp = TempDir::new().expect("tempdir");
12564        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12565        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12566        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12567        let workspace = tmp.path().join("workspace");
12568        fs::create_dir_all(&workspace).expect("workspace");
12569
12570        let report = doctor_setup_report_json(&Config::default(), &workspace);
12571
12572        assert_eq!(report["source"], "derived");
12573        assert_eq!(report["inherited"], true);
12574        assert_eq!(report["next_actions"]["constitution"], "/constitution");
12575        assert_eq!(report["next_actions"]["setup_report"], "/setup report");
12576        assert_eq!(
12577            report["next_actions"]["provider_model"],
12578            "/setup provider, /provider setup <name>, or /model"
12579        );
12580        assert_eq!(report["next_actions"]["runtime_posture"], "/config");
12581        assert_eq!(
12582            report["next_actions"]["operate_fleet"],
12583            "/setup fleet (readiness), /fleet setup (explicit profile authoring)"
12584        );
12585        assert_eq!(report["next_actions"]["hotbar"], "/setup hotbar");
12586        assert_eq!(report["next_actions"]["tools_mcp"], "/setup tools");
12587        assert_eq!(report["next_actions"]["remote_runtime"], "/setup remote");
12588        assert_eq!(report["next_actions"]["persistence"], "/setup persistence");
12589        assert_eq!(
12590            report["checkpoint_version"],
12591            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12592        );
12593        assert_eq!(report["update_ready"], false);
12594        assert_eq!(report["operate_ready"], false);
12595        assert_eq!(
12596            report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12597            false
12598        );
12599        assert_eq!(
12600            report["operate_fleet"]["roster"]["readiness_rule"],
12601            "built-in starter roster or custom roster"
12602        );
12603        assert_eq!(report["provider_model"]["provider"]["id"], "deepseek");
12604        assert_eq!(report["provider_model"]["provider"]["display"], "DeepSeek");
12605        assert_eq!(
12606            report["provider_model"]["model"]["resolved"],
12607            crate::config::DEFAULT_TEXT_MODEL
12608        );
12609        assert_eq!(
12610            report["provider_model"]["auth"]["source"],
12611            "secret_store_unprobed"
12612        );
12613        assert_eq!(
12614            report["provider_model"]["auth"]["availability"],
12615            "not_probed"
12616        );
12617        assert_eq!(
12618            report["provider_model"]["auth"]["credential_url"],
12619            "https://platform.deepseek.com"
12620        );
12621        assert_eq!(
12622            report["provider_model"]["auth"]["credential_mode"],
12623            "api_key"
12624        );
12625        assert_eq!(
12626            report["provider_model"]["auth"]["env_vars"][0],
12627            "DEEPSEEK_API_KEY"
12628        );
12629        assert_eq!(report["provider_model"]["health"]["live_validation"], false);
12630        assert_eq!(report["constitution"]["source"], "bundled");
12631        assert_eq!(report["constitution"]["autonomy_preference"], "unspecified");
12632        assert_eq!(report["runtime_posture"]["source"], "unset");
12633        assert_eq!(report["runtime_posture"]["default_mode"]["value"], "agent");
12634        assert_eq!(
12635            report["runtime_posture"]["approval_policy"]["value"],
12636            "on-request"
12637        );
12638        assert_eq!(report["runtime_posture"]["allow_shell"]["value"], true);
12639        assert_eq!(
12640            report["runtime_posture"]["sandbox_mode"]["value"],
12641            "mode-derived"
12642        );
12643        assert_eq!(
12644            report["runtime_posture"]["network_default"]["value"],
12645            "prompt"
12646        );
12647        assert_eq!(provider_step(&report)["status"], "needs_action");
12648    }
12649
12650    #[test]
12651    fn doctor_setup_provider_model_json_covers_cn_codex_and_local_matrix() {
12652        let _guard = crate::test_support::lock_test_env();
12653        let tmp = TempDir::new().expect("tempdir");
12654        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12655        let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
12656        let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
12657        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12658        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12659        let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
12660        let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
12661        let codex_auth_path = tmp.path().join("external-codex-auth.json");
12662        let codex_auth_raw = serde_json::json!({
12663            "tokens": {
12664                "access_token": crate::test_support::future_test_jwt("doctor"),
12665                "account_id": "acct-doctor-read-only",
12666                "refresh_token": "must-never-be-used",
12667                "unknown": {"preserve": true}
12668            }
12669        })
12670        .to_string();
12671        fs::write(&codex_auth_path, &codex_auth_raw).expect("Codex auth trap fixture");
12672        let _codex_auth =
12673            crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_auth_path);
12674        let workspace = tmp.path().join("workspace");
12675        fs::create_dir_all(&workspace).expect("workspace");
12676
12677        let cn_config = Config {
12678            provider: Some("deepseek-cn".to_string()),
12679            ..Config::default()
12680        };
12681        let cn_report = doctor_setup_report_json(&cn_config, &workspace);
12682        assert_eq!(cn_report["provider_model"]["provider"]["id"], "deepseek-cn");
12683        assert_eq!(
12684            cn_report["provider_model"]["provider"]["display"],
12685            "DeepSeek (legacy alias)"
12686        );
12687        assert_eq!(
12688            cn_report["provider_model"]["auth"]["env_vars"][0],
12689            "DEEPSEEK_API_KEY"
12690        );
12691        assert_eq!(
12692            cn_report["provider_model"]["auth"]["credential_url"],
12693            "https://platform.deepseek.com"
12694        );
12695        assert_eq!(cn_report["provider_model"]["auth"]["oauth_only"], false);
12696        assert_eq!(
12697            cn_report["provider_model"]["health"]["live_validation"],
12698            false
12699        );
12700
12701        let codex_config = Config {
12702            provider: Some("openai-codex".to_string()),
12703            ..Config::default()
12704        };
12705        crate::external_credentials::reset_side_effect_trap();
12706        let codex_report = doctor_setup_report_json(&codex_config, &workspace);
12707        assert_eq!(
12708            codex_report["provider_model"]["provider"]["id"],
12709            crate::config::ApiProvider::OpenaiCodex.as_str()
12710        );
12711        assert!(codex_report["provider_model"]["auth"]["credential_url"].is_null());
12712        assert_eq!(
12713            codex_report["provider_model"]["auth"]["credential_mode"],
12714            "oauth"
12715        );
12716        assert_eq!(codex_report["provider_model"]["auth"]["oauth_only"], true);
12717        assert_eq!(
12718            codex_report["provider_model"]["health"]["next_action"],
12719            "/setup provider or /provider setup <name>"
12720        );
12721        assert_eq!(
12722            crate::external_credentials::side_effect_trap_counts(),
12723            (0, 0),
12724            "doctor must not stat or read external credentials without consent"
12725        );
12726
12727        let mut consent = codewhale_config::ExternalCredentialConsentToml::read_only(
12728            codewhale_config::ProviderKind::OpenaiCodex,
12729            codewhale_config::ExternalCredentialSource::CodexCli,
12730            codex_auth_path.clone(),
12731        );
12732        let codex_read_only = Config {
12733            provider: Some("openai-codex".to_string()),
12734            providers: Some(crate::config::ProvidersConfig {
12735                openai_codex: crate::config::ProviderConfig {
12736                    auth_mode: Some("oauth".to_string()),
12737                    external_credentials: Some(consent.clone()),
12738                    ..Default::default()
12739                },
12740                ..Default::default()
12741            }),
12742            ..Config::default()
12743        };
12744        let changed_ambient_path = tmp.path().join("new-ambient-codex-auth.json");
12745        let _changed_codex_auth =
12746            crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &changed_ambient_path);
12747        crate::external_credentials::reset_side_effect_trap();
12748        let codex_read_only_report = doctor_setup_report_json(&codex_read_only, &workspace);
12749        assert_eq!(
12750            codex_read_only_report["provider_model"]["auth"]["present_or_local"],
12751            false
12752        );
12753        assert_eq!(
12754            codex_read_only_report["provider_model"]["auth"]["source"],
12755            "external_consent"
12756        );
12757        let status_json = doctor_external_credential_consent_json(&codex_read_only);
12758        let codex_status = status_json
12759            .as_array()
12760            .and_then(|rows| rows.first())
12761            .expect("Codex structural status");
12762        assert_eq!(codex_status["access"], "read_only");
12763        assert_eq!(codex_status["provider"], "openai-codex");
12764        assert_eq!(codex_status["source"], "codex_cli");
12765        assert_eq!(codex_status["route_state"], "active");
12766        assert_eq!(codex_status["ambient_path_changed"], true);
12767        assert!(
12768            codex_status["ambient_path_warning"]
12769                .as_str()
12770                .is_some_and(|warning| warning.contains("remains pinned"))
12771        );
12772        assert_eq!(
12773            codex_status["revoke_command"],
12774            "codewhale auth external-revoke --provider openai-codex"
12775        );
12776        let human = doctor_external_credential_consent_lines(&codex_read_only).join("\n");
12777        assert!(human.contains("path="), "{human}");
12778        assert!(human.contains("version=1"), "{human}");
12779        assert!(human.contains("no refresh, identity-provider or discovery requests"));
12780        assert!(human.contains("normal requests to the explicitly selected provider"));
12781        assert!(human.contains("consent remains pinned"), "{human}");
12782        assert!(
12783            human.contains(&codewhale_config::quote_os_path(&codex_auth_path)),
12784            "{human}"
12785        );
12786        assert!(!human.contains(&changed_ambient_path.display().to_string()));
12787        assert_eq!(
12788            crate::external_credentials::complete_side_effect_trap_counts(),
12789            (0, 0, 0, 0, 0),
12790            "doctor consent status is structural and must not inspect the file"
12791        );
12792        assert_eq!(
12793            fs::read_to_string(&codex_auth_path).expect("unchanged Codex auth fixture"),
12794            codex_auth_raw
12795        );
12796
12797        consent.access = codewhale_config::ExternalCredentialAccess::Managed;
12798        let codex_managed = Config {
12799            provider: Some("openai-codex".to_string()),
12800            providers: Some(crate::config::ProvidersConfig {
12801                openai_codex: crate::config::ProviderConfig {
12802                    auth_mode: Some("oauth".to_string()),
12803                    external_credentials: Some(consent),
12804                    ..Default::default()
12805                },
12806                ..Default::default()
12807            }),
12808            ..Config::default()
12809        };
12810        crate::external_credentials::reset_side_effect_trap();
12811        let codex_managed_report = doctor_setup_report_json(&codex_managed, &workspace);
12812        assert_eq!(
12813            codex_managed_report["provider_model"]["auth"]["present_or_local"],
12814            false
12815        );
12816        assert_eq!(
12817            crate::external_credentials::side_effect_trap_counts(),
12818            (0, 0),
12819            "unsupported managed mode must fail before external I/O"
12820        );
12821        assert_eq!(
12822            fs::read_to_string(&codex_auth_path).expect("unchanged managed auth fixture"),
12823            codex_auth_raw
12824        );
12825
12826        let local_config = Config {
12827            provider: Some("ollama".to_string()),
12828            ..Config::default()
12829        };
12830        let local_report = doctor_setup_report_json(&local_config, &workspace);
12831        assert_eq!(local_report["provider_model"]["provider"]["id"], "ollama");
12832        assert_eq!(
12833            local_report["provider_model"]["auth"]["present_or_local"],
12834            true
12835        );
12836        assert!(local_report["provider_model"]["auth"]["credential_url"].is_null());
12837        assert_eq!(
12838            local_report["provider_model"]["auth"]["credential_mode"],
12839            "local_optional"
12840        );
12841        assert_eq!(local_report["provider_model"]["auth"]["oauth_only"], false);
12842        assert_eq!(
12843            local_report["provider_model"]["health"]["next_action"],
12844            "/model"
12845        );
12846
12847        let kimi_config = Config {
12848            provider: Some("moonshot".to_string()),
12849            ..Config::default()
12850        };
12851        let kimi_report = doctor_setup_report_json(&kimi_config, &workspace);
12852        assert_eq!(
12853            kimi_report["provider_model"]["auth"]["credential_url"],
12854            "https://platform.kimi.ai"
12855        );
12856        assert_eq!(
12857            kimi_report["provider_model"]["auth"]["credential_docs_url"],
12858            "https://platform.kimi.ai"
12859        );
12860        assert_eq!(
12861            kimi_report["provider_model"]["auth"]["credential_mode"],
12862            "api_key"
12863        );
12864        assert!(
12865            kimi_report["provider_model"]["auth"]["credential_guidance"]
12866                .as_str()
12867                .is_some_and(|guidance| guidance.contains("OAuth is not available"))
12868        );
12869    }
12870
12871    #[test]
12872    fn doctor_setup_report_json_uses_persisted_state() {
12873        let _guard = crate::test_support::lock_test_env();
12874        let tmp = TempDir::new().expect("tempdir");
12875        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12876        let workspace = tmp.path().join("workspace");
12877        fs::create_dir_all(&workspace).expect("workspace");
12878        let mut state = codewhale_config::SetupState::default();
12879        state.set_step(
12880            codewhale_config::SetupStep::Language,
12881            codewhale_config::StepEntry::new(
12882                codewhale_config::StepStatus::Verified,
12883                true,
12884                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12885            ),
12886        );
12887        state.set_step(
12888            codewhale_config::SetupStep::ProviderModel,
12889            codewhale_config::StepEntry::new(
12890                codewhale_config::StepStatus::Verified,
12891                true,
12892                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12893            )
12894            .with_result("deepseek/deepseek-chat"),
12895        );
12896        state.set_step(
12897            codewhale_config::SetupStep::TrustSandbox,
12898            codewhale_config::StepEntry::new(
12899                codewhale_config::StepStatus::Verified,
12900                true,
12901                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12902            ),
12903        );
12904        state
12905            .complete_constitution_checkpoint(
12906                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12907                codewhale_config::ConstitutionChoice::Bundled,
12908            )
12909            .set_step(
12910                codewhale_config::SetupStep::Constitution,
12911                codewhale_config::StepEntry::new(
12912                    codewhale_config::StepStatus::Verified,
12913                    true,
12914                    crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12915                ),
12916            );
12917        state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
12918        state.save().expect("persist setup state");
12919        codewhale_config::UserConstitution {
12920            autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
12921            ..Default::default()
12922        }
12923        .save()
12924        .expect("persist user constitution");
12925        let config = Config {
12926            api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
12927            approval_policy: Some("never".to_string()),
12928            allow_shell: Some(false),
12929            sandbox_mode: Some("read-only".to_string()),
12930            network: Some(crate::config::NetworkPolicyToml {
12931                default: "deny".to_string(),
12932                ..Default::default()
12933            }),
12934            ..Config::default()
12935        };
12936
12937        let report = doctor_setup_report_json(&config, &workspace);
12938
12939        assert_eq!(report["source"], "persisted");
12940        assert_eq!(report["first_run_ready"], true);
12941        assert_eq!(report["update_ready"], true);
12942        assert_eq!(report["operate_ready"], false);
12943        assert_eq!(report["constitution"]["choice"], "bundled");
12944        assert_eq!(
12945            report["constitution"]["checkpoint_completed_for"],
12946            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12947        );
12948        assert_eq!(report["constitution"]["autonomy_preference"], "balanced");
12949        assert_eq!(report["runtime_posture_source"], "confirmed");
12950        assert_eq!(report["runtime_posture"]["source"], "confirmed");
12951        assert_eq!(
12952            report["runtime_posture"]["approval_policy"]["value"],
12953            "never"
12954        );
12955        assert_eq!(
12956            report["runtime_posture"]["approval_policy"]["source"],
12957            "config"
12958        );
12959        assert_eq!(report["runtime_posture"]["allow_shell"]["value"], false);
12960        assert_eq!(report["runtime_posture"]["allow_shell"]["source"], "config");
12961        assert_eq!(
12962            report["runtime_posture"]["sandbox_mode"]["value"],
12963            "read-only"
12964        );
12965        assert_eq!(
12966            report["runtime_posture"]["sandbox_mode"]["source"],
12967            "config"
12968        );
12969        assert_eq!(
12970            report["runtime_posture"]["network_default"]["value"],
12971            "deny"
12972        );
12973        assert_eq!(
12974            report["runtime_posture"]["network_default"]["source"],
12975            "config"
12976        );
12977        assert_eq!(provider_step(&report)["result"], "deepseek/deepseek-chat");
12978    }
12979
12980    #[test]
12981    fn doctor_reports_settings_permission_posture_when_approval_policy_unset() {
12982        let _guard = crate::test_support::lock_test_env();
12983        let tmp = TempDir::new().expect("tempdir");
12984        let (_home_guard, codewhale_home) = prepare_env(&tmp);
12985        let workspace = tmp.path().join("workspace");
12986        fs::create_dir_all(&workspace).expect("workspace");
12987        fs::write(
12988            codewhale_home.join("settings.toml"),
12989            "permission_posture = \"full-access\"\n",
12990        )
12991        .expect("write settings.toml");
12992
12993        let config = Config::default();
12994        assert!(config.approval_policy.is_none());
12995
12996        let line = doctor_runtime_posture_line(&config, &workspace);
12997        assert!(
12998            line.contains("permission_posture=full-access (settings)"),
12999            "text doctor should report saved settings posture: {line}"
13000        );
13001        assert!(
13002            line.contains("approval_policy=on-request (default)"),
13003            "text doctor should keep unset config approval_policy default: {line}"
13004        );
13005
13006        let report = doctor_setup_report_json(&config, &workspace);
13007        assert_eq!(
13008            report["runtime_posture"]["permission_posture"]["value"],
13009            "full-access"
13010        );
13011        assert_eq!(
13012            report["runtime_posture"]["permission_posture"]["source"],
13013            "settings"
13014        );
13015        assert_eq!(
13016            report["runtime_posture"]["approval_policy"]["value"],
13017            "on-request"
13018        );
13019        assert_eq!(
13020            report["runtime_posture"]["approval_policy"]["source"],
13021            "default"
13022        );
13023    }
13024
13025    #[test]
13026    fn doctor_setup_report_json_fails_closed_without_operate_receipts() {
13027        let _guard = crate::test_support::lock_test_env();
13028        let tmp = TempDir::new().expect("tempdir");
13029        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13030        let workspace = tmp.path().join("workspace");
13031        fs::create_dir_all(&workspace).expect("workspace");
13032        let mut state = codewhale_config::SetupState::default();
13033        state.set_step(
13034            codewhale_config::SetupStep::Language,
13035            codewhale_config::StepEntry::new(
13036                codewhale_config::StepStatus::Verified,
13037                true,
13038                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13039            ),
13040        );
13041        state.set_step(
13042            codewhale_config::SetupStep::ProviderModel,
13043            codewhale_config::StepEntry::new(
13044                codewhale_config::StepStatus::Verified,
13045                true,
13046                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13047            ),
13048        );
13049        state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13050        state.complete_constitution_checkpoint(
13051            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13052            codewhale_config::ConstitutionChoice::Bundled,
13053        );
13054        state.set_step(
13055            codewhale_config::SetupStep::OperateFleet,
13056            codewhale_config::StepEntry::new(
13057                codewhale_config::StepStatus::Verified,
13058                false,
13059                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13060            )
13061            .with_result(
13062                "provider=ready, runtime=ready, roster=ready, concurrency=plan limit not probed",
13063            ),
13064        );
13065        state.save().expect("persist setup state");
13066
13067        let config = Config {
13068            api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13069            ..Config::default()
13070        };
13071        let report = doctor_setup_report_json(&config, &workspace);
13072
13073        assert_eq!(report["first_run_ready"], true);
13074        assert_eq!(report["operate_ready"], false);
13075        assert_eq!(
13076            report["operate_fleet"]["concurrency"]["plan_limit_probed"],
13077            false
13078        );
13079        assert!(
13080            report["operate_fleet"]["roster"]["built_in"]
13081                .as_u64()
13082                .is_some_and(|count| count > 0)
13083        );
13084        let operate_step = report["steps"]
13085            .as_array()
13086            .expect("steps array")
13087            .iter()
13088            .find(|step| step["step"] == "operate_fleet")
13089            .expect("operate/fleet step");
13090        assert_eq!(operate_step["status"], "verified");
13091        assert!(
13092            operate_step["result"]
13093                .as_str()
13094                .is_some_and(|result| result.contains("plan limit not probed"))
13095        );
13096    }
13097}
13098
13099#[cfg(test)]
13100mod doctor_endpoint_tests {
13101    use super::*;
13102
13103    #[test]
13104    fn doctor_api_target_reports_default_endpoint() {
13105        let config = Config::default();
13106
13107        let target = doctor_api_target(&config);
13108
13109        assert_eq!(target.provider, "deepseek");
13110        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13111        assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13112        assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13113    }
13114
13115    #[test]
13116    fn doctor_api_target_falls_back_to_configured_model_when_resolution_fails() {
13117        // `custom` with no custom provider table cannot resolve an identity;
13118        // doctor must fall back to the raw configured model and say so
13119        // instead of presenting an unresolved value as the engine's route.
13120        let config = Config {
13121            provider: Some("custom".to_string()),
13122            ..Default::default()
13123        };
13124
13125        let target = doctor_api_target(&config);
13126
13127        assert_eq!(target.resolution, DoctorModelResolution::ConfiguredOnly);
13128        assert_eq!(target.model, config.default_model());
13129    }
13130
13131    #[test]
13132    fn doctor_api_target_routes_deepseek_cn_alias_to_beta_endpoint() {
13133        let config = Config {
13134            provider: Some("deepseek-cn".to_string()),
13135            ..Default::default()
13136        };
13137
13138        let target = doctor_api_target(&config);
13139
13140        assert_eq!(target.provider, "deepseek-cn");
13141        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
13142        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13143        assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13144        assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13145    }
13146
13147    #[test]
13148    fn strict_tool_mode_doctor_reports_disabled_by_default() {
13149        let config = Config::default();
13150
13151        let status = doctor_strict_tool_mode_status(&config);
13152
13153        assert!(!status.enabled);
13154        assert_eq!(status.status, "disabled");
13155        assert!(!status.function_strict_sent);
13156        assert!(status.recommended_base_url.is_none());
13157    }
13158
13159    #[test]
13160    fn doctor_known_base_urls_are_ascii_case_insensitive() {
13161        assert!(doctor_xiaomi_mimo_base_url_uses_token_plan(
13162            "HTTPS://TOKEN-PLAN-CN.XIAOMIMIMO.COM/V1/"
13163        ));
13164        assert_eq!(
13165            known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/BETA/"),
13166            Some(DeepSeekBaseUrlKind::Beta)
13167        );
13168        assert_eq!(
13169            known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/V1/"),
13170            Some(DeepSeekBaseUrlKind::NonBeta)
13171        );
13172    }
13173
13174    #[test]
13175    fn strict_tool_mode_doctor_accepts_default_beta_endpoint() {
13176        let config = Config {
13177            strict_tool_mode: Some(true),
13178            ..Default::default()
13179        };
13180
13181        let status = doctor_strict_tool_mode_status(&config);
13182
13183        assert!(status.enabled);
13184        assert_eq!(status.status, "ready");
13185        assert!(status.function_strict_sent);
13186        assert!(status.message.contains("beta endpoint"));
13187        assert!(status.recommended_base_url.is_none());
13188    }
13189
13190    #[test]
13191    fn strict_tool_mode_doctor_warns_for_non_beta_deepseek_endpoint() {
13192        let config = Config {
13193            strict_tool_mode: Some(true),
13194            base_url: Some("https://api.deepseek.com".to_string()),
13195            ..Default::default()
13196        };
13197
13198        let status = doctor_strict_tool_mode_status(&config);
13199
13200        assert_eq!(status.status, "fallback_non_beta");
13201        assert!(!status.function_strict_sent);
13202        assert_eq!(
13203            status.recommended_base_url.as_deref(),
13204            Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL)
13205        );
13206        assert_eq!(
13207            doctor_strict_tool_mode_report_json(&status)["recommended_base_url"],
13208            "https://api.deepseek.com"
13209        );
13210    }
13211
13212    #[test]
13213    fn strict_tool_mode_doctor_accepts_deepseek_cn_alias_default_endpoint() {
13214        let config = Config {
13215            provider: Some("deepseek-cn".to_string()),
13216            strict_tool_mode: Some(true),
13217            ..Default::default()
13218        };
13219
13220        let status = doctor_strict_tool_mode_status(&config);
13221
13222        assert_eq!(status.status, "ready");
13223        assert!(status.function_strict_sent);
13224        assert!(status.message.contains("beta endpoint"));
13225        assert!(status.recommended_base_url.is_none());
13226    }
13227
13228    #[test]
13229    fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() {
13230        let config = Config {
13231            provider: Some("vllm".to_string()),
13232            strict_tool_mode: Some(true),
13233            ..Default::default()
13234        };
13235
13236        let status = doctor_strict_tool_mode_status(&config);
13237
13238        assert_eq!(status.status, "custom_endpoint");
13239        assert!(status.function_strict_sent);
13240        assert!(status.message.contains("custom endpoint"));
13241    }
13242
13243    #[test]
13244    fn doctor_tls_status_reports_verification_enabled_by_default() {
13245        let status = doctor_tls_status(&Config::default());
13246
13247        assert!(status.certificate_verification);
13248        assert!(!status.insecure_skip_tls_verify);
13249        assert_eq!(status.provider, "deepseek");
13250        assert!(status.message.contains("enabled"));
13251    }
13252
13253    #[test]
13254    fn doctor_tls_status_warns_when_active_provider_skips_verification() {
13255        let mut providers = crate::config::ProvidersConfig::default();
13256        providers.openai.insecure_skip_tls_verify = Some(true);
13257        let config = Config {
13258            provider: Some("openai".to_string()),
13259            providers: Some(providers),
13260            ..Default::default()
13261        };
13262
13263        let status = doctor_tls_status(&config);
13264
13265        assert!(status.certificate_verification);
13266        assert!(status.insecure_skip_tls_verify);
13267        assert_eq!(status.provider, "openai");
13268        assert!(status.message.contains("cannot be disabled"));
13269        assert!(status.message.contains("SSL_CERT_FILE"));
13270    }
13271
13272    #[test]
13273    fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
13274        let mut config = Config {
13275            default_text_model: Some("deepseek-chat".to_string()),
13276            ..Default::default()
13277        };
13278        crate::config::normalize_model_config_for_test(&mut config);
13279
13280        let report = provider_capability_report(&config);
13281
13282        assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13283        assert_eq!(report["context_window"], 1_000_000);
13284        assert_eq!(report["thinking_supported"], true);
13285        assert_eq!(report["alias_deprecation"]["alias"], "deepseek-chat");
13286        assert_eq!(
13287            report["alias_deprecation"]["replacement"],
13288            "deepseek-v4-flash"
13289        );
13290        assert_eq!(
13291            report["alias_deprecation"]["retirement_utc"],
13292            "2026-07-24T15:59:00Z"
13293        );
13294    }
13295
13296    #[test]
13297    fn provider_capability_report_preserves_custom_deepseek_alias_namespace() {
13298        let mut config = Config {
13299            base_url: Some("https://models.example/v1".to_string()),
13300            default_text_model: Some("deepseek-chat".to_string()),
13301            ..Default::default()
13302        };
13303        crate::config::normalize_model_config_for_test(&mut config);
13304
13305        let report = provider_capability_report(&config);
13306
13307        assert_eq!(report["resolved_model"], "deepseek-chat");
13308        assert!(report["alias_deprecation"].is_null());
13309    }
13310
13311    #[test]
13312    fn provider_capability_report_leaves_canonical_flash_alias_metadata_null() {
13313        let config = Config {
13314            default_text_model: Some("deepseek-v4-flash".to_string()),
13315            ..Default::default()
13316        };
13317
13318        let report = provider_capability_report(&config);
13319
13320        assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13321        assert!(report["alias_deprecation"].is_null());
13322    }
13323
13324    #[test]
13325    fn doctor_route_report_exposes_tokenhub_openai_compatible_route_without_secret() {
13326        let mut providers = crate::config::ProvidersConfig::default();
13327        providers.openai.api_key = Some("tokenhub-secret-value".to_string());
13328        providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
13329        providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
13330        let config = Config {
13331            provider: Some("openai".to_string()),
13332            providers: Some(providers),
13333            ..Default::default()
13334        };
13335
13336        let report = doctor_route_report(&config);
13337        let serialized = report.to_string();
13338
13339        assert_eq!(report["provider"], "openai");
13340        assert_eq!(report["provider_source"], "config");
13341        assert_eq!(report["provider_config_table"], "openai");
13342        assert_eq!(report["model"], "deepseek-ai/DeepSeek-V4-Pro");
13343        assert_eq!(report["wire_protocol"], "chat_completions");
13344        assert_eq!(
13345            report["base_url"]["redacted"],
13346            "https://tokenhub.tencentmaas.com"
13347        );
13348        assert_eq!(report["base_url"]["class"], "custom");
13349        assert_eq!(report["auth"]["scheme"], "bearer");
13350        assert_eq!(report["auth"]["source"], "config_declared");
13351        assert!(
13352            report["base_url"]["fingerprint"]
13353                .as_str()
13354                .is_some_and(|value| value.starts_with("<redacted:"))
13355        );
13356        assert!(!serialized.contains("tokenhub-secret-value"));
13357    }
13358
13359    #[test]
13360    fn doctor_route_report_exposes_siliconflow_cn_provider_route() {
13361        let mut providers = crate::config::ProvidersConfig::default();
13362        providers.siliconflow_cn.api_key = Some("sf-cn-secret-value".to_string());
13363        providers.siliconflow_cn.base_url =
13364            Some(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
13365        providers.siliconflow_cn.model = Some(crate::config::DEFAULT_SILICONFLOW_MODEL.to_string());
13366        let config = Config {
13367            provider: Some("siliconflow-CN".to_string()),
13368            providers: Some(providers),
13369            ..Default::default()
13370        };
13371
13372        let report = doctor_route_report(&config);
13373        let serialized = report.to_string();
13374
13375        assert_eq!(report["provider"], "siliconflow-CN");
13376        assert_eq!(report["provider_config_table"], "siliconflow_cn");
13377        assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
13378        assert_eq!(
13379            report["base_url"]["redacted"],
13380            crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
13381        );
13382        assert_eq!(report["base_url"]["class"], "default");
13383        assert_eq!(report["auth"]["scheme"], "bearer");
13384        assert_eq!(report["auth"]["source"], "config_declared");
13385        assert!(!serialized.contains("sf-cn-secret-value"));
13386    }
13387
13388    #[test]
13389    fn doctor_route_report_names_kimi_code_context_provenance() {
13390        let config = Config {
13391            provider: Some("moonshot".to_string()),
13392            providers: Some(crate::config::ProvidersConfig {
13393                moonshot: crate::config::ProviderConfig {
13394                    api_key: Some("kimi-plan-secret".to_string()),
13395                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13396                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13397                    ..Default::default()
13398                },
13399                ..Default::default()
13400            }),
13401            ..Default::default()
13402        };
13403
13404        let report = doctor_route_report(&config);
13405        let serialized = report.to_string();
13406
13407        assert_eq!(report["context_window"]["tokens"], 262_144);
13408        assert_eq!(
13409            report["context_window"]["source"],
13410            "static Kimi Code safe floor"
13411        );
13412        assert!(!serialized.contains("kimi-plan-secret"));
13413    }
13414
13415    #[test]
13416    fn provider_capability_report_uses_exact_kimi_code_route_facts() {
13417        let config = Config {
13418            provider: Some("moonshot".to_string()),
13419            providers: Some(crate::config::ProvidersConfig {
13420                moonshot: crate::config::ProviderConfig {
13421                    api_key: Some("kimi-plan-secret".to_string()),
13422                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13423                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13424                    ..Default::default()
13425                },
13426                ..Default::default()
13427            }),
13428            ..Default::default()
13429        };
13430
13431        let report = provider_capability_report(&config);
13432
13433        assert_eq!(report["resolved_model"], crate::config::KIMI_CODE_K3_MODEL);
13434        assert_eq!(report["context_window"], 262_144);
13435        assert_eq!(
13436            report["context_window_source"],
13437            "static Kimi Code safe floor"
13438        );
13439        assert_eq!(report["thinking_supported"], true);
13440    }
13441
13442    #[test]
13443    fn provider_capability_report_honors_kimi_code_context_override() {
13444        let config = Config {
13445            provider: Some("moonshot".to_string()),
13446            providers: Some(crate::config::ProvidersConfig {
13447                moonshot: crate::config::ProviderConfig {
13448                    api_key: Some("kimi-plan-secret".to_string()),
13449                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13450                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13451                    context_window: Some(1_048_576),
13452                    ..Default::default()
13453                },
13454                ..Default::default()
13455            }),
13456            ..Default::default()
13457        };
13458
13459        let report = provider_capability_report(&config);
13460
13461        assert_eq!(
13462            report["resolved_model"],
13463            crate::config::KIMI_CODE_K3_MODEL,
13464            "the configured window must preserve Kimi Code's bare wire id"
13465        );
13466        assert_eq!(report["context_window"], 1_048_576);
13467        assert_eq!(report["context_window_source"], "configured");
13468        assert_eq!(report["thinking_supported"], true);
13469    }
13470
13471    #[test]
13472    fn provider_capability_report_uses_direct_moonshot_k3_route_facts() {
13473        let config = Config {
13474            provider: Some("moonshot".to_string()),
13475            providers: Some(crate::config::ProvidersConfig {
13476                moonshot: crate::config::ProviderConfig {
13477                    api_key: Some("moonshot-secret".to_string()),
13478                    base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
13479                    model: Some("kimi-k3".to_string()),
13480                    ..Default::default()
13481                },
13482                ..Default::default()
13483            }),
13484            ..Default::default()
13485        };
13486
13487        let report = provider_capability_report(&config);
13488
13489        assert_eq!(report["resolved_model"], "kimi-k3");
13490        assert_eq!(report["context_window"], 1_048_576);
13491        assert_eq!(report["context_window_source"], "catalog");
13492        assert_eq!(report["max_output"], 1_048_576);
13493        assert_eq!(report["thinking_supported"], true);
13494    }
13495
13496    #[test]
13497    fn doctor_search_provider_line_includes_firecrawl_default_source_and_switch_hint() {
13498        let _guard = crate::test_support::lock_test_env();
13499        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13500        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13501
13502        let line = doctor_search_provider_line(&Config::default());
13503
13504        match prev {
13505            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13506            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13507        }
13508        assert!(line.contains("search_provider: firecrawl"));
13509        assert!(line.contains("source: default"));
13510        assert!(line.contains("[search] provider"));
13511        assert!(line.contains("provider = \"baidu\""));
13512    }
13513
13514    #[test]
13515    fn doctor_search_provider_json_reports_config_source() {
13516        let _guard = crate::test_support::lock_test_env();
13517        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13518        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13519        let config = Config {
13520            search: Some(crate::config::SearchConfig {
13521                provider: Some(crate::config::SearchProvider::DuckDuckGo),
13522                base_url: None,
13523                api_key: None,
13524            }),
13525            ..Default::default()
13526        };
13527
13528        let report = doctor_search_provider_json(&config);
13529
13530        match prev {
13531            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13532            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13533        }
13534        assert_eq!(report["provider"], "duckduckgo");
13535        assert_eq!(report["source"], "config");
13536    }
13537
13538    #[test]
13539    fn doctor_search_provider_json_reports_env_override_source() {
13540        let _guard = crate::test_support::lock_test_env();
13541        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13542        unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", "tavily") };
13543
13544        let report = doctor_search_provider_json(&Config::default());
13545
13546        match prev {
13547            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13548            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13549        }
13550        assert_eq!(report["provider"], "tavily");
13551        assert_eq!(report["source"], "env override");
13552    }
13553
13554    #[test]
13555    fn doctor_search_provider_line_omits_switch_hint_when_bing_is_configured() {
13556        let _guard = crate::test_support::lock_test_env();
13557        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13558        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13559        let config = Config {
13560            search: Some(crate::config::SearchConfig {
13561                provider: Some(crate::config::SearchProvider::Bing),
13562                base_url: None,
13563                api_key: None,
13564            }),
13565            ..Default::default()
13566        };
13567
13568        let line = doctor_search_provider_line(&config);
13569
13570        match prev {
13571            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13572            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13573        }
13574        assert!(line.contains("search_provider: bing"));
13575        assert!(line.contains("source: config"));
13576        assert!(!line.contains("[search] provider"));
13577    }
13578
13579    #[test]
13580    fn timeout_recovery_keeps_default_deepseek_users_on_default_endpoint() {
13581        let config = Config::default();
13582
13583        let text = doctor_timeout_recovery_lines(&config).join("\n");
13584
13585        assert!(text.contains("api.deepseek.com"));
13586        assert!(text.contains("custom DeepSeek-compatible endpoint"));
13587        assert!(!text.contains("provider = \"deepseek-cn\""));
13588        assert!(text.contains("codewhale doctor --json"));
13589    }
13590
13591    #[test]
13592    fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
13593        let config = Config {
13594            provider: Some("vllm".to_string()),
13595            ..Default::default()
13596        };
13597
13598        let text = doctor_timeout_recovery_lines(&config).join("\n");
13599
13600        assert!(text.contains("/v1/models"));
13601        assert!(text.contains("/v1/chat/completions"));
13602        assert!(!text.contains("api.deepseeki.com"));
13603    }
13604}
13605
13606#[cfg(test)]
13607mod terminal_mode_tests {
13608    use super::*;
13609    use clap::Parser;
13610
13611    fn parse_cli(args: &[&str]) -> Cli {
13612        Cli::try_parse_from(args).expect("CLI args should parse")
13613    }
13614
13615    #[test]
13616    fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
13617        let config = Config {
13618            network: Some(crate::config::NetworkPolicyToml {
13619                default: "allow".to_string(),
13620                audit: false,
13621                ..crate::config::NetworkPolicyToml::default()
13622            }),
13623            ..Config::default()
13624        };
13625        let authority = crate::tools::spec::ToolAuthorityEnvelope {
13626            schema_version: 1,
13627            owner: "consultant-1".to_string(),
13628            authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
13629            network_access: Some(false),
13630            shell: crate::tools::spec::ToolShellAuthority::None,
13631            verification: crate::tools::spec::ToolVerificationAuthority::None,
13632            writable_roots: Vec::new(),
13633            writable_files: Vec::new(),
13634            coordination_contracts: Vec::new(),
13635        }
13636        .normalized()
13637        .expect("Consultant authority");
13638
13639        let policy = exec_network_policy(&config, authority.network_access)
13640            .expect("explicit network=false always installs a policy");
13641        assert_eq!(
13642            policy.evaluate("example.com", "web_search"),
13643            crate::network_policy::Decision::Deny,
13644            "the permissive user config must not widen Consultant network authority"
13645        );
13646        let mut features = crate::features::Features::default();
13647        features.enable(crate::features::Feature::ShellTool);
13648        features.enable(crate::features::Feature::WebSearch);
13649        apply_fleet_engine_feature_caps(
13650            &mut features,
13651            true,
13652            authority.network_access,
13653            authority.shell,
13654        );
13655        assert!(!features.enabled(crate::features::Feature::WebSearch));
13656        assert!(!features.enabled(crate::features::Feature::ShellTool));
13657
13658        let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
13659        assert_eq!(
13660            worker_policy.evaluate("example.com", "web_search"),
13661            crate::network_policy::Decision::Allow,
13662            "a network-capable role keeps the configured policy"
13663        );
13664    }
13665    #[test]
13666    fn hidden_remote_control_flag_starts_the_interactive_handoff() {
13667        let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
13668        assert!(cli.remote_control);
13669    }
13670
13671    #[test]
13672    fn plugin_registry_discovery_is_route_independent_and_read_only() {
13673        let _env_lock = crate::test_support::lock_test_env();
13674        let temp = tempfile::tempdir().unwrap();
13675        let workspace = temp.path().join("workspace");
13676        let codewhale_home = temp.path().join("home");
13677        std::fs::create_dir_all(&workspace).unwrap();
13678        let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
13679        let workspace_arg = workspace.to_string_lossy().into_owned();
13680
13681        for route in [
13682            Vec::<&str>::new(),
13683            vec!["resume", "--last"],
13684            vec!["fork", "--last"],
13685            vec!["exec", "hello"],
13686            vec!["serve", "--mcp"],
13687        ] {
13688            let mut args = vec![
13689                "codewhale-tui".to_string(),
13690                "--workspace".to_string(),
13691                workspace_arg.clone(),
13692            ];
13693            args.extend(route.into_iter().map(str::to_string));
13694            let cli = Cli::try_parse_from(args).expect("route should parse");
13695            let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
13696            let registry = discovery
13697                .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
13698            assert_eq!(registry.workspace(), workspace.as_path());
13699            assert!(
13700                !codewhale_home.join("plugins/state.json").exists(),
13701                "startup discovery must remain read-only"
13702            );
13703        }
13704    }
13705
13706    fn custom_exec_config(active: &str) -> Config {
13707        let mut custom = std::collections::HashMap::new();
13708        for (name, base_url, model) in [
13709            (
13710                "custom-a",
13711                "http://127.0.0.1:18181/v1",
13712                crate::config::ZAI_GLM_5_2_MODEL,
13713            ),
13714            ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
13715        ] {
13716            custom.insert(
13717                name.to_string(),
13718                crate::config::ProviderConfig {
13719                    kind: Some("openai-compatible".to_string()),
13720                    base_url: Some(base_url.to_string()),
13721                    model: Some(model.to_string()),
13722                    api_key: Some("local-test-key".to_string()),
13723                    ..Default::default()
13724                },
13725            );
13726        }
13727        Config {
13728            provider: Some(active.to_string()),
13729            providers: Some(crate::config::ProvidersConfig {
13730                custom,
13731                ..Default::default()
13732            }),
13733            ..Default::default()
13734        }
13735    }
13736
13737    #[test]
13738    fn doctor_json_surfaces_keep_exact_named_custom_provider() {
13739        let config = custom_exec_config("custom-a");
13740        let workspace = tempfile::tempdir().expect("doctor workspace");
13741
13742        let operate = doctor_operate_fleet_report_json(&config, workspace.path());
13743        let provider_model = doctor_provider_model_report_json(&config);
13744        let capability = provider_capability_report(&config);
13745        let route = doctor_route_report(&config);
13746
13747        assert_eq!(operate["provider"]["id"], "custom-a");
13748        assert_eq!(provider_model["provider"]["id"], "custom-a");
13749        assert_eq!(capability["resolved_provider"], "custom-a");
13750        assert_eq!(route["provider"], "custom-a");
13751        assert_eq!(route["provider_config_table"], "providers.custom-a");
13752        let serialized = serde_json::to_string(&serde_json::json!({
13753            "operate": operate,
13754            "provider_model": provider_model,
13755            "capability": capability,
13756            "route": route,
13757        }))
13758        .expect("doctor JSON");
13759        assert!(!serialized.contains("local-test-key"));
13760    }
13761
13762    fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
13763        let mut saved = session_manager::create_saved_session_with_mode(
13764            &[],
13765            model,
13766            Path::new("/tmp/exec-resume"),
13767            0,
13768            None,
13769            Some("exec"),
13770        );
13771        let kind = crate::config::ApiProvider::parse(provider)
13772            .unwrap_or(crate::config::ApiProvider::Custom)
13773            .as_str();
13774        let exact_id = (!provider
13775            .eq_ignore_ascii_case(crate::config::ApiProvider::Custom.as_str()))
13776        .then_some(provider);
13777        saved.metadata.set_model_provider_route(kind, exact_id);
13778        saved
13779    }
13780
13781    #[test]
13782    fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
13783        let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
13784
13785        assert_eq!(cli.prompt, vec!["hello", "world"]);
13786    }
13787
13788    #[test]
13789    fn prompt_flag_starts_interactive_submit_input() {
13790        let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
13791
13792        assert_eq!(
13793            top_level_prompt_initial_input(&cli.prompt),
13794            Some(tui::InitialInput::Submit("read the project".to_string()))
13795        );
13796    }
13797
13798    #[test]
13799    fn companion_binary_reports_its_own_name() {
13800        assert_eq!(Cli::command().get_name(), "codewhale-tui");
13801    }
13802
13803    #[test]
13804    fn xai_device_auth_subcommand_parses() {
13805        let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
13806        assert!(matches!(
13807            cli.command,
13808            Some(Commands::Auth(TuiAuthArgs {
13809                command: TuiAuthCommand::XaiDevice
13810            }))
13811        ));
13812    }
13813
13814    #[test]
13815    fn workflow_tool_internal_subcommand_parses_exact_json() {
13816        let cli = parse_cli(&[
13817            "codewhale-tui",
13818            "workflow-tool",
13819            "--approval-source",
13820            "explicit-workflow-command",
13821            "--input-json",
13822            r#"{"action":"run","source_path":"workflows/demo.js"}"#,
13823        ]);
13824        let Some(Commands::WorkflowTool(args)) = cli.command else {
13825            panic!("expected workflow-tool command");
13826        };
13827        assert!(args.input_json.contains("\"action\":\"run\""));
13828    }
13829
13830    #[tokio::test]
13831    async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
13832        use crate::tools::spec::ToolSpec;
13833
13834        let workspace = tempfile::tempdir().expect("workspace");
13835        let config = Config {
13836            provider: Some("vllm".to_string()),
13837            mcp_config_path: Some(
13838                workspace
13839                    .path()
13840                    .join("missing-mcp.json")
13841                    .display()
13842                    .to_string(),
13843            ),
13844            providers: Some(crate::config::ProvidersConfig {
13845                vllm: crate::config::ProviderConfig {
13846                    base_url: Some("http://127.0.0.1:9/v1".to_string()),
13847                    model: Some("offline-test-model".to_string()),
13848                    ..Default::default()
13849                },
13850                ..Default::default()
13851            }),
13852            ..Default::default()
13853        };
13854        let route = CliAutoRoute {
13855            provider: crate::config::ApiProvider::Vllm,
13856            model: "offline-test-model".to_string(),
13857            reasoning_effort: None,
13858            auto_controls_reasoning: false,
13859            auto_model: false,
13860        };
13861        let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
13862        let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
13863        let (tool, context) =
13864            build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
13865                .await
13866                .expect("build direct workflow runtime");
13867
13868        let result = tool
13869            .execute(
13870                serde_json::json!({
13871                    "action": "run",
13872                    "script": "phase('offline'); return { ok: true };",
13873                    "token_budget": 1_000_000
13874                }),
13875                &context,
13876            )
13877            .await
13878            .expect("model-free workflow run");
13879        let payload: serde_json::Value =
13880            serde_json::from_str(&result.content).expect("workflow JSON");
13881
13882        assert_eq!(payload["status"], "completed");
13883        assert_eq!(payload["result"]["ok"], true);
13884        assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
13885        assert_eq!(
13886            payload["plan_approval"]["decision"],
13887            "approved_explicit_cli_command"
13888        );
13889        assert!(!context.auto_approve);
13890        assert!(!context.trust_mode);
13891        assert_eq!(
13892            context.shell_policy,
13893            crate::worker_profile::ShellPolicy::None
13894        );
13895        assert!(matches!(
13896            context.elevated_sandbox_policy,
13897            Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
13898        ));
13899        let mut event_types = Vec::new();
13900        while let Ok(event) = event_rx.try_recv() {
13901            if let crate::core::events::Event::WorkflowUi { event, .. } = event
13902                && let Some(kind) = event["type"].as_str()
13903            {
13904                event_types.push(kind.to_string());
13905            }
13906        }
13907        assert!(event_types.iter().any(|kind| kind == "run_started"));
13908        assert!(event_types.iter().any(|kind| kind == "run_completed"));
13909    }
13910
13911    #[tokio::test]
13912    async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
13913        let workspace = tempfile::tempdir().expect("workspace");
13914        let mcp_path = workspace.path().join("mcp.json");
13915        std::fs::write(
13916            &mcp_path,
13917            r#"{
13918                "mcpServers": {
13919                    "blocked": { "url": "https://blocked.invalid/mcp" }
13920                }
13921            }"#,
13922        )
13923        .expect("write MCP config");
13924        let config = Config {
13925            mcp_config_path: Some(mcp_path.display().to_string()),
13926            ..Default::default()
13927        };
13928        let policy = crate::network_policy::NetworkPolicyDecider::new(
13929            crate::network_policy::NetworkPolicy {
13930                default: crate::network_policy::DecisionToml::Deny,
13931                allow: Vec::new(),
13932                deny: Vec::new(),
13933                proxy: Vec::new(),
13934                proxy_fake_ip_cidrs: Vec::new(),
13935                audit: false,
13936            },
13937            None,
13938        );
13939
13940        let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
13941        let (_pool, failures) =
13942            initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
13943                .await
13944                .expect("MCP feature enabled");
13945        assert_eq!(failures.len(), 1, "failures={failures:?}");
13946        assert_eq!(failures[0].0, "blocked");
13947        assert!(failures[0].1.contains("blocked by network policy"));
13948    }
13949
13950    #[test]
13951    fn exec_model_resolution_uses_provider_scoped_default() {
13952        let _env_lock = crate::test_support::lock_test_env();
13953        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
13954        let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
13955        let config = Config {
13956            provider: Some("openrouter".to_string()),
13957            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13958            providers: Some(crate::config::ProvidersConfig {
13959                openrouter: crate::config::ProviderConfig {
13960                    model: Some("arcee-ai/trinity-large-thinking".to_string()),
13961                    ..Default::default()
13962                },
13963                ..Default::default()
13964            }),
13965            ..Default::default()
13966        };
13967
13968        assert_eq!(
13969            resolve_exec_model(&config, None),
13970            "arcee-ai/trinity-large-thinking"
13971        );
13972        assert_eq!(
13973            resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
13974            "arcee-ai/trinity-large-thinking"
13975        );
13976    }
13977
13978    #[test]
13979    fn exec_model_resolution_prefers_codewhale_model_env_override() {
13980        let _env_lock = crate::test_support::lock_test_env();
13981        let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
13982        let _deepseek_model =
13983            crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
13984        let config = Config {
13985            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13986            ..Default::default()
13987        };
13988
13989        assert_eq!(resolve_exec_model(&config, None), "auto");
13990    }
13991
13992    #[test]
13993    fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
13994        let _env_lock = crate::test_support::lock_test_env();
13995        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
13996        let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
13997        let config = Config {
13998            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13999            ..Default::default()
14000        };
14001
14002        assert_eq!(resolve_exec_model(&config, None), "auto");
14003    }
14004
14005    #[test]
14006    fn exec_model_resolution_uses_provider_safe_default_for_zai() {
14007        let _env_lock = crate::test_support::lock_test_env();
14008        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14009        let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14010        let config = Config {
14011            provider: Some("zai".to_string()),
14012            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14013            ..Default::default()
14014        };
14015
14016        assert_eq!(
14017            resolve_exec_model(&config, None),
14018            crate::config::DEFAULT_ZAI_MODEL
14019        );
14020    }
14021
14022    #[tokio::test]
14023    #[allow(clippy::await_holding_lock)]
14024    async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
14025        let _env_lock = crate::test_support::lock_test_env();
14026        let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14027        let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
14028        let config = Config {
14029            provider: Some("deepseek".to_string()),
14030            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14031            ..Default::default()
14032        };
14033
14034        let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14035            .await
14036            .expect("explicit GLM should route to the configured Z.ai provider");
14037
14038        assert_eq!(route.provider, crate::config::ApiProvider::Zai);
14039        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14040        assert!(!route.auto_model);
14041    }
14042
14043    #[tokio::test]
14044    #[allow(clippy::await_holding_lock)]
14045    async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
14046        let _env_lock = crate::test_support::lock_test_env();
14047        let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14048        let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
14049        let config = Config {
14050            provider: Some("deepseek".to_string()),
14051            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14052            ..Default::default()
14053        };
14054
14055        let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14056            .await
14057            .expect_err("ambiguous GLM route should ask for an explicit provider");
14058        let message = err.to_string();
14059
14060        assert!(message.contains("model `GLM-5.2` is available"));
14061        assert!(message.contains("openrouter"));
14062        assert!(message.contains("zai"));
14063        assert!(message.contains("--provider"));
14064        assert!(message.contains("/provider"));
14065        assert!(message.contains("/model"));
14066        assert!(message.contains("/setup"));
14067    }
14068
14069    #[tokio::test]
14070    async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
14071        let config = Config {
14072            provider: Some("vllm".to_string()),
14073            reasoning_effort: Some("low".to_string()),
14074            providers: Some(crate::config::ProvidersConfig {
14075                vllm: crate::config::ProviderConfig {
14076                    base_url: Some("http://127.0.0.1:18190/v1".to_string()),
14077                    model: Some("local-auto-model".to_string()),
14078                    ..Default::default()
14079                },
14080                ..Default::default()
14081            }),
14082            ..Default::default()
14083        };
14084
14085        let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
14086            .await
14087            .expect("Auto model route");
14088
14089        assert!(route.auto_model);
14090        assert_eq!(
14091            route.reasoning_effort,
14092            Some(crate::tui::app::ReasoningEffort::Low)
14093        );
14094        assert!(
14095            !route.auto_controls_reasoning,
14096            "a fixed saved tier must not be replaced per prompt"
14097        );
14098    }
14099
14100    #[test]
14101    fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
14102        let mut providers = crate::config::ProvidersConfig::default();
14103        providers.deepseek.model = Some("deepseek-v4-pro".to_string());
14104        let config = Config {
14105            provider: Some("deepseek".to_string()),
14106            providers: Some(providers),
14107            ..Default::default()
14108        };
14109        let route = CliAutoRoute {
14110            provider: crate::config::ApiProvider::Deepseek,
14111            model: "deepseek-v4-flash".to_string(),
14112            reasoning_effort: None,
14113            auto_controls_reasoning: true,
14114            auto_model: true,
14115        };
14116
14117        let execution_config = config_for_cli_route(&config, &route);
14118
14119        assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
14120        assert_eq!(
14121            execution_config
14122                .provider_config_for(crate::config::ApiProvider::Deepseek)
14123                .and_then(|entry| entry.model.as_deref()),
14124            Some("deepseek-v4-flash")
14125        );
14126    }
14127
14128    #[test]
14129    fn cli_route_execution_config_preserves_legacy_literal_custom_root_route() {
14130        let _lock = crate::test_support::lock_test_env();
14131        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
14132        let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
14133        let config = Config {
14134            provider: Some("custom".to_string()),
14135            api_key: Some("legacy-root-key".to_string()),
14136            base_url: Some("http://127.0.0.1:18183/v1".to_string()),
14137            default_text_model: Some("legacy-model".to_string()),
14138            ..Default::default()
14139        };
14140        let route = CliAutoRoute {
14141            provider: crate::config::ApiProvider::Custom,
14142            model: "routed-legacy-model".to_string(),
14143            reasoning_effort: None,
14144            auto_controls_reasoning: false,
14145            auto_model: false,
14146        };
14147
14148        let execution = config_for_cli_route(&config, &route);
14149
14150        assert!(execution.uses_legacy_literal_custom_route());
14151        assert!(
14152            execution
14153                .providers
14154                .as_ref()
14155                .is_none_or(|providers| !providers.custom.contains_key("custom"))
14156        );
14157        assert_eq!(execution.provider.as_deref(), Some("custom"));
14158        assert_eq!(execution.default_model(), "routed-legacy-model");
14159        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18183/v1");
14160        assert_eq!(execution.deepseek_api_key().unwrap(), "legacy-root-key");
14161        for _ in 0..2 {
14162            let identity = execution
14163                .resolve_provider_identity("custom")
14164                .expect("legacy identity remains repeatedly resolvable");
14165            assert_eq!(identity.key, "custom");
14166        }
14167        let client =
14168            crate::client::DeepSeekClient::new(&execution).expect("legacy execution client");
14169        assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
14170    }
14171
14172    #[test]
14173    fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
14174        let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
14175        let Some(Commands::Exec(args)) = cli.command else {
14176            panic!("expected exec command");
14177        };
14178
14179        assert_eq!(args.prompt, vec!["hello", "world"]);
14180    }
14181
14182    #[test]
14183    fn exec_keeps_model_flag_before_split_prompt_words() {
14184        let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
14185        let Some(Commands::Exec(args)) = cli.command else {
14186            panic!("expected exec command");
14187        };
14188
14189        assert_eq!(args.model.as_deref(), Some("auto"));
14190        assert_eq!(args.prompt, vec!["hello", "world"]);
14191    }
14192
14193    #[test]
14194    fn exec_keeps_flags_before_split_prompt_words() {
14195        let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
14196        let Some(Commands::Exec(args)) = cli.command else {
14197            panic!("expected exec command");
14198        };
14199
14200        assert!(args.json);
14201        assert_eq!(args.prompt, vec!["hello", "world"]);
14202    }
14203
14204    #[test]
14205    fn exec_parses_provider_flag_alongside_model() {
14206        // #4093: Fleet threads `--provider <id>` so a worker launches on its
14207        // profile-pinned provider even when the parent session is elsewhere.
14208        let cli = parse_cli(&[
14209            "codewhale",
14210            "exec",
14211            "--provider",
14212            "openrouter",
14213            "--model",
14214            "glm-5.2",
14215            "audit",
14216        ]);
14217        let Some(Commands::Exec(args)) = cli.command else {
14218            panic!("expected exec command");
14219        };
14220
14221        assert_eq!(args.provider.as_deref(), Some("openrouter"));
14222        assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14223        assert_eq!(args.prompt, vec!["audit"]);
14224        // The threaded id round-trips through the provider vocabulary the exec
14225        // handler validates against — never a model-id sniff (EPIC #2608).
14226        assert_eq!(
14227            crate::config::ApiProvider::parse(args.provider.as_deref().unwrap()),
14228            Some(crate::config::ApiProvider::Openrouter)
14229        );
14230    }
14231
14232    #[test]
14233    fn exec_provider_override_accepts_configured_custom_provider() {
14234        let mut custom = std::collections::HashMap::new();
14235        custom.insert(
14236            "lm-studio".to_string(),
14237            crate::config::ProviderConfig {
14238                kind: Some("openai-compatible".to_string()),
14239                base_url: Some("http://127.0.0.1:1234/v1".to_string()),
14240                model: Some("qwen-2.5-7b".to_string()),
14241                api_key: Some("lm-studio".to_string()),
14242                ..Default::default()
14243            },
14244        );
14245        let mut config = Config {
14246            provider: Some("deepseek".to_string()),
14247            providers: Some(crate::config::ProvidersConfig {
14248                custom,
14249                ..Default::default()
14250            }),
14251            ..Default::default()
14252        };
14253
14254        apply_exec_provider_override(&mut config, "lm-studio")
14255            .expect("configured custom provider should be accepted");
14256
14257        assert_eq!(config.provider.as_deref(), Some("lm-studio"));
14258        assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14259    }
14260
14261    #[test]
14262    fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
14263        let mut config = Config {
14264            provider: Some("deepseek".to_string()),
14265            providers: Some(crate::config::ProvidersConfig {
14266                custom: std::collections::HashMap::from([(
14267                    "CUSTOM".to_string(),
14268                    crate::config::ProviderConfig {
14269                        kind: Some("openai-compatible".to_string()),
14270                        base_url: Some("http://127.0.0.1:5678/v1".to_string()),
14271                        model: Some("case-model".to_string()),
14272                        api_key: Some("case-key".to_string()),
14273                        ..Default::default()
14274                    },
14275                )]),
14276                ..Default::default()
14277            }),
14278            ..Default::default()
14279        };
14280
14281        apply_exec_provider_override(&mut config, "CUSTOM")
14282            .expect("exact case-colliding custom provider");
14283        assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
14284        assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14285        assert_eq!(
14286            config.provider_identity_for(crate::config::ApiProvider::Custom),
14287            "CUSTOM"
14288        );
14289        let route = crate::route_runtime::resolve_runtime_route(
14290            &config,
14291            crate::config::ApiProvider::Custom,
14292            Some("case-model"),
14293        )
14294        .expect("resolve exact case-colliding route")
14295        .validate()
14296        .expect("preflight exact case-colliding route");
14297        assert_eq!(route.identity.key, "CUSTOM");
14298        assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
14299    }
14300
14301    #[test]
14302    fn exec_provider_override_rejects_unknown_provider() {
14303        let mut config = Config {
14304            provider: Some("deepseek".to_string()),
14305            ..Default::default()
14306        };
14307
14308        let err = apply_exec_provider_override(&mut config, "lm-studio")
14309            .expect_err("unconfigured custom provider should fail closed");
14310        let message = err.to_string();
14311
14312        assert!(message.contains("Unrecognized --provider"));
14313        assert!(message.contains("[providers.<name>] custom provider"));
14314        assert_eq!(config.provider.as_deref(), Some("deepseek"));
14315    }
14316
14317    #[test]
14318    fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
14319        let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14320
14321        let mut restored = custom_exec_config("custom-b");
14322        let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
14323            .expect("plain resume restores saved route");
14324        assert_eq!(restored.provider.as_deref(), Some("custom-a"));
14325        assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
14326
14327        let mut explicit_provider = custom_exec_config("custom-a");
14328        apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
14329        let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
14330            .expect("explicit provider wins");
14331        assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
14332        assert_eq!(model, "model-b");
14333
14334        let mut explicit_model = custom_exec_config("custom-b");
14335        let model =
14336            resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
14337                .expect("explicit model keeps saved provider");
14338        assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
14339        assert_eq!(model, "override-model");
14340
14341        let mut missing = custom_exec_config("custom-b");
14342        missing
14343            .providers
14344            .as_mut()
14345            .expect("providers")
14346            .custom
14347            .remove("custom-a");
14348        let before = missing.provider.clone();
14349        let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
14350            .expect_err("removed saved provider must fail closed");
14351        assert!(err.to_string().contains("will not fall back"), "{err}");
14352        assert_eq!(missing.provider, before);
14353    }
14354
14355    #[test]
14356    fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
14357        let (started_tx, started_rx) = std::sync::mpsc::channel();
14358        let (tx, rx) = std::sync::mpsc::channel();
14359
14360        let (reader, expected_after_restore) = {
14361            let lock = crate::test_support::lock_test_env();
14362            let expected_after_restore = exec_model_env_override();
14363            let temporary =
14364                crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
14365            let reader = std::thread::spawn(move || {
14366                started_tx.send(()).expect("signal model read start");
14367                tx.send(exec_model_env_override())
14368                    .expect("send resolved model override");
14369            });
14370
14371            started_rx
14372                .recv_timeout(std::time::Duration::from_secs(2))
14373                .expect("reader reached model read");
14374            assert!(
14375                rx.recv_timeout(std::time::Duration::from_millis(50))
14376                    .is_err(),
14377                "a foreign reader observed another test's temporary model override"
14378            );
14379            drop(temporary);
14380            drop(lock);
14381            (reader, expected_after_restore)
14382        };
14383
14384        let observed = rx
14385            .recv_timeout(std::time::Duration::from_secs(2))
14386            .expect("reader resumed after model override was restored");
14387        reader.join().expect("reader thread");
14388        assert_eq!(observed, expected_after_restore);
14389    }
14390
14391    #[tokio::test]
14392    async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
14393        let config = custom_exec_config("custom-a");
14394
14395        let route =
14396            resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
14397                .await
14398                .expect("forced route");
14399        let execution = config_for_cli_route(&config, &route);
14400
14401        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14402        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14403        assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14404    }
14405
14406    #[tokio::test]
14407    async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
14408        let mut config = custom_exec_config("custom-a");
14409        config
14410            .providers
14411            .as_mut()
14412            .expect("providers")
14413            .custom
14414            .get_mut("custom-a")
14415            .expect("custom A")
14416            .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
14417        let model = resolve_exec_model(&config, None);
14418        let force = should_force_configured_exec_route(false, None, None);
14419
14420        assert!(force, "configured/default exec route must be authoritative");
14421        assert!(!should_force_configured_exec_route(
14422            false,
14423            None,
14424            Some(crate::config::ZAI_GLM_5_2_MODEL)
14425        ));
14426        assert!(should_force_configured_exec_route(
14427            false,
14428            Some("custom-a"),
14429            Some(crate::config::ZAI_GLM_5_2_MODEL)
14430        ));
14431        assert!(should_force_configured_exec_route(
14432            true,
14433            None,
14434            Some("override-model")
14435        ));
14436
14437        let route = resolve_cli_exec_route(&config, &model, "audit", force)
14438            .await
14439            .expect("no-flag configured route");
14440        let execution = config_for_cli_route(&config, &route);
14441        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14442        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14443        assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14444    }
14445
14446    #[tokio::test]
14447    async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
14448        let mut config = custom_exec_config("custom-a");
14449        config
14450            .providers
14451            .as_mut()
14452            .expect("providers")
14453            .custom
14454            .get_mut("custom-a")
14455            .expect("custom A")
14456            .model = Some("model-a".to_string());
14457        config.default_text_model = Some("stale-root-deepseek-model".to_string());
14458        let model = resolve_review_model(&config, None);
14459        assert_eq!(model, "model-a");
14460        assert_eq!(
14461            resolve_review_model(&config, Some("explicit-review-model")),
14462            "explicit-review-model"
14463        );
14464
14465        let route = resolve_cli_exec_route(&config, &model, "review diff", true)
14466            .await
14467            .expect("configured review route");
14468        let execution = config_for_cli_route(&config, &route);
14469        let provider = execution.provider_identity_for(route.provider);
14470
14471        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14472        assert_eq!(provider, "custom-a");
14473        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14474        let output = crate::tools::review::ReviewOutput::from_str("{}");
14475        let receipt = crate::tools::review::build_review_receipt(
14476            "working tree",
14477            "diff --git a/a b/a",
14478            provider,
14479            &route.model,
14480            &output,
14481            "{}",
14482            Vec::new(),
14483        );
14484        assert_eq!(receipt.provider, "custom-a");
14485        let serialized = serde_json::to_string(&receipt).expect("review receipt");
14486        assert!(!serialized.contains("127.0.0.1"));
14487        assert!(!serialized.contains("local-test-key"));
14488    }
14489
14490    #[tokio::test]
14491    async fn configured_workflow_default_keeps_named_custom_route() {
14492        let config = custom_exec_config("custom-a");
14493        let model = config.default_model();
14494
14495        let route = resolve_cli_exec_route(
14496            &config,
14497            &model,
14498            "Run a checked-in Workflow through the host runtime",
14499            true,
14500        )
14501        .await
14502        .expect("configured workflow route");
14503        let execution = config_for_cli_route(&config, &route);
14504
14505        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14506        assert_eq!(execution.provider_identity_for(route.provider), "custom-a");
14507        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14508        let client = crate::client::DeepSeekClient::new(&execution).expect("workflow client");
14509        assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
14510    }
14511
14512    #[test]
14513    fn exec_json_receipts_keep_exact_named_custom_provider() {
14514        let config = custom_exec_config("custom-a");
14515        let provider = config.provider_identity_for(crate::config::ApiProvider::Custom);
14516        let one_shot = one_shot_exec_json_receipt(
14517            provider.clone(),
14518            "model-a".to_string(),
14519            "done".to_string(),
14520            Some("end_turn".to_string()),
14521            crate::models::Usage {
14522                input_tokens: 12,
14523                output_tokens: 3,
14524                ..Default::default()
14525            },
14526        );
14527        assert_eq!(one_shot["provider"], "custom-a");
14528        assert_eq!(one_shot["success"], true);
14529
14530        let truncated = one_shot_exec_json_receipt(
14531            provider.clone(),
14532            "model-a".to_string(),
14533            "partial".to_string(),
14534            Some("max_output_tokens".to_string()),
14535            crate::models::Usage {
14536                input_tokens: 20,
14537                output_tokens: 9,
14538                ..Default::default()
14539            },
14540        );
14541        assert_eq!(truncated["success"], false);
14542        assert_eq!(truncated["stop_reason"], "max_output_tokens");
14543        assert_eq!(truncated["usage"]["input_tokens"], 20);
14544        assert_eq!(truncated["usage"]["output_tokens"], 9);
14545        assert!(truncated["error"].as_str().is_some_and(|error| {
14546            error.contains("Model response incomplete") && error.contains("max_output_tokens")
14547        }));
14548
14549        let agent = serde_json::to_value(ExecSummary {
14550            mode: "agent".to_string(),
14551            provider,
14552            model: "model-a".to_string(),
14553            ..ExecSummary::default()
14554        })
14555        .expect("agent exec JSON receipt");
14556        assert_eq!(agent["provider"], "custom-a");
14557        let serialized = serde_json::to_string(&agent).expect("serialize receipt");
14558        assert!(!serialized.contains("127.0.0.1"));
14559        assert!(!serialized.contains("local-test-key"));
14560    }
14561
14562    #[test]
14563    fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
14564        let named = crate::config::ProviderIdentity {
14565            provider: crate::config::ApiProvider::Custom,
14566            key: "lm-studio".to_string(),
14567            exact_id: Some("lm-studio".to_string()),
14568        };
14569        let literal = crate::config::ProviderIdentity {
14570            provider: crate::config::ApiProvider::Custom,
14571            key: "custom".to_string(),
14572            exact_id: Some("custom".to_string()),
14573        };
14574        let root = crate::config::ProviderIdentity {
14575            provider: crate::config::ApiProvider::Custom,
14576            key: "custom".to_string(),
14577            exact_id: None,
14578        };
14579        let built_in = crate::config::ProviderIdentity {
14580            provider: crate::config::ApiProvider::Deepseek,
14581            key: "deepseek".to_string(),
14582            exact_id: Some("deepseek".to_string()),
14583        };
14584
14585        assert_eq!(
14586            exec_stream_provider_route(&named),
14587            ("custom".to_string(), Some("lm-studio".to_string()))
14588        );
14589        assert_eq!(
14590            exec_stream_provider_route(&literal),
14591            ("custom".to_string(), Some("custom".to_string()))
14592        );
14593        assert_eq!(
14594            exec_stream_provider_route(&root),
14595            ("custom".to_string(), None)
14596        );
14597        assert_eq!(
14598            exec_stream_provider_route(&built_in),
14599            ("deepseek".to_string(), None)
14600        );
14601    }
14602
14603    #[test]
14604    fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
14605        let saved_a = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14606        let mut config = custom_exec_config("custom-a");
14607        apply_exec_provider_override(&mut config, "custom-b").expect("custom B");
14608        let model = resolve_exec_resume_route(&mut config, &saved_a, true, None)
14609            .expect("explicit provider route");
14610        let mut persisted = saved_a;
14611        stamp_exec_session_metadata(
14612            &mut persisted,
14613            &model,
14614            crate::config::ApiProvider::Custom.as_str(),
14615            Some("custom-b"),
14616            Path::new("/tmp/exec-resume"),
14617        );
14618
14619        let mut next_config = custom_exec_config("custom-a");
14620        let resumed_model = resolve_exec_resume_route(&mut next_config, &persisted, false, None)
14621            .expect("next plain resume");
14622
14623        assert_eq!(persisted.metadata.model_provider, "custom");
14624        assert_eq!(
14625            persisted.metadata.model_provider_id.as_deref(),
14626            Some("custom-b")
14627        );
14628        assert_eq!(persisted.metadata.model, "model-b");
14629        assert_eq!(next_config.provider.as_deref(), Some("custom-b"));
14630        assert_eq!(resumed_model, "model-b");
14631    }
14632
14633    #[test]
14634    fn exec_persistence_omits_id_for_legacy_root_custom_route() {
14635        let mut saved = session_manager::create_saved_session_with_mode(
14636            &[],
14637            "legacy-root-model",
14638            Path::new("/tmp/exec-root"),
14639            0,
14640            None,
14641            Some("exec"),
14642        );
14643        stamp_exec_session_metadata(
14644            &mut saved,
14645            "legacy-root-model",
14646            crate::config::ApiProvider::Custom.as_str(),
14647            None,
14648            Path::new("/tmp/exec-root"),
14649        );
14650
14651        assert_eq!(saved.metadata.model_provider, "custom");
14652        assert_eq!(saved.metadata.model_provider_id, None);
14653        assert!(
14654            !serde_json::to_string(&saved)
14655                .expect("serialize exec session")
14656                .contains("model_provider_id")
14657        );
14658    }
14659
14660    #[test]
14661    fn exec_parses_reasoning_effort_flag_alongside_provider() {
14662        let cli = parse_cli(&[
14663            "codewhale",
14664            "exec",
14665            "--provider",
14666            "openrouter",
14667            "--model",
14668            "glm-5.2",
14669            "--reasoning-effort",
14670            "max",
14671            "audit",
14672        ]);
14673        let Some(Commands::Exec(args)) = cli.command else {
14674            panic!("expected exec command");
14675        };
14676
14677        assert_eq!(args.provider.as_deref(), Some("openrouter"));
14678        assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14679        assert_eq!(args.reasoning_effort.as_deref(), Some("max"));
14680        assert_eq!(args.prompt, vec!["audit"]);
14681    }
14682
14683    #[test]
14684    fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
14685        assert_eq!(
14686            normalize_cli_reasoning_effort("xhigh").unwrap().as_deref(),
14687            Some("max")
14688        );
14689        assert_eq!(normalize_cli_reasoning_effort("default").unwrap(), None);
14690        assert!(normalize_cli_reasoning_effort("expensive").is_err());
14691    }
14692
14693    #[test]
14694    fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() {
14695        let config = Config {
14696            provider: Some("moonshot".to_string()),
14697            providers: Some(crate::config::ProvidersConfig {
14698                moonshot: crate::config::ProviderConfig {
14699                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
14700                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
14701                    ..Default::default()
14702                },
14703                ..Default::default()
14704            }),
14705            ..Default::default()
14706        };
14707
14708        for (prompt, expected) in [
14709            ("lookup the public docs", "low"),
14710            ("debug this error", "max"),
14711            ("review this ordinary change", "high"),
14712        ] {
14713            assert_eq!(
14714                cli_reasoning_effort_value_for_prompt(
14715                    &config,
14716                    crate::config::KIMI_CODE_K3_MODEL,
14717                    crate::tui::app::ReasoningEffort::Auto,
14718                    prompt,
14719                )
14720                .as_deref(),
14721                Some(expected),
14722                "prompt selector must resolve Auto for `{prompt}`"
14723            );
14724        }
14725
14726        assert_eq!(
14727            cli_reasoning_effort_value_for_prompt(
14728                &config,
14729                crate::config::KIMI_CODE_K3_MODEL,
14730                crate::tui::app::ReasoningEffort::Off,
14731                "debug must not override an explicit effort",
14732            )
14733            .as_deref(),
14734            Some("low"),
14735            "membership K3 still applies its exact-route always-thinking floor"
14736        );
14737    }
14738
14739    #[test]
14740    fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
14741        use crate::tui::app::ReasoningEffort;
14742
14743        let fixed_model_auto_reasoning = CliAutoRoute {
14744            provider: crate::config::ApiProvider::Deepseek,
14745            model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
14746            reasoning_effort: Some(ReasoningEffort::Auto),
14747            auto_controls_reasoning: true,
14748            auto_model: false,
14749        };
14750        let auto_model_fixed_reasoning = CliAutoRoute {
14751            provider: crate::config::ApiProvider::OpenaiCodex,
14752            model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
14753            reasoning_effort: Some(ReasoningEffort::High),
14754            auto_controls_reasoning: false,
14755            auto_model: true,
14756        };
14757
14758        assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
14759        assert!(!fixed_model_auto_reasoning.auto_model);
14760        assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
14761        assert!(auto_model_fixed_reasoning.auto_model);
14762    }
14763
14764    #[test]
14765    fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
14766        let mut config = Config {
14767            reasoning_effort: Some("max".to_string()),
14768            reasoning_effort_inferred_from_legacy_alias: true,
14769            ..Default::default()
14770        };
14771        let settings = crate::settings::Settings {
14772            reasoning_effort: Some("low".to_string()),
14773            ..Default::default()
14774        };
14775
14776        apply_saved_reasoning_preference(&mut config, &settings);
14777
14778        assert_eq!(config.reasoning_effort(), Some("low"));
14779        assert!(config.reasoning_effort_is_explicit());
14780    }
14781
14782    /// `run_exec_agent` must hand the engine a concrete tier, never the literal
14783    /// `"auto"` sentinel, for a fixed-model Auto launch.
14784    #[test]
14785    fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
14786        let config = Config {
14787            provider: Some("zai".to_string()),
14788            ..Default::default()
14789        };
14790
14791        let resolved = cli_reasoning_effort_value_for_prompt(
14792            &config,
14793            crate::config::ZAI_GLM_5_2_MODEL,
14794            crate::tui::app::ReasoningEffort::Auto,
14795            "debug this failing integration test",
14796        )
14797        .expect("Auto must resolve to a concrete tier");
14798
14799        assert_ne!(
14800            resolved, "auto",
14801            "the literal auto sentinel must never reach a provider"
14802        );
14803        assert!(
14804            matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
14805            "unexpected resolved tier: {resolved}"
14806        );
14807    }
14808
14809    #[test]
14810    fn exec_accepts_resume_session_flags_for_harnesses() {
14811        let cli = parse_cli(&[
14812            "codewhale",
14813            "exec",
14814            "--resume",
14815            "abc123",
14816            "--output-format",
14817            "stream-json",
14818            "follow up",
14819        ]);
14820        let Some(Commands::Exec(args)) = cli.command else {
14821            panic!("expected exec command");
14822        };
14823
14824        assert_eq!(args.resume.as_deref(), Some("abc123"));
14825        assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
14826        assert_eq!(args.prompt, vec!["follow up"]);
14827    }
14828
14829    #[test]
14830    fn exec_accepts_session_id_alias() {
14831        let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]);
14832        let Some(Commands::Exec(args)) = cli.command else {
14833            panic!("expected exec command");
14834        };
14835
14836        assert_eq!(args.session_id.as_deref(), Some("abc123"));
14837        assert_eq!(args.output_format, ExecOutputFormat::Text);
14838    }
14839
14840    #[test]
14841    fn exec_parses_tool_gate_and_hardening_flags() {
14842        let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#;
14843        let cli = parse_cli(&[
14844            "codewhale",
14845            "exec",
14846            "--allowed-tools",
14847            "File,Git",
14848            "--disallowed-tools",
14849            "Bash",
14850            "--max-turns",
14851            "7",
14852            "--append-system-prompt",
14853            "extra rules",
14854            "--tool-authority-json",
14855            envelope,
14856            "do the thing",
14857        ]);
14858        let Some(Commands::Exec(args)) = cli.command else {
14859            panic!("expected exec command");
14860        };
14861
14862        assert_eq!(
14863            args.allowed_tools.as_deref(),
14864            Some(&["File".to_string(), "Git".to_string()][..])
14865        );
14866        assert_eq!(
14867            args.disallowed_tools.as_deref(),
14868            Some(&["Bash".to_string()][..])
14869        );
14870        assert_eq!(args.max_turns, Some(7));
14871        assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
14872        assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
14873        assert_eq!(args.prompt, vec!["do the thing"]);
14874    }
14875
14876    #[test]
14877    fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
14878        assert!(validate_exec_tool_authority_resume(None, true).is_ok());
14879        assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
14880        let error = validate_exec_tool_authority_resume(Some("{}"), true)
14881            .expect_err("authority must remain bound to its fresh Fleet launch")
14882            .to_string();
14883        assert!(error.contains("cannot be combined with exec --resume"));
14884    }
14885
14886    #[test]
14887    fn exec_auto_does_not_authorize_sandbox_elevation() {
14888        let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
14889        let Some(Commands::Exec(args)) = cli.command else {
14890            panic!("expected exec command");
14891        };
14892
14893        assert!(!exec_sandbox_elevation_authorized(
14894            args.allow_sandbox_elevation,
14895            args.sandbox.as_deref()
14896        ));
14897    }
14898
14899    #[test]
14900    fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
14901        let danger = parse_cli(&[
14902            "codewhale",
14903            "exec",
14904            "--auto",
14905            "--sandbox",
14906            "danger-full-access",
14907            "run it",
14908        ]);
14909        let Some(Commands::Exec(args)) = danger.command else {
14910            panic!("expected exec command");
14911        };
14912        assert!(exec_sandbox_elevation_authorized(
14913            args.allow_sandbox_elevation,
14914            args.sandbox.as_deref()
14915        ));
14916
14917        let flag = parse_cli(&[
14918            "codewhale",
14919            "exec",
14920            "--auto",
14921            "--allow-sandbox-elevation",
14922            "run it",
14923        ]);
14924        let Some(Commands::Exec(args)) = flag.command else {
14925            panic!("expected exec command");
14926        };
14927        assert!(exec_sandbox_elevation_authorized(
14928            args.allow_sandbox_elevation,
14929            args.sandbox.as_deref()
14930        ));
14931    }
14932
14933    #[test]
14934    fn exec_sandbox_denial_stream_event_is_typed() {
14935        let event = ExecStreamEvent::SandboxDenied {
14936            tool_id: "call_1".to_string(),
14937            tool_name: "exec_shell".to_string(),
14938            reason: "write blocked".to_string(),
14939            outcome: "approval_required".to_string(),
14940        };
14941        let value: serde_json::Value =
14942            serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
14943                .expect("valid json");
14944        assert_eq!(value["type"], "sandbox_denied");
14945        assert_eq!(value["outcome"], "approval_required");
14946    }
14947
14948    #[test]
14949    fn exec_help_separates_agent_mode_from_sandbox_elevation() {
14950        let mut cli = Cli::command();
14951        let help = cli
14952            .find_subcommand_mut("exec")
14953            .expect("exec command")
14954            .render_help()
14955            .to_string();
14956        assert!(help.contains("--auto"));
14957        assert!(help.contains("--sandbox"));
14958        assert!(help.contains("--allow-sandbox-elevation"));
14959        assert!(help.contains("does not change the"));
14960        assert!(help.contains("explicitly authorize sandbox elevation"));
14961    }
14962
14963    #[test]
14964    fn exec_shell_only_tool_surface_env_sets_shell_allowlist() {
14965        let _env_lock = crate::test_support::lock_test_env();
14966        let _surface =
14967            crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, " shell-only ");
14968
14969        let allowed_tools = resolve_exec_allowed_tools(None, exec_tool_surface_from_env())
14970            .expect("shell-only surface should set an allowlist");
14971
14972        assert_eq!(allowed_tools, vec!["bash".to_string()]);
14973    }
14974
14975    #[test]
14976    fn exec_explicit_allowed_tools_override_shell_only_env() {
14977        let _env_lock = crate::test_support::lock_test_env();
14978        let _surface =
14979            crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "shell-only");
14980        let explicit = vec![" File ".to_string(), "GIT".to_string()];
14981
14982        let allowed_tools =
14983            resolve_exec_allowed_tools(Some(&explicit), exec_tool_surface_from_env())
14984                .expect("explicit allowlist should be preserved");
14985
14986        assert_eq!(allowed_tools, vec!["file".to_string(), "git".to_string()]);
14987    }
14988
14989    #[test]
14990    fn exec_full_tool_surface_env_leaves_allowlist_unset() {
14991        let _env_lock = crate::test_support::lock_test_env();
14992        let _surface = crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "full");
14993
14994        assert_eq!(
14995            resolve_exec_allowed_tools(None, exec_tool_surface_from_env()),
14996            None
14997        );
14998    }
14999
15000    #[test]
15001    fn exec_unknown_tool_surface_env_warns_without_allowlist() {
15002        assert!(should_warn_unknown_exec_tool_surface("shell_onyl"));
15003        assert!(!should_warn_unknown_exec_tool_surface("shell-only"));
15004        assert!(!should_warn_unknown_exec_tool_surface("native-tools"));
15005        assert!(!should_warn_unknown_exec_tool_surface("full"));
15006        assert!(!should_warn_unknown_exec_tool_surface(" "));
15007        assert_eq!(parse_exec_tool_surface("shell_onyl"), None);
15008    }
15009
15010    #[test]
15011    fn exec_rejects_zero_max_turns() {
15012        let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
15013            .expect_err("max-turns must be >= 1");
15014        assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
15015    }
15016
15017    #[test]
15018    fn exec_omits_the_headless_turn_cap_by_default() {
15019        let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
15020        let Some(Commands::Exec(args)) = cli.command else {
15021            panic!("expected exec command");
15022        };
15023
15024        assert_eq!(args.max_turns, None);
15025        assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
15026        assert_eq!(exec_max_steps(Some(7)), 7);
15027    }
15028
15029    #[test]
15030    fn exec_accepts_continue_for_latest_workspace_session() {
15031        let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);
15032        let Some(Commands::Exec(args)) = cli.command else {
15033            panic!("expected exec command");
15034        };
15035
15036        assert!(args.continue_session);
15037    }
15038
15039    #[test]
15040    fn sessions_footer_points_to_resume_subcommand() {
15041        let cli = parse_cli(&["codewhale", "resume", "abc123"]);
15042        let Some(Commands::Resume { session_id, last }) = cli.command else {
15043            panic!("expected resume command");
15044        };
15045
15046        assert_eq!(session_id.as_deref(), Some("abc123"));
15047        assert!(!last);
15048        assert_eq!(sessions_resume_command(), "codewhale resume");
15049        assert!(!sessions_resume_command().contains("--resume"));
15050    }
15051
15052    #[test]
15053    fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() {
15054        use std::cell::Cell;
15055
15056        #[derive(Clone, Copy)]
15057        enum Expected {
15058            Plain,
15059            Resume,
15060            Fork,
15061            Exec,
15062            Serve,
15063        }
15064
15065        let cases: &[(&[&str], Expected)] = &[
15066            (&["codewhale"], Expected::Plain),
15067            (&["codewhale", "resume", "--last"], Expected::Resume),
15068            (&["codewhale", "fork", "--last"], Expected::Fork),
15069            (&["codewhale", "exec", "probe"], Expected::Exec),
15070            (&["codewhale", "serve", "--mcp"], Expected::Serve),
15071        ];
15072
15073        for (args, expected) in cases {
15074            let phase = Cell::new(0);
15075            let (_cli, command) = prepare_cli_startup(
15076                parse_cli(args),
15077                || {
15078                    assert_eq!(phase.get(), 0, "plugin init order for {args:?}");
15079                    phase.set(1);
15080                },
15081                || {
15082                    assert_eq!(phase.get(), 1, "dotenv load order for {args:?}");
15083                    phase.set(2);
15084                },
15085            );
15086
15087            assert_eq!(phase.get(), 2, "startup phases for {args:?}");
15088            let correct_variant = matches!(
15089                (expected, command.as_ref()),
15090                (Expected::Plain, None)
15091                    | (Expected::Resume, Some(Commands::Resume { .. }))
15092                    | (Expected::Fork, Some(Commands::Fork { .. }))
15093                    | (Expected::Exec, Some(Commands::Exec(_)))
15094                    | (Expected::Serve, Some(Commands::Serve(_)))
15095            );
15096            assert!(correct_variant, "unexpected command for {args:?}");
15097        }
15098    }
15099
15100    #[test]
15101    fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() {
15102        let _lock = crate::test_support::lock_test_env();
15103        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15104        let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key");
15105        let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
15106        let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
15107        let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL");
15108        let tmp = tempfile::TempDir::new().expect("temp workspace");
15109        let dotenv = tmp.path().join(".env");
15110        std::fs::write(
15111            &dotenv,
15112            "DEEPSEEK_API_KEY=workspace-key\n\
15113             NVIDIA_API_KEY=repo-must-not-override-shell\n\
15114             CODEWHALE_HOME=./attacker-home\n\
15115             CODEWHALE_CONFIG_PATH=./attacker.toml\n\
15116             DEEPSEEK_ALLOW_SHELL=true\n",
15117        )
15118        .expect("write dotenv");
15119
15120        let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load");
15121
15122        assert_eq!(
15123            std::env::var("DEEPSEEK_API_KEY").as_deref(),
15124            Ok("workspace-key")
15125        );
15126        assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key"));
15127        assert!(std::env::var_os("CODEWHALE_HOME").is_none());
15128        assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none());
15129        assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none());
15130        assert_eq!(
15131            report.loaded,
15132            BTreeSet::from(["DEEPSEEK_API_KEY".to_string()])
15133        );
15134        assert_eq!(
15135            report.ignored,
15136            BTreeSet::from([
15137                "CODEWHALE_CONFIG_PATH".to_string(),
15138                "CODEWHALE_HOME".to_string(),
15139                "DEEPSEEK_ALLOW_SHELL".to_string(),
15140            ])
15141        );
15142    }
15143
15144    #[test]
15145    fn workspace_dotenv_rejects_ambient_variable_substitution() {
15146        let _lock = crate::test_support::lock_test_env();
15147        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15148        let _ambient = crate::test_support::EnvVarGuard::set(
15149            "CODEWHALE_JS_SECRET_LEAK_TEST",
15150            "ambient-secret-must-not-expand",
15151        );
15152        let tmp = tempfile::TempDir::new().expect("temp workspace");
15153        let dotenv = tmp.path().join(".env");
15154        std::fs::write(
15155            &dotenv,
15156            "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n",
15157        )
15158        .expect("write dotenv");
15159
15160        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15161            .expect_err("expansion must fail closed")
15162            .to_string();
15163
15164        assert!(error.contains("variable expansion"));
15165        assert!(!error.contains("ambient-secret-must-not-expand"));
15166        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15167    }
15168
15169    #[test]
15170    fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() {
15171        let _lock = crate::test_support::lock_test_env();
15172        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15173        let _ambient = crate::test_support::EnvVarGuard::set(
15174            "CODEWHALE_JS_SECRET_LEAK_TEST",
15175            "ambient-secret-must-not-expand",
15176        );
15177        let tmp = tempfile::TempDir::new().expect("temp workspace");
15178        let dotenv = tmp.path().join(".env");
15179        std::fs::write(
15180            &dotenv,
15181            "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n",
15182        )
15183        .expect("write dotenv");
15184
15185        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15186            .expect_err("multiline expansion must fail closed")
15187            .to_string();
15188
15189        assert!(error.contains("variable expansion"));
15190        assert!(!error.contains("ambient-secret-must-not-expand"));
15191        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15192    }
15193
15194    #[test]
15195    fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() {
15196        let _lock = crate::test_support::lock_test_env();
15197        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15198        let _ambient = crate::test_support::EnvVarGuard::set(
15199            "CODEWHALE_JS_SECRET_LEAK_TEST",
15200            "ambient-secret-must-not-expand",
15201        );
15202        let tmp = tempfile::TempDir::new().expect("temp workspace");
15203        let dotenv = tmp.path().join(".env");
15204        std::fs::write(
15205            &dotenv,
15206            "# unmatched quote in ignored comment: '\n\
15207             DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n",
15208        )
15209        .expect("write dotenv");
15210
15211        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15212            .expect_err("comment quote must not hide expansion")
15213            .to_string();
15214
15215        assert!(error.contains("variable expansion"));
15216        assert!(!error.contains("ambient-secret-must-not-expand"));
15217        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15218    }
15219
15220    #[test]
15221    fn workspace_dotenv_allows_single_quoted_literal_dollar() {
15222        let _lock = crate::test_support::lock_test_env();
15223        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15224        let tmp = tempfile::TempDir::new().expect("temp workspace");
15225        let dotenv = tmp.path().join(".env");
15226        std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv");
15227
15228        load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load");
15229
15230        assert_eq!(
15231            std::env::var("DEEPSEEK_API_KEY").as_deref(),
15232            Ok("$literal-value")
15233        );
15234    }
15235
15236    #[test]
15237    fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() {
15238        let _lock = crate::test_support::lock_test_env();
15239        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15240        let tmp = tempfile::TempDir::new().expect("temp workspace");
15241        let dotenv = tmp.path().join(".env");
15242        std::fs::write(
15243            &dotenv,
15244            "DEEPSEEK_API_KEY=must-not-survive\nBROKEN=\"unterminated\n",
15245        )
15246        .expect("write dotenv");
15247
15248        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15249            .expect_err("parse failure must be transactional")
15250            .to_string();
15251
15252        assert!(error.contains("could not be parsed safely"), "{error}");
15253        assert!(!error.contains("must-not-survive"));
15254        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15255    }
15256
15257    #[test]
15258    fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() {
15259        for provider in codewhale_config::provider::providers_sorted_for_display() {
15260            for key in provider.env_vars() {
15261                assert!(
15262                    is_workspace_dotenv_credential_key(key),
15263                    "provider credential {key} must remain supported"
15264                );
15265            }
15266        }
15267        for key in [
15268            "CODEWHALE_HOME",
15269            "CODEWHALE_CONFIG_PATH",
15270            "DEEPSEEK_CONFIG_PATH",
15271            "DEEPSEEK_PROFILE",
15272            "DEEPSEEK_MANAGED_CONFIG_PATH",
15273            "DEEPSEEK_REQUIREMENTS_PATH",
15274            "DEEPSEEK_PROVIDER",
15275            "DEEPSEEK_BASE_URL",
15276            "DEEPSEEK_MODEL",
15277            "DEEPSEEK_APPROVAL_POLICY",
15278            "DEEPSEEK_SANDBOX_MODE",
15279            "DEEPSEEK_ALLOW_SHELL",
15280            "DEEPSEEK_YOLO",
15281            "DEEPSEEK_MCP_CONFIG",
15282            "CODEWHALE_RUNTIME_TOKEN",
15283            "PATH",
15284            "NODE_OPTIONS",
15285            "PYTHONPATH",
15286            "LD_PRELOAD",
15287            "DYLD_INSERT_LIBRARIES",
15288        ] {
15289            assert!(
15290                !is_workspace_dotenv_credential_key(key),
15291                "control-plane variable {key} must not load from a workspace"
15292            );
15293        }
15294    }
15295
15296    #[cfg(unix)]
15297    #[test]
15298    fn workspace_dotenv_does_not_follow_symbolic_links() {
15299        use std::os::unix::fs::symlink;
15300
15301        let tmp = tempfile::TempDir::new().expect("temp workspace");
15302        let external = tmp.path().join("external-credentials");
15303        let dotenv = tmp.path().join(".env");
15304        std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15305            .expect("write external fixture");
15306        symlink(&external, &dotenv).expect("create dotenv symlink");
15307
15308        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15309            .expect_err("symlink must fail closed")
15310            .to_string();
15311
15312        assert!(error.contains("securely open"), "{error}");
15313        assert!(!error.contains("external-secret"));
15314    }
15315
15316    #[cfg(unix)]
15317    #[test]
15318    fn workspace_dotenv_rejects_hard_links_to_external_files() {
15319        let tmp = tempfile::TempDir::new().expect("temp workspace");
15320        let external = tmp.path().join("external-credentials");
15321        let dotenv = tmp.path().join(".env");
15322        std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15323            .expect("write external fixture");
15324        std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link");
15325
15326        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15327            .expect_err("hard link must fail closed")
15328            .to_string();
15329
15330        assert!(error.contains("multiple filesystem links"), "{error}");
15331        assert!(!error.contains("external-secret"));
15332    }
15333
15334    #[cfg(unix)]
15335    #[test]
15336    fn workspace_dotenv_rejects_fifo_without_blocking_startup() {
15337        use std::ffi::CString;
15338        use std::os::unix::ffi::OsStrExt;
15339        use std::sync::mpsc;
15340        use std::time::Duration;
15341
15342        let tmp = tempfile::TempDir::new().expect("temp workspace");
15343        let dotenv = tmp.path().join(".env");
15344        let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path");
15345        // SAFETY: `c_path` is a live, NUL-terminated path and the requested
15346        // mode grants access only to the current user.
15347        let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) };
15348        assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
15349
15350        let (tx, rx) = mpsc::channel();
15351        let worker_path = dotenv.clone();
15352        let worker = std::thread::spawn(move || {
15353            let result = load_workspace_dotenv_credentials_from_path(&worker_path)
15354                .map(|_| "unexpected success".to_string())
15355                .unwrap_or_else(|error| error.to_string());
15356            tx.send(result).expect("send loader result");
15357        });
15358
15359        let error = match rx.recv_timeout(Duration::from_secs(1)) {
15360            Ok(error) => error,
15361            Err(timeout) => {
15362                // Release a regressed blocking reader so the test can fail
15363                // promptly instead of leaving a stuck process behind.
15364                let _writer = std::fs::OpenOptions::new()
15365                    .write(true)
15366                    .open(&dotenv)
15367                    .expect("open fifo writer to release blocked reader");
15368                let _ = rx.recv_timeout(Duration::from_secs(1));
15369                worker.join().expect("join released loader");
15370                panic!("workspace .env FIFO blocked startup: {timeout}");
15371            }
15372        };
15373        worker.join().expect("join loader");
15374
15375        assert!(error.contains("not a regular file"), "{error}");
15376    }
15377
15378    #[test]
15379    fn exec_json_conflicts_with_stream_json_output() {
15380        let err = Cli::try_parse_from([
15381            "codewhale",
15382            "exec",
15383            "--json",
15384            "--output-format",
15385            "stream-json",
15386            "hello",
15387        ])
15388        .expect_err("json summary and stream-json must not mix");
15389
15390        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
15391    }
15392
15393    #[test]
15394    fn exec_stream_turn_usage_event_serializes_reported_fields() {
15395        let event = ExecStreamEvent::TurnUsage {
15396            turn: 2,
15397            input_tokens: 1200,
15398            output_tokens: 180,
15399            reasoning_tokens: Some(90),
15400            prompt_cache_hit_tokens: Some(900),
15401            prompt_cache_miss_tokens: Some(300),
15402            prompt_cache_write_tokens: Some(0),
15403            reasoning_replay_tokens: Some(40),
15404            duration_ms: 1834,
15405        };
15406
15407        let value = exec_stream_value(&event).expect("serializes");
15408        let json = serde_json::to_string(&value).expect("serializes");
15409        assert!(!json.contains('\n'));
15410        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15411        assert_eq!(parsed["type"], "turn_usage");
15412        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15413        assert_eq!(parsed["schema_version"], 1);
15414        assert_eq!(parsed["turn"], 2);
15415        assert_eq!(parsed["input_tokens"], 1200);
15416        assert_eq!(parsed["output_tokens"], 180);
15417        assert_eq!(parsed["reasoning_tokens"], 90);
15418        assert_eq!(parsed["prompt_cache_hit_tokens"], 900);
15419        assert_eq!(parsed["prompt_cache_miss_tokens"], 300);
15420        assert_eq!(parsed["prompt_cache_write_tokens"], 0);
15421        assert_eq!(parsed["reasoning_replay_tokens"], 40);
15422        assert_eq!(parsed["duration_ms"], 1834);
15423    }
15424
15425    #[test]
15426    fn exec_stream_turn_usage_event_omits_unreported_fields() {
15427        // Honest absence: optional token fields the provider did not report
15428        // are dropped from the object entirely — never emitted as null and
15429        // never backfilled with fabricated zeros.
15430        let event = ExecStreamEvent::TurnUsage {
15431            turn: 1,
15432            input_tokens: 11,
15433            output_tokens: 3,
15434            reasoning_tokens: None,
15435            prompt_cache_hit_tokens: None,
15436            prompt_cache_miss_tokens: None,
15437            prompt_cache_write_tokens: None,
15438            reasoning_replay_tokens: None,
15439            duration_ms: 250,
15440        };
15441
15442        let value = exec_stream_value(&event).expect("serializes");
15443        let parsed = value;
15444        assert_eq!(parsed["type"], "turn_usage");
15445        assert_eq!(parsed["input_tokens"], 11);
15446        assert_eq!(parsed["output_tokens"], 3);
15447        assert_eq!(parsed["duration_ms"], 250);
15448        let object = parsed.as_object().expect("event object");
15449        for absent in [
15450            "reasoning_tokens",
15451            "prompt_cache_hit_tokens",
15452            "prompt_cache_miss_tokens",
15453            "prompt_cache_write_tokens",
15454            "reasoning_replay_tokens",
15455        ] {
15456            assert!(!object.contains_key(absent), "{absent} leaked: {parsed}");
15457        }
15458    }
15459
15460    #[test]
15461    fn exec_stream_pre_existing_event_type_tags_are_unchanged() {
15462        // Contract guard for existing stream consumers (bench harness, fleet
15463        // ledger): the pre-turn_usage event vocabulary keeps its exact tags.
15464        let cases: Vec<(ExecStreamEvent, &str)> = vec![
15465            (
15466                ExecStreamEvent::Content {
15467                    content: "hi".to_string(),
15468                },
15469                "content",
15470            ),
15471            (
15472                ExecStreamEvent::ToolUse {
15473                    name: "read_file".to_string(),
15474                    id: "call_1".to_string(),
15475                    input: serde_json::json!({}),
15476                    started_at: "2026-08-03T00:00:00Z".to_string(),
15477                },
15478                "tool_use",
15479            ),
15480            (
15481                ExecStreamEvent::ToolResult {
15482                    id: "call_1".to_string(),
15483                    name: "read_file".to_string(),
15484                    output: "ok".to_string(),
15485                    status: "success".to_string(),
15486                    started_at: "2026-08-03T00:00:00Z".to_string(),
15487                    completed_at: "2026-08-03T00:00:01Z".to_string(),
15488                    duration_ms: 1,
15489                    side_effect_status: "unknown".to_string(),
15490                    error_category: None,
15491                    truncated: None,
15492                    artifact: None,
15493                    result_metadata: None,
15494                },
15495                "tool_result",
15496            ),
15497            (
15498                ExecStreamEvent::SandboxDenied {
15499                    tool_id: "call_1".to_string(),
15500                    tool_name: "exec_shell".to_string(),
15501                    reason: "denied".to_string(),
15502                    outcome: "approval_required".to_string(),
15503                },
15504                "sandbox_denied",
15505            ),
15506            (
15507                ExecStreamEvent::WorkflowEvent {
15508                    run_id: "workflow_1".to_string(),
15509                    event: serde_json::json!({"type": "task_completed"}),
15510                },
15511                "workflow_event",
15512            ),
15513            (
15514                ExecStreamEvent::SessionCapture {
15515                    content: "x".to_string(),
15516                },
15517                "session_capture",
15518            ),
15519            (
15520                ExecStreamEvent::Error {
15521                    error: "boom".to_string(),
15522                },
15523                "error",
15524            ),
15525            (ExecStreamEvent::Done, "done"),
15526        ];
15527
15528        for (event, expected_type) in cases {
15529            let value = exec_stream_value(&event).expect("serializes");
15530            assert_eq!(value["type"], expected_type, "event tag drifted");
15531            assert_eq!(value["schema"], "codewhale.exec-stream");
15532            assert_eq!(value["schema_version"], 1);
15533        }
15534    }
15535
15536    #[test]
15537    fn exec_stream_events_are_json_lines() {
15538        let event = ExecStreamEvent::ToolResult {
15539            id: "call_1".to_string(),
15540            name: "read_file".to_string(),
15541            output: "line 1\nline 2".to_string(),
15542            status: "success".to_string(),
15543            started_at: "2026-07-13T00:00:00Z".to_string(),
15544            completed_at: "2026-07-13T00:00:01Z".to_string(),
15545            duration_ms: 1000,
15546            side_effect_status: "not_started".to_string(),
15547            error_category: None,
15548            truncated: Some(false),
15549            artifact: None,
15550            result_metadata: None,
15551        };
15552
15553        let value = exec_stream_value(&event).expect("serializes");
15554        let json = serde_json::to_string(&value).expect("serializes");
15555        assert!(!json.contains('\n'));
15556        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15557        assert_eq!(parsed["type"], "tool_result");
15558        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15559        assert_eq!(parsed["schema_version"], 1);
15560        assert_eq!(parsed["duration_ms"], 1000);
15561        assert_eq!(parsed["side_effect_status"], "not_started");
15562    }
15563
15564    #[test]
15565    fn workflow_receipt_stream_event_is_one_json_line() {
15566        let event = ExecStreamEvent::WorkflowEvent {
15567            run_id: "workflow_1234".to_string(),
15568            event: serde_json::json!({
15569                "type": "handoff_promoted",
15570                "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15571                "gate_id": "review-gate",
15572                "kind": "review_report",
15573                "from_role": "reviewer",
15574                "to_role": "verifier",
15575                "producer_task_id": "agent_1"
15576            }),
15577        };
15578
15579        let value = exec_stream_value(&event).expect("serializes");
15580        let json = serde_json::to_string(&value).expect("serializes");
15581        assert!(!json.contains('\n'));
15582        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15583        assert_eq!(parsed["type"], "workflow_event");
15584        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15585        assert_eq!(parsed["schema_version"], 1);
15586        assert_eq!(parsed["run_id"], "workflow_1234");
15587        assert_eq!(parsed["event"]["type"], "handoff_promoted");
15588        assert_eq!(
15589            parsed["event"]["artifact_id"],
15590            "workflow_1234:agent_1:review-gate:review_report"
15591        );
15592        assert_eq!(parsed["event"]["gate_id"], "review-gate");
15593        assert_eq!(parsed["event"]["kind"], "review_report");
15594        assert_eq!(parsed["event"]["from_role"], "reviewer");
15595        assert_eq!(parsed["event"]["to_role"], "verifier");
15596        assert_eq!(parsed["event"]["producer_task_id"], "agent_1");
15597        assert!(parsed["event"].get("payload").is_none(), "{parsed}");
15598
15599        let consumed = ExecStreamEvent::WorkflowEvent {
15600            run_id: "workflow_1234".to_string(),
15601            event: serde_json::json!({
15602                "type": "handoff_consumed",
15603                "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15604                "kind": "review_report",
15605                "from_role": "reviewer",
15606                "to_role": "verifier",
15607                "consumer_task_id": "agent_2"
15608            }),
15609        };
15610        let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt");
15611        assert_eq!(consumed["type"], "workflow_event");
15612        assert_eq!(consumed["schema"], "codewhale.exec-stream");
15613        assert_eq!(consumed["schema_version"], 1);
15614        assert_eq!(consumed["event"]["type"], "handoff_consumed");
15615        assert_eq!(
15616            consumed["event"]["artifact_id"],
15617            "workflow_1234:agent_1:review-gate:review_report"
15618        );
15619        assert_eq!(consumed["event"]["consumer_task_id"], "agent_2");
15620        assert!(consumed["event"].get("payload").is_none(), "{consumed}");
15621    }
15622
15623    #[test]
15624    fn exec_stream_metadata_redacts_resume_breadcrumbs() {
15625        let raw_session_id = "abc123fullsecret";
15626        let event = ExecStreamEvent::Metadata {
15627            meta: Box::new(ExecStreamMeta {
15628                receipt_kind: "terminal",
15629                provider: "deepseek".to_string(),
15630                provider_id: None,
15631                model: "deepseek-v4-flash".to_string(),
15632                route_source: "explicit_or_configured".to_string(),
15633                input_tokens: Some(123),
15634                output_tokens: Some(45),
15635                prompt_cache_hit_tokens: Some(10),
15636                prompt_cache_miss_tokens: None,
15637                prompt_cache_write_tokens: None,
15638                reasoning_tokens: Some(3),
15639                duration_ms: 2500,
15640                retry_count: None,
15641                approval_posture: "ask".to_string(),
15642                sandbox_posture: "configured_default".to_string(),
15643                binary_sha256: Some("sha256:binary".to_string()),
15644                config_sha256: None,
15645                prompt_sha256: "sha256:prompt".to_string(),
15646                tool_catalog_sha256: Some("sha256:tools".to_string()),
15647                input_analysis: ExecStreamInputAnalysis::default(),
15648                visible_final_answer_chars: 17,
15649                session_id: exec_stream_session_ref(raw_session_id),
15650                resume_command: exec_stream_resume_hint(raw_session_id),
15651                workspace: "/tmp/work".to_string(),
15652                message_count: 4,
15653                status: Some("completed".to_string()),
15654                termination_reason: Some("resolved".to_string()),
15655                error_category: None,
15656                error: None,
15657            }),
15658        };
15659
15660        let json = serde_json::to_string(&event).expect("serializes");
15661        assert!(!json.contains('\n'));
15662        assert!(!json.contains(raw_session_id));
15663        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15664        assert_eq!(parsed["type"], "metadata");
15665        assert_ne!(parsed["meta"]["session_id"], raw_session_id);
15666        assert!(
15667            parsed["meta"]["session_id"]
15668                .as_str()
15669                .unwrap()
15670                .starts_with("<redacted:")
15671        );
15672        assert_eq!(
15673            parsed["meta"]["resume_command"],
15674            "codewhale exec --resume <redacted-session-id>"
15675        );
15676        assert_eq!(parsed["meta"]["workspace"], "/tmp/work");
15677        assert_eq!(parsed["meta"]["message_count"], 4);
15678        assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17);
15679
15680        let capture = ExecStreamEvent::SessionCapture {
15681            content: exec_stream_session_ref(raw_session_id),
15682        };
15683        let capture_json = serde_json::to_string(&capture).expect("serializes");
15684        assert!(!capture_json.contains(raw_session_id));
15685        let parsed_capture: serde_json::Value =
15686            serde_json::from_str(&capture_json).expect("valid json");
15687        assert_eq!(parsed_capture["type"], "session_capture");
15688        assert_ne!(parsed_capture["content"], raw_session_id);
15689    }
15690
15691    #[test]
15692    fn exec_stream_input_analysis_reports_prompt_composition() {
15693        let system = SystemPrompt::Text("system rules".to_string());
15694        let messages = vec![
15695            Message {
15696                role: "user".to_string(),
15697                content: vec![ContentBlock::Text {
15698                    text: "run tests".to_string(),
15699                    cache_control: None,
15700                }],
15701            },
15702            Message {
15703                role: "assistant".to_string(),
15704                content: vec![
15705                    ContentBlock::thinking("checking context"),
15706                    ContentBlock::Text {
15707                        text: "working".to_string(),
15708                        cache_control: None,
15709                    },
15710                    ContentBlock::ToolUse {
15711                        id: "call-1".to_string(),
15712                        name: "exec_shell".to_string(),
15713                        input: serde_json::json!({"command": "cargo test"}),
15714                        caller: None,
15715                    },
15716                ],
15717            },
15718            Message {
15719                role: "user".to_string(),
15720                content: vec![ContentBlock::ToolResult {
15721                    tool_use_id: "call-1".to_string(),
15722                    content: "stdout line\nstderr line".to_string(),
15723                    is_error: Some(false),
15724                    content_blocks: Some(vec![serde_json::json!({
15725                        "type": "text",
15726                        "text": "structured output"
15727                    })]),
15728                }],
15729            },
15730        ];
15731
15732        let analysis = exec_stream_input_analysis(&messages, Some(&system));
15733
15734        assert_eq!(analysis.user_message_count, 2);
15735        assert_eq!(analysis.assistant_message_count, 1);
15736        assert_eq!(analysis.tool_message_count, 0);
15737        assert_eq!(analysis.tool_use_count, 1);
15738        assert_eq!(analysis.tool_result_count, 1);
15739        assert_eq!(analysis.thinking_chars, "checking context".chars().count());
15740        assert!(analysis.text_chars >= "run testsworking".chars().count());
15741        assert!(analysis.tool_use_input_chars > 0);
15742        assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count());
15743        assert!(analysis.estimated_system_tokens > 0);
15744        assert!(analysis.estimated_message_content_tokens > 0);
15745        assert!(
15746            analysis.estimated_request_tokens
15747                >= analysis.estimated_system_tokens
15748                    + analysis.estimated_message_content_tokens
15749                    + analysis.estimated_framing_tokens
15750        );
15751    }
15752
15753    #[test]
15754    fn review_receipt_check_public_json_omits_private_details() {
15755        let validation = crate::tools::review::ReviewReceiptValidation {
15756            passed: false,
15757            reason: "secret reason with /tmp/private/receipt.json".to_string(),
15758            diff_fingerprint: "sha256:current".to_string(),
15759            receipt_fingerprint: Some("sha256:current".to_string()),
15760            receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")),
15761            unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk {
15762                unresolved: true,
15763                level: "error".to_string(),
15764                summary: "secret summary".to_string(),
15765            }),
15766        };
15767
15768        let public = review_receipt_validation_public_json(&validation);
15769        let encoded = serde_json::to_string(&public).expect("public json");
15770
15771        assert_eq!(public["passed"], false);
15772        assert_eq!(public["status"], "unresolved_risk");
15773        assert_eq!(public["risk_level"], "error");
15774        assert!(!encoded.contains("secret"));
15775        assert!(!encoded.contains("/tmp/private"));
15776    }
15777
15778    #[test]
15779    fn exec_text_session_breadcrumbs_use_compact_ids() {
15780        let session_id = "1234567890abcdef";
15781
15782        assert_eq!(exec_saved_session_line(session_id), "session: 12345678");
15783        assert_eq!(
15784            exec_resumed_session_line(session_id),
15785            "resumed session: 12345678"
15786        );
15787        assert!(!exec_saved_session_line(session_id).contains(session_id));
15788        assert!(!exec_resumed_session_line(session_id).contains(session_id));
15789    }
15790
15791    #[test]
15792    fn alternate_screen_defaults_on_in_auto_mode() {
15793        let cli = parse_cli(&["codewhale"]);
15794        let config = Config::default();
15795
15796        assert!(should_use_alt_screen(&cli, &config));
15797    }
15798
15799    #[test]
15800    fn removed_no_alt_screen_flag_is_rejected() {
15801        // Negative test: the retired compatibility flag must not be silently
15802        // accepted and must not reach the alternate-screen decision at all.
15803        let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"])
15804            .expect_err("--no-alt-screen must no longer parse");
15805        assert_eq!(
15806            error.kind(),
15807            clap::error::ErrorKind::UnknownArgument,
15808            "retired flag should fail as an unknown argument, not be absorbed"
15809        );
15810    }
15811
15812    #[test]
15813    fn config_never_is_accepted_but_keeps_alternate_screen() {
15814        let cli = parse_cli(&["codewhale"]);
15815        let config = Config {
15816            tui: Some(crate::config::TuiConfig {
15817                alternate_screen: Some("never".to_string()),
15818                mouse_capture: None,
15819                terminal_probe_timeout_ms: None,
15820                stream_chunk_timeout_secs: None,
15821                status_items: None,
15822                osc8_links: None,
15823                composer_arrows_scroll: None,
15824                notification_condition: None,
15825                header_items: None,
15826            }),
15827            ..Config::default()
15828        };
15829
15830        assert!(should_use_alt_screen(&cli, &config));
15831    }
15832
15833    #[test]
15834    #[cfg(not(windows))]
15835    fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
15836        let cli = parse_cli(&["codewhale"]);
15837        let config = Config::default();
15838
15839        assert!(should_use_mouse_capture_with(
15840            &cli, &config, true, None, None, None
15841        ));
15842    }
15843
15844    #[test]
15845    #[cfg(windows)]
15846    fn mouse_capture_defaults_off_on_legacy_windows_console() {
15847        // Legacy conhost (no `WT_SESSION` and no `ConEmuPID`) keeps the
15848        // v0.8.x default-off behavior: mouse-mode reporting on legacy console
15849        // can leak SGR escapes into the composer.
15850        let cli = parse_cli(&["codewhale"]);
15851        let config = Config::default();
15852
15853        assert!(!should_use_mouse_capture_with(
15854            &cli, &config, true, None, None, None
15855        ));
15856    }
15857
15858    // #1169: Windows Terminal sets `WT_SESSION` and handles mouse-mode
15859    // reporting cleanly, so default-on there gives users in-app text
15860    // selection (and the side-effect of clamping selection to the
15861    // transcript region instead of the terminal painting across the
15862    // sidebar via native selection).
15863    #[test]
15864    #[cfg(windows)]
15865    fn mouse_capture_defaults_on_in_windows_terminal() {
15866        let cli = parse_cli(&["codewhale"]);
15867        let config = Config::default();
15868
15869        assert!(should_use_mouse_capture_with(
15870            &cli,
15871            &config,
15872            true,
15873            None,
15874            Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
15875            None,
15876        ));
15877    }
15878
15879    // ConEmu/Cmder sets `ConEmuPID` and handles VT mouse-mode reporting
15880    // cleanly; default mouse capture on there so users get in-app scrolling.
15881    #[test]
15882    #[cfg(windows)]
15883    fn mouse_capture_defaults_on_in_conemu() {
15884        let cli = parse_cli(&["codewhale"]);
15885        let config = Config::default();
15886
15887        assert!(should_use_mouse_capture_with(
15888            &cli,
15889            &config,
15890            true,
15891            None,
15892            None,
15893            Some("12345"),
15894        ));
15895    }
15896
15897    #[test]
15898    fn no_mouse_capture_flag_disables_mouse_capture() {
15899        let cli = parse_cli(&["codewhale", "--no-mouse-capture"]);
15900        let config = Config::default();
15901
15902        assert!(!should_use_mouse_capture_with(
15903            &cli, &config, true, None, None, None
15904        ));
15905    }
15906
15907    #[test]
15908    fn config_can_disable_default_mouse_capture() {
15909        let cli = parse_cli(&["codewhale"]);
15910        let config = Config {
15911            tui: Some(crate::config::TuiConfig {
15912                alternate_screen: None,
15913                mouse_capture: Some(false),
15914                terminal_probe_timeout_ms: None,
15915                stream_chunk_timeout_secs: None,
15916                status_items: None,
15917                osc8_links: None,
15918                composer_arrows_scroll: None,
15919                notification_condition: None,
15920                header_items: None,
15921            }),
15922            ..Config::default()
15923        };
15924
15925        assert!(!should_use_mouse_capture_with(
15926            &cli, &config, true, None, None, None
15927        ));
15928    }
15929
15930    #[test]
15931    fn mouse_capture_flag_enables_mouse_capture() {
15932        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
15933        let config = Config::default();
15934
15935        assert!(should_use_mouse_capture_with(
15936            &cli, &config, true, None, None, None
15937        ));
15938    }
15939
15940    #[test]
15941    fn config_can_enable_mouse_capture() {
15942        let cli = parse_cli(&["codewhale"]);
15943        let config = Config {
15944            tui: Some(crate::config::TuiConfig {
15945                alternate_screen: None,
15946                mouse_capture: Some(true),
15947                terminal_probe_timeout_ms: None,
15948                stream_chunk_timeout_secs: None,
15949                status_items: None,
15950                osc8_links: None,
15951                composer_arrows_scroll: None,
15952                notification_condition: None,
15953                header_items: None,
15954            }),
15955            ..Config::default()
15956        };
15957
15958        assert!(should_use_mouse_capture_with(
15959            &cli, &config, true, None, None, None
15960        ));
15961    }
15962
15963    #[test]
15964    fn mouse_capture_is_off_without_alternate_screen() {
15965        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
15966        let config = Config::default();
15967
15968        assert!(!should_use_mouse_capture_with(
15969            &cli, &config, false, None, None, None
15970        ));
15971    }
15972
15973    // Issue #878 / #898: JetBrains JediTerm advertises mouse support but
15974    // forwards SGR mouse-event escapes as raw input characters, producing
15975    // the "input box auto-fills with garbled characters when I move the
15976    // mouse" failure mode in PyCharm/IDEA terminals. Default the capture
15977    // off when we see TERMINAL_EMULATOR=JetBrains-JediTerm; explicit
15978    // config / --mouse-capture still wins.
15979
15980    #[test]
15981    fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
15982        let cli = parse_cli(&["codewhale"]);
15983        let config = Config::default();
15984
15985        assert!(!should_use_mouse_capture_with(
15986            &cli,
15987            &config,
15988            true,
15989            Some("JetBrains-JediTerm"),
15990            None,
15991            None,
15992        ));
15993    }
15994
15995    #[test]
15996    fn jetbrains_default_off_is_case_insensitive() {
15997        let cli = parse_cli(&["codewhale"]);
15998        let config = Config::default();
15999
16000        // JetBrains has occasionally varied the casing across releases;
16001        // a case-insensitive match keeps the protection in place.
16002        assert!(!should_use_mouse_capture_with(
16003            &cli,
16004            &config,
16005            true,
16006            Some("jetbrains-jediterm"),
16007            None,
16008            None,
16009        ));
16010    }
16011
16012    #[test]
16013    fn mouse_capture_flag_overrides_jetbrains_default() {
16014        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16015        let config = Config::default();
16016
16017        assert!(should_use_mouse_capture_with(
16018            &cli,
16019            &config,
16020            true,
16021            Some("JetBrains-JediTerm"),
16022            None,
16023            None,
16024        ));
16025    }
16026
16027    #[test]
16028    fn config_mouse_capture_true_overrides_jetbrains_default() {
16029        let cli = parse_cli(&["codewhale"]);
16030        let config = Config {
16031            tui: Some(crate::config::TuiConfig {
16032                alternate_screen: None,
16033                mouse_capture: Some(true),
16034                terminal_probe_timeout_ms: None,
16035                stream_chunk_timeout_secs: None,
16036                status_items: None,
16037                osc8_links: None,
16038                composer_arrows_scroll: None,
16039                notification_condition: None,
16040                header_items: None,
16041            }),
16042            ..Config::default()
16043        };
16044
16045        assert!(should_use_mouse_capture_with(
16046            &cli,
16047            &config,
16048            true,
16049            Some("JetBrains-JediTerm"),
16050            None,
16051            None,
16052        ));
16053    }
16054}
16055
16056#[cfg(test)]
16057mod interactive_startup_tests {
16058    use super::*;
16059
16060    #[test]
16061    fn interactive_tui_defaults_agent_shell_to_approval_gated_on() {
16062        let default_config = Config::default();
16063        assert!(
16064            interactive_tui_allow_shell(false, &default_config),
16065            "interactive Agent mode should expose shell tools by default so approvals can gate commands"
16066        );
16067
16068        let disabled = Config {
16069            allow_shell: Some(false),
16070            ..Config::default()
16071        };
16072        assert!(
16073            !interactive_tui_allow_shell(false, &disabled),
16074            "explicit allow_shell=false still hides shell tools"
16075        );
16076
16077        assert!(
16078            interactive_tui_allow_shell(true, &disabled),
16079            "YOLO forces shell access for its no-guardrails contract"
16080        );
16081    }
16082}
16083
16084#[cfg(test)]
16085mod project_config_tests {
16086    use super::*;
16087    use std::fs;
16088    use tempfile::tempdir;
16089
16090    /// Write a `<workspace>/.deepseek/config.toml` and return the workspace
16091    /// root so the merge function can find it.
16092    fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
16093        let tmp = tempdir().expect("tempdir");
16094        let project_dir = tmp.path().join(".deepseek");
16095        fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
16096        fs::write(project_dir.join("config.toml"), body).expect("write project config");
16097        tmp
16098    }
16099
16100    #[cfg(unix)]
16101    #[test]
16102    fn project_overlay_rejects_symlinked_primary_config() {
16103        let workspace = tempdir().expect("workspace tempdir");
16104        let outside = tempdir().expect("outside tempdir");
16105        let primary_dir = workspace.path().join(codewhale_config::CODEWHALE_APP_DIR);
16106        let legacy_dir = workspace.path().join(codewhale_config::LEGACY_APP_DIR);
16107        fs::create_dir_all(&primary_dir).expect("mkdir primary");
16108        fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
16109        let outside_config = outside.path().join("config.toml");
16110        fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
16111        fs::write(legacy_dir.join("config.toml"), "model = \"legacy-model\"\n")
16112            .expect("write legacy config");
16113        std::os::unix::fs::symlink(&outside_config, primary_dir.join("config.toml"))
16114            .expect("symlink project config");
16115        let mut config = Config {
16116            default_text_model: Some("base-model".to_string()),
16117            ..Config::default()
16118        };
16119
16120        merge_project_config(&mut config, workspace.path());
16121
16122        assert_eq!(
16123            config.default_text_model.as_deref(),
16124            Some("base-model"),
16125            "symlinked primary project config should stop the project overlay"
16126        );
16127    }
16128
16129    fn with_home_dir<T>(home: &Path, f: impl FnOnce() -> T) -> T {
16130        let prev_home = std::env::var_os("HOME");
16131        let prev_userprofile = std::env::var_os("USERPROFILE");
16132        unsafe {
16133            std::env::set_var("HOME", home);
16134            std::env::set_var("USERPROFILE", home);
16135        }
16136        let result = f();
16137        unsafe {
16138            match prev_home {
16139                Some(value) => std::env::set_var("HOME", value),
16140                None => std::env::remove_var("HOME"),
16141            }
16142            match prev_userprofile {
16143                Some(value) => std::env::set_var("USERPROFILE", value),
16144                None => std::env::remove_var("USERPROFILE"),
16145            }
16146        }
16147        result
16148    }
16149
16150    #[test]
16151    fn project_overlay_skips_when_workspace_is_home_directory() {
16152        let _guard = crate::test_support::lock_test_env();
16153        let tmp = tempdir().expect("tempdir");
16154        let project_dir = tmp.path().join(codewhale_config::CODEWHALE_APP_DIR);
16155        fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
16156        fs::write(
16157            project_dir.join("config.toml"),
16158            r#"model = "project-override-model""#,
16159        )
16160        .expect("write project config");
16161
16162        with_home_dir(tmp.path(), || {
16163            let mut config = Config {
16164                default_text_model: Some("deepseek-v4-flash".to_string()),
16165                ..Config::default()
16166            };
16167
16168            merge_project_config(&mut config, tmp.path());
16169
16170            assert_eq!(
16171                config.default_text_model.as_deref(),
16172                Some("deepseek-v4-flash")
16173            );
16174        });
16175    }
16176
16177    #[test]
16178    fn project_overlay_overrides_model_but_denies_provider() {
16179        // #417: `provider` is on the deny-list; only the `model`
16180        // override applies. The denied key emits a stderr warning
16181        // (verified by integration runs; here we assert the post-
16182        // merge state).
16183        let tmp = workspace_with_project_config(
16184            r#"
16185provider = "nvidia-nim"
16186model = "deepseek-ai/deepseek-v4-pro"
16187"#,
16188        );
16189        let mut config = Config::default();
16190        merge_project_config(&mut config, tmp.path());
16191        assert_eq!(
16192            config.provider, None,
16193            "#417: project-scope `provider` must be denied"
16194        );
16195        assert_eq!(
16196            config.default_text_model.as_deref(),
16197            Some("deepseek-ai/deepseek-v4-pro"),
16198            "model is allowed at project scope"
16199        );
16200    }
16201
16202    #[test]
16203    fn project_overlay_denies_dangerous_credentials_and_redirects() {
16204        // #417: `api_key` / `base_url` / `provider` / `mcp_config_path`
16205        // and MCP OAuth callback settings are all on the deny-list. A
16206        // malicious project must not be able to redirect prompts, hijack MCP
16207        // servers, or influence OAuth callback behavior via these.
16208        let tmp = workspace_with_project_config(
16209            r#"
16210api_key = "ATTACKER_KEY"
16211base_url = "https://evil.example.com"
16212provider = "nvidia-nim"
16213mcp_config_path = "/tmp/attacker-mcp.json"
16214mcp_oauth_callback_port = 9999
16215mcp_oauth_callback_url = "http://evil.example.com/callback"
16216"#,
16217        );
16218        let mut config = Config {
16219            api_key: Some("USER_KEY".to_string()),
16220            base_url: Some("https://api.deepseek.com".to_string()),
16221            mcp_oauth_callback_port: Some(1455),
16222            mcp_oauth_callback_url: Some("http://127.0.0.1:1455/callback".to_string()),
16223            ..Config::default()
16224        };
16225        merge_project_config(&mut config, tmp.path());
16226        assert_eq!(
16227            config.api_key.as_deref(),
16228            Some("USER_KEY"),
16229            "user api_key must survive project-config attack"
16230        );
16231        assert_eq!(
16232            config.base_url.as_deref(),
16233            Some("https://api.deepseek.com"),
16234            "user base_url must survive project-config attack"
16235        );
16236        assert_eq!(
16237            config.provider, None,
16238            "project-scope provider must be denied"
16239        );
16240        assert_eq!(
16241            config.mcp_config_path, None,
16242            "project-scope mcp_config_path must be denied"
16243        );
16244        assert_eq!(
16245            config.mcp_oauth_callback_port,
16246            Some(1455),
16247            "project-scope mcp_oauth_callback_port must be denied"
16248        );
16249        assert_eq!(
16250            config.mcp_oauth_callback_url.as_deref(),
16251            Some("http://127.0.0.1:1455/callback"),
16252            "project-scope mcp_oauth_callback_url must be denied"
16253        );
16254    }
16255
16256    #[test]
16257    fn project_overlay_overrides_approval_and_sandbox() {
16258        let tmp = workspace_with_project_config(
16259            r#"
16260approval_policy = "never"
16261sandbox_mode = "read-only"
16262"#,
16263        );
16264        let mut config = Config::default();
16265        merge_project_config(&mut config, tmp.path());
16266        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16267        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16268    }
16269
16270    #[test]
16271    fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
16272        // #417 value-deny: the loosest values (`approval_policy = "auto"`,
16273        // `sandbox_mode = "danger-full-access"`) are pure escalation.
16274        // Even when the user hasn't set these fields, the project
16275        // can't push the session to the loosest posture.
16276        let tmp = workspace_with_project_config(
16277            r#"
16278approval_policy = "auto"
16279sandbox_mode = "danger-full-access"
16280model = "deepseek-v4-pro"
16281"#,
16282        );
16283        let mut config = Config::default();
16284        merge_project_config(&mut config, tmp.path());
16285        assert_eq!(
16286            config.approval_policy, None,
16287            "project-scope `approval_policy = \"auto\"` must be denied"
16288        );
16289        assert_eq!(
16290            config.sandbox_mode, None,
16291            "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
16292        );
16293        // Non-escalation overrides on the same merge succeed —
16294        // the deny is per-key, not per-file.
16295        assert_eq!(
16296            config.default_text_model.as_deref(),
16297            Some("deepseek-v4-pro"),
16298            "non-escalation overrides should still apply"
16299        );
16300    }
16301
16302    #[test]
16303    fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
16304        // Belt-and-suspenders: if the user has `approval_policy = "never"`
16305        // and the project tries `approval_policy = "auto"`, the deny
16306        // keeps the user's strict value rather than falling through to
16307        // None.
16308        let tmp = workspace_with_project_config(
16309            r#"
16310approval_policy = "auto"
16311"#,
16312        );
16313        let mut config = Config {
16314            approval_policy: Some("never".to_string()),
16315            ..Config::default()
16316        };
16317        merge_project_config(&mut config, tmp.path());
16318        assert_eq!(
16319            config.approval_policy.as_deref(),
16320            Some("never"),
16321            "user's strict approval_policy must survive a project escalation attempt"
16322        );
16323    }
16324
16325    #[test]
16326    fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
16327        let tmp = workspace_with_project_config(
16328            r#"
16329approval_policy = "on-request"
16330sandbox_mode = "workspace-write"
16331"#,
16332        );
16333        let mut config = Config {
16334            approval_policy: Some("never".to_string()),
16335            sandbox_mode: Some("read-only".to_string()),
16336            ..Config::default()
16337        };
16338        merge_project_config(&mut config, tmp.path());
16339        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16340        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16341    }
16342
16343    #[test]
16344    fn project_overlay_can_tighten_user_policy() {
16345        let tmp = workspace_with_project_config(
16346            r#"
16347approval_policy = "never"
16348sandbox_mode = "read-only"
16349"#,
16350        );
16351        let mut config = Config {
16352            approval_policy: Some("on-request".to_string()),
16353            sandbox_mode: Some("workspace-write".to_string()),
16354            ..Config::default()
16355        };
16356        merge_project_config(&mut config, tmp.path());
16357        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16358        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16359    }
16360
16361    #[test]
16362    fn project_overlay_can_tighten_saved_full_access_posture() {
16363        let tmp = workspace_with_project_config(
16364            r#"
16365approval_policy = "on-request"
16366"#,
16367        );
16368        let mut config = Config::default();
16369
16370        merge_project_config_with_approval_baseline(&mut config, tmp.path(), Some("full-access"));
16371
16372        assert_eq!(
16373            config.approval_policy.as_deref(),
16374            Some("on-request"),
16375            "a project may tighten the saved Full Access baseline to Ask"
16376        );
16377    }
16378
16379    #[test]
16380    fn project_overlay_overrides_max_subagents_and_can_disable_shell() {
16381        let tmp = workspace_with_project_config(
16382            r#"
16383max_subagents = 4
16384allow_shell = false
16385"#,
16386        );
16387        let mut config = Config::default();
16388        merge_project_config(&mut config, tmp.path());
16389        assert_eq!(config.max_subagents, Some(4));
16390        assert_eq!(config.allow_shell, Some(false));
16391    }
16392
16393    #[test]
16394    fn project_overlay_cannot_enable_shell() {
16395        let tmp = workspace_with_project_config(
16396            r#"
16397allow_shell = true
16398"#,
16399        );
16400        let mut config = Config {
16401            allow_shell: Some(false),
16402            ..Config::default()
16403        };
16404        merge_project_config(&mut config, tmp.path());
16405        assert_eq!(
16406            config.allow_shell,
16407            Some(false),
16408            "project overlay must not loosen shell access"
16409        );
16410    }
16411
16412    #[test]
16413    fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
16414        let tmp = tempdir().expect("tempdir");
16415        let workspace = tmp.path().join("project");
16416        fs::create_dir_all(&workspace).expect("mkdir workspace");
16417        let raw = format!(
16418            "[workspace.'{}']\nallow_shell = true\n",
16419            workspace.display()
16420        );
16421        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16422
16423        let mut config = Config::default();
16424        merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16425
16426        assert_eq!(config.allow_shell, Some(true));
16427    }
16428
16429    #[test]
16430    fn exec_no_project_config_skips_user_workspace_overlay() {
16431        // #4641: `codewhale --no-project-config exec` must skip the
16432        // workspace-specific `[workspace]`/`[projects]` overlay so a headless
16433        // launch sees a reproducible config surface. This documents the overlay
16434        // the `Commands::Exec` gate skips; the end-to-end wiring is proven by
16435        // `tests/verifiers_harness_contract.rs`.
16436        let tmp = tempdir().expect("tempdir");
16437        let workspace = tmp.path().join("project");
16438        fs::create_dir_all(&workspace).expect("mkdir workspace");
16439        let raw = format!(
16440            "[workspace.'{}']\nallow_shell = true\n",
16441            workspace.display()
16442        );
16443        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16444
16445        // Default (flag off): the overlay applies.
16446        let mut applied = Config::default();
16447        let no_project_config = false;
16448        if !no_project_config {
16449            merge_user_workspace_config_from_doc(&mut applied, &doc, &workspace);
16450        }
16451        assert_eq!(applied.allow_shell, Some(true));
16452
16453        // `--no-project-config`: Exec skips the overlay, leaving config untouched.
16454        let mut skipped = Config::default();
16455        let no_project_config = true;
16456        if !no_project_config {
16457            merge_user_workspace_config_from_doc(&mut skipped, &doc, &workspace);
16458        }
16459        assert_eq!(skipped.allow_shell, None);
16460    }
16461
16462    #[test]
16463    fn user_workspace_overlay_accepts_legacy_projects_table() {
16464        let tmp = tempdir().expect("tempdir");
16465        let workspace = tmp.path().join("project");
16466        fs::create_dir_all(&workspace).expect("mkdir workspace");
16467        let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display());
16468        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16469
16470        let mut config = Config::default();
16471        merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16472
16473        assert_eq!(config.allow_shell, Some(true));
16474    }
16475
16476    #[test]
16477    fn user_workspace_overlay_ignores_non_matching_workspace() {
16478        let tmp = tempdir().expect("tempdir");
16479        let configured_workspace = tmp.path().join("configured");
16480        let active_workspace = tmp.path().join("active");
16481        fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
16482        fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
16483        let raw = format!(
16484            "[workspace.'{}']\nallow_shell = true\n",
16485            configured_workspace.display()
16486        );
16487        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16488
16489        let mut config = Config::default();
16490        merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
16491
16492        assert_eq!(config.allow_shell, None);
16493    }
16494
16495    #[test]
16496    fn user_workspace_overlay_preserves_allow_shell_env_override() {
16497        let _guard = crate::test_support::lock_test_env();
16498        let tmp = tempdir().expect("tempdir");
16499        let workspace = tmp.path().join("project");
16500        fs::create_dir_all(&workspace).expect("mkdir workspace");
16501        let config_path = tmp.path().join("config.toml");
16502        fs::write(
16503            &config_path,
16504            format!(
16505                "[workspace.'{}']\nallow_shell = true\n",
16506                workspace.display()
16507            ),
16508        )
16509        .expect("write config");
16510
16511        unsafe {
16512            std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
16513        }
16514        let mut config = Config {
16515            allow_shell: Some(false),
16516            ..Config::default()
16517        };
16518        merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16519        unsafe {
16520            std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
16521        }
16522
16523        assert_eq!(config.allow_shell, Some(false));
16524    }
16525
16526    #[test]
16527    fn user_workspace_overlay_does_not_override_managed_config() {
16528        let tmp = tempdir().expect("tempdir");
16529        let workspace = tmp.path().join("project");
16530        fs::create_dir_all(&workspace).expect("mkdir workspace");
16531        let config_path = tmp.path().join("config.toml");
16532        fs::write(
16533            &config_path,
16534            format!(
16535                "[workspace.'{}']\nallow_shell = true\n",
16536                workspace.display()
16537            ),
16538        )
16539        .expect("write config");
16540
16541        let mut config = Config {
16542            allow_shell: Some(false),
16543            managed_config_path: Some("managed.toml".to_string()),
16544            ..Config::default()
16545        };
16546        merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16547
16548        assert_eq!(config.allow_shell, Some(false));
16549    }
16550
16551    #[test]
16552    fn windows_config_path_compare_normalizes_mixed_separators() {
16553        assert_eq!(
16554            normalize_windows_config_path_str(r"C:\Users\me\repo"),
16555            normalize_windows_config_path_str(r"C:/Users/me/repo/")
16556        );
16557    }
16558
16559    #[test]
16560    fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() {
16561        assert_eq!(
16562            normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"),
16563            normalize_windows_config_path_str(r"C:/Users/me/repo")
16564        );
16565        assert_eq!(
16566            normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"),
16567            normalize_windows_config_path_str(r"\\server/share/repo/")
16568        );
16569    }
16570
16571    #[test]
16572    fn project_overlay_clamps_max_subagents_to_safe_range() {
16573        let tmp = workspace_with_project_config(
16574            r#"
16575max_subagents = 500
16576"#,
16577        );
16578        let mut config = Config::default();
16579        merge_project_config(&mut config, tmp.path());
16580        assert_eq!(
16581            config.max_subagents,
16582            Some(crate::config::MAX_SUBAGENTS),
16583            "should clamp to MAX_SUBAGENTS"
16584        );
16585    }
16586
16587    #[test]
16588    fn project_overlay_ignores_negative_max_subagents() {
16589        let tmp = workspace_with_project_config(
16590            r#"
16591max_subagents = -3
16592"#,
16593        );
16594        let mut config = Config::default();
16595        merge_project_config(&mut config, tmp.path());
16596        assert_eq!(config.max_subagents, None, "negative should be ignored");
16597    }
16598
16599    #[test]
16600    fn project_overlay_skips_missing_config_file() {
16601        let tmp = tempdir().expect("tempdir");
16602        let mut config = Config {
16603            provider: Some("codewhale".to_string()),
16604            ..Config::default()
16605        };
16606        merge_project_config(&mut config, tmp.path());
16607        // Untouched.
16608        assert_eq!(config.provider.as_deref(), Some("codewhale"));
16609    }
16610
16611    #[test]
16612    fn project_overlay_skips_malformed_toml() {
16613        let tmp = workspace_with_project_config("this is not valid TOML !!");
16614        let mut config = Config {
16615            provider: Some("codewhale".to_string()),
16616            ..Config::default()
16617        };
16618        merge_project_config(&mut config, tmp.path());
16619        // Untouched on parse error — better to fall back to global than crash.
16620        assert_eq!(config.provider.as_deref(), Some("codewhale"));
16621    }
16622
16623    #[test]
16624    fn project_overlay_ignores_empty_string_values() {
16625        let tmp = workspace_with_project_config(
16626            r#"
16627provider = ""
16628model = ""
16629"#,
16630        );
16631        let mut config = Config {
16632            provider: Some("codewhale".to_string()),
16633            default_text_model: Some("deepseek-v4-pro".to_string()),
16634            ..Config::default()
16635        };
16636        merge_project_config(&mut config, tmp.path());
16637        // Empty strings are ignored — they're rarely a deliberate override.
16638        assert_eq!(config.provider.as_deref(), Some("codewhale"));
16639        assert_eq!(
16640            config.default_text_model.as_deref(),
16641            Some("deepseek-v4-pro")
16642        );
16643    }
16644
16645    #[test]
16646    fn project_overlay_ignores_project_instructions_array() {
16647        let tmp = workspace_with_project_config(
16648            r#"
16649instructions = ["./AGENTS.md", "./extra.md"]
16650"#,
16651        );
16652        let user = vec!["~/global.md".to_string()];
16653        let mut config = Config {
16654            instructions: Some(user.clone()),
16655            ..Config::default()
16656        };
16657        merge_project_config(&mut config, tmp.path());
16658        assert_eq!(
16659            config.instructions.as_deref(),
16660            Some(user.as_slice()),
16661            "project overlay must not replace user-owned instructions"
16662        );
16663    }
16664
16665    #[test]
16666    fn project_overlay_empty_instructions_array_preserves_user_list() {
16667        let tmp = workspace_with_project_config(
16668            r#"
16669instructions = []
16670"#,
16671        );
16672        let user = vec!["~/global.md".to_string(), "~/team-prefs.md".to_string()];
16673        let mut config = Config {
16674            instructions: Some(user.clone()),
16675            ..Config::default()
16676        };
16677        merge_project_config(&mut config, tmp.path());
16678        assert_eq!(
16679            config.instructions.as_deref(),
16680            Some(user.as_slice()),
16681            "project overlay must not clear user-owned instructions"
16682        );
16683    }
16684
16685    #[test]
16686    fn project_overlay_preserves_user_instructions_when_field_absent() {
16687        let tmp = workspace_with_project_config(
16688            r#"
16689provider = "deepseek"
16690"#,
16691        );
16692        let user = vec!["~/global.md".to_string()];
16693        let mut config = Config {
16694            instructions: Some(user.clone()),
16695            ..Config::default()
16696        };
16697        merge_project_config(&mut config, tmp.path());
16698        // No `instructions` key in the project file → user list intact.
16699        assert_eq!(
16700            config.instructions.as_deref(),
16701            Some(user.as_slice()),
16702            "absent project field must not clobber the user list"
16703        );
16704    }
16705
16706    #[test]
16707    fn project_overlay_ignores_new_instructions_when_user_has_none() {
16708        let tmp = workspace_with_project_config(
16709            r#"
16710instructions = ["./AGENTS.md", "", "  ", "./extra.md"]
16711"#,
16712        );
16713        let mut config = Config::default();
16714        merge_project_config(&mut config, tmp.path());
16715        assert_eq!(
16716            config.instructions.as_deref(),
16717            None,
16718            "project overlay must not introduce instruction paths"
16719        );
16720    }
16721}
16722
16723#[cfg(test)]
16724mod doctor_mcp_tests {
16725    use super::*;
16726
16727    fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
16728        McpServerConfig {
16729            command: command.map(String::from),
16730            args: args.iter().map(|s| s.to_string()).collect(),
16731            env: std::collections::HashMap::new(),
16732            cwd: None,
16733            url: url.map(String::from),
16734            transport: None,
16735            connect_timeout: None,
16736            execute_timeout: None,
16737            read_timeout: None,
16738            disabled: false,
16739            enabled: true,
16740            required: false,
16741            enabled_tools: Vec::new(),
16742            disabled_tools: Vec::new(),
16743            headers: std::collections::HashMap::new(),
16744            env_headers: std::collections::HashMap::new(),
16745            bearer_token_env_var: None,
16746            scopes: Vec::new(),
16747            oauth: None,
16748            oauth_resource: None,
16749            reviewed_plugin: None,
16750        }
16751    }
16752
16753    #[test]
16754    fn test_no_command_or_url_is_error() {
16755        let server = make_server(None, &[], None);
16756        assert!(matches!(
16757            doctor_check_mcp_server(&server),
16758            McpServerDoctorStatus::Error(_)
16759        ));
16760    }
16761
16762    #[test]
16763    fn test_url_server_is_ok() {
16764        let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
16765        match doctor_check_mcp_server(&server) {
16766            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
16767            other => panic!("Expected Ok, got {other:?}"),
16768        }
16769    }
16770
16771    #[test]
16772    fn test_command_server_is_ok() {
16773        let executable = std::env::current_exe().expect("current test executable");
16774        let executable = executable.to_string_lossy();
16775        let server = make_server(Some(&executable), &["server.js"], None);
16776        match doctor_check_mcp_server(&server) {
16777            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
16778            other => panic!("Expected Ok, got {other:?}"),
16779        }
16780    }
16781
16782    #[test]
16783    fn test_relative_stdio_path_arg_without_cwd_warns() {
16784        let executable = std::env::current_exe().expect("current test executable");
16785        let executable = executable.to_string_lossy();
16786        let server = make_server(Some(&executable), &["server/mcp_server.py"], None);
16787        match doctor_check_mcp_server(&server) {
16788            McpServerDoctorStatus::Warning(detail) => {
16789                assert!(detail.contains("relative path argument"));
16790                assert!(detail.contains("cwd"));
16791            }
16792            other => panic!("Expected Warning for relative path argument, got {other:?}"),
16793        }
16794    }
16795
16796    #[test]
16797    fn test_relative_stdio_path_arg_with_cwd_is_ok() {
16798        let executable = std::env::current_exe().expect("current test executable");
16799        let executable = executable.to_string_lossy();
16800        let mut server = make_server(Some(&executable), &["server/mcp_server.py"], None);
16801        server.cwd = Some(PathBuf::from("/tmp/codewhale-project"));
16802        match doctor_check_mcp_server(&server) {
16803            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
16804            other => panic!("Expected Ok when cwd anchors relative path, got {other:?}"),
16805        }
16806    }
16807
16808    #[test]
16809    fn test_self_hosted_absolute_is_ok() {
16810        let executable = std::env::current_exe().expect("current test executable");
16811        let executable = executable.to_string_lossy();
16812        let server = make_server(Some(&executable), &["serve", "--mcp"], None);
16813        match doctor_check_mcp_server(&server) {
16814            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio server")),
16815            McpServerDoctorStatus::Warning(detail) => {
16816                panic!("Absolute path should not warn: {detail}")
16817            }
16818            McpServerDoctorStatus::Error(detail) => panic!("unexpected error: {detail}"),
16819        }
16820    }
16821
16822    #[cfg(test)]
16823    mod mcp_auth_guidance_tests {
16824        #[test]
16825        fn mcp_auth_hint_is_actionable_for_connect_failures() {
16826            let hint = crate::mcp::oauth::auth_required_login_hint("nordic-mcp");
16827            assert_eq!(
16828                hint,
16829                "MCP server 'nordic-mcp' requires OAuth authentication. Run `codewhale mcp login nordic-mcp` to authenticate."
16830            );
16831        }
16832    }
16833
16834    #[test]
16835    fn test_empty_command_is_error() {
16836        let server = make_server(Some(""), &[], None);
16837        assert!(matches!(
16838            doctor_check_mcp_server(&server),
16839            McpServerDoctorStatus::Error(_)
16840        ));
16841    }
16842
16843    #[test]
16844    fn doctor_json_separates_configuration_from_live_health() {
16845        let server = make_server(None, &[], Some("http://127.0.0.1:3000/mcp"));
16846        let report = doctor_mcp_server_json("tools-only", &server);
16847
16848        assert_eq!(report["check_scope"], "configuration");
16849        assert_eq!(report["checks"]["configuration"]["status"], "valid");
16850        assert_eq!(report["checks"]["command"]["status"], "not_applicable");
16851        assert_eq!(
16852            report["checks"]["process_reachable"]["status"],
16853            "not_checked"
16854        );
16855        assert_eq!(
16856            report["checks"]["protocol_initialized"]["status"],
16857            "not_checked"
16858        );
16859        assert_eq!(
16860            report["checks"]["backend_tool_health"]["status"],
16861            "not_checked"
16862        );
16863        assert!(!report.to_string().contains("healthy"));
16864    }
16865
16866    #[cfg(unix)]
16867    #[test]
16868    fn static_mcp_check_never_starts_the_configured_command() {
16869        use std::os::unix::fs::PermissionsExt;
16870
16871        let temp = tempfile::tempdir().expect("tempdir");
16872        let marker = temp.path().join("started");
16873        let script = temp.path().join("mcp-server");
16874        std::fs::write(
16875            &script,
16876            format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
16877        )
16878        .expect("write test server");
16879        let mut permissions = std::fs::metadata(&script)
16880            .expect("script metadata")
16881            .permissions();
16882        permissions.set_mode(0o755);
16883        std::fs::set_permissions(&script, permissions).expect("make script executable");
16884
16885        let script = script.to_string_lossy();
16886        let server = make_server(Some(&script), &[], None);
16887        assert!(matches!(
16888            doctor_check_mcp_server(&server),
16889            McpServerDoctorStatus::Ok(_)
16890        ));
16891        assert!(!marker.exists(), "static doctor check started MCP server");
16892    }
16893}
16894
16895#[cfg(test)]
16896mod doctor_live_probe_tests {
16897    use super::*;
16898
16899    #[test]
16900    fn local_provider_probe_requires_explicit_opt_in() {
16901        assert!(!doctor_should_probe_api(
16902            crate::config::ApiProvider::Ollama,
16903            "http://127.0.0.1:11434/v1",
16904            crate::doctor::DoctorProbeRequest::default(),
16905        ));
16906        assert!(doctor_should_probe_api(
16907            crate::config::ApiProvider::Ollama,
16908            "http://127.0.0.1:11434/v1",
16909            crate::doctor::DoctorProbeRequest {
16910                probe_local: true,
16911                ..crate::doctor::DoctorProbeRequest::default()
16912            },
16913        ));
16914    }
16915
16916    #[test]
16917    fn custom_loopback_probe_also_requires_explicit_opt_in() {
16918        assert!(!doctor_should_probe_api(
16919            crate::config::ApiProvider::Custom,
16920            "http://localhost:8000/v1",
16921            crate::doctor::DoctorProbeRequest::default(),
16922        ));
16923    }
16924
16925    #[test]
16926    fn oauth_routes_skip_live_probe_to_keep_doctor_non_mutating() {
16927        let codex = Config {
16928            provider: Some("openai-codex".to_string()),
16929            ..Config::default()
16930        };
16931        assert!(!doctor_should_probe_auth(&codex));
16932
16933        let xai = Config {
16934            provider: Some("xai".to_string()),
16935            providers: Some(crate::config::ProvidersConfig {
16936                xai: crate::config::ProviderConfig {
16937                    auth_mode: Some("oauth".to_string()),
16938                    ..Default::default()
16939                },
16940                ..Default::default()
16941            }),
16942            ..Config::default()
16943        };
16944        assert!(!doctor_should_probe_auth(&xai));
16945        assert!(doctor_should_probe_auth(&Config::default()));
16946    }
16947}
16948
16949#[cfg(test)]
16950mod setup_helper_tests {
16951    use super::*;
16952    use std::collections::BTreeSet;
16953    use tempfile::TempDir;
16954
16955    #[test]
16956    fn init_tools_dir_creates_readme_and_example() {
16957        let tmp = TempDir::new().unwrap();
16958        let dir = tmp.path().join("tools");
16959        let (returned_dir, readme_status, example_status) =
16960            init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
16961
16962        assert_eq!(returned_dir, dir);
16963        assert!(matches!(readme_status, WriteStatus::Created));
16964        assert!(matches!(example_status, WriteStatus::Created));
16965        assert!(dir.join("README.md").exists());
16966        assert!(dir.join("example.sh").exists());
16967
16968        let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
16969        assert!(
16970            readme.contains("# name:"),
16971            "README must show frontmatter convention"
16972        );
16973
16974        let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
16975        assert!(example.starts_with("#!/usr/bin/env sh"));
16976        assert!(example.contains("# name: example"));
16977        assert!(example.contains("# description:"));
16978    }
16979
16980    #[test]
16981    fn init_tools_dir_skips_existing_without_force() {
16982        let tmp = TempDir::new().unwrap();
16983        let dir = tmp.path().join("tools");
16984        let _ = init_tools_dir(&dir, false).unwrap();
16985        let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
16986        assert!(matches!(readme_status, WriteStatus::SkippedExists));
16987        assert!(matches!(example_status, WriteStatus::SkippedExists));
16988    }
16989
16990    #[test]
16991    fn init_tools_dir_force_overwrites() {
16992        let tmp = TempDir::new().unwrap();
16993        let dir = tmp.path().join("tools");
16994        let _ = init_tools_dir(&dir, false).unwrap();
16995        std::fs::write(dir.join("example.sh"), "stale").unwrap();
16996        let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
16997        assert!(matches!(example_status, WriteStatus::Overwritten));
16998        let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
16999        assert_ne!(example, "stale");
17000    }
17001
17002    #[test]
17003    fn init_plugins_dir_creates_readme_and_example_layout() {
17004        let tmp = TempDir::new().unwrap();
17005        let dir = tmp.path().join("plugins");
17006        let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
17007            init_plugins_dir(&dir, false).unwrap();
17008
17009        assert_eq!(readme_path, dir.join("README.md"));
17010        assert_eq!(manifest_path, dir.join("example").join("plugin.toml"));
17011        assert_eq!(
17012            skill_path,
17013            dir.join("example/skills/hello").join("SKILL.md")
17014        );
17015        assert!(matches!(readme_status, WriteStatus::Created));
17016        assert!(matches!(manifest_status, WriteStatus::Created));
17017        assert!(matches!(skill_status, WriteStatus::Created));
17018        assert!(readme_path.exists());
17019        assert!(manifest_path.exists());
17020        assert!(skill_path.exists());
17021
17022        let manifest = std::fs::read_to_string(&manifest_path).unwrap();
17023        assert!(manifest.contains("schema_version = 1"));
17024        assert!(manifest.contains("name = \"example\""));
17025        let validated =
17026            crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
17027                .expect("scaffolded plugin should validate");
17028        assert_eq!(validated.inventory.skills, 1);
17029    }
17030
17031    #[test]
17032    fn collect_clean_targets_finds_all_checkpoint_json_files() {
17033        let tmp = TempDir::new().unwrap();
17034        let dir = tmp.path();
17035        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17036        std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17037        // Per-session crash checkpoint files are clean targets too.
17038        std::fs::write(dir.join("some-session-id.json"), "{}").unwrap();
17039        // Non-JSON files and subdirectories are left alone.
17040        std::fs::write(dir.join("notes.txt"), "keep").unwrap();
17041        std::fs::create_dir_all(dir.join("subdir")).unwrap();
17042
17043        let plan = collect_clean_targets(dir);
17044        assert_eq!(plan.targets.len(), 3);
17045        assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
17046        assert!(
17047            plan.targets
17048                .iter()
17049                .any(|p| p.ends_with("offline_queue.json"))
17050        );
17051        assert!(
17052            plan.targets
17053                .iter()
17054                .any(|p| p.ends_with("some-session-id.json"))
17055        );
17056        assert!(!plan.targets.iter().any(|p| p.ends_with("notes.txt")));
17057    }
17058
17059    #[test]
17060    fn execute_clean_plan_removes_files_and_returns_them() {
17061        let tmp = TempDir::new().unwrap();
17062        let dir = tmp.path();
17063        let latest = dir.join("latest.json");
17064        let queue = dir.join("offline_queue.json");
17065        std::fs::write(&latest, "{}").unwrap();
17066        std::fs::write(&queue, "[]").unwrap();
17067
17068        let plan = collect_clean_targets(dir);
17069        let removed = execute_clean_plan(&plan).unwrap();
17070        assert_eq!(removed.len(), 2);
17071        assert!(!latest.exists());
17072        assert!(!queue.exists());
17073    }
17074
17075    #[test]
17076    fn run_setup_clean_dry_run_lists_targets_without_force() {
17077        let tmp = TempDir::new().unwrap();
17078        let dir = tmp.path();
17079        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17080        run_setup_clean(dir, false).unwrap();
17081        // Without --force, files must remain on disk.
17082        assert!(dir.join("latest.json").exists());
17083    }
17084
17085    #[test]
17086    fn run_setup_clean_force_removes_files() {
17087        let tmp = TempDir::new().unwrap();
17088        let dir = tmp.path();
17089        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17090        std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17091        run_setup_clean(dir, true).unwrap();
17092        assert!(!dir.join("latest.json").exists());
17093        assert!(!dir.join("offline_queue.json").exists());
17094    }
17095
17096    #[test]
17097    fn run_setup_clean_handles_missing_dir() {
17098        let tmp = TempDir::new().unwrap();
17099        let dir = tmp.path().join("does-not-exist");
17100        // Should print and return Ok without error.
17101        run_setup_clean(&dir, true).unwrap();
17102        assert!(!dir.exists());
17103    }
17104
17105    fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
17106        let prev_home = std::env::var_os("HOME");
17107        let prev_userprofile = std::env::var_os("USERPROFILE");
17108        unsafe {
17109            std::env::set_var("HOME", home);
17110            std::env::set_var("USERPROFILE", home);
17111        }
17112        let result = f();
17113        unsafe {
17114            match prev_home {
17115                Some(value) => std::env::set_var("HOME", value),
17116                None => std::env::remove_var("HOME"),
17117            }
17118            match prev_userprofile {
17119                Some(value) => std::env::set_var("USERPROFILE", value),
17120                None => std::env::remove_var("USERPROFILE"),
17121            }
17122        }
17123        result
17124    }
17125
17126    #[test]
17127    fn plain_launch_preserves_checkpoint_but_starts_fresh() {
17128        let _guard = crate::test_support::lock_test_env();
17129        let tmp = TempDir::new().unwrap();
17130        let workspace = tmp.path().join("workspace");
17131        std::fs::create_dir_all(&workspace).unwrap();
17132
17133        with_home(tmp.path(), || {
17134            let manager = SessionManager::default_location().expect("manager");
17135            let messages = vec![Message {
17136                role: "user".to_string(),
17137                content: vec![ContentBlock::Text {
17138                    text: "in flight".to_string(),
17139                    cache_control: None,
17140                }],
17141            }];
17142            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17143            let session_id = session.metadata.id.clone();
17144            manager.save_checkpoint(&session).expect("save checkpoint");
17145
17146            preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17147
17148            assert!(
17149                manager
17150                    .load_session_checkpoint(&session_id)
17151                    .expect("load checkpoint")
17152                    .is_some(),
17153                "normal launch must leave the per-session checkpoint in place \
17154                 (it may belong to a live session; `--continue` consumes it)"
17155            );
17156            // #4479: checkpoint is no longer promoted to session file.
17157            assert!(
17158                manager
17159                    .load_session_checkpoint(&session_id)
17160                    .expect("load checkpoint")
17161                    .is_some(),
17162                "checkpoint stays in checkpoints/ for --continue"
17163            );
17164        });
17165    }
17166
17167    #[test]
17168    fn plain_launch_consumes_legacy_checkpoint_after_preserving_it() {
17169        let _guard = crate::test_support::lock_test_env();
17170        let tmp = TempDir::new().unwrap();
17171        let workspace = tmp.path().join("workspace");
17172        std::fs::create_dir_all(&workspace).unwrap();
17173
17174        with_home(tmp.path(), || {
17175            let manager = SessionManager::default_location().expect("manager");
17176            let session = create_saved_session(
17177                &[Message {
17178                    role: "user".to_string(),
17179                    content: vec![ContentBlock::Text {
17180                        text: "legacy in flight".to_string(),
17181                        cache_control: None,
17182                    }],
17183                }],
17184                "test-model",
17185                &workspace,
17186                0,
17187                None,
17188            );
17189            let session_id = session.metadata.id.clone();
17190            write_legacy_checkpoint(&manager, &session);
17191
17192            preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17193
17194            assert!(
17195                manager
17196                    .load_legacy_checkpoint()
17197                    .expect("load legacy checkpoint")
17198                    .is_none(),
17199                "normal launch should consume the legacy single-slot checkpoint"
17200            );
17201            // #4479: checkpoint is no longer promoted to session file.
17202            assert!(
17203                manager
17204                    .load_session_checkpoint(&session_id)
17205                    .expect("load checkpoint")
17206                    .is_some(),
17207                "checkpoint stays in checkpoints/ for --continue"
17208            );
17209        });
17210    }
17211
17212    #[test]
17213    fn continue_recovers_same_workspace_checkpoint() {
17214        let _guard = crate::test_support::lock_test_env();
17215        let tmp = TempDir::new().unwrap();
17216        let workspace = tmp.path().join("workspace");
17217        std::fs::create_dir_all(&workspace).unwrap();
17218
17219        with_home(tmp.path(), || {
17220            let manager = SessionManager::default_location().expect("manager");
17221            let messages = vec![Message {
17222                role: "user".to_string(),
17223                content: vec![ContentBlock::Text {
17224                    text: "continue me".to_string(),
17225                    cache_control: None,
17226                }],
17227            }];
17228            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17229            let session_id = session.metadata.id.clone();
17230            manager.save_checkpoint(&session).expect("save checkpoint");
17231
17232            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17233
17234            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17235            assert!(
17236                manager
17237                    .load_session_checkpoint(&session_id)
17238                    .expect("load checkpoint")
17239                    .is_none(),
17240                "--continue should consume the per-session checkpoint"
17241            );
17242            assert!(manager.load_session(&session_id).is_ok());
17243        });
17244    }
17245
17246    /// Write a legacy single-slot checkpoint file the way pre-cutover
17247    /// binaries did. The current binary only reads this slot.
17248    fn write_legacy_checkpoint(manager: &SessionManager, session: &session_manager::SavedSession) {
17249        let checkpoints = manager.sessions_dir().join("checkpoints");
17250        std::fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
17251        let content = serde_json::to_string_pretty(session).expect("serialize checkpoint");
17252        std::fs::write(checkpoints.join("latest.json"), content).expect("write legacy checkpoint");
17253    }
17254
17255    #[test]
17256    fn continue_recovers_legacy_checkpoint_and_migrates_it() {
17257        let _guard = crate::test_support::lock_test_env();
17258        let tmp = TempDir::new().unwrap();
17259        let workspace = tmp.path().join("workspace");
17260        std::fs::create_dir_all(&workspace).unwrap();
17261
17262        with_home(tmp.path(), || {
17263            let manager = SessionManager::default_location().expect("manager");
17264            let messages = vec![Message {
17265                role: "user".to_string(),
17266                content: vec![ContentBlock::Text {
17267                    text: "legacy continue".to_string(),
17268                    cache_control: None,
17269                }],
17270            }];
17271            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17272            let session_id = session.metadata.id.clone();
17273            write_legacy_checkpoint(&manager, &session);
17274
17275            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17276
17277            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17278            assert!(
17279                manager.load_session(&session_id).is_ok(),
17280                "recovered legacy checkpoint must be loadable as a session"
17281            );
17282            assert!(
17283                manager
17284                    .load_session_checkpoint(&session_id)
17285                    .expect("load per-session checkpoint")
17286                    .is_some(),
17287                "legacy recovery must migrate to a per-session checkpoint file"
17288            );
17289            assert!(
17290                manager
17291                    .load_legacy_checkpoint()
17292                    .expect("load legacy checkpoint")
17293                    .is_some(),
17294                "legacy latest.json stays in place for one more release"
17295            );
17296        });
17297    }
17298
17299    #[test]
17300    fn continue_refuses_checkpoint_from_other_workspace() {
17301        let _guard = crate::test_support::lock_test_env();
17302        let tmp = TempDir::new().unwrap();
17303        let launch_workspace = tmp.path().join("launch-workspace");
17304        let other_workspace = tmp.path().join("other-workspace");
17305        std::fs::create_dir_all(&launch_workspace).unwrap();
17306        std::fs::create_dir_all(&other_workspace).unwrap();
17307
17308        with_home(tmp.path(), || {
17309            let manager = SessionManager::default_location().expect("manager");
17310            let messages = vec![Message {
17311                role: "user".to_string(),
17312                content: vec![ContentBlock::Text {
17313                    text: "belongs elsewhere".to_string(),
17314                    cache_control: None,
17315                }],
17316            }];
17317            let session = create_saved_session(&messages, "test-model", &other_workspace, 0, None);
17318            let session_id = session.metadata.id.clone();
17319            manager.save_checkpoint(&session).expect("save checkpoint");
17320
17321            let recovered = recover_interrupted_checkpoint_for_resume(&launch_workspace);
17322
17323            assert_eq!(recovered, None, "workspace mismatch must refuse recovery");
17324            assert!(
17325                manager
17326                    .load_session_checkpoint(&session_id)
17327                    .expect("load checkpoint")
17328                    .is_some(),
17329                "another workspace's checkpoint file must be left untouched"
17330            );
17331        });
17332    }
17333
17334    #[test]
17335    fn continue_twice_does_not_clobber_newer_session_with_stale_legacy_checkpoint() {
17336        let _guard = crate::test_support::lock_test_env();
17337        let tmp = TempDir::new().unwrap();
17338        let workspace = tmp.path().join("workspace");
17339        std::fs::create_dir_all(&workspace).unwrap();
17340
17341        with_home(tmp.path(), || {
17342            let manager = SessionManager::default_location().expect("manager");
17343            let stale = create_saved_session(
17344                &[Message {
17345                    role: "user".to_string(),
17346                    content: vec![ContentBlock::Text {
17347                        text: "crash-time state".to_string(),
17348                        cache_control: None,
17349                    }],
17350                }],
17351                "test-model",
17352                &workspace,
17353                0,
17354                None,
17355            );
17356            let session_id = stale.metadata.id.clone();
17357            write_legacy_checkpoint(&manager, &stale);
17358
17359            // The session advanced after the checkpoint was taken: a newer
17360            // regular session file exists for the same id.
17361            let mut advanced = stale.clone();
17362            advanced.messages.push(Message {
17363                role: "assistant".to_string(),
17364                content: vec![ContentBlock::Text {
17365                    text: "post-recovery progress".to_string(),
17366                    cache_control: None,
17367                }],
17368            });
17369            advanced.metadata.message_count = advanced.messages.len();
17370            advanced.metadata.updated_at = stale.metadata.updated_at + chrono::Duration::hours(1);
17371            manager.save_session(&advanced).expect("save newer session");
17372
17373            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17374
17375            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17376            let persisted = manager.load_session(&session_id).expect("load session");
17377            assert_eq!(
17378                persisted.messages.len(),
17379                advanced.messages.len(),
17380                "stale checkpoint content must not overwrite the newer session"
17381            );
17382        });
17383    }
17384
17385    #[test]
17386    fn dotenv_status_points_to_example_when_present() {
17387        let tmp = TempDir::new().unwrap();
17388        std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
17389
17390        assert_eq!(
17391            dotenv_status_line(tmp.path()),
17392            ".env not present in workspace (run `cp .env.example .env` and edit)"
17393        );
17394
17395        std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
17396        assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
17397    }
17398
17399    #[test]
17400    fn env_example_is_trackable_and_every_key_is_wired() {
17401        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
17402        let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
17403        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
17404
17405        assert!(gitignore.contains("!.env.example"));
17406
17407        let keys = documented_env_keys(&env_example);
17408        for required in [
17409            "DEEPSEEK_API_KEY",
17410            "NVIDIA_API_KEY",
17411            "NVIDIA_NIM_API_KEY",
17412            "ATLASCLOUD_API_KEY",
17413        ] {
17414            assert!(
17415                keys.contains(required),
17416                ".env.example is missing {required}"
17417            );
17418        }
17419
17420        for key in &keys {
17421            assert!(
17422                is_workspace_dotenv_credential_key(key),
17423                ".env.example documents non-credential control setting {key}"
17424            );
17425        }
17426
17427        let sources = [
17428            include_str!("config.rs"),
17429            include_str!("logging.rs"),
17430            include_str!("../../config/src/lib.rs"),
17431            include_str!("../../config/src/provider.rs"),
17432            include_str!("../../cli/src/main.rs"),
17433        ]
17434        .join("\n");
17435
17436        for key in keys {
17437            assert!(
17438                sources.contains(&key),
17439                ".env.example documents {key}, but no source file references it"
17440            );
17441        }
17442    }
17443
17444    fn documented_env_keys(content: &str) -> BTreeSet<String> {
17445        content
17446            .lines()
17447            .filter_map(|line| {
17448                let trimmed = line.trim();
17449                let uncommented = trimmed
17450                    .strip_prefix('#')
17451                    .map(str::trim_start)
17452                    .unwrap_or(trimmed);
17453                let (key, _) = uncommented.split_once('=')?;
17454                let key = key.trim();
17455                let is_env_key = key
17456                    .chars()
17457                    .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
17458                    && key.chars().any(|ch| ch == '_');
17459                is_env_key.then(|| key.to_string())
17460            })
17461            .collect()
17462    }
17463
17464    #[test]
17465    fn custom_provider_env_source_precedes_saved_secret_store() {
17466        let _lock = crate::test_support::lock_test_env();
17467        let temp = TempDir::new().expect("temp home");
17468        let codewhale_home = temp.path().join("codewhale-home");
17469        std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17470        let _home =
17471            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17472        let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17473        let _declared_env =
17474            crate::test_support::EnvVarGuard::set("QA_CUSTOM_API_KEY", "declared-env-key");
17475        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17476        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17477        codewhale_secrets::Secrets::auto_detect()
17478            .set("custom", "saved-custom-secret")
17479            .expect("save secret");
17480
17481        let mut custom = std::collections::HashMap::new();
17482        custom.insert(
17483            "qa-gateway".to_string(),
17484            crate::config::ProviderConfig {
17485                kind: Some("openai-compatible".to_string()),
17486                base_url: Some("https://gateway.example.test/v1".to_string()),
17487                model: Some("qa-model".to_string()),
17488                api_key_env: Some("QA_CUSTOM_API_KEY".to_string()),
17489                ..Default::default()
17490            },
17491        );
17492        let config = Config {
17493            provider: Some("qa-gateway".to_string()),
17494            providers: Some(crate::config::ProvidersConfig {
17495                custom,
17496                ..Default::default()
17497            }),
17498            ..Config::default()
17499        };
17500
17501        assert_eq!(resolve_api_key_source(&config), ApiKeySource::EnvDeclared);
17502        assert_eq!(
17503            config.deepseek_api_key().expect("custom key"),
17504            "declared-env-key"
17505        );
17506    }
17507
17508    #[test]
17509    fn named_custom_provider_does_not_report_generic_secret_store() {
17510        let _lock = crate::test_support::lock_test_env();
17511        let temp = TempDir::new().expect("temp home");
17512        let codewhale_home = temp.path().join("codewhale-home");
17513        std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17514        let _home =
17515            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17516        let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17517        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17518        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17519        codewhale_secrets::Secrets::auto_detect()
17520            .set("custom", "unrelated-custom-secret")
17521            .expect("save secret");
17522
17523        let mut custom = std::collections::HashMap::new();
17524        custom.insert(
17525            "qa-gateway".to_string(),
17526            crate::config::ProviderConfig {
17527                kind: Some("openai-compatible".to_string()),
17528                base_url: Some("https://gateway.example.test/v1".to_string()),
17529                model: Some("qa-model".to_string()),
17530                auth_mode: Some("api_key".to_string()),
17531                ..Default::default()
17532            },
17533        );
17534        let config = Config {
17535            provider: Some("qa-gateway".to_string()),
17536            providers: Some(crate::config::ProvidersConfig {
17537                custom,
17538                ..Default::default()
17539            }),
17540            ..Config::default()
17541        };
17542
17543        assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17544        assert!(config.deepseek_api_key().is_err());
17545    }
17546
17547    #[test]
17548    fn custom_built_in_endpoint_does_not_report_ambient_provider_key() {
17549        let _lock = crate::test_support::lock_test_env();
17550        let _openrouter =
17551            crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17552        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17553        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17554        let mut providers = crate::config::ProvidersConfig::default();
17555        providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
17556        let config = Config {
17557            provider: Some("openrouter".to_string()),
17558            providers: Some(providers),
17559            ..Config::default()
17560        };
17561
17562        assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17563        assert!(config.deepseek_api_key().is_err());
17564    }
17565
17566    #[test]
17567    fn auth_mode_none_reports_distinct_no_auth_source_and_scheme() {
17568        let _lock = crate::test_support::lock_test_env();
17569        let _openrouter =
17570            crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17571        let mut providers = crate::config::ProvidersConfig::default();
17572        providers.openrouter.auth_mode = Some("none".to_string());
17573        providers.openrouter.api_key = Some("configured-key".to_string());
17574        let config = Config {
17575            provider: Some("openrouter".to_string()),
17576            providers: Some(providers),
17577            ..Config::default()
17578        };
17579
17580        assert_eq!(resolve_api_key_source(&config), ApiKeySource::NoAuth);
17581        assert_eq!(doctor_api_key_source_label(ApiKeySource::NoAuth), "none");
17582        assert_eq!(doctor_auth_scheme(&config), "none");
17583        assert_eq!(config.deepseek_api_key().expect("no-auth route"), "");
17584    }
17585
17586    #[test]
17587    fn resolve_api_key_source_prefers_config_over_env() {
17588        let _guard = crate::test_support::lock_test_env();
17589        let prev = std::env::var("DEEPSEEK_API_KEY").ok();
17590        let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
17591        unsafe {
17592            std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
17593            std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
17594        }
17595        let cfg = Config {
17596            api_key: Some("fresh-config-key".to_string()),
17597            ..Config::default()
17598        };
17599        let source = resolve_api_key_source(&cfg);
17600        match prev {
17601            Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
17602            None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
17603        }
17604        match prev_source {
17605            Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
17606            None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
17607        }
17608        assert_eq!(source, ApiKeySource::ConfigDeclared);
17609    }
17610
17611    #[test]
17612    fn resolve_api_key_source_reports_active_provider_env_from_metadata() {
17613        let _guard = crate::test_support::lock_test_env();
17614        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17615        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17616        let _anthropic_key =
17617            crate::test_support::EnvVarGuard::set("ANTHROPIC_API_KEY", "test-anthropic-key");
17618        let cfg = Config {
17619            provider: Some("anthropic".to_string()),
17620            ..Config::default()
17621        };
17622
17623        let source = resolve_api_key_source(&cfg);
17624
17625        assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
17626    }
17627
17628    #[test]
17629    fn resolve_api_key_source_ignores_unresolved_provider_command_metadata() {
17630        let _guard = crate::test_support::lock_test_env();
17631        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17632        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17633        let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17634        let mut providers = crate::config::ProvidersConfig::default();
17635        providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17636            source: codewhale_config::AuthSourceKind::Command,
17637            command: vec!["secret-tool".to_string(), "lookup".to_string()],
17638            timeout_ms: Some(2000),
17639            secret_id: None,
17640        });
17641        let cfg = Config {
17642            provider: Some("openai".to_string()),
17643            providers: Some(providers),
17644            ..Config::default()
17645        };
17646
17647        let source = resolve_api_key_source(&cfg);
17648
17649        assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
17650        assert!(cfg.deepseek_api_key().is_err());
17651    }
17652
17653    #[test]
17654    fn resolve_api_key_source_ignores_unresolved_provider_secret_metadata() {
17655        let _guard = crate::test_support::lock_test_env();
17656        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17657        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17658        let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17659        let mut providers = crate::config::ProvidersConfig::default();
17660        providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17661            source: codewhale_config::AuthSourceKind::Secret,
17662            command: Vec::new(),
17663            timeout_ms: None,
17664            secret_id: Some("codewhale/openai".to_string()),
17665        });
17666        let cfg = Config {
17667            provider: Some("openai".to_string()),
17668            providers: Some(providers),
17669            ..Config::default()
17670        };
17671
17672        let source = resolve_api_key_source(&cfg);
17673
17674        assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
17675        assert!(cfg.deepseek_api_key().is_err());
17676    }
17677
17678    #[test]
17679    fn resolve_api_key_source_ignores_root_deepseek_key_for_other_provider() {
17680        let _guard = crate::test_support::lock_test_env();
17681        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17682        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17683        let _openrouter_key = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
17684        let cfg = Config {
17685            provider: Some("openrouter".to_string()),
17686            api_key: Some("legacy-deepseek-root-key".to_string()),
17687            ..Config::default()
17688        };
17689
17690        let source = resolve_api_key_source(&cfg);
17691
17692        assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
17693    }
17694
17695    #[test]
17696    fn provider_status_helpers_use_provider_metadata() {
17697        assert_eq!(
17698            provider_config_table_key(crate::config::ApiProvider::Anthropic),
17699            "anthropic"
17700        );
17701        assert_eq!(
17702            provider_config_table_key(crate::config::ApiProvider::SiliconflowCn),
17703            "siliconflow_cn"
17704        );
17705    }
17706
17707    #[test]
17708    fn skills_count_for_returns_zero_for_missing_dir() {
17709        let tmp = TempDir::new().unwrap();
17710        let dir = tmp.path().join("nope");
17711        assert_eq!(skills_count_for(&dir), 0);
17712    }
17713
17714    #[test]
17715    fn skills_count_for_counts_valid_skill_dirs() {
17716        let tmp = TempDir::new().unwrap();
17717        let dir = tmp.path().join("skills");
17718        let skill_dir = dir.join("getting-started");
17719        std::fs::create_dir_all(&skill_dir).unwrap();
17720        std::fs::write(
17721            skill_dir.join("SKILL.md"),
17722            "---\nname: getting-started\ndescription: hi\n---\nbody",
17723        )
17724        .unwrap();
17725        assert_eq!(skills_count_for(&dir), 1);
17726    }
17727}
17728
17729#[cfg(test)]
17730#[path = "tests/pr_prompt.rs"]
17731mod pr_prompt_tests;
17732
17733#[cfg(test)]
17734#[path = "tests/telemetry_surface.rs"]
17735mod telemetry_surface_tests;
17736
17737#[cfg(test)]
17738#[path = "tests/telemetry_counters.rs"]
17739mod telemetry_counter_tests;