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 tool_history_repair;
139mod tool_inspection;
140mod tool_output_receipts;
141mod tools;
142mod tui;
143mod turn_route_plan;
144mod utils;
145mod vision;
146mod work_graph;
147mod work_grounding;
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            crate::tui::ui::emergency_restore_terminal();
697            // Nothing async survives the `exit` below, so this is the last
698            // chance to say how the session ended. `record_blocking` is one
699            // `O_APPEND` write with no lock: taking the compaction lock here
700            // would let a second Codewhale process sharing CODEWHALE_HOME hang
701            // Ctrl-C, and the second-signal short-circuit below has to stay
702            // reachable. A no-op unless this process was armed.
703            //
704            // The class is stated, not derived: `RunTerminationReason::Canceled`
705            // also exits 130, so `exit_code` cannot tell a signal from an
706            // Esc-cancelled turn.
707            record_signal_session_end();
708        }
709        std::process::exit(exit_code);
710    });
711}
712
713/// When this process's armed telemetry session began. Set once, at arming, and
714/// read from both the ordinary teardown and the signal path.
715static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
716    std::sync::OnceLock::new();
717
718/// Build `session_end` from what this process actually accumulated.
719///
720/// The exit class is read from the process-wide atomic and never derived from
721/// an exit code: `RunTerminationReason::Canceled` maps to 130, the same value
722/// the SIGINT path uses, so a code-based derivation would report every
723/// Esc-cancelled turn as a signal.
724///
725/// The cold-start bucket is `None` unless the interactive event loop actually
726/// began, which is what keeps it absent rather than invented on the surfaces
727/// that have no event loop.
728fn telemetry_session_end() -> codewhale_telemetry::Event {
729    let counters = codewhale_telemetry::session_counters();
730    codewhale_telemetry::Event::SessionEnd {
731        duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
732            TELEMETRY_SESSION_START
733                .get()
734                .map_or(0, |start| start.elapsed().as_secs()),
735        ),
736        exit_class: codewhale_telemetry::exit_class(),
737        cold_start_bucket: crate::startup_trace::cold_start_ms()
738            .map(codewhale_telemetry::ColdStartBucket::from_millis),
739        providers: counters.providers(),
740        counters: counters.counters(),
741        errors: counters.errors(),
742        turn_wall: counters.turn_wall(),
743    }
744}
745
746/// Close the session synchronously, from the signal handler.
747///
748/// A no-op unless this process was armed.
749fn record_signal_session_end() {
750    codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
751    codewhale_telemetry::record_blocking(telemetry_session_end());
752}
753
754/// Terminating-signal streams, registered up front and awaited later.
755///
756/// Splitting registration from the await is the point: the OS disposition
757/// changes when `register` returns, not when the waiting task is first polled.
758#[cfg(unix)]
759struct TerminatingSignals {
760    sigint: Option<tokio::signal::unix::Signal>,
761    sigterm: Option<tokio::signal::unix::Signal>,
762    sighup: Option<tokio::signal::unix::Signal>,
763}
764
765#[cfg(unix)]
766impl TerminatingSignals {
767    /// Install the handlers. Failing to install any individual stream is
768    /// non-fatal: we still want the others to work.
769    fn register() -> Self {
770        use tokio::signal::unix::{SignalKind, signal};
771        Self {
772            sigint: signal(SignalKind::interrupt()).ok(),
773            sigterm: signal(SignalKind::terminate()).ok(),
774            sighup: signal(SignalKind::hangup()).ok(),
775        }
776    }
777
778    /// Resolve with 128 + signal number for whichever arrives first. The
779    /// fallback never-resolving future keeps `select!` well-typed when a
780    /// stream failed to register.
781    async fn wait(mut self) -> i32 {
782        tokio::select! {
783            _ = async { match self.sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130,
784            _ = async { match self.sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143,
785            _ = async { match self.sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129,
786        }
787    }
788}
789
790/// Windows: `ctrl_c` covers both Ctrl+C and Ctrl+Break (CTRL_C_EVENT /
791/// CTRL_BREAK_EVENT). Console-close, logoff, and shutdown events are not
792/// currently routed through tokio.
793#[cfg(not(unix))]
794struct TerminatingSignals {
795    ctrl_c: Option<tokio::signal::windows::CtrlC>,
796}
797
798#[cfg(not(unix))]
799impl TerminatingSignals {
800    fn register() -> Self {
801        Self {
802            ctrl_c: tokio::signal::windows::ctrl_c().ok(),
803        }
804    }
805
806    async fn wait(mut self) -> i32 {
807        match self.ctrl_c.as_mut() {
808            Some(s) => {
809                s.recv().await;
810            }
811            None => std::future::pending::<()>().await,
812        }
813        130
814    }
815}
816
817fn join_prompt_parts(parts: &[String]) -> String {
818    parts.join(" ")
819}
820
821fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String {
822    explicit_model
823        .map(str::trim)
824        .filter(|model| !model.is_empty())
825        .map(ToOwned::to_owned)
826        .or_else(exec_model_env_override)
827        .unwrap_or_else(|| config.default_model())
828}
829
830fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> {
831    let provider_arg = provider_arg.trim();
832    if provider_arg.is_empty() {
833        return Ok(());
834    }
835    if config
836        .providers
837        .as_ref()
838        .and_then(|providers| providers.custom_provider_config(provider_arg))
839        .is_some()
840    {
841        config.provider = Some(provider_arg.to_string());
842        return Ok(());
843    }
844    if let Some(provider) = crate::config::ApiProvider::parse(provider_arg) {
845        config.provider = Some(provider.as_str().to_string());
846        return Ok(());
847    }
848    bail!(
849        "Unrecognized --provider {provider_arg:?}. Known providers: {} \
850         or a configured [providers.<name>] custom provider",
851        crate::config::ApiProvider::names_hint()
852    );
853}
854
855fn exec_model_env_override() -> Option<String> {
856    let read = || {
857        ["CODEWHALE_MODEL", "DEEPSEEK_MODEL"]
858            .into_iter()
859            .find_map(|key| {
860                std::env::var(key)
861                    .ok()
862                    .map(|model| model.trim().to_string())
863                    .filter(|model| !model.is_empty())
864            })
865    };
866    #[cfg(test)]
867    {
868        crate::test_support::with_test_env_lock(read)
869    }
870    #[cfg(not(test))]
871    {
872        read()
873    }
874}
875
876fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
877    (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
878}
879
880fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
881    if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
882        return Ok(Some(id.clone()));
883    }
884    if !args.continue_session {
885        return Ok(None);
886    }
887    latest_session_id_for_workspace(workspace)?.map_or_else(
888        || {
889            bail!(
890                "No saved sessions found for workspace {}. Use `codewhale sessions` to list sessions, or pass `codewhale exec --resume <SESSION_ID> ...`.",
891                workspace.display()
892            )
893        },
894        |id| Ok(Some(id)),
895    )
896}
897
898fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
899    let session_ref = exec_stream_session_ref(session_id);
900    SessionManager::default_location()
901        .context("could not open session manager for resume")?
902        .load_session_by_prefix(session_id)
903        .with_context(|| format!("could not load session {session_ref}"))
904}
905
906/// Select the route for `exec --resume` before any engine/client is built.
907///
908/// Precedence is intentionally field-aware:
909/// - no explicit `--provider` or `--model`: restore the saved provider/model;
910/// - explicit `--provider`: keep that route and use its configured/default model
911///   unless `--model` is also present;
912/// - explicit `--model` alone: restore the saved provider, then use that model.
913fn resolve_exec_resume_route(
914    config: &mut Config,
915    saved: &session_manager::SavedSession,
916    explicit_provider: bool,
917    explicit_model: Option<&str>,
918) -> Result<String> {
919    if !explicit_provider {
920        let saved_provider_identity = saved
921            .metadata
922            .model_provider_id
923            .as_deref()
924            .filter(|identity| !identity.trim().is_empty())
925            .unwrap_or(&saved.metadata.model_provider);
926        let identity = config
927            .resolve_persisted_provider_identity(
928                Some(&saved.metadata.model_provider),
929                saved.metadata.model_provider_id.as_deref(),
930            )
931            .map_err(anyhow::Error::msg)
932            .with_context(|| {
933                format!(
934                    "saved session provider '{}' is unavailable; Codewhale will not fall back",
935                    saved_provider_identity
936                )
937            })?;
938        config.scope_to_provider_identity(&identity);
939    }
940
941    if let Some(model) = explicit_model {
942        return Ok(resolve_exec_model(config, Some(model)));
943    }
944    if explicit_provider {
945        return Ok(resolve_exec_model(config, None));
946    }
947    Ok(saved.metadata.model.clone())
948}
949
950#[derive(Args, Debug, Clone, Default)]
951struct SetupArgs {
952    /// Initialize MCP configuration at the configured path
953    #[arg(long, default_value_t = false)]
954    mcp: bool,
955    /// Initialize skills directory and an example skill
956    #[arg(long, default_value_t = false)]
957    skills: bool,
958    /// Initialize tools directory with a self-describing example script
959    #[arg(long, default_value_t = false)]
960    tools: bool,
961    /// Initialize plugins directory with a self-describing example
962    #[arg(long, default_value_t = false)]
963    plugins: bool,
964    /// Initialize MCP config, skills, tools, and plugins
965    #[arg(long, default_value_t = false)]
966    all: bool,
967    /// Create a local workspace skills directory (./skills)
968    #[arg(long, default_value_t = false)]
969    local: bool,
970    /// Overwrite existing template files
971    #[arg(long, default_value_t = false)]
972    force: bool,
973    /// Print a compact, read-only status report (no network calls)
974    #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
975    status: bool,
976    /// Remove regenerable session checkpoints (latest + offline_queue)
977    #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
978    clean: bool,
979}
980
981#[derive(Args, Debug, Clone, Default)]
982struct DoctorArgs {
983    /// Emit machine-readable structural JSON output (always offline)
984    #[arg(long, default_value_t = false)]
985    json: bool,
986    /// Emit only the diagnostic context source map as JSON
987    #[arg(long, default_value_t = false, conflicts_with = "json")]
988    context_json: bool,
989    /// Opt in to probing a local provider endpoint (may start a local service)
990    #[arg(
991        long,
992        default_value_t = false,
993        conflicts_with_all = ["json", "context_json"]
994    )]
995    probe_local: bool,
996    /// Opt in to probing the configured hosted provider API
997    #[arg(
998        long,
999        default_value_t = false,
1000        conflicts_with_all = ["json", "context_json"]
1001    )]
1002    probe_api: bool,
1003    /// Opt in to contacting the release service for an update check
1004    #[arg(
1005        long,
1006        default_value_t = false,
1007        conflicts_with_all = ["json", "context_json"]
1008    )]
1009    check_updates: bool,
1010    /// Opt in to starting enabled MCP servers and checking process/protocol reachability
1011    #[arg(
1012        long,
1013        default_value_t = false,
1014        conflicts_with_all = ["json", "context_json"]
1015    )]
1016    probe_mcp: bool,
1017}
1018
1019#[derive(Args, Debug, Clone)]
1020struct SessionDiagnosticsArgs {
1021    /// JSONL session log to inspect
1022    #[arg(value_name = "JSONL")]
1023    path: PathBuf,
1024    /// Emit machine-readable JSON with redacted source handles
1025    #[arg(long, default_value_t = false)]
1026    json: bool,
1027}
1028
1029#[derive(Args, Debug, Clone)]
1030struct ScorecardArgs {
1031    /// JSON file with the recorded turns to score: an array of
1032    /// `{ "turn_id", "provider", "model", "billing_surface", "usage": {…} }`.
1033    /// `turn_end` hooks emit this route provenance plus `created_at`; persisted
1034    /// runtime exports may instead use `id`, `effective_provider`,
1035    /// `effective_model`, and `effective_billing_surface`.
1036    /// Shell-only hook rows marked `model_backed: false` are excluded. Legacy
1037    /// rows without provider remain readable but their cost is unavailable.
1038    #[arg(long, value_name = "FILE")]
1039    input: PathBuf,
1040    /// Optional baseline scorecard-metrics JSON to compare against. When set,
1041    /// the command exits non-zero if any metric regresses past the threshold.
1042    #[arg(long, value_name = "FILE")]
1043    baseline: Option<PathBuf>,
1044    /// Regression threshold, in percent increase over the baseline.
1045    #[arg(long, default_value_t = 5.0)]
1046    threshold: f64,
1047    /// Emit machine-readable JSON instead of the human summary.
1048    #[arg(long, default_value_t = false)]
1049    json: bool,
1050}
1051
1052#[derive(Args, Debug, Clone)]
1053struct EvalArgs {
1054    /// Intentionally fail a specific step (list, read, search, edit, patch, shell)
1055    #[arg(long, value_name = "STEP")]
1056    fail_step: Option<String>,
1057    /// Shell command to run during the exec step
1058    #[arg(long, default_value = "printf eval-harness")]
1059    shell_command: String,
1060    /// Token that must appear in shell output for validation
1061    #[arg(long, default_value = "eval-harness")]
1062    shell_expect_token: String,
1063    /// Maximum characters stored per step output summary
1064    #[arg(long, default_value_t = 240)]
1065    max_output_chars: usize,
1066    /// Emit machine-readable JSON output
1067    #[arg(long, default_value_t = false)]
1068    json: bool,
1069    /// Append one JSONL fixture line per step to `<DIR>/<scenario>.jsonl`.
1070    /// Mock LLM tests can later replay these fixtures.
1071    #[arg(long, value_name = "DIR")]
1072    record: Option<PathBuf>,
1073}
1074
1075#[derive(Args, Debug, Clone, Default)]
1076struct ModelsArgs {
1077    /// Print models as pretty JSON
1078    #[arg(long, default_value_t = false)]
1079    json: bool,
1080}
1081
1082#[derive(Args, Debug, Clone)]
1083struct SpeechArgs {
1084    /// Text to synthesize. This is sent as the assistant message content.
1085    #[arg(value_name = "TEXT")]
1086    text: String,
1087
1088    /// Output audio path. Defaults to `speech.<format>` in `--output-dir`,
1089    /// `[speech].output_dir`, or the current directory.
1090    #[arg(short, long, value_name = "FILE")]
1091    output: Option<PathBuf>,
1092
1093    /// Directory for the default `speech.<format>` output file when `-o`/`--output` is omitted.
1094    #[arg(long = "output-dir", value_name = "DIR")]
1095    output_dir: Option<PathBuf>,
1096
1097    /// TTS model. Defaults to built-in voices, or is inferred from --voice-prompt/--clone-voice.
1098    #[arg(long)]
1099    model: Option<String>,
1100
1101    /// Built-in voice ID, or a data:audio/...;base64,... URI for voice clone.
1102    #[arg(long)]
1103    voice: Option<String>,
1104
1105    /// Natural language style instruction; not spoken verbatim.
1106    #[arg(long)]
1107    instruction: Option<String>,
1108
1109    /// Voice design prompt. Implies mimo-v2.5-tts-voicedesign when --model is omitted.
1110    #[arg(long = "voice-prompt")]
1111    voice_prompt: Option<String>,
1112
1113    /// MP3/WAV sample used for voice cloning. Implies mimo-v2.5-tts-voiceclone when --model is omitted.
1114    #[arg(long = "clone-voice", value_name = "FILE")]
1115    clone_voice: Option<PathBuf>,
1116
1117    /// Output audio format requested from the API
1118    #[arg(long, default_value = "wav")]
1119    format: String,
1120
1121    /// Emit machine-readable JSON output
1122    #[arg(long, default_value_t = false)]
1123    json: bool,
1124}
1125
1126#[derive(Args, Debug, Default, Clone)]
1127struct FeatureToggles {
1128    /// Enable a feature (repeatable). Equivalent to `features.<name>=true`.
1129    #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1130    enable: Vec<String>,
1131
1132    /// Disable a feature (repeatable). Equivalent to `features.<name>=false`.
1133    #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1134    disable: Vec<String>,
1135}
1136
1137impl FeatureToggles {
1138    fn apply(&self, config: &mut Config) -> Result<()> {
1139        for feature in &self.enable {
1140            config.set_feature(feature, true)?;
1141        }
1142        for feature in &self.disable {
1143            config.set_feature(feature, false)?;
1144        }
1145        Ok(())
1146    }
1147}
1148
1149#[derive(Args, Debug, Clone)]
1150struct ReviewArgs {
1151    /// Review staged changes instead of the working tree
1152    #[arg(long, conflicts_with = "base")]
1153    staged: bool,
1154    /// Base ref to diff against (e.g. origin/main)
1155    #[arg(long)]
1156    base: Option<String>,
1157    /// Limit diff to a specific path
1158    #[arg(long)]
1159    path: Option<PathBuf>,
1160    /// Override model for this review
1161    #[arg(long)]
1162    model: Option<String>,
1163    /// Maximum diff characters to include
1164    #[arg(long, default_value_t = 200_000)]
1165    max_chars: usize,
1166    /// Write a durable pre-push review receipt after a successful review
1167    #[arg(long, default_value_t = false)]
1168    write_receipt: bool,
1169    /// Validate the current diff against a durable review receipt without calling a model
1170    #[arg(long, default_value_t = false)]
1171    check_receipt: bool,
1172    /// Override where the review receipt is written or read
1173    #[arg(long)]
1174    receipt_path: Option<PathBuf>,
1175    /// Emit machine-readable JSON output
1176    #[arg(long, default_value_t = false)]
1177    json: bool,
1178}
1179
1180#[derive(Args, Debug, Clone)]
1181struct ApplyArgs {
1182    /// Patch file to apply (defaults to stdin)
1183    #[arg(value_name = "PATCH_FILE")]
1184    patch_file: Option<PathBuf>,
1185}
1186
1187#[derive(Args, Debug, Clone)]
1188struct ServeArgs {
1189    /// Start MCP server over stdio
1190    #[arg(long)]
1191    mcp: bool,
1192    /// Start runtime HTTP/SSE API server
1193    #[arg(long)]
1194    http: bool,
1195    /// Start runtime HTTP/SSE API server with the built-in mobile control page
1196    #[arg(long)]
1197    mobile: bool,
1198    /// Start the embedded loopback-only browser client and open it
1199    #[arg(long)]
1200    web: bool,
1201    /// Show a QR code for the mobile URL in the terminal (requires --mobile)
1202    #[arg(long, requires = "mobile")]
1203    qr: bool,
1204    /// Start ACP server over stdio for editor clients such as Zed
1205    #[arg(long)]
1206    acp: bool,
1207    /// Bind host for HTTP server (default localhost; --mobile defaults to 0.0.0.0)
1208    #[arg(long)]
1209    host: Option<String>,
1210    /// Bind port for HTTP server
1211    #[arg(long, default_value_t = 7878)]
1212    port: u16,
1213    /// Background task worker count (1-8)
1214    #[arg(long, default_value_t = 2)]
1215    workers: usize,
1216    /// Additional CORS origin to allow (repeatable). Stacks on top of the
1217    /// built-in defaults (localhost:3000, localhost:1420, tauri://localhost).
1218    /// Also reads `CODEWHALE_CORS_ORIGINS` (comma-separated), then
1219    /// `DEEPSEEK_CORS_ORIGINS` as an alias, and `[runtime_api] cors_origins`
1220    /// from `config.toml`. Whalescale#255.
1221    #[arg(long = "cors-origin", value_name = "URL")]
1222    cors_origin: Vec<String>,
1223    /// Require this bearer token for `/v1/*` runtime API routes. Also reads
1224    /// `CODEWHALE_RUNTIME_TOKEN` when omitted, then `DEEPSEEK_RUNTIME_TOKEN`
1225    /// as an alias.
1226    #[arg(long = "auth-token", value_name = "TOKEN")]
1227    auth_token: Option<String>,
1228    /// Disable runtime API auth when no token is configured. Only use on a trusted loopback.
1229    #[arg(long = "insecure")]
1230    insecure_no_auth: bool,
1231}
1232
1233#[derive(Debug, Clone, PartialEq, Eq)]
1234struct ServeBindHost {
1235    host: String,
1236    mobile_rebound_to_lan: bool,
1237}
1238
1239fn resolve_serve_bind_host(mobile: bool, host: Option<String>) -> ServeBindHost {
1240    match (mobile, host) {
1241        (true, None) => ServeBindHost {
1242            host: "0.0.0.0".to_string(),
1243            mobile_rebound_to_lan: true,
1244        },
1245        (_, Some(host)) => ServeBindHost {
1246            host,
1247            mobile_rebound_to_lan: false,
1248        },
1249        (false, None) => ServeBindHost {
1250            host: "127.0.0.1".to_string(),
1251            mobile_rebound_to_lan: false,
1252        },
1253    }
1254}
1255
1256fn validate_serve_mode_selection(
1257    mcp: bool,
1258    http: bool,
1259    mobile: bool,
1260    web: bool,
1261    acp: bool,
1262) -> Result<bool> {
1263    if http && mobile {
1264        bail!("--http and --mobile are mutually exclusive; choose one");
1265    }
1266    if web && (http || mobile) {
1267        bail!("--web is mutually exclusive with --http and --mobile");
1268    }
1269    let http_selected = http || mobile || web;
1270    let selected_modes = [mcp, http_selected, acp]
1271        .into_iter()
1272        .filter(|selected| *selected)
1273        .count();
1274    if selected_modes != 1 {
1275        bail!("Choose exactly one server mode: --mcp, --http/--mobile/--web, or --acp");
1276    }
1277    Ok(http_selected)
1278}
1279
1280#[derive(Subcommand, Debug, Clone)]
1281enum McpCommand {
1282    /// List configured MCP servers
1283    List,
1284    /// Create a template MCP config at the configured path
1285    Init {
1286        /// Overwrite an existing MCP config file
1287        #[arg(long, default_value_t = false)]
1288        force: bool,
1289    },
1290    /// Connect to MCP servers and report status
1291    Connect {
1292        /// Optional server name to connect to
1293        #[arg(value_name = "SERVER")]
1294        server: Option<String>,
1295    },
1296    /// List tools discovered from MCP servers
1297    Tools {
1298        /// Optional server name to list tools for
1299        #[arg(value_name = "SERVER")]
1300        server: Option<String>,
1301    },
1302    /// Add an MCP server entry
1303    Add {
1304        /// Server name
1305        name: String,
1306        /// Command to launch stdio server
1307        #[arg(long, conflicts_with = "url")]
1308        command: Option<String>,
1309        /// URL for streamable HTTP/SSE server
1310        #[arg(long, conflicts_with = "command")]
1311        url: Option<String>,
1312        /// Explicit URL transport override. Use "sse" for legacy SSE endpoints.
1313        #[arg(long, requires = "url")]
1314        transport: Option<String>,
1315        /// Environment variable containing a bearer token for URL-based servers
1316        #[arg(long, requires = "url")]
1317        bearer_token_env_var: Option<String>,
1318        /// OAuth client ID for servers that do not support dynamic registration
1319        #[arg(long, requires = "url")]
1320        oauth_client_id: Option<String>,
1321        /// OAuth resource parameter to append to the authorization URL
1322        #[arg(long, requires = "url")]
1323        oauth_resource: Option<String>,
1324        /// OAuth scope to request during login. Repeat or comma-separate.
1325        #[arg(long = "scope", requires = "url", value_delimiter = ',')]
1326        scopes: Vec<String>,
1327        /// Arguments for command-based servers
1328        #[arg(long = "arg")]
1329        args: Vec<String>,
1330    },
1331    /// Authenticate to a URL-based MCP server using OAuth
1332    Login {
1333        /// Server name
1334        name: String,
1335        /// OAuth scope to request. Repeat or comma-separate; defaults to config/discovery.
1336        #[arg(long = "scope", value_delimiter = ',')]
1337        scopes: Vec<String>,
1338    },
1339    /// Delete stored OAuth credentials for a URL-based MCP server
1340    Logout {
1341        /// Server name
1342        name: String,
1343    },
1344    /// Remove an MCP server entry
1345    Remove {
1346        /// Server name
1347        name: String,
1348    },
1349    /// Enable an MCP server
1350    Enable {
1351        /// Server name
1352        name: String,
1353    },
1354    /// Disable an MCP server
1355    Disable {
1356        /// Server name
1357        name: String,
1358    },
1359    /// Validate MCP config and required servers
1360    Validate,
1361    /// Register this Codewhale binary as a local MCP stdio server.
1362    ///
1363    /// This adds a config entry that runs `codewhale serve --mcp` (stdio protocol).
1364    /// For the HTTP/SSE runtime API, use `codewhale serve --http` directly instead.
1365    #[command(
1366        name = "add-self",
1367        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."
1368    )]
1369    AddSelf {
1370        /// Server name in mcp.json (default: "codewhale")
1371        #[arg(long, default_value = "codewhale")]
1372        name: String,
1373        /// Workspace directory for the MCP server
1374        #[arg(long)]
1375        workspace: Option<String>,
1376    },
1377}
1378
1379#[derive(Args, Debug, Clone)]
1380struct FeaturesCli {
1381    #[command(subcommand)]
1382    command: FeaturesSubcommand,
1383}
1384
1385#[derive(Subcommand, Debug, Clone)]
1386enum FeaturesSubcommand {
1387    /// List known feature flags and their state
1388    List,
1389}
1390
1391#[derive(Args, Debug, Clone)]
1392struct SandboxArgs {
1393    #[command(subcommand)]
1394    command: SandboxCommand,
1395}
1396
1397#[derive(Subcommand, Debug, Clone)]
1398enum SandboxCommand {
1399    /// Run a command with sandboxing
1400    Run {
1401        /// Sandbox policy (danger-full-access, read-only, external-sandbox, workspace-write)
1402        #[arg(long, default_value = "workspace-write")]
1403        policy: String,
1404        /// Allow outbound network access
1405        #[arg(long)]
1406        network: bool,
1407        /// Additional writable roots (repeatable)
1408        #[arg(long, value_name = "PATH")]
1409        writable_root: Vec<PathBuf>,
1410        /// Exclude TMPDIR from writable paths
1411        #[arg(long)]
1412        exclude_tmpdir: bool,
1413        /// Exclude /tmp from writable paths
1414        #[arg(long)]
1415        exclude_slash_tmp: bool,
1416        /// Command working directory
1417        #[arg(long)]
1418        cwd: Option<PathBuf>,
1419        /// Timeout in milliseconds
1420        #[arg(long, default_value_t = 60_000)]
1421        timeout_ms: u64,
1422        /// Command and arguments to run
1423        #[arg(required = true, trailing_var_arg = true)]
1424        command: Vec<String>,
1425    },
1426}
1427
1428const CODEWHALE_MAIN_STACK_BYTES: usize = 16 * 1024 * 1024;
1429
1430/// Entry point for the single binary. Takes argv including binary name at 0,
1431/// parses with clap, and runs the TUI/runtime dispatch. Returns process exit
1432/// code for the caller to exit with.
1433pub fn run(args: Vec<String>) -> std::process::ExitCode {
1434    match run_with_args(args) {
1435        Ok(()) => std::process::ExitCode::SUCCESS,
1436        Err(err) => {
1437            eprintln!("error: {err}");
1438            for cause in err.chain().skip(1) {
1439                eprintln!("  caused by: {cause}");
1440            }
1441            std::process::ExitCode::FAILURE
1442        }
1443    }
1444}
1445
1446/// Internal implementation that mirrors the old `main()` but takes explicit
1447/// args instead of reading `std::env::args()`. Used by `run()` and tested
1448/// directly.
1449fn run_with_args(args: Vec<String>) -> Result<()> {
1450    // Match the dispatcher entrypoint: Unix shells and supervisors may inherit
1451    // SIGPIPE ignored, which turns short pipelines such as `codewhale doctor |
1452    // head` into BrokenPipe panics once this delegated TUI binary prints.
1453    #[cfg(unix)]
1454    unsafe {
1455        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1456    }
1457
1458    startup_trace::mark_process_start();
1459    configure_windows_console_utf8();
1460    install_rustls_crypto_provider();
1461
1462    // ── Process hardening (#2183) ─────────────────────────────────────────
1463    // MUST run before Tokio is booted and before any threads are spawned.
1464    // See crates/tui/src/sandbox/process_hardening.rs for ordering rationale.
1465    crate::sandbox::process_hardening::apply_process_hardening();
1466
1467    // Set up process panic hook before anything else — writes crash dumps
1468    // to ~/.deepseek/crashes/ even if the panic happens before tokio is up,
1469    // and restores the terminal so a panicked TUI doesn't leave the user's
1470    // shell stuck in alt-screen mode.
1471    let orig_hook = std::panic::take_hook();
1472    std::panic::set_hook(Box::new(move |panic_info| {
1473        // Restore the terminal first so the panic message itself, plus the
1474        // user's shell after exit, are visible. Best-effort — we may not be
1475        // in raw / alt-screen mode if the panic happens pre-TUI. Shared
1476        // with the signal handler installed below so both exit paths leave
1477        // the terminal in the same well-defined state.
1478        crate::tui::ui::emergency_restore_terminal();
1479
1480        let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1481            s.to_string()
1482        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1483            s.clone()
1484        } else {
1485            format!("{:?}", panic_info.payload())
1486        };
1487        let location = panic_info
1488            .location()
1489            .map(|loc| loc.to_string())
1490            .unwrap_or_else(|| "unknown".to_string());
1491        tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
1492
1493        // Telemetry, if and only if this process was armed. This hook is
1494        // installed before `Cli::parse()` and long before any config is
1495        // resolved, so it cannot consult a resolved value — but it can consult
1496        // a `OnceLock` that is by construction empty until resolution
1497        // completes. A user who never opted in panics without writing a byte
1498        // and without creating a directory.
1499        //
1500        // The site is allowlist-reduced and `msg` is deliberately not read: a
1501        // slicing panic embeds the entire string being sliced, and this tree
1502        // slices user and model text in dozens of places.
1503        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
1504        if let Some(site) = panic_info
1505            .location()
1506            .map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
1507        {
1508            codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
1509        }
1510        // Write crash dump best-effort
1511        if let Some(home) = crate::config::effective_home_dir() {
1512            let crash_dir = home.join(".deepseek").join("crashes");
1513            let _ = std::fs::create_dir_all(&crash_dir);
1514            use chrono::Utc;
1515            let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1516            let path = crash_dir.join(format!("{ts}-process-panic.log"));
1517            let contents =
1518                format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
1519            let _ = std::fs::write(&path, contents);
1520        }
1521        // Invoke the original hook (prints to stderr, etc.)
1522        orig_hook(panic_info);
1523    }));
1524
1525    // Parse and freeze every startup authority before Tokio or any other
1526    // worker thread exists. A workspace `.env` is intentionally a narrow
1527    // credential convenience surface: it must never redirect product state,
1528    // configuration, MCP, trust, sandbox, executable lookup, or plugin
1529    // discovery. Plugin discovery therefore runs first, and the loader below
1530    // admits only built-in provider credential names from a stable file read.
1531    let cli = match Cli::try_parse_from(args) {
1532        Ok(c) => c,
1533        Err(e) => {
1534            e.exit();
1535        }
1536    };
1537    // #5098: project-scope fleet agent profiles (`.codewhale/agents/*.toml`)
1538    // join the dispatch roster under the same trust decision as the rest of
1539    // project-level config — `--no-project-config` opts the layer out for
1540    // every roster read in this process.
1541    crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
1542    let workspace = resolve_workspace(&cli);
1543    let mut plugin_discovery = None;
1544    let mut plugin_registry = None;
1545    let (cli, command) = prepare_cli_startup(
1546        cli,
1547        || {
1548            let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
1549            plugin_registry = Some(discovery.registry_for_workspace(&workspace));
1550            plugin_discovery = Some(discovery);
1551        },
1552        warn_on_workspace_dotenv_result,
1553    );
1554    let plugin_discovery = plugin_discovery
1555        .expect("plugin discovery initialization must precede workspace dotenv loading");
1556    let plugin_registry = plugin_registry
1557        .expect("plugin discovery initialization must precede workspace dotenv loading");
1558
1559    // The interactive runtime intentionally carries a large state machine:
1560    // terminal rendering, modal dispatch, provider setup, and fleet/workflow
1561    // events all share one async owner. Debug builds retain enough stack
1562    // temporaries that nesting a modal event over the TUI loop can exceed the
1563    // platform main-thread default (8 MiB on macOS). Give that owner an
1564    // explicit stack while keeping process hardening and the global panic hook
1565    // above this boundary, before Tokio or any worker thread exists.
1566    let runtime_thread = std::thread::Builder::new()
1567        .name("codewhale-main".to_string())
1568        .stack_size(CODEWHALE_MAIN_STACK_BYTES)
1569        .spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
1570        .context("Failed to start the Codewhale runtime thread")?;
1571    match runtime_thread.join() {
1572        Ok(result) => result,
1573        Err(payload) => {
1574            let message = payload
1575                .downcast_ref::<&str>()
1576                .map(|value| (*value).to_string())
1577                .or_else(|| payload.downcast_ref::<String>().cloned())
1578                .unwrap_or_else(|| "unknown panic payload".to_string());
1579            Err(anyhow!("Codewhale runtime thread panicked: {message}"))
1580        }
1581    }
1582}
1583
1584fn run_async_main(
1585    cli: Cli,
1586    command: Option<Commands>,
1587    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1588    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1589) -> Result<()> {
1590    build_runtime()?.block_on(run_async_main_inner(
1591        cli,
1592        command,
1593        plugin_discovery,
1594        plugin_registry,
1595    ))
1596}
1597
1598/// Build the runtime that owns every async task in this binary.
1599///
1600/// `#[tokio::main]` used to expand here, which left every worker thread on
1601/// tokio's 2 MiB default while only the `codewhale-main` owner thread above
1602/// received `CODEWHALE_MAIN_STACK_BYTES`. The engine does not run on that owner
1603/// thread — `core::engine::spawn_engine` hands `Engine::run` to
1604/// `utils::spawn_supervised`, a bare `tokio::spawn` — so the explicit stack
1605/// never applied where the depth actually is.
1606///
1607/// A debug-build `agent` dispatch (turn_loop -> FuturesUnordered ->
1608/// execute_full_with_context -> AgentTool::execute -> spawn_subagent_from_input)
1609/// measured a stack high-water mark between 2.25 and 2.5 MiB and aborted the
1610/// whole process on the guard page. A Rust stack overflow is not a panic: it
1611/// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the
1612/// process dies with 134 mid-dispatch.
1613///
1614/// This is behavior-identical to the old `#[tokio::main]` expansion apart from
1615/// the stack size, and it makes the knob greppable.
1616pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
1617    tokio::runtime::Builder::new_multi_thread()
1618        .enable_all()
1619        .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
1620        .build()
1621        .context("Failed to build the Codewhale Tokio runtime")
1622}
1623
1624/// Which product surface this process is serving.
1625///
1626/// A function of the parsed subcommand, never of the executable: this one
1627/// binary serves at least five surfaces, so `current_exe()` would label all of
1628/// them the same.
1629fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
1630    use codewhale_telemetry::Surface;
1631    match command {
1632        None | Some(Commands::Resume { .. } | Commands::Fork { .. }) => Surface::Tui,
1633        Some(Commands::Exec(_)) => Surface::Exec,
1634        Some(Commands::Serve(args)) => {
1635            if args.mcp {
1636                Surface::McpServer
1637            } else {
1638                Surface::Serve
1639            }
1640        }
1641        Some(_) => Surface::Cli,
1642    }
1643}
1644
1645/// How this session was started, for `session_start`.
1646fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
1647    use codewhale_telemetry::SessionSource;
1648    match command {
1649        None => SessionSource::Interactive,
1650        Some(Commands::Resume { .. }) => SessionSource::Resume,
1651        Some(Commands::Fork { .. }) => SessionSource::Fork,
1652        Some(Commands::Serve(_)) => SessionSource::Api,
1653        Some(_) => SessionSource::Unknown,
1654    }
1655}
1656
1657/// Resolve the emit predicate and arm, once, before anything can record.
1658///
1659/// This is the read that v1 of the design was missing entirely:
1660/// `resolve_runtime_options` had no non-test caller in this crate, so neither
1661/// `telemetry = false` in the config file nor `CODEWHALE_TELEMETRY=0` was ever
1662/// consulted by a process that would have emitted.
1663///
1664/// `CliRuntimeOverrides::default()` is correct here. The dispatcher has already
1665/// applied the kill-switch floor and forwarded the *resolved* value through
1666/// `CODEWHALE_TELEMETRY`, which `EnvRuntimeOverrides::load()` picks up — and
1667/// re-reading `CODEWHALE_TELEMETRY` inside the telemetry crate would fork
1668/// `parse_bool`, the `DEEPSEEK_TELEMETRY` alias, and the floor into a second
1669/// source of truth.
1670fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
1671    let surface = telemetry_surface(command);
1672    let Ok(store) = codewhale_config::ConfigStore::load(cli.config.clone()) else {
1673        return;
1674    };
1675    let resolved = store
1676        .config
1677        .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1678    let setup = codewhale_config::SetupState::load()
1679        .ok()
1680        .flatten()
1681        .unwrap_or_default();
1682    let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
1683        codewhale_telemetry::decide(&resolved, &setup, surface)
1684    else {
1685        return;
1686    };
1687    codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
1688    let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
1689    codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart {
1690        source: telemetry_session_source(command),
1691    });
1692}
1693
1694/// Close the armed session and flush, bounded.
1695async fn finish_telemetry(outcome: &Result<()>) {
1696    if !codewhale_telemetry::is_armed() {
1697        return;
1698    }
1699    // Only escalate: the panic hook and the signal path have already spoken if
1700    // they ran, and a stated class must not be overwritten by an inferred one.
1701    if outcome.is_err()
1702        && codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
1703    {
1704        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
1705    }
1706    codewhale_telemetry::record(telemetry_session_end());
1707    // `shutdown_blocking` parks a thread waiting on the writer, so it goes to
1708    // the blocking pool, and it is bounded there. The persistence actor's
1709    // unbounded `let _ = task.await` next door is not a pattern to copy here: a
1710    // hung TLS handshake would hold the process open past the last frame.
1711    let _ = tokio::time::timeout(
1712        codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
1713        tokio::task::spawn_blocking(|| {
1714            codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
1715        }),
1716    )
1717    .await;
1718}
1719
1720async fn run_async_main_inner(
1721    cli: Cli,
1722    command: Option<Commands>,
1723    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1724    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1725) -> Result<()> {
1726    // Install signal handlers that restore the terminal before the process
1727    // exits. Without this, Ctrl+C delivered while raw mode / kitty keyboard
1728    // enhancement / alt-screen are active (or in the brief windows around
1729    // startup and teardown where they're being toggled) leaves the user's shell
1730    // receiving raw CSI sequences like `^[[>5u` until they run `reset` (#1583).
1731    //
1732    // Once the TUI's raw mode is engaged the terminal driver delivers Ctrl+C as
1733    // the byte 0x03 rather than SIGINT, so the in-TUI key handler — not this
1734    // handler — is what processes user interrupts during normal operation. This
1735    // handler exists for the gaps: pre-TUI subcommands (--version, doctor,
1736    // login, …), the moments around enable_raw_mode / disable_raw_mode, the
1737    // external-editor suspend path, and SIGTERM / SIGHUP from the OS.
1738    //
1739    // It goes up before arming and before the notice: arming is the first
1740    // externally observable thing this process does (it creates the telemetry
1741    // buffer), and the notice is the first thing that can sit waiting on a
1742    // human. A Ctrl-C in either window must still restore the terminal and exit
1743    // 130 rather than kill the process outright. Recording a `session_end` from
1744    // the signal path is a no-op until `arm_telemetry` runs, so installing
1745    // ahead of it collects nothing.
1746    spawn_signal_cleanup_task();
1747
1748    // Arming is what makes the panic hook installed back in `main` — and every
1749    // other write path — stop being a no-op. Nothing before this line can
1750    // record anything, which is precisely how a disabled user's panic writes
1751    // nothing and creates no directory.
1752    // The notice runs before arming, and only on the interactive surface: it
1753    // is the one surface that owns a terminal it can ask on, and asking before
1754    // `arm_telemetry` is what lets a user who says yes be counted from this
1755    // session rather than the next one.
1756    //
1757    // Every path that does not render and answer it leaves the decision unset,
1758    // and unset means nothing is ever collected. `--skip-onboarding` and a
1759    // non-TTY stdin both take that path, on purpose.
1760    if telemetry_surface(command.as_ref()) == codewhale_telemetry::Surface::Tui {
1761        crate::telemetry_notice::prompt_if_due(cli.skip_onboarding, cli.config.clone());
1762    }
1763    arm_telemetry(&cli, command.as_ref());
1764    let outcome = run_async_main_dispatch(cli, command, plugin_discovery, plugin_registry).await;
1765    finish_telemetry(&outcome).await;
1766    outcome
1767}
1768
1769async fn run_async_main_dispatch(
1770    cli: Cli,
1771    command: Option<Commands>,
1772    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1773    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1774) -> Result<()> {
1775    logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
1776
1777    // Install any user prompt overrides from the config directory before an
1778    // engine can compose a system prompt. The override cells are
1779    // first-call-wins; doing this once here keeps every downstream turn
1780    // consistent. Missing files are a no-op (bundled defaults). See #3638.
1781    crate::prompts::load_prompt_overrides_from_config_home();
1782
1783    // Plugins own one read-only discovery snapshot per process. Initialize it
1784    // before the subcommand match so plain launch, resume, fork, exec, serve,
1785    // and every other runtime surface feed Skills and MCP from the same trust
1786    // decision (#3916, #4399). Discovery never enables, trusts, executes, or
1787    // persists a bundle.
1788
1789    // Handle subcommands first
1790    if let Some(command) = command {
1791        return match command {
1792            Commands::Doctor(args) => {
1793                let config = match load_doctor_config_from_cli(&cli, &args) {
1794                    Ok(config) => config,
1795                    Err(error) if args.json => return run_doctor_json_config_error(&error),
1796                    Err(_) => {
1797                        bail!(
1798                            "doctor configuration validation failed; details omitted because configuration errors may contain credential material"
1799                        )
1800                    }
1801                };
1802                let workspace = resolve_workspace(&cli);
1803                if args.context_json {
1804                    run_doctor_context_json(&config, &workspace)
1805                } else if args.json {
1806                    run_doctor_json(
1807                        &config,
1808                        &workspace,
1809                        cli.config.as_deref(),
1810                        plugin_registry.as_ref(),
1811                    )
1812                } else {
1813                    let probes = crate::doctor::DoctorProbeRequest {
1814                        check_updates: args.check_updates,
1815                        probe_api: args.probe_api,
1816                        probe_local: args.probe_local,
1817                        probe_mcp: args.probe_mcp,
1818                    };
1819                    run_doctor(
1820                        &config,
1821                        &workspace,
1822                        cli.config.as_deref(),
1823                        probes,
1824                        plugin_registry.as_ref(),
1825                    )
1826                    .await;
1827                    Ok(())
1828                }
1829            }
1830            Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
1831            Commands::Setup(args) => {
1832                let config = load_config_from_cli(&cli)?;
1833                let workspace = resolve_workspace(&cli);
1834                run_setup(&config, &workspace, args, plugin_registry.as_ref())
1835            }
1836            Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args),
1837            Commands::Completions { shell } => {
1838                generate_completions(shell);
1839                Ok(())
1840            }
1841            Commands::Sessions { limit, search } => list_sessions(limit, search),
1842            Commands::Init => init_project(),
1843            Commands::Login { api_key } => run_login(api_key),
1844            Commands::Logout => run_logout(),
1845            Commands::Auth(args) => match args.command {
1846                TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
1847            },
1848            Commands::Models(args) => {
1849                let config = load_config_from_cli(&cli)?;
1850                run_models(&config, args).await
1851            }
1852            Commands::Speech(args) => {
1853                let config = load_config_from_cli(&cli)?;
1854                run_speech(&config, args).await
1855            }
1856            Commands::Exec(args) => {
1857                let config = load_config_from_cli(&cli)?;
1858                let workspace = cli.workspace.clone().unwrap_or_else(|| {
1859                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1860                });
1861                let mut config = config.clone();
1862                // #4641: `--no-project-config` skips the workspace-specific
1863                // `[workspace]`/`[projects]` user-config overlay so a headless
1864                // launch (e.g. a future Verifiers harness) sees a reproducible
1865                // config surface that depends only on the explicit `--config`.
1866                if !cli.no_project_config {
1867                    merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
1868                }
1869                if let Some(sandbox) = args.sandbox.as_deref() {
1870                    let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
1871                    config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
1872                }
1873                // Honour CODEWHALE_BASE_URL / DEEPSEEK_BASE_URL forwarded by
1874                // the CLI dispatcher from --base-url.
1875                if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
1876                    .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
1877                {
1878                    let trimmed = env_url.trim();
1879                    if !trimmed.is_empty() {
1880                        config.base_url = Some(trimmed.to_string());
1881                    }
1882                }
1883                // Honour `--provider` (#4093): a Fleet worker whose profile pins
1884                // a provider launches on that provider even when the parent
1885                // session is on another one. This sets ONLY the non-secret
1886                // provider identity (`config.provider`); credentials/base URL
1887                // still resolve from the worker's own env/config, and for a
1888                // non-DeepSeek provider the legacy root `base_url` above is
1889                // ignored by `deepseek_base_url()`. Must precede model
1890                // resolution so an `auto`/default model resolves to the
1891                // overridden provider's default.
1892                let explicit_provider = args
1893                    .provider
1894                    .as_deref()
1895                    .map(str::trim)
1896                    .filter(|provider| !provider.is_empty());
1897                if let Some(provider_arg) = explicit_provider {
1898                    apply_exec_provider_override(&mut config, provider_arg)?;
1899                }
1900                if let Some(reasoning_arg) = args
1901                    .reasoning_effort
1902                    .as_deref()
1903                    .map(str::trim)
1904                    .filter(|value| !value.is_empty())
1905                {
1906                    config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
1907                    config.reasoning_effort_inferred_from_legacy_alias = false;
1908                }
1909                let prompt = join_prompt_parts(&args.prompt);
1910                let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
1911                validate_exec_tool_authority_resume(
1912                    args.tool_authority_json.as_deref(),
1913                    resume_session_id.is_some(),
1914                )?;
1915                let resume_session = resume_session_id
1916                    .as_deref()
1917                    .map(load_exec_resume_session)
1918                    .transpose()?;
1919                let explicit_model = args
1920                    .model
1921                    .as_deref()
1922                    .map(str::trim)
1923                    .filter(|model| !model.is_empty());
1924                let model = if let Some(saved) = resume_session.as_ref() {
1925                    resolve_exec_resume_route(
1926                        &mut config,
1927                        saved,
1928                        explicit_provider.is_some(),
1929                        explicit_model,
1930                    )?
1931                } else {
1932                    resolve_exec_model(&config, explicit_model)
1933                };
1934                let force_configured_route = should_force_configured_exec_route(
1935                    resume_session.is_some(),
1936                    explicit_provider,
1937                    explicit_model,
1938                );
1939                // The `deepseek` launcher forwards `--yolo` to this binary via
1940                // the DEEPSEEK_YOLO env var (which the config loader folds into
1941                // `config.yolo`), not as a CLI flag. Honour either source.
1942                let yolo = cli.yolo || config.yolo.unwrap_or(false);
1943                let env_tool_surface = exec_tool_surface_from_env();
1944                let needs_engine = args.auto
1945                    || yolo
1946                    || resume_session_id.is_some()
1947                    || args.output_format == ExecOutputFormat::StreamJson
1948                    || args.max_turns.is_some()
1949                    || args.allowed_tools.is_some()
1950                    || args.disallowed_tools.is_some()
1951                    || args.append_system_prompt.is_some()
1952                    || args.tool_authority_json.is_some()
1953                    || args.sandbox.is_some()
1954                    || args.allow_sandbox_elevation
1955                    || env_tool_surface.is_some();
1956                if needs_engine {
1957                    let provider = config.api_provider();
1958                    let max_subagents = cli.max_subagents.map_or_else(
1959                        || config.max_subagents_for_provider(provider),
1960                        |value| value.clamp(1, MAX_SUBAGENTS),
1961                    );
1962                    let auto_mode = args.auto || yolo;
1963                    let max_turns = exec_max_steps(args.max_turns);
1964                    let allowed_tools =
1965                        resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface);
1966                    let disallowed_tools = args
1967                        .disallowed_tools
1968                        .as_deref()
1969                        .map(normalize_exec_tool_names);
1970                    run_exec_agent(
1971                        &config,
1972                        &model,
1973                        &prompt,
1974                        workspace,
1975                        max_subagents,
1976                        auto_mode,
1977                        args.allow_sandbox_elevation,
1978                        args.sandbox.as_deref(),
1979                        auto_mode,
1980                        args.json,
1981                        resume_session,
1982                        force_configured_route,
1983                        args.output_format,
1984                        max_turns,
1985                        allowed_tools,
1986                        disallowed_tools,
1987                        args.append_system_prompt.clone(),
1988                        args.tool_authority_json.clone(),
1989                        std::sync::Arc::clone(&plugin_registry),
1990                    )
1991                    .await
1992                } else if args.json {
1993                    run_one_shot_json(&config, &model, &prompt, force_configured_route).await
1994                } else {
1995                    run_one_shot(&config, &model, &prompt, force_configured_route).await
1996                }
1997            }
1998            Commands::Fleet(args) => {
1999                let config = load_config_from_cli(&cli)?;
2000                let workspace = resolve_workspace(&cli);
2001                run_fleet_command(&workspace, &config, args).await
2002            }
2003            Commands::WorkflowTool(args) => {
2004                run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
2005            }
2006            Commands::Review(args) => {
2007                let config = load_config_from_cli(&cli)?;
2008                run_review(&config, args).await
2009            }
2010            Commands::Pr {
2011                number,
2012                repo,
2013                checkout,
2014            } => {
2015                let config = load_config_from_cli(&cli)?;
2016                run_pr(
2017                    &cli,
2018                    &config,
2019                    number,
2020                    repo.as_deref(),
2021                    checkout,
2022                    Arc::clone(&plugin_registry),
2023                )
2024                .await
2025            }
2026            Commands::Apply(args) => run_apply(args),
2027            Commands::Eval(args) => run_eval(args),
2028            Commands::Scorecard(args) => run_scorecard(args),
2029            Commands::Mcp { command } => {
2030                let config = load_config_from_cli(&cli)?;
2031                let workspace = resolve_workspace(&cli);
2032                run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
2033            }
2034            Commands::Features(command) => {
2035                let config = load_config_from_cli(&cli)?;
2036                run_features_command(&config, command)
2037            }
2038            Commands::Sandbox(args) => run_sandbox_command(args),
2039            Commands::Serve(args) => {
2040                let workspace = cli.workspace.clone().unwrap_or_else(|| {
2041                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2042                });
2043                let http_selected = validate_serve_mode_selection(
2044                    args.mcp,
2045                    args.http,
2046                    args.mobile,
2047                    args.web,
2048                    args.acp,
2049                )?;
2050                if args.mcp {
2051                    tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
2052                } else if http_selected {
2053                    let (config, config_profile) =
2054                        load_config_from_cli_with_effective_profile(&cli)?;
2055                    let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
2056                    let bind_host = resolve_serve_bind_host(args.mobile, args.host);
2057                    if args.web && bind_host.host != "127.0.0.1" {
2058                        bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2059                    }
2060                    if bind_host.mobile_rebound_to_lan {
2061                        println!(
2062                            "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."
2063                        );
2064                    }
2065                    runtime_api::run_http_server(
2066                        config,
2067                        workspace,
2068                        std::sync::Arc::clone(&plugin_discovery),
2069                        runtime_api::RuntimeApiOptions {
2070                            host: bind_host.host,
2071                            port: args.port,
2072                            workers: args.workers.clamp(1, 8),
2073                            cors_origins,
2074                            auth_token: args.auth_token,
2075                            insecure_no_auth: args.insecure_no_auth,
2076                            mobile: args.mobile,
2077                            web: args.web,
2078                            show_qr: args.qr,
2079                            config_path: cli.config.clone(),
2080                            config_profile,
2081                        },
2082                    )
2083                    .await
2084                } else if args.acp {
2085                    let config = load_config_from_cli(&cli)?;
2086                    let model = config.default_model();
2087                    acp_server::run_acp_server(config, model, workspace).await
2088                } else {
2089                    unreachable!("server mode count checked above")
2090                }
2091            }
2092            Commands::Resume { session_id, last } => {
2093                let config = load_config_from_cli(&cli)?;
2094                let workspace = resolve_workspace(&cli);
2095                let resume_id = resolve_session_id(session_id, last, &workspace)?;
2096                run_interactive(
2097                    &cli,
2098                    &config,
2099                    Some(resume_id),
2100                    None,
2101                    std::sync::Arc::clone(&plugin_registry),
2102                )
2103                .await
2104            }
2105            Commands::Fork { session_id, last } => {
2106                let config = load_config_from_cli(&cli)?;
2107                let workspace = resolve_workspace(&cli);
2108                let new_session_id = fork_session(&config, session_id, last, &workspace)?;
2109                run_interactive(
2110                    &cli,
2111                    &config,
2112                    Some(new_session_id),
2113                    None,
2114                    std::sync::Arc::clone(&plugin_registry),
2115                )
2116                .await
2117            }
2118        };
2119    }
2120
2121    // Top-level prompt mode: submit the initial prompt, then keep the TUI alive
2122    // for follow-up messages. Use `codewhale exec` for explicit non-interactive
2123    // one-shot behavior (#2370).
2124    let config = load_config_from_cli(&cli)?;
2125    if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
2126        return run_interactive(
2127            &cli,
2128            &config,
2129            None,
2130            Some(initial_input),
2131            std::sync::Arc::clone(&plugin_registry),
2132        )
2133        .await;
2134    }
2135
2136    // Handle session resume. Plain `codewhale` starts fresh: interrupted
2137    // snapshots are preserved for explicit resume, but never auto-attached.
2138    let mut startup_notice = None;
2139    let resume_session_id = if cli.continue_session {
2140        let workspace = resolve_workspace(&cli);
2141        recover_interrupted_checkpoint_for_resume(&workspace)
2142            .or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
2143    } else if let Some(id) = cli.resume.clone() {
2144        Some(id)
2145    } else if !cli.fresh {
2146        let workspace = resolve_workspace(&cli);
2147        preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
2148        // Opt-in auto-resume (#2934). Off by default, so the historical
2149        // "plain `codewhale` starts fresh" behaviour is unchanged unless the
2150        // user asked for something else. The decision never resumes an
2151        // archived, unreadable, or foreign-workspace session; every fallback
2152        // carries a receipt rather than silently starting blank.
2153        let (session_id, notice) = resolve_auto_resume(&workspace);
2154        startup_notice = notice;
2155        session_id
2156    } else {
2157        None
2158    };
2159
2160    // Default: Interactive TUI
2161    // --yolo starts in YOLO mode (auto-approve; shell enabled)
2162    run_interactive_with_notice(
2163        &cli,
2164        &config,
2165        resume_session_id,
2166        None,
2167        startup_notice,
2168        plugin_registry,
2169    )
2170    .await
2171}
2172
2173/// Resolve the opt-in auto-resume setting into a session id plus a receipt.
2174///
2175/// Deliberately scoped to the plain interactive launch. `codewhale "do X"`
2176/// (top-level prompt) and `codewhale exec` are not covered: silently prefixing
2177/// a one-shot task with a prior conversation would change what is sent to the
2178/// model, which is not a layout preference the user opted into.
2179fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
2180    use crate::session_resume::{ResumeRequest, decide_auto_resume};
2181
2182    let enabled = crate::settings::Settings::load_persisted()
2183        .map(|settings| settings.session_auto_resume)
2184        .unwrap_or(false);
2185    if !enabled {
2186        return (None, None);
2187    }
2188    let Ok(manager) = SessionManager::default_location() else {
2189        return (None, None);
2190    };
2191    let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
2192    (
2193        decision.session_id().map(str::to_string),
2194        decision.status_message(),
2195    )
2196}
2197
2198fn prepare_cli_startup(
2199    cli: Cli,
2200    initialize_plugins: impl FnOnce(),
2201    load_dotenv: impl FnOnce(),
2202) -> (Cli, Option<Commands>) {
2203    initialize_plugins();
2204    let command = cli.command.clone();
2205    let should_load_dotenv = match command.as_ref() {
2206        Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
2207        _ => true,
2208    };
2209    if should_load_dotenv {
2210        load_dotenv();
2211    }
2212    (cli, command)
2213}
2214
2215const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
2216
2217#[derive(Debug, Default)]
2218struct WorkspaceDotenvReport {
2219    path: PathBuf,
2220    loaded: BTreeSet<String>,
2221    ignored: BTreeSet<String>,
2222}
2223
2224/// Load the narrow, data-plane subset of a workspace `.env` before Tokio.
2225///
2226/// Repository content is not product authority. In particular, a committed
2227/// `.env` must not be able to redirect `CODEWHALE_HOME`, config/profile files,
2228/// MCP servers, plugin trust, executable lookup, sandbox/approval posture, or
2229/// network destinations. Shell-exported values and config/CLI arguments remain
2230/// the explicit surfaces for those controls.
2231fn warn_on_workspace_dotenv_result() {
2232    match load_workspace_dotenv_credentials() {
2233        Ok(Some(report)) if !report.ignored.is_empty() => {
2234            eprintln!(
2235                "Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
2236                report.path.display(),
2237                display_env_key_set(&report.ignored)
2238            );
2239        }
2240        Ok(_) => {}
2241        Err(error) => {
2242            // The error intentionally contains no file contents or parsed
2243            // values. A malformed or unsafe workspace file fails closed while
2244            // shell/config credentials remain available.
2245            eprintln!("Codewhale did not load workspace .env: {error}");
2246        }
2247    }
2248}
2249
2250fn display_env_key_set(keys: &BTreeSet<String>) -> String {
2251    const MAX_DISPLAYED: usize = 12;
2252    let mut labels = keys
2253        .iter()
2254        .take(MAX_DISPLAYED)
2255        .map(|key| {
2256            if key
2257                .chars()
2258                .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
2259            {
2260                key.as_str()
2261            } else {
2262                "<invalid-name>"
2263            }
2264        })
2265        .collect::<Vec<_>>();
2266    if keys.len() > MAX_DISPLAYED {
2267        labels.push("...");
2268    }
2269    labels.join(", ")
2270}
2271
2272fn load_workspace_dotenv_credentials() -> Result<Option<WorkspaceDotenvReport>> {
2273    let Some(path) = find_workspace_dotenv()? else {
2274        return Ok(None);
2275    };
2276    load_workspace_dotenv_credentials_from_path(&path).map(Some)
2277}
2278
2279fn find_workspace_dotenv() -> Result<Option<PathBuf>> {
2280    let cwd = std::env::current_dir().context("could not resolve the current workspace")?;
2281    let boundary = cwd
2282        .ancestors()
2283        .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
2284        .unwrap_or(cwd.as_path());
2285
2286    for ancestor in cwd.ancestors() {
2287        let candidate = ancestor.join(".env");
2288        match std::fs::symlink_metadata(&candidate) {
2289            Ok(_) => return Ok(Some(candidate)),
2290            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2291            Err(error) => {
2292                return Err(anyhow!(
2293                    "could not inspect {}: {error}",
2294                    candidate.display()
2295                ));
2296            }
2297        }
2298        if ancestor == boundary {
2299            break;
2300        }
2301    }
2302    Ok(None)
2303}
2304
2305fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
2306    let contents = read_stable_workspace_dotenv(path)?;
2307    let text = std::str::from_utf8(&contents)
2308        .map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
2309    if dotenv_has_variable_expansion(text) {
2310        bail!(
2311            "{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
2312            path.display()
2313        );
2314    }
2315
2316    let mut report = WorkspaceDotenvReport {
2317        path: path.to_path_buf(),
2318        ..WorkspaceDotenvReport::default()
2319    };
2320    let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
2321        .collect::<std::result::Result<Vec<_>, _>>()
2322        .map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
2323    for entry in entries {
2324        let (key, value) = entry;
2325        if !is_workspace_dotenv_credential_key(&key) {
2326            report.ignored.insert(key);
2327            continue;
2328        }
2329        if std::env::var_os(&key).is_some() {
2330            continue;
2331        }
2332
2333        // SAFETY: this loader runs synchronously in `main` before the runtime
2334        // owner or Tokio workers are spawned. No concurrent environment reader
2335        // exists inside Codewhale, and later startup code treats this process
2336        // environment as immutable.
2337        unsafe { std::env::set_var(&key, value) };
2338        report.loaded.insert(key);
2339    }
2340    Ok(report)
2341}
2342
2343fn is_workspace_dotenv_credential_key(key: &str) -> bool {
2344    codewhale_config::provider::providers_sorted_for_display()
2345        .into_iter()
2346        .any(|provider| provider.env_vars().contains(&key))
2347        || matches!(
2348            key,
2349            "DEEPSEEK_SEARCH_API_KEY"
2350                | "SOFYA_API_KEY"
2351                | "METASO_API_KEY"
2352                | "BAIDU_SEARCH_API_KEY"
2353                | "DEEPSEEK_SANDBOX_API_KEY"
2354        )
2355}
2356
2357fn dotenv_has_variable_expansion(contents: &str) -> bool {
2358    let mut escaped = false;
2359    let mut single_quoted = false;
2360    let mut double_quoted = false;
2361    let mut comment = false;
2362
2363    for ch in contents.chars() {
2364        if comment {
2365            // Reject expansion markers even in comments. This is deliberately
2366            // conservative, and ignoring other comment text prevents an
2367            // unmatched quote there from changing how the next line is read.
2368            if ch == '$' {
2369                return true;
2370            }
2371            if ch == '\n' {
2372                comment = false;
2373                escaped = false;
2374            }
2375            continue;
2376        }
2377        if single_quoted {
2378            if ch == '\'' {
2379                single_quoted = false;
2380            }
2381            continue;
2382        }
2383        if escaped {
2384            escaped = false;
2385            continue;
2386        }
2387        if ch == '\\' {
2388            escaped = true;
2389            continue;
2390        }
2391        if ch == '\'' && !double_quoted {
2392            single_quoted = true;
2393            continue;
2394        }
2395        if ch == '"' {
2396            double_quoted = !double_quoted;
2397            continue;
2398        }
2399        if ch == '#' && !double_quoted {
2400            comment = true;
2401            continue;
2402        }
2403        if ch == '$' {
2404            return true;
2405        }
2406    }
2407    false
2408}
2409
2410fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
2411    let mut file = open_workspace_dotenv_without_following_links(path)?;
2412    let metadata = file
2413        .metadata()
2414        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2415    if !metadata.is_file() {
2416        bail!("{} is not a regular file", path.display());
2417    }
2418    if workspace_dotenv_has_multiple_links(&file, &metadata)? {
2419        bail!(
2420            "{} has multiple filesystem links, not a unique workspace-owned file",
2421            path.display()
2422        );
2423    }
2424    if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
2425        bail!(
2426            "{} exceeds the {} byte workspace .env limit",
2427            path.display(),
2428            MAX_WORKSPACE_DOTENV_BYTES
2429        );
2430    }
2431
2432    let mut contents = Vec::with_capacity(metadata.len() as usize);
2433    (&mut file)
2434        .take(MAX_WORKSPACE_DOTENV_BYTES + 1)
2435        .read_to_end(&mut contents)
2436        .map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
2437    if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
2438        bail!(
2439            "{} exceeds the {} byte workspace .env limit",
2440            path.display(),
2441            MAX_WORKSPACE_DOTENV_BYTES
2442        );
2443    }
2444    Ok(contents)
2445}
2446
2447#[cfg(unix)]
2448fn workspace_dotenv_has_multiple_links(
2449    _file: &std::fs::File,
2450    metadata: &std::fs::Metadata,
2451) -> Result<bool> {
2452    use std::os::unix::fs::MetadataExt;
2453
2454    Ok(metadata.nlink() > 1)
2455}
2456
2457#[cfg(windows)]
2458fn workspace_dotenv_has_multiple_links(
2459    file: &std::fs::File,
2460    _metadata: &std::fs::Metadata,
2461) -> Result<bool> {
2462    use std::os::windows::io::AsRawHandle;
2463    use windows::Win32::Foundation::HANDLE;
2464    use windows::Win32::Storage::FileSystem::{
2465        BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
2466    };
2467
2468    let mut information = BY_HANDLE_FILE_INFORMATION::default();
2469    // SAFETY: `file` owns a live kernel handle for the already-open `.env`;
2470    // `information` remains writable for the duration of this synchronous
2471    // call. No path lookup or re-open occurs here.
2472    unsafe {
2473        GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
2474            .map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
2475    }
2476    Ok(information.nNumberOfLinks > 1)
2477}
2478
2479#[cfg(not(any(unix, windows)))]
2480fn workspace_dotenv_has_multiple_links(
2481    _file: &std::fs::File,
2482    _metadata: &std::fs::Metadata,
2483) -> Result<bool> {
2484    Ok(false)
2485}
2486
2487#[cfg(unix)]
2488fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2489    use std::os::unix::fs::OpenOptionsExt;
2490
2491    std::fs::OpenOptions::new()
2492        .read(true)
2493        // `O_NONBLOCK` is inert for regular files but prevents a FIFO named
2494        // `.env` from hanging startup before the metadata check can reject it.
2495        .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
2496        .open(path)
2497        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2498}
2499
2500#[cfg(windows)]
2501fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2502    use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
2503
2504    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
2505    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2506    let file = std::fs::OpenOptions::new()
2507        .read(true)
2508        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
2509        .open(path)
2510        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
2511    let metadata = file
2512        .metadata()
2513        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2514    if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
2515        bail!(
2516            "{} is a reparse point, not a workspace-owned file",
2517            path.display()
2518        );
2519    }
2520    Ok(file)
2521}
2522
2523#[cfg(not(any(unix, windows)))]
2524fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2525    let metadata = std::fs::symlink_metadata(path)
2526        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2527    if metadata.file_type().is_symlink() {
2528        bail!(
2529            "{} is a symbolic link, not a workspace-owned file",
2530            path.display()
2531        );
2532    }
2533    std::fs::File::open(path)
2534        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2535}
2536
2537/// Generate shell completions for the given shell
2538fn generate_completions(shell: Shell) {
2539    let mut cmd = Cli::command();
2540    let name = cmd.get_name().to_string();
2541    generate(shell, &mut cmd, name, &mut io::stdout());
2542}
2543
2544/// Run the offline evaluation harness (no network/LLM calls).
2545fn run_eval(args: EvalArgs) -> Result<()> {
2546    let fail_step = match args.fail_step.as_deref() {
2547        Some(value) => ScenarioStepKind::parse(value)
2548            .map(Some)
2549            .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
2550        None => None,
2551    };
2552
2553    let config = EvalHarnessConfig {
2554        fail_step,
2555        shell_command: args.shell_command,
2556        shell_expect_token: args.shell_expect_token,
2557        max_output_chars: args.max_output_chars,
2558        record_dir: args.record.clone(),
2559        ..EvalHarnessConfig::default()
2560    };
2561
2562    let harness = EvalHarness::new(config);
2563    let run = harness.run().context("evaluation harness failed")?;
2564    let report = run.to_report();
2565
2566    if args.json {
2567        let json = serde_json::to_string_pretty(&report)?;
2568        println!("{json}");
2569    } else {
2570        println!("Offline Eval Harness");
2571        println!("scenario: {}", report.scenario_name);
2572        println!("workspace: {}", report.workspace_root.display());
2573        println!("success: {}", report.metrics.success);
2574        println!("steps: {}", report.metrics.steps);
2575        println!("tool_errors: {}", report.metrics.tool_errors);
2576        println!("duration_ms: {}", report.metrics.duration.as_millis());
2577
2578        if !report.metrics.per_tool.is_empty() {
2579            println!("per_tool:");
2580            for (kind, stats) in &report.metrics.per_tool {
2581                println!(
2582                    "  {} invocations={} errors={} duration_ms={}",
2583                    kind.tool_name(),
2584                    stats.invocations,
2585                    stats.errors,
2586                    stats.total_duration.as_millis()
2587                );
2588            }
2589        }
2590
2591        let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
2592        if !failed_steps.is_empty() {
2593            println!("failed_steps:");
2594            for step in failed_steps {
2595                let error = step.error.as_deref().unwrap_or("unknown error");
2596                println!(
2597                    "  {} tool={} error={}",
2598                    step.kind.tool_name(),
2599                    step.tool_name,
2600                    error
2601                );
2602            }
2603        }
2604    }
2605
2606    if report.metrics.success {
2607        Ok(())
2608    } else {
2609        bail!("offline evaluation harness reported failure")
2610    }
2611}
2612
2613/// Score a run's token/cache/cost from recorded turns and (optionally) flag
2614/// regressions against a committed baseline. Offline: reads recorded usage from
2615/// a JSON file, reuses the pricing layer, never calls a model. Exits non-zero
2616/// when a baseline is supplied and a metric regresses past the threshold, so it
2617/// can be wired as a release gate (#3388).
2618fn run_scorecard(args: ScorecardArgs) -> Result<()> {
2619    use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
2620
2621    let raw = std::fs::read_to_string(&args.input)
2622        .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
2623    let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
2624        .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
2625
2626    let card = Scorecard::from_recorded_turns(&recorded);
2627
2628    let regressions = match &args.baseline {
2629        Some(path) => {
2630            let baseline_raw = std::fs::read_to_string(path)
2631                .with_context(|| format!("failed to read baseline {}", path.display()))?;
2632            let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
2633                .with_context(|| format!("failed to parse baseline {}", path.display()))?;
2634            card.metrics.regressions_against(&baseline, args.threshold)
2635        }
2636        None => Vec::new(),
2637    };
2638
2639    if args.json {
2640        let out = serde_json::json!({
2641            "per_turn": card.per_turn,
2642            "metrics": card.metrics,
2643            "regressions": regressions,
2644        });
2645        println!("{}", serde_json::to_string_pretty(&out)?);
2646    } else {
2647        print!("{}", card.to_summary());
2648        for r in &regressions {
2649            println!(
2650                "REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
2651                r.metric, r.baseline, r.current, r.pct_increase
2652            );
2653        }
2654    }
2655
2656    if regressions.is_empty() {
2657        Ok(())
2658    } else {
2659        bail!(
2660            "{} metric(s) regressed past the {:.1}% threshold",
2661            regressions.len(),
2662            args.threshold
2663        )
2664    }
2665}
2666
2667async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
2668    use crate::fleet::alerts::{
2669        FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
2670        FleetEnvSecretResolver,
2671    };
2672    use crate::fleet::control as fleet_control;
2673    use crate::fleet::executor::FleetExecutor;
2674    use crate::fleet::manager::{FleetManager, FleetStatusSnapshot, FleetWorkerInspection};
2675    use codewhale_lane::{ControlOperation, ControlSurface};
2676    use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
2677
2678    // Every label and every row below comes from the shared Fleet control
2679    // surface, so `codewhale fleet …` and `/fleet …` cannot drift in how they
2680    // describe the same durable ledger (#1888, #4022).
2681    fn print_status(status: &FleetStatusSnapshot) {
2682        println!("{}", fleet_control::render_fleet_status_snapshot(status));
2683    }
2684
2685    fn print_inspection(inspection: &FleetWorkerInspection) {
2686        println!("{}", fleet_control::render_inspection(inspection));
2687    }
2688
2689    fn print_artifacts(inspection: &FleetWorkerInspection) {
2690        println!("{}", fleet_control::render_artifacts(inspection));
2691    }
2692
2693    /// Print one shared control receipt on the CLI surface.
2694    fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
2695        if receipt.is_error() {
2696            eprintln!("{}", receipt.render());
2697            let detail = receipt
2698                .failure
2699                .as_ref()
2700                .map(ToString::to_string)
2701                .unwrap_or_else(|| receipt.outcome.as_str().to_string());
2702            bail!("{}: {detail}", receipt.operation_id);
2703        }
2704        println!("{}", receipt.render());
2705        Ok(())
2706    }
2707
2708    fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
2709        let mut printed = false;
2710        for artifact in inspection
2711            .artifacts
2712            .iter()
2713            .filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
2714        {
2715            let path = workspace.join(&artifact.path);
2716            println!("== {} ==", artifact.path.display());
2717            let contents = std::fs::read_to_string(&path)
2718                .with_context(|| format!("reading fleet log {}", path.display()))?;
2719            let preview: String = contents.chars().take(16 * 1024).collect();
2720            // Worker logs can contain captured terminal bytes (a child TUI's
2721            // mouse-tracking handshake, SGR, OSC). Printing them raw would
2722            // re-arm mouse reporting in the caller's shell and leave it
2723            // executing escape fragments after this command exits.
2724            let mut safe_preview = String::with_capacity(preview.len());
2725            crate::tui::osc8::strip_ansi_into(&preview, &mut safe_preview);
2726            print!("{safe_preview}");
2727            if contents.chars().count() > preview.chars().count() {
2728                println!("\n[truncated]");
2729            } else if !preview.ends_with('\n') {
2730                println!();
2731            }
2732            printed = true;
2733        }
2734        if !printed {
2735            println!("logs: none");
2736        }
2737        Ok(())
2738    }
2739
2740    fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
2741        match arg {
2742            FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
2743            FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
2744            FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
2745            FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
2746            FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
2747            FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
2748        }
2749    }
2750
2751    fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
2752        if let Some(status) = override_status {
2753            return status;
2754        }
2755        match class {
2756            FleetAlertEventClass::Stale => "stale",
2757            FleetAlertEventClass::RestartExhausted => "failed",
2758            FleetAlertEventClass::NeedsHuman => "needs_human",
2759            FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
2760            FleetAlertEventClass::VerifierFailed => "verifier_failed",
2761            FleetAlertEventClass::RunCompleted => "completed",
2762        }
2763        .to_string()
2764    }
2765
2766    fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
2767        match args.adapter {
2768            FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
2769                webhook_env: args.slack_webhook_env.clone(),
2770                channel: None,
2771            },
2772            FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
2773                url_env: args.webhook_url_env.clone(),
2774                secret_env: args.webhook_secret_env.clone(),
2775            },
2776            FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
2777                routing_key_env: args.pagerduty_routing_key_env.clone(),
2778                severity: args.pagerduty_severity.clone(),
2779            },
2780        }
2781    }
2782
2783    let fleet_config = config.fleet_config();
2784    let provider = config.api_provider();
2785    let max_subagents = config.max_subagents_for_provider(provider);
2786    let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout(
2787        workspace.to_path_buf(),
2788        max_subagents,
2789        config
2790            .max_admitted_subagents_for_provider(provider)
2791            .max(max_subagents),
2792        Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
2793        config.launch_concurrency_for_provider(provider),
2794        config.subagent_token_budget_for_provider(provider),
2795    );
2796    // Probe the durable ledger *before* opening the manager: FleetManager::open
2797    // creates `.codewhale/fleet.jsonl` as a side effect, so a later probe would
2798    // always find a ledger and the CLI would report availability differently
2799    // from the slash surface for the same workspace (#4022).
2800    let fleet_context = fleet_control::fleet_control_context(workspace);
2801    // Probing is not enough on its own: `FleetManager::open` *creates* the
2802    // ledger, and it used to run for every subcommand before this match. That
2803    // made `codewhale fleet status` in a ledgerless workspace print
2804    // "no_fleet_ledger" while simultaneously creating the file it said was
2805    // missing — and the next invocation then reported an empty ledger as if a
2806    // Fleet had existed all along. Refuse the control verbs here, before the
2807    // manager exists, so the CLI and `/fleet` agree and neither surface
2808    // conjures the store it is reporting on (#4022).
2809    if let Some(operation) = match &args.command {
2810        FleetCommand::List => Some(ControlOperation::FleetList),
2811        FleetCommand::Status => Some(ControlOperation::FleetStatus),
2812        FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
2813        FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
2814        _ => None,
2815    } {
2816        let descriptor = operation.descriptor();
2817        let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
2818        if !availability.is_available() {
2819            return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
2820                descriptor,
2821                ControlSurface::Cli,
2822                availability,
2823            ));
2824        }
2825    }
2826
2827    // The configured route is the operator: fleet workers without a
2828    // task/profile model pin inherit the session's active model.
2829    let manager = FleetManager::open(workspace)?
2830        .with_exec_config(fleet_config.exec.clone())
2831        .with_fleet_config(fleet_config)
2832        .with_sub_agent_manager(coordination_manager)
2833        .with_session_model(config.default_model())
2834        .with_route_config(config.clone());
2835    match args.command {
2836        FleetCommand::Init => {
2837            println!("fleet ledger: {}", manager.ledger_path().display());
2838            Ok(())
2839        }
2840        FleetCommand::Run(args) => {
2841            let max_workers = args.max_workers.clamp(1, 128);
2842            let manager =
2843                manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
2844            let report = manager.create_run_from_task_spec_path(&args.task_spec, max_workers)?;
2845            println!(
2846                "fleet run: {} tasks={} leased={} queued={}",
2847                report.run_id.0, report.task_count, report.leased, report.queued
2848            );
2849            for warning in &report.warnings {
2850                println!("warning: {warning}");
2851            }
2852            println!("workers:");
2853            for worker_id in &report.worker_ids {
2854                println!("  {worker_id}");
2855            }
2856            if args.once {
2857                print_status(&manager.run_status(&report.run_id)?);
2858                return Ok(());
2859            }
2860            println!(
2861                "manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
2862            );
2863            let mut executor = FleetExecutor::new(workspace);
2864            let codewhale_binary = fleet::executor::configured_codewhale_binary();
2865            let status = manager
2866                .run_to_completion(
2867                    &report.run_id,
2868                    max_workers,
2869                    &mut executor,
2870                    &codewhale_binary,
2871                    None,
2872                    Duration::from_secs(2),
2873                )
2874                .await?;
2875            print_status(&status);
2876            Ok(())
2877        }
2878        FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2879            ControlSurface::Cli,
2880            workspace,
2881            fleet_context,
2882            &manager,
2883            ControlOperation::FleetList,
2884            None,
2885        )),
2886        FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2887            ControlSurface::Cli,
2888            workspace,
2889            fleet_context,
2890            &manager,
2891            ControlOperation::FleetStatus,
2892            None,
2893        )),
2894        FleetCommand::Inspect { worker_id } => {
2895            print_inspection(&manager.inspect_worker(&worker_id)?);
2896            Ok(())
2897        }
2898        FleetCommand::Logs { worker_id } => {
2899            let inspection = manager.inspect_worker(&worker_id)?;
2900            print_logs(workspace, &inspection)
2901        }
2902        FleetCommand::Artifacts { worker_id } => {
2903            let inspection = manager.inspect_worker(&worker_id)?;
2904            print_artifacts(&inspection);
2905            Ok(())
2906        }
2907        FleetCommand::Interrupt { worker_id } => {
2908            emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2909                ControlSurface::Cli,
2910                workspace,
2911                fleet_context,
2912                &manager,
2913                ControlOperation::FleetInterrupt,
2914                Some(&worker_id),
2915            ))
2916        }
2917        FleetCommand::Restart { worker_id } => {
2918            let report = manager.restart_worker(&worker_id)?;
2919            print_inspection(&report.inspection);
2920            println!(
2921                "manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
2922                report.run_id.0
2923            );
2924            let mut executor = FleetExecutor::new(workspace);
2925            let codewhale_binary = fleet::executor::configured_codewhale_binary();
2926            let status = manager
2927                .run_to_completion(
2928                    &report.run_id,
2929                    report.max_workers,
2930                    &mut executor,
2931                    &codewhale_binary,
2932                    None,
2933                    Duration::from_secs(2),
2934                )
2935                .await?;
2936            print_status(&status);
2937            Ok(())
2938        }
2939        FleetCommand::Resume {
2940            run_id,
2941            stale_after_seconds,
2942        } => {
2943            let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
2944            emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
2945                ControlSurface::Cli,
2946                workspace,
2947                fleet_context,
2948                &manager,
2949                ControlOperation::FleetResume,
2950                Some(&run_id),
2951            ))
2952        }
2953        FleetCommand::Stop { all } => {
2954            if !all {
2955                bail!("pass --all to stop all fleet work");
2956            }
2957            let stopped = manager.stop_all()?;
2958            println!("stopped: {stopped}");
2959            Ok(())
2960        }
2961        FleetCommand::AlertDryRun(args) => {
2962            let class = alert_event_class(args.event);
2963            let adapter = alert_adapter(&args);
2964            let event = FleetAlertEvent {
2965                class,
2966                run_id: FleetRunId::from(args.run_id.clone()),
2967                worker_id: args.worker_id.clone(),
2968                task_id: args.task_id.clone(),
2969                status: alert_status(class, args.status.clone()),
2970                reason: args.reason.clone(),
2971            };
2972            let dispatcher = FleetAlertDispatcher::new(
2973                FleetAlertConfig::dry_run_for_adapter(adapter),
2974                FleetEnvSecretResolver,
2975            );
2976            let deliveries = dispatcher.dispatch(&event)?;
2977            for delivery in deliveries {
2978                println!(
2979                    "{}",
2980                    serde_json::to_string_pretty(&delivery.redacted_payload)?
2981                );
2982            }
2983            Ok(())
2984        }
2985    }
2986}
2987
2988#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2989enum WriteStatus {
2990    Created,
2991    Overwritten,
2992    SkippedExists,
2993}
2994
2995fn ensure_parent_dir(path: &Path) -> Result<()> {
2996    if let Some(parent) = path.parent()
2997        && !parent.as_os_str().is_empty()
2998    {
2999        std::fs::create_dir_all(parent)
3000            .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
3001    }
3002    Ok(())
3003}
3004
3005fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
3006    ensure_parent_dir(path)?;
3007
3008    if path.exists() && !force {
3009        return Ok(WriteStatus::SkippedExists);
3010    }
3011
3012    let status = if path.exists() {
3013        WriteStatus::Overwritten
3014    } else {
3015        WriteStatus::Created
3016    };
3017
3018    std::fs::write(path, contents)
3019        .with_context(|| format!("Failed to write template at {}", path.display()))?;
3020
3021    Ok(status)
3022}
3023
3024fn mcp_template_json() -> Result<String> {
3025    let mut cfg = McpConfig::default();
3026    cfg.servers.insert(
3027        "example".to_string(),
3028        McpServerConfig {
3029            command: Some("node".to_string()),
3030            args: vec!["./path/to/your-mcp-server.js".to_string()],
3031            env: std::collections::HashMap::new(),
3032            cwd: None,
3033            url: None,
3034            transport: None,
3035            connect_timeout: None,
3036            execute_timeout: None,
3037            read_timeout: None,
3038            disabled: true,
3039            enabled: true,
3040            required: false,
3041            enabled_tools: Vec::new(),
3042            disabled_tools: Vec::new(),
3043            headers: std::collections::HashMap::new(),
3044            env_headers: std::collections::HashMap::new(),
3045            bearer_token_env_var: None,
3046            scopes: Vec::new(),
3047            oauth: None,
3048            oauth_resource: None,
3049            reviewed_plugin: None,
3050        },
3051    );
3052    serde_json::to_string_pretty(&cfg)
3053        .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
3054}
3055
3056fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
3057    let template = mcp_template_json()?;
3058    write_template_file(path, &template, force)
3059}
3060
3061fn skills_template(name: &str) -> String {
3062    format!(
3063        "\
3064---\n\
3065name: {name}\n\
3066description: Quick repo diagnostics and setup guidance\n\
3067allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
3068---\n\n\
3069When this skill is active:\n\
30701. Run the diagnostics tool to report workspace and sandbox status.\n\
30712. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
30723. Prefer small, validated changes and summarize what you verified.\n\
3073"
3074    )
3075}
3076
3077fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
3078    std::fs::create_dir_all(skills_dir)
3079        .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
3080
3081    let skill_name = "getting-started";
3082    let skill_path = skills_dir.join(skill_name).join("SKILL.md");
3083    ensure_parent_dir(&skill_path)?;
3084
3085    let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
3086    Ok((skill_path, status))
3087}
3088
3089fn tools_readme_template() -> &'static str {
3090    "# Local tools\n\n\
3091     Drop self-describing scripts here so they can be discovered by\n\
3092     `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
3093     When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
3094     `~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
3095     registered as model-visible tools.\n\n\
3096     Each script should start with a frontmatter-style header so the\n\
3097     description is visible without executing the file and the agent knows\n\
3098     the tool name, description, and input schema:\n\n\
3099     ```\n\
3100     # name: my-tool\n\
3101     # description: One-line summary of what this tool does\n\
3102     # usage: my-tool [args...]\n\
3103     ```\n\n\
3104     The directory is intentionally not auto-loaded into the agent's tool\n\
3105     catalog. Wire individual tools through MCP, hooks, or skills when you\n\
3106     want them available inside a session.\n"
3107}
3108
3109fn tools_example_script() -> &'static str {
3110    "#!/usr/bin/env sh\n\
3111     # name: example\n\
3112     # description: Print a confirmation that local tool discovery works\n\
3113     # usage: example [name]\n\
3114     printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n"
3115}
3116
3117fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
3118    std::fs::create_dir_all(tools_dir)
3119        .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
3120
3121    let readme_path = tools_dir.join("README.md");
3122    let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
3123
3124    let example_path = tools_dir.join("example.sh");
3125    let example_status = write_template_file(&example_path, tools_example_script(), force)?;
3126
3127    Ok((tools_dir.to_path_buf(), readme_status, example_status))
3128}
3129
3130fn plugins_readme_template() -> &'static str {
3131    "# Local plugins\n\n\
3132     Each Codewhale plugin bundle lives in its own subdirectory with a\n\
3133     versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
3134     under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
3135     untrusted, and disabled by default.\n\n\
3136     A v0.9.1 bundle layout looks like:\n\n\
3137     ```\n\
3138     plugins/\n\
3139       my-plugin/\n\
3140         plugin.toml\n\
3141         skills/\n\
3142           my-skill/SKILL.md\n\
3143     ```\n\n\
3144     Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
3145     Enablement opens a content- and capability-bound trust review;\n\
3146     confirm the displayed `/plugin trust` command to create an owner-only,\n\
3147     content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
3148     authentication must name environment sources; never store secret values\n\
3149     in `plugin.toml`.\n\n\
3150     v0.9.1 activates only declarative Skills and MCP servers through their\n\
3151     existing engines. Commands, agents, hooks, LSP, native extensions,\n\
3152     filesystem grants, and lifecycle mutation are inventoried but inactive.\n\
3153     There is no marketplace, install, update, ambient compatibility scan, or\n\
3154     automatic trust surface in this release.\n"
3155}
3156
3157fn plugin_example_manifest_template() -> &'static str {
3158    "schema_version = 1\n\n\
3159     [plugin]\n\
3160     name = \"example\"\n\
3161     version = \"0.1.0\"\n\
3162     description = \"Starter Codewhale plugin bundle\"\n\n\
3163     [skills]\n\
3164     path = \"skills\"\n"
3165}
3166
3167fn plugin_example_skill_template() -> &'static str {
3168    "---\n\
3169     name: hello\n\
3170     description: Explain that the example plugin bundle is active.\n\
3171     ---\n\n\
3172     Tell the user this instruction came from the namespaced\n\
3173     `example:hello` plugin skill. Do not perform side effects.\n"
3174}
3175
3176fn init_plugins_dir(
3177    plugins_dir: &Path,
3178    force: bool,
3179) -> Result<(
3180    PathBuf,
3181    PathBuf,
3182    PathBuf,
3183    WriteStatus,
3184    WriteStatus,
3185    WriteStatus,
3186)> {
3187    std::fs::create_dir_all(plugins_dir)
3188        .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
3189
3190    let readme_path = plugins_dir.join("README.md");
3191    let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
3192
3193    let manifest_path = plugins_dir.join("example").join("plugin.toml");
3194    ensure_parent_dir(&manifest_path)?;
3195    let manifest_status =
3196        write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
3197
3198    let skill_path = plugins_dir
3199        .join("example")
3200        .join("skills")
3201        .join("hello")
3202        .join("SKILL.md");
3203    ensure_parent_dir(&skill_path)?;
3204    let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
3205
3206    Ok((
3207        readme_path,
3208        manifest_path,
3209        skill_path,
3210        readme_status,
3211        manifest_status,
3212        skill_status,
3213    ))
3214}
3215
3216/// Resolve the user-supplied CORS origins for `codewhale serve --http`.
3217///
3218/// Sources, in priority order (later sources extend earlier ones):
3219/// 1. `--cors-origin URL` flags (repeatable)
3220/// 2. `CODEWHALE_CORS_ORIGINS` env var (comma-separated),
3221///    then `DEEPSEEK_CORS_ORIGINS` as an alias
3222/// 3. `[runtime_api] cors_origins = [...]` in `config.toml`
3223///
3224/// The runtime API always allows the built-in dev defaults
3225/// (localhost:3000, localhost:1420, tauri://localhost). User entries are
3226/// appended on top — empty strings are skipped, and duplicates are deduped
3227/// while preserving first-seen order. Whalescale#255 / #561.
3228fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
3229    let mut out: Vec<String> = Vec::new();
3230    let mut push = |raw: &str| {
3231        let trimmed = raw.trim();
3232        if trimmed.is_empty() {
3233            return;
3234        }
3235        if !out.iter().any(|existing| existing == trimmed) {
3236            out.push(trimmed.to_string());
3237        }
3238    };
3239    for o in flag_origins {
3240        push(o);
3241    }
3242    if let Ok(env_value) =
3243        std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
3244    {
3245        for piece in env_value.split(',') {
3246            push(piece);
3247        }
3248    }
3249    if let Some(rt) = &config.runtime_api
3250        && let Some(list) = &rt.cors_origins
3251    {
3252        for o in list {
3253            push(o);
3254        }
3255    }
3256    out
3257}
3258
3259fn deepseek_home_dir() -> PathBuf {
3260    codewhale_config::codewhale_home().unwrap_or_else(|_| {
3261        crate::config::effective_home_dir()
3262            .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
3263    })
3264}
3265
3266/// Resolve the default tools directory. Mirrors `default_skills_dir` shape.
3267fn default_tools_dir() -> PathBuf {
3268    deepseek_home_dir().join("tools")
3269}
3270
3271/// Resolve the default plugins directory.
3272fn default_plugins_dir() -> PathBuf {
3273    deepseek_home_dir().join("plugins")
3274}
3275
3276/// Default location for crash/offline-queue checkpoints managed by the TUI.
3277fn default_checkpoints_dir() -> PathBuf {
3278    deepseek_home_dir().join("sessions").join("checkpoints")
3279}
3280
3281#[derive(Debug, Clone, PartialEq, Eq)]
3282struct CleanPlan {
3283    targets: Vec<PathBuf>,
3284}
3285
3286fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
3287    // Every `*.json` file in the checkpoints directory is checkpoint state:
3288    // per-session crash checkpoints (`<session_id>.json`), the legacy
3289    // single-slot checkpoint (`latest.json`), and the offline input queue
3290    // (`offline_queue.json`). Non-JSON files and subdirectories are left
3291    // alone.
3292    let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
3293        .map(|entries| {
3294            entries
3295                .filter_map(|entry| entry.ok().map(|e| e.path()))
3296                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
3297                .collect()
3298        })
3299        .unwrap_or_default();
3300    targets.sort();
3301    CleanPlan { targets }
3302}
3303
3304fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
3305    let mut removed = Vec::with_capacity(plan.targets.len());
3306    for path in &plan.targets {
3307        std::fs::remove_file(path)
3308            .with_context(|| format!("Failed to remove {}", path.display()))?;
3309        removed.push(path.clone());
3310    }
3311    Ok(removed)
3312}
3313
3314fn run_setup(
3315    config: &Config,
3316    workspace: &Path,
3317    args: SetupArgs,
3318    plugins: &crate::plugins::PluginRegistry,
3319) -> Result<()> {
3320    if args.status {
3321        return run_setup_status(config, workspace, plugins);
3322    }
3323    if args.clean {
3324        return run_setup_clean(&default_checkpoints_dir(), args.force);
3325    }
3326
3327    use crate::palette;
3328    use colored::Colorize;
3329
3330    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3331    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3332
3333    let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
3334    let run_mcp = args.mcp || args.all || !any_explicit;
3335    let run_skills = args.skills || args.all || !any_explicit;
3336    let run_tools = args.tools || args.all;
3337    let run_plugins = args.plugins || args.all;
3338
3339    println!(
3340        "{}",
3341        "Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
3342    );
3343    println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
3344    println!("Workspace: {}", crate::utils::display_path(workspace));
3345
3346    if run_mcp {
3347        let mcp_path = config.mcp_config_path();
3348        let status = init_mcp_config(&mcp_path, args.force)?;
3349        match status {
3350            WriteStatus::Created => {
3351                println!("  ✓ Created MCP config at {}", mcp_path.display());
3352            }
3353            WriteStatus::Overwritten => {
3354                println!("  ✓ Overwrote MCP config at {}", mcp_path.display());
3355            }
3356            WriteStatus::SkippedExists => {
3357                println!("  · MCP config already exists at {}", mcp_path.display());
3358            }
3359        }
3360        println!(
3361            "    Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
3362        );
3363    }
3364
3365    if run_skills {
3366        let skills_dir = if args.local {
3367            workspace.join("skills")
3368        } else {
3369            config.skills_dir()
3370        };
3371        let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
3372        match status {
3373            WriteStatus::Created => {
3374                println!("  ✓ Created example skill at {}", skill_path.display());
3375            }
3376            WriteStatus::Overwritten => {
3377                println!("  ✓ Overwrote example skill at {}", skill_path.display());
3378            }
3379            WriteStatus::SkippedExists => {
3380                println!(
3381                    "  · Example skill already exists at {}",
3382                    skill_path.display()
3383                );
3384            }
3385        }
3386        if args.local {
3387            println!(
3388                "    Local skills dir enabled for this workspace: {}",
3389                crate::utils::display_path(&skills_dir)
3390            );
3391        } else {
3392            println!(
3393                "    Skills dir: {}",
3394                crate::utils::display_path(&skills_dir)
3395            );
3396        }
3397        println!("    Next: run the TUI and use `/skills` then `/skill getting-started`.");
3398    }
3399
3400    if run_tools {
3401        let tools_dir = default_tools_dir();
3402        let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
3403        report_write_status("Tools README", &dir.join("README.md"), readme_status);
3404        report_write_status("Example tool", &dir.join("example.sh"), example_status);
3405        println!("    Tools dir: {}", crate::utils::display_path(&dir));
3406        println!("    Next: drop scripts here; surface them via skills/MCP when ready.");
3407    }
3408
3409    if run_plugins {
3410        let plugins_dir = default_plugins_dir();
3411        let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
3412            init_plugins_dir(&plugins_dir, args.force)?;
3413        report_write_status("Plugins README", &readme_path, readme_status);
3414        report_write_status("Example plugin manifest", &manifest_path, manifest_status);
3415        report_write_status("Example plugin skill", &skill_path, skill_status);
3416        println!(
3417            "    Plugins dir: {}",
3418            crate::utils::display_path(&plugins_dir)
3419        );
3420        println!("    Next: run `/plugin validate`, review `example`, then trust and enable it.");
3421    }
3422
3423    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3424        config.prefer_bwrap.unwrap_or(false),
3425    );
3426    if let Some(kind) = sandbox {
3427        println!("  ✓ Sandbox available: {kind}");
3428    } else {
3429        println!("  · Sandbox not available on this platform (best-effort only).");
3430    }
3431
3432    Ok(())
3433}
3434
3435fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
3436    match status {
3437        WriteStatus::Created => {
3438            println!("  ✓ Created {label} at {}", path.display());
3439        }
3440        WriteStatus::Overwritten => {
3441            println!("  ✓ Overwrote {label} at {}", path.display());
3442        }
3443        WriteStatus::SkippedExists => {
3444            println!("  · {label} already exists at {}", path.display());
3445        }
3446    }
3447}
3448
3449/// Source of the resolved API key, used only by static doctor/setup reports.
3450///
3451/// These reports must not migrate a legacy secret store or acquire a
3452/// write-capable credential handle just to label a source.
3453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3454enum ApiKeySource {
3455    ConfigDeclared,
3456    EnvDeclared,
3457    ExternalAuthDeclared,
3458    SecretStoreUnprobed,
3459    SecretStoreUnavailable,
3460    OAuth,
3461    ExternalConsent,
3462    NoAuth,
3463    LocalRuntime,
3464    Unknown,
3465}
3466
3467/// What structural diagnostics can truthfully say about credential
3468/// availability without consulting environment values or durable stores.
3469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3470enum CredentialAvailability {
3471    Present,
3472    NotRequired,
3473    Unknown,
3474    NotProbed,
3475    Unavailable,
3476}
3477
3478impl CredentialAvailability {
3479    fn label(self) -> &'static str {
3480        match self {
3481            Self::Present => "present",
3482            Self::NotRequired => "not_required",
3483            Self::Unknown => "unknown",
3484            Self::NotProbed => "not_probed",
3485            Self::Unavailable => "unavailable",
3486        }
3487    }
3488
3489    fn certifies_ready(self) -> bool {
3490        matches!(self, Self::Present | Self::NotRequired)
3491    }
3492}
3493
3494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3495struct CredentialDiagnostic {
3496    source: ApiKeySource,
3497    availability: CredentialAvailability,
3498}
3499
3500impl CredentialDiagnostic {
3501    const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
3502        Self {
3503            source,
3504            availability,
3505        }
3506    }
3507}
3508
3509fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
3510    let provider = config.api_provider();
3511    let auth_mode = config.auth_mode_for_provider(provider);
3512    if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
3513        return CredentialDiagnostic::new(
3514            ApiKeySource::NoAuth,
3515            CredentialAvailability::NotRequired,
3516        );
3517    }
3518    if !crate::config::auth_mode_requires_api_key(auth_mode.as_deref())
3519        && (provider.is_self_hosted()
3520            || crate::config::base_url_uses_local_host(&config.deepseek_base_url()))
3521    {
3522        return CredentialDiagnostic::new(
3523            ApiKeySource::LocalRuntime,
3524            CredentialAvailability::NotRequired,
3525        );
3526    }
3527    let custom_endpoint = config.provider_uses_custom_endpoint(provider);
3528    if !custom_endpoint && provider == crate::config::ApiProvider::OpenaiCodex {
3529        return config
3530            .external_credential_consent_status(provider)
3531            .filter(|status| status.route_state == "active")
3532            .map_or_else(
3533                || {
3534                    CredentialDiagnostic::new(
3535                        ApiKeySource::OAuth,
3536                        CredentialAvailability::NotProbed,
3537                    )
3538                },
3539                |_| {
3540                    CredentialDiagnostic::new(
3541                        ApiKeySource::ExternalConsent,
3542                        CredentialAvailability::NotProbed,
3543                    )
3544                },
3545            );
3546    }
3547    if !custom_endpoint
3548        && provider == crate::config::ApiProvider::Xai
3549        && auth_mode
3550            .as_deref()
3551            .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3552    {
3553        return config
3554            .external_credential_consent_status(provider)
3555            .filter(|status| status.route_state == "active")
3556            .map_or_else(
3557                || {
3558                    CredentialDiagnostic::new(
3559                        ApiKeySource::OAuth,
3560                        CredentialAvailability::NotProbed,
3561                    )
3562                },
3563                |_| {
3564                    CredentialDiagnostic::new(
3565                        ApiKeySource::ExternalConsent,
3566                        CredentialAvailability::NotProbed,
3567                    )
3568                },
3569            );
3570    }
3571    let provider_config = config.provider_config();
3572    let provider_config_key_kind = provider_config
3573        .and_then(|entry| entry.api_key.as_deref())
3574        .map(crate::config::classify_config_api_key_value);
3575    let root_key_applies = matches!(
3576        provider,
3577        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
3578    ) || (provider == crate::config::ApiProvider::Custom
3579        && config.uses_legacy_literal_custom_route());
3580    let root_key_kind = root_key_applies
3581        .then_some(config.api_key.as_deref())
3582        .flatten()
3583        .map(crate::config::classify_config_api_key_value);
3584
3585    if matches!(
3586        provider_config_key_kind,
3587        Some(crate::config::ConfigApiKeyValueKind::Literal)
3588    ) || matches!(
3589        root_key_kind,
3590        Some(crate::config::ConfigApiKeyValueKind::Literal)
3591    ) {
3592        CredentialDiagnostic::new(
3593            ApiKeySource::ConfigDeclared,
3594            CredentialAvailability::Present,
3595        )
3596    } else if config
3597        .provider_config()
3598        .and_then(|entry| entry.api_key_env.as_deref())
3599        .is_some_and(|name| !name.trim().is_empty())
3600    {
3601        CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
3602    } else if config
3603        .provider_config()
3604        .and_then(|entry| entry.auth.as_ref())
3605        .is_some()
3606    {
3607        CredentialDiagnostic::new(
3608            ApiKeySource::ExternalAuthDeclared,
3609            CredentialAvailability::NotProbed,
3610        )
3611    } else if matches!(
3612        provider_config_key_kind,
3613        Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3614    ) || matches!(
3615        root_key_kind,
3616        Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3617    ) {
3618        if config.should_skip_secret_store_for_provider(provider) {
3619            return CredentialDiagnostic::new(
3620                ApiKeySource::SecretStoreUnavailable,
3621                CredentialAvailability::Unavailable,
3622            );
3623        }
3624        // The sentinel is a declaration that runtime resolution should use
3625        // the secret-store layer, never a literal key. Doctor does not read it.
3626        CredentialDiagnostic::new(
3627            ApiKeySource::SecretStoreUnprobed,
3628            CredentialAvailability::NotProbed,
3629        )
3630    } else if !config.should_skip_secret_store_for_provider(provider) {
3631        // No literal config declaration was found, but this route can continue
3632        // through the durable store and ambient provider environment. Ordinary
3633        // doctor deliberately does not inspect either source.
3634        CredentialDiagnostic::new(
3635            ApiKeySource::SecretStoreUnprobed,
3636            CredentialAvailability::NotProbed,
3637        )
3638    } else {
3639        CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
3640    }
3641}
3642
3643#[cfg(test)]
3644fn resolve_api_key_source(config: &Config) -> ApiKeySource {
3645    resolve_credential_diagnostic(config).source
3646}
3647
3648fn provider_config_table_key(provider: crate::config::ApiProvider) -> &'static str {
3649    provider
3650        .metadata()
3651        .map(|metadata| metadata.provider_config_key())
3652        .unwrap_or("deepseek_cn")
3653}
3654
3655fn count_dir_entries(dir: &Path) -> usize {
3656    std::fs::read_dir(dir)
3657        .map(|entries| entries.filter_map(std::result::Result::ok).count())
3658        .unwrap_or(0)
3659}
3660
3661fn skills_count_for(dir: &Path) -> usize {
3662    if !dir.exists() {
3663        return 0;
3664    }
3665    crate::skills::SkillRegistry::discover(dir).len()
3666}
3667
3668fn run_setup_status(
3669    config: &Config,
3670    workspace: &Path,
3671    plugins: &crate::plugins::PluginRegistry,
3672) -> Result<()> {
3673    use crate::palette;
3674    use colored::Colorize;
3675
3676    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3677    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3678
3679    println!(
3680        "{}",
3681        "Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
3682    );
3683    println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
3684    println!("workspace: {}", workspace.display());
3685
3686    let credential = resolve_credential_diagnostic(config);
3687    match credential.source {
3688        ApiKeySource::ConfigDeclared => println!(
3689            "  {} api_key: literal config value structurally present",
3690            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3691        ),
3692        ApiKeySource::EnvDeclared => println!(
3693            "  {} api_key: environment source declared (value not inspected)",
3694            "·".dimmed()
3695        ),
3696        ApiKeySource::ExternalAuthDeclared => println!(
3697            "  {} api_key: external auth source declared (value not inspected)",
3698            "·".dimmed()
3699        ),
3700        ApiKeySource::SecretStoreUnprobed => println!(
3701            "  {} api_key: secret store eligible (store not probed)",
3702            "·".dimmed()
3703        ),
3704        ApiKeySource::SecretStoreUnavailable => println!(
3705            "  {} api_key: secret-store sentinel declared, but this route cannot use that store",
3706            "!".truecolor(sky_r, sky_g, sky_b)
3707        ),
3708        ApiKeySource::OAuth => println!(
3709            "  {} oauth: Codewhale-owned route selected (token availability not probed)",
3710            "·".dimmed()
3711        ),
3712        ApiKeySource::ExternalConsent => println!(
3713            "  {} oauth: external read-only consent configured (credential file not probed)",
3714            "·".dimmed()
3715        ),
3716        ApiKeySource::NoAuth => println!(
3717            "  {} api_key: disabled for this route",
3718            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3719        ),
3720        ApiKeySource::LocalRuntime => println!(
3721            "  {} api_key: not required for this local runtime",
3722            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3723        ),
3724        ApiKeySource::Unknown => println!(
3725            "  {} api_key: unknown (credential environment and durable stores not inspected)",
3726            "·".dimmed()
3727        ),
3728    }
3729    println!(
3730        "  · credential availability: {}",
3731        credential.availability.label()
3732    );
3733    println!(
3734        "  · base_url: {}",
3735        crate::doctor::structural_url_authority(&config.deepseek_base_url())
3736    );
3737    let model = config
3738        .default_text_model
3739        .clone()
3740        .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
3741    println!("  · default_text_model: {model}");
3742    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
3743    println!("  · default_mode: {default_mode} ({default_mode_source})");
3744
3745    let mcp_path = config.mcp_config_path();
3746    let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
3747    let mcp_count =
3748        match crate::mcp::load_config_with_workspace_and_plugins(&mcp_path, workspace, plugins) {
3749            Ok(cfg) => cfg.servers.len(),
3750            Err(_) => 0,
3751        };
3752    let mcp_present = if mcp_path.exists() { "" } else { "  (missing)" };
3753    let project_mcp_present = if project_mcp_path.exists() {
3754        ""
3755    } else {
3756        "  (missing)"
3757    };
3758    println!(
3759        "  · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
3760        mcp_path.display(),
3761        project_mcp_path.display()
3762    );
3763
3764    let skills_dir = config.skills_dir();
3765    println!(
3766        "  · skills: {} at {}",
3767        skills_count_for(&skills_dir),
3768        crate::utils::display_path(&skills_dir)
3769    );
3770
3771    let tools_dir = default_tools_dir();
3772    let tools_present = if tools_dir.exists() {
3773        ""
3774    } else {
3775        "  (missing — run `setup --tools`)"
3776    };
3777    println!(
3778        "  · tools: {} entries at {}{tools_present}",
3779        if tools_dir.exists() {
3780            count_dir_entries(&tools_dir)
3781        } else {
3782            0
3783        },
3784        crate::utils::display_path(&tools_dir)
3785    );
3786
3787    let plugins_dir = default_plugins_dir();
3788    let plugins_present = if plugins_dir.exists() {
3789        ""
3790    } else {
3791        "  (missing — run `setup --plugins`)"
3792    };
3793    println!(
3794        "  · plugins: {} entries at {}{plugins_present}",
3795        if plugins_dir.exists() {
3796            count_dir_entries(&plugins_dir)
3797        } else {
3798            0
3799        },
3800        crate::utils::display_path(&plugins_dir)
3801    );
3802
3803    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3804        config.prefer_bwrap.unwrap_or(false),
3805    );
3806    match sandbox {
3807        Some(kind) => println!(
3808            "  {} sandbox: {kind}",
3809            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3810        ),
3811        None => println!(
3812            "  {} sandbox: unavailable (commands run best-effort)",
3813            "!".truecolor(sky_r, sky_g, sky_b)
3814        ),
3815    }
3816
3817    println!("  {} {}", "·".dimmed(), dotenv_status_line(workspace));
3818
3819    println!();
3820    println!("Run `codewhale doctor --json` for a machine-readable check.");
3821    Ok(())
3822}
3823
3824fn dotenv_status_line(workspace: &Path) -> String {
3825    let dotenv = workspace.join(".env");
3826    if dotenv.exists() {
3827        return format!(
3828            ".env present at {} (literal provider credentials only)",
3829            dotenv.display()
3830        );
3831    }
3832
3833    if workspace.join(".env.example").exists() {
3834        return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
3835    }
3836
3837    ".env not present in workspace".to_string()
3838}
3839
3840fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
3841    use colored::Colorize;
3842
3843    if !checkpoints_dir.exists() {
3844        println!(
3845            "Nothing to clean — checkpoints dir does not exist: {}",
3846            checkpoints_dir.display()
3847        );
3848        return Ok(());
3849    }
3850
3851    let plan = collect_clean_targets(checkpoints_dir);
3852    if plan.targets.is_empty() {
3853        println!(
3854            "Nothing to clean — no checkpoint files in {}",
3855            checkpoints_dir.display()
3856        );
3857        return Ok(());
3858    }
3859
3860    if !force {
3861        println!(
3862            "Would remove {} checkpoint file(s) (use --force to apply):",
3863            plan.targets.len()
3864        );
3865        for path in &plan.targets {
3866            println!("  · {}", path.display());
3867        }
3868        return Ok(());
3869    }
3870
3871    let removed = execute_clean_plan(&plan)?;
3872    println!("{}", "Cleaned checkpoints:".bold());
3873    for path in &removed {
3874        println!("  ✓ {}", path.display());
3875    }
3876    Ok(())
3877}
3878
3879fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
3880    let contents = std::fs::read_to_string(&args.path).with_context(|| {
3881        format!(
3882            "read session diagnostic JSONL from {}",
3883            crate::utils::display_path(&args.path)
3884        )
3885    })?;
3886    let summary = crate::session_diagnostics::analyze_session_failure_jsonl(&contents);
3887    if args.json {
3888        println!("{}", serde_json::to_string_pretty(&summary)?);
3889    } else {
3890        println!(
3891            "{}",
3892            crate::session_diagnostics::format_redacted_failure_summary(&summary)
3893        );
3894    }
3895    Ok(())
3896}
3897
3898/// Live API checks are explicit. Local endpoints have a separate opt-in because
3899/// an HTTP request can wake a desktop-managed daemon (notably Ollama.app).
3900fn doctor_should_probe_api(
3901    provider: crate::config::ApiProvider,
3902    base_url: &str,
3903    probes: crate::doctor::DoctorProbeRequest,
3904) -> bool {
3905    let local = provider.is_self_hosted() || crate::config::base_url_uses_local_host(base_url);
3906    probes.should_probe_api(local)
3907}
3908
3909/// Doctor must never turn credential inspection into a refresh/write path.
3910/// OAuth connectivity is exercised by an ordinary user request instead;
3911/// doctor limits itself to non-mutating readiness inspection.
3912fn doctor_should_probe_auth(config: &Config) -> bool {
3913    let provider = config.api_provider();
3914    if provider == crate::config::ApiProvider::OpenaiCodex
3915        && !config.provider_uses_custom_endpoint(provider)
3916    {
3917        return false;
3918    }
3919    let auth_mode = config.auth_mode_for_provider(provider);
3920    if provider == crate::config::ApiProvider::Xai
3921        && auth_mode
3922            .as_deref()
3923            .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3924    {
3925        return false;
3926    }
3927    !(provider == crate::config::ApiProvider::Moonshot
3928        && auth_mode
3929            .as_deref()
3930            .is_some_and(crate::config::auth_mode_uses_kimi_imported_token))
3931}
3932
3933/// Run system diagnostics
3934async fn run_doctor(
3935    config: &Config,
3936    workspace: &Path,
3937    config_path_override: Option<&Path>,
3938    probes: crate::doctor::DoctorProbeRequest,
3939    plugins: &crate::plugins::PluginRegistry,
3940) {
3941    use crate::palette;
3942    use colored::Colorize;
3943
3944    let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
3945    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3946    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3947    let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
3948
3949    println!(
3950        "{}",
3951        "codewhale Doctor"
3952            .truecolor(accent_r, accent_g, accent_b)
3953            .bold()
3954    );
3955    println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
3956    println!();
3957
3958    // Version info
3959    println!("{}", "Version Information:".bold());
3960    println!("  codewhale-tui: {}", env!("DEEPSEEK_BUILD_VERSION"));
3961    println!("  rust: {}", rustc_version());
3962    println!();
3963
3964    println!("{}", "Updates:".bold());
3965    crate::doctor::print_update_report(probes).await;
3966    println!();
3967
3968    // Configuration summary
3969    let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
3970        Ok(paths) => paths,
3971        Err(error) => {
3972            println!("{}", "Resolved User Paths:".bold());
3973            println!(
3974                "  {} unavailable: {error:#}",
3975                "✗".truecolor(red_r, red_g, red_b)
3976            );
3977            return;
3978        }
3979    };
3980    println!("{}", "Configuration:".bold());
3981    let config_path = &doctor_paths.config;
3982
3983    if config_path.exists() {
3984        println!(
3985            "  {} config.toml found at {}",
3986            "✓".truecolor(aqua_r, aqua_g, aqua_b),
3987            crate::utils::display_path(config_path)
3988        );
3989        // Secret hygiene: name the keys, never the values. Plain-text config
3990        // is not a secret store.
3991        if let Ok(raw) = std::fs::read_to_string(config_path) {
3992            let flagged = crate::doctor::config_credential_shaped_keys(&raw);
3993            if !flagged.is_empty() {
3994                println!(
3995                    "  {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
3996                    "!".truecolor(sky_r, sky_g, sky_b),
3997                    flagged.join(", ")
3998                );
3999            }
4000        }
4001    } else {
4002        println!(
4003            "  {} config.toml not found at {} (using defaults/env)",
4004            "!".truecolor(sky_r, sky_g, sky_b),
4005            crate::utils::display_path(config_path)
4006        );
4007    }
4008    println!("  workspace: {}", crate::utils::display_path(workspace));
4009    println!("  {}", doctor_search_provider_line(config));
4010
4011    println!();
4012    println!("{}", "Resolved User Paths (read-only):".bold());
4013    for (label, path) in doctor_paths.entries() {
4014        println!("  · {label}: {}", crate::utils::display_path(path));
4015    }
4016
4017    let secret_backend = codewhale_secrets::diagnose_secret_backend();
4018    println!();
4019    println!("{}", "Secret Backend (structural only):".bold());
4020    for line in crate::doctor::secret_backend_human_lines(&secret_backend) {
4021        println!("  · {line}");
4022    }
4023
4024    // State root (v0.8.44)
4025    println!();
4026    println!("{}", "State Root:".bold());
4027    let (code_home, legacy_home) = doctor_state_roots();
4028    let active_root = if code_home.exists() {
4029        &code_home
4030    } else if legacy_home.exists() {
4031        &legacy_home
4032    } else {
4033        &code_home
4034    };
4035    println!("  active: {}", crate::utils::display_path(active_root));
4036    if active_root != &code_home {
4037        println!(
4038            "  note: legacy {} found; start Codewhale once to trigger safe migration where available.",
4039            crate::utils::display_path(&legacy_home)
4040        );
4041    }
4042    if legacy_home.exists() && code_home.exists() {
4043        println!(
4044            "  dual roots: {} (primary) + {} (legacy)",
4045            crate::utils::display_path(&code_home),
4046            crate::utils::display_path(&legacy_home)
4047        );
4048    }
4049    let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
4050    let session_recovery = doctor_session_recovery_report(
4051        &code_home,
4052        &legacy_home,
4053        codewhale_config::codewhale_home_is_explicit(),
4054    );
4055    print_doctor_legacy_state_report(
4056        &legacy_state_report,
4057        &session_recovery,
4058        (aqua_r, aqua_g, aqua_b),
4059        (sky_r, sky_g, sky_b),
4060    );
4061
4062    let (setup_state, setup_source) = doctor_setup_state(config, workspace);
4063    print_doctor_setup_report(
4064        config,
4065        workspace,
4066        &setup_state,
4067        setup_source,
4068        (aqua_r, aqua_g, aqua_b),
4069        (sky_r, sky_g, sky_b),
4070    );
4071
4072    // Check API keys
4073    println!();
4074    println!("{}", "API Keys:".bold());
4075
4076    // Per-provider state: env + config file only (no values printed).
4077    // Keep doctor/status prompt-free and credential-value-free even for
4078    // unsigned rebuilt binaries.
4079    for provider in crate::config::ApiProvider::all().iter().copied() {
4080        let slot = provider.as_str();
4081        let provider_config = config.provider_config_for(provider);
4082        let config_declared = provider_config.is_some_and(|entry| {
4083            entry.api_key.as_deref().is_some_and(|key| {
4084                crate::config::classify_config_api_key_value(key)
4085                    == crate::config::ConfigApiKeyValueKind::Literal
4086            })
4087        }) || (matches!(provider, crate::config::ApiProvider::Deepseek)
4088            && config.api_key.as_deref().is_some_and(|key| {
4089                crate::config::classify_config_api_key_value(key)
4090                    == crate::config::ConfigApiKeyValueKind::Literal
4091            }));
4092        let env_source_declared = provider_config
4093            .and_then(|entry| entry.api_key_env.as_deref())
4094            .is_some_and(|name| !name.trim().is_empty());
4095        let icon = if config_declared || env_source_declared {
4096            "·".truecolor(aqua_r, aqua_g, aqua_b)
4097        } else {
4098            "·".dimmed()
4099        };
4100        println!(
4101            "  {} {slot}: env_source={}, config_source={}",
4102            icon,
4103            if env_source_declared {
4104                "declared (value not inspected)"
4105            } else {
4106                "not inspected"
4107            },
4108            if config_declared {
4109                "declared (value not inspected)"
4110            } else {
4111                "not declared"
4112            }
4113        );
4114    }
4115    println!("  · credential precedence is unchanged; doctor does not inspect credential values");
4116    println!();
4117    println!(
4118        "{}",
4119        "External credential consent (configuration only):".bold()
4120    );
4121    for line in doctor_external_credential_consent_lines(config) {
4122        println!("  {line}");
4123    }
4124
4125    let credential = resolve_credential_diagnostic(config);
4126    let source_label = match credential.source {
4127        ApiKeySource::ConfigDeclared => "literal config value structurally present",
4128        ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
4129        ApiKeySource::ExternalAuthDeclared => {
4130            "external auth source declared; credential not resolved"
4131        }
4132        ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
4133        ApiKeySource::SecretStoreUnavailable => {
4134            "secret-store sentinel declared, but this route cannot use that store"
4135        }
4136        ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
4137        ApiKeySource::ExternalConsent => "external consent configured; token file not read",
4138        ApiKeySource::NoAuth => "no-auth route",
4139        ApiKeySource::LocalRuntime => "local runtime; credentials not required",
4140        ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
4141    };
4142    println!(
4143        "  {} active provider credential source: {source_label}",
4144        "·".dimmed()
4145    );
4146    println!(
4147        "  · active provider credential availability: {}",
4148        credential.availability.label()
4149    );
4150
4151    // API connectivity test
4152    println!();
4153    println!("{}", "API Connectivity:".bold());
4154    let api_target = doctor_api_target(config);
4155    // Configured-vs-active honesty (DGF-01): doctor describes the route a
4156    // session launched NOW would resolve. It cannot see inside an already
4157    // running session, which keeps the route it resolved at its own launch.
4158    println!(
4159        "  · 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)"
4160    );
4161    println!("  · provider: {}", api_target.provider);
4162    println!(
4163        "  · base_url: {}",
4164        crate::doctor::structural_url_authority(&api_target.base_url)
4165    );
4166    match api_target.resolution {
4167        DoctorModelResolution::Resolved => {
4168            println!("  · model: {} (resolved)", api_target.model);
4169        }
4170        DoctorModelResolution::ConfiguredOnly => {
4171            println!(
4172                "  · model: {} (configured; route resolution unavailable)",
4173                api_target.model
4174            );
4175        }
4176    }
4177    let tls_status = doctor_tls_status(config);
4178    if !tls_status.certificate_verification {
4179        println!("  ! {}", tls_status.message);
4180        println!("    Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
4181    }
4182    let strict_tool_mode = doctor_strict_tool_mode_status(config);
4183    let strict_icon = match strict_tool_mode.status {
4184        "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
4185        "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
4186        _ => "·".dimmed(),
4187    };
4188    println!(
4189        "  {} strict_tool_mode: {}",
4190        strict_icon, strict_tool_mode.message
4191    );
4192    if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
4193        println!(
4194            "    Use the {} endpoint for DeepSeek strict schemas.",
4195            crate::doctor::structural_url_authority(recommended)
4196        );
4197    }
4198    let capability = crate::config::provider_capability(config.api_provider(), &api_target.model);
4199    if let Some(alias) = capability.alias_deprecation.as_ref() {
4200        println!(
4201            "  ! model alias {} retires {}; switch to {}",
4202            alias.alias, alias.retirement_date, alias.replacement
4203        );
4204    }
4205    let live_api_requested =
4206        doctor_should_probe_api(config.api_provider(), &api_target.base_url, probes);
4207    let endpoint_is_local = config.api_provider().is_self_hosted()
4208        || crate::config::base_url_uses_local_host(&api_target.base_url);
4209    if doctor_should_probe_auth(config) && live_api_requested {
4210        print!("  {} Testing connection...", "·".dimmed());
4211        use std::io::Write;
4212        std::io::stdout().flush().ok();
4213
4214        // Resolve a credential through the diagnostic-only store first, then
4215        // probe with an in-memory clone. Constructing the normal client from
4216        // the original config could otherwise trigger its legacy secret-store
4217        // migration while a user merely asks doctor to test connectivity.
4218        let connectivity_result = match config.with_read_only_api_key_for_diagnostic() {
4219            Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
4220            Err(error) => Err(error),
4221        };
4222        match connectivity_result {
4223            Ok(()) => {
4224                println!(
4225                    "\r  {} API connection successful",
4226                    "✓".truecolor(aqua_r, aqua_g, aqua_b)
4227                );
4228            }
4229            Err(e) => {
4230                let error_msg = e.to_string();
4231                println!(
4232                    "\r  {} API connection failed",
4233                    "✗".truecolor(red_r, red_g, red_b)
4234                );
4235                if error_msg.contains("401") || error_msg.contains("Unauthorized") {
4236                    println!(
4237                        "    Invalid API key. Check `codewhale auth status`, DEEPSEEK_API_KEY, or config.toml"
4238                    );
4239                } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
4240                    println!(
4241                        "    API key lacks permissions. Verify key is active at platform.deepseek.com"
4242                    );
4243                } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
4244                    for line in doctor_timeout_recovery_lines(config) {
4245                        println!("    {line}");
4246                    }
4247                } else if error_msg.contains("dns") || error_msg.contains("resolve") {
4248                    println!("    DNS resolution failed. Check your network connection");
4249                } else if error_msg.contains("connect") {
4250                    println!("    Connection failed. Check firewall settings or try again");
4251                } else {
4252                    println!(
4253                        "    Error details omitted because provider failures can contain credential material."
4254                    );
4255                }
4256            }
4257        }
4258    } else if !doctor_should_probe_auth(config) {
4259        println!(
4260            "  {} Live OAuth connectivity not checked by non-mutating doctor",
4261            "·".dimmed()
4262        );
4263        println!(
4264            "    Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
4265        );
4266    } else {
4267        if endpoint_is_local {
4268            println!(
4269                "  {} Live connectivity not checked for this local endpoint",
4270                "·".dimmed()
4271            );
4272            println!(
4273                "    Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
4274            );
4275        } else {
4276            println!(
4277                "  {} Live hosted connectivity not checked (offline default)",
4278                "·".dimmed()
4279            );
4280            println!("    Run `codewhale doctor --probe-api` to opt in.");
4281        }
4282    }
4283
4284    // MCP configuration
4285    println!();
4286    println!("{}", "MCP Servers (configuration only):".bold());
4287    println!("  · Static check only; no server process was started.");
4288    let features = config.features();
4289    if features.enabled(Feature::Mcp) {
4290        println!(
4291            "  {} MCP feature flag enabled",
4292            "✓".truecolor(aqua_r, aqua_g, aqua_b)
4293        );
4294    } else {
4295        println!(
4296            "  {} MCP feature flag disabled",
4297            "!".truecolor(sky_r, sky_g, sky_b)
4298        );
4299    }
4300
4301    let mcp_config_path = config.mcp_config_path();
4302    let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
4303    if mcp_config_path.exists() {
4304        println!(
4305            "  {} MCP config found at {}",
4306            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4307            crate::utils::display_path(&mcp_config_path)
4308        );
4309    } else {
4310        println!(
4311            "  {} MCP config not found at {}",
4312            "·".dimmed(),
4313            crate::utils::display_path(&mcp_config_path)
4314        );
4315    }
4316    if project_mcp_config_path.exists() {
4317        println!(
4318            "  {} Project MCP config found at {}",
4319            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4320            crate::utils::display_path(&project_mcp_config_path)
4321        );
4322    } else {
4323        println!(
4324            "  {} Project MCP config not found at {}",
4325            "·".dimmed(),
4326            crate::utils::display_path(&project_mcp_config_path)
4327        );
4328    }
4329
4330    match crate::mcp::load_config_with_workspace_and_plugins(&mcp_config_path, workspace, plugins) {
4331        Ok(cfg) if cfg.servers.is_empty() => {
4332            println!("  {} 0 merged server(s) configured", "·".dimmed());
4333            if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
4334                println!("    Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
4335            }
4336        }
4337        Ok(cfg) => {
4338            println!(
4339                "  {} {} merged server(s) configured",
4340                "·".dimmed(),
4341                cfg.servers.len()
4342            );
4343            for (name, server) in &cfg.servers {
4344                let status = doctor_check_mcp_server(server);
4345                let icon = match &status {
4346                    McpServerDoctorStatus::Ok(detail) => {
4347                        format!(
4348                            "  {} {name}: configuration valid; {}",
4349                            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4350                            detail
4351                        )
4352                    }
4353                    McpServerDoctorStatus::Warning(detail) => {
4354                        format!(
4355                            "  {} {name}: configuration warning; {}",
4356                            "!".truecolor(sky_r, sky_g, sky_b),
4357                            detail
4358                        )
4359                    }
4360                    McpServerDoctorStatus::Error(detail) => {
4361                        format!(
4362                            "  {} {name}: configuration invalid; {}",
4363                            "✗".truecolor(red_r, red_g, red_b),
4364                            detail
4365                        )
4366                    }
4367                };
4368                println!("{icon}");
4369                if !server.is_enabled() {
4370                    println!("      disabled; live health not checked");
4371                } else {
4372                    println!(
4373                        "      process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
4374                    );
4375                }
4376            }
4377            if probes.should_probe_mcp() {
4378                println!();
4379                println!(
4380                    "  {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
4381                    "!".truecolor(sky_r, sky_g, sky_b)
4382                );
4383                match crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
4384                    &mcp_config_path,
4385                    workspace,
4386                    std::sync::Arc::new(plugins.clone()),
4387                ) {
4388                    Ok(mut pool) => {
4389                        let errors = pool.connect_all().await;
4390                        let failed = errors
4391                            .iter()
4392                            .map(|(name, _)| name.as_str())
4393                            .collect::<std::collections::BTreeSet<_>>();
4394                        for (name, server) in &cfg.servers {
4395                            if !server.is_enabled() {
4396                                continue;
4397                            }
4398                            if failed.contains(name.as_str()) {
4399                                println!(
4400                                    "      {} {name}: process/protocol unreachable; error details omitted",
4401                                    "✗".truecolor(red_r, red_g, red_b)
4402                                );
4403                            } else {
4404                                println!(
4405                                    "      {} {name}: process reachable and protocol initialized; backend tool health not checked",
4406                                    "✓".truecolor(aqua_r, aqua_g, aqua_b)
4407                                );
4408                            }
4409                        }
4410                    }
4411                    Err(_) => println!(
4412                        "      {} live MCP probe could not load merged configuration; details omitted",
4413                        "✗".truecolor(red_r, red_g, red_b)
4414                    ),
4415                }
4416            } else {
4417                println!(
4418                    "    Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
4419                );
4420            }
4421        }
4422        Err(_) => {
4423            println!(
4424                "  {} MCP configuration could not be loaded; details omitted",
4425                "✗".truecolor(red_r, red_g, red_b)
4426            );
4427        }
4428    }
4429
4430    // Skills configuration
4431    println!();
4432    println!("{}", "Skills:".bold());
4433    let global_skills_dir = config.skills_dir();
4434    let agents_skills_dir = workspace.join(".agents").join("skills");
4435    let local_skills_dir = workspace.join("skills");
4436    let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
4437    // #432: cross-tool skill discovery dirs. Presence is reported here
4438    // even though they sit lower in the precedence chain so users can
4439    // see at a glance whether a `.opencode/skills/`, `.claude/skills/`,
4440    // `.cursor/skills/`, or global agentskills.io directory is contributing
4441    // to the merged catalogue.
4442    let opencode_skills_dir = workspace.join(".opencode").join("skills");
4443    let claude_skills_dir = workspace.join(".claude").join("skills");
4444    let selected_skills_dir = if agents_skills_dir.exists() {
4445        agents_skills_dir.clone()
4446    } else if local_skills_dir.exists() {
4447        local_skills_dir.clone()
4448    } else if config.skills_dir.is_none()
4449        && let Some(global_agents) = agents_global_skills_dir.as_ref()
4450        && global_agents.exists()
4451    {
4452        global_agents.clone()
4453    } else {
4454        global_skills_dir.clone()
4455    };
4456
4457    let describe_dir = |dir: &Path| -> usize {
4458        std::fs::read_dir(dir)
4459            .map(|entries| entries.filter_map(std::result::Result::ok).count())
4460            .unwrap_or(0)
4461    };
4462
4463    if local_skills_dir.exists() {
4464        println!(
4465            "  {} local skills dir found at {} ({} items)",
4466            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4467            crate::utils::display_path(&local_skills_dir),
4468            describe_dir(&local_skills_dir)
4469        );
4470    } else {
4471        println!(
4472            "  {} local skills dir not found at {}",
4473            "·".dimmed(),
4474            crate::utils::display_path(&local_skills_dir)
4475        );
4476    }
4477
4478    if agents_skills_dir.exists() {
4479        println!(
4480            "  {} .agents skills dir found at {} ({} items)",
4481            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4482            crate::utils::display_path(&agents_skills_dir),
4483            describe_dir(&agents_skills_dir)
4484        );
4485    } else {
4486        println!(
4487            "  {} .agents skills dir not found at {}",
4488            "·".dimmed(),
4489            crate::utils::display_path(&agents_skills_dir)
4490        );
4491    }
4492
4493    if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
4494        if agents_global_skills_dir.exists() {
4495            println!(
4496                "  {} global .agents skills dir found at {} ({} items)",
4497                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4498                crate::utils::display_path(agents_global_skills_dir),
4499                describe_dir(agents_global_skills_dir)
4500            );
4501        } else {
4502            println!(
4503                "  {} global .agents skills dir not found at {}",
4504                "·".dimmed(),
4505                crate::utils::display_path(agents_global_skills_dir)
4506            );
4507        }
4508    }
4509
4510    if global_skills_dir.exists() {
4511        println!(
4512            "  {} global skills dir found at {} ({} items)",
4513            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4514            crate::utils::display_path(&global_skills_dir),
4515            describe_dir(&global_skills_dir)
4516        );
4517    } else {
4518        println!(
4519            "  {} global skills dir not found at {}",
4520            "·".dimmed(),
4521            crate::utils::display_path(&global_skills_dir)
4522        );
4523    }
4524
4525    // #432: only print interop dirs when they're populated — empty
4526    // .opencode/.claude folders are common and would just clutter
4527    // the report with false-positive "absent" lines.
4528    if opencode_skills_dir.exists() {
4529        println!(
4530            "  {} .opencode skills dir found at {} ({} items)",
4531            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4532            crate::utils::display_path(&opencode_skills_dir),
4533            describe_dir(&opencode_skills_dir)
4534        );
4535    }
4536    if claude_skills_dir.exists() {
4537        println!(
4538            "  {} .claude skills dir found at {} ({} items)",
4539            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4540            crate::utils::display_path(&claude_skills_dir),
4541            describe_dir(&claude_skills_dir)
4542        );
4543    }
4544
4545    println!(
4546        "  {} selected skills dir: {}",
4547        "·".dimmed(),
4548        crate::utils::display_path(&selected_skills_dir)
4549    );
4550    if !agents_skills_dir.exists()
4551        && !local_skills_dir.exists()
4552        && !agents_global_skills_dir
4553            .as_ref()
4554            .is_some_and(|dir| dir.exists())
4555        && !global_skills_dir.exists()
4556    {
4557        println!("    Run `codewhale setup --skills` (or add --local for ./skills).");
4558    }
4559
4560    // Tools directory
4561    println!();
4562    println!("{}", "Tools:".bold());
4563    let tools_dir = default_tools_dir();
4564    if tools_dir.exists() {
4565        let count = count_dir_entries(&tools_dir);
4566        println!(
4567            "  {} tools dir found at {} ({} items)",
4568            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4569            crate::utils::display_path(&tools_dir),
4570            count
4571        );
4572    } else {
4573        println!(
4574            "  {} tools dir not found at {}",
4575            "·".dimmed(),
4576            crate::utils::display_path(&tools_dir)
4577        );
4578        println!("    Run `codewhale setup --tools` to scaffold a starter dir.");
4579    }
4580
4581    // Plugins directory
4582    println!();
4583    println!("{}", "Plugins:".bold());
4584    let plugins_dir = default_plugins_dir();
4585    if plugins_dir.exists() {
4586        let count = count_dir_entries(&plugins_dir);
4587        println!(
4588            "  {} plugins dir found at {} ({} items)",
4589            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4590            crate::utils::display_path(&plugins_dir),
4591            count
4592        );
4593    } else {
4594        println!(
4595            "  {} plugins dir not found at {}",
4596            "·".dimmed(),
4597            crate::utils::display_path(&plugins_dir)
4598        );
4599        println!("    Run `codewhale setup --plugins` to scaffold a starter dir.");
4600    }
4601
4602    // Storage surfaces (#422 / #440 / #500)
4603    println!();
4604    println!("{}", "Storage:".bold());
4605    if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
4606        let (present, count) = if spillover_root.is_dir() {
4607            (true, count_dir_entries(&spillover_root))
4608        } else {
4609            (false, 0)
4610        };
4611        if present {
4612            println!(
4613                "  {} tool-output spillover at {} ({} file{})",
4614                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4615                crate::utils::display_path(&spillover_root),
4616                count,
4617                if count == 1 { "" } else { "s" }
4618            );
4619        } else {
4620            println!(
4621                "  {} tool-output spillover dir not yet created at {}",
4622                "·".dimmed(),
4623                crate::utils::display_path(&spillover_root)
4624            );
4625        }
4626    }
4627    let stash = crate::composer_stash::diagnostic_stash_report();
4628    if let Some(stash_path) = stash.path.as_ref() {
4629        if let Some(error) = stash.error.as_deref() {
4630            println!(
4631                "  {} composer stash was not inspected at {}: {error}",
4632                "!".truecolor(sky_r, sky_g, sky_b),
4633                crate::utils::display_path(stash_path),
4634            );
4635        } else if stash.present {
4636            println!(
4637                "  {} composer stash at {} ({} parked draft{})",
4638                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4639                crate::utils::display_path(stash_path),
4640                stash.count,
4641                if stash.count == 1 { "" } else { "s" }
4642            );
4643        } else {
4644            println!(
4645                "  {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
4646                "·".dimmed()
4647            );
4648        }
4649    } else if let Some(error) = stash.error.as_deref() {
4650        println!(
4651            "  {} composer stash was not inspected: {error}",
4652            "!".truecolor(sky_r, sky_g, sky_b),
4653        );
4654    }
4655
4656    // Tool dependencies — probe external binaries that individual
4657    // tools rely on (Python for code_execution, pdftotext for PDF
4658    // reading) so users see explicit ✓/✗ rather than the tool failing
4659    // at execution time with "program not found". New in v0.8.31.
4660    println!();
4661    println!("{}", "Tool Dependencies:".bold());
4662
4663    match crate::dependencies::resolve_python_interpreter() {
4664        Some(name) => println!(
4665            "  {} Python: {} → code_execution tool registered",
4666            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4667            name
4668        ),
4669        None => {
4670            println!(
4671                "  {} Python: not found (tried {:?})",
4672                "✗".truecolor(red_r, red_g, red_b),
4673                crate::dependencies::PYTHON_CANDIDATES,
4674            );
4675            println!("    code_execution tool is NOT advertised to the model on this install.");
4676            println!("    Install Python 3 and ensure one of those names is on PATH:");
4677            match std::env::consts::OS {
4678                "macos" => {
4679                    println!("      brew install python@3.12   (or download from python.org)")
4680                }
4681                "linux" => println!(
4682                    "      sudo apt install python3    (Debian/Ubuntu) — or your distro's equivalent"
4683                ),
4684                "windows" => {
4685                    println!("      winget install Python.Python.3   (or download from python.org)")
4686                }
4687                other => println!("      install Python 3 for {other} from python.org"),
4688            }
4689        }
4690    }
4691
4692    match crate::dependencies::resolve_node() {
4693        Some(_) => println!(
4694            "  {} Node.js: present → js_execution tool registered",
4695            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4696        ),
4697        None => {
4698            println!(
4699                "  {} Node.js: not found (tried `node`)",
4700                "✗".truecolor(red_r, red_g, red_b),
4701            );
4702            println!("    js_execution tool is NOT advertised to the model on this install.");
4703            println!("    Install Node 18+ and ensure `node` is on PATH:");
4704            match std::env::consts::OS {
4705                "macos" => println!("      brew install node   (or download from nodejs.org)"),
4706                "linux" => println!(
4707                    "      sudo apt install nodejs    (Debian/Ubuntu) — or your distro's equivalent"
4708                ),
4709                "windows" => {
4710                    println!("      winget install OpenJS.NodeJS   (or download from nodejs.org)")
4711                }
4712                other => println!("      install Node.js for {other} from nodejs.org"),
4713            }
4714        }
4715    }
4716
4717    match crate::dependencies::resolve_pandoc() {
4718        Some(_) => println!(
4719            "  {} pandoc: present → pandoc_convert tool registered",
4720            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4721        ),
4722        None => {
4723            println!("  {} pandoc: not found (optional)", "·".dimmed(),);
4724            println!(
4725                "    pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:"
4726            );
4727            match std::env::consts::OS {
4728                "macos" => println!("      brew install pandoc"),
4729                "linux" => println!(
4730                    "      sudo apt install pandoc    (Debian/Ubuntu) — or your distro's equivalent"
4731                ),
4732                "windows" => {
4733                    println!("      winget install JohnMacFarlane.Pandoc")
4734                }
4735                other => println!("      install pandoc for {other} from pandoc.org"),
4736            }
4737        }
4738    }
4739
4740    match crate::dependencies::resolve_tesseract() {
4741        Some(_) => {
4742            if cfg!(target_os = "macos") {
4743                println!(
4744                    "  {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
4745                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4746                );
4747            } else {
4748                println!(
4749                    "  {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
4750                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4751                );
4752            }
4753        }
4754        None => {
4755            if cfg!(target_os = "macos") {
4756                println!(
4757                    "  {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
4758                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4759                );
4760                println!(
4761                    "    tesseract not found (optional; install only for alternate OCR packs)."
4762                );
4763            } else {
4764                println!("  {} tesseract: not found (optional)", "·".dimmed(),);
4765                println!(
4766                    "    image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
4767                );
4768                match std::env::consts::OS {
4769                    "macos" => println!("      brew install tesseract"),
4770                    "linux" => println!(
4771                        "      sudo apt install tesseract-ocr    (Debian/Ubuntu) — or your distro's equivalent"
4772                    ),
4773                    "windows" => println!("      winget install UB-Mannheim.TesseractOCR"),
4774                    other => {
4775                        println!("      install tesseract for {other} from tesseract-ocr.github.io")
4776                    }
4777                }
4778            }
4779        }
4780    }
4781
4782    // PDF text extraction is an optional integration. Codewhale itself stays
4783    // a single required executable; file and web tools report a typed
4784    // failed `binary_unavailable` result when Poppler is not installed.
4785    match crate::dependencies::resolve_pdftotext() {
4786        Some(_) => println!(
4787            "  {} pdftotext: available → PDF text extraction enabled",
4788            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4789        ),
4790        None => {
4791            println!(
4792                "  {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
4793                "·".dimmed(),
4794            );
4795            match std::env::consts::OS {
4796                "macos" => println!("    Install via: brew install poppler"),
4797                "linux" => {
4798                    println!("    Install via: sudo apt install poppler-utils   (Debian/Ubuntu)")
4799                }
4800                "windows" => println!(
4801                    "    Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
4802                ),
4803                _ => {}
4804            }
4805        }
4806    }
4807
4808    // Terminal-quirk overrides currently active. Mirrors the env
4809    // signals checked by `Settings::apply_env_overrides` so users
4810    // can see at a glance which a11y/compat overrides fired.
4811    println!();
4812    println!("{}", "Terminal Quirks:".bold());
4813    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
4814    let term_program_lc = term_program.to_ascii_lowercase();
4815    let mut any_quirk = false;
4816    if matches!(term_program.as_str(), "vscode" | "ghostty") {
4817        println!(
4818            "  {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
4819            "•".truecolor(sky_r, sky_g, sky_b),
4820            term_program
4821        );
4822        any_quirk = true;
4823    }
4824    if term_program == "Termius"
4825        || std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
4826        || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
4827    {
4828        println!(
4829            "  {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
4830            "•".truecolor(sky_r, sky_g, sky_b)
4831        );
4832        any_quirk = true;
4833    }
4834    if term_program_lc.contains("ptyxis")
4835        || std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
4836    {
4837        println!(
4838            "  {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
4839            "•".truecolor(sky_r, sky_g, sky_b)
4840        );
4841        any_quirk = true;
4842    }
4843    if crate::settings::detected_legacy_windows_console_host() {
4844        println!(
4845            "  {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
4846            "•".truecolor(sky_r, sky_g, sky_b)
4847        );
4848        any_quirk = true;
4849    }
4850    if !any_quirk {
4851        println!(
4852            "  {} no env-driven terminal-quirk overrides active",
4853            "·".dimmed()
4854        );
4855    }
4856
4857    // Platform and sandbox checks
4858    println!();
4859    println!("{}", "Platform:".bold());
4860    println!("  OS: {}", std::env::consts::OS);
4861    println!("  Arch: {}", std::env::consts::ARCH);
4862
4863    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
4864        config.prefer_bwrap.unwrap_or(false),
4865    );
4866    if let Some(kind) = sandbox {
4867        println!(
4868            "  {} sandbox available: {}",
4869            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4870            kind
4871        );
4872    } else {
4873        println!(
4874            "  {} sandbox not available (commands run best-effort)",
4875            "!".truecolor(sky_r, sky_g, sky_b)
4876        );
4877    }
4878
4879    println!();
4880    println!(
4881        "{}",
4882        "All checks complete!"
4883            .truecolor(aqua_r, aqua_g, aqua_b)
4884            .bold()
4885    );
4886}
4887
4888const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
4889    "sessions",
4890    "tasks",
4891    "skills",
4892    "slop_ledger",
4893    "trophies",
4894    "catalog",
4895    "review-receipts",
4896    "config.toml",
4897    "settings.toml",
4898    "mcp.json",
4899];
4900const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
4901const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
4902
4903#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4904enum DoctorLegacyStateStatus {
4905    PrimaryOnly,
4906    LegacyOnly,
4907    Both,
4908    Absent,
4909}
4910
4911impl DoctorLegacyStateStatus {
4912    fn as_str(self) -> &'static str {
4913        match self {
4914            Self::PrimaryOnly => "primary_only",
4915            Self::LegacyOnly => "legacy_only",
4916            Self::Both => "both",
4917            Self::Absent => "absent",
4918        }
4919    }
4920}
4921
4922#[derive(Debug, Clone)]
4923struct DoctorLegacyStateEntry {
4924    name: &'static str,
4925    primary_path: PathBuf,
4926    legacy_path: PathBuf,
4927    primary_present: bool,
4928    legacy_present: bool,
4929    status: DoctorLegacyStateStatus,
4930}
4931
4932#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4933enum DoctorSessionRecoveryStatus {
4934    Isolated,
4935    NoLegacySessions,
4936    MigrationPending,
4937    MigrationIncomplete,
4938    MigrationComplete,
4939    ScanFailed,
4940}
4941
4942impl DoctorSessionRecoveryStatus {
4943    fn as_str(self) -> &'static str {
4944        match self {
4945            Self::Isolated => "isolated",
4946            Self::NoLegacySessions => "no_legacy_sessions",
4947            Self::MigrationPending => "migration_pending",
4948            Self::MigrationIncomplete => "migration_incomplete",
4949            Self::MigrationComplete => "migration_complete",
4950            Self::ScanFailed => "scan_failed",
4951        }
4952    }
4953}
4954
4955#[derive(Debug, Clone)]
4956struct DoctorRecoverableSessionEntry {
4957    name: PathBuf,
4958    source_path: PathBuf,
4959    destination_path: PathBuf,
4960}
4961
4962#[derive(Debug, Clone)]
4963struct DoctorSessionRecoveryReport {
4964    status: DoctorSessionRecoveryStatus,
4965    primary_sessions_path: PathBuf,
4966    legacy_sessions_path: PathBuf,
4967    codewhale_home_is_explicit: bool,
4968    legacy_session_file_count: usize,
4969    already_present_file_count: usize,
4970    recoverable_file_count: usize,
4971    /// Bounded filename/path sample; the total is `recoverable_file_count`.
4972    recoverable: Vec<DoctorRecoverableSessionEntry>,
4973    error: Option<String>,
4974}
4975
4976impl DoctorSessionRecoveryReport {
4977    fn needs_attention(&self) -> bool {
4978        matches!(
4979            self.status,
4980            DoctorSessionRecoveryStatus::MigrationPending
4981                | DoctorSessionRecoveryStatus::MigrationIncomplete
4982                | DoctorSessionRecoveryStatus::ScanFailed
4983        )
4984    }
4985}
4986
4987fn doctor_legacy_state_status(
4988    primary_present: bool,
4989    legacy_present: bool,
4990) -> DoctorLegacyStateStatus {
4991    match (primary_present, legacy_present) {
4992        (true, false) => DoctorLegacyStateStatus::PrimaryOnly,
4993        (false, true) => DoctorLegacyStateStatus::LegacyOnly,
4994        (true, true) => DoctorLegacyStateStatus::Both,
4995        (false, false) => DoctorLegacyStateStatus::Absent,
4996    }
4997}
4998
4999fn doctor_state_roots() -> (PathBuf, PathBuf) {
5000    let code_home =
5001        codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
5002    let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
5003        code_home.join(codewhale_config::LEGACY_APP_DIR)
5004    } else {
5005        codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
5006    };
5007    (code_home, legacy_home)
5008}
5009
5010fn doctor_legacy_state_report(
5011    primary_root: &Path,
5012    legacy_root: &Path,
5013) -> Vec<DoctorLegacyStateEntry> {
5014    DOCTOR_LEGACY_STATE_ITEMS
5015        .iter()
5016        .copied()
5017        .map(|name| {
5018            let primary_path = primary_root.join(name);
5019            let legacy_path = legacy_root.join(name);
5020            let primary_present = primary_path.exists();
5021            let legacy_present = legacy_path.exists();
5022            let status = doctor_legacy_state_status(primary_present, legacy_present);
5023            DoctorLegacyStateEntry {
5024                name,
5025                primary_path,
5026                legacy_path,
5027                primary_present,
5028                legacy_present,
5029                status,
5030            }
5031        })
5032        .collect()
5033}
5034
5035/// Compare legacy and primary session filenames without opening session files.
5036///
5037/// This is deliberately separate from `SessionManager::default_location()`:
5038/// constructing the manager can trigger the additive legacy migration, while
5039/// doctor must remain a read-only diagnostic. Session history is stored as
5040/// top-level JSON files. Directories (including `checkpoints`) and symlinks
5041/// observed during the scan are ignored, so the diagnostic does not
5042/// intentionally traverse checkpoint internals or link targets. These checks
5043/// are best-effort observations, not a race-free no-follow guarantee.
5044/// A matching filename is only a regular-file counterpart check: doctor does
5045/// not parse or compare session descriptors.
5046fn doctor_session_recovery_report(
5047    primary_root: &Path,
5048    legacy_root: &Path,
5049    codewhale_home_is_explicit: bool,
5050) -> DoctorSessionRecoveryReport {
5051    let primary_sessions_path = primary_root.join("sessions");
5052    let legacy_sessions_path = legacy_root.join("sessions");
5053    let mut report = DoctorSessionRecoveryReport {
5054        status: DoctorSessionRecoveryStatus::NoLegacySessions,
5055        primary_sessions_path,
5056        legacy_sessions_path,
5057        codewhale_home_is_explicit,
5058        legacy_session_file_count: 0,
5059        already_present_file_count: 0,
5060        recoverable_file_count: 0,
5061        recoverable: Vec::new(),
5062        error: None,
5063    };
5064
5065    if codewhale_home_is_explicit {
5066        report.status = DoctorSessionRecoveryStatus::Isolated;
5067        return report;
5068    }
5069
5070    let legacy_root_is_present =
5071        match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
5072            Ok(present) => present,
5073            Err(error) => {
5074                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5075                report.error = Some(error);
5076                return report;
5077            }
5078        };
5079    if !legacy_root_is_present {
5080        return report;
5081    }
5082    if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
5083        report.status = DoctorSessionRecoveryStatus::ScanFailed;
5084        report.error = Some(error);
5085        return report;
5086    }
5087
5088    let legacy_sessions_are_present = match doctor_session_directory_is_safe(
5089        &report.legacy_sessions_path,
5090        "legacy sessions root",
5091    ) {
5092        Ok(present) => present,
5093        Err(error) => {
5094            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5095            report.error = Some(error);
5096            return report;
5097        }
5098    };
5099    if !legacy_sessions_are_present {
5100        return report;
5101    }
5102    let primary_sessions_are_present = match doctor_session_directory_is_safe(
5103        &report.primary_sessions_path,
5104        "primary sessions root",
5105    ) {
5106        Ok(present) => present,
5107        Err(error) => {
5108            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5109            report.error = Some(error);
5110            return report;
5111        }
5112    };
5113
5114    let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
5115        Ok(entries) => entries,
5116        Err(err) => {
5117            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5118            report.error = Some(format!(
5119                "could not inspect legacy session filenames at {}: {err}",
5120                crate::utils::display_path(&report.legacy_sessions_path)
5121            ));
5122            return report;
5123        }
5124    };
5125
5126    for entry in entries {
5127        let entry = match entry {
5128            Ok(entry) => entry,
5129            Err(err) => {
5130                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5131                report.error = Some(format!(
5132                    "could not inspect an entry under {}: {err}",
5133                    crate::utils::display_path(&report.legacy_sessions_path)
5134                ));
5135                return report;
5136            }
5137        };
5138        let file_type = match entry.file_type() {
5139            Ok(file_type) => file_type,
5140            Err(err) => {
5141                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5142                report.error = Some(format!(
5143                    "could not inspect legacy session entry metadata under {}: {err}",
5144                    crate::utils::display_path(&report.legacy_sessions_path)
5145                ));
5146                return report;
5147            }
5148        };
5149        if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
5150            continue;
5151        }
5152
5153        report.legacy_session_file_count += 1;
5154        let name = PathBuf::from(entry.file_name());
5155        let destination_path = report.primary_sessions_path.join(&name);
5156        match std::fs::symlink_metadata(&destination_path) {
5157            Ok(metadata) if metadata.file_type().is_file() => {
5158                report.already_present_file_count += 1;
5159            }
5160            Ok(metadata) => {
5161                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5162                let shape = if metadata.file_type().is_symlink() {
5163                    "destination session entry is a symlink"
5164                } else {
5165                    "destination session entry is not a regular file"
5166                };
5167                report.error = Some(format!(
5168                    "could not inspect destination session metadata at {}: {shape}",
5169                    crate::utils::display_path(&destination_path)
5170                ));
5171                return report;
5172            }
5173            Err(err) if err.kind() == io::ErrorKind::NotFound => {
5174                report.recoverable_file_count += 1;
5175                record_doctor_recoverable_session(
5176                    &mut report.recoverable,
5177                    DoctorRecoverableSessionEntry {
5178                        source_path: entry.path(),
5179                        destination_path,
5180                        name,
5181                    },
5182                );
5183            }
5184            Err(err) => {
5185                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5186                report.error = Some(format!(
5187                    "could not inspect destination metadata at {}: {err}",
5188                    crate::utils::display_path(&destination_path)
5189                ));
5190                return report;
5191            }
5192        }
5193    }
5194
5195    report.status = if report.legacy_session_file_count == 0 {
5196        DoctorSessionRecoveryStatus::NoLegacySessions
5197    } else if report.recoverable_file_count == 0 {
5198        DoctorSessionRecoveryStatus::MigrationComplete
5199    } else if primary_sessions_are_present {
5200        DoctorSessionRecoveryStatus::MigrationIncomplete
5201    } else {
5202        DoctorSessionRecoveryStatus::MigrationPending
5203    };
5204    report
5205}
5206
5207/// Validate a session-state directory from observed metadata.
5208///
5209/// `doctor` only compares top-level filenames. It rejects a state-root or
5210/// sessions-root symlink observed during inspection rather than using it for a
5211/// recovery suggestion. This is a best-effort observation, not a race-free
5212/// no-follow guarantee. Missing paths are normal on a fresh install and are
5213/// reported as `false`.
5214fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
5215    let metadata = match std::fs::symlink_metadata(path) {
5216        Ok(metadata) => metadata,
5217        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
5218        Err(error) => {
5219            return Err(format!(
5220                "could not inspect {label} at {}: {error}",
5221                crate::utils::display_path(path)
5222            ));
5223        }
5224    };
5225    if metadata.file_type().is_symlink() {
5226        return Err(format!(
5227            "could not inspect {label} at {}: path is a symlink",
5228            crate::utils::display_path(path)
5229        ));
5230    }
5231    if !metadata.file_type().is_dir() {
5232        return Err(format!(
5233            "could not inspect {label} at {}: path is not a directory",
5234            crate::utils::display_path(path)
5235        ));
5236    }
5237    Ok(true)
5238}
5239
5240/// Keep the report bounded while preserving a deterministic, lexical sample.
5241/// `read_dir` order is platform- and filesystem-dependent, so retaining the
5242/// first entries encountered would make the JSON and human receipts drift.
5243fn record_doctor_recoverable_session(
5244    recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
5245    entry: DoctorRecoverableSessionEntry,
5246) {
5247    let insert_at = recoverable
5248        .binary_search_by(|existing| existing.name.cmp(&entry.name))
5249        .unwrap_or_else(|index| index);
5250    if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
5251        && insert_at == recoverable.len()
5252    {
5253        return;
5254    }
5255    recoverable.insert(insert_at, entry);
5256    if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
5257        recoverable.pop();
5258    }
5259}
5260
5261fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
5262    entry.name != "sessions"
5263        && matches!(
5264            entry.status,
5265            DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
5266        )
5267}
5268
5269fn print_doctor_legacy_state_report(
5270    report: &[DoctorLegacyStateEntry],
5271    session_recovery: &DoctorSessionRecoveryReport,
5272    ok_rgb: (u8, u8, u8),
5273    warn_rgb: (u8, u8, u8),
5274) {
5275    use colored::Colorize;
5276
5277    let attention: Vec<_> = report
5278        .iter()
5279        .filter(|entry| legacy_state_needs_attention(entry))
5280        .collect();
5281    if attention.is_empty()
5282        && !session_recovery.needs_attention()
5283        && session_recovery.status != DoctorSessionRecoveryStatus::Isolated
5284    {
5285        println!(
5286            "  {} legacy state: no known .deepseek entries need migration",
5287            "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5288        );
5289    } else if !attention.is_empty() {
5290        println!(
5291            "  {} legacy state needs review:",
5292            "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5293        );
5294        for entry in attention {
5295            match entry.status {
5296                DoctorLegacyStateStatus::LegacyOnly => {
5297                    println!(
5298                        "    {} {} exists but {} is missing",
5299                        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5300                        crate::utils::display_path(&entry.legacy_path),
5301                        crate::utils::display_path(&entry.primary_path),
5302                    );
5303                }
5304                DoctorLegacyStateStatus::Both => {
5305                    println!(
5306                        "    {} {} exists alongside primary {}; legacy data may still need review",
5307                        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5308                        crate::utils::display_path(&entry.legacy_path),
5309                        crate::utils::display_path(&entry.primary_path),
5310                    );
5311                }
5312                DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
5313            }
5314        }
5315        println!(
5316            "    Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
5317        );
5318    }
5319
5320    print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
5321}
5322
5323fn print_doctor_session_recovery_report(
5324    report: &DoctorSessionRecoveryReport,
5325    ok_rgb: (u8, u8, u8),
5326    warn_rgb: (u8, u8, u8),
5327) {
5328    use colored::Colorize;
5329
5330    match report.status {
5331        DoctorSessionRecoveryStatus::Isolated => {
5332            println!(
5333                "  {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
5334                "·".dimmed()
5335            );
5336            println!(
5337                "    This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
5338            );
5339        }
5340        DoctorSessionRecoveryStatus::NoLegacySessions => {
5341            println!(
5342                "  {} legacy sessions: no top-level session JSON files found",
5343                "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5344            );
5345        }
5346        DoctorSessionRecoveryStatus::MigrationComplete => {
5347            println!(
5348                "  {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
5349                "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
5350                report.legacy_session_file_count,
5351                crate::utils::display_path(&report.primary_sessions_path),
5352            );
5353        }
5354        DoctorSessionRecoveryStatus::MigrationPending
5355        | DoctorSessionRecoveryStatus::MigrationIncomplete => {
5356            let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
5357                "migration is incomplete"
5358            } else {
5359                "migration has not completed"
5360            };
5361            println!(
5362                "  {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
5363                "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5364                report.recoverable_file_count,
5365                crate::utils::display_path(&report.primary_sessions_path),
5366            );
5367            for entry in report
5368                .recoverable
5369                .iter()
5370                .take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
5371            {
5372                println!(
5373                    "    {} {} -> {}",
5374                    "·".dimmed(),
5375                    crate::utils::display_path(&entry.source_path),
5376                    crate::utils::display_path(&entry.destination_path),
5377                );
5378            }
5379            if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
5380                println!(
5381                    "    · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
5382                    report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
5383                );
5384            }
5385            println!("    Safe recovery:");
5386            println!(
5387                "      1. Back up {} and {} (if present).",
5388                crate::utils::display_path(&report.legacy_sessions_path),
5389                crate::utils::display_path(&report.primary_sessions_path),
5390            );
5391            println!(
5392                "      2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
5393            );
5394            println!(
5395                "      3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
5396            );
5397        }
5398        DoctorSessionRecoveryStatus::ScanFailed => {
5399            println!(
5400                "  {} legacy sessions: recovery diagnostic could not complete",
5401                "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5402            );
5403            if let Some(error) = report.error.as_deref() {
5404                println!("    {error}");
5405            }
5406            println!(
5407                "    Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
5408            );
5409        }
5410    }
5411    if report.status != DoctorSessionRecoveryStatus::Isolated {
5412        println!(
5413            "    Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
5414        );
5415    }
5416}
5417
5418fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
5419    use serde_json::json;
5420
5421    let recoverable: Vec<_> = report
5422        .recoverable
5423        .iter()
5424        .take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
5425        .map(|entry| {
5426            json!({
5427                "name": entry.name.display().to_string(),
5428                "source_path": entry.source_path.display().to_string(),
5429                "destination_path": entry.destination_path.display().to_string(),
5430            })
5431        })
5432        .collect();
5433
5434    json!({
5435        "status": report.status.as_str(),
5436        "needs_attention": report.needs_attention(),
5437        "read_only": true,
5438        "chat_contents_read": false,
5439        "checkpoint_internals_scanned": false,
5440        "session_descriptors_compared": false,
5441        "counterpart_check": "top_level_filename_and_regular_file_only",
5442        "codewhale_home_is_explicit": report.codewhale_home_is_explicit,
5443        "legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
5444        "primary_sessions_path": report.primary_sessions_path.display().to_string(),
5445        "legacy_session_file_count": report.legacy_session_file_count,
5446        "already_present_file_count": report.already_present_file_count,
5447        "recoverable_file_count": report.recoverable_file_count,
5448        "recoverable_files": recoverable,
5449        "recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
5450        "error": report.error,
5451        "recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
5452            Some("codewhale sessions")
5453        } else {
5454            None
5455        },
5456    })
5457}
5458
5459fn doctor_legacy_state_json(
5460    primary_root: &Path,
5461    legacy_root: &Path,
5462    report: &[DoctorLegacyStateEntry],
5463    session_recovery: &DoctorSessionRecoveryReport,
5464) -> serde_json::Value {
5465    use serde_json::json;
5466
5467    let legacy_only = report
5468        .iter()
5469        .filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
5470        .count();
5471    let both = report
5472        .iter()
5473        .filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
5474        .count();
5475    let entries: Vec<_> = report
5476        .iter()
5477        .map(|entry| {
5478            json!({
5479                "name": entry.name,
5480                "primary_path": entry.primary_path.display().to_string(),
5481                "legacy_path": entry.legacy_path.display().to_string(),
5482                "primary_present": entry.primary_present,
5483                "legacy_present": entry.legacy_present,
5484                "status": entry.status.as_str(),
5485            })
5486        })
5487        .collect();
5488
5489    json!({
5490        "primary_root": primary_root.display().to_string(),
5491        "legacy_root": legacy_root.display().to_string(),
5492        "needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
5493        "legacy_only_count": legacy_only,
5494        "dual_present_count": both,
5495        "session_recovery": doctor_session_recovery_json(session_recovery),
5496        "entries": entries,
5497    })
5498}
5499
5500fn doctor_setup_state(
5501    config: &Config,
5502    workspace: &Path,
5503) -> (codewhale_config::SetupState, &'static str) {
5504    if let Ok(Some(state)) = codewhale_config::SetupState::load() {
5505        return (state, "persisted");
5506    }
5507
5508    (
5509        codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
5510            config, workspace,
5511        )),
5512        "derived",
5513    )
5514}
5515
5516fn doctor_inherited_setup_facts(
5517    config: &Config,
5518    workspace: &Path,
5519) -> codewhale_config::InheritedConfigFacts {
5520    let user_constitution = codewhale_config::UserConstitution::load().ok();
5521    let user_constitution_validity = user_constitution.as_ref().map_or(
5522        codewhale_config::ConstitutionValidity::Unknown,
5523        codewhale_config::UserConstitutionLoad::validity,
5524    );
5525    let has_user_constitution = user_constitution
5526        .as_ref()
5527        .is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
5528    let has_expert_override = codewhale_config::codewhale_home()
5529        .ok()
5530        .map(|home| home.join(Path::new(crate::prompts::CONSTITUTION_OVERRIDE_FILE)))
5531        .is_some_and(|path| path.exists());
5532
5533    codewhale_config::InheritedConfigFacts {
5534        language: None,
5535        has_provider_route: !config.default_model().trim().is_empty(),
5536        has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
5537        trust_chosen: !crate::tui::onboarding::needs_trust(workspace),
5538        has_expert_override,
5539        has_user_constitution,
5540        user_constitution_validity,
5541    }
5542}
5543
5544fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
5545    resolve_credential_diagnostic(config)
5546        .availability
5547        .certifies_ready()
5548}
5549
5550fn print_doctor_setup_report(
5551    config: &Config,
5552    workspace: &Path,
5553    state: &codewhale_config::SetupState,
5554    source: &str,
5555    ok_rgb: (u8, u8, u8),
5556    warn_rgb: (u8, u8, u8),
5557) {
5558    use colored::Colorize;
5559
5560    let credential = resolve_credential_diagnostic(config);
5561    let credential_ready = credential.availability.certifies_ready();
5562    let first_run_ready = state.first_run_ready() && credential_ready;
5563    let update_ready =
5564        state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION) && credential_ready;
5565    let operate_ready = state.operate_ready() && credential_ready;
5566    let first_run_icon = if first_run_ready {
5567        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5568    } else {
5569        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5570    };
5571    let update_icon = if update_ready {
5572        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5573    } else {
5574        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5575    };
5576    let operate_icon = if operate_ready {
5577        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5578    } else {
5579        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5580    };
5581
5582    println!();
5583    println!("{}", "Setup State:".bold());
5584    println!("  · source: {source}");
5585    println!(
5586        "  · credential: source={}, availability={}",
5587        doctor_api_key_source_label(credential.source),
5588        credential.availability.label()
5589    );
5590    println!(
5591        "  {first_run_icon} first-run: {}",
5592        doctor_ready_label(first_run_ready)
5593    );
5594    println!(
5595        "  {update_icon} update checkpoint {}: {}",
5596        crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
5597        doctor_ready_label(update_ready)
5598    );
5599    println!(
5600        "  {operate_icon} operate/fleet: {}",
5601        doctor_ready_label(operate_ready)
5602    );
5603    println!(
5604        "  · constitution autonomy: {} (guidance only)",
5605        doctor_constitution_autonomy_preference_id()
5606    );
5607    println!(
5608        "  · runtime posture: {}",
5609        doctor_runtime_posture_line(config, workspace)
5610    );
5611    let consistency = doctor_setup_consistency(state, source);
5612    if consistency["status"] == "inconsistent" {
5613        let issues = consistency["issues"]
5614            .as_array()
5615            .map(|issues| {
5616                issues
5617                    .iter()
5618                    .filter_map(serde_json::Value::as_str)
5619                    .collect::<Vec<_>>()
5620                    .join(", ")
5621            })
5622            .unwrap_or_default();
5623        println!(
5624            "  {} consistency: half-applied setup detected ({issues}) — {}",
5625            "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5626            consistency["repair"].as_str().unwrap_or("/setup"),
5627        );
5628    }
5629    println!(
5630        "  · 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)"
5631    );
5632    for step in codewhale_config::SetupStep::ALL {
5633        let entry = state.steps.get(&step);
5634        let required = entry.is_some_and(|entry| entry.required);
5635        let version = entry.and_then(|entry| entry.version.as_deref());
5636        let result = entry.and_then(|entry| entry.result.as_deref());
5637        let required_label = if required { "required" } else { "optional" };
5638        let version_label = version.unwrap_or("unversioned");
5639        let result_label = result.unwrap_or("no result");
5640        println!(
5641            "    · {}: {} ({required_label}, {version_label}, {result_label})",
5642            setup_step_id(step),
5643            setup_status_id(state.status(step))
5644        );
5645    }
5646}
5647
5648fn doctor_ready_label(ready: bool) -> &'static str {
5649    if ready { "ready" } else { "needs action" }
5650}
5651
5652/// Detect half-applied setup persistence (#3410).
5653///
5654/// The setup transaction writes `constitution.json` and `setup_state.json`
5655/// together, so a persisted state that points at a user-global constitution
5656/// which is missing or unusable on disk means a write was interrupted or a
5657/// file was removed out-of-band. Stale `.tmp*` files in `$CODEWHALE_HOME`
5658/// are the other fingerprint of an interrupted atomic write.
5659fn doctor_setup_consistency(
5660    state: &codewhale_config::SetupState,
5661    source: &str,
5662) -> serde_json::Value {
5663    use serde_json::json;
5664
5665    let mut issues: Vec<&'static str> = Vec::new();
5666
5667    if source == "persisted"
5668        && matches!(
5669            state.constitution_source,
5670            codewhale_config::ConstitutionSource::UserGlobal
5671        )
5672    {
5673        match codewhale_config::UserConstitution::load() {
5674            Ok(codewhale_config::UserConstitutionLoad::Missing) => {
5675                issues.push("setup_state_points_at_missing_user_constitution");
5676            }
5677            Ok(codewhale_config::UserConstitutionLoad::Empty) => {
5678                issues.push("user_constitution_empty");
5679            }
5680            Ok(codewhale_config::UserConstitutionLoad::Invalid(_)) => {
5681                issues.push("user_constitution_invalid");
5682            }
5683            Ok(codewhale_config::UserConstitutionLoad::Unreadable(_)) | Err(_) => {
5684                issues.push("user_constitution_unreadable");
5685            }
5686            Ok(codewhale_config::UserConstitutionLoad::Loaded(_)) => {}
5687        }
5688    }
5689
5690    if doctor_home_has_stale_setup_temp_files() {
5691        issues.push("stale_setup_temp_files_in_codewhale_home");
5692    }
5693
5694    json!({
5695        "status": if issues.is_empty() { "consistent" } else { "inconsistent" },
5696        "issues": issues,
5697        "repair": "/constitution to rebuild standing law, /setup to re-run the checkpoint",
5698    })
5699}
5700
5701fn doctor_home_has_stale_setup_temp_files() -> bool {
5702    let Ok(home) = codewhale_config::codewhale_home() else {
5703        return false;
5704    };
5705    let Ok(entries) = std::fs::read_dir(&home) else {
5706        return false;
5707    };
5708    entries.flatten().any(|entry| {
5709        entry.file_name().to_string_lossy().starts_with(".tmp")
5710            && entry.file_type().is_ok_and(|kind| kind.is_file())
5711    })
5712}
5713
5714fn doctor_constitution_autonomy_preference() -> codewhale_config::AutonomyPreference {
5715    codewhale_config::UserConstitution::load()
5716        .ok()
5717        .and_then(|load| {
5718            load.constitution()
5719                .map(|constitution| constitution.autonomy_preference)
5720        })
5721        .unwrap_or(codewhale_config::AutonomyPreference::Unspecified)
5722}
5723
5724fn doctor_constitution_autonomy_preference_id() -> &'static str {
5725    autonomy_preference_id(doctor_constitution_autonomy_preference())
5726}
5727
5728fn autonomy_preference_id(preference: codewhale_config::AutonomyPreference) -> &'static str {
5729    match preference {
5730        codewhale_config::AutonomyPreference::Unspecified => "unspecified",
5731        codewhale_config::AutonomyPreference::Cautious => "cautious",
5732        codewhale_config::AutonomyPreference::Balanced => "balanced",
5733        codewhale_config::AutonomyPreference::Autonomous => "autonomous",
5734    }
5735}
5736
5737fn doctor_runtime_default_mode() -> (String, &'static str) {
5738    match crate::settings::Settings::load_read_only() {
5739        Ok(settings) => (settings.default_mode, "settings"),
5740        Err(_) => (crate::settings::Settings::default().default_mode, "default"),
5741    }
5742}
5743
5744/// TUI settings posture used when `config.approval_policy` is unset.
5745/// Doctor must surface this separately so a saved Full Access baseline is not
5746/// misreported as the config default `approval_policy=on-request`.
5747fn doctor_runtime_permission_posture() -> (String, &'static str) {
5748    match crate::settings::Settings::load_read_only() {
5749        Ok(settings) => match settings.permission_posture {
5750            Some(posture) => (posture, "settings"),
5751            None => ("unset".to_string(), "default"),
5752        },
5753        Err(_) => ("unset".to_string(), "default"),
5754    }
5755}
5756
5757fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
5758    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
5759    let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
5760    let approval = config.approval_policy.as_deref().unwrap_or("on-request");
5761    let approval_source = if config.approval_policy.is_some() {
5762        "config"
5763    } else {
5764        "default"
5765    };
5766    let allow_shell = config.interactive_allow_shell();
5767    let allow_shell_source = if config.allow_shell.is_some() {
5768        "config"
5769    } else {
5770        "interactive default"
5771    };
5772    let sandbox = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
5773    let sandbox_source = if config.sandbox_mode.is_some() {
5774        "config"
5775    } else {
5776        "default"
5777    };
5778    let network = config
5779        .network
5780        .as_ref()
5781        .map_or("prompt", |policy| policy.default.as_str());
5782    let network_source = if config.network.is_some() {
5783        "config"
5784    } else {
5785        "default"
5786    };
5787    let trust = if crate::tui::onboarding::needs_trust(workspace) {
5788        "workspace not elevated"
5789    } else {
5790        "workspace trusted"
5791    };
5792
5793    format!(
5794        "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}"
5795    )
5796}
5797
5798fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
5799    use serde_json::json;
5800
5801    let provider = config.api_provider();
5802    // Doctor reports configured routing posture only. In particular it must
5803    // never consume an external-file grant merely to label Fleet readiness.
5804    let credential = resolve_credential_diagnostic(config);
5805    let has_credentials_or_local = credential.availability.certifies_ready();
5806    let subagents_enabled = config.subagents_enabled_for_provider(provider);
5807    let disabled_reason = if subagents_enabled {
5808        None
5809    } else {
5810        Some(
5811            config
5812                .subagents_disabled_reason()
5813                .unwrap_or("disabled for active provider"),
5814        )
5815    };
5816    let max_subagents = config.max_subagents_for_provider(provider);
5817    let launch_concurrency = config.launch_concurrency_for_provider(provider);
5818    let max_admitted = config.max_admitted_subagents_for_provider(provider);
5819    let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
5820    let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
5821    let mut built_in_members = 0usize;
5822    let mut config_members = 0usize;
5823    let mut personal_members = 0usize;
5824    let mut workspace_members = 0usize;
5825    for member in roster.members() {
5826        match member.origin {
5827            crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
5828            crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
5829            crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
5830            crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
5831        }
5832    }
5833    let roster_members = roster.members().len();
5834    let custom_members = config_members + personal_members + workspace_members;
5835    let roster_ready = roster_members > 0;
5836    let runtime_ready =
5837        subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
5838
5839    json!({
5840        "ready": has_credentials_or_local && runtime_ready && roster_ready,
5841        "provider": {
5842            "id": config.provider_identity_for(provider),
5843            "auth": {
5844                "present_or_local": has_credentials_or_local,
5845                "source": doctor_api_key_source_label(credential.source),
5846                "availability": credential.availability.label(),
5847            },
5848        },
5849        "worker_runtime": {
5850            "ready": runtime_ready,
5851            "enabled": subagents_enabled,
5852            "disabled_reason": disabled_reason,
5853            "max_subagents": max_subagents,
5854            "launch_concurrency": launch_concurrency,
5855            "max_admitted": max_admitted,
5856            "max_spawn_depth": max_spawn_depth,
5857            "host_enforced_workflow_receipts": true,
5858        },
5859        "roster": {
5860            "ready": roster_ready,
5861            "total": roster_members,
5862            "built_in": built_in_members,
5863            "config": config_members,
5864            "personal": personal_members,
5865            "workspace": workspace_members,
5866            "custom": custom_members,
5867            "starter_roster_available": built_in_members > 0,
5868            "readiness_rule": "built-in starter roster or custom roster",
5869        },
5870        "concurrency": {
5871            "launch_concurrency": launch_concurrency,
5872            "max_subagents": max_subagents,
5873            "max_admitted": max_admitted,
5874            "plan_limit_probed": false,
5875        },
5876    })
5877}
5878
5879fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
5880    use serde_json::json;
5881
5882    let provider = config.api_provider();
5883    let credential = resolve_credential_diagnostic(config);
5884    let auth_present_or_local = credential.availability.certifies_ready();
5885    let credential_help = provider.credential_help();
5886    let credential_url = credential_help
5887        .credential_url
5888        .map(crate::doctor::structural_url_authority);
5889    let credential_docs_url = credential_help
5890        .docs_url
5891        .map(crate::doctor::structural_url_authority);
5892
5893    json!({
5894        "provider": {
5895            "id": config.provider_identity_for(provider),
5896            "display": provider.display_name(),
5897        },
5898        "model": {
5899            "resolved": config.default_model(),
5900        },
5901        "auth": {
5902            "present_or_local": auth_present_or_local,
5903            "source": doctor_api_key_source_label(credential.source),
5904            "availability": credential.availability.label(),
5905            "env_vars": provider.env_vars(),
5906            "credential_mode": credential_help.acquisition.as_str(),
5907            "credential_url": credential_url,
5908            "credential_docs_url": credential_docs_url,
5909            "credential_guidance": credential_help.guidance,
5910            "oauth_only": credential_help.acquisition
5911                == codewhale_config::provider::CredentialAcquisition::OAuth,
5912        },
5913        "health": {
5914            "live_validation": false,
5915            "next_action": if auth_present_or_local {
5916                "/model"
5917            } else {
5918                "/setup provider or /provider setup <name>"
5919            },
5920        },
5921    })
5922}
5923
5924fn doctor_external_credential_consent_statuses(
5925    config: &Config,
5926) -> Vec<codewhale_config::ExternalCredentialConsentStatus> {
5927    [
5928        crate::config::ApiProvider::OpenaiCodex,
5929        crate::config::ApiProvider::Xai,
5930    ]
5931    .into_iter()
5932    .filter_map(|provider| config.external_credential_consent_status(provider))
5933    .collect()
5934}
5935
5936fn doctor_external_credential_consent_lines(config: &Config) -> Vec<String> {
5937    doctor_external_credential_consent_statuses(config)
5938        .into_iter()
5939        .flat_map(|status| {
5940            let mut lines = vec![
5941                format!(
5942                    "{}: access={}, provider={}, source={}, owner={}, path={}, version={}, state={}, ambient_path_changed={}",
5943                    status.provider,
5944                    status.access.as_str(),
5945                    status.provider,
5946                    status.source.as_str(),
5947                    status.owner,
5948                    codewhale_config::quote_os_path(&status.path),
5949                    status.consent_version,
5950                    status.route_state,
5951                    status.ambient_path_changed,
5952                ),
5953                format!("  semantics: {}", status.semantics),
5954                format!("  revoke: {}", status.revoke_command),
5955            ];
5956            if let Some(warning) = status.ambient_path_warning() {
5957                lines.push(format!("  {warning}"));
5958            }
5959            lines
5960        })
5961        .collect()
5962}
5963
5964fn doctor_external_credential_consent_json(config: &Config) -> serde_json::Value {
5965    serde_json::Value::Array(
5966        doctor_external_credential_consent_statuses(config)
5967            .into_iter()
5968            .map(|status| {
5969                serde_json::json!({
5970                    "provider": status.provider,
5971                    "access": status.access.as_str(),
5972                    "source": status.source.as_str(),
5973                    "owner": status.owner,
5974                    "path": codewhale_config::quote_os_path(&status.path),
5975                    "consent_version": status.consent_version,
5976                    "scope_valid": status.scope_valid,
5977                    "ambient_path_changed": status.ambient_path_changed,
5978                    "ambient_path_warning": status.ambient_path_warning(),
5979                    "route_state": status.route_state,
5980                    "semantics": status.semantics,
5981                    "revoke_command": status.revoke_command,
5982                })
5983            })
5984            .collect(),
5985    )
5986}
5987
5988fn doctor_setup_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
5989    use serde_json::json;
5990
5991    let (state, source) = doctor_setup_state(config, workspace);
5992    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
5993    let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
5994    let approval_policy = config.approval_policy.as_deref().unwrap_or("on-request");
5995    let approval_policy_source = if config.approval_policy.is_some() {
5996        "config"
5997    } else {
5998        "default"
5999    };
6000    let allow_shell = config.interactive_allow_shell();
6001    let allow_shell_source = if config.allow_shell.is_some() {
6002        "config"
6003    } else {
6004        "interactive_default"
6005    };
6006    let sandbox_mode = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6007    let sandbox_mode_source = if config.sandbox_mode.is_some() {
6008        "config"
6009    } else {
6010        "default"
6011    };
6012    let network_default = config
6013        .network
6014        .as_ref()
6015        .map_or("prompt", |policy| policy.default.as_str());
6016    let network_source = if config.network.is_some() {
6017        "config"
6018    } else {
6019        "default"
6020    };
6021    let workspace_trusted = !crate::tui::onboarding::needs_trust(workspace);
6022    let credential = resolve_credential_diagnostic(config);
6023    let credential_ready = credential.availability.certifies_ready();
6024    let steps: Vec<_> = codewhale_config::SetupStep::ALL
6025        .into_iter()
6026        .map(|step| {
6027            let entry = state.steps.get(&step);
6028            json!({
6029                "step": setup_step_id(step),
6030                "status": setup_status_id(state.status(step)),
6031                "required": entry.is_some_and(|entry| entry.required),
6032                "version": entry.and_then(|entry| entry.version.clone()),
6033                "result": entry.and_then(|entry| entry.result.clone()),
6034            })
6035        })
6036        .collect();
6037
6038    json!({
6039        "source": source,
6040        "schema_version": state.schema_version,
6041        "inherited": state.inherited,
6042        "checkpoint_version": crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
6043        "first_run_ready": state.first_run_ready() && credential_ready,
6044        "update_ready": state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION)
6045            && credential_ready,
6046        "operate_ready": state.operate_ready() && credential_ready,
6047        "credential": {
6048            "ready": credential_ready,
6049            "source": doctor_api_key_source_label(credential.source),
6050            "availability": credential.availability.label(),
6051        },
6052        "constitution": {
6053            "choice": constitution_choice_id(state.constitution_choice),
6054            "source": constitution_source_id(state.constitution_source),
6055            "validity": constitution_validity_id(state.constitution_validity),
6056            "checkpoint_completed_for": state.constitution_checkpoint_completed_for.clone(),
6057            "language": state.constitution_language.clone(),
6058            "preview_hash_present": state.constitution_preview_hash.is_some(),
6059            "preview_version": state.constitution_preview_version,
6060            "autonomy_preference": doctor_constitution_autonomy_preference_id(),
6061        },
6062        "runtime_posture_source": runtime_posture_source_id(state.runtime_posture_source),
6063        "runtime_posture": {
6064            "source": runtime_posture_source_id(state.runtime_posture_source),
6065            "default_mode": {
6066                "value": default_mode,
6067                "source": default_mode_source,
6068            },
6069            "permission_posture": {
6070                "value": permission_posture,
6071                "source": permission_posture_source,
6072            },
6073            "approval_policy": {
6074                "value": approval_policy,
6075                "source": approval_policy_source,
6076            },
6077            "allow_shell": {
6078                "value": allow_shell,
6079                "source": allow_shell_source,
6080            },
6081            "sandbox_mode": {
6082                "value": sandbox_mode,
6083                "source": sandbox_mode_source,
6084            },
6085            "network_default": {
6086                "value": network_default,
6087                "source": network_source,
6088            },
6089            "workspace_trust": {
6090                "trusted": workspace_trusted,
6091                "source": "workspace",
6092            },
6093        },
6094        "provider_model": doctor_provider_model_report_json(config),
6095        "operate_fleet": doctor_operate_fleet_report_json(config, workspace),
6096        "consistency": doctor_setup_consistency(&state, source),
6097        "next_actions": {
6098            "constitution": "/constitution",
6099            "setup_report": "/setup report",
6100            "provider_model": "/setup provider, /provider setup <name>, or /model",
6101            "runtime_posture": "/config",
6102            "operate_fleet": "/setup fleet (readiness), /fleet setup (explicit profile authoring)",
6103            "hotbar": "/setup hotbar",
6104            "tools_mcp": "/setup tools",
6105            "remote_runtime": "/setup remote",
6106            "persistence": "/setup persistence",
6107        },
6108        "steps": steps,
6109    })
6110}
6111
6112fn setup_step_id(step: codewhale_config::SetupStep) -> &'static str {
6113    match step {
6114        codewhale_config::SetupStep::Language => "language",
6115        codewhale_config::SetupStep::ProviderModel => "provider_model",
6116        codewhale_config::SetupStep::TrustSandbox => "trust_sandbox",
6117        codewhale_config::SetupStep::ToolsMcp => "tools_mcp",
6118        codewhale_config::SetupStep::Hotbar => "hotbar",
6119        codewhale_config::SetupStep::RemoteRuntime => "remote_runtime",
6120        codewhale_config::SetupStep::Persistence => "persistence",
6121        codewhale_config::SetupStep::Constitution => "constitution",
6122        codewhale_config::SetupStep::OperateFleet => "operate_fleet",
6123        codewhale_config::SetupStep::Verification => "verification",
6124    }
6125}
6126
6127fn setup_status_id(status: codewhale_config::StepStatus) -> &'static str {
6128    match status {
6129        codewhale_config::StepStatus::NotStarted => "not_started",
6130        codewhale_config::StepStatus::Recommended => "recommended",
6131        codewhale_config::StepStatus::Optional => "optional",
6132        codewhale_config::StepStatus::Deferred => "deferred",
6133        codewhale_config::StepStatus::InProgress => "in_progress",
6134        codewhale_config::StepStatus::Verified => "verified",
6135        codewhale_config::StepStatus::NeedsAction => "needs_action",
6136        codewhale_config::StepStatus::Failed => "failed",
6137        codewhale_config::StepStatus::Skipped => "skipped",
6138    }
6139}
6140
6141fn constitution_choice_id(choice: codewhale_config::ConstitutionChoice) -> &'static str {
6142    match choice {
6143        codewhale_config::ConstitutionChoice::Unset => "unset",
6144        codewhale_config::ConstitutionChoice::Bundled => "bundled",
6145        codewhale_config::ConstitutionChoice::GuidedCustom => "guided_custom",
6146        codewhale_config::ConstitutionChoice::ExpertOverride => "expert_override",
6147        codewhale_config::ConstitutionChoice::Deferred => "deferred",
6148    }
6149}
6150
6151fn constitution_source_id(source: codewhale_config::ConstitutionSource) -> &'static str {
6152    match source {
6153        codewhale_config::ConstitutionSource::Bundled => "bundled",
6154        codewhale_config::ConstitutionSource::UserGlobal => "user_global",
6155        codewhale_config::ConstitutionSource::ExpertOverride => "expert_override",
6156    }
6157}
6158
6159fn constitution_validity_id(validity: codewhale_config::ConstitutionValidity) -> &'static str {
6160    match validity {
6161        codewhale_config::ConstitutionValidity::Unknown => "unknown",
6162        codewhale_config::ConstitutionValidity::Valid => "valid",
6163        codewhale_config::ConstitutionValidity::Invalid => "invalid",
6164        codewhale_config::ConstitutionValidity::Empty => "empty",
6165        codewhale_config::ConstitutionValidity::Unreadable => "unreadable",
6166    }
6167}
6168
6169fn runtime_posture_source_id(source: codewhale_config::RuntimePostureSource) -> &'static str {
6170    match source {
6171        codewhale_config::RuntimePostureSource::Unset => "unset",
6172        codewhale_config::RuntimePostureSource::Inherited => "inherited",
6173        codewhale_config::RuntimePostureSource::Confirmed => "confirmed",
6174    }
6175}
6176
6177/// Emit a bounded, secret-redacted JSON failure when configuration cannot be
6178/// loaded or validated. Invalid configuration must not be forced through the
6179/// normal doctor report because its route/capability facts would be misleading.
6180fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
6181    let safe_message = error
6182        .downcast_ref::<crate::config::SafeConfigDiagnostic>()
6183        .map(ToString::to_string);
6184    let report = serde_json::json!({
6185        "status": "error",
6186        "error": {
6187            "kind": "config_validation",
6188            "message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
6189        },
6190    });
6191    println!("{}", serde_json::to_string_pretty(&report)?);
6192
6193    // Keep stderr generic: the actionable, redacted error is already on
6194    // stdout, and Rust's Result termination must never redisclose a secret.
6195    bail!("doctor configuration validation failed; see JSON output")
6196}
6197
6198/// Machine-readable counterpart to `run_doctor`. This report is always
6199/// structural and offline; live probe flags conflict with `--json`.
6200fn run_doctor_json(
6201    config: &Config,
6202    workspace: &Path,
6203    config_path_override: Option<&Path>,
6204    plugins: &crate::plugins::PluginRegistry,
6205) -> Result<()> {
6206    use serde_json::json;
6207
6208    let doctor_paths = crate::doctor::DoctorPathReport::resolve(config_path_override)?;
6209    let config_path = &doctor_paths.config;
6210    let secret_backend = codewhale_secrets::diagnose_secret_backend();
6211
6212    let credential = resolve_credential_diagnostic(config);
6213
6214    let mcp_config_path = config.mcp_config_path();
6215    let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
6216    let mcp_present = mcp_config_path.exists();
6217    let project_mcp_present = project_mcp_config_path.exists();
6218    let mcp_summary = match crate::mcp::load_config_with_workspace_and_plugins(
6219        &mcp_config_path,
6220        workspace,
6221        plugins,
6222    ) {
6223        Ok(cfg) => {
6224            let servers: Vec<serde_json::Value> = cfg
6225                .servers
6226                .iter()
6227                .map(|(name, server)| doctor_mcp_server_json(name, server))
6228                .collect();
6229            json!({
6230                "config_path": mcp_config_path.display().to_string(),
6231                "present": mcp_present,
6232                "project_config_path": project_mcp_config_path.display().to_string(),
6233                "project_present": project_mcp_present,
6234                "probe_scope": "configuration",
6235                "live_health_checked": false,
6236                "servers": servers,
6237            })
6238        }
6239        Err(_) => json!({
6240            "config_path": mcp_config_path.display().to_string(),
6241            "present": mcp_present,
6242            "project_config_path": project_mcp_config_path.display().to_string(),
6243            "project_present": project_mcp_present,
6244            "probe_scope": "configuration",
6245            "live_health_checked": false,
6246            "servers": [],
6247            "error": "configuration_unavailable_details_omitted",
6248        }),
6249    };
6250
6251    let global_skills_dir = config.skills_dir();
6252    let agents_skills_dir = workspace.join(".agents").join("skills");
6253    let local_skills_dir = workspace.join("skills");
6254    let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
6255    // #432: cross-tool skill discovery dirs surface in the JSON
6256    // report so external dashboards can see whether any
6257    // `.opencode/skills/`, `.claude/skills/`, `.cursor/skills/`, or
6258    // global agentskills.io content is contributing to the merged catalogue.
6259    let opencode_skills_dir = workspace.join(".opencode").join("skills");
6260    let claude_skills_dir = workspace.join(".claude").join("skills");
6261    let selected_skills_dir = if agents_skills_dir.exists() {
6262        agents_skills_dir.clone()
6263    } else if local_skills_dir.exists() {
6264        local_skills_dir.clone()
6265    } else if config.skills_dir.is_none()
6266        && let Some(global_agents) = agents_global_skills_dir.as_ref()
6267        && global_agents.exists()
6268    {
6269        global_agents.clone()
6270    } else {
6271        global_skills_dir.clone()
6272    };
6273    let agents_global_summary = agents_global_skills_dir
6274        .as_ref()
6275        .map(|path| {
6276            json!({
6277                "path": path.display().to_string(),
6278                "present": path.exists(),
6279                "count": skills_count_for(path),
6280            })
6281        })
6282        .unwrap_or_else(|| {
6283            json!({
6284                "path": null,
6285                "present": false,
6286                "count": 0,
6287            })
6288        });
6289
6290    let tools_dir = default_tools_dir();
6291    let plugins_dir = default_plugins_dir();
6292
6293    // Memory feature state (#489). Operators ask "is memory on?" and
6294    // "where does it live?" — surface both here so the question can be
6295    // answered without booting the TUI. Both inputs are checked: the
6296    // config flag and the env-var override that the runtime would
6297    // honour. (The dedicated `Config::memory_enabled()` accessor lives
6298    // on the memory-MVP branch (#518); this duplicates the same logic
6299    // until the two PRs land and it can be replaced with a single
6300    // method call.)
6301    let memory_path = config.memory_path();
6302    let memory_enabled_env = std::env::var("CODEWHALE_MEMORY")
6303        .or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
6304        .ok()
6305        .map(|raw| {
6306            matches!(
6307                raw.trim().to_ascii_lowercase().as_str(),
6308                "1" | "on" | "true" | "yes" | "y" | "enabled"
6309            )
6310        })
6311        .unwrap_or(false);
6312    let memory_summary = json!({
6313        // The MVP feature is opt-in by default; this defaults to false
6314        // on branches without the [memory] section in `Config`.
6315        "enabled": memory_enabled_env,
6316        "path": memory_path.display().to_string(),
6317        "file_present": memory_path.exists(),
6318    });
6319    let api_target = doctor_api_target(config);
6320    let strict_tool_mode = doctor_strict_tool_mode_status(config);
6321    let tls_status = doctor_tls_status(config);
6322    let (code_home, legacy_home) = doctor_state_roots();
6323    let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
6324    let session_recovery = doctor_session_recovery_report(
6325        &code_home,
6326        &legacy_home,
6327        codewhale_config::codewhale_home_is_explicit(),
6328    );
6329
6330    let stash = crate::composer_stash::diagnostic_stash_report();
6331    let report = json!({
6332        "version": env!("CARGO_PKG_VERSION"),
6333        "config_path": config_path.display().to_string(),
6334        "config_present": config_path.exists(),
6335        "paths": doctor_paths,
6336        "secret_backend": secret_backend,
6337        "workspace": workspace.display().to_string(),
6338        "legacy_state": doctor_legacy_state_json(
6339            &code_home,
6340            &legacy_home,
6341            &legacy_state_report,
6342            &session_recovery,
6343        ),
6344        "setup": doctor_setup_report_json(config, workspace),
6345        "api_key": {
6346            "source": doctor_api_key_source_label(credential.source),
6347            "availability": credential.availability.label(),
6348        },
6349        "external_credentials": doctor_external_credential_consent_json(config),
6350        "base_url": crate::doctor::structural_url_authority(&api_target.base_url),
6351        "default_text_model": api_target.model,
6352        // DGF-01: this report describes the route a session launched now
6353        // would resolve; a running session keeps its launch-time route.
6354        "route_scope": "configured_at_launch",
6355        "model_resolution": match api_target.resolution {
6356            DoctorModelResolution::Resolved => "resolved",
6357            DoctorModelResolution::ConfiguredOnly => "configured_unresolved",
6358        },
6359        "route": doctor_route_report(config),
6360        "strict_tool_mode": doctor_strict_tool_mode_report_json(&strict_tool_mode),
6361        "tls": {
6362            "certificate_verification": tls_status.certificate_verification,
6363            "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
6364            "provider": tls_status.provider,
6365            "message": tls_status.message,
6366        },
6367        "search_provider": doctor_search_provider_json(config),
6368        "memory": memory_summary,
6369        "mcp": mcp_summary,
6370        "skills": {
6371            "selected": selected_skills_dir.display().to_string(),
6372            "global": {
6373                "path": global_skills_dir.display().to_string(),
6374                "present": global_skills_dir.exists(),
6375                "count": skills_count_for(&global_skills_dir),
6376            },
6377            "agents": {
6378                "path": agents_skills_dir.display().to_string(),
6379                "present": agents_skills_dir.exists(),
6380                "count": skills_count_for(&agents_skills_dir),
6381            },
6382            "agents_global": agents_global_summary,
6383            "local": {
6384                "path": local_skills_dir.display().to_string(),
6385                "present": local_skills_dir.exists(),
6386                "count": skills_count_for(&local_skills_dir),
6387            },
6388            "opencode": {
6389                "path": opencode_skills_dir.display().to_string(),
6390                "present": opencode_skills_dir.exists(),
6391                "count": skills_count_for(&opencode_skills_dir),
6392            },
6393            "claude": {
6394                "path": claude_skills_dir.display().to_string(),
6395                "present": claude_skills_dir.exists(),
6396                "count": skills_count_for(&claude_skills_dir),
6397            },
6398        },
6399        "tools": {
6400            "path": tools_dir.display().to_string(),
6401            "present": tools_dir.exists(),
6402            "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
6403        },
6404        "plugins": {
6405            "path": plugins_dir.display().to_string(),
6406            "present": plugins_dir.exists(),
6407            "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
6408        },
6409        "storage": {
6410            "spillover": {
6411                "path": crate::tools::truncate::spillover_root()
6412                    .map(|p| p.display().to_string())
6413                    .unwrap_or_default(),
6414                "present": crate::tools::truncate::spillover_root()
6415                    .is_some_and(|p| p.is_dir()),
6416                "count": crate::tools::truncate::spillover_root()
6417                    .filter(|p| p.is_dir())
6418                    .map(|p| count_dir_entries(&p))
6419                    .unwrap_or(0),
6420            },
6421            "stash": {
6422                "path": stash
6423                    .path
6424                    .as_ref()
6425                    .map(|path| path.display().to_string())
6426                    .unwrap_or_default(),
6427                "present": stash.present,
6428                "count": stash.count,
6429                "error": stash.error,
6430            },
6431        },
6432        "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
6433            config.prefer_bwrap.unwrap_or(false),
6434        ) {
6435            Some(kind) => json!({"available": true, "kind": kind.to_string()}),
6436            None => json!({"available": false, "kind": null}),
6437        },
6438        "platform": {
6439            "os": std::env::consts::OS,
6440            "arch": std::env::consts::ARCH,
6441        },
6442        "api_connectivity": {
6443            "checked": false,
6444            "status": "not_probed",
6445            "note": "JSON doctor is offline; use `codewhale doctor --probe-api` or `--probe-local` for an explicit live check.",
6446        },
6447        "capability": provider_capability_report(config),
6448    });
6449
6450    println!("{}", serde_json::to_string_pretty(&report)?);
6451    Ok(())
6452}
6453
6454fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
6455    let report = crate::context_report::build_headless_context_report(config, workspace);
6456    println!("{}", crate::context_report::context_report_json(&report));
6457    Ok(())
6458}
6459
6460/// Build the `capability` section for the machine-readable doctor report.
6461///
6462/// Returns a JSON value with the resolved provider, resolved model, context
6463/// window, max output, thinking support, cache telemetry support, and request
6464/// payload mode.
6465fn provider_capability_report(config: &Config) -> serde_json::Value {
6466    use serde_json::json;
6467
6468    let provider = config.api_provider();
6469    let configured_model = config.default_model();
6470    let route_result =
6471        crate::route_runtime::resolve_runtime_route(config, provider, Some(&configured_model));
6472    let route_error = route_result
6473        .is_err()
6474        .then_some("route_resolution_failed_details_omitted");
6475    let route = route_result.ok();
6476    let resolved_model = route
6477        .as_ref()
6478        .map_or(configured_model.as_str(), |route| route.model.as_str());
6479    let cap = crate::config::provider_capability(provider, resolved_model);
6480    let route_profile = route.as_ref().map(|route| {
6481        crate::model_profile::resolved_capability_profile_for_route(
6482            provider,
6483            resolved_model,
6484            route.candidate.capabilities(),
6485            route.candidate.limits(),
6486        )
6487    });
6488    let context_window = route
6489        .as_ref()
6490        .map_or(cap.context_window, |route| route.context_window.tokens);
6491    let context_window_source = route.as_ref().map_or(
6492        crate::route_runtime::ContextWindowSource::Fallback.label(),
6493        |route| route.context_window.source.label(),
6494    );
6495    // `null` when neither the resolved route nor the compatibility matrix
6496    // publishes an output ceiling — doctor must not invent one.
6497    let max_output = route_profile
6498        .as_ref()
6499        .and_then(|profile| profile.max_output)
6500        .or(cap.max_output);
6501    let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
6502        crate::config::is_exact_kimi_code_k3_route(
6503            provider,
6504            &route.candidate.endpoint().base_url,
6505            route.candidate.wire_model_id().as_str(),
6506        )
6507    });
6508    let thinking_supported = is_exact_kimi_code_k3
6509        || route_profile
6510            .as_ref()
6511            .map_or(cap.thinking_supported, |profile| {
6512                profile.supports_reasoning()
6513            });
6514    let cache_telemetry_supported = route_profile
6515        .as_ref()
6516        .map_or(cap.cache_telemetry_supported, |profile| {
6517            profile.prompt_caching.is_supported()
6518        });
6519    let request_payload_mode = route_profile
6520        .as_ref()
6521        .map_or(cap.request_payload_mode, |profile| {
6522            profile.request_payload_mode
6523        });
6524    let alias_deprecation = config.active_deepseek_alias_deprecation();
6525
6526    json!({
6527        "resolved_provider": config.provider_identity_for(provider),
6528        "resolved_model": resolved_model,
6529        "context_window": context_window,
6530        "context_window_source": context_window_source,
6531        "max_output": max_output,
6532        "thinking_supported": thinking_supported,
6533        "cache_telemetry_supported": cache_telemetry_supported,
6534        "request_payload_mode": serde_json::to_value(request_payload_mode).unwrap_or_default(),
6535        "route_error": route_error,
6536        "alias_deprecation": alias_deprecation,
6537    })
6538}
6539
6540fn doctor_route_report(config: &Config) -> serde_json::Value {
6541    use serde_json::json;
6542
6543    let target = doctor_api_target(config);
6544    let provider = config.api_provider();
6545    let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
6546    let route_result =
6547        crate::route_runtime::resolve_runtime_route(config, provider, Some(&target.model));
6548    let route_error = route_result
6549        .is_err()
6550        .then_some("route_resolution_failed_details_omitted");
6551    let context_window = route_result
6552        .ok()
6553        .map(|route| {
6554        json!({
6555            "tokens": route.context_window.tokens,
6556            "source": route.context_window.source.label(),
6557        })
6558    })
6559    .unwrap_or_else(|| {
6560        json!({
6561            "tokens": crate::config::provider_capability(provider, &target.model).context_window,
6562            "source": crate::route_runtime::ContextWindowSource::Fallback.label(),
6563        })
6564    });
6565
6566    let route_identity =
6567        crate::config::moonshot_k3_route_display_name(&target.base_url, &target.model);
6568    let credential = resolve_credential_diagnostic(config);
6569
6570    json!({
6571        "provider": target.provider,
6572        "provider_source": doctor_provider_source(config),
6573        "provider_config_table": doctor_provider_config_table(config, provider),
6574        "model": target.model,
6575        "route_identity": route_identity,
6576        "wire_protocol": doctor_wire_protocol(provider),
6577        "base_url": {
6578            "redacted": redacted_base_url,
6579            "class": doctor_base_url_class(provider, &target.base_url),
6580            "fingerprint": crate::utils::redacted_identifier_for_log(&target.base_url),
6581        },
6582        "auth": {
6583            "scheme": doctor_auth_scheme(config),
6584            "source": doctor_api_key_source_label(credential.source),
6585            "availability": credential.availability.label(),
6586        },
6587        "context_window": context_window,
6588        "route_error": route_error,
6589    })
6590}
6591
6592fn doctor_provider_config_table(config: &Config, provider: crate::config::ApiProvider) -> String {
6593    if provider != crate::config::ApiProvider::Custom {
6594        return provider_config_table_key(provider).to_string();
6595    }
6596    if config.uses_legacy_literal_custom_route() {
6597        "root (legacy literal custom)".to_string()
6598    } else {
6599        format!("providers.{}", config.provider_identity_for(provider))
6600    }
6601}
6602
6603fn doctor_provider_source(config: &Config) -> &'static str {
6604    if config
6605        .provider
6606        .as_ref()
6607        .is_some_and(|provider| !provider.trim().is_empty())
6608    {
6609        "config"
6610    } else {
6611        "default"
6612    }
6613}
6614
6615fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str {
6616    let policy = provider
6617        .metadata()
6618        .map(|metadata| metadata.wire_policy())
6619        .unwrap_or(codewhale_config::provider::WirePolicy::Fixed(
6620            codewhale_config::provider::WireFormat::ChatCompletions,
6621        ));
6622    match policy.fixed() {
6623        Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions",
6624        Some(codewhale_config::provider::WireFormat::Responses) => "responses",
6625        Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages",
6626        None => "model_aware",
6627    }
6628}
6629
6630fn doctor_base_url_class(provider: crate::config::ApiProvider, base_url: &str) -> &'static str {
6631    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
6632    if normalized.starts_with("http://localhost")
6633        || normalized.starts_with("http://127.0.0.1")
6634        || normalized.starts_with("http://[::1]")
6635    {
6636        return "local";
6637    }
6638    if normalized
6639        == provider
6640            .default_base_url()
6641            .trim_end_matches('/')
6642            .to_ascii_lowercase()
6643    {
6644        "default"
6645    } else {
6646        "custom"
6647    }
6648}
6649
6650fn doctor_auth_scheme(config: &Config) -> &'static str {
6651    let provider = config.api_provider();
6652    if crate::config::auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
6653    {
6654        "none"
6655    } else if provider == crate::config::ApiProvider::Anthropic {
6656        "x-api-key"
6657    } else if provider == crate::config::ApiProvider::XiaomiMimo
6658        && doctor_xiaomi_mimo_base_url_uses_token_plan(&config.deepseek_base_url())
6659    {
6660        "api-key"
6661    } else if provider == crate::config::ApiProvider::XiaomiMimo {
6662        // The alternate MiMo scheme depends on a credential prefix. Ordinary
6663        // doctor does not read credentials merely to make this label precise.
6664        "unknown"
6665    } else if matches!(
6666        provider,
6667        crate::config::ApiProvider::Sglang
6668            | crate::config::ApiProvider::Vllm
6669            | crate::config::ApiProvider::Ollama
6670    ) {
6671        "optional_bearer"
6672    } else {
6673        "bearer"
6674    }
6675}
6676
6677fn doctor_xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
6678    let normalized = base_url.trim_end_matches('/');
6679    [
6680        crate::config::XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
6681        crate::config::XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
6682        crate::config::XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
6683    ]
6684    .iter()
6685    .any(|candidate| normalized.eq_ignore_ascii_case(candidate.trim_end_matches('/')))
6686}
6687
6688fn doctor_api_key_source_label(source: ApiKeySource) -> &'static str {
6689    match source {
6690        ApiKeySource::ConfigDeclared => "config_declared",
6691        ApiKeySource::EnvDeclared => "env_declared",
6692        ApiKeySource::ExternalAuthDeclared => "external_auth_declared",
6693        ApiKeySource::SecretStoreUnprobed => "secret_store_unprobed",
6694        ApiKeySource::SecretStoreUnavailable => "secret_store_unavailable",
6695        ApiKeySource::OAuth => "oauth_unprobed",
6696        ApiKeySource::ExternalConsent => "external_consent",
6697        ApiKeySource::NoAuth => "none",
6698        ApiKeySource::LocalRuntime => "local_runtime",
6699        ApiKeySource::Unknown => "unknown",
6700    }
6701}
6702
6703fn doctor_search_provider_line(config: &Config) -> String {
6704    let search_provider = config.search_provider_resolution();
6705    let switch_hint = if matches!(
6706        (search_provider.provider, search_provider.source),
6707        (
6708            crate::config::SearchProvider::DuckDuckGo,
6709            crate::config::SearchProviderSource::Default
6710        )
6711    ) {
6712        "; set [search] provider = \"bing\" | \"tavily\" | \"bocha\" to switch"
6713    } else {
6714        ""
6715    };
6716
6717    format!(
6718        "search_provider: {} (source: {}{})",
6719        search_provider.provider.as_str(),
6720        search_provider.source.as_str(),
6721        switch_hint
6722    )
6723}
6724
6725fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
6726    use serde_json::json;
6727
6728    let search_provider = config.search_provider_resolution();
6729    json!({
6730        "provider": search_provider.provider.as_str(),
6731        "source": search_provider.source.as_str(),
6732    })
6733}
6734
6735/// Whether the model in a [`DoctorApiTarget`] is the wire id the engine
6736/// resolver produced, or only the raw configured value because resolution
6737/// failed. Doctor never prints resolution error details — the JSON route
6738/// report already redacts them for the same reason.
6739#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6740enum DoctorModelResolution {
6741    Resolved,
6742    ConfiguredOnly,
6743}
6744
6745#[derive(Debug, Clone, PartialEq, Eq)]
6746struct DoctorApiTarget {
6747    provider: String,
6748    base_url: String,
6749    model: String,
6750    resolution: DoctorModelResolution,
6751}
6752
6753#[derive(Debug, Clone, PartialEq, Eq)]
6754struct DoctorStrictToolModeStatus {
6755    enabled: bool,
6756    status: &'static str,
6757    function_strict_sent: bool,
6758    message: String,
6759    recommended_base_url: Option<String>,
6760}
6761
6762fn doctor_api_target(config: &Config) -> DoctorApiTarget {
6763    let provider = config.api_provider();
6764    // Report the model through the same resolver the live client uses at
6765    // session launch (`client.rs` → `resolve_runtime_route`), so doctor's
6766    // answer matches what a session started now would actually serve —
6767    // saved provider models, alias normalization, and roster preference
6768    // included — instead of re-deriving a config default that can diverge
6769    // from the engine (DGF-01, dogfood 2026-08-02).
6770    let (model, resolution) =
6771        match crate::route_runtime::resolve_runtime_route(config, provider, None) {
6772            Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved),
6773            Err(_) => (
6774                config.default_model(),
6775                DoctorModelResolution::ConfiguredOnly,
6776            ),
6777        };
6778    DoctorApiTarget {
6779        provider: config.provider_identity_for(provider),
6780        base_url: config.deepseek_base_url(),
6781        model,
6782        resolution,
6783    }
6784}
6785
6786fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus {
6787    if !config.strict_tool_mode.unwrap_or(false) {
6788        return DoctorStrictToolModeStatus {
6789            enabled: false,
6790            status: "disabled",
6791            function_strict_sent: false,
6792            message: "disabled".to_string(),
6793            recommended_base_url: None,
6794        };
6795    }
6796
6797    let target = doctor_api_target(config);
6798    match known_deepseek_base_url_kind(&target.base_url) {
6799        Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus {
6800            enabled: true,
6801            status: "ready",
6802            function_strict_sent: true,
6803            message: "enabled; DeepSeek strict schemas use the beta endpoint".to_string(),
6804            recommended_base_url: None,
6805        },
6806        Some(DeepSeekBaseUrlKind::NonBeta) => {
6807            let recommended = recommended_strict_base_url(config, &target.base_url);
6808            DoctorStrictToolModeStatus {
6809                enabled: true,
6810                status: "fallback_non_beta",
6811                function_strict_sent: false,
6812                message:
6813                    "enabled, but function.strict is stripped for this non-beta DeepSeek endpoint"
6814                        .to_string(),
6815                recommended_base_url: Some(recommended.to_string()),
6816            }
6817        }
6818        None => DoctorStrictToolModeStatus {
6819            enabled: true,
6820            status: "custom_endpoint",
6821            function_strict_sent: true,
6822            message: "enabled; function.strict will be sent to this custom endpoint".to_string(),
6823            recommended_base_url: None,
6824        },
6825    }
6826}
6827
6828fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> serde_json::Value {
6829    serde_json::json!({
6830        "enabled": status.enabled,
6831        "status": status.status,
6832        "function_strict_sent": status.function_strict_sent,
6833        "message": status.message,
6834        "recommended_base_url": status
6835            .recommended_base_url
6836            .as_deref()
6837            .map(crate::doctor::structural_url_authority),
6838    })
6839}
6840
6841#[derive(Debug, Clone, PartialEq, Eq)]
6842struct DoctorTlsStatus {
6843    certificate_verification: bool,
6844    insecure_skip_tls_verify: bool,
6845    provider: String,
6846    message: String,
6847}
6848
6849fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
6850    let provider = config.provider_identity_for(config.api_provider());
6851    let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
6852    let message = if insecure_skip_tls_verify {
6853        format!(
6854            "TLS certificate verification cannot be disabled for provider {provider}; use SSL_CERT_FILE with a trusted custom CA bundle"
6855        )
6856    } else {
6857        "TLS certificate verification enabled".to_string()
6858    };
6859    DoctorTlsStatus {
6860        certificate_verification: true,
6861        insecure_skip_tls_verify,
6862        provider,
6863        message,
6864    }
6865}
6866
6867#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6868enum DeepSeekBaseUrlKind {
6869    Beta,
6870    NonBeta,
6871}
6872
6873fn known_deepseek_base_url_kind(base_url: &str) -> Option<DeepSeekBaseUrlKind> {
6874    let normalized = base_url.trim_end_matches('/');
6875    if normalized.eq_ignore_ascii_case("https://api.deepseek.com/beta")
6876        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/beta")
6877    {
6878        Some(DeepSeekBaseUrlKind::Beta)
6879    } else if normalized.eq_ignore_ascii_case("https://api.deepseek.com")
6880        || normalized.eq_ignore_ascii_case("https://api.deepseek.com/v1")
6881        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com")
6882        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/v1")
6883    {
6884        Some(DeepSeekBaseUrlKind::NonBeta)
6885    } else {
6886        None
6887    }
6888}
6889
6890fn recommended_strict_base_url(_config: &Config, _base_url: &str) -> &'static str {
6891    crate::config::DEFAULT_DEEPSEEK_BASE_URL
6892}
6893
6894fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
6895    let target = doctor_api_target(config);
6896    let mut lines = vec![format!(
6897        "Connection timed out while reaching {}.",
6898        crate::doctor::structural_url_authority(&target.base_url)
6899    )];
6900
6901    match config.api_provider() {
6902        crate::config::ApiProvider::Deepseek
6903            if target.base_url.contains("api.deepseek.com")
6904                && !target.base_url.contains("api.deepseeki.com") =>
6905        {
6906            lines.push(
6907                "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.codewhale/config.toml and rerun `codewhale doctor`."
6908                    .to_string(),
6909            );
6910        }
6911        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
6912            lines.push(
6913                "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
6914                    .to_string(),
6915            );
6916        }
6917        _ => {
6918            lines.push(
6919                "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
6920                    .to_string(),
6921            );
6922        }
6923    }
6924
6925    lines.push(
6926        "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
6927            .to_string(),
6928    );
6929    lines
6930}
6931
6932fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
6933    match command.command {
6934        FeaturesSubcommand::List => {
6935            print!("{}", render_feature_table(&config.features()));
6936            Ok(())
6937        }
6938    }
6939}
6940
6941async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
6942    use crate::client::DeepSeekClient;
6943
6944    let client = DeepSeekClient::new(config)?;
6945    let mut models = client.list_models().await?;
6946    models.sort_by(|a, b| a.id.cmp(&b.id));
6947
6948    if args.json {
6949        println!("{}", serde_json::to_string_pretty(&models)?);
6950        return Ok(());
6951    }
6952
6953    if models.is_empty() {
6954        println!("No models returned by the API.");
6955        return Ok(());
6956    }
6957
6958    let default_model = config.default_model();
6959
6960    println!("Available models (default: {default_model})");
6961    for model in models {
6962        let marker = if model.id == default_model { "*" } else { " " };
6963        if let Some(owner) = model.owned_by {
6964            println!("{marker} {} ({owner})", model.id);
6965        } else {
6966            println!("{marker} {}", model.id);
6967        }
6968    }
6969
6970    Ok(())
6971}
6972
6973async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
6974    use crate::client::{DeepSeekClient, SpeechSynthesisRequest};
6975    use crate::config::ApiProvider;
6976    use crate::tools::speech::{
6977        DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions,
6978        default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri,
6979        infer_speech_model, normalize_speech_format,
6980    };
6981
6982    let SpeechArgs {
6983        text,
6984        output,
6985        output_dir,
6986        model,
6987        voice,
6988        instruction,
6989        voice_prompt,
6990        clone_voice,
6991        format,
6992        json: json_output,
6993    } = args;
6994
6995    if config.api_provider() != ApiProvider::XiaomiMimo {
6996        bail!(
6997            "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
6998            config.api_provider().as_str()
6999        );
7000    }
7001
7002    if text.trim().is_empty() {
7003        bail!("Speech text cannot be empty");
7004    }
7005    let voice_is_data_uri = voice
7006        .as_deref()
7007        .map(str::trim)
7008        .is_some_and(|value| value.starts_with("data:audio/"));
7009    if clone_voice.is_some() && voice.is_some() {
7010        bail!("Use either --clone-voice or --voice for cloned voice data, not both");
7011    }
7012    let model = infer_speech_model(
7013        model.as_deref(),
7014        clone_voice.is_some() || voice_is_data_uri,
7015        voice_prompt.is_some(),
7016    );
7017    let model_lower = model.to_ascii_lowercase();
7018    if !model_lower.contains("tts") {
7019        bail!(
7020            "speech requires a TTS model (examples: {}); got {model}",
7021            SPEECH_MODEL_EXAMPLES.join(", ")
7022        );
7023    }
7024    let is_voice_design = model_lower.contains("voicedesign");
7025    let is_voice_clone = model_lower.contains("voiceclone");
7026
7027    let instruction = combine_speech_instructions(instruction, voice_prompt);
7028    if is_voice_design
7029        && instruction
7030            .as_deref()
7031            .is_none_or(|value| value.trim().is_empty())
7032    {
7033        bail!(
7034            "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice"
7035        );
7036    }
7037
7038    let voice = if let Some(clone_path) = clone_voice {
7039        Some(encode_voice_clone_sample_data_uri(&clone_path)?)
7040    } else if is_voice_design {
7041        None
7042    } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) {
7043        Some(value)
7044    } else if is_voice_clone {
7045        bail!("mimo-v2.5-tts-voiceclone requires --clone-voice <mp3|wav> or --voice <data-uri>");
7046    } else {
7047        Some(DEFAULT_VOICE.to_string())
7048    };
7049    let format = normalize_speech_format(&format).with_context(|| {
7050        format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)")
7051    })?;
7052    let output = output.unwrap_or_else(|| {
7053        output_dir
7054            .or_else(|| config.speech_output_dir())
7055            .unwrap_or_default()
7056            .join(default_speech_output_name(&format))
7057    });
7058
7059    let client = DeepSeekClient::new(config)?;
7060    let response = client
7061        .synthesize_speech(SpeechSynthesisRequest {
7062            model: model.clone(),
7063            text,
7064            instruction,
7065            audio_format: format.clone(),
7066            voice,
7067        })
7068        .await?;
7069
7070    if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
7071        std::fs::create_dir_all(parent)
7072            .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
7073    }
7074    std::fs::write(&output, &response.audio_bytes)
7075        .with_context(|| format!("Failed to write audio file {}", output.display()))?;
7076
7077    if json_output {
7078        println!(
7079            "{}",
7080            serde_json::to_string_pretty(&serde_json::json!({
7081                "mode": "speech",
7082                "success": true,
7083                "model": response.model,
7084                "format": response.audio_format,
7085                "output": output.display().to_string(),
7086                "bytes": response.audio_bytes.len(),
7087                "voice": response.voice.as_deref().map(describe_speech_voice),
7088                "transcript": response.transcript,
7089            }))?
7090        );
7091    } else {
7092        println!(
7093            "Generated speech: {} ({} bytes, model: {}, format: {})",
7094            output.display(),
7095            response.audio_bytes.len(),
7096            response.model,
7097            response.audio_format
7098        );
7099    }
7100
7101    Ok(())
7102}
7103
7104#[cfg(test)]
7105mod speech_cli_tests {
7106    use super::*;
7107    use crate::tools::speech::{
7108        default_speech_output_name, infer_speech_model, normalize_speech_format,
7109    };
7110
7111    #[test]
7112    fn normalizes_documented_speech_formats() {
7113        assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav"));
7114        assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16"));
7115        assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16"));
7116        assert_eq!(normalize_speech_format("flac"), None);
7117    }
7118
7119    #[test]
7120    fn default_speech_output_tracks_requested_format() {
7121        assert_eq!(
7122            PathBuf::from(default_speech_output_name("mp3")),
7123            PathBuf::from("speech.mp3")
7124        );
7125        assert_eq!(
7126            PathBuf::from("audio").join(default_speech_output_name("pcm")),
7127            PathBuf::from("audio").join("speech.pcm16")
7128        );
7129    }
7130
7131    #[test]
7132    fn speech_command_parses_cli_passthrough_smoke() {
7133        let cli = Cli::try_parse_from([
7134            "codewhale-tui",
7135            "speech",
7136            "hello",
7137            "--model",
7138            "tts",
7139            "--format",
7140            "pcm",
7141            "--output-dir",
7142            "audio",
7143            "--voice",
7144            "Mia",
7145        ])
7146        .expect("speech command parses");
7147
7148        let Some(Commands::Speech(args)) = cli.command else {
7149            panic!("expected speech command");
7150        };
7151        assert_eq!(args.text, "hello");
7152        assert_eq!(
7153            infer_speech_model(args.model.as_deref(), false, false),
7154            "mimo-v2.5-tts"
7155        );
7156        assert_eq!(
7157            normalize_speech_format(&args.format).as_deref(),
7158            Some("pcm16")
7159        );
7160        assert_eq!(args.output_dir, Some(PathBuf::from("audio")));
7161        assert_eq!(args.voice.as_deref(), Some("Mia"));
7162    }
7163}
7164
7165/// Test API connectivity by making a minimal request
7166async fn test_api_connectivity(config: &Config) -> Result<()> {
7167    use crate::client::DeepSeekClient;
7168    use crate::models::{ContentBlock, Message, MessageRequest};
7169
7170    let client = DeepSeekClient::new(config)?;
7171    let model = client.model().to_string();
7172
7173    // Minimal request: single word prompt, 1 max token
7174    let request = MessageRequest {
7175        model: model.clone(),
7176        messages: vec![Message {
7177            role: "user".to_string(),
7178            content: vec![ContentBlock::Text {
7179                text: "hi".to_string(),
7180                cache_control: None,
7181            }],
7182        }],
7183        max_tokens: 1,
7184        system: None,
7185        tools: None,
7186        tool_choice: None,
7187        metadata: None,
7188        thinking: None,
7189        reasoning_effort: None,
7190        stream: Some(false),
7191        temperature: None,
7192        top_p: None,
7193    };
7194
7195    // Use tokio timeout to catch hanging requests
7196    let timeout_duration = std::time::Duration::from_secs(15);
7197    match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
7198        Ok(Ok(_response)) => Ok(()),
7199        Ok(Err(e)) => Err(e),
7200        Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
7201    }
7202}
7203
7204fn rustc_version() -> String {
7205    let Some(mut cmd) = crate::dependencies::RustC::command() else {
7206        return "unknown".to_string();
7207    };
7208    let Ok(output) = cmd.arg("--version").output() else {
7209        return "unknown".to_string();
7210    };
7211    String::from_utf8(output.stdout)
7212        .map(|s| s.trim().to_string())
7213        .unwrap_or_else(|_| "unknown".to_string())
7214}
7215
7216/// List saved sessions
7217fn sessions_resume_command() -> &'static str {
7218    "codewhale resume"
7219}
7220
7221fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
7222    use crate::palette;
7223    use colored::Colorize;
7224    use session_manager::{SessionManager, format_session_line};
7225
7226    let (action_r, action_g, action_b) = palette::WHALE_ACTION_RGB;
7227    let (human_r, human_g, human_b) = palette::WHALE_HUMAN_RGB;
7228    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7229    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7230
7231    let manager = SessionManager::default_location()?;
7232
7233    let sessions = if let Some(query) = search {
7234        manager.search_sessions(&query)?
7235    } else {
7236        manager.list_sessions()?
7237    };
7238
7239    if sessions.is_empty() {
7240        println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
7241        println!(
7242            "Start a new session with: {}",
7243            "codewhale".truecolor(human_r, human_g, human_b)
7244        );
7245        return Ok(());
7246    }
7247
7248    println!(
7249        "{}",
7250        "Saved Sessions"
7251            .truecolor(action_r, action_g, action_b)
7252            .bold()
7253    );
7254    println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
7255    println!();
7256
7257    for (i, session) in sessions.iter().take(limit).enumerate() {
7258        let line = format_session_line(session);
7259        if i == 0 {
7260            println!("  {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
7261        } else {
7262            println!("    {line}");
7263        }
7264    }
7265
7266    let total = sessions.len();
7267    if total > limit {
7268        println!();
7269        println!(
7270            "  {} more session(s). Use --limit to show more.",
7271            total - limit
7272        );
7273    }
7274
7275    println!();
7276    println!(
7277        "Resume with: {} {}",
7278        sessions_resume_command().truecolor(action_r, action_g, action_b),
7279        "<session-id>".dimmed()
7280    );
7281    println!(
7282        "Continue latest in this workspace: {}",
7283        "codewhale --continue".truecolor(action_r, action_g, action_b)
7284    );
7285
7286    Ok(())
7287}
7288
7289/// Initialize a new project with AGENTS.md
7290fn init_project() -> Result<()> {
7291    use crate::palette;
7292    use colored::Colorize;
7293    use project_context::create_default_agents_md;
7294
7295    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7296    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7297    let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
7298
7299    let workspace = std::env::current_dir()?;
7300    let agents_path = workspace.join("AGENTS.md");
7301
7302    if agents_path.exists() {
7303        println!(
7304            "{} AGENTS.md already exists at {}",
7305            "!".truecolor(sky_r, sky_g, sky_b),
7306            agents_path.display()
7307        );
7308        return Ok(());
7309    }
7310
7311    match create_default_agents_md(&workspace) {
7312        Ok(path) => {
7313            println!(
7314                "{} Created {}",
7315                "✓".truecolor(aqua_r, aqua_g, aqua_b),
7316                path.display()
7317            );
7318            println!();
7319            println!("Edit this file to customize how the AI agent works with your project.");
7320            println!("The instructions will be loaded automatically when you run codewhale.");
7321        }
7322        Err(e) => {
7323            println!(
7324                "{} Failed to create AGENTS.md: {}",
7325                "✗".truecolor(red_r, red_g, red_b),
7326                e
7327            );
7328        }
7329    }
7330
7331    Ok(())
7332}
7333
7334fn resolve_workspace(cli: &Cli) -> PathBuf {
7335    cli.workspace
7336        .clone()
7337        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
7338}
7339
7340fn load_config_from_cli(cli: &Cli) -> Result<Config> {
7341    load_config_from_cli_with_effective_profile(cli).map(|(config, _)| config)
7342}
7343
7344/// Doctor is a structural report unless the user explicitly asks it to probe
7345/// a provider endpoint. Keep credential-bearing environment values out of the
7346/// regular diagnostic configuration so an unrelated renderer or error path
7347/// cannot disclose them.
7348fn load_doctor_config_from_cli(cli: &Cli, args: &DoctorArgs) -> Result<Config> {
7349    if args.probe_api || args.probe_local {
7350        return load_config_from_cli(cli);
7351    }
7352    load_structural_config_from_cli(cli)
7353}
7354
7355fn load_structural_config_from_cli(cli: &Cli) -> Result<Config> {
7356    let profile = effective_config_profile(cli);
7357    let mut config = Config::load_structural(cli.config.clone(), profile.as_deref())?;
7358    if let Ok(settings) = crate::settings::Settings::load_read_only() {
7359        apply_saved_reasoning_preference(&mut config, &settings);
7360    }
7361    cli.feature_toggles.apply(&mut config)?;
7362    Ok(config)
7363}
7364
7365fn effective_config_profile(cli: &Cli) -> Option<String> {
7366    cli.profile
7367        .clone()
7368        .or_else(|| std::env::var("CODEWHALE_PROFILE").ok())
7369        .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok())
7370}
7371
7372fn load_config_from_cli_with_effective_profile(cli: &Cli) -> Result<(Config, Option<String>)> {
7373    let profile = effective_config_profile(cli);
7374    let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
7375    // Config loading is shared by diagnostics and mutating runtimes. Read the
7376    // saved preference without migrating or creating state here; interactive
7377    // startup performs any permitted migration later through `Settings::load`.
7378    if let Ok(settings) = crate::settings::Settings::load_read_only() {
7379        apply_saved_reasoning_preference(&mut config, &settings);
7380    }
7381    cli.feature_toggles.apply(&mut config)?;
7382    Ok((config, profile))
7383}
7384
7385/// Apply the same reasoning-preference precedence as interactive `App`
7386/// construction to non-TUI runtimes.
7387///
7388/// `/model` and the config editor persist this preference in `settings.toml`.
7389/// Exec, review, workflow, ACP, and runtime-thread launches all begin with a
7390/// `Config`, so copying the saved value here keeps those entry points from
7391/// silently falling back to a route classifier or an older config.toml value.
7392fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::settings::Settings) {
7393    let Some(reasoning_effort) = settings.reasoning_effort.as_ref() else {
7394        return;
7395    };
7396    config.reasoning_effort = Some(reasoning_effort.clone());
7397    config.reasoning_effort_inferred_from_legacy_alias = false;
7398}
7399
7400fn read_api_key_from_stdin() -> Result<String> {
7401    let mut stdin = io::stdin();
7402    if stdin.is_terminal() {
7403        bail!("No API key provided. Pass --api-key or pipe one via stdin.");
7404    }
7405    let mut buffer = String::new();
7406    stdin.read_to_string(&mut buffer)?;
7407    let api_key = buffer.trim().to_string();
7408    if api_key.is_empty() {
7409        bail!("No API key provided via stdin.");
7410    }
7411    Ok(api_key)
7412}
7413
7414fn run_login(api_key: Option<String>) -> Result<()> {
7415    let api_key = match api_key {
7416        Some(key) => key,
7417        None => read_api_key_from_stdin()?,
7418    };
7419    let saved = config::save_api_key(&api_key)?;
7420    println!("Saved API key to {}", saved.describe());
7421    Ok(())
7422}
7423
7424fn run_logout() -> Result<()> {
7425    config::clear_api_key()?;
7426    println!("Cleared saved API key.");
7427    Ok(())
7428}
7429
7430async fn run_xai_device_auth(config_path: Option<&Path>) -> Result<()> {
7431    let pending = xai_oauth::device_code_login().await?;
7432    let activation = xai_oauth::activate_device_login(pending, config_path, None)?;
7433    println!(
7434        "xAI OAuth is ready; activated {} via {}",
7435        codewhale_config::quote_os_path(&activation.auth_path),
7436        codewhale_config::quote_os_path(&activation.config_path)
7437    );
7438    Ok(())
7439}
7440
7441fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
7442    if last {
7443        return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
7444            anyhow!(
7445                "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume <SESSION_ID>` to resume one explicitly.",
7446                workspace.display()
7447            )
7448        });
7449    }
7450    if let Some(id) = session_id {
7451        return Ok(id);
7452    }
7453    pick_session_id()
7454}
7455
7456fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
7457    let manager = SessionManager::default_location()?;
7458    Ok(manager
7459        .get_latest_session_for_workspace(workspace)?
7460        .map(|session| session.id))
7461}
7462
7463fn fork_session(
7464    config: &Config,
7465    session_id: Option<String>,
7466    last: bool,
7467    workspace: &Path,
7468) -> Result<String> {
7469    let manager = SessionManager::default_location()?;
7470    let saved = if last {
7471        let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
7472            bail!(
7473                "No saved sessions found for workspace {}.",
7474                workspace.display()
7475            );
7476        };
7477        manager.load_session(&meta.id)?
7478    } else {
7479        let id = resolve_session_id(session_id, false, workspace)?;
7480        manager.load_session_by_prefix(&id)?
7481    };
7482    let saved_provider_identity = saved
7483        .metadata
7484        .model_provider_id
7485        .as_deref()
7486        .filter(|identity| !identity.trim().is_empty())
7487        .unwrap_or(&saved.metadata.model_provider);
7488    let provider_identity = config
7489        .resolve_persisted_provider_identity(
7490            Some(&saved.metadata.model_provider),
7491            saved.metadata.model_provider_id.as_deref(),
7492        )
7493        .map_err(anyhow::Error::msg)
7494        .with_context(|| {
7495            format!(
7496                "saved session provider '{}' is unavailable; fork will not fall back",
7497                saved_provider_identity
7498            )
7499        })?;
7500
7501    let system_prompt = saved
7502        .system_prompt
7503        .as_ref()
7504        .map(|text| SystemPrompt::Text(text.clone()));
7505    let mut forked = create_saved_session(
7506        &saved.messages,
7507        &saved.metadata.model,
7508        &saved.metadata.workspace,
7509        saved.metadata.total_tokens,
7510        system_prompt.as_ref(),
7511    );
7512    forked.metadata.set_model_provider_route(
7513        provider_identity.provider.as_str(),
7514        provider_identity.persisted_id(),
7515    );
7516    forked.metadata.copy_cost_from(&saved.metadata);
7517    forked.metadata.mark_forked_from(&saved.metadata);
7518    manager.save_session(&forked)?;
7519
7520    let source_title = saved.metadata.title.trim();
7521    let source_label = if source_title.is_empty() {
7522        "session".to_string()
7523    } else {
7524        format!("\"{source_title}\"")
7525    };
7526    println!(
7527        "Forked {source_label} ({source_id}) → new session {new_id}",
7528        source_id = truncate_id(&saved.metadata.id),
7529        new_id = truncate_id(&forked.metadata.id),
7530    );
7531
7532    Ok(forked.metadata.id)
7533}
7534
7535fn pick_session_id() -> Result<String> {
7536    let manager = SessionManager::default_location()?;
7537    let sessions = manager.list_sessions()?;
7538    if sessions.is_empty() {
7539        bail!("No saved sessions found.");
7540    }
7541
7542    println!("Select a session to resume:");
7543    for (idx, session) in sessions.iter().enumerate() {
7544        println!("  {:>2}. {} ({})", idx + 1, session.title, session.id);
7545    }
7546    print!("Enter a number (or press Enter to cancel): ");
7547    io::stdout().flush()?;
7548
7549    let mut input = String::new();
7550    io::stdin().read_line(&mut input)?;
7551    let input = input.trim();
7552    if input.is_empty() {
7553        bail!("No session selected.");
7554    }
7555    let idx: usize = input
7556        .parse()
7557        .map_err(|_| anyhow::anyhow!("Invalid input"))?;
7558    let session = sessions
7559        .get(idx.saturating_sub(1))
7560        .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
7561    Ok(session.id.clone())
7562}
7563
7564async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
7565    use crate::client::DeepSeekClient;
7566
7567    let diff = collect_diff(&args)?;
7568    if diff.trim().is_empty() {
7569        bail!("No diff to review.");
7570    }
7571    validate_review_receipt_args(&args)?;
7572    if args.check_receipt {
7573        return run_review_receipt_check(&diff, &args);
7574    }
7575
7576    let model = resolve_review_model(config, args.model.as_deref());
7577    let route = resolve_cli_exec_route(config, &model, &diff, args.model.is_none()).await?;
7578    let execution_config = config_for_cli_route(config, &route);
7579    let route_provider = execution_config.provider_identity_for(route.provider);
7580    let model = route.model.clone();
7581    let user_prompt =
7582        format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
7583    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
7584        cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
7585    });
7586
7587    let system = SystemPrompt::Text(
7588        "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
7589Provide findings ordered by severity with file references, then open questions, then a brief summary."
7590            .to_string(),
7591    );
7592    let client = DeepSeekClient::new(&execution_config)?;
7593    let request = MessageRequest {
7594        model: model.clone(),
7595        messages: vec![Message {
7596            role: "user".to_string(),
7597            content: vec![ContentBlock::Text {
7598                text: user_prompt,
7599                cache_control: None,
7600            }],
7601        }],
7602        max_tokens: 4096,
7603        system: Some(system),
7604        tools: None,
7605        tool_choice: None,
7606        metadata: None,
7607        thinking: None,
7608        reasoning_effort,
7609        stream: Some(false),
7610        temperature: Some(0.2),
7611        top_p: Some(0.9),
7612    };
7613
7614    let response = client.create_message(request).await?;
7615    let mut output = String::new();
7616    for block in response.content {
7617        if let ContentBlock::Text { text, .. } = block {
7618            output.push_str(&text);
7619        }
7620    }
7621    let receipt = if args.write_receipt {
7622        let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
7623        let receipt = crate::tools::review::build_review_receipt(
7624            review_target_label(&args),
7625            &diff,
7626            &route_provider,
7627            &model,
7628            &parsed_output,
7629            &output,
7630            Vec::new(),
7631        );
7632        let path =
7633            crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref())?;
7634        Some((path, receipt))
7635    } else {
7636        None
7637    };
7638    if args.json {
7639        println!(
7640            "{}",
7641            serde_json::to_string_pretty(&serde_json::json!({
7642                "mode": "review",
7643                "provider": route_provider,
7644                "model": model,
7645                "success": true,
7646                "content": output,
7647                "receipt_path": receipt
7648                    .as_ref()
7649                    .map(|(path, _)| path.display().to_string()),
7650                "receipt": receipt.as_ref().map(|(_, receipt)| receipt),
7651            }))?
7652        );
7653    } else {
7654        println!("{output}");
7655        if let Some((path, _)) = receipt {
7656            eprintln!("Review receipt written: {}", path.display());
7657        }
7658    }
7659    Ok(())
7660}
7661
7662fn resolve_review_model(config: &Config, explicit_model: Option<&str>) -> String {
7663    explicit_model
7664        .map(str::trim)
7665        .filter(|model| !model.is_empty())
7666        .map(str::to_string)
7667        .unwrap_or_else(|| config.default_model())
7668}
7669
7670fn validate_review_receipt_args(args: &ReviewArgs) -> Result<()> {
7671    if args.receipt_path.is_some() && !args.write_receipt && !args.check_receipt {
7672        bail!("--receipt-path requires --write-receipt or --check-receipt");
7673    }
7674    if args.write_receipt && args.check_receipt {
7675        bail!("--write-receipt and --check-receipt are mutually exclusive");
7676    }
7677    Ok(())
7678}
7679
7680fn run_review_receipt_check(diff: &str, args: &ReviewArgs) -> Result<()> {
7681    let (path, receipt) = if let Some(path) = args.receipt_path.as_ref() {
7682        (
7683            path.clone(),
7684            crate::tools::review::read_review_receipt(path)
7685                .with_context(|| format!("failed to read review receipt {}", path.display()))?,
7686        )
7687    } else {
7688        crate::tools::review::latest_review_receipt_for_diff(diff)?.ok_or_else(|| {
7689            anyhow!(
7690                "No review receipt found for the current diff. Run `codewhale review --write-receipt` first, or pass --receipt-path."
7691            )
7692        })?
7693    };
7694    let validation =
7695        crate::tools::review::validate_review_receipt_for_diff(diff, &receipt, Some(path.clone()));
7696
7697    if args.json {
7698        println!(
7699            "{}",
7700            serde_json::to_string_pretty(&serde_json::json!({
7701                "mode": "review_receipt_check",
7702                "success": validation.passed,
7703                "validation": review_receipt_validation_public_json(&validation),
7704            }))?
7705        );
7706    } else if validation.passed {
7707        println!("Review receipt valid: {}", path.display());
7708    }
7709
7710    if !validation.passed {
7711        bail!("Review receipt check failed: {}", validation.reason);
7712    }
7713    Ok(())
7714}
7715
7716fn review_receipt_validation_public_json(
7717    validation: &crate::tools::review::ReviewReceiptValidation,
7718) -> serde_json::Value {
7719    let unresolved_risk = validation.unresolved_risk.as_ref();
7720    serde_json::json!({
7721        "passed": validation.passed,
7722        "status": review_receipt_validation_status(validation),
7723        "diff_fingerprint": validation.diff_fingerprint.as_str(),
7724        "receipt_fingerprint": validation.receipt_fingerprint.as_deref(),
7725        "unresolved": unresolved_risk.is_some_and(|risk| risk.unresolved),
7726        "risk_level": unresolved_risk.map(|risk| risk.level.as_str()),
7727    })
7728}
7729
7730fn review_receipt_validation_status(
7731    validation: &crate::tools::review::ReviewReceiptValidation,
7732) -> &'static str {
7733    if validation.passed {
7734        "valid"
7735    } else if validation
7736        .receipt_fingerprint
7737        .as_deref()
7738        .is_some_and(|fingerprint| fingerprint != validation.diff_fingerprint.as_str())
7739    {
7740        "diff_mismatch"
7741    } else if validation
7742        .unresolved_risk
7743        .as_ref()
7744        .is_some_and(|risk| risk.unresolved)
7745    {
7746        "unresolved_risk"
7747    } else if validation
7748        .reason
7749        .starts_with("unsupported review receipt schema version")
7750    {
7751        "unsupported_schema"
7752    } else if validation.reason.starts_with("review receipt check ") {
7753        "check_failed"
7754    } else {
7755        "invalid"
7756    }
7757}
7758
7759/// `codewhale pr <N>` (#451) — fetch a GitHub PR via `gh`, format
7760/// title + body + diff as the composer's first message, and launch
7761/// the interactive TUI. Falls back gracefully if `gh` is missing.
7762async fn run_pr(
7763    cli: &Cli,
7764    config: &Config,
7765    number: u32,
7766    repo: Option<&str>,
7767    checkout: bool,
7768    plugin_registry: Arc<crate::plugins::PluginRegistry>,
7769) -> Result<()> {
7770    if !is_command_available("gh") {
7771        bail!(
7772            "`gh` CLI not found on PATH. Install GitHub CLI \
7773             (https://cli.github.com) and authenticate (`gh auth login`) \
7774             so `codewhale pr <N>` can fetch PR metadata and the diff."
7775        );
7776    }
7777
7778    let view = run_gh_pr_view(number, repo)?;
7779    let diff = run_gh_pr_diff(number, repo)?;
7780
7781    if checkout {
7782        match run_gh_pr_checkout(number, repo) {
7783            Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
7784            Err(err) => eprintln!(
7785                "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
7786            ),
7787        }
7788    }
7789
7790    let prompt = format_pr_prompt(number, &view, &diff);
7791    let resume_session_id = if cli.continue_session {
7792        let workspace = resolve_workspace(cli);
7793        latest_session_id_for_workspace(&workspace).ok().flatten()
7794    } else {
7795        cli.resume.clone()
7796    };
7797    run_interactive(
7798        cli,
7799        config,
7800        resume_session_id,
7801        Some(tui::InitialInput::Prefill(prompt)),
7802        plugin_registry,
7803    )
7804    .await
7805}
7806
7807/// Return true if `name` resolves to an executable on the current `PATH`.
7808///
7809/// Walks `$PATH` directly instead of probing with `--version`. The
7810/// previous implementation invoked `Command::new(name).arg("--version")`,
7811/// which fails on the Ubuntu CI runner because `/bin/sh` is `dash` —
7812/// `dash --version` exits with status 2 ("invalid option") even though
7813/// `sh` is plainly on PATH. macOS happens to ship bash as `sh`, which
7814/// does honor `--version`, so the bug was invisible locally and only
7815/// surfaced in CI logs.
7816///
7817/// Windows: also checks the `.exe` extension when `name` doesn't have
7818/// one, matching the platform's PATHEXT lookup behavior for the common
7819/// case.
7820fn is_command_available(name: &str) -> bool {
7821    let Some(path) = std::env::var_os("PATH") else {
7822        return false;
7823    };
7824    for dir in std::env::split_paths(&path) {
7825        let candidate = dir.join(name);
7826        if candidate.is_file() {
7827            return true;
7828        }
7829        #[cfg(windows)]
7830        {
7831            // PATHEXT gives `.exe`/`.cmd`/`.bat` etc. priority — we only
7832            // probe `.exe` because that's the case that actually trips
7833            // up the negative case (`gh` resolves as `gh.exe`).
7834            if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
7835                return true;
7836            }
7837        }
7838    }
7839    false
7840}
7841
7842#[derive(Debug, Clone, Default)]
7843struct GhPullRequest {
7844    title: String,
7845    body: String,
7846    base: String,
7847    head: String,
7848    url: String,
7849}
7850
7851fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
7852    let mut cmd = crate::dependencies::Gh::command()
7853        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7854    cmd.arg("pr").arg("view").arg(number.to_string());
7855    if let Some(r) = repo {
7856        cmd.arg("--repo").arg(r);
7857    }
7858    cmd.arg("--json")
7859        .arg("title,body,baseRefName,headRefName,url");
7860    let output = cmd
7861        .output()
7862        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
7863    if !output.status.success() {
7864        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
7865        bail!("gh pr view #{number} failed: {stderr}");
7866    }
7867    let raw = String::from_utf8_lossy(&output.stdout).to_string();
7868    let value: serde_json::Value = serde_json::from_str(&raw)
7869        .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
7870    let pick = |key: &str| {
7871        value
7872            .get(key)
7873            .and_then(serde_json::Value::as_str)
7874            .unwrap_or_default()
7875            .to_string()
7876    };
7877    Ok(GhPullRequest {
7878        title: pick("title"),
7879        body: pick("body"),
7880        base: pick("baseRefName"),
7881        head: pick("headRefName"),
7882        url: pick("url"),
7883    })
7884}
7885
7886fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
7887    let mut cmd = crate::dependencies::Gh::command()
7888        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7889    cmd.arg("pr").arg("diff").arg(number.to_string());
7890    if let Some(r) = repo {
7891        cmd.arg("--repo").arg(r);
7892    }
7893    let output = cmd
7894        .output()
7895        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
7896    if !output.status.success() {
7897        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
7898        bail!("gh pr diff #{number} failed: {stderr}");
7899    }
7900    Ok(String::from_utf8_lossy(&output.stdout).to_string())
7901}
7902
7903fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
7904    let mut cmd = crate::dependencies::Gh::command()
7905        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
7906    cmd.arg("pr").arg("checkout").arg(number.to_string());
7907    if let Some(r) = repo {
7908        cmd.arg("--repo").arg(r);
7909    }
7910    let output = cmd
7911        .output()
7912        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
7913    if !output.status.success() {
7914        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
7915        bail!("gh pr checkout #{number} failed: {stderr}");
7916    }
7917    Ok(())
7918}
7919
7920/// Format the PR review prompt that lands in the composer. Caps the
7921/// diff at 200 KiB so a massive PR doesn't blow the model's context
7922/// window before the user even hits Enter — they can always ask the
7923/// model to fetch more via `gh pr diff #N` from inside the session.
7924fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
7925    const MAX_DIFF_BYTES: usize = 200 * 1024;
7926    let diff_section = if diff.len() > MAX_DIFF_BYTES {
7927        let cut = (0..=MAX_DIFF_BYTES)
7928            .rev()
7929            .find(|&i| diff.is_char_boundary(i))
7930            .unwrap_or(0);
7931        format!(
7932            "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
7933            &diff[..cut],
7934            MAX_DIFF_BYTES / 1024
7935        )
7936    } else {
7937        diff.to_string()
7938    };
7939    let body = if view.body.trim().is_empty() {
7940        "(no description)".to_string()
7941    } else {
7942        view.body.trim().to_string()
7943    };
7944    let title = if view.title.trim().is_empty() {
7945        format!("(PR #{number})")
7946    } else {
7947        view.title.trim().to_string()
7948    };
7949    let branches = match (view.base.is_empty(), view.head.is_empty()) {
7950        (false, false) => format!("{} ← {}", view.base, view.head),
7951        (false, true) => view.base.clone(),
7952        (true, false) => view.head.clone(),
7953        _ => "(unknown)".to_string(),
7954    };
7955    format!(
7956        "Review PR #{number} — {title}\n\
7957         \n\
7958         URL: {url}\n\
7959         Branches: {branches}\n\
7960         \n\
7961         ## Description\n\
7962         \n\
7963         {body}\n\
7964         \n\
7965         ## Diff\n\
7966         \n\
7967         ```diff\n\
7968         {diff_section}\n\
7969         ```\n",
7970        url = if view.url.is_empty() {
7971            "(unavailable)"
7972        } else {
7973            view.url.as_str()
7974        },
7975    )
7976}
7977
7978fn collect_diff(args: &ReviewArgs) -> Result<String> {
7979    let mut cmd = crate::dependencies::Git::command()
7980        .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
7981    cmd.arg("diff");
7982    if args.staged {
7983        cmd.arg("--cached");
7984    }
7985    if let Some(base) = &args.base {
7986        cmd.arg(format!("{base}...HEAD"));
7987    }
7988    if let Some(path) = &args.path {
7989        cmd.arg("--").arg(path);
7990    }
7991
7992    let output = cmd
7993        .output()
7994        .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
7995    if !output.status.success() {
7996        let stderr = String::from_utf8_lossy(&output.stderr);
7997        bail!("git diff failed: {}", stderr.trim());
7998    }
7999    let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
8000    if diff.len() > args.max_chars {
8001        diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
8002    }
8003    Ok(diff)
8004}
8005
8006fn review_target_label(args: &ReviewArgs) -> String {
8007    let mut label = if args.staged {
8008        "staged".to_string()
8009    } else if let Some(base) = args
8010        .base
8011        .as_deref()
8012        .map(str::trim)
8013        .filter(|base| !base.is_empty())
8014    {
8015        format!("base:{base}")
8016    } else {
8017        "working-tree".to_string()
8018    };
8019    if let Some(path) = &args.path {
8020        label.push(' ');
8021        label.push_str(path.to_string_lossy().as_ref());
8022    }
8023    label
8024}
8025
8026fn run_apply(args: ApplyArgs) -> Result<()> {
8027    let patch = if let Some(path) = args.patch_file {
8028        std::fs::read_to_string(&path)
8029            .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
8030    } else {
8031        read_patch_from_stdin()?
8032    };
8033    if patch.trim().is_empty() {
8034        bail!("Patch is empty.");
8035    }
8036
8037    let mut tmp = NamedTempFile::new()?;
8038    tmp.write_all(patch.as_bytes())?;
8039    let tmp_path = tmp.path().to_path_buf();
8040
8041    let output = crate::dependencies::Git::command()
8042        .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?
8043        .arg("apply")
8044        .arg("--whitespace=nowarn")
8045        .arg(&tmp_path)
8046        .output()
8047        .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?;
8048
8049    if !output.status.success() {
8050        let stderr = String::from_utf8_lossy(&output.stderr);
8051        bail!("git apply failed: {}", stderr.trim());
8052    }
8053    println!("Applied patch successfully.");
8054    Ok(())
8055}
8056
8057fn read_patch_from_stdin() -> Result<String> {
8058    let mut stdin = io::stdin();
8059    if stdin.is_terminal() {
8060        bail!("No patch file provided and stdin is empty.");
8061    }
8062    let mut buffer = String::new();
8063    stdin.read_to_string(&mut buffer)?;
8064    Ok(buffer)
8065}
8066
8067async fn run_mcp_command(
8068    config: &Config,
8069    workspace: &Path,
8070    command: McpCommand,
8071    plugins: &crate::plugins::PluginRegistry,
8072) -> Result<()> {
8073    let config_path = config.mcp_config_path();
8074    match command {
8075        McpCommand::Init { force } => {
8076            let status = init_mcp_config(&config_path, force)?;
8077            match status {
8078                WriteStatus::Created => {
8079                    println!("Created MCP config at {}", config_path.display());
8080                }
8081                WriteStatus::Overwritten => {
8082                    println!("Overwrote MCP config at {}", config_path.display());
8083                }
8084                WriteStatus::SkippedExists => {
8085                    println!(
8086                        "MCP config already exists at {} (use --force to overwrite)",
8087                        config_path.display()
8088                    );
8089                }
8090            }
8091            println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`.");
8092            Ok(())
8093        }
8094        McpCommand::List => {
8095            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8096                &config_path,
8097                workspace,
8098                plugins,
8099            )?;
8100            if cfg.servers.is_empty() {
8101                println!(
8102                    "No MCP servers configured in {} or {}",
8103                    config_path.display(),
8104                    crate::mcp::workspace_mcp_config_path(workspace).display()
8105                );
8106                return Ok(());
8107            }
8108            println!("MCP servers ({}):", cfg.servers.len());
8109            for (name, server) in cfg.servers {
8110                let status = if server.enabled && !server.disabled {
8111                    "enabled"
8112                } else {
8113                    "disabled"
8114                };
8115                let auth_status = crate::mcp::oauth::auth_status_for_server(&name, &server).await;
8116                let auth = if auth_status == crate::mcp::oauth::McpAuthStatus::Unsupported {
8117                    String::new()
8118                } else {
8119                    format!(
8120                        " auth={}",
8121                        auth_status
8122                            .to_string()
8123                            .to_ascii_lowercase()
8124                            .replace(' ', "-")
8125                    )
8126                };
8127                let args = if server.args.is_empty() {
8128                    "".to_string()
8129                } else {
8130                    format!(" {}", server.args.join(" "))
8131                };
8132                let cmd_str = if let Some(cmd) = server.command {
8133                    format!("{cmd}{args}")
8134                } else if let Some(url) = server.url {
8135                    url
8136                } else {
8137                    "unknown".to_string()
8138                };
8139                let required = if server.required { " required" } else { "" };
8140                println!("  - {name} [{status}{required}{auth}] {cmd_str}");
8141            }
8142            Ok(())
8143        }
8144        McpCommand::Connect { server } => {
8145            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8146                &config_path,
8147                workspace,
8148                std::sync::Arc::new(plugins.clone()),
8149            )?;
8150            if let Some(name) = server {
8151                if let Err(err) = pool.get_or_connect(&name).await {
8152                    if crate::mcp::oauth::error_looks_auth_required(&err) {
8153                        let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8154                        return Err(err).context(hint);
8155                    }
8156                    return Err(err);
8157                }
8158                println!("Connected to MCP server: {name}");
8159            } else {
8160                let errors = pool.connect_all().await;
8161                if errors.is_empty() {
8162                    println!("Connected to all configured MCP servers.");
8163                } else {
8164                    for (name, err) in errors {
8165                        eprintln!("Failed to connect {name}: {err:#}");
8166                        if crate::mcp::oauth::error_looks_auth_required(&err) {
8167                            eprintln!("  {}", crate::mcp::oauth::auth_required_login_hint(&name));
8168                        }
8169                    }
8170                }
8171            }
8172            Ok(())
8173        }
8174        McpCommand::Tools { server } => {
8175            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8176                &config_path,
8177                workspace,
8178                std::sync::Arc::new(plugins.clone()),
8179            )?;
8180            if let Some(name) = server {
8181                let conn = match pool.get_or_connect(&name).await {
8182                    Ok(conn) => conn,
8183                    Err(err) => {
8184                        if crate::mcp::oauth::error_looks_auth_required(&err) {
8185                            let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8186                            return Err(err).context(hint);
8187                        }
8188                        return Err(err);
8189                    }
8190                };
8191                if conn.tools().is_empty() {
8192                    println!("No tools found for MCP server: {name}");
8193                } else {
8194                    println!("Tools for {name}:");
8195                    for tool in conn.tools() {
8196                        println!(
8197                            "  - {}{}",
8198                            tool.name,
8199                            crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8200                        );
8201                    }
8202                }
8203            } else {
8204                let errors = pool.connect_all().await;
8205                for (name, err) in errors {
8206                    eprintln!("Failed to connect {name}: {err:#}");
8207                    if crate::mcp::oauth::error_looks_auth_required(&err) {
8208                        eprintln!("  {}", crate::mcp::oauth::auth_required_login_hint(&name));
8209                    }
8210                }
8211                let tools = pool.all_tools();
8212                if tools.is_empty() {
8213                    println!("No MCP tools discovered.");
8214                } else {
8215                    println!("MCP tools:");
8216                    for (name, tool) in tools {
8217                        println!(
8218                            "  - {}{}",
8219                            name,
8220                            crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8221                        );
8222                    }
8223                }
8224            }
8225            Ok(())
8226        }
8227        McpCommand::Add {
8228            name,
8229            command,
8230            url,
8231            transport,
8232            bearer_token_env_var,
8233            oauth_client_id,
8234            oauth_resource,
8235            scopes,
8236            args,
8237        } => {
8238            if command.is_none() && url.is_none() {
8239                bail!("Provide either --command or --url for `mcp add`.");
8240            }
8241            if let Some(transport) = transport.as_deref()
8242                && !transport.trim().eq_ignore_ascii_case("sse")
8243            {
8244                bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
8245            }
8246            let added_server = McpServerConfig {
8247                command,
8248                args,
8249                env: std::collections::HashMap::new(),
8250                cwd: None,
8251                url,
8252                transport,
8253                connect_timeout: None,
8254                execute_timeout: None,
8255                read_timeout: None,
8256                disabled: false,
8257                enabled: true,
8258                required: false,
8259                enabled_tools: Vec::new(),
8260                disabled_tools: Vec::new(),
8261                headers: std::collections::HashMap::new(),
8262                env_headers: std::collections::HashMap::new(),
8263                bearer_token_env_var,
8264                scopes,
8265                oauth: oauth_client_id.map(|client_id| McpServerOAuthConfig {
8266                    client_id: Some(client_id),
8267                }),
8268                oauth_resource,
8269                reviewed_plugin: None,
8270            };
8271            let can_suggest_oauth = added_server.url.is_some()
8272                && added_server.bearer_token_env_var.is_none()
8273                && added_server
8274                    .headers
8275                    .keys()
8276                    .all(|key| !key.trim().eq_ignore_ascii_case("authorization"))
8277                && added_server
8278                    .env_headers
8279                    .keys()
8280                    .all(|key| !key.trim().eq_ignore_ascii_case("authorization"));
8281            let mut cfg = load_mcp_config(&config_path)?;
8282            cfg.servers.insert(name.clone(), added_server.clone());
8283            save_mcp_config(&config_path, &cfg)?;
8284            println!("Added MCP server '{name}' in {}", config_path.display());
8285            if can_suggest_oauth
8286                && crate::mcp::oauth::oauth_login_support(&added_server)
8287                    .await
8288                    .is_ok_and(|support| support.is_some())
8289            {
8290                println!(
8291                    "OAuth is available for '{name}'. Run `codewhale mcp login {name}` to authenticate."
8292                );
8293            }
8294            Ok(())
8295        }
8296        McpCommand::Login { name, scopes } => {
8297            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8298                &config_path,
8299                workspace,
8300                plugins,
8301            )?;
8302            let server = cfg
8303                .servers
8304                .get(&name)
8305                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8306            let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
8307            crate::mcp::oauth::perform_oauth_login_for_server(
8308                &name,
8309                server,
8310                explicit_scopes,
8311                config.mcp_oauth_callback_port,
8312                config.mcp_oauth_callback_url.as_deref(),
8313            )
8314            .await?;
8315            println!("Stored OAuth credentials for MCP server '{name}'.");
8316            Ok(())
8317        }
8318        McpCommand::Logout { name } => {
8319            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8320                &config_path,
8321                workspace,
8322                plugins,
8323            )?;
8324            let server = cfg
8325                .servers
8326                .get(&name)
8327                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8328            if crate::mcp::oauth::delete_oauth_tokens_for_server(&name, server)? {
8329                println!("Deleted stored OAuth credentials for MCP server '{name}'.");
8330            } else {
8331                println!("No stored OAuth credentials found for MCP server '{name}'.");
8332            }
8333            Ok(())
8334        }
8335        McpCommand::Remove { name } => {
8336            let mut cfg = load_mcp_config(&config_path)?;
8337            if cfg.servers.remove(&name).is_none() {
8338                bail!("MCP server '{name}' not found");
8339            }
8340            save_mcp_config(&config_path, &cfg)?;
8341            println!("Removed MCP server '{name}'");
8342            Ok(())
8343        }
8344        McpCommand::Enable { name } => {
8345            let mut cfg = load_mcp_config(&config_path)?;
8346            let server = cfg
8347                .servers
8348                .get_mut(&name)
8349                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8350            server.enabled = true;
8351            server.disabled = false;
8352            save_mcp_config(&config_path, &cfg)?;
8353            println!("Enabled MCP server '{name}'");
8354            Ok(())
8355        }
8356        McpCommand::Disable { name } => {
8357            let mut cfg = load_mcp_config(&config_path)?;
8358            let server = cfg
8359                .servers
8360                .get_mut(&name)
8361                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8362            server.enabled = false;
8363            server.disabled = true;
8364            save_mcp_config(&config_path, &cfg)?;
8365            println!("Disabled MCP server '{name}'");
8366            Ok(())
8367        }
8368        McpCommand::Validate => {
8369            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8370                &config_path,
8371                workspace,
8372                std::sync::Arc::new(plugins.clone()),
8373            )?;
8374            let errors = pool.connect_all().await;
8375            if errors.is_empty() {
8376                println!("MCP config is valid. All enabled servers connected.");
8377                return Ok(());
8378            }
8379            eprintln!("MCP validation failed:");
8380            for (name, err) in errors {
8381                eprintln!("  - {name}: {err:#}");
8382            }
8383            bail!("one or more MCP servers failed validation");
8384        }
8385        McpCommand::AddSelf { name, workspace } => {
8386            let exe_path = std::env::current_exe()
8387                .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
8388            let exe_str = exe_path.to_string_lossy().to_string();
8389
8390            let mut args = vec!["serve".to_string(), "--mcp".to_string()];
8391            if let Some(ref ws) = workspace {
8392                args.push("--workspace".to_string());
8393                args.push(ws.clone());
8394            }
8395
8396            let mut cfg = load_mcp_config(&config_path)?;
8397            if cfg.servers.contains_key(&name) {
8398                bail!(
8399                    "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.",
8400                    config_path.display()
8401                );
8402            }
8403            cfg.servers.insert(
8404                name.clone(),
8405                McpServerConfig {
8406                    command: Some(exe_str.clone()),
8407                    args,
8408                    env: std::collections::HashMap::new(),
8409                    cwd: None,
8410                    url: None,
8411                    transport: None,
8412                    connect_timeout: None,
8413                    execute_timeout: None,
8414                    read_timeout: None,
8415                    disabled: false,
8416                    enabled: true,
8417                    required: false,
8418                    enabled_tools: Vec::new(),
8419                    disabled_tools: Vec::new(),
8420                    headers: std::collections::HashMap::new(),
8421                    env_headers: std::collections::HashMap::new(),
8422                    bearer_token_env_var: None,
8423                    scopes: Vec::new(),
8424                    oauth: None,
8425                    oauth_resource: None,
8426                    reviewed_plugin: None,
8427                },
8428            );
8429            save_mcp_config(&config_path, &cfg)?;
8430            println!(
8431                "Registered Codewhale as MCP server '{name}' in {}",
8432                config_path.display()
8433            );
8434            println!("  command: {exe_str}");
8435            println!(
8436                "  args:    serve --mcp{}",
8437                workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
8438            );
8439            println!();
8440            println!("Tip: Use `codewhale mcp validate` to test the connection.");
8441            println!("     Use `codewhale serve --http` for the HTTP/SSE runtime API instead.");
8442            Ok(())
8443        }
8444    }
8445}
8446
8447fn load_mcp_config(path: &Path) -> Result<McpConfig> {
8448    if !path.exists() {
8449        return Ok(McpConfig::default());
8450    }
8451    let contents = std::fs::read_to_string(path)
8452        .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
8453    let cfg: McpConfig = serde_json::from_str(&contents).map_err(|_| {
8454        anyhow::anyhow!(
8455            "Failed to parse MCP config {}; file contents were omitted",
8456            codewhale_config::quote_os_path(path)
8457        )
8458    })?;
8459    Ok(cfg)
8460}
8461
8462/// Diagnostic status for an MCP server entry.
8463#[derive(Debug)]
8464enum McpServerDoctorStatus {
8465    Ok(String),
8466    Warning(String),
8467    Error(String),
8468}
8469
8470impl McpServerDoctorStatus {
8471    fn legacy_status(&self) -> &'static str {
8472        match self {
8473            Self::Ok(_) => "ok",
8474            Self::Warning(_) => "warning",
8475            Self::Error(_) => "error",
8476        }
8477    }
8478
8479    fn configuration_status(&self) -> &'static str {
8480        match self {
8481            Self::Ok(_) => "valid",
8482            Self::Warning(_) => "warning",
8483            Self::Error(_) => "invalid",
8484        }
8485    }
8486
8487    fn detail(&self) -> &str {
8488        match self {
8489            Self::Ok(detail) | Self::Warning(detail) | Self::Error(detail) => detail,
8490        }
8491    }
8492}
8493
8494/// Inspect command availability without starting the configured MCP server.
8495fn doctor_mcp_command_status(server: &McpServerConfig) -> McpCommandAvailability {
8496    if server.url.is_some() {
8497        return McpCommandAvailability::NotApplicable;
8498    }
8499    match server.command.as_deref() {
8500        Some("") => McpCommandAvailability::Missing,
8501        Some(_) | None => McpCommandAvailability::NotChecked,
8502    }
8503}
8504
8505fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::Value {
8506    use serde_json::json;
8507
8508    let status = doctor_check_mcp_server(server);
8509    json!({
8510        "name": name,
8511        "enabled": server.enabled && !server.disabled,
8512        // Compatibility field retained for existing doctor JSON consumers.
8513        // Its scope is now explicit in `checks.configuration` below.
8514        "status": status.legacy_status(),
8515        "detail": status.detail(),
8516        "transport": if server.url.is_some() { "http" } else { "stdio" },
8517        "endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
8518        "command_configured": server.command.is_some(),
8519        "args_count": server.args.len(),
8520        "env_count": server.env.len(),
8521        "headers_count": server.headers.len(),
8522        "env_headers_count": server.env_headers.len(),
8523        "check_scope": "configuration",
8524        "checks": {
8525            "configuration": {
8526                "status": status.configuration_status(),
8527                "detail": status.detail(),
8528            },
8529            "command": {
8530                "status": doctor_mcp_command_status(server).as_str(),
8531            },
8532            "process_reachable": {
8533                "status": "not_checked",
8534            },
8535            "protocol_initialized": {
8536                "status": "not_checked",
8537            },
8538            "backend_tool_health": {
8539                "status": "not_checked",
8540            },
8541        },
8542    })
8543}
8544
8545/// Check an MCP server config entry for common issues.
8546fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
8547    // No command or URL — incomplete entry.
8548    if server.command.is_none() && server.url.is_none() {
8549        return McpServerDoctorStatus::Error("no command or url configured".to_string());
8550    }
8551
8552    // URL-based server: omit userinfo, query, and fragment entirely.
8553    if let Some(ref url) = server.url {
8554        let authority = crate::doctor::structural_url_authority(url);
8555        return if authority.starts_with("unparseable") {
8556            McpServerDoctorStatus::Warning(
8557                "HTTP/SSE server URL is invalid; configured value omitted".to_string(),
8558            )
8559        } else {
8560            McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {authority}"))
8561        };
8562    }
8563
8564    // Command-based: validate command path exists.
8565    let cmd = server.command.as_deref().unwrap_or("");
8566    if cmd.is_empty() {
8567        return McpServerDoctorStatus::Error("empty command".to_string());
8568    }
8569
8570    if server.cwd.is_none() {
8571        if is_relative_stdio_path_arg(cmd) {
8572            return McpServerDoctorStatus::Warning(
8573                "stdio server uses a relative command without cwd; command value omitted"
8574                    .to_string(),
8575            );
8576        }
8577        if server
8578            .args
8579            .iter()
8580            .any(|arg| is_relative_stdio_path_arg(arg))
8581        {
8582            return McpServerDoctorStatus::Warning(
8583                "stdio server uses a relative path argument without cwd; argument values omitted"
8584                    .to_string(),
8585            );
8586        }
8587    }
8588
8589    McpServerDoctorStatus::Ok(format!(
8590        "stdio server configured (command omitted; {} argument(s), {} environment binding(s))",
8591        server.args.len(),
8592        server.env.len()
8593    ))
8594}
8595
8596fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
8597    if let Some(parent) = path.parent() {
8598        std::fs::create_dir_all(parent).with_context(|| {
8599            format!("Failed to create MCP config directory {}", parent.display())
8600        })?;
8601    }
8602    let rendered = serde_json::to_string_pretty(cfg)
8603        .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
8604    crate::utils::write_atomic(path, rendered.as_bytes())
8605        .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
8606    Ok(())
8607}
8608
8609fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
8610    use crate::sandbox::{CommandSpec, SandboxManager};
8611
8612    let SandboxCommand::Run {
8613        policy,
8614        network,
8615        writable_root,
8616        exclude_tmpdir,
8617        exclude_slash_tmp,
8618        cwd,
8619        timeout_ms,
8620        command,
8621    } = args.command;
8622
8623    let policy = parse_sandbox_policy(
8624        &policy,
8625        network,
8626        writable_root,
8627        exclude_tmpdir,
8628        exclude_slash_tmp,
8629    )?;
8630    let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
8631    let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
8632
8633    let (program, args) = command
8634        .split_first()
8635        .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
8636    let spec =
8637        CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
8638    let manager = SandboxManager::new();
8639    let exec_env = manager.prepare(&spec);
8640
8641    let mut cmd = Command::new(exec_env.program());
8642    cmd.args(exec_env.args())
8643        .current_dir(&exec_env.cwd)
8644        .stdout(Stdio::piped())
8645        .stderr(Stdio::piped());
8646    child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
8647
8648    let mut child = cmd
8649        .spawn()
8650        .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
8651    let stdout_handle = child
8652        .stdout
8653        .take()
8654        .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
8655    let stderr_handle = child
8656        .stderr
8657        .take()
8658        .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
8659
8660    let timeout = exec_env.timeout;
8661    let stdout_thread = std::thread::spawn(move || {
8662        let mut reader = stdout_handle;
8663        let mut buf = Vec::new();
8664        let _ = reader.read_to_end(&mut buf);
8665        buf
8666    });
8667    let stderr_thread = std::thread::spawn(move || {
8668        let mut reader = stderr_handle;
8669        let mut buf = Vec::new();
8670        let _ = reader.read_to_end(&mut buf);
8671        buf
8672    });
8673
8674    if let Some(status) = child.wait_timeout(timeout)? {
8675        let stdout = stdout_thread.join().unwrap_or_default();
8676        let stderr = stderr_thread.join().unwrap_or_default();
8677        let stderr_str = String::from_utf8_lossy(&stderr);
8678        let exit_code = status.code().unwrap_or(-1);
8679        let sandbox_type = exec_env.sandbox_type;
8680        let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
8681
8682        if !stdout.is_empty() {
8683            print!("{}", String::from_utf8_lossy(&stdout));
8684        }
8685        if !stderr.is_empty() {
8686            eprint!("{stderr_str}");
8687        }
8688        if sandbox_denied {
8689            eprintln!(
8690                "{}",
8691                SandboxManager::denial_message(sandbox_type, &stderr_str)
8692            );
8693        }
8694
8695        if !status.success() {
8696            bail!("Command failed with exit code {exit_code}");
8697        }
8698    } else {
8699        let _ = child.kill();
8700        let _ = child.wait();
8701        bail!("Command timed out after {}ms", timeout.as_millis());
8702    }
8703    Ok(())
8704}
8705
8706fn parse_sandbox_policy(
8707    policy: &str,
8708    network: bool,
8709    writable_root: Vec<PathBuf>,
8710    exclude_tmpdir: bool,
8711    exclude_slash_tmp: bool,
8712) -> Result<crate::sandbox::SandboxPolicy> {
8713    use crate::sandbox::SandboxPolicy;
8714
8715    match policy {
8716        "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
8717        "read-only" => Ok(SandboxPolicy::ReadOnly),
8718        "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
8719            network_access: network,
8720        }),
8721        "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
8722            writable_roots: writable_root,
8723            network_access: network,
8724            exclude_tmpdir,
8725            exclude_slash_tmp,
8726        }),
8727        other => bail!("Unknown sandbox policy: {other}"),
8728    }
8729}
8730
8731fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
8732    true
8733}
8734
8735fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
8736    let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
8737    let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
8738    let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
8739    should_use_mouse_capture_with(
8740        cli,
8741        config,
8742        use_alt_screen,
8743        terminal_emulator.as_deref(),
8744        wt_session.as_deref(),
8745        conemu_pid.as_deref(),
8746    )
8747}
8748
8749fn should_use_mouse_capture_with(
8750    cli: &Cli,
8751    config: &Config,
8752    use_alt_screen: bool,
8753    terminal_emulator: Option<&str>,
8754    wt_session: Option<&str>,
8755    conemu_pid: Option<&str>,
8756) -> bool {
8757    if !use_alt_screen || cli.no_mouse_capture {
8758        return false;
8759    }
8760    if cli.mouse_capture {
8761        return true;
8762    }
8763    config
8764        .tui
8765        .as_ref()
8766        .and_then(|tui| tui.mouse_capture)
8767        .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
8768}
8769
8770/// Whether to enable terminal mouse capture by default for this platform/host.
8771///
8772/// On Windows the default depends on the host: Windows Terminal (which sets
8773/// `WT_SESSION`) and ConEmu/Cmder (which set `ConEmuPID`) handle mouse-mode
8774/// reporting cleanly, so default-on there gives users in-app text selection
8775/// and keeps the application's selection clamped to the transcript area
8776/// (#1169). Legacy conhost (CMD without either env var) stays default-off
8777/// because its mouse-mode reporting can leak SGR escape sequences as raw
8778/// text into the composer (#878 / #898).
8779///
8780/// Off elsewhere only for JetBrains' JediTerm, which advertises mouse
8781/// support but forwards the same SGR escape sequences as raw input. The
8782/// user can still opt back in with `[tui] mouse_capture = true` in
8783/// `~/.codewhale/config.toml` or `--mouse-capture`.
8784fn default_mouse_capture_enabled(
8785    terminal_emulator: Option<&str>,
8786    wt_session: Option<&str>,
8787    conemu_pid: Option<&str>,
8788) -> bool {
8789    if cfg!(windows) {
8790        return wt_session.is_some() || conemu_pid.is_some();
8791    }
8792    if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
8793        return false;
8794    }
8795    true
8796}
8797
8798/// A loadable crash-recovery checkpoint candidate: session content, file
8799/// age, and which slot it came from (per-session file or the legacy single
8800/// slot).
8801struct RecentCheckpoint {
8802    session: session_manager::SavedSession,
8803    age: std::time::Duration,
8804    source: session_manager::CheckpointSource,
8805}
8806
8807const CHECKPOINT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
8808
8809/// Load all recent crash-recovery checkpoints, pruning stale ones first.
8810///
8811/// Candidates are the per-session checkpoint files plus the legacy
8812/// single-slot `checkpoints/latest.json` (compatibility read). Files older
8813/// than 24 hours are removed; unreadable files are skipped. The result is
8814/// sorted most recent first.
8815fn load_recent_checkpoints(manager: &session_manager::SessionManager) -> Vec<RecentCheckpoint> {
8816    let refs = manager.list_checkpoints().unwrap_or_default();
8817    let mut recent = Vec::new();
8818    for checkpoint_ref in refs {
8819        let Ok(age) = std::time::SystemTime::now().duration_since(checkpoint_ref.modified) else {
8820            continue;
8821        };
8822        if age > CHECKPOINT_MAX_AGE {
8823            let _ = match &checkpoint_ref.source {
8824                session_manager::CheckpointSource::Session(id) => {
8825                    manager.clear_session_checkpoint(id)
8826                }
8827                session_manager::CheckpointSource::Legacy => manager.clear_legacy_checkpoint(),
8828            };
8829            continue;
8830        }
8831        let loaded = match &checkpoint_ref.source {
8832            session_manager::CheckpointSource::Session(id) => manager.load_session_checkpoint(id),
8833            session_manager::CheckpointSource::Legacy => manager.load_legacy_checkpoint(),
8834        };
8835        let Ok(Some(session)) = loaded else {
8836            continue;
8837        };
8838        recent.push(RecentCheckpoint {
8839            session,
8840            age,
8841            source: checkpoint_ref.source,
8842        });
8843    }
8844    // `list_checkpoints` sorts newest-first already; keep it explicit here so
8845    // selection does not silently depend on the manager's ordering.
8846    recent.sort_by_key(|c| c.age);
8847    recent
8848}
8849
8850fn checkpoint_age_label(age: std::time::Duration) -> String {
8851    if age.as_secs() < 60 {
8852        format!("{}s ago", age.as_secs())
8853    } else if age.as_secs() < 3600 {
8854        format!("{}m ago", age.as_secs() / 60)
8855    } else {
8856        format!("{}h ago", age.as_secs() / 3600)
8857    }
8858}
8859
8860/// Check for a crash-recovery checkpoint and return the session ID if explicit
8861/// recovery was requested *and* the checkpoint belongs to the current
8862/// workspace.
8863///
8864/// Candidates are all per-session checkpoint files plus the legacy
8865/// single-slot `checkpoints/latest.json`; each must be younger than 24 hours
8866/// **and its workspace must match the resolved launch workspace after
8867/// canonicalisation** — the newest matching candidate wins. If no candidate
8868/// matches, a one-line notice points at `codewhale sessions`, and nothing is
8869/// auto-loaded: another workspace's checkpoint file is never touched (it may
8870/// belong to a live session there).
8871fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
8872    let manager = session_manager::SessionManager::default_location().ok()?;
8873    let candidates = load_recent_checkpoints(&manager);
8874    if candidates.is_empty() {
8875        return None;
8876    }
8877
8878    // Refuse to silently restore a session from another workspace. Compare
8879    // against the resolved launch workspace, not the shell cwd, so callers
8880    // using `--workspace` cannot accidentally recover a checkpoint from the
8881    // directory their shell happened to be in.
8882    let (matching, mismatched): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|candidate| {
8883        session_manager::workspace_scope_matches(
8884            &candidate.session.metadata.workspace,
8885            launch_workspace,
8886        )
8887    });
8888
8889    let Some(best) = matching.into_iter().next() else {
8890        if let Some(newest) = mismatched.first() {
8891            eprintln!(
8892                "Note: an interrupted session from another workspace ({}) is \
8893                 available. Run `codewhale sessions` to list saved sessions. Starting \
8894                 fresh in {}.",
8895                newest.session.metadata.workspace.display(),
8896                launch_workspace.display(),
8897            );
8898        }
8899        return None;
8900    };
8901
8902    let session_id = best.session.metadata.id.clone();
8903
8904    // Persist the checkpoint as a regular session so the TUI can load it by
8905    // id — unless a newer regular session file for the same id already
8906    // exists (e.g. `--continue` ran before and the session advanced since).
8907    // A stale checkpoint must never overwrite newer durable session state.
8908    if !saved_session_is_newer(&manager, &best.session)
8909        && manager.save_session(&best.session).is_err()
8910    {
8911        return None;
8912    }
8913
8914    match &best.source {
8915        session_manager::CheckpointSource::Session(id) => {
8916            // Consume the per-session checkpoint now that it is recovered.
8917            let _ = manager.clear_session_checkpoint(id);
8918        }
8919        session_manager::CheckpointSource::Legacy => {
8920            // Migrate the legacy slot to a per-session file (never
8921            // overwriting an existing one) and leave `latest.json` in place
8922            // so an older binary can still find it; its writer is already
8923            // gone and the file ages out within 24 hours.
8924            let _ = manager.write_session_checkpoint_if_absent(&best.session);
8925        }
8926    }
8927
8928    let age_str = checkpoint_age_label(best.age);
8929    eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
8930
8931    Some(session_id)
8932}
8933
8934/// Whether a regular session file for the checkpoint's id already exists and
8935/// is at least as recent as the checkpoint. When it is, persisting the
8936/// checkpoint over it would replace newer durable state with older in-flight
8937/// state.
8938fn saved_session_is_newer(
8939    manager: &session_manager::SessionManager,
8940    checkpoint: &session_manager::SavedSession,
8941) -> bool {
8942    manager
8943        .load_session(&checkpoint.metadata.id)
8944        .is_ok_and(|existing| existing.metadata.updated_at >= checkpoint.metadata.updated_at)
8945}
8946
8947/// Preserve an interrupted checkpoint on a normal fresh launch without
8948/// attaching it to the new TUI instance. This keeps "open another codewhale in
8949/// the same folder" from re-entering the previous in-flight session while still
8950/// leaving an explicit resume path.
8951///
8952/// Only the newest recent checkpoint drives the notice. The legacy
8953/// single-slot file is persisted as a regular session and consumed (today's
8954/// behavior for that slot); per-session checkpoint files are persisted but
8955/// left in place — they may belong to a live session in another terminal,
8956/// and `--continue` reads them directly.
8957fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
8958    let Some(manager) = session_manager::SessionManager::default_location().ok() else {
8959        return;
8960    };
8961    let Some(newest) = load_recent_checkpoints(&manager).into_iter().next() else {
8962        return;
8963    };
8964
8965    let session_workspace = newest.session.metadata.workspace.clone();
8966    // #4479: removed save_session call — checkpoint should not be auto-promoted to session
8967    if newest.source == session_manager::CheckpointSource::Legacy {
8968        // Migrate legacy single-slot checkpoint to per-session format
8969        // before clearing the legacy file, or the data is unrecoverable.
8970        let _ = manager.save_checkpoint(&newest.session);
8971        let _ = manager.clear_legacy_checkpoint();
8972    }
8973
8974    let age_str = checkpoint_age_label(newest.age);
8975    if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
8976        eprintln!(
8977            "Found an in-flight session snapshot ({age_str}). Starting a new \
8978             session. Run `codewhale --continue` to resume it."
8979        );
8980    } else {
8981        eprintln!(
8982            "Note: an interrupted session from another workspace ({}) is \
8983             available. Run `codewhale sessions` to list saved sessions. Starting \
8984             fresh in {}.",
8985            session_workspace.display(),
8986            launch_workspace.display(),
8987        );
8988    }
8989}
8990
8991/// Load project-level config from `$WORKSPACE/.codewhale/config.toml`, with
8992/// legacy `$WORKSPACE/.deepseek/config.toml` fallback, then apply its fields as
8993/// overrides on top of the global config (#485).
8994/// Only explicitly set fields in the project file are applied; everything
8995/// else falls back to the global value.
8996#[cfg(test)]
8997fn merge_project_config(config: &mut Config, workspace: &Path) {
8998    merge_project_config_with_approval_baseline(config, workspace, None);
8999}
9000
9001/// Apply project config while evaluating approval tightening against the
9002/// user's effective interactive baseline. `Config::approval_policy` remains
9003/// authoritative when present; the saved TUI posture is used only when the
9004/// root config leaves approval unset.
9005fn merge_project_config_with_approval_baseline(
9006    config: &mut Config,
9007    workspace: &Path,
9008    saved_permission_posture: Option<&str>,
9009) {
9010    // When the workspace is the user's home directory, the project-scope
9011    // config file is also the global config file. Skip the merge to avoid
9012    // redundant processing and a misleading "project-scope config key
9013    // ignored" warning on every launch from ~.
9014    if let Some(home) = effective_home_dir()
9015        && let (Ok(w), Ok(h)) = (
9016            std::fs::canonicalize(workspace),
9017            std::fs::canonicalize(&home),
9018        )
9019        && w == h
9020    {
9021        return;
9022    }
9023
9024    // v0.8.44: prefer .codewhale/config.toml, fall back to .deepseek/
9025    let path = workspace
9026        .join(codewhale_config::CODEWHALE_APP_DIR)
9027        .join("config.toml");
9028    let raw = match read_project_config_file(&path) {
9029        Ok(Some(r)) => r,
9030        Ok(None) => {
9031            let legacy = workspace
9032                .join(codewhale_config::LEGACY_APP_DIR)
9033                .join("config.toml");
9034            match read_project_config_file(&legacy) {
9035                Ok(Some(r)) => r,
9036                Ok(None) => return,
9037                Err(err) => {
9038                    eprintln!(
9039                        "warning: failed to read project-scope config {}: {err}",
9040                        legacy.display()
9041                    );
9042                    return;
9043                }
9044            }
9045        }
9046        Err(err) => {
9047            eprintln!(
9048                "warning: failed to read project-scope config {}: {err}",
9049                path.display()
9050            );
9051            return;
9052        }
9053    };
9054    let project: toml::Value = match toml::from_str(&raw) {
9055        Ok(v) => v,
9056        Err(_) => return,
9057    };
9058    let table = match project.as_table() {
9059        Some(t) => t,
9060        None => return,
9061    };
9062
9063    // #417: dangerous keys are denied at project scope. A malicious
9064    // `<workspace>/.deepseek/config.toml` could otherwise:
9065    // * `api_key` / `base_url` / `provider` — exfiltrate prompts to a
9066    //   look-alike endpoint by swapping the user's credentials and
9067    //   target host with project-controlled values.
9068    // * `mcp_config_path` — point the loader at an MCP config that
9069    //   spawns arbitrary stdio servers under the user's identity.
9070    // * `mcp_oauth_callback_*` — choose local OAuth redirect listener
9071    //   behavior for user-owned MCP credentials.
9072    //
9073    // The overlay path is non-interactive; users can't visually
9074    // confirm a rogue project config is hijacking these. We surface
9075    // a stderr warning on first encounter so a user who *did* expect
9076    // the override has a chance to notice the deny instead of silent
9077    // discard.
9078    const DENY_AT_PROJECT_SCOPE: &[&str] = &[
9079        "api_key",
9080        "base_url",
9081        "provider",
9082        "mcp_config_path",
9083        "mcp_oauth_callback_port",
9084        "mcp_oauth_callback_url",
9085    ];
9086    for key in DENY_AT_PROJECT_SCOPE {
9087        if table.contains_key(*key) {
9088            eprintln!(
9089                "warning: project-scope config key `{key}` is ignored — \
9090                 set it in `~/.codewhale/config.toml` instead. \
9091                 (See #417 for the deny-list rationale.)"
9092            );
9093        }
9094    }
9095
9096    // String fields a project may legitimately override (model,
9097    // approval/sandbox tightening, notes path, reasoning effort).
9098    for (key, field) in [
9099        ("model", &mut config.default_text_model),
9100        ("reasoning_effort", &mut config.reasoning_effort),
9101        ("notes_path", &mut config.notes_path),
9102    ] {
9103        if let Some(v) = table.get(key).and_then(toml::Value::as_str)
9104            && !v.is_empty()
9105        {
9106            *field = Some(v.to_string());
9107        }
9108    }
9109
9110    if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
9111        && !v.is_empty()
9112    {
9113        let saved_approval_baseline =
9114            crate::config::approval_policy_baseline_from_permission_posture(
9115                saved_permission_posture,
9116            );
9117        let approval_baseline = config
9118            .approval_policy
9119            .as_deref()
9120            .or(saved_approval_baseline);
9121        if codewhale_config::project_approval_policy_is_allowed(approval_baseline, v) {
9122            config.approval_policy = Some(v.to_string());
9123        } else {
9124            eprintln!(
9125                "warning: project-scope `approval_policy = \"{v}\"` is ignored — \
9126                 project config can only tighten the user's approval policy. \
9127                 (See #417.)"
9128            );
9129        }
9130    }
9131
9132    if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
9133        && !v.is_empty()
9134    {
9135        if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
9136            config.sandbox_mode = Some(v.to_string());
9137        } else {
9138            eprintln!(
9139                "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
9140                 project config can only tighten the user's sandbox mode. \
9141                 (See #417.)"
9142            );
9143        }
9144    }
9145
9146    // Numeric / bool fields that benefit from per-project overrides.
9147    if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
9148        && v > 0
9149    {
9150        config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
9151    }
9152    if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
9153        if v {
9154            eprintln!(
9155                "warning: project-scope `allow_shell = true` is ignored — \
9156                 enable shell from user config for this workspace instead. \
9157                 (See #417.)"
9158            );
9159        } else {
9160            config.allow_shell = Some(false);
9161        }
9162    }
9163
9164    if table.contains_key("instructions") {
9165        eprintln!(
9166            "warning: project-scope `instructions` is ignored — \
9167             configure instruction files from user config instead. \
9168             (See #417.)"
9169        );
9170    }
9171}
9172
9173fn read_project_config_file(path: &Path) -> io::Result<Option<String>> {
9174    let metadata = match std::fs::symlink_metadata(path) {
9175        Ok(metadata) => metadata,
9176        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
9177        Err(err) => return Err(err),
9178    };
9179    let file_type = metadata.file_type();
9180    if file_type.is_symlink() {
9181        return Err(io::Error::new(
9182            io::ErrorKind::InvalidInput,
9183            "project-scope config must not be a symlink",
9184        ));
9185    }
9186    if !file_type.is_file() {
9187        return Ok(None);
9188    }
9189
9190    let mut file = open_project_config_file(path)?;
9191    let mut raw = String::new();
9192    file.read_to_string(&mut raw)?;
9193    Ok(Some(raw))
9194}
9195
9196#[cfg(unix)]
9197fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9198    use std::os::unix::fs::OpenOptionsExt;
9199
9200    std::fs::OpenOptions::new()
9201        .read(true)
9202        .custom_flags(libc::O_NOFOLLOW)
9203        .open(path)
9204}
9205
9206#[cfg(not(unix))]
9207fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9208    std::fs::File::open(path)
9209}
9210
9211fn merge_user_workspace_config(
9212    config: &mut Config,
9213    config_path: Option<PathBuf>,
9214    workspace: &Path,
9215) {
9216    if config.managed_config_path.is_some() || config.requirements_path.is_some() {
9217        return;
9218    }
9219    let allow_shell_before = config.allow_shell;
9220    let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
9221        || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
9222    let path = match crate::config::resolve_load_config_path(config_path) {
9223        Ok(Some(path)) => path,
9224        Ok(None) => return,
9225        Err(error) => {
9226            tracing::error!(
9227                error = %error,
9228                "failed to resolve workspace config overlay; refusing to substitute another file"
9229            );
9230            return;
9231        }
9232    };
9233    let raw = match std::fs::read_to_string(&path) {
9234        Ok(raw) => raw,
9235        Err(error) => {
9236            eprintln!(
9237                "warning: could not read user config at {}: {error}. \
9238                 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9239                 revert to defaults for this session. Fix or remove the file to \
9240                 restore them.",
9241                path.display()
9242            );
9243            return;
9244        }
9245    };
9246    let doc = match toml::from_str::<toml::Value>(&raw) {
9247        Ok(doc) => doc,
9248        Err(error) => {
9249            eprintln!(
9250                "warning: could not parse user config at {}: {error}. \
9251                 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9252                 revert to defaults for this session. Fix the TOML syntax to \
9253                 restore them.",
9254                path.display()
9255            );
9256            return;
9257        }
9258    };
9259    merge_user_workspace_config_from_doc(config, &doc, workspace);
9260    if allow_shell_from_env {
9261        config.allow_shell = allow_shell_before;
9262    }
9263}
9264
9265fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
9266    for table_name in ["workspace", "projects"] {
9267        let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
9268            continue;
9269        };
9270        for (raw_path, entry) in entries {
9271            if !workspace_config_path_matches(raw_path, workspace) {
9272                continue;
9273            }
9274            if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
9275                config.allow_shell = Some(allow_shell);
9276            }
9277        }
9278    }
9279}
9280
9281fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
9282    let configured = crate::config::expand_path(raw_path);
9283    let configured = configured.canonicalize().unwrap_or(configured);
9284    let workspace = workspace
9285        .canonicalize()
9286        .unwrap_or_else(|_| workspace.to_path_buf());
9287    paths_equal_for_config(&configured, &workspace)
9288}
9289
9290#[cfg(windows)]
9291fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9292    normalize_windows_config_path_for_compare(left)
9293        == normalize_windows_config_path_for_compare(right)
9294}
9295
9296#[cfg(not(windows))]
9297fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9298    left == right
9299}
9300
9301#[cfg(windows)]
9302fn normalize_windows_config_path_for_compare(path: &Path) -> String {
9303    normalize_windows_config_path_str(&path.to_string_lossy())
9304}
9305
9306#[cfg(any(windows, test))]
9307fn normalize_windows_config_path_str(path: &str) -> String {
9308    let mut normalized = path.replace('/', "\\");
9309    if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") {
9310        normalized = format!("\\\\{rest}");
9311    } else if let Some(rest) = normalized.strip_prefix(r"\\?\") {
9312        normalized = rest.to_string();
9313    }
9314    while normalized.len() > 3 && normalized.ends_with('\\') {
9315        normalized.pop();
9316    }
9317    normalized.to_ascii_lowercase()
9318}
9319
9320fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool {
9321    yolo || config.interactive_allow_shell()
9322}
9323
9324async fn run_interactive(
9325    cli: &Cli,
9326    config: &Config,
9327    resume_session_id: Option<String>,
9328    initial_input: Option<tui::InitialInput>,
9329    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9330) -> Result<()> {
9331    run_interactive_with_notice(
9332        cli,
9333        config,
9334        resume_session_id,
9335        initial_input,
9336        None,
9337        plugin_registry,
9338    )
9339    .await
9340}
9341
9342/// As [`run_interactive`], but carrying a one-line startup receipt to show in
9343/// the transcript — used by auto-resume to explain why it did or did not
9344/// reattach to a previous session (#2934).
9345async fn run_interactive_with_notice(
9346    cli: &Cli,
9347    config: &Config,
9348    resume_session_id: Option<String>,
9349    initial_input: Option<tui::InitialInput>,
9350    startup_notice: Option<String>,
9351    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9352) -> Result<()> {
9353    let initial_input = if cli.remote_control {
9354        Some(tui::InitialInput::RemoteControl)
9355    } else {
9356        initial_input
9357    };
9358    let workspace = cli
9359        .workspace
9360        .clone()
9361        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9362
9363    // Merge project-level config from $WORKSPACE/.codewhale/config.toml
9364    // or legacy $WORKSPACE/.deepseek/config.toml
9365    // unless --no-project-config was passed (#485).
9366    let mut merged_config = config.clone();
9367    merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
9368    if !cli.no_project_config {
9369        let saved_permission_posture = crate::settings::Settings::load_persisted()
9370            .ok()
9371            .and_then(|settings| settings.permission_posture);
9372        merge_project_config_with_approval_baseline(
9373            &mut merged_config,
9374            &workspace,
9375            saved_permission_posture.as_deref(),
9376        );
9377    }
9378    let config = &merged_config;
9379
9380    if !cli.skip_onboarding {
9381        match crate::config::ensure_config_file_exists(cli.config.clone()) {
9382            Ok(Some(path)) => logging::info(format!(
9383                "Created first-run config file at {}",
9384                path.display()
9385            )),
9386            Ok(None) => {}
9387            Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
9388        }
9389    }
9390
9391    // v0.8.44: migrate config from ~/.deepseek/ to ~/.codewhale/ on first
9392    // launch. Non-fatal — existing installs keep working either way.
9393    match codewhale_config::migrate_config_if_needed() {
9394        Ok(Some(migration)) => {
9395            eprintln!("{}", migration.user_notice());
9396        }
9397        Ok(None) => {}
9398        Err(err) => logging::warn(format!("Config migration skipped: {err}")),
9399    }
9400
9401    let model = config.default_model();
9402    let provider = config.api_provider();
9403    let max_subagents = cli.max_subagents.map_or_else(
9404        || config.max_subagents_for_provider(provider),
9405        |value| value.clamp(1, MAX_SUBAGENTS),
9406    );
9407    let use_alt_screen = should_use_alt_screen(cli, config);
9408    let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
9409    let use_bracketed_paste = crate::settings::Settings::load()
9410        .map(|s| s.effective_bracketed_paste())
9411        .unwrap_or_else(|_| !crate::settings::detected_legacy_windows_console_host());
9412
9413    // Auto-install bundled system skills (e.g. skill-creator) on first launch.
9414    // Errors are non-fatal: log a warning and continue.
9415    let skills_dir = config.skills_dir();
9416    if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
9417        logging::warn(format!("Failed to install system skills: {e}"));
9418    }
9419
9420    startup_trace::mark("interactive_config");
9421
9422    // Seed ProviderLake from the secret-free Models.dev disk cache before any
9423    // picker/inventory read, then kick a best-effort background refresh (#4187).
9424    // Failures are quiet: bundled catalog rows always remain available.
9425    crate::models_dev_live::maybe_load_persisted_cache();
9426    crate::models_dev_live::spawn_background_refresh();
9427    // Best-effort per-provider catalog refresh: fetches the active provider's
9428    // own /v1/models endpoint and merges live rows into the provider lake
9429    // alongside the Models.dev snapshot. Currently active for TelecomJS, whose
9430    // model list is not covered by the Models.dev catalog.
9431    crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
9432
9433    // Boot janitors — snapshot prune (7-day default), spillover prune
9434    // (#422), and managed-session cleanup (v0.8.44) — are best-effort disk
9435    // hygiene. On a large ~/.codewhale they were the dominant startup cost
9436    // (a git object walk plus thousands of stat/read calls), so they run on
9437    // a blocking worker while the TUI brings up its first frame (#3757).
9438    // All three were already documented as non-fatal.
9439    let snapshots = config.snapshots_config();
9440    let janitor_snapshots_enabled = snapshots.enabled;
9441    let janitor_max_age = snapshots.max_age();
9442    let janitor_workspace = workspace.clone();
9443    // Session cleanup races session restore: skip it entirely when a session
9444    // is being resumed/continued this launch (the just-resumed session could
9445    // be pruned before its first save bumps `updated_at`). It runs next
9446    // clean launch. When we do run it, exclude the explicit resume id too.
9447    let janitor_resume_id = resume_session_id.clone();
9448    let janitor_skip_session_cleanup = resume_session_id.is_some() || cli.continue_session;
9449    tokio::task::spawn_blocking(move || {
9450        if janitor_snapshots_enabled {
9451            session_manager::prune_workspace_snapshots(&janitor_workspace, janitor_max_age);
9452        }
9453
9454        match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
9455            Ok(0) => {}
9456            Ok(n) => tracing::debug!(
9457                target: "spillover",
9458                "boot prune removed {n} spillover file(s)"
9459            ),
9460            Err(err) => tracing::warn!(
9461                target: "spillover",
9462                ?err,
9463                "spillover prune skipped on boot"
9464            ),
9465        }
9466
9467        if !janitor_skip_session_cleanup
9468            && let Ok(manager) = session_manager::SessionManager::default_location()
9469        {
9470            let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
9471        }
9472    });
9473
9474    // The `deepseek` launcher forwards `--yolo` to this binary via the
9475    // DEEPSEEK_YOLO env var (config.yolo), not as a CLI flag. Honour either.
9476    let yolo = cli.yolo || config.yolo.unwrap_or(false);
9477
9478    tui::run_tui(
9479        config,
9480        tui::TuiOptions {
9481            model,
9482            workspace,
9483            config_path: cli.config.clone(),
9484            config_profile: effective_config_profile(cli),
9485            allow_shell: interactive_tui_allow_shell(yolo, config),
9486            use_alt_screen,
9487            use_mouse_capture,
9488            use_bracketed_paste,
9489            skills_dir,
9490            memory_path: config.memory_path(),
9491            notes_path: config.notes_path(),
9492            mcp_config_path: config.mcp_config_path(),
9493            use_memory: config.memory_enabled(),
9494            start_in_agent_mode: yolo,
9495            skip_onboarding: cli.skip_onboarding,
9496            yolo, // YOLO mode auto-approves all tool executions
9497            resume_session_id,
9498            initial_input,
9499            startup_notice,
9500            max_subagents,
9501        },
9502        plugin_registry,
9503    )
9504    .await
9505}
9506
9507#[derive(Debug)]
9508struct CliAutoRoute {
9509    provider: crate::config::ApiProvider,
9510    model: String,
9511    reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
9512    /// Whether the runtime should continue resolving reasoning per prompt.
9513    ///
9514    /// This is independent from `auto_model`: an Auto model can carry a fixed
9515    /// saved effort, while a fixed Fleet model can still request Auto effort.
9516    auto_controls_reasoning: bool,
9517    auto_model: bool,
9518}
9519
9520fn cli_reasoning_effort_value(
9521    config: &Config,
9522    model: &str,
9523    effort: crate::tui::app::ReasoningEffort,
9524) -> Option<String> {
9525    effort
9526        .api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
9527        .map(str::to_string)
9528}
9529
9530fn cli_reasoning_effort_value_for_prompt(
9531    config: &Config,
9532    model: &str,
9533    effort: crate::tui::app::ReasoningEffort,
9534    prompt: &str,
9535) -> Option<String> {
9536    let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
9537        crate::auto_reasoning::select(false, prompt)
9538    } else {
9539        effort
9540    };
9541    cli_reasoning_effort_value(config, model, resolved)
9542}
9543
9544fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
9545    let trimmed = value.trim();
9546    if trimmed.is_empty() {
9547        return Ok(None);
9548    }
9549    if matches!(
9550        trimmed.to_ascii_lowercase().as_str(),
9551        "inherit" | "parent" | "same" | "current" | "default" | "unset"
9552    ) {
9553        return Ok(None);
9554    }
9555    crate::tui::app::ReasoningEffort::parse_strict(trimmed)
9556        .map(|effort| Some(effort.as_setting().to_string()))
9557        .map_err(anyhow::Error::msg)
9558}
9559
9560fn config_for_cli_route(config: &Config, route: &CliAutoRoute) -> Config {
9561    let mut execution_config = config.clone();
9562    execution_config.provider = Some(config.provider_identity_for(route.provider));
9563    execution_config.set_provider_model_override(route.provider, Some(route.model.clone()));
9564    if matches!(
9565        route.provider,
9566        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
9567    ) {
9568        execution_config.default_text_model = Some(route.model.clone());
9569    }
9570    execution_config
9571}
9572
9573async fn resolve_cli_auto_route(
9574    config: &Config,
9575    model: &str,
9576    prompt: &str,
9577) -> Result<CliAutoRoute> {
9578    if model.trim().eq_ignore_ascii_case("auto") {
9579        let selection =
9580            model_routing::resolve_auto_route_with_inventory(config, prompt, "", "auto", "auto")
9581                .await?;
9582        let preference = config
9583            .reasoning_effort()
9584            .filter(|_| config.reasoning_effort_is_explicit())
9585            .map(crate::tui::app::ReasoningEffort::from_setting);
9586        let (reasoning_effort, auto_controls_reasoning) =
9587            model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
9588        Ok(CliAutoRoute {
9589            provider: selection.provider,
9590            model: selection.model,
9591            reasoning_effort,
9592            auto_controls_reasoning,
9593            auto_model: true,
9594        })
9595    } else {
9596        if let Some(selection) = model_routing::resolve_explicit_route_with_inventory(config, model)
9597        {
9598            let auto_controls_reasoning = matches!(
9599                selection.reasoning_effort,
9600                Some(crate::tui::app::ReasoningEffort::Auto)
9601            );
9602            return Ok(CliAutoRoute {
9603                provider: selection.provider,
9604                model: selection.model,
9605                reasoning_effort: selection.reasoning_effort,
9606                auto_controls_reasoning,
9607                auto_model: false,
9608            });
9609        }
9610
9611        let candidate_providers = model_routing::explicit_route_candidate_providers(config, model);
9612        if !candidate_providers.is_empty() && !candidate_providers.contains(&config.api_provider())
9613        {
9614            let providers = candidate_providers
9615                .iter()
9616                .map(|provider| provider.as_str())
9617                .collect::<Vec<_>>()
9618                .join(", ");
9619            bail!(
9620                "model `{model}` is available from configured provider route(s): {providers}. \
9621                 Pass `--provider <provider>` with `--model {model}` to choose one explicitly. \
9622                 In the TUI, use `/provider`, `/model`, or `/setup` to resolve the route before sending."
9623            );
9624        }
9625
9626        // When --model is not `auto`, fall back to the reasoning_effort
9627        // declared in the user's config.toml. The previous hard-coded `None`
9628        // silently dropped the user's setting on every non-auto-route exec
9629        // call, which (for example) prevented vllm + Qwen3 users from
9630        // disabling thinking via `reasoning_effort = "off"` and caused
9631        // 30+ second SSE idle timeouts on trivial prompts.
9632        let reasoning_effort = config
9633            .reasoning_effort()
9634            .map(crate::tui::app::ReasoningEffort::from_setting);
9635        Ok(CliAutoRoute {
9636            provider: config.api_provider(),
9637            model: model.to_string(),
9638            auto_controls_reasoning: matches!(
9639                reasoning_effort,
9640                Some(crate::tui::app::ReasoningEffort::Auto)
9641            ),
9642            reasoning_effort,
9643            auto_model: false,
9644        })
9645    }
9646}
9647
9648async fn resolve_cli_exec_route(
9649    config: &Config,
9650    model: &str,
9651    prompt: &str,
9652    force_configured_route: bool,
9653) -> Result<CliAutoRoute> {
9654    if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
9655        let reasoning_effort = config
9656            .reasoning_effort()
9657            .map(crate::tui::app::ReasoningEffort::from_setting);
9658        return Ok(CliAutoRoute {
9659            provider: config.api_provider(),
9660            model: model.to_string(),
9661            auto_controls_reasoning: matches!(
9662                reasoning_effort,
9663                Some(crate::tui::app::ReasoningEffort::Auto)
9664            ),
9665            reasoning_effort,
9666            auto_model: false,
9667        });
9668    }
9669    resolve_cli_auto_route(config, model, prompt).await
9670}
9671
9672fn should_force_configured_exec_route(
9673    resuming: bool,
9674    explicit_provider: Option<&str>,
9675    explicit_model: Option<&str>,
9676) -> bool {
9677    // A configured/default model belongs to the configured provider route.
9678    // Cross-provider inventory inference is reserved for an explicit model
9679    // override without an explicit provider. Resume remains route-authoritative
9680    // even when its model is overridden because it restores the saved provider.
9681    resuming || explicit_provider.is_some() || explicit_model.is_none()
9682}
9683
9684async fn run_one_shot(
9685    config: &Config,
9686    model: &str,
9687    prompt: &str,
9688    force_configured_route: bool,
9689) -> Result<()> {
9690    use crate::client::DeepSeekClient;
9691    use crate::models::{ContentBlock, Message, MessageRequest};
9692
9693    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
9694    let execution_config = config_for_cli_route(config, &route);
9695    let client = DeepSeekClient::new(&execution_config)?;
9696    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
9697        cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt)
9698    });
9699
9700    let request = MessageRequest {
9701        model: route.model,
9702        messages: vec![Message {
9703            role: "user".to_string(),
9704            content: vec![ContentBlock::Text {
9705                text: prompt.to_string(),
9706                cache_control: None,
9707            }],
9708        }],
9709        max_tokens: 4096,
9710        system: None,
9711        tools: None,
9712        tool_choice: None,
9713        metadata: None,
9714        thinking: None,
9715        reasoning_effort,
9716        stream: Some(false),
9717        temperature: None,
9718        top_p: None,
9719    };
9720
9721    let response = client.create_message(request).await?;
9722
9723    for block in response.content {
9724        if let ContentBlock::Text { text, .. } = block {
9725            println!("{text}");
9726        }
9727    }
9728
9729    Ok(())
9730}
9731
9732async fn run_one_shot_json(
9733    config: &Config,
9734    model: &str,
9735    prompt: &str,
9736    force_configured_route: bool,
9737) -> Result<()> {
9738    use crate::client::DeepSeekClient;
9739    use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
9740
9741    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
9742    let execution_config = config_for_cli_route(config, &route);
9743    let provider = execution_config.provider_identity_for(route.provider);
9744    let client = DeepSeekClient::new(&execution_config)?;
9745    let model = route.model.clone();
9746    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
9747        cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt)
9748    });
9749    let request = MessageRequest {
9750        model: model.clone(),
9751        messages: vec![Message {
9752            role: "user".to_string(),
9753            content: vec![ContentBlock::Text {
9754                text: prompt.to_string(),
9755                cache_control: None,
9756            }],
9757        }],
9758        max_tokens: 4096,
9759        system: Some(SystemPrompt::Text(
9760            "You are a coding assistant. Give concise, actionable responses.".to_string(),
9761        )),
9762        tools: None,
9763        tool_choice: None,
9764        metadata: None,
9765        thinking: None,
9766        reasoning_effort,
9767        stream: Some(false),
9768        temperature: Some(0.2),
9769        top_p: Some(0.9),
9770    };
9771
9772    let response = client.create_message(request).await?;
9773    let mut output = String::new();
9774    for block in response.content {
9775        if let ContentBlock::Text { text, .. } = block {
9776            output.push_str(&text);
9777        }
9778    }
9779    println!(
9780        "{}",
9781        serde_json::to_string_pretty(&one_shot_exec_json_receipt(provider, model, output,))?
9782    );
9783    Ok(())
9784}
9785
9786fn one_shot_exec_json_receipt(
9787    provider: String,
9788    model: String,
9789    output: String,
9790) -> serde_json::Value {
9791    serde_json::json!({
9792        "mode": "one-shot",
9793        "provider": provider,
9794        "model": model,
9795        "success": true,
9796        "output": output
9797    })
9798}
9799
9800fn exec_stream_provider_route(
9801    identity: &crate::config::ProviderIdentity,
9802) -> (String, Option<String>) {
9803    let provider = identity.provider.as_str().to_string();
9804    let provider_id = if identity.provider == crate::config::ApiProvider::Custom {
9805        identity.exact_id.clone()
9806    } else {
9807        None
9808    };
9809    (provider, provider_id)
9810}
9811
9812#[derive(serde::Serialize)]
9813struct ExecStreamMeta {
9814    receipt_kind: &'static str,
9815    provider: String,
9816    /// Exact configured provider-table id, when one selected the route.
9817    /// `None` deliberately distinguishes the legacy idless root custom route
9818    /// from literal `[providers.custom]`, whose exact id is `"custom"`.
9819    #[serde(skip_serializing_if = "Option::is_none")]
9820    provider_id: Option<String>,
9821    model: String,
9822    route_source: String,
9823    #[serde(skip_serializing_if = "Option::is_none")]
9824    input_tokens: Option<u32>,
9825    #[serde(skip_serializing_if = "Option::is_none")]
9826    output_tokens: Option<u32>,
9827    #[serde(skip_serializing_if = "Option::is_none")]
9828    prompt_cache_hit_tokens: Option<u32>,
9829    #[serde(skip_serializing_if = "Option::is_none")]
9830    prompt_cache_miss_tokens: Option<u32>,
9831    #[serde(skip_serializing_if = "Option::is_none")]
9832    prompt_cache_write_tokens: Option<u32>,
9833    #[serde(skip_serializing_if = "Option::is_none")]
9834    reasoning_tokens: Option<u32>,
9835    duration_ms: u64,
9836    #[serde(skip_serializing_if = "Option::is_none")]
9837    retry_count: Option<u32>,
9838    approval_posture: String,
9839    sandbox_posture: String,
9840    #[serde(skip_serializing_if = "Option::is_none")]
9841    binary_sha256: Option<String>,
9842    #[serde(skip_serializing_if = "Option::is_none")]
9843    config_sha256: Option<String>,
9844    prompt_sha256: String,
9845    #[serde(skip_serializing_if = "Option::is_none")]
9846    tool_catalog_sha256: Option<String>,
9847    input_analysis: ExecStreamInputAnalysis,
9848    visible_final_answer_chars: usize,
9849    session_id: String,
9850    resume_command: String,
9851    workspace: String,
9852    message_count: usize,
9853    #[serde(skip_serializing_if = "Option::is_none")]
9854    status: Option<String>,
9855    #[serde(skip_serializing_if = "Option::is_none")]
9856    termination_reason: Option<String>,
9857    #[serde(skip_serializing_if = "Option::is_none")]
9858    error_category: Option<String>,
9859}
9860
9861#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)]
9862struct ExecStreamInputAnalysis {
9863    estimated_request_tokens: usize,
9864    estimated_message_content_tokens: usize,
9865    estimated_system_tokens: usize,
9866    estimated_framing_tokens: usize,
9867    user_message_count: usize,
9868    assistant_message_count: usize,
9869    tool_message_count: usize,
9870    tool_use_count: usize,
9871    tool_result_count: usize,
9872    text_chars: usize,
9873    thinking_chars: usize,
9874    tool_use_input_chars: usize,
9875    tool_result_chars: usize,
9876    text_estimated_tokens: usize,
9877    thinking_estimated_tokens: usize,
9878    tool_use_input_estimated_tokens: usize,
9879    tool_result_estimated_tokens: usize,
9880}
9881
9882#[derive(serde::Serialize)]
9883#[serde(tag = "type")]
9884// Keep receipts flat for stable JSONL consumers. Boxing the whole tool_result
9885// payload would introduce a nested object and break the stream schema.
9886#[allow(clippy::large_enum_variant)]
9887enum ExecStreamEvent {
9888    #[serde(rename = "content")]
9889    Content { content: String },
9890    #[serde(rename = "tool_use")]
9891    ToolUse {
9892        name: String,
9893        id: String,
9894        input: serde_json::Value,
9895        started_at: String,
9896    },
9897    #[serde(rename = "tool_result")]
9898    ToolResult {
9899        id: String,
9900        name: String,
9901        output: String,
9902        status: String,
9903        started_at: String,
9904        completed_at: String,
9905        duration_ms: u64,
9906        side_effect_status: String,
9907        #[serde(skip_serializing_if = "Option::is_none")]
9908        error_category: Option<String>,
9909        #[serde(skip_serializing_if = "Option::is_none")]
9910        truncated: Option<bool>,
9911        #[serde(skip_serializing_if = "Option::is_none")]
9912        artifact: Option<serde_json::Value>,
9913        #[serde(skip_serializing_if = "Option::is_none")]
9914        result_metadata: Option<serde_json::Value>,
9915    },
9916    /// A sub-agent was launched, and the model it was launched on.
9917    ///
9918    /// Without this, a delegated child is invisible to anything reading the
9919    /// stream: a parent turn on one route could spawn children billed on
9920    /// another and the only place it surfaced was the invoice. That is not
9921    /// hypothetical — the `Fast` loadout re-priced scout children onto a
9922    /// cheaper sibling until it was fixed, and nothing in the output said so.
9923    #[serde(rename = "agent_spawned")]
9924    AgentSpawned {
9925        id: String,
9926        model: String,
9927        spawn_depth: u32,
9928        #[serde(skip_serializing_if = "Option::is_none")]
9929        parent_run_id: Option<String>,
9930        /// Why the child got this route, when the spawn path resolved one.
9931        #[serde(skip_serializing_if = "Option::is_none")]
9932        route_source: Option<String>,
9933    },
9934    #[serde(rename = "sandbox_denied")]
9935    SandboxDenied {
9936        tool_id: String,
9937        tool_name: String,
9938        reason: String,
9939        outcome: String,
9940    },
9941    #[serde(rename = "workflow_event")]
9942    WorkflowEvent {
9943        run_id: String,
9944        event: serde_json::Value,
9945    },
9946    #[serde(rename = "session_capture")]
9947    SessionCapture { content: String },
9948    /// Per-model-call usage receipt. Field names mirror the terminal
9949    /// `metadata` receipt (`prompt_cache_hit_tokens` is the provider's
9950    /// cache-read count, `prompt_cache_write_tokens` the cache-creation
9951    /// count). Optional fields are omitted — never emitted as null or zero —
9952    /// when the provider does not report them; the whole event is skipped
9953    /// for model calls whose provider reported no usage at all.
9954    #[serde(rename = "turn_usage")]
9955    TurnUsage {
9956        /// 1-based index of the model call within this exec run.
9957        turn: u32,
9958        input_tokens: u32,
9959        output_tokens: u32,
9960        #[serde(skip_serializing_if = "Option::is_none")]
9961        reasoning_tokens: Option<u32>,
9962        #[serde(skip_serializing_if = "Option::is_none")]
9963        prompt_cache_hit_tokens: Option<u32>,
9964        #[serde(skip_serializing_if = "Option::is_none")]
9965        prompt_cache_miss_tokens: Option<u32>,
9966        #[serde(skip_serializing_if = "Option::is_none")]
9967        prompt_cache_write_tokens: Option<u32>,
9968        #[serde(skip_serializing_if = "Option::is_none")]
9969        reasoning_replay_tokens: Option<u32>,
9970        duration_ms: u64,
9971    },
9972    #[serde(rename = "metadata")]
9973    Metadata { meta: Box<ExecStreamMeta> },
9974    #[serde(rename = "done")]
9975    Done,
9976    #[serde(rename = "error")]
9977    Error { error: String },
9978}
9979
9980fn exec_sandbox_elevation_authorized(
9981    allow_sandbox_elevation: bool,
9982    explicit_sandbox: Option<&str>,
9983) -> bool {
9984    allow_sandbox_elevation
9985        || explicit_sandbox.is_some_and(|policy| policy.eq_ignore_ascii_case("danger-full-access"))
9986}
9987
9988fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> {
9989    println!("{}", serde_json::to_string(&exec_stream_value(event)?)?);
9990    Ok(())
9991}
9992
9993/// Process exit code `codewhale exec` uses when a turn ends on a retryable
9994/// infrastructure failure (provider/transport) rather than a genuine task
9995/// failure. 75 is `EX_TEMPFAIL` from sysexits.h — "temporary failure; the
9996/// invocation is expected to succeed on retry" — so bench harnesses and
9997/// supervisors can distinguish retryable infra exits from genuine task
9998/// failures (exit 1) without parsing the stream-json metadata.
9999const EXEC_EXIT_RETRYABLE_INFRA: i32 = 75; // EX_TEMPFAIL
10000
10001/// Map a terminal exec error category to the process exit code.
10002///
10003/// `network` / `timeout` mean the provider connection dropped or stalled
10004/// after every in-session retry budget was exhausted: the task itself
10005/// neither passed nor failed, and re-running the same command is safe.
10006/// `rate_limit` is deliberately NOT mapped to the retryable code — the same
10007/// category also covers quota exhaustion, which a blind retry would hammer.
10008fn exec_failure_exit_code(error_category: Option<&str>) -> i32 {
10009    match error_category {
10010        Some("network" | "timeout") => EXEC_EXIT_RETRYABLE_INFRA,
10011        _ => 1,
10012    }
10013}
10014
10015/// Should a mid-turn engine error event force the final exec summary into
10016/// failure? Only non-recoverable envelopes do. Recoverable warnings (stream
10017/// stall notices, transient retry noise) are emitted on the stream for
10018/// visibility, but the terminal `TurnComplete` event carries the
10019/// authoritative turn outcome — a warning must never fail a run whose turn
10020/// later completes.
10021fn exec_error_event_is_fatal(envelope: &crate::error_taxonomy::ErrorEnvelope) -> bool {
10022    !envelope.recoverable
10023}
10024
10025fn exec_stream_value(event: &ExecStreamEvent) -> Result<serde_json::Value> {
10026    let mut value = serde_json::to_value(event)?;
10027    if let Some(object) = value.as_object_mut() {
10028        object.insert("schema_version".to_string(), serde_json::json!(1));
10029        object.insert(
10030            "schema".to_string(),
10031            serde_json::json!("codewhale.exec-stream"),
10032        );
10033    }
10034    Ok(value)
10035}
10036
10037fn tool_error_receipt_category(error: &crate::tools::spec::ToolError) -> &'static str {
10038    use crate::tools::spec::ToolError;
10039    match error {
10040        ToolError::InvalidInput { .. } => "invalid_input",
10041        ToolError::MissingField { .. } => "missing_field",
10042        ToolError::PathEscape { .. } => "path_escape",
10043        ToolError::ExecutionFailed { .. } => "execution_failed",
10044        ToolError::Timeout { .. } => "timeout",
10045        ToolError::Cancelled { .. } => "cancelled",
10046        ToolError::NotAvailable { .. } => "not_available",
10047        ToolError::PermissionDenied { .. } => "permission_denied",
10048    }
10049}
10050
10051fn tool_artifact_receipt(metadata: Option<&serde_json::Value>) -> Option<serde_json::Value> {
10052    let object = metadata?.as_object()?;
10053    let mut artifact = serde_json::Map::new();
10054    for key in [
10055        "artifact_id",
10056        "artifact_path",
10057        "artifact_relative_path",
10058        "artifact_byte_size",
10059        "spillover_path",
10060        "content_digest",
10061        "original_byte_count",
10062        "retained_head_bytes",
10063        "retained_tail_bytes",
10064    ] {
10065        if let Some(value) = object.get(key) {
10066            artifact.insert(key.to_string(), value.clone());
10067        }
10068    }
10069    (!artifact.is_empty()).then_some(serde_json::Value::Object(artifact))
10070}
10071
10072fn current_binary_sha256() -> Option<String> {
10073    let bytes = std::fs::read(std::env::current_exe().ok()?).ok()?;
10074    Some(format!("sha256:{}", crate::hashing::sha256_hex(&bytes)))
10075}
10076
10077async fn run_workflow_tool_command(
10078    cli: &Cli,
10079    args: WorkflowToolArgs,
10080    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10081) -> Result<()> {
10082    match run_workflow_tool_command_inner(cli, args, plugin_registry).await {
10083        Ok(()) => Ok(()),
10084        Err(error) => {
10085            let _ = emit_exec_stream_event(&ExecStreamEvent::Error {
10086                error: format!("{error:#}"),
10087            });
10088            exit_workflow_tool_failure();
10089        }
10090    }
10091}
10092
10093async fn run_workflow_tool_command_inner(
10094    cli: &Cli,
10095    args: WorkflowToolArgs,
10096    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10097) -> Result<()> {
10098    use crate::tools::spec::ToolSpec;
10099
10100    if args.approval_source != "explicit-workflow-command" {
10101        bail!("workflow-tool requires --approval-source explicit-workflow-command");
10102    }
10103    let input: serde_json::Value = serde_json::from_str(&args.input_json)
10104        .context("--input-json must be a valid Workflow tool input object")?;
10105    if !input.is_object() {
10106        bail!("--input-json must be a JSON object");
10107    }
10108    if !input
10109        .get("action")
10110        .and_then(serde_json::Value::as_str)
10111        .is_some_and(|action| action.eq_ignore_ascii_case("run"))
10112    {
10113        bail!("workflow-tool accepts only action=run");
10114    }
10115
10116    let workspace = resolve_workspace(cli);
10117    let mut config = load_config_from_cli(cli)?;
10118    merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
10119    if let Ok(env_url) =
10120        std::env::var("CODEWHALE_BASE_URL").or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
10121    {
10122        let trimmed = env_url.trim();
10123        if !trimmed.is_empty() {
10124            config.base_url = Some(trimmed.to_string());
10125        }
10126    }
10127
10128    let model = resolve_exec_model(&config, None);
10129    let route = resolve_cli_exec_route(
10130        &config,
10131        &model,
10132        "Run a checked-in Workflow through the host runtime",
10133        true,
10134    )
10135    .await?;
10136    let execution_config = config_for_cli_route(&config, &route);
10137    let route_identity = execution_config
10138        .active_provider_identity(route.provider)
10139        .map_err(anyhow::Error::msg)
10140        .context("workflow terminal route lost its exact provider identity")?;
10141    let (route_provider, route_provider_id) = exec_stream_provider_route(&route_identity);
10142    let workflow_input_sha256 = format!(
10143        "sha256:{}",
10144        crate::hashing::sha256_hex(&serde_json::to_vec(&input)?)
10145    );
10146    let tool_id = format!("workflow_host_{}", &uuid::Uuid::new_v4().to_string()[..8]);
10147    let tool_started = Instant::now();
10148    let tool_started_at = chrono::Utc::now().to_rfc3339();
10149
10150    emit_exec_stream_event(&ExecStreamEvent::ToolUse {
10151        name: "workflow".to_string(),
10152        id: tool_id.clone(),
10153        input: input.clone(),
10154        started_at: tool_started_at.clone(),
10155    })?;
10156
10157    let (event_tx, event_rx) = tokio::sync::mpsc::channel(1024);
10158    let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
10159    let event_forwarder = tokio::spawn(forward_direct_workflow_events(event_rx, stop_rx));
10160    let (tool, context) = match build_direct_workflow_tool(
10161        &execution_config,
10162        &route,
10163        &workspace,
10164        event_tx,
10165        plugin_registry,
10166    )
10167    .await
10168    {
10169        Ok(built) => built,
10170        Err(err) => {
10171            let _ = stop_tx.send(());
10172            let _ = event_forwarder.await;
10173            exit_workflow_tool_error(&tool_id, err.to_string());
10174        }
10175    };
10176
10177    let result = tool.execute(input, &context).await;
10178    drop(tool);
10179    let _ = stop_tx.send(());
10180    event_forwarder
10181        .await
10182        .context("workflow event forwarder task failed")??;
10183
10184    let result = match result {
10185        Ok(result) => result,
10186        Err(err) => {
10187            let error = err.to_string();
10188            exit_workflow_tool_error(&tool_id, error);
10189        }
10190    };
10191
10192    let workflow_status =
10193        direct_workflow_status(&result.content).unwrap_or_else(|| "unknown".to_string());
10194    let completed = result.success && workflow_status == "completed";
10195    emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10196        id: tool_id,
10197        name: "workflow".to_string(),
10198        output: result.content.clone(),
10199        status: if completed { "success" } else { "error" }.to_string(),
10200        started_at: tool_started_at,
10201        completed_at: chrono::Utc::now().to_rfc3339(),
10202        duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10203        side_effect_status: result
10204            .metadata
10205            .as_ref()
10206            .and_then(|metadata| metadata.get("side_effect_status"))
10207            .and_then(serde_json::Value::as_str)
10208            .unwrap_or("unknown")
10209            .to_string(),
10210        error_category: (!completed).then(|| "tool_error".to_string()),
10211        truncated: result
10212            .metadata
10213            .as_ref()
10214            .and_then(|metadata| metadata.get("truncated"))
10215            .and_then(serde_json::Value::as_bool),
10216        artifact: tool_artifact_receipt(result.metadata.as_ref()),
10217        result_metadata: result.metadata.clone(),
10218    })?;
10219    emit_exec_stream_event(&ExecStreamEvent::Metadata {
10220        meta: Box::new(ExecStreamMeta {
10221            receipt_kind: "terminal",
10222            provider: route_provider,
10223            provider_id: route_provider_id,
10224            // No parent/operator model call occurs on this host-owned path;
10225            // child model/provider usage remains attributable in typed task
10226            // receipts rather than being misreported as one root model.
10227            model: "host-workflow".to_string(),
10228            route_source: "host_workflow".to_string(),
10229            input_tokens: None,
10230            output_tokens: None,
10231            prompt_cache_hit_tokens: None,
10232            prompt_cache_miss_tokens: None,
10233            prompt_cache_write_tokens: None,
10234            reasoning_tokens: None,
10235            duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10236            retry_count: None,
10237            approval_posture: "explicit_workflow_command".to_string(),
10238            sandbox_posture: "configured".to_string(),
10239            binary_sha256: current_binary_sha256(),
10240            config_sha256: None,
10241            prompt_sha256: workflow_input_sha256,
10242            tool_catalog_sha256: None,
10243            input_analysis: ExecStreamInputAnalysis::default(),
10244            visible_final_answer_chars: result.content.chars().count(),
10245            session_id: String::new(),
10246            resume_command: String::new(),
10247            workspace: workspace.display().to_string(),
10248            message_count: 0,
10249            status: Some(workflow_status.clone()),
10250            termination_reason: Some(if completed { "resolved" } else { "tool_error" }.to_string()),
10251            error_category: (!completed).then(|| "tool".to_string()),
10252        }),
10253    })?;
10254    if !completed {
10255        let error = format!("workflow run ended with terminal status {workflow_status}");
10256        emit_exec_stream_event(&ExecStreamEvent::Error {
10257            error: error.clone(),
10258        })?;
10259        exit_workflow_tool_failure();
10260    }
10261    emit_exec_stream_event(&ExecStreamEvent::Done)?;
10262    Ok(())
10263}
10264
10265fn exit_workflow_tool_failure() -> ! {
10266    let _ = io::stdout().flush();
10267    std::process::exit(1)
10268}
10269
10270fn exit_workflow_tool_error(tool_id: &str, error: String) -> ! {
10271    let now = chrono::Utc::now().to_rfc3339();
10272    let _ = emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10273        id: tool_id.to_string(),
10274        name: "workflow".to_string(),
10275        output: error.clone(),
10276        status: "error".to_string(),
10277        started_at: now.clone(),
10278        completed_at: now,
10279        duration_ms: 0,
10280        side_effect_status: "unknown".to_string(),
10281        error_category: Some("execution_failed".to_string()),
10282        truncated: None,
10283        artifact: None,
10284        result_metadata: None,
10285    });
10286    let _ = emit_exec_stream_event(&ExecStreamEvent::Error { error });
10287    exit_workflow_tool_failure()
10288}
10289
10290async fn initialize_direct_workflow_mcp_pool(
10291    config: &Config,
10292    workspace: &Path,
10293    network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
10294    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10295) -> Option<(
10296    std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
10297    Vec<(String, String)>,
10298)> {
10299    if !config.features().enabled(Feature::Mcp) {
10300        return None;
10301    }
10302    let mut pool = crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
10303        &config.mcp_config_path(),
10304        workspace,
10305        plugin_registry,
10306    )
10307    .unwrap_or_else(|error| {
10308        tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
10309        crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
10310    });
10311    if let Some(policy) = network_policy {
10312        pool = pool.with_network_policy(policy);
10313    }
10314    let failures = pool
10315        .connect_all()
10316        .await
10317        .into_iter()
10318        .map(|(server, error)| (server, format!("{error:#}")))
10319        .collect();
10320    Some((std::sync::Arc::new(tokio::sync::Mutex::new(pool)), failures))
10321}
10322
10323async fn build_direct_workflow_tool(
10324    config: &Config,
10325    route: &CliAutoRoute,
10326    workspace: &Path,
10327    event_tx: tokio::sync::mpsc::Sender<crate::core::events::Event>,
10328    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10329) -> Result<(
10330    crate::tools::workflow::WorkflowTool,
10331    crate::tools::ToolContext,
10332)> {
10333    use std::sync::Arc;
10334
10335    use crate::client::DeepSeekClient;
10336    use crate::core::authority::shell_policy_for_mode;
10337    use crate::fleet::roster::FleetRoster;
10338    use crate::tools::AgentToolSurfaceOptions;
10339    use crate::tools::goal::new_shared_goal_state;
10340    use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager_with_timeout};
10341    use crate::tools::todo::new_shared_todo_list;
10342    use crate::tui::app::AppMode;
10343
10344    let provider = config.api_provider();
10345    if !config.subagents_enabled_for_provider(provider) {
10346        bail!(
10347            "Workflow dispatch requires sub-agents for provider {} ({})",
10348            provider.as_str(),
10349            config
10350                .subagents_disabled_reason()
10351                .unwrap_or("provider-specific sub-agent configuration disabled it")
10352        );
10353    }
10354
10355    let yolo = config.yolo.unwrap_or(false);
10356    let mode = if yolo {
10357        AppMode::Yolo
10358    } else {
10359        AppMode::Operate
10360    };
10361    let allow_shell = yolo || config.allow_shell();
10362    let shell_policy = shell_policy_for_mode(mode, allow_shell);
10363    let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
10364    let mut context = crate::tools::ToolContext::with_auto_approve(
10365        workspace.to_path_buf(),
10366        yolo,
10367        config.notes_path(),
10368        config.mcp_config_path(),
10369        yolo,
10370    )
10371    .with_features(config.features())
10372    .with_skills_config(
10373        config.skills_dir(),
10374        config.skills_config().scan_codewhale_only(),
10375    )
10376    .with_plugin_registry(std::sync::Arc::clone(&plugin_registry))
10377    .with_shell_policy(shell_policy)
10378    .with_trusted_external_paths(trusted.paths().to_vec())
10379    .with_elevated_sandbox_policy(crate::core::authority::sandbox_policy_for_turn(
10380        mode,
10381        if yolo {
10382            crate::tui::approval::ApprovalMode::Bypass
10383        } else {
10384            crate::tui::approval::ApprovalMode::Suggest
10385        },
10386        config.sandbox_mode.as_deref(),
10387        workspace,
10388    ));
10389    let network_policy = config.network.clone().map(|network| {
10390        crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
10391    });
10392    if let Some(policy) = network_policy.as_ref() {
10393        context = context.with_network_policy(policy.clone());
10394    }
10395    if config.memory_enabled() {
10396        context.memory_path = Some(config.memory_path());
10397    }
10398    context.search_provider = config.search_provider();
10399    context.search_api_key = config
10400        .search
10401        .as_ref()
10402        .and_then(|search| search.api_key.clone());
10403    context.search_base_url = config
10404        .search
10405        .as_ref()
10406        .and_then(|search| search.base_url.clone());
10407    if let Some(backend) = crate::sandbox::backend::create_backend(config)? {
10408        context = context.with_sandbox_backend(Arc::from(backend));
10409    }
10410
10411    let max_subagents = config.max_subagents_for_provider(provider);
10412    let manager = new_shared_subagent_manager_with_timeout(
10413        workspace.to_path_buf(),
10414        max_subagents,
10415        config
10416            .max_admitted_subagents_for_provider(provider)
10417            .max(max_subagents),
10418        Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
10419        config.launch_concurrency_for_provider(provider),
10420        config.subagent_token_budget_for_provider(provider),
10421    );
10422    let roster = Arc::new(FleetRoster::load(&config.fleet_config(), workspace));
10423    let mut role_models = roster.model_overrides();
10424    role_models.extend(config.subagent_model_overrides());
10425
10426    let features = config.features();
10427    let mut surface = AgentToolSurfaceOptions::new(shell_policy);
10428    surface.apply_patch_enabled = features.enabled(Feature::ApplyPatch);
10429    surface.web_search_enabled = features.enabled(Feature::WebSearch);
10430    surface.memory_tool_enabled = config.memory_enabled();
10431    surface.vision_config = features
10432        .enabled(Feature::VisionModel)
10433        .then(|| config.vision_model_config())
10434        .flatten();
10435    surface.speech_output_dir = config.speech_output_dir();
10436    surface.goal_state = Some(new_shared_goal_state());
10437
10438    let client = DeepSeekClient::new(config)?;
10439    // A FIXED model with `reasoning_effort = auto` (the shape a Fleet worker
10440    // subprocess launches with: `--model <exact> --reasoning-effort auto`) is
10441    // still Auto. Deriving the auto flag from `route.auto_model` alone left it
10442    // raw AND non-auto: the runtime carried the literal string `"auto"` while
10443    // nothing was allowed to resolve it. Auto is a reasoning decision, not a
10444    // model decision — it does not require `--model auto`.
10445    let reasoning_effort_auto = route.auto_controls_reasoning;
10446    let reasoning_effort = route
10447        .reasoning_effort
10448        .and_then(|effort| cli_reasoning_effort_value(config, &route.model, effort));
10449    let mcp_pool = if let Some((pool, failures)) =
10450        initialize_direct_workflow_mcp_pool(config, workspace, network_policy, plugin_registry)
10451            .await
10452    {
10453        for (server, error) in failures {
10454            tracing::warn!(
10455                server = %server,
10456                error = %error,
10457                "direct Workflow runtime could not connect MCP server"
10458            );
10459        }
10460        Some(pool)
10461    } else {
10462        None
10463    };
10464    let runtime = SubAgentRuntime::new(
10465        client,
10466        route.model.clone(),
10467        context.clone(),
10468        allow_shell,
10469        Some(event_tx),
10470        manager.clone(),
10471    )
10472    .with_locale_tag(
10473        crate::localization::resolve_locale(
10474            &crate::settings::Settings::load_persisted()
10475                .unwrap_or_default()
10476                .locale,
10477        )
10478        .tag(),
10479    )
10480    .with_role_models(role_models)
10481    .with_api_config(config.clone())
10482    .with_fleet_roster(roster)
10483    .with_auto_model(route.auto_model)
10484    .with_reasoning_effort(reasoning_effort, reasoning_effort_auto)
10485    .with_agent_tool_surface_options(surface)
10486    .with_max_spawn_depth(config.subagent_max_spawn_depth_for_provider(provider))
10487    .with_step_api_timeout(Duration::from_secs(
10488        config.subagent_api_timeout_secs_for_provider(provider),
10489    ))
10490    .with_speech_output_dir(config.speech_output_dir())
10491    .with_mcp_pool(mcp_pool)
10492    .with_todos(new_shared_todo_list())
10493    .with_parent_mode(mode);
10494
10495    Ok((
10496        crate::tools::workflow::WorkflowTool::new(manager, runtime).with_explicit_cli_approval(),
10497        context,
10498    ))
10499}
10500
10501async fn forward_direct_workflow_events(
10502    mut event_rx: tokio::sync::mpsc::Receiver<crate::core::events::Event>,
10503    mut stop_rx: tokio::sync::oneshot::Receiver<()>,
10504) -> Result<()> {
10505    loop {
10506        tokio::select! {
10507            biased;
10508            event = event_rx.recv() => match event {
10509                Some(event) => emit_direct_workflow_event(event)?,
10510                None => return Ok(()),
10511            },
10512            _ = &mut stop_rx => {
10513                while let Ok(event) = event_rx.try_recv() {
10514                    emit_direct_workflow_event(event)?;
10515                }
10516                return Ok(());
10517            }
10518        }
10519    }
10520}
10521
10522fn emit_direct_workflow_event(event: crate::core::events::Event) -> Result<()> {
10523    if let crate::core::events::Event::WorkflowUi { run_id, event } = event {
10524        emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
10525    }
10526    Ok(())
10527}
10528
10529fn direct_workflow_status(content: &str) -> Option<String> {
10530    serde_json::from_str::<serde_json::Value>(content)
10531        .ok()?
10532        .get("status")?
10533        .as_str()
10534        .map(str::to_ascii_lowercase)
10535}
10536
10537fn exec_stream_input_analysis(
10538    messages: &[Message],
10539    system: Option<&SystemPrompt>,
10540) -> ExecStreamInputAnalysis {
10541    let mut analysis = ExecStreamInputAnalysis {
10542        estimated_request_tokens: crate::compaction::estimate_input_tokens_conservative(
10543            messages, system,
10544        ),
10545        estimated_message_content_tokens: crate::compaction::estimate_tokens(messages),
10546        estimated_system_tokens: exec_stream_estimate_system_tokens(system),
10547        estimated_framing_tokens: messages.len().saturating_mul(12).saturating_add(48),
10548        ..ExecStreamInputAnalysis::default()
10549    };
10550
10551    for message in messages {
10552        match message.role.as_str() {
10553            "user" => analysis.user_message_count += 1,
10554            "assistant" => analysis.assistant_message_count += 1,
10555            "tool" => analysis.tool_message_count += 1,
10556            _ => {}
10557        }
10558
10559        for block in &message.content {
10560            match block {
10561                ContentBlock::Text { text, .. } => {
10562                    exec_stream_add_text_estimate(
10563                        text,
10564                        &mut analysis.text_chars,
10565                        &mut analysis.text_estimated_tokens,
10566                    );
10567                }
10568                ContentBlock::Thinking { thinking, .. } => {
10569                    exec_stream_add_text_estimate(
10570                        thinking,
10571                        &mut analysis.thinking_chars,
10572                        &mut analysis.thinking_estimated_tokens,
10573                    );
10574                }
10575                ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
10576                    analysis.tool_use_count += 1;
10577                    exec_stream_add_json_estimate(
10578                        input,
10579                        &mut analysis.tool_use_input_chars,
10580                        &mut analysis.tool_use_input_estimated_tokens,
10581                    );
10582                }
10583                ContentBlock::ToolResult {
10584                    content,
10585                    content_blocks,
10586                    ..
10587                } => {
10588                    analysis.tool_result_count += 1;
10589                    exec_stream_add_text_estimate(
10590                        content,
10591                        &mut analysis.tool_result_chars,
10592                        &mut analysis.tool_result_estimated_tokens,
10593                    );
10594                    if let Some(blocks) = content_blocks {
10595                        exec_stream_add_json_estimate(
10596                            blocks,
10597                            &mut analysis.tool_result_chars,
10598                            &mut analysis.tool_result_estimated_tokens,
10599                        );
10600                    }
10601                }
10602                ContentBlock::ToolSearchToolResult { content, .. }
10603                | ContentBlock::CodeExecutionToolResult { content, .. } => {
10604                    analysis.tool_result_count += 1;
10605                    exec_stream_add_json_estimate(
10606                        content,
10607                        &mut analysis.tool_result_chars,
10608                        &mut analysis.tool_result_estimated_tokens,
10609                    );
10610                }
10611                ContentBlock::ImageUrl { .. } => {}
10612            }
10613        }
10614    }
10615
10616    analysis
10617}
10618
10619fn exec_stream_add_text_estimate(text: &str, chars: &mut usize, tokens: &mut usize) {
10620    *chars = chars.saturating_add(text.chars().count());
10621    *tokens = tokens.saturating_add(crate::compaction::estimate_text_tokens_conservative(text));
10622}
10623
10624fn exec_stream_add_json_estimate<T: serde::Serialize>(
10625    value: &T,
10626    chars: &mut usize,
10627    tokens: &mut usize,
10628) {
10629    let text = serde_json::to_string(value).unwrap_or_default();
10630    exec_stream_add_text_estimate(&text, chars, tokens);
10631}
10632
10633fn exec_stream_estimate_system_tokens(system: Option<&SystemPrompt>) -> usize {
10634    match system {
10635        Some(SystemPrompt::Text(text)) => {
10636            crate::compaction::estimate_text_tokens_conservative(text)
10637        }
10638        Some(SystemPrompt::Blocks(blocks)) => blocks
10639            .iter()
10640            .map(|block| crate::compaction::estimate_text_tokens_conservative(&block.text))
10641            .sum(),
10642        None => 0,
10643    }
10644}
10645
10646fn exec_saved_session_line(session_id: &str) -> String {
10647    format!("session: {}", truncate_id(session_id))
10648}
10649
10650fn exec_resumed_session_line(session_id: &str) -> String {
10651    format!("resumed session: {}", truncate_id(session_id))
10652}
10653
10654fn exec_stream_session_ref(session_id: &str) -> String {
10655    crate::utils::redacted_identifier_for_log(session_id)
10656}
10657
10658fn exec_stream_resume_hint(session_id: &str) -> String {
10659    if session_id.trim().is_empty() {
10660        String::new()
10661    } else {
10662        "codewhale exec --resume <redacted-session-id>".to_string()
10663    }
10664}
10665
10666#[derive(Clone, Copy)]
10667struct PersistedProviderRoute<'a> {
10668    kind: &'a str,
10669    id: Option<&'a str>,
10670}
10671
10672fn persist_exec_session(
10673    messages: &[Message],
10674    model: &str,
10675    provider_route: PersistedProviderRoute<'_>,
10676    workspace: &Path,
10677    system_prompt: &Option<SystemPrompt>,
10678    session_id: Option<&str>,
10679    total_tokens: u64,
10680) -> Result<String> {
10681    let manager =
10682        SessionManager::default_location().context("could not open session manager for save")?;
10683    let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) {
10684        match manager.load_session(id) {
10685            Ok(existing) => session_manager::update_session(
10686                existing,
10687                messages,
10688                total_tokens,
10689                system_prompt.as_ref(),
10690            ),
10691            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
10692                session_manager::create_saved_session_with_id_and_mode(
10693                    id.to_string(),
10694                    messages,
10695                    model,
10696                    workspace,
10697                    total_tokens,
10698                    system_prompt.as_ref(),
10699                    Some("exec"),
10700                )
10701            }
10702            Err(err) => return Err(err).context("could not load existing exec session"),
10703        }
10704    } else {
10705        session_manager::create_saved_session_with_mode(
10706            messages,
10707            model,
10708            workspace,
10709            total_tokens,
10710            system_prompt.as_ref(),
10711            Some("exec"),
10712        )
10713    };
10714    stamp_exec_session_metadata(
10715        &mut saved,
10716        model,
10717        provider_route.kind,
10718        provider_route.id,
10719        workspace,
10720    );
10721    let id = saved.metadata.id.clone();
10722    manager
10723        .save_session(&saved)
10724        .context("could not save exec session")?;
10725    Ok(id)
10726}
10727
10728fn stamp_exec_session_metadata(
10729    saved: &mut session_manager::SavedSession,
10730    model: &str,
10731    model_provider_kind: &str,
10732    model_provider_id: Option<&str>,
10733    workspace: &Path,
10734) {
10735    saved.metadata.model = model.to_string();
10736    saved
10737        .metadata
10738        .set_model_provider_route(model_provider_kind, model_provider_id);
10739    saved.metadata.workspace = workspace.to_path_buf();
10740    saved.metadata.mode = Some("exec".to_string());
10741}
10742
10743#[derive(serde::Serialize)]
10744struct ExecToolEntry {
10745    name: String,
10746    success: bool,
10747    output: String,
10748}
10749
10750#[derive(serde::Serialize)]
10751struct ExecOutcome {
10752    kind: String,
10753    outcome: String,
10754    tool_name: String,
10755    reason: String,
10756}
10757
10758#[derive(serde::Serialize, Default)]
10759struct ExecSummary {
10760    mode: String,
10761    provider: String,
10762    model: String,
10763    prompt: String,
10764    output: String,
10765    tools: Vec<ExecToolEntry>,
10766    outcomes: Vec<ExecOutcome>,
10767    status: Option<String>,
10768    termination_reason: Option<String>,
10769    error_category: Option<String>,
10770    error: Option<String>,
10771}
10772
10773fn validate_exec_tool_authority_resume(
10774    tool_authority_json: Option<&str>,
10775    resuming: bool,
10776) -> Result<()> {
10777    if tool_authority_json.is_some() && resuming {
10778        bail!(
10779            "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue"
10780        );
10781    }
10782    Ok(())
10783}
10784
10785fn exec_network_policy(
10786    config: &Config,
10787    outer_network_access: Option<bool>,
10788) -> Option<crate::network_policy::NetworkPolicyDecider> {
10789    // Fleet caps are an outer authority boundary: user configuration may
10790    // narrow them further, but it may never widen an explicit network denial.
10791    if outer_network_access == Some(false) {
10792        return Some(crate::network_policy::NetworkPolicyDecider::new(
10793            crate::network_policy::NetworkPolicy {
10794                default: crate::network_policy::DecisionToml::Deny,
10795                ..crate::network_policy::NetworkPolicy::default()
10796            },
10797            None,
10798        ));
10799    }
10800    config.network.clone().map(|toml_cfg| {
10801        crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
10802    })
10803}
10804
10805fn apply_fleet_engine_feature_caps(
10806    features: &mut crate::features::Features,
10807    fleet_authority_active: bool,
10808    outer_network_access: Option<bool>,
10809    shell_authority: crate::tools::spec::ToolShellAuthority,
10810) {
10811    if fleet_authority_active {
10812        features.disable(crate::features::Feature::Subagents);
10813        features.disable(crate::features::Feature::Mcp);
10814        if shell_authority != crate::tools::spec::ToolShellAuthority::ReadOnly {
10815            features.disable(crate::features::Feature::ShellTool);
10816        }
10817    }
10818    if outer_network_access == Some(false) {
10819        features.disable(crate::features::Feature::WebSearch);
10820    }
10821}
10822
10823/// Resolve the optional headless safety budget without imposing a hidden
10824/// default. Benchmarks and other long-running exec callers continue until the
10825/// model finishes unless they opt into a finite `--max-turns` value.
10826fn exec_max_steps(max_turns: Option<u32>) -> u32 {
10827    max_turns.unwrap_or(u32::MAX)
10828}
10829
10830#[allow(clippy::too_many_arguments)]
10831async fn run_exec_agent(
10832    config: &Config,
10833    model: &str,
10834    prompt: &str,
10835    workspace: PathBuf,
10836    max_subagents: usize,
10837    auto_approve: bool,
10838    allow_sandbox_elevation: bool,
10839    explicit_sandbox: Option<&str>,
10840    trust_mode: bool,
10841    json_output: bool,
10842    resume_session: Option<session_manager::SavedSession>,
10843    force_configured_route: bool,
10844    output_format: ExecOutputFormat,
10845    max_turns: u32,
10846    allowed_tools: Option<Vec<String>>,
10847    disallowed_tools: Option<Vec<String>>,
10848    append_system_prompt: Option<String>,
10849    tool_authority_json: Option<String>,
10850    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10851) -> Result<()> {
10852    use crate::compaction::CompactionConfig;
10853    use crate::core::engine::{EngineConfig, spawn_engine};
10854    use crate::core::events::Event;
10855    use crate::core::ops::Op;
10856    use crate::tools::plan::new_shared_plan_state;
10857    use crate::tools::todo::new_shared_todo_list;
10858    use crate::tui::app::AppMode;
10859
10860    validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
10861    let fleet_authority = tool_authority_json
10862        .as_deref()
10863        .map(crate::tools::spec::ToolAuthorityEnvelope::from_json)
10864        .transpose()
10865        .map_err(anyhow::Error::msg)?;
10866    let fleet_authority_active = fleet_authority.is_some();
10867    let outer_network_access = fleet_authority
10868        .as_ref()
10869        .and_then(|authority| authority.network_access);
10870    let outer_shell_authority = fleet_authority
10871        .as_ref()
10872        .map(|authority| authority.shell)
10873        .unwrap_or_default();
10874    if let Some(envelope) = fleet_authority {
10875        crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?;
10876    }
10877
10878    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10879    let execution_config = config_for_cli_route(config, &route);
10880    let auto_model = route.auto_model;
10881    let effective_provider = route.provider;
10882    let effective_model = route.model;
10883    let validated_route = crate::route_runtime::resolve_runtime_route(
10884        &execution_config,
10885        effective_provider,
10886        Some(&effective_model),
10887    )
10888    .map_err(anyhow::Error::msg)?
10889    .validate()
10890    .map_err(anyhow::Error::msg)?;
10891    let effective_provider_name = validated_route.identity.key.clone();
10892    let effective_provider_id = validated_route.identity.exact_id.clone();
10893    let (effective_provider_kind, effective_stream_provider_id) =
10894        exec_stream_provider_route(&validated_route.identity);
10895    let route_source = if auto_model {
10896        "auto_resolver"
10897    } else {
10898        "explicit_or_configured"
10899    }
10900    .to_string();
10901    let exec_started = Instant::now();
10902    let prompt_sha256 = format!("sha256:{}", crate::hashing::sha256_hex(prompt.as_bytes()));
10903    let binary_sha256 = current_binary_sha256();
10904    let approval_posture = if auto_approve { "auto_tools" } else { "ask" }.to_string();
10905    let sandbox_posture = explicit_sandbox.unwrap_or("configured_default").to_string();
10906    let active_route_limits =
10907        crate::route_budget::known_route_limits(validated_route.candidate.limits());
10908    let max_subagents = if max_subagents == config.max_subagents_for_provider(config.api_provider())
10909    {
10910        execution_config
10911            .max_subagents_for_provider(effective_provider)
10912            .clamp(1, MAX_SUBAGENTS)
10913    } else {
10914        max_subagents
10915    };
10916    // A FIXED model with `--reasoning-effort auto` (the exact shape a Fleet
10917    // worker subprocess launches with: `--model <exact> --reasoning-effort
10918    // auto`) is still Auto. `auto_model` is a *model* decision and is false
10919    // here, so deriving the auto flag from it left this path both raw and
10920    // non-auto: the literal string `"auto"` travelled to the engine while the
10921    // receipt claimed no Auto was in play.
10922    let reasoning_effort_auto = route.auto_controls_reasoning;
10923    // Resolve Auto against this run's prompt at the CLI boundary, exactly like
10924    // `run_one_shot`/`run_one_shot_json` and the interactive launch path do,
10925    // so the tier the engine (and the receipt below) sees is concrete.
10926    let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| {
10927        cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt)
10928    });
10929
10930    let settings = crate::settings::Settings::load().unwrap_or_default();
10931    let auto_compact_enabled = if crate::settings::Settings::auto_compact_explicitly_configured() {
10932        settings.auto_compact
10933    } else {
10934        crate::route_budget::auto_compact_default_for_route(
10935            effective_provider,
10936            &effective_model,
10937            active_route_limits,
10938        )
10939    };
10940    let compaction = CompactionConfig {
10941        enabled: auto_compact_enabled,
10942        model: effective_model.clone(),
10943        effective_context_window: Some(crate::route_budget::route_context_window_tokens(
10944            effective_provider,
10945            &effective_model,
10946            active_route_limits,
10947        )),
10948        token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
10949            effective_provider,
10950            &effective_model,
10951            active_route_limits,
10952            settings.auto_compact_threshold_percent,
10953        ),
10954        ..Default::default()
10955    };
10956
10957    let network_policy = exec_network_policy(&execution_config, outer_network_access);
10958
10959    let lsp_config = (!fleet_authority_active)
10960        .then(|| {
10961            execution_config
10962                .lsp
10963                .clone()
10964                .map(crate::config::LspConfigToml::into_runtime)
10965        })
10966        .flatten();
10967    let mut engine_features = execution_config.features();
10968    apply_fleet_engine_feature_caps(
10969        &mut engine_features,
10970        fleet_authority_active,
10971        outer_network_access,
10972        outer_shell_authority,
10973    );
10974    let engine_plugin_registry = if fleet_authority_active {
10975        std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
10976    } else {
10977        plugin_registry
10978    };
10979    let engine_config = EngineConfig {
10980        model: effective_model.clone(),
10981        active_route_limits,
10982        workspace: workspace.clone(),
10983        subagent_state_root: None,
10984        plugin_registry: Some(engine_plugin_registry),
10985        allow_shell: crate::tools::spec::fleet_exec_shell_enabled(
10986            fleet_authority_active,
10987            outer_shell_authority,
10988            disallowed_tools.as_deref(),
10989        ) || (!fleet_authority_active
10990            && (auto_approve || execution_config.allow_shell())),
10991        trust_mode,
10992        notes_path: execution_config.notes_path(),
10993        mcp_config_path: execution_config.mcp_config_path(),
10994        skills_dir: execution_config.skills_dir(),
10995        skills_scan_codewhale_only: execution_config.skills_config().scan_codewhale_only(),
10996        instructions: {
10997            let mut instrs: Vec<crate::prompts::InstructionSource> = execution_config
10998                .instructions_paths()
10999                .into_iter()
11000                .map(Into::into)
11001                .collect();
11002            if let Some(ref extra) = append_system_prompt {
11003                instrs.push(crate::prompts::InstructionSource::Inline {
11004                    name: "cli:append-system-prompt".into(),
11005                    content: extra.clone(),
11006                });
11007            }
11008            instrs
11009        },
11010        project_context_pack_enabled: execution_config.project_context_pack_enabled(),
11011        translation_enabled: false,
11012        max_steps: max_turns,
11013        max_subagents,
11014        max_admitted_subagents: execution_config
11015            .max_admitted_subagents_for_provider(effective_provider)
11016            .max(max_subagents),
11017        launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider),
11018        subagents_enabled: !fleet_authority_active
11019            && execution_config.subagents_enabled_for_provider(effective_provider),
11020        features: engine_features,
11021        auto_review_policy: execution_config.auto_review_policy(),
11022        compaction: compaction.clone(),
11023        todos: new_shared_todo_list(),
11024        plan_state: new_shared_plan_state(),
11025        goal_state: crate::tools::goal::new_shared_goal_state(),
11026        max_spawn_depth: if fleet_authority_active {
11027            0
11028        } else {
11029            execution_config.subagent_max_spawn_depth_for_provider(effective_provider)
11030        },
11031        subagent_token_budget: execution_config
11032            .subagent_token_budget_for_provider(effective_provider),
11033        network_policy,
11034        snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled,
11035        snapshots_max_workspace_bytes: execution_config
11036            .snapshots_config()
11037            .max_workspace_gb
11038            .saturating_mul(1024 * 1024 * 1024),
11039        lsp_config,
11040        runtime_services: crate::tools::spec::RuntimeToolServices::default(),
11041        subagent_model_overrides: execution_config.subagent_model_overrides(),
11042        fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
11043            &execution_config.fleet_config(),
11044            &workspace,
11045        )),
11046        subagent_api_timeout: std::time::Duration::from_secs(
11047            execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
11048        ),
11049        stream_chunk_timeout: std::time::Duration::from_secs(
11050            execution_config.stream_chunk_timeout_secs(),
11051        ),
11052        subagent_heartbeat_timeout: std::time::Duration::from_secs(
11053            execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
11054        ),
11055        prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
11056        memory_enabled: execution_config.memory_enabled(),
11057        memory_path: execution_config.memory_path(),
11058        speech_output_dir: execution_config.speech_output_dir(),
11059        vision_config: execution_config.vision_model_config(),
11060        strict_tool_mode: execution_config.strict_tool_mode.unwrap_or(false),
11061        goal_objective: None,
11062        goal_token_budget: None,
11063        goal_status: crate::tools::goal::GoalStatus::Active,
11064        goal_max_continuations: execution_config.goal_max_continuations(),
11065        allowed_tools: allowed_tools.clone(),
11066        disallowed_tools: disallowed_tools.clone(),
11067        max_tool_calls: None,
11068        hook_executor: None,
11069        locale_tag: crate::localization::resolve_locale(&settings.locale)
11070            .tag()
11071            .to_string(),
11072        workshop: config.workshop.clone(),
11073        search_provider: execution_config.search_provider(),
11074        search_api_key: execution_config
11075            .search
11076            .as_ref()
11077            .and_then(|s| s.api_key.clone()),
11078        search_base_url: execution_config
11079            .search
11080            .as_ref()
11081            .and_then(|s| s.base_url.clone()),
11082        tools_always_load: if fleet_authority_active {
11083            std::collections::HashSet::new()
11084        } else {
11085            execution_config.tools_always_load()
11086        },
11087        tools: if fleet_authority_active {
11088            None
11089        } else {
11090            execution_config.tools.clone()
11091        },
11092        verbosity: execution_config.verbosity.clone(),
11093        workspace_follow_symlinks: settings.workspace_follow_symlinks,
11094        exec_policy_engine: execution_config.exec_policy_engine.clone(),
11095        terminal_chrome_enabled: false,
11096        advisor_config: execution_config
11097            .advisor
11098            .as_ref()
11099            .map(crate::tools::subagent::AdvisorConfig::from_toml)
11100            .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
11101    };
11102
11103    let engine_handle = spawn_engine(engine_config, &execution_config);
11104    let mode = if auto_approve {
11105        AppMode::Yolo
11106    } else {
11107        AppMode::Agent
11108    };
11109
11110    let resuming_session = resume_session.is_some();
11111    let mut loaded_session_id = None;
11112    if let Some(saved) = resume_session {
11113        let saved_id = saved.metadata.id.clone();
11114        if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text {
11115            eprintln!(
11116                "Warning: session {} was created in a different workspace ({}). Resuming anyway.",
11117                truncate_id(&saved_id),
11118                saved.metadata.workspace.display(),
11119            );
11120        }
11121
11122        engine_handle
11123            .send(Op::SyncSession {
11124                session_id: Some(saved_id.clone()),
11125                messages: saved.messages,
11126                system_prompt: saved.system_prompt.map(SystemPrompt::Text),
11127                system_prompt_override: false,
11128                model: saved.metadata.model,
11129                workspace: saved.metadata.workspace,
11130                mode,
11131            })
11132            .await?;
11133        loaded_session_id = Some(saved_id.clone());
11134        if output_format == ExecOutputFormat::Text && !json_output {
11135            eprintln!("{}", exec_resumed_session_line(&saved_id));
11136        }
11137    }
11138
11139    engine_handle
11140        .send(Op::SendMessage {
11141            content: prompt.to_string(),
11142            mode,
11143            route: Box::new(validated_route.into_resolved()),
11144            compaction: Box::new(compaction.clone()),
11145            goal_objective: None,
11146            goal_token_budget: None,
11147            goal_status: crate::tools::goal::GoalStatus::Active,
11148            allowed_tools: allowed_tools.clone(),
11149            dynamic_tools: Vec::new(),
11150            hook_executor: None,
11151            reasoning_effort: effective_reasoning_effort,
11152            reasoning_effort_auto,
11153            auto_model,
11154            allow_shell: auto_approve || execution_config.allow_shell(),
11155            trust_mode,
11156            auto_approve,
11157            translation_enabled: false,
11158            approval_mode: if auto_approve {
11159                crate::tui::approval::ApprovalMode::Bypass
11160            } else {
11161                execution_config
11162                    .approval_policy
11163                    .as_deref()
11164                    .and_then(crate::tui::approval::ApprovalMode::from_config_value)
11165                    .unwrap_or_default()
11166            },
11167            verbosity: execution_config.verbosity.clone(),
11168            provenance: crate::core::ops::UserInputProvenance::ExternalUser,
11169        })
11170        .await?;
11171
11172    let mut summary = ExecSummary {
11173        mode: "agent".to_string(),
11174        provider: effective_provider_name.clone(),
11175        model: effective_model.clone(),
11176        prompt: prompt.to_string(),
11177        ..ExecSummary::default()
11178    };
11179    let can_elevate_sandbox =
11180        exec_sandbox_elevation_authorized(allow_sandbox_elevation, explicit_sandbox);
11181    let mut sandbox_denied = false;
11182    let mut approval_required = false;
11183    let mut tool_error_seen = false;
11184    let mut last_error_category = None;
11185    let mut reported_sandbox_contract = false;
11186
11187    let should_persist_session = resuming_session || output_format == ExecOutputFormat::StreamJson;
11188    let mut latest_session_id = loaded_session_id;
11189    let mut latest_messages: Vec<Message> = Vec::new();
11190    let mut latest_system_prompt: Option<SystemPrompt> = None;
11191    let mut latest_model = effective_model;
11192    let mut latest_workspace = workspace.clone();
11193    let mut tool_starts: HashMap<String, (Instant, String)> = HashMap::new();
11194    let mut turn_usage_seq: u32 = 0;
11195
11196    let mut stdout = io::stdout();
11197    let mut ends_with_newline = false;
11198    loop {
11199        let event = {
11200            let mut rx = engine_handle.rx_event.write().await;
11201            rx.recv().await
11202        };
11203
11204        let Some(event) = event else {
11205            break;
11206        };
11207
11208        match event {
11209            Event::MessageDelta { content, .. } => {
11210                summary.output.push_str(&content);
11211                if output_format == ExecOutputFormat::StreamJson {
11212                    emit_exec_stream_event(&ExecStreamEvent::Content { content })?;
11213                } else if !json_output {
11214                    print!("{content}");
11215                    stdout.flush()?;
11216                }
11217                ends_with_newline = summary.output.ends_with('\n');
11218            }
11219            Event::MessageComplete { .. }
11220                if output_format == ExecOutputFormat::Text
11221                    && !json_output
11222                    && !ends_with_newline =>
11223            {
11224                println!();
11225            }
11226            Event::ThinkingDelta { .. } => {
11227                // Exec stream-json intentionally omits reasoning deltas; the
11228                // TUI transcript retains its existing Activity Detail surface.
11229            }
11230            Event::ToolCallStarted { id, name, input } => {
11231                let started_at = chrono::Utc::now().to_rfc3339();
11232                tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
11233                if output_format == ExecOutputFormat::StreamJson {
11234                    emit_exec_stream_event(&ExecStreamEvent::ToolUse {
11235                        name,
11236                        id,
11237                        input,
11238                        started_at,
11239                    })?;
11240                } else if !json_output {
11241                    let summary = summarize_tool_args(&input);
11242                    if let Some(summary) = summary {
11243                        eprintln!("tool: {name} ({summary})");
11244                    } else {
11245                        eprintln!("tool: {name}");
11246                    }
11247                }
11248            }
11249            Event::ToolCallComplete {
11250                id, name, result, ..
11251            } => {
11252                let (duration_ms, started_at) = tool_starts
11253                    .remove(&id)
11254                    .map(|(started, timestamp)| {
11255                        (
11256                            u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
11257                            timestamp,
11258                        )
11259                    })
11260                    .unwrap_or_else(|| (0, chrono::Utc::now().to_rfc3339()));
11261                let receipt_name = name.clone();
11262                match result {
11263                    Ok(output) => {
11264                        tool_error_seen |= !output.success;
11265                        summary.tools.push(ExecToolEntry {
11266                            name: name.clone(),
11267                            success: output.success,
11268                            output: output.content.clone(),
11269                        });
11270                        if output_format == ExecOutputFormat::StreamJson {
11271                            emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11272                                id,
11273                                name: receipt_name,
11274                                output: output.content,
11275                                status: if output.success {
11276                                    "success".to_string()
11277                                } else {
11278                                    "error".to_string()
11279                                },
11280                                started_at,
11281                                completed_at: chrono::Utc::now().to_rfc3339(),
11282                                duration_ms,
11283                                side_effect_status: output
11284                                    .metadata
11285                                    .as_ref()
11286                                    .and_then(|metadata| metadata.get("side_effect_status"))
11287                                    .and_then(serde_json::Value::as_str)
11288                                    .unwrap_or("unknown")
11289                                    .to_string(),
11290                                error_category: (!output.success).then(|| {
11291                                    output
11292                                        .metadata
11293                                        .as_ref()
11294                                        .and_then(|metadata| metadata.get("error_category"))
11295                                        .and_then(serde_json::Value::as_str)
11296                                        .unwrap_or("tool_reported_failure")
11297                                        .to_string()
11298                                }),
11299                                truncated: output
11300                                    .metadata
11301                                    .as_ref()
11302                                    .and_then(|metadata| metadata.get("truncated"))
11303                                    .and_then(serde_json::Value::as_bool),
11304                                artifact: tool_artifact_receipt(output.metadata.as_ref()),
11305                                result_metadata: output.metadata,
11306                            })?;
11307                        } else if !json_output {
11308                            if name == "exec_shell" && !output.content.trim().is_empty() {
11309                                eprintln!("tool {name} completed");
11310                                eprintln!(
11311                                    "--- stdout/stderr ---\n{}\n---------------------",
11312                                    output.content
11313                                );
11314                            } else {
11315                                eprintln!(
11316                                    "tool {name} completed: {}",
11317                                    summarize_tool_output(&output.content)
11318                                );
11319                            }
11320                        }
11321                    }
11322                    Err(err) => {
11323                        tool_error_seen = true;
11324                        let error_text = err.to_string();
11325                        summary.tools.push(ExecToolEntry {
11326                            name: name.clone(),
11327                            success: false,
11328                            output: error_text.clone(),
11329                        });
11330                        if output_format == ExecOutputFormat::StreamJson {
11331                            emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11332                                id,
11333                                name: receipt_name,
11334                                output: error_text,
11335                                status: "error".to_string(),
11336                                started_at,
11337                                completed_at: chrono::Utc::now().to_rfc3339(),
11338                                duration_ms,
11339                                side_effect_status: "not_started_or_unknown".to_string(),
11340                                error_category: Some(tool_error_receipt_category(&err).to_string()),
11341                                truncated: None,
11342                                artifact: None,
11343                                result_metadata: None,
11344                            })?;
11345                        } else if !json_output {
11346                            eprintln!("tool {name} failed: {err}");
11347                        }
11348                    }
11349                }
11350            }
11351            Event::AgentSpawned { id, prompt, .. }
11352                if output_format == ExecOutputFormat::Text && !json_output =>
11353            {
11354                eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
11355            }
11356            Event::AgentProgress { id, status, .. }
11357                if output_format == ExecOutputFormat::Text && !json_output =>
11358            {
11359                eprintln!("sub-agent {id}: {status}");
11360            }
11361            Event::AgentComplete { id, result }
11362                if output_format == ExecOutputFormat::Text && !json_output =>
11363            {
11364                eprintln!(
11365                    "sub-agent {id} completed: {}",
11366                    summarize_tool_output(&result)
11367                );
11368            }
11369            Event::AgentSpawned {
11370                id,
11371                parent_run_id,
11372                spawn_depth,
11373                model,
11374                route_source,
11375                ..
11376            } if output_format == ExecOutputFormat::StreamJson => {
11377                emit_exec_stream_event(&ExecStreamEvent::AgentSpawned {
11378                    id,
11379                    model,
11380                    spawn_depth,
11381                    parent_run_id,
11382                    route_source,
11383                })?;
11384            }
11385            Event::AgentSpawned { .. }
11386            | Event::AgentProgress { .. }
11387            | Event::AgentComplete { .. } => {}
11388            Event::WorkflowUi { run_id, event }
11389                if output_format == ExecOutputFormat::StreamJson =>
11390            {
11391                emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11392            }
11393            Event::ApprovalRequired { id, .. } => {
11394                if auto_approve {
11395                    let _ = engine_handle.approve_tool_call(id).await;
11396                } else {
11397                    approval_required = true;
11398                    let _ = engine_handle.deny_tool_call(id).await;
11399                }
11400            }
11401            Event::ElevationRequired {
11402                tool_id,
11403                tool_name,
11404                denial_reason,
11405                ..
11406            } => {
11407                if can_elevate_sandbox {
11408                    let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
11409                    let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
11410                } else {
11411                    sandbox_denied = true;
11412                    approval_required = true;
11413                    summary.outcomes.push(ExecOutcome {
11414                        kind: "sandbox_denied".to_string(),
11415                        outcome: "approval_required".to_string(),
11416                        tool_name: tool_name.clone(),
11417                        reason: denial_reason.clone(),
11418                    });
11419                    if !reported_sandbox_contract {
11420                        eprintln!(
11421                            "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"
11422                        );
11423                        reported_sandbox_contract = true;
11424                    }
11425                    if output_format == ExecOutputFormat::StreamJson {
11426                        emit_exec_stream_event(&ExecStreamEvent::SandboxDenied {
11427                            tool_id: tool_id.clone(),
11428                            tool_name,
11429                            reason: denial_reason,
11430                            outcome: "approval_required".to_string(),
11431                        })?;
11432                    }
11433                    let _ = engine_handle.deny_tool_call(tool_id).await;
11434                }
11435            }
11436            Event::Error {
11437                envelope,
11438                recoverable: _,
11439            } => {
11440                // Only a non-recoverable envelope may force the run summary
11441                // into failure. Recoverable warnings (stream-stall notices,
11442                // transient retry noise) are still streamed for visibility,
11443                // but the terminal TurnComplete event carries the
11444                // authoritative turn outcome — letting a warning set
11445                // `summary.error` here would exit an otherwise-successful
11446                // `exec` run non-zero.
11447                if exec_error_event_is_fatal(&envelope) {
11448                    last_error_category = Some(envelope.category);
11449                    summary.error_category = Some(envelope.category.to_string());
11450                    summary.error = Some(envelope.message.clone());
11451                }
11452                if output_format == ExecOutputFormat::StreamJson {
11453                    emit_exec_stream_event(&ExecStreamEvent::Error {
11454                        error: envelope.message,
11455                    })?;
11456                } else if !json_output {
11457                    eprintln!("error: {}", envelope.message);
11458                }
11459            }
11460            Event::TurnUsage { usage, duration_ms } => {
11461                if output_format == ExecOutputFormat::StreamJson {
11462                    turn_usage_seq = turn_usage_seq.saturating_add(1);
11463                    emit_exec_stream_event(&ExecStreamEvent::TurnUsage {
11464                        turn: turn_usage_seq,
11465                        input_tokens: usage.input_tokens,
11466                        output_tokens: usage.output_tokens,
11467                        reasoning_tokens: usage.reasoning_tokens,
11468                        prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11469                        prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11470                        prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11471                        reasoning_replay_tokens: usage.reasoning_replay_tokens,
11472                        duration_ms,
11473                    })?;
11474                }
11475            }
11476            Event::TurnComplete {
11477                status,
11478                error,
11479                usage,
11480                tool_catalog,
11481                ..
11482            } => {
11483                summary.status = Some(format!("{status:?}").to_lowercase());
11484                if error.is_some() {
11485                    summary.error = error;
11486                }
11487                if sandbox_denied
11488                    && summary.error.is_none()
11489                    && matches!(status, crate::core::events::TurnOutcomeStatus::Failed)
11490                {
11491                    summary.error = Some(
11492                        "exec turn failed after sandbox denial; explicit sandbox elevation was not authorized"
11493                            .to_string(),
11494                    );
11495                }
11496                if last_error_category.is_none() {
11497                    last_error_category = summary
11498                        .error
11499                        .as_deref()
11500                        .map(crate::error_taxonomy::classify_error_message);
11501                    summary.error_category =
11502                        last_error_category.map(|category| category.to_string());
11503                }
11504                let termination_reason = crate::core::termination::classify_turn_termination(
11505                    status,
11506                    last_error_category,
11507                    tool_error_seen,
11508                    approval_required,
11509                );
11510                summary.termination_reason = Some(termination_reason.as_str().to_string());
11511                // State the exit class here rather than inferring it later
11512                // from the process exit code: `Canceled` exits 130, the same
11513                // value the SIGINT path uses, so a code-based derivation would
11514                // report every Esc-cancelled turn as a signal. A no-op unless
11515                // this process was armed.
11516                if !termination_reason.is_success() {
11517                    codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
11518                }
11519                let saved_session_id = if should_persist_session && !latest_messages.is_empty() {
11520                    match persist_exec_session(
11521                        &latest_messages,
11522                        &latest_model,
11523                        PersistedProviderRoute {
11524                            kind: effective_provider.as_str(),
11525                            id: effective_provider_id.as_deref(),
11526                        },
11527                        &latest_workspace,
11528                        &latest_system_prompt,
11529                        latest_session_id.as_deref(),
11530                        u64::from(usage.input_tokens) + u64::from(usage.output_tokens),
11531                    ) {
11532                        Ok(id) => {
11533                            if output_format == ExecOutputFormat::Text && !json_output {
11534                                eprintln!("{}", exec_saved_session_line(&id));
11535                            }
11536                            Some(id)
11537                        }
11538                        Err(err) => {
11539                            if output_format == ExecOutputFormat::Text && !json_output {
11540                                eprintln!("warning: failed to save exec session: {err}");
11541                            }
11542                            latest_session_id.clone()
11543                        }
11544                    }
11545                } else {
11546                    latest_session_id.clone()
11547                };
11548
11549                if output_format == ExecOutputFormat::StreamJson {
11550                    if let Some(id) = saved_session_id.as_ref() {
11551                        emit_exec_stream_event(&ExecStreamEvent::SessionCapture {
11552                            content: exec_stream_session_ref(id),
11553                        })?;
11554                    }
11555                    emit_exec_stream_event(&ExecStreamEvent::Metadata {
11556                        meta: Box::new(ExecStreamMeta {
11557                            receipt_kind: "terminal",
11558                            provider: effective_provider_kind.clone(),
11559                            provider_id: effective_stream_provider_id.clone(),
11560                            model: latest_model.clone(),
11561                            route_source: route_source.clone(),
11562                            input_tokens: Some(usage.input_tokens),
11563                            output_tokens: Some(usage.output_tokens),
11564                            prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11565                            prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11566                            prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11567                            reasoning_tokens: usage.reasoning_tokens,
11568                            duration_ms: u64::try_from(exec_started.elapsed().as_millis())
11569                                .unwrap_or(u64::MAX),
11570                            retry_count: None,
11571                            approval_posture: approval_posture.clone(),
11572                            sandbox_posture: sandbox_posture.clone(),
11573                            binary_sha256: binary_sha256.clone(),
11574                            config_sha256: None,
11575                            prompt_sha256: prompt_sha256.clone(),
11576                            tool_catalog_sha256: tool_catalog.as_ref().and_then(|catalog| {
11577                                serde_json::to_vec(catalog).ok().map(|bytes| {
11578                                    format!("sha256:{}", crate::hashing::sha256_hex(&bytes))
11579                                })
11580                            }),
11581                            input_analysis: exec_stream_input_analysis(
11582                                &latest_messages,
11583                                latest_system_prompt.as_ref(),
11584                            ),
11585                            visible_final_answer_chars: summary.output.chars().count(),
11586                            resume_command: saved_session_id
11587                                .as_deref()
11588                                .map(exec_stream_resume_hint)
11589                                .unwrap_or_default(),
11590                            session_id: saved_session_id
11591                                .as_deref()
11592                                .map(exec_stream_session_ref)
11593                                .unwrap_or_default(),
11594                            workspace: latest_workspace.display().to_string(),
11595                            message_count: latest_messages.len(),
11596                            status: summary.status.clone(),
11597                            termination_reason: summary.termination_reason.clone(),
11598                            error_category: summary.error_category.clone(),
11599                        }),
11600                    })?;
11601                    emit_exec_stream_event(&ExecStreamEvent::Done)?;
11602                }
11603                let _ = engine_handle.send(Op::Shutdown).await;
11604                break;
11605            }
11606            Event::SessionUpdated {
11607                session_id,
11608                messages,
11609                system_prompt,
11610                model,
11611                workspace,
11612            } => {
11613                latest_session_id = Some(session_id);
11614                latest_messages = messages;
11615                latest_system_prompt = system_prompt;
11616                latest_model = model;
11617                latest_workspace = workspace;
11618            }
11619            // #3027: surface the engine's max-steps notice in text mode so a
11620            // --max-turns run that stops early says why instead of going quiet.
11621            Event::Status { message }
11622                if output_format == ExecOutputFormat::Text
11623                    && !json_output
11624                    && message.contains("Reached maximum steps") =>
11625            {
11626                eprintln!("{message}");
11627            }
11628            _ => {}
11629        }
11630    }
11631
11632    if json_output {
11633        println!("{}", serde_json::to_string_pretty(&summary)?);
11634    }
11635
11636    if let Some(error) = summary.error.as_ref()
11637        && !error.trim().is_empty()
11638    {
11639        // Distinguish retryable infrastructure failures (provider/transport,
11640        // after all in-session retries are exhausted) from genuine task
11641        // failures so supervisors and bench harnesses can tell them apart at
11642        // the process level without parsing the stream. Genuine failures
11643        // keep the historical `bail!` → exit 1 path.
11644        let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
11645        if exit_code != 1 {
11646            eprintln!("Error: exec turn failed: {error}");
11647            let _ = io::stdout().flush();
11648            std::process::exit(exit_code);
11649        }
11650        bail!("exec turn failed: {error}");
11651    }
11652
11653    if matches!(
11654        summary.status.as_deref(),
11655        Some("failed" | "canceled" | "interrupted")
11656    ) {
11657        let status = summary.status.as_deref().unwrap_or("unknown");
11658        bail!("exec turn ended with status {status}");
11659    }
11660
11661    Ok(())
11662}
11663
11664#[cfg(test)]
11665mod serve_bind_host_tests {
11666    use super::*;
11667
11668    #[test]
11669    fn http_defaults_to_loopback() {
11670        assert_eq!(
11671            resolve_serve_bind_host(false, None),
11672            ServeBindHost {
11673                host: "127.0.0.1".to_string(),
11674                mobile_rebound_to_lan: false,
11675            }
11676        );
11677    }
11678
11679    #[test]
11680    fn mobile_default_rebinds_to_lan_with_warning_flag() {
11681        assert_eq!(
11682            resolve_serve_bind_host(true, None),
11683            ServeBindHost {
11684                host: "0.0.0.0".to_string(),
11685                mobile_rebound_to_lan: true,
11686            }
11687        );
11688    }
11689
11690    #[test]
11691    fn mobile_respects_explicit_loopback_host() {
11692        assert_eq!(
11693            resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
11694            ServeBindHost {
11695                host: "127.0.0.1".to_string(),
11696                mobile_rebound_to_lan: false,
11697            }
11698        );
11699    }
11700
11701    #[test]
11702    fn http_and_mobile_are_mutually_exclusive() {
11703        let err = validate_serve_mode_selection(false, true, true, false, false).unwrap_err();
11704        assert!(
11705            err.to_string()
11706                .contains("--http and --mobile are mutually exclusive")
11707        );
11708    }
11709
11710    #[test]
11711    fn web_is_a_distinct_loopback_runtime_mode() {
11712        assert!(validate_serve_mode_selection(false, false, false, true, false).unwrap());
11713        let err = validate_serve_mode_selection(false, true, false, true, false).unwrap_err();
11714        assert!(err.to_string().contains("--web is mutually exclusive"));
11715        assert_eq!(
11716            resolve_serve_bind_host(false, None),
11717            ServeBindHost {
11718                host: "127.0.0.1".to_string(),
11719                mobile_rebound_to_lan: false,
11720            }
11721        );
11722    }
11723}
11724
11725#[cfg(test)]
11726mod exec_exit_semantics_tests {
11727    use super::*;
11728
11729    #[test]
11730    fn retryable_infra_categories_exit_with_ex_tempfail() {
11731        // Provider/transport failures after all in-session retries: the task
11732        // neither passed nor failed, and the harness may safely retry.
11733        assert_eq!(exec_failure_exit_code(Some("network")), 75);
11734        assert_eq!(exec_failure_exit_code(Some("timeout")), 75);
11735        assert_eq!(EXEC_EXIT_RETRYABLE_INFRA, 75, "EX_TEMPFAIL from sysexits.h");
11736    }
11737
11738    #[test]
11739    fn genuine_failures_keep_exit_1() {
11740        // Task-side failures and unknown categories keep the historical
11741        // exit-1 contract — no masking, no forced zero exits.
11742        assert_eq!(exec_failure_exit_code(Some("tool")), 1);
11743        assert_eq!(exec_failure_exit_code(Some("authentication")), 1);
11744        assert_eq!(exec_failure_exit_code(Some("invalid_input")), 1);
11745        assert_eq!(exec_failure_exit_code(None), 1);
11746        // rate_limit is deliberately exit 1: the same category also covers
11747        // quota exhaustion, which a blind harness retry would hammer.
11748        assert_eq!(exec_failure_exit_code(Some("rate_limit")), 1);
11749    }
11750
11751    #[test]
11752    fn recoverable_error_events_do_not_fail_the_run_summary() {
11753        // A recoverable warning (e.g. a stream-stall notice mid-turn) must
11754        // not force the exec summary into failure; the terminal TurnComplete
11755        // carries the authoritative outcome.
11756        let warning = crate::error_taxonomy::ErrorEnvelope::network(
11757            "Stream stalled: no data received for 120s, closing stream",
11758        );
11759        assert!(
11760            !exec_error_event_is_fatal(&warning),
11761            "recoverable envelopes must not poison the exec summary"
11762        );
11763        let fatal = crate::error_taxonomy::ErrorEnvelope::fatal("engine exploded");
11764        assert!(exec_error_event_is_fatal(&fatal));
11765    }
11766}
11767
11768#[cfg(test)]
11769mod doctor_legacy_state_tests {
11770    use super::*;
11771    use std::env;
11772    use std::ffi::OsString;
11773    use std::fs;
11774    use tempfile::TempDir;
11775
11776    struct EnvVarRestore {
11777        key: &'static str,
11778        previous: Option<OsString>,
11779    }
11780
11781    impl EnvVarRestore {
11782        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
11783            let previous = env::var_os(key);
11784            unsafe {
11785                env::set_var(key, value);
11786            }
11787            Self { key, previous }
11788        }
11789    }
11790
11791    impl Drop for EnvVarRestore {
11792        fn drop(&mut self) {
11793            unsafe {
11794                match &self.previous {
11795                    Some(value) => env::set_var(self.key, value),
11796                    None => env::remove_var(self.key),
11797                }
11798            }
11799        }
11800    }
11801
11802    fn roots(tmp: &TempDir) -> (PathBuf, PathBuf) {
11803        (tmp.path().join(".codewhale"), tmp.path().join(".deepseek"))
11804    }
11805
11806    fn entry<'a>(report: &'a [DoctorLegacyStateEntry], name: &str) -> &'a DoctorLegacyStateEntry {
11807        report
11808            .iter()
11809            .find(|entry| entry.name == name)
11810            .expect("legacy state entry should exist")
11811    }
11812
11813    #[test]
11814    fn doctor_legacy_state_report_marks_unmigrated_legacy_entries() {
11815        let tmp = TempDir::new().expect("tempdir");
11816        let (primary_root, legacy_root) = roots(&tmp);
11817        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
11818        fs::create_dir_all(legacy_root.join("tasks")).expect("legacy tasks");
11819        fs::create_dir_all(&primary_root).expect("primary root");
11820        fs::write(legacy_root.join("config.toml"), "api_key = 'old'").expect("legacy config");
11821
11822        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
11823        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11824
11825        assert_eq!(
11826            entry(&report, "sessions").status,
11827            DoctorLegacyStateStatus::LegacyOnly
11828        );
11829        assert_eq!(
11830            entry(&report, "config.toml").status,
11831            DoctorLegacyStateStatus::LegacyOnly
11832        );
11833        assert_eq!(
11834            entry(&report, "skills").status,
11835            DoctorLegacyStateStatus::Absent
11836        );
11837
11838        let json =
11839            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
11840        assert_eq!(json["needs_attention"], true);
11841        assert_eq!(json["legacy_only_count"], 3);
11842        assert_eq!(json["dual_present_count"], 0);
11843    }
11844
11845    #[test]
11846    fn doctor_legacy_state_report_marks_dual_present_entries() {
11847        let tmp = TempDir::new().expect("tempdir");
11848        let (primary_root, legacy_root) = roots(&tmp);
11849        fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
11850        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
11851        fs::write(primary_root.join("mcp.json"), "{}").expect("primary mcp");
11852        fs::write(legacy_root.join("mcp.json"), "{}").expect("legacy mcp");
11853
11854        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
11855        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11856
11857        assert_eq!(
11858            entry(&report, "sessions").status,
11859            DoctorLegacyStateStatus::Both
11860        );
11861        assert_eq!(
11862            entry(&report, "mcp.json").status,
11863            DoctorLegacyStateStatus::Both
11864        );
11865
11866        let json =
11867            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
11868        assert_eq!(json["needs_attention"], true);
11869        assert_eq!(json["legacy_only_count"], 0);
11870        assert_eq!(json["dual_present_count"], 2);
11871    }
11872
11873    #[test]
11874    fn doctor_legacy_state_report_is_clear_when_only_primary_exists() {
11875        let tmp = TempDir::new().expect("tempdir");
11876        let (primary_root, legacy_root) = roots(&tmp);
11877        fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
11878        fs::write(primary_root.join("settings.toml"), "default_mode = 'ask'")
11879            .expect("primary settings");
11880
11881        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
11882        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11883
11884        assert_eq!(
11885            entry(&report, "sessions").status,
11886            DoctorLegacyStateStatus::PrimaryOnly
11887        );
11888        assert!(!report.iter().any(legacy_state_needs_attention));
11889
11890        let json =
11891            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
11892        assert_eq!(json["needs_attention"], false);
11893        assert_eq!(json["legacy_only_count"], 0);
11894        assert_eq!(json["dual_present_count"], 0);
11895    }
11896
11897    #[test]
11898    fn doctor_legacy_state_report_is_clear_when_neither_root_exists() {
11899        let tmp = TempDir::new().expect("tempdir");
11900        let (primary_root, legacy_root) = roots(&tmp);
11901
11902        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
11903        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11904
11905        assert!(
11906            report
11907                .iter()
11908                .all(|entry| entry.status == DoctorLegacyStateStatus::Absent)
11909        );
11910        assert!(!report.iter().any(legacy_state_needs_attention));
11911
11912        let json =
11913            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
11914        assert_eq!(json["needs_attention"], false);
11915        assert_eq!(json["legacy_only_count"], 0);
11916        assert_eq!(json["dual_present_count"], 0);
11917    }
11918
11919    #[test]
11920    fn doctor_reports_incomplete_session_migration_without_mutating_files() {
11921        let tmp = TempDir::new().expect("tempdir");
11922        let (primary_root, legacy_root) = roots(&tmp);
11923        let primary_sessions = primary_root.join("sessions");
11924        let legacy_sessions = legacy_root.join("sessions");
11925        fs::create_dir_all(&primary_sessions).expect("primary sessions");
11926        fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
11927        fs::write(primary_sessions.join("already-there.json"), b"primary")
11928            .expect("primary session");
11929        fs::write(legacy_sessions.join("already-there.json"), b"legacy")
11930            .expect("legacy matching session");
11931        fs::write(
11932            legacy_sessions.join("recover-me.json"),
11933            b"not parsed by doctor",
11934        )
11935        .expect("legacy recoverable session");
11936        fs::write(
11937            legacy_sessions.join("checkpoints").join("latest.json"),
11938            b"checkpoint not inspected",
11939        )
11940        .expect("legacy checkpoint");
11941
11942        let legacy_before = fs::read(legacy_sessions.join("recover-me.json"))
11943            .expect("read legacy fixture before diagnostic");
11944        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11945
11946        assert_eq!(
11947            report.status,
11948            DoctorSessionRecoveryStatus::MigrationIncomplete
11949        );
11950        assert_eq!(report.legacy_session_file_count, 2);
11951        assert_eq!(report.already_present_file_count, 1);
11952        assert_eq!(report.recoverable_file_count, 1);
11953        assert_eq!(report.recoverable.len(), 1);
11954        assert_eq!(report.recoverable[0].name, PathBuf::from("recover-me.json"));
11955        assert!(
11956            !primary_sessions.join("recover-me.json").exists(),
11957            "doctor must not copy a recoverable session"
11958        );
11959        assert_eq!(
11960            fs::read(legacy_sessions.join("recover-me.json"))
11961                .expect("legacy file remains after diagnostic"),
11962            legacy_before,
11963            "doctor must not rewrite or delete the legacy source"
11964        );
11965
11966        let json = doctor_session_recovery_json(&report);
11967        assert_eq!(json["needs_attention"], true);
11968        assert_eq!(json["read_only"], true);
11969        assert_eq!(json["chat_contents_read"], false);
11970        assert_eq!(json["checkpoint_internals_scanned"], false);
11971        assert_eq!(json["recoverable_file_count"], 1);
11972        assert_eq!(json["recovery_command"], "codewhale sessions");
11973        assert_eq!(json["recoverable_files"][0]["name"], "recover-me.json");
11974        let serialized = json.to_string();
11975        assert!(
11976            !serialized.contains("not parsed by doctor"),
11977            "the report must not expose session contents"
11978        );
11979        assert!(
11980            !serialized.contains("checkpoint not inspected"),
11981            "the report must not expose checkpoint contents"
11982        );
11983    }
11984
11985    #[test]
11986    fn doctor_treats_preserved_legacy_sessions_as_complete_by_filename() {
11987        let tmp = TempDir::new().expect("tempdir");
11988        let (primary_root, legacy_root) = roots(&tmp);
11989        let primary_sessions = primary_root.join("sessions");
11990        let legacy_sessions = legacy_root.join("sessions");
11991        fs::create_dir_all(&primary_sessions).expect("primary sessions");
11992        fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
11993        fs::write(primary_sessions.join("same-name.json"), b"primary").expect("primary session");
11994        fs::write(legacy_sessions.join("same-name.json"), b"legacy").expect("legacy session");
11995
11996        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
11997
11998        assert_eq!(
11999            report.status,
12000            DoctorSessionRecoveryStatus::MigrationComplete
12001        );
12002        assert!(!report.needs_attention());
12003        assert_eq!(report.recoverable_file_count, 0);
12004        assert!(report.recoverable.is_empty());
12005        assert_eq!(report.already_present_file_count, 1);
12006        let json = doctor_session_recovery_json(&report);
12007        assert_eq!(json["session_descriptors_compared"], false);
12008        assert_eq!(
12009            json["counterpart_check"],
12010            "top_level_filename_and_regular_file_only"
12011        );
12012    }
12013
12014    #[test]
12015    fn doctor_bounds_recoverable_session_filename_samples() {
12016        let tmp = TempDir::new().expect("tempdir");
12017        let (primary_root, legacy_root) = roots(&tmp);
12018        let legacy_sessions = legacy_root.join("sessions");
12019        fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12020        for index in 0..DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
12021            fs::write(
12022                legacy_sessions.join(format!("late-{index:03}.json")),
12023                b"fixture",
12024            )
12025            .expect("legacy session fixture");
12026        }
12027        fs::write(legacy_sessions.join("early-000.json"), b"fixture")
12028            .expect("earliest legacy session fixture");
12029        fs::write(legacy_sessions.join("early-001.json"), b"fixture")
12030            .expect("second earliest legacy session fixture");
12031        let total = DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT + 2;
12032
12033        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12034        let json = doctor_session_recovery_json(&report);
12035
12036        assert_eq!(report.recoverable_file_count, total);
12037        assert_eq!(
12038            report.recoverable.len(),
12039            DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
12040        );
12041        assert_eq!(
12042            json["recoverable_files"].as_array().map(Vec::len),
12043            Some(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
12044        );
12045        assert_eq!(
12046            report.recoverable.first().map(|entry| entry.name.as_path()),
12047            Some(Path::new("early-000.json")),
12048            "the bounded sample must not depend on read_dir order"
12049        );
12050        assert_eq!(
12051            report.recoverable.last().map(|entry| entry.name.as_path()),
12052            Some(Path::new("late-097.json")),
12053            "the bounded sample must retain the lexical prefix"
12054        );
12055        assert_eq!(json["recoverable_files_truncated"], true);
12056    }
12057
12058    #[test]
12059    fn doctor_session_recovery_fails_closed_on_an_unreadable_path_shape() {
12060        let tmp = TempDir::new().expect("tempdir");
12061        let (primary_root, legacy_root) = roots(&tmp);
12062        fs::create_dir_all(&legacy_root).expect("legacy root");
12063        fs::write(legacy_root.join("sessions"), b"not a directory")
12064            .expect("invalid legacy sessions path");
12065
12066        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12067
12068        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12069        assert!(report.needs_attention());
12070        assert!(report.error.as_deref().is_some_and(|error| {
12071            error.contains("legacy sessions root") && error.contains("not a directory")
12072        }));
12073    }
12074
12075    #[test]
12076    fn doctor_session_recovery_rejects_a_non_directory_legacy_state_root() {
12077        let tmp = TempDir::new().expect("tempdir");
12078        let (primary_root, legacy_root) = roots(&tmp);
12079        fs::write(&legacy_root, b"not a state directory").expect("invalid legacy root");
12080
12081        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12082
12083        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12084        assert!(report.error.as_deref().is_some_and(|error| {
12085            error.contains("legacy state root") && error.contains("not a directory")
12086        }));
12087    }
12088
12089    #[test]
12090    fn doctor_session_recovery_rejects_a_non_directory_primary_state_root() {
12091        let tmp = TempDir::new().expect("tempdir");
12092        let (primary_root, legacy_root) = roots(&tmp);
12093        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12094        fs::write(&primary_root, b"not a state directory").expect("invalid primary root");
12095
12096        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12097
12098        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12099        assert!(report.error.as_deref().is_some_and(|error| {
12100            error.contains("primary state root") && error.contains("not a directory")
12101        }));
12102    }
12103
12104    #[test]
12105    fn doctor_session_recovery_rejects_a_non_directory_primary_sessions_root() {
12106        let tmp = TempDir::new().expect("tempdir");
12107        let (primary_root, legacy_root) = roots(&tmp);
12108        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12109        fs::create_dir_all(&primary_root).expect("primary root");
12110        fs::write(primary_root.join("sessions"), b"not a sessions directory")
12111            .expect("invalid primary sessions path");
12112
12113        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12114
12115        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12116        assert!(report.error.as_deref().is_some_and(|error| {
12117            error.contains("primary sessions root") && error.contains("not a directory")
12118        }));
12119    }
12120
12121    #[cfg(unix)]
12122    #[test]
12123    fn doctor_session_recovery_rejects_a_symlinked_legacy_sessions_root() {
12124        use std::os::unix::fs::symlink;
12125
12126        let tmp = TempDir::new().expect("tempdir");
12127        let (primary_root, legacy_root) = roots(&tmp);
12128        let external_sessions = tmp.path().join("external-sessions");
12129        fs::create_dir_all(&external_sessions).expect("external sessions");
12130        fs::write(
12131            external_sessions.join("must-not-be-enumerated.json"),
12132            b"session contents must stay unread",
12133        )
12134        .expect("external session fixture");
12135        fs::create_dir_all(&legacy_root).expect("legacy root");
12136        symlink(&external_sessions, legacy_root.join("sessions"))
12137            .expect("symlinked legacy sessions root");
12138
12139        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12140
12141        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12142        assert!(report.needs_attention());
12143        assert_eq!(report.legacy_session_file_count, 0);
12144        assert!(report.recoverable.is_empty());
12145        assert!(
12146            report
12147                .error
12148                .as_deref()
12149                .is_some_and(|error| error.contains("legacy sessions root")
12150                    && error.contains("path is a symlink"))
12151        );
12152    }
12153
12154    #[cfg(unix)]
12155    #[test]
12156    fn doctor_session_recovery_rejects_symlinked_primary_root_and_sessions_root() {
12157        use std::os::unix::fs::symlink;
12158
12159        let tmp = TempDir::new().expect("tempdir");
12160        let (primary_root, legacy_root) = roots(&tmp);
12161        let external_primary = tmp.path().join("external-primary");
12162        fs::create_dir_all(external_primary.join("sessions")).expect("external primary");
12163        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12164        symlink(&external_primary, &primary_root).expect("symlinked primary root");
12165
12166        let root_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12167        assert_eq!(root_report.status, DoctorSessionRecoveryStatus::ScanFailed);
12168        assert!(root_report.error.as_deref().is_some_and(|error| {
12169            error.contains("primary state root") && error.contains("path is a symlink")
12170        }));
12171
12172        fs::remove_file(&primary_root).expect("remove primary root symlink");
12173        fs::create_dir_all(&primary_root).expect("primary root");
12174        symlink(&external_primary, primary_root.join("sessions"))
12175            .expect("symlinked primary sessions root");
12176
12177        let sessions_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12178        assert_eq!(
12179            sessions_report.status,
12180            DoctorSessionRecoveryStatus::ScanFailed
12181        );
12182        assert!(sessions_report.error.as_deref().is_some_and(|error| {
12183            error.contains("primary sessions root") && error.contains("path is a symlink")
12184        }));
12185    }
12186
12187    #[test]
12188    fn explicit_codewhale_home_skips_session_recovery_scan() {
12189        let tmp = TempDir::new().expect("tempdir");
12190        let (primary_root, legacy_root) = roots(&tmp);
12191        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12192        fs::write(legacy_root.join("sessions").join("ambient.json"), b"legacy")
12193            .expect("legacy session");
12194
12195        let report = doctor_session_recovery_report(&primary_root, &legacy_root, true);
12196
12197        assert_eq!(report.status, DoctorSessionRecoveryStatus::Isolated);
12198        assert!(report.codewhale_home_is_explicit);
12199        assert_eq!(report.legacy_session_file_count, 0);
12200        assert_eq!(report.recoverable_file_count, 0);
12201        assert!(report.recoverable.is_empty());
12202        assert!(!report.needs_attention());
12203    }
12204
12205    #[test]
12206    fn doctor_state_roots_ignore_ambient_legacy_home_when_codewhale_home_is_explicit() {
12207        let _env_lock = crate::test_support::lock_test_env();
12208        let tmp = TempDir::new().expect("tempdir");
12209        let explicit_home = tmp.path().join("isolated-codewhale");
12210        let ambient_legacy = tmp.path().join(".deepseek");
12211        fs::create_dir_all(&ambient_legacy).expect("ambient legacy root");
12212        fs::write(
12213            ambient_legacy.join("config.toml"),
12214            "provider = 'deepseek'\n",
12215        )
12216        .expect("ambient legacy config");
12217        let _home = EnvVarRestore::set("HOME", tmp.path());
12218        let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
12219
12220        let (primary_root, legacy_root) = doctor_state_roots();
12221        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12222        let session_recovery = doctor_session_recovery_report(
12223            &primary_root,
12224            &legacy_root,
12225            codewhale_config::codewhale_home_is_explicit(),
12226        );
12227
12228        assert_eq!(primary_root, explicit_home);
12229        assert_eq!(
12230            legacy_root,
12231            primary_root.join(codewhale_config::LEGACY_APP_DIR)
12232        );
12233        assert!(
12234            report
12235                .iter()
12236                .all(|entry| entry.status == DoctorLegacyStateStatus::Absent),
12237            "doctor must not report ambient legacy state when CODEWHALE_HOME is explicit"
12238        );
12239        assert!(!report.iter().any(legacy_state_needs_attention));
12240        assert_eq!(
12241            session_recovery.status,
12242            DoctorSessionRecoveryStatus::Isolated
12243        );
12244        assert!(session_recovery.recoverable.is_empty());
12245    }
12246}
12247
12248#[cfg(test)]
12249mod doctor_setup_state_tests {
12250    use super::*;
12251    use std::fs;
12252    use tempfile::TempDir;
12253
12254    fn prepare_env(tmp: &TempDir) -> (crate::test_support::EnvVarGuard, PathBuf) {
12255        let codewhale_home = tmp.path().join(".codewhale");
12256        fs::create_dir_all(&codewhale_home).expect("codewhale home");
12257        (
12258            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str()),
12259            codewhale_home,
12260        )
12261    }
12262
12263    fn provider_step(report: &serde_json::Value) -> &serde_json::Value {
12264        report["steps"]
12265            .as_array()
12266            .expect("steps array")
12267            .iter()
12268            .find(|step| step["step"] == "provider_model")
12269            .expect("provider/model step")
12270    }
12271
12272    #[test]
12273    fn doctor_setup_consistency_flags_missing_user_constitution() {
12274        let _guard = crate::test_support::lock_test_env();
12275        let tmp = TempDir::new().expect("tempdir");
12276        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12277        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12278        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12279        let workspace = tmp.path().join("workspace");
12280        fs::create_dir_all(&workspace).expect("workspace");
12281
12282        let state = codewhale_config::SetupState {
12283            constitution_source: codewhale_config::ConstitutionSource::UserGlobal,
12284            ..Default::default()
12285        };
12286        state.save().expect("persist setup state");
12287
12288        let report = doctor_setup_report_json(&Config::default(), &workspace);
12289
12290        assert_eq!(report["source"], "persisted");
12291        assert_eq!(report["consistency"]["status"], "inconsistent");
12292        let issues = report["consistency"]["issues"].to_string();
12293        assert!(
12294            issues.contains("setup_state_points_at_missing_user_constitution"),
12295            "{issues}"
12296        );
12297    }
12298
12299    #[test]
12300    fn doctor_setup_consistency_flags_stale_temp_files() {
12301        let _guard = crate::test_support::lock_test_env();
12302        let tmp = TempDir::new().expect("tempdir");
12303        let (_home_guard, codewhale_home) = prepare_env(&tmp);
12304        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12305        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12306        let workspace = tmp.path().join("workspace");
12307        fs::create_dir_all(&workspace).expect("workspace");
12308        fs::write(codewhale_home.join(".tmpAbC123"), b"orphaned atomic write")
12309            .expect("stale temp file");
12310
12311        let report = doctor_setup_report_json(&Config::default(), &workspace);
12312
12313        assert_eq!(report["consistency"]["status"], "inconsistent");
12314        let issues = report["consistency"]["issues"].to_string();
12315        assert!(
12316            issues.contains("stale_setup_temp_files_in_codewhale_home"),
12317            "{issues}"
12318        );
12319    }
12320
12321    #[test]
12322    fn doctor_setup_consistency_reports_consistent_for_clean_home() {
12323        let _guard = crate::test_support::lock_test_env();
12324        let tmp = TempDir::new().expect("tempdir");
12325        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12326        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12327        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12328        let workspace = tmp.path().join("workspace");
12329        fs::create_dir_all(&workspace).expect("workspace");
12330
12331        let report = doctor_setup_report_json(&Config::default(), &workspace);
12332
12333        assert_eq!(report["consistency"]["status"], "consistent");
12334        assert_eq!(
12335            report["consistency"]["issues"]
12336                .as_array()
12337                .map(Vec::len)
12338                .unwrap_or_default(),
12339            0
12340        );
12341    }
12342
12343    #[test]
12344    fn doctor_setup_report_json_derives_state_without_sidecar() {
12345        let _guard = crate::test_support::lock_test_env();
12346        let tmp = TempDir::new().expect("tempdir");
12347        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12348        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12349        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12350        let workspace = tmp.path().join("workspace");
12351        fs::create_dir_all(&workspace).expect("workspace");
12352
12353        let report = doctor_setup_report_json(&Config::default(), &workspace);
12354
12355        assert_eq!(report["source"], "derived");
12356        assert_eq!(report["inherited"], true);
12357        assert_eq!(report["next_actions"]["constitution"], "/constitution");
12358        assert_eq!(report["next_actions"]["setup_report"], "/setup report");
12359        assert_eq!(
12360            report["next_actions"]["provider_model"],
12361            "/setup provider, /provider setup <name>, or /model"
12362        );
12363        assert_eq!(report["next_actions"]["runtime_posture"], "/config");
12364        assert_eq!(
12365            report["next_actions"]["operate_fleet"],
12366            "/setup fleet (readiness), /fleet setup (explicit profile authoring)"
12367        );
12368        assert_eq!(report["next_actions"]["hotbar"], "/setup hotbar");
12369        assert_eq!(report["next_actions"]["tools_mcp"], "/setup tools");
12370        assert_eq!(report["next_actions"]["remote_runtime"], "/setup remote");
12371        assert_eq!(report["next_actions"]["persistence"], "/setup persistence");
12372        assert_eq!(
12373            report["checkpoint_version"],
12374            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12375        );
12376        assert_eq!(report["update_ready"], false);
12377        assert_eq!(report["operate_ready"], false);
12378        assert_eq!(
12379            report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12380            false
12381        );
12382        assert_eq!(
12383            report["operate_fleet"]["roster"]["readiness_rule"],
12384            "built-in starter roster or custom roster"
12385        );
12386        assert_eq!(report["provider_model"]["provider"]["id"], "deepseek");
12387        assert_eq!(report["provider_model"]["provider"]["display"], "DeepSeek");
12388        assert_eq!(
12389            report["provider_model"]["model"]["resolved"],
12390            crate::config::DEFAULT_TEXT_MODEL
12391        );
12392        assert_eq!(
12393            report["provider_model"]["auth"]["source"],
12394            "secret_store_unprobed"
12395        );
12396        assert_eq!(
12397            report["provider_model"]["auth"]["availability"],
12398            "not_probed"
12399        );
12400        assert_eq!(
12401            report["provider_model"]["auth"]["credential_url"],
12402            "https://platform.deepseek.com"
12403        );
12404        assert_eq!(
12405            report["provider_model"]["auth"]["credential_mode"],
12406            "api_key"
12407        );
12408        assert_eq!(
12409            report["provider_model"]["auth"]["env_vars"][0],
12410            "DEEPSEEK_API_KEY"
12411        );
12412        assert_eq!(report["provider_model"]["health"]["live_validation"], false);
12413        assert_eq!(report["constitution"]["source"], "bundled");
12414        assert_eq!(report["constitution"]["autonomy_preference"], "unspecified");
12415        assert_eq!(report["runtime_posture"]["source"], "unset");
12416        assert_eq!(report["runtime_posture"]["default_mode"]["value"], "agent");
12417        assert_eq!(
12418            report["runtime_posture"]["approval_policy"]["value"],
12419            "on-request"
12420        );
12421        assert_eq!(report["runtime_posture"]["allow_shell"]["value"], true);
12422        assert_eq!(
12423            report["runtime_posture"]["sandbox_mode"]["value"],
12424            "mode-derived"
12425        );
12426        assert_eq!(
12427            report["runtime_posture"]["network_default"]["value"],
12428            "prompt"
12429        );
12430        assert_eq!(provider_step(&report)["status"], "needs_action");
12431    }
12432
12433    #[test]
12434    fn doctor_setup_provider_model_json_covers_cn_codex_and_local_matrix() {
12435        let _guard = crate::test_support::lock_test_env();
12436        let tmp = TempDir::new().expect("tempdir");
12437        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12438        let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
12439        let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
12440        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12441        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12442        let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
12443        let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
12444        let codex_auth_path = tmp.path().join("external-codex-auth.json");
12445        let codex_auth_raw = serde_json::json!({
12446            "tokens": {
12447                "access_token": crate::test_support::future_test_jwt("doctor"),
12448                "account_id": "acct-doctor-read-only",
12449                "refresh_token": "must-never-be-used",
12450                "unknown": {"preserve": true}
12451            }
12452        })
12453        .to_string();
12454        fs::write(&codex_auth_path, &codex_auth_raw).expect("Codex auth trap fixture");
12455        let _codex_auth =
12456            crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_auth_path);
12457        let workspace = tmp.path().join("workspace");
12458        fs::create_dir_all(&workspace).expect("workspace");
12459
12460        let cn_config = Config {
12461            provider: Some("deepseek-cn".to_string()),
12462            ..Config::default()
12463        };
12464        let cn_report = doctor_setup_report_json(&cn_config, &workspace);
12465        assert_eq!(cn_report["provider_model"]["provider"]["id"], "deepseek-cn");
12466        assert_eq!(
12467            cn_report["provider_model"]["provider"]["display"],
12468            "DeepSeek (legacy alias)"
12469        );
12470        assert_eq!(
12471            cn_report["provider_model"]["auth"]["env_vars"][0],
12472            "DEEPSEEK_API_KEY"
12473        );
12474        assert_eq!(
12475            cn_report["provider_model"]["auth"]["credential_url"],
12476            "https://platform.deepseek.com"
12477        );
12478        assert_eq!(cn_report["provider_model"]["auth"]["oauth_only"], false);
12479        assert_eq!(
12480            cn_report["provider_model"]["health"]["live_validation"],
12481            false
12482        );
12483
12484        let codex_config = Config {
12485            provider: Some("openai-codex".to_string()),
12486            ..Config::default()
12487        };
12488        crate::external_credentials::reset_side_effect_trap();
12489        let codex_report = doctor_setup_report_json(&codex_config, &workspace);
12490        assert_eq!(
12491            codex_report["provider_model"]["provider"]["id"],
12492            crate::config::ApiProvider::OpenaiCodex.as_str()
12493        );
12494        assert!(codex_report["provider_model"]["auth"]["credential_url"].is_null());
12495        assert_eq!(
12496            codex_report["provider_model"]["auth"]["credential_mode"],
12497            "oauth"
12498        );
12499        assert_eq!(codex_report["provider_model"]["auth"]["oauth_only"], true);
12500        assert_eq!(
12501            codex_report["provider_model"]["health"]["next_action"],
12502            "/setup provider or /provider setup <name>"
12503        );
12504        assert_eq!(
12505            crate::external_credentials::side_effect_trap_counts(),
12506            (0, 0),
12507            "doctor must not stat or read external credentials without consent"
12508        );
12509
12510        let mut consent = codewhale_config::ExternalCredentialConsentToml::read_only(
12511            codewhale_config::ProviderKind::OpenaiCodex,
12512            codewhale_config::ExternalCredentialSource::CodexCli,
12513            codex_auth_path.clone(),
12514        );
12515        let codex_read_only = Config {
12516            provider: Some("openai-codex".to_string()),
12517            providers: Some(crate::config::ProvidersConfig {
12518                openai_codex: crate::config::ProviderConfig {
12519                    auth_mode: Some("oauth".to_string()),
12520                    external_credentials: Some(consent.clone()),
12521                    ..Default::default()
12522                },
12523                ..Default::default()
12524            }),
12525            ..Config::default()
12526        };
12527        let changed_ambient_path = tmp.path().join("new-ambient-codex-auth.json");
12528        let _changed_codex_auth =
12529            crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &changed_ambient_path);
12530        crate::external_credentials::reset_side_effect_trap();
12531        let codex_read_only_report = doctor_setup_report_json(&codex_read_only, &workspace);
12532        assert_eq!(
12533            codex_read_only_report["provider_model"]["auth"]["present_or_local"],
12534            false
12535        );
12536        assert_eq!(
12537            codex_read_only_report["provider_model"]["auth"]["source"],
12538            "external_consent"
12539        );
12540        let status_json = doctor_external_credential_consent_json(&codex_read_only);
12541        let codex_status = status_json
12542            .as_array()
12543            .and_then(|rows| rows.first())
12544            .expect("Codex structural status");
12545        assert_eq!(codex_status["access"], "read_only");
12546        assert_eq!(codex_status["provider"], "openai-codex");
12547        assert_eq!(codex_status["source"], "codex_cli");
12548        assert_eq!(codex_status["route_state"], "active");
12549        assert_eq!(codex_status["ambient_path_changed"], true);
12550        assert!(
12551            codex_status["ambient_path_warning"]
12552                .as_str()
12553                .is_some_and(|warning| warning.contains("remains pinned"))
12554        );
12555        assert_eq!(
12556            codex_status["revoke_command"],
12557            "codewhale auth external-revoke --provider openai-codex"
12558        );
12559        let human = doctor_external_credential_consent_lines(&codex_read_only).join("\n");
12560        assert!(human.contains("path="), "{human}");
12561        assert!(human.contains("version=1"), "{human}");
12562        assert!(human.contains("no refresh, identity-provider or discovery requests"));
12563        assert!(human.contains("normal requests to the explicitly selected provider"));
12564        assert!(human.contains("consent remains pinned"), "{human}");
12565        assert!(
12566            human.contains(&codewhale_config::quote_os_path(&codex_auth_path)),
12567            "{human}"
12568        );
12569        assert!(!human.contains(&changed_ambient_path.display().to_string()));
12570        assert_eq!(
12571            crate::external_credentials::complete_side_effect_trap_counts(),
12572            (0, 0, 0, 0, 0),
12573            "doctor consent status is structural and must not inspect the file"
12574        );
12575        assert_eq!(
12576            fs::read_to_string(&codex_auth_path).expect("unchanged Codex auth fixture"),
12577            codex_auth_raw
12578        );
12579
12580        consent.access = codewhale_config::ExternalCredentialAccess::Managed;
12581        let codex_managed = Config {
12582            provider: Some("openai-codex".to_string()),
12583            providers: Some(crate::config::ProvidersConfig {
12584                openai_codex: crate::config::ProviderConfig {
12585                    auth_mode: Some("oauth".to_string()),
12586                    external_credentials: Some(consent),
12587                    ..Default::default()
12588                },
12589                ..Default::default()
12590            }),
12591            ..Config::default()
12592        };
12593        crate::external_credentials::reset_side_effect_trap();
12594        let codex_managed_report = doctor_setup_report_json(&codex_managed, &workspace);
12595        assert_eq!(
12596            codex_managed_report["provider_model"]["auth"]["present_or_local"],
12597            false
12598        );
12599        assert_eq!(
12600            crate::external_credentials::side_effect_trap_counts(),
12601            (0, 0),
12602            "unsupported managed mode must fail before external I/O"
12603        );
12604        assert_eq!(
12605            fs::read_to_string(&codex_auth_path).expect("unchanged managed auth fixture"),
12606            codex_auth_raw
12607        );
12608
12609        let local_config = Config {
12610            provider: Some("ollama".to_string()),
12611            ..Config::default()
12612        };
12613        let local_report = doctor_setup_report_json(&local_config, &workspace);
12614        assert_eq!(local_report["provider_model"]["provider"]["id"], "ollama");
12615        assert_eq!(
12616            local_report["provider_model"]["auth"]["present_or_local"],
12617            true
12618        );
12619        assert!(local_report["provider_model"]["auth"]["credential_url"].is_null());
12620        assert_eq!(
12621            local_report["provider_model"]["auth"]["credential_mode"],
12622            "local_optional"
12623        );
12624        assert_eq!(local_report["provider_model"]["auth"]["oauth_only"], false);
12625        assert_eq!(
12626            local_report["provider_model"]["health"]["next_action"],
12627            "/model"
12628        );
12629
12630        let kimi_config = Config {
12631            provider: Some("moonshot".to_string()),
12632            ..Config::default()
12633        };
12634        let kimi_report = doctor_setup_report_json(&kimi_config, &workspace);
12635        assert_eq!(
12636            kimi_report["provider_model"]["auth"]["credential_url"],
12637            "https://platform.kimi.ai"
12638        );
12639        assert_eq!(
12640            kimi_report["provider_model"]["auth"]["credential_docs_url"],
12641            "https://platform.kimi.ai"
12642        );
12643        assert_eq!(
12644            kimi_report["provider_model"]["auth"]["credential_mode"],
12645            "api_key"
12646        );
12647        assert!(
12648            kimi_report["provider_model"]["auth"]["credential_guidance"]
12649                .as_str()
12650                .is_some_and(|guidance| guidance.contains("OAuth is not available"))
12651        );
12652    }
12653
12654    #[test]
12655    fn doctor_setup_report_json_uses_persisted_state() {
12656        let _guard = crate::test_support::lock_test_env();
12657        let tmp = TempDir::new().expect("tempdir");
12658        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12659        let workspace = tmp.path().join("workspace");
12660        fs::create_dir_all(&workspace).expect("workspace");
12661        let mut state = codewhale_config::SetupState::default();
12662        state.set_step(
12663            codewhale_config::SetupStep::Language,
12664            codewhale_config::StepEntry::new(
12665                codewhale_config::StepStatus::Verified,
12666                true,
12667                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12668            ),
12669        );
12670        state.set_step(
12671            codewhale_config::SetupStep::ProviderModel,
12672            codewhale_config::StepEntry::new(
12673                codewhale_config::StepStatus::Verified,
12674                true,
12675                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12676            )
12677            .with_result("deepseek/deepseek-chat"),
12678        );
12679        state.set_step(
12680            codewhale_config::SetupStep::TrustSandbox,
12681            codewhale_config::StepEntry::new(
12682                codewhale_config::StepStatus::Verified,
12683                true,
12684                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12685            ),
12686        );
12687        state
12688            .complete_constitution_checkpoint(
12689                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12690                codewhale_config::ConstitutionChoice::Bundled,
12691            )
12692            .set_step(
12693                codewhale_config::SetupStep::Constitution,
12694                codewhale_config::StepEntry::new(
12695                    codewhale_config::StepStatus::Verified,
12696                    true,
12697                    crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12698                ),
12699            );
12700        state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
12701        state.save().expect("persist setup state");
12702        codewhale_config::UserConstitution {
12703            autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
12704            ..Default::default()
12705        }
12706        .save()
12707        .expect("persist user constitution");
12708        let config = Config {
12709            api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
12710            approval_policy: Some("never".to_string()),
12711            allow_shell: Some(false),
12712            sandbox_mode: Some("read-only".to_string()),
12713            network: Some(crate::config::NetworkPolicyToml {
12714                default: "deny".to_string(),
12715                ..Default::default()
12716            }),
12717            ..Config::default()
12718        };
12719
12720        let report = doctor_setup_report_json(&config, &workspace);
12721
12722        assert_eq!(report["source"], "persisted");
12723        assert_eq!(report["first_run_ready"], true);
12724        assert_eq!(report["update_ready"], true);
12725        assert_eq!(report["operate_ready"], false);
12726        assert_eq!(report["constitution"]["choice"], "bundled");
12727        assert_eq!(
12728            report["constitution"]["checkpoint_completed_for"],
12729            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12730        );
12731        assert_eq!(report["constitution"]["autonomy_preference"], "balanced");
12732        assert_eq!(report["runtime_posture_source"], "confirmed");
12733        assert_eq!(report["runtime_posture"]["source"], "confirmed");
12734        assert_eq!(
12735            report["runtime_posture"]["approval_policy"]["value"],
12736            "never"
12737        );
12738        assert_eq!(
12739            report["runtime_posture"]["approval_policy"]["source"],
12740            "config"
12741        );
12742        assert_eq!(report["runtime_posture"]["allow_shell"]["value"], false);
12743        assert_eq!(report["runtime_posture"]["allow_shell"]["source"], "config");
12744        assert_eq!(
12745            report["runtime_posture"]["sandbox_mode"]["value"],
12746            "read-only"
12747        );
12748        assert_eq!(
12749            report["runtime_posture"]["sandbox_mode"]["source"],
12750            "config"
12751        );
12752        assert_eq!(
12753            report["runtime_posture"]["network_default"]["value"],
12754            "deny"
12755        );
12756        assert_eq!(
12757            report["runtime_posture"]["network_default"]["source"],
12758            "config"
12759        );
12760        assert_eq!(provider_step(&report)["result"], "deepseek/deepseek-chat");
12761    }
12762
12763    #[test]
12764    fn doctor_reports_settings_permission_posture_when_approval_policy_unset() {
12765        let _guard = crate::test_support::lock_test_env();
12766        let tmp = TempDir::new().expect("tempdir");
12767        let (_home_guard, codewhale_home) = prepare_env(&tmp);
12768        let workspace = tmp.path().join("workspace");
12769        fs::create_dir_all(&workspace).expect("workspace");
12770        fs::write(
12771            codewhale_home.join("settings.toml"),
12772            "permission_posture = \"full-access\"\n",
12773        )
12774        .expect("write settings.toml");
12775
12776        let config = Config::default();
12777        assert!(config.approval_policy.is_none());
12778
12779        let line = doctor_runtime_posture_line(&config, &workspace);
12780        assert!(
12781            line.contains("permission_posture=full-access (settings)"),
12782            "text doctor should report saved settings posture: {line}"
12783        );
12784        assert!(
12785            line.contains("approval_policy=on-request (default)"),
12786            "text doctor should keep unset config approval_policy default: {line}"
12787        );
12788
12789        let report = doctor_setup_report_json(&config, &workspace);
12790        assert_eq!(
12791            report["runtime_posture"]["permission_posture"]["value"],
12792            "full-access"
12793        );
12794        assert_eq!(
12795            report["runtime_posture"]["permission_posture"]["source"],
12796            "settings"
12797        );
12798        assert_eq!(
12799            report["runtime_posture"]["approval_policy"]["value"],
12800            "on-request"
12801        );
12802        assert_eq!(
12803            report["runtime_posture"]["approval_policy"]["source"],
12804            "default"
12805        );
12806    }
12807
12808    #[test]
12809    fn doctor_setup_report_json_fails_closed_without_operate_receipts() {
12810        let _guard = crate::test_support::lock_test_env();
12811        let tmp = TempDir::new().expect("tempdir");
12812        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12813        let workspace = tmp.path().join("workspace");
12814        fs::create_dir_all(&workspace).expect("workspace");
12815        let mut state = codewhale_config::SetupState::default();
12816        state.set_step(
12817            codewhale_config::SetupStep::Language,
12818            codewhale_config::StepEntry::new(
12819                codewhale_config::StepStatus::Verified,
12820                true,
12821                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12822            ),
12823        );
12824        state.set_step(
12825            codewhale_config::SetupStep::ProviderModel,
12826            codewhale_config::StepEntry::new(
12827                codewhale_config::StepStatus::Verified,
12828                true,
12829                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12830            ),
12831        );
12832        state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
12833        state.complete_constitution_checkpoint(
12834            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12835            codewhale_config::ConstitutionChoice::Bundled,
12836        );
12837        state.set_step(
12838            codewhale_config::SetupStep::OperateFleet,
12839            codewhale_config::StepEntry::new(
12840                codewhale_config::StepStatus::Verified,
12841                false,
12842                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
12843            )
12844            .with_result(
12845                "provider=ready, runtime=ready, roster=ready, concurrency=plan limit not probed",
12846            ),
12847        );
12848        state.save().expect("persist setup state");
12849
12850        let config = Config {
12851            api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
12852            ..Config::default()
12853        };
12854        let report = doctor_setup_report_json(&config, &workspace);
12855
12856        assert_eq!(report["first_run_ready"], true);
12857        assert_eq!(report["operate_ready"], false);
12858        assert_eq!(
12859            report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12860            false
12861        );
12862        assert!(
12863            report["operate_fleet"]["roster"]["built_in"]
12864                .as_u64()
12865                .is_some_and(|count| count > 0)
12866        );
12867        let operate_step = report["steps"]
12868            .as_array()
12869            .expect("steps array")
12870            .iter()
12871            .find(|step| step["step"] == "operate_fleet")
12872            .expect("operate/fleet step");
12873        assert_eq!(operate_step["status"], "verified");
12874        assert!(
12875            operate_step["result"]
12876                .as_str()
12877                .is_some_and(|result| result.contains("plan limit not probed"))
12878        );
12879    }
12880}
12881
12882#[cfg(test)]
12883mod doctor_endpoint_tests {
12884    use super::*;
12885
12886    #[test]
12887    fn doctor_api_target_reports_default_endpoint() {
12888        let config = Config::default();
12889
12890        let target = doctor_api_target(&config);
12891
12892        assert_eq!(target.provider, "deepseek");
12893        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
12894        assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
12895        assert_eq!(target.resolution, DoctorModelResolution::Resolved);
12896    }
12897
12898    #[test]
12899    fn doctor_api_target_falls_back_to_configured_model_when_resolution_fails() {
12900        // `custom` with no custom provider table cannot resolve an identity;
12901        // doctor must fall back to the raw configured model and say so
12902        // instead of presenting an unresolved value as the engine's route.
12903        let config = Config {
12904            provider: Some("custom".to_string()),
12905            ..Default::default()
12906        };
12907
12908        let target = doctor_api_target(&config);
12909
12910        assert_eq!(target.resolution, DoctorModelResolution::ConfiguredOnly);
12911        assert_eq!(target.model, config.default_model());
12912    }
12913
12914    #[test]
12915    fn doctor_api_target_routes_deepseek_cn_alias_to_beta_endpoint() {
12916        let config = Config {
12917            provider: Some("deepseek-cn".to_string()),
12918            ..Default::default()
12919        };
12920
12921        let target = doctor_api_target(&config);
12922
12923        assert_eq!(target.provider, "deepseek-cn");
12924        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
12925        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
12926        assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
12927        assert_eq!(target.resolution, DoctorModelResolution::Resolved);
12928    }
12929
12930    #[test]
12931    fn strict_tool_mode_doctor_reports_disabled_by_default() {
12932        let config = Config::default();
12933
12934        let status = doctor_strict_tool_mode_status(&config);
12935
12936        assert!(!status.enabled);
12937        assert_eq!(status.status, "disabled");
12938        assert!(!status.function_strict_sent);
12939        assert!(status.recommended_base_url.is_none());
12940    }
12941
12942    #[test]
12943    fn doctor_known_base_urls_are_ascii_case_insensitive() {
12944        assert!(doctor_xiaomi_mimo_base_url_uses_token_plan(
12945            "HTTPS://TOKEN-PLAN-CN.XIAOMIMIMO.COM/V1/"
12946        ));
12947        assert_eq!(
12948            known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/BETA/"),
12949            Some(DeepSeekBaseUrlKind::Beta)
12950        );
12951        assert_eq!(
12952            known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/V1/"),
12953            Some(DeepSeekBaseUrlKind::NonBeta)
12954        );
12955    }
12956
12957    #[test]
12958    fn strict_tool_mode_doctor_accepts_default_beta_endpoint() {
12959        let config = Config {
12960            strict_tool_mode: Some(true),
12961            ..Default::default()
12962        };
12963
12964        let status = doctor_strict_tool_mode_status(&config);
12965
12966        assert!(status.enabled);
12967        assert_eq!(status.status, "ready");
12968        assert!(status.function_strict_sent);
12969        assert!(status.message.contains("beta endpoint"));
12970        assert!(status.recommended_base_url.is_none());
12971    }
12972
12973    #[test]
12974    fn strict_tool_mode_doctor_warns_for_non_beta_deepseek_endpoint() {
12975        let config = Config {
12976            strict_tool_mode: Some(true),
12977            base_url: Some("https://api.deepseek.com".to_string()),
12978            ..Default::default()
12979        };
12980
12981        let status = doctor_strict_tool_mode_status(&config);
12982
12983        assert_eq!(status.status, "fallback_non_beta");
12984        assert!(!status.function_strict_sent);
12985        assert_eq!(
12986            status.recommended_base_url.as_deref(),
12987            Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL)
12988        );
12989        assert_eq!(
12990            doctor_strict_tool_mode_report_json(&status)["recommended_base_url"],
12991            "https://api.deepseek.com"
12992        );
12993    }
12994
12995    #[test]
12996    fn strict_tool_mode_doctor_accepts_deepseek_cn_alias_default_endpoint() {
12997        let config = Config {
12998            provider: Some("deepseek-cn".to_string()),
12999            strict_tool_mode: Some(true),
13000            ..Default::default()
13001        };
13002
13003        let status = doctor_strict_tool_mode_status(&config);
13004
13005        assert_eq!(status.status, "ready");
13006        assert!(status.function_strict_sent);
13007        assert!(status.message.contains("beta endpoint"));
13008        assert!(status.recommended_base_url.is_none());
13009    }
13010
13011    #[test]
13012    fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() {
13013        let config = Config {
13014            provider: Some("vllm".to_string()),
13015            strict_tool_mode: Some(true),
13016            ..Default::default()
13017        };
13018
13019        let status = doctor_strict_tool_mode_status(&config);
13020
13021        assert_eq!(status.status, "custom_endpoint");
13022        assert!(status.function_strict_sent);
13023        assert!(status.message.contains("custom endpoint"));
13024    }
13025
13026    #[test]
13027    fn doctor_tls_status_reports_verification_enabled_by_default() {
13028        let status = doctor_tls_status(&Config::default());
13029
13030        assert!(status.certificate_verification);
13031        assert!(!status.insecure_skip_tls_verify);
13032        assert_eq!(status.provider, "deepseek");
13033        assert!(status.message.contains("enabled"));
13034    }
13035
13036    #[test]
13037    fn doctor_tls_status_warns_when_active_provider_skips_verification() {
13038        let mut providers = crate::config::ProvidersConfig::default();
13039        providers.openai.insecure_skip_tls_verify = Some(true);
13040        let config = Config {
13041            provider: Some("openai".to_string()),
13042            providers: Some(providers),
13043            ..Default::default()
13044        };
13045
13046        let status = doctor_tls_status(&config);
13047
13048        assert!(status.certificate_verification);
13049        assert!(status.insecure_skip_tls_verify);
13050        assert_eq!(status.provider, "openai");
13051        assert!(status.message.contains("cannot be disabled"));
13052        assert!(status.message.contains("SSL_CERT_FILE"));
13053    }
13054
13055    #[test]
13056    fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
13057        let mut config = Config {
13058            default_text_model: Some("deepseek-chat".to_string()),
13059            ..Default::default()
13060        };
13061        crate::config::normalize_model_config_for_test(&mut config);
13062
13063        let report = provider_capability_report(&config);
13064
13065        assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13066        assert_eq!(report["context_window"], 1_000_000);
13067        assert_eq!(report["thinking_supported"], true);
13068        assert_eq!(report["alias_deprecation"]["alias"], "deepseek-chat");
13069        assert_eq!(
13070            report["alias_deprecation"]["replacement"],
13071            "deepseek-v4-flash"
13072        );
13073        assert_eq!(
13074            report["alias_deprecation"]["retirement_utc"],
13075            "2026-07-24T15:59:00Z"
13076        );
13077    }
13078
13079    #[test]
13080    fn provider_capability_report_preserves_custom_deepseek_alias_namespace() {
13081        let mut config = Config {
13082            base_url: Some("https://models.example/v1".to_string()),
13083            default_text_model: Some("deepseek-chat".to_string()),
13084            ..Default::default()
13085        };
13086        crate::config::normalize_model_config_for_test(&mut config);
13087
13088        let report = provider_capability_report(&config);
13089
13090        assert_eq!(report["resolved_model"], "deepseek-chat");
13091        assert!(report["alias_deprecation"].is_null());
13092    }
13093
13094    #[test]
13095    fn provider_capability_report_leaves_canonical_flash_alias_metadata_null() {
13096        let config = Config {
13097            default_text_model: Some("deepseek-v4-flash".to_string()),
13098            ..Default::default()
13099        };
13100
13101        let report = provider_capability_report(&config);
13102
13103        assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13104        assert!(report["alias_deprecation"].is_null());
13105    }
13106
13107    #[test]
13108    fn doctor_route_report_exposes_tokenhub_openai_compatible_route_without_secret() {
13109        let mut providers = crate::config::ProvidersConfig::default();
13110        providers.openai.api_key = Some("tokenhub-secret-value".to_string());
13111        providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
13112        providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
13113        let config = Config {
13114            provider: Some("openai".to_string()),
13115            providers: Some(providers),
13116            ..Default::default()
13117        };
13118
13119        let report = doctor_route_report(&config);
13120        let serialized = report.to_string();
13121
13122        assert_eq!(report["provider"], "openai");
13123        assert_eq!(report["provider_source"], "config");
13124        assert_eq!(report["provider_config_table"], "openai");
13125        assert_eq!(report["model"], "deepseek-ai/DeepSeek-V4-Pro");
13126        assert_eq!(report["wire_protocol"], "chat_completions");
13127        assert_eq!(
13128            report["base_url"]["redacted"],
13129            "https://tokenhub.tencentmaas.com"
13130        );
13131        assert_eq!(report["base_url"]["class"], "custom");
13132        assert_eq!(report["auth"]["scheme"], "bearer");
13133        assert_eq!(report["auth"]["source"], "config_declared");
13134        assert!(
13135            report["base_url"]["fingerprint"]
13136                .as_str()
13137                .is_some_and(|value| value.starts_with("<redacted:"))
13138        );
13139        assert!(!serialized.contains("tokenhub-secret-value"));
13140    }
13141
13142    #[test]
13143    fn doctor_route_report_exposes_siliconflow_cn_provider_route() {
13144        let mut providers = crate::config::ProvidersConfig::default();
13145        providers.siliconflow_cn.api_key = Some("sf-cn-secret-value".to_string());
13146        providers.siliconflow_cn.base_url =
13147            Some(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
13148        providers.siliconflow_cn.model = Some(crate::config::DEFAULT_SILICONFLOW_MODEL.to_string());
13149        let config = Config {
13150            provider: Some("siliconflow-CN".to_string()),
13151            providers: Some(providers),
13152            ..Default::default()
13153        };
13154
13155        let report = doctor_route_report(&config);
13156        let serialized = report.to_string();
13157
13158        assert_eq!(report["provider"], "siliconflow-CN");
13159        assert_eq!(report["provider_config_table"], "siliconflow_cn");
13160        assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
13161        assert_eq!(
13162            report["base_url"]["redacted"],
13163            crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
13164        );
13165        assert_eq!(report["base_url"]["class"], "default");
13166        assert_eq!(report["auth"]["scheme"], "bearer");
13167        assert_eq!(report["auth"]["source"], "config_declared");
13168        assert!(!serialized.contains("sf-cn-secret-value"));
13169    }
13170
13171    #[test]
13172    fn doctor_route_report_names_kimi_code_context_provenance() {
13173        let config = Config {
13174            provider: Some("moonshot".to_string()),
13175            providers: Some(crate::config::ProvidersConfig {
13176                moonshot: crate::config::ProviderConfig {
13177                    api_key: Some("kimi-plan-secret".to_string()),
13178                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13179                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13180                    ..Default::default()
13181                },
13182                ..Default::default()
13183            }),
13184            ..Default::default()
13185        };
13186
13187        let report = doctor_route_report(&config);
13188        let serialized = report.to_string();
13189
13190        assert_eq!(report["context_window"]["tokens"], 262_144);
13191        assert_eq!(
13192            report["context_window"]["source"],
13193            "static Kimi Code safe floor"
13194        );
13195        assert!(!serialized.contains("kimi-plan-secret"));
13196    }
13197
13198    #[test]
13199    fn provider_capability_report_uses_exact_kimi_code_route_facts() {
13200        let config = Config {
13201            provider: Some("moonshot".to_string()),
13202            providers: Some(crate::config::ProvidersConfig {
13203                moonshot: crate::config::ProviderConfig {
13204                    api_key: Some("kimi-plan-secret".to_string()),
13205                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13206                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13207                    ..Default::default()
13208                },
13209                ..Default::default()
13210            }),
13211            ..Default::default()
13212        };
13213
13214        let report = provider_capability_report(&config);
13215
13216        assert_eq!(report["resolved_model"], crate::config::KIMI_CODE_K3_MODEL);
13217        assert_eq!(report["context_window"], 262_144);
13218        assert_eq!(
13219            report["context_window_source"],
13220            "static Kimi Code safe floor"
13221        );
13222        assert_eq!(report["thinking_supported"], true);
13223    }
13224
13225    #[test]
13226    fn provider_capability_report_honors_kimi_code_context_override() {
13227        let config = Config {
13228            provider: Some("moonshot".to_string()),
13229            providers: Some(crate::config::ProvidersConfig {
13230                moonshot: crate::config::ProviderConfig {
13231                    api_key: Some("kimi-plan-secret".to_string()),
13232                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13233                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13234                    context_window: Some(1_048_576),
13235                    ..Default::default()
13236                },
13237                ..Default::default()
13238            }),
13239            ..Default::default()
13240        };
13241
13242        let report = provider_capability_report(&config);
13243
13244        assert_eq!(
13245            report["resolved_model"],
13246            crate::config::KIMI_CODE_K3_MODEL,
13247            "the configured window must preserve Kimi Code's bare wire id"
13248        );
13249        assert_eq!(report["context_window"], 1_048_576);
13250        assert_eq!(report["context_window_source"], "configured");
13251        assert_eq!(report["thinking_supported"], true);
13252    }
13253
13254    #[test]
13255    fn provider_capability_report_uses_direct_moonshot_k3_route_facts() {
13256        let config = Config {
13257            provider: Some("moonshot".to_string()),
13258            providers: Some(crate::config::ProvidersConfig {
13259                moonshot: crate::config::ProviderConfig {
13260                    api_key: Some("moonshot-secret".to_string()),
13261                    base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
13262                    model: Some("kimi-k3".to_string()),
13263                    ..Default::default()
13264                },
13265                ..Default::default()
13266            }),
13267            ..Default::default()
13268        };
13269
13270        let report = provider_capability_report(&config);
13271
13272        assert_eq!(report["resolved_model"], "kimi-k3");
13273        assert_eq!(report["context_window"], 1_048_576);
13274        assert_eq!(report["context_window_source"], "catalog");
13275        assert_eq!(report["max_output"], 1_048_576);
13276        assert_eq!(report["thinking_supported"], true);
13277    }
13278
13279    #[test]
13280    fn doctor_search_provider_line_includes_duckduckgo_default_source_and_switch_hint() {
13281        let _guard = crate::test_support::lock_test_env();
13282        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13283        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13284
13285        let line = doctor_search_provider_line(&Config::default());
13286
13287        match prev {
13288            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13289            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13290        }
13291        assert!(line.contains("search_provider: duckduckgo"));
13292        assert!(line.contains("source: default"));
13293        assert!(line.contains("[search] provider"));
13294        assert!(line.contains("provider = \"bing\""));
13295    }
13296
13297    #[test]
13298    fn doctor_search_provider_json_reports_config_source() {
13299        let _guard = crate::test_support::lock_test_env();
13300        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13301        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13302        let config = Config {
13303            search: Some(crate::config::SearchConfig {
13304                provider: Some(crate::config::SearchProvider::DuckDuckGo),
13305                base_url: None,
13306                api_key: None,
13307            }),
13308            ..Default::default()
13309        };
13310
13311        let report = doctor_search_provider_json(&config);
13312
13313        match prev {
13314            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13315            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13316        }
13317        assert_eq!(report["provider"], "duckduckgo");
13318        assert_eq!(report["source"], "config");
13319    }
13320
13321    #[test]
13322    fn doctor_search_provider_json_reports_env_override_source() {
13323        let _guard = crate::test_support::lock_test_env();
13324        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13325        unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", "tavily") };
13326
13327        let report = doctor_search_provider_json(&Config::default());
13328
13329        match prev {
13330            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13331            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13332        }
13333        assert_eq!(report["provider"], "tavily");
13334        assert_eq!(report["source"], "env override");
13335    }
13336
13337    #[test]
13338    fn doctor_search_provider_line_omits_switch_hint_when_bing_is_configured() {
13339        let _guard = crate::test_support::lock_test_env();
13340        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13341        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13342        let config = Config {
13343            search: Some(crate::config::SearchConfig {
13344                provider: Some(crate::config::SearchProvider::Bing),
13345                base_url: None,
13346                api_key: None,
13347            }),
13348            ..Default::default()
13349        };
13350
13351        let line = doctor_search_provider_line(&config);
13352
13353        match prev {
13354            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13355            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13356        }
13357        assert!(line.contains("search_provider: bing"));
13358        assert!(line.contains("source: config"));
13359        assert!(!line.contains("[search] provider"));
13360    }
13361
13362    #[test]
13363    fn timeout_recovery_keeps_default_deepseek_users_on_default_endpoint() {
13364        let config = Config::default();
13365
13366        let text = doctor_timeout_recovery_lines(&config).join("\n");
13367
13368        assert!(text.contains("api.deepseek.com"));
13369        assert!(text.contains("custom DeepSeek-compatible endpoint"));
13370        assert!(!text.contains("provider = \"deepseek-cn\""));
13371        assert!(text.contains("codewhale doctor --json"));
13372    }
13373
13374    #[test]
13375    fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
13376        let config = Config {
13377            provider: Some("vllm".to_string()),
13378            ..Default::default()
13379        };
13380
13381        let text = doctor_timeout_recovery_lines(&config).join("\n");
13382
13383        assert!(text.contains("/v1/models"));
13384        assert!(text.contains("/v1/chat/completions"));
13385        assert!(!text.contains("api.deepseeki.com"));
13386    }
13387}
13388
13389#[cfg(test)]
13390mod terminal_mode_tests {
13391    use super::*;
13392    use clap::Parser;
13393
13394    fn parse_cli(args: &[&str]) -> Cli {
13395        Cli::try_parse_from(args).expect("CLI args should parse")
13396    }
13397
13398    #[test]
13399    fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
13400        let config = Config {
13401            network: Some(crate::config::NetworkPolicyToml {
13402                default: "allow".to_string(),
13403                audit: false,
13404                ..crate::config::NetworkPolicyToml::default()
13405            }),
13406            ..Config::default()
13407        };
13408        let authority = crate::tools::spec::ToolAuthorityEnvelope {
13409            schema_version: 1,
13410            owner: "consultant-1".to_string(),
13411            authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
13412            network_access: Some(false),
13413            shell: crate::tools::spec::ToolShellAuthority::None,
13414            verification: crate::tools::spec::ToolVerificationAuthority::None,
13415            writable_roots: Vec::new(),
13416            writable_files: Vec::new(),
13417            coordination_contracts: Vec::new(),
13418        }
13419        .normalized()
13420        .expect("Consultant authority");
13421
13422        let policy = exec_network_policy(&config, authority.network_access)
13423            .expect("explicit network=false always installs a policy");
13424        assert_eq!(
13425            policy.evaluate("example.com", "web_search"),
13426            crate::network_policy::Decision::Deny,
13427            "the permissive user config must not widen Consultant network authority"
13428        );
13429        let mut features = crate::features::Features::default();
13430        features.enable(crate::features::Feature::ShellTool);
13431        features.enable(crate::features::Feature::WebSearch);
13432        apply_fleet_engine_feature_caps(
13433            &mut features,
13434            true,
13435            authority.network_access,
13436            authority.shell,
13437        );
13438        assert!(!features.enabled(crate::features::Feature::WebSearch));
13439        assert!(!features.enabled(crate::features::Feature::ShellTool));
13440
13441        let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
13442        assert_eq!(
13443            worker_policy.evaluate("example.com", "web_search"),
13444            crate::network_policy::Decision::Allow,
13445            "a network-capable role keeps the configured policy"
13446        );
13447    }
13448    #[test]
13449    fn hidden_remote_control_flag_starts_the_interactive_handoff() {
13450        let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
13451        assert!(cli.remote_control);
13452    }
13453
13454    #[test]
13455    fn plugin_registry_discovery_is_route_independent_and_read_only() {
13456        let _env_lock = crate::test_support::lock_test_env();
13457        let temp = tempfile::tempdir().unwrap();
13458        let workspace = temp.path().join("workspace");
13459        let codewhale_home = temp.path().join("home");
13460        std::fs::create_dir_all(&workspace).unwrap();
13461        let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
13462        let workspace_arg = workspace.to_string_lossy().into_owned();
13463
13464        for route in [
13465            Vec::<&str>::new(),
13466            vec!["resume", "--last"],
13467            vec!["fork", "--last"],
13468            vec!["exec", "hello"],
13469            vec!["serve", "--mcp"],
13470        ] {
13471            let mut args = vec![
13472                "codewhale-tui".to_string(),
13473                "--workspace".to_string(),
13474                workspace_arg.clone(),
13475            ];
13476            args.extend(route.into_iter().map(str::to_string));
13477            let cli = Cli::try_parse_from(args).expect("route should parse");
13478            let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
13479            let registry = discovery
13480                .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
13481            assert_eq!(registry.workspace(), workspace.as_path());
13482            assert!(
13483                !codewhale_home.join("plugins/state.json").exists(),
13484                "startup discovery must remain read-only"
13485            );
13486        }
13487    }
13488
13489    fn custom_exec_config(active: &str) -> Config {
13490        let mut custom = std::collections::HashMap::new();
13491        for (name, base_url, model) in [
13492            (
13493                "custom-a",
13494                "http://127.0.0.1:18181/v1",
13495                crate::config::ZAI_GLM_5_2_MODEL,
13496            ),
13497            ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
13498        ] {
13499            custom.insert(
13500                name.to_string(),
13501                crate::config::ProviderConfig {
13502                    kind: Some("openai-compatible".to_string()),
13503                    base_url: Some(base_url.to_string()),
13504                    model: Some(model.to_string()),
13505                    api_key: Some("local-test-key".to_string()),
13506                    ..Default::default()
13507                },
13508            );
13509        }
13510        Config {
13511            provider: Some(active.to_string()),
13512            providers: Some(crate::config::ProvidersConfig {
13513                custom,
13514                ..Default::default()
13515            }),
13516            ..Default::default()
13517        }
13518    }
13519
13520    #[test]
13521    fn doctor_json_surfaces_keep_exact_named_custom_provider() {
13522        let config = custom_exec_config("custom-a");
13523        let workspace = tempfile::tempdir().expect("doctor workspace");
13524
13525        let operate = doctor_operate_fleet_report_json(&config, workspace.path());
13526        let provider_model = doctor_provider_model_report_json(&config);
13527        let capability = provider_capability_report(&config);
13528        let route = doctor_route_report(&config);
13529
13530        assert_eq!(operate["provider"]["id"], "custom-a");
13531        assert_eq!(provider_model["provider"]["id"], "custom-a");
13532        assert_eq!(capability["resolved_provider"], "custom-a");
13533        assert_eq!(route["provider"], "custom-a");
13534        assert_eq!(route["provider_config_table"], "providers.custom-a");
13535        let serialized = serde_json::to_string(&serde_json::json!({
13536            "operate": operate,
13537            "provider_model": provider_model,
13538            "capability": capability,
13539            "route": route,
13540        }))
13541        .expect("doctor JSON");
13542        assert!(!serialized.contains("local-test-key"));
13543    }
13544
13545    fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
13546        let mut saved = session_manager::create_saved_session_with_mode(
13547            &[],
13548            model,
13549            Path::new("/tmp/exec-resume"),
13550            0,
13551            None,
13552            Some("exec"),
13553        );
13554        let kind = crate::config::ApiProvider::parse(provider)
13555            .unwrap_or(crate::config::ApiProvider::Custom)
13556            .as_str();
13557        let exact_id = (!provider
13558            .eq_ignore_ascii_case(crate::config::ApiProvider::Custom.as_str()))
13559        .then_some(provider);
13560        saved.metadata.set_model_provider_route(kind, exact_id);
13561        saved
13562    }
13563
13564    #[test]
13565    fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
13566        let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
13567
13568        assert_eq!(cli.prompt, vec!["hello", "world"]);
13569    }
13570
13571    #[test]
13572    fn prompt_flag_starts_interactive_submit_input() {
13573        let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
13574
13575        assert_eq!(
13576            top_level_prompt_initial_input(&cli.prompt),
13577            Some(tui::InitialInput::Submit("read the project".to_string()))
13578        );
13579    }
13580
13581    #[test]
13582    fn companion_binary_reports_its_own_name() {
13583        assert_eq!(Cli::command().get_name(), "codewhale-tui");
13584    }
13585
13586    #[test]
13587    fn xai_device_auth_subcommand_parses() {
13588        let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
13589        assert!(matches!(
13590            cli.command,
13591            Some(Commands::Auth(TuiAuthArgs {
13592                command: TuiAuthCommand::XaiDevice
13593            }))
13594        ));
13595    }
13596
13597    #[test]
13598    fn workflow_tool_internal_subcommand_parses_exact_json() {
13599        let cli = parse_cli(&[
13600            "codewhale-tui",
13601            "workflow-tool",
13602            "--approval-source",
13603            "explicit-workflow-command",
13604            "--input-json",
13605            r#"{"action":"run","source_path":"workflows/demo.js"}"#,
13606        ]);
13607        let Some(Commands::WorkflowTool(args)) = cli.command else {
13608            panic!("expected workflow-tool command");
13609        };
13610        assert!(args.input_json.contains("\"action\":\"run\""));
13611    }
13612
13613    #[tokio::test]
13614    async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
13615        use crate::tools::spec::ToolSpec;
13616
13617        let workspace = tempfile::tempdir().expect("workspace");
13618        let config = Config {
13619            provider: Some("vllm".to_string()),
13620            mcp_config_path: Some(
13621                workspace
13622                    .path()
13623                    .join("missing-mcp.json")
13624                    .display()
13625                    .to_string(),
13626            ),
13627            providers: Some(crate::config::ProvidersConfig {
13628                vllm: crate::config::ProviderConfig {
13629                    base_url: Some("http://127.0.0.1:9/v1".to_string()),
13630                    model: Some("offline-test-model".to_string()),
13631                    ..Default::default()
13632                },
13633                ..Default::default()
13634            }),
13635            ..Default::default()
13636        };
13637        let route = CliAutoRoute {
13638            provider: crate::config::ApiProvider::Vllm,
13639            model: "offline-test-model".to_string(),
13640            reasoning_effort: None,
13641            auto_controls_reasoning: false,
13642            auto_model: false,
13643        };
13644        let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
13645        let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
13646        let (tool, context) =
13647            build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
13648                .await
13649                .expect("build direct workflow runtime");
13650
13651        let result = tool
13652            .execute(
13653                serde_json::json!({
13654                    "action": "run",
13655                    "script": "phase('offline'); return { ok: true };",
13656                    "token_budget": 1_000_000
13657                }),
13658                &context,
13659            )
13660            .await
13661            .expect("model-free workflow run");
13662        let payload: serde_json::Value =
13663            serde_json::from_str(&result.content).expect("workflow JSON");
13664
13665        assert_eq!(payload["status"], "completed");
13666        assert_eq!(payload["result"]["ok"], true);
13667        assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
13668        assert_eq!(
13669            payload["plan_approval"]["decision"],
13670            "approved_explicit_cli_command"
13671        );
13672        assert!(!context.auto_approve);
13673        assert!(!context.trust_mode);
13674        assert_eq!(
13675            context.shell_policy,
13676            crate::worker_profile::ShellPolicy::None
13677        );
13678        assert!(matches!(
13679            context.elevated_sandbox_policy,
13680            Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
13681        ));
13682        let mut event_types = Vec::new();
13683        while let Ok(event) = event_rx.try_recv() {
13684            if let crate::core::events::Event::WorkflowUi { event, .. } = event
13685                && let Some(kind) = event["type"].as_str()
13686            {
13687                event_types.push(kind.to_string());
13688            }
13689        }
13690        assert!(event_types.iter().any(|kind| kind == "run_started"));
13691        assert!(event_types.iter().any(|kind| kind == "run_completed"));
13692    }
13693
13694    #[tokio::test]
13695    async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
13696        let workspace = tempfile::tempdir().expect("workspace");
13697        let mcp_path = workspace.path().join("mcp.json");
13698        std::fs::write(
13699            &mcp_path,
13700            r#"{
13701                "mcpServers": {
13702                    "blocked": { "url": "https://blocked.invalid/mcp" }
13703                }
13704            }"#,
13705        )
13706        .expect("write MCP config");
13707        let config = Config {
13708            mcp_config_path: Some(mcp_path.display().to_string()),
13709            ..Default::default()
13710        };
13711        let policy = crate::network_policy::NetworkPolicyDecider::new(
13712            crate::network_policy::NetworkPolicy {
13713                default: crate::network_policy::DecisionToml::Deny,
13714                allow: Vec::new(),
13715                deny: Vec::new(),
13716                proxy: Vec::new(),
13717                proxy_fake_ip_cidrs: Vec::new(),
13718                audit: false,
13719            },
13720            None,
13721        );
13722
13723        let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
13724        let (_pool, failures) =
13725            initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
13726                .await
13727                .expect("MCP feature enabled");
13728        assert_eq!(failures.len(), 1, "failures={failures:?}");
13729        assert_eq!(failures[0].0, "blocked");
13730        assert!(failures[0].1.contains("blocked by network policy"));
13731    }
13732
13733    #[test]
13734    fn exec_model_resolution_uses_provider_scoped_default() {
13735        let _env_lock = crate::test_support::lock_test_env();
13736        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
13737        let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
13738        let config = Config {
13739            provider: Some("openrouter".to_string()),
13740            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13741            providers: Some(crate::config::ProvidersConfig {
13742                openrouter: crate::config::ProviderConfig {
13743                    model: Some("arcee-ai/trinity-large-thinking".to_string()),
13744                    ..Default::default()
13745                },
13746                ..Default::default()
13747            }),
13748            ..Default::default()
13749        };
13750
13751        assert_eq!(
13752            resolve_exec_model(&config, None),
13753            "arcee-ai/trinity-large-thinking"
13754        );
13755        assert_eq!(
13756            resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
13757            "arcee-ai/trinity-large-thinking"
13758        );
13759    }
13760
13761    #[test]
13762    fn exec_model_resolution_prefers_codewhale_model_env_override() {
13763        let _env_lock = crate::test_support::lock_test_env();
13764        let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
13765        let _deepseek_model =
13766            crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
13767        let config = Config {
13768            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13769            ..Default::default()
13770        };
13771
13772        assert_eq!(resolve_exec_model(&config, None), "auto");
13773    }
13774
13775    #[test]
13776    fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
13777        let _env_lock = crate::test_support::lock_test_env();
13778        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
13779        let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
13780        let config = Config {
13781            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
13782            ..Default::default()
13783        };
13784
13785        assert_eq!(resolve_exec_model(&config, None), "auto");
13786    }
13787
13788    #[test]
13789    fn exec_model_resolution_uses_provider_safe_default_for_zai() {
13790        let _env_lock = crate::test_support::lock_test_env();
13791        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
13792        let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
13793        let config = Config {
13794            provider: Some("zai".to_string()),
13795            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
13796            ..Default::default()
13797        };
13798
13799        assert_eq!(
13800            resolve_exec_model(&config, None),
13801            crate::config::DEFAULT_ZAI_MODEL
13802        );
13803    }
13804
13805    #[tokio::test]
13806    #[allow(clippy::await_holding_lock)]
13807    async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
13808        let _env_lock = crate::test_support::lock_test_env();
13809        let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
13810        let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
13811        let config = Config {
13812            provider: Some("deepseek".to_string()),
13813            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
13814            ..Default::default()
13815        };
13816
13817        let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
13818            .await
13819            .expect("explicit GLM should route to the configured Z.ai provider");
13820
13821        assert_eq!(route.provider, crate::config::ApiProvider::Zai);
13822        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
13823        assert!(!route.auto_model);
13824    }
13825
13826    #[tokio::test]
13827    #[allow(clippy::await_holding_lock)]
13828    async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
13829        let _env_lock = crate::test_support::lock_test_env();
13830        let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
13831        let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
13832        let config = Config {
13833            provider: Some("deepseek".to_string()),
13834            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
13835            ..Default::default()
13836        };
13837
13838        let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
13839            .await
13840            .expect_err("ambiguous GLM route should ask for an explicit provider");
13841        let message = err.to_string();
13842
13843        assert!(message.contains("model `GLM-5.2` is available"));
13844        assert!(message.contains("openrouter"));
13845        assert!(message.contains("zai"));
13846        assert!(message.contains("--provider"));
13847        assert!(message.contains("/provider"));
13848        assert!(message.contains("/model"));
13849        assert!(message.contains("/setup"));
13850    }
13851
13852    #[tokio::test]
13853    async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
13854        let config = Config {
13855            provider: Some("vllm".to_string()),
13856            reasoning_effort: Some("low".to_string()),
13857            providers: Some(crate::config::ProvidersConfig {
13858                vllm: crate::config::ProviderConfig {
13859                    base_url: Some("http://127.0.0.1:18190/v1".to_string()),
13860                    model: Some("local-auto-model".to_string()),
13861                    ..Default::default()
13862                },
13863                ..Default::default()
13864            }),
13865            ..Default::default()
13866        };
13867
13868        let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
13869            .await
13870            .expect("Auto model route");
13871
13872        assert!(route.auto_model);
13873        assert_eq!(
13874            route.reasoning_effort,
13875            Some(crate::tui::app::ReasoningEffort::Low)
13876        );
13877        assert!(
13878            !route.auto_controls_reasoning,
13879            "a fixed saved tier must not be replaced per prompt"
13880        );
13881    }
13882
13883    #[test]
13884    fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
13885        let mut providers = crate::config::ProvidersConfig::default();
13886        providers.deepseek.model = Some("deepseek-v4-pro".to_string());
13887        let config = Config {
13888            provider: Some("deepseek".to_string()),
13889            providers: Some(providers),
13890            ..Default::default()
13891        };
13892        let route = CliAutoRoute {
13893            provider: crate::config::ApiProvider::Deepseek,
13894            model: "deepseek-v4-flash".to_string(),
13895            reasoning_effort: None,
13896            auto_controls_reasoning: true,
13897            auto_model: true,
13898        };
13899
13900        let execution_config = config_for_cli_route(&config, &route);
13901
13902        assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
13903        assert_eq!(
13904            execution_config
13905                .provider_config_for(crate::config::ApiProvider::Deepseek)
13906                .and_then(|entry| entry.model.as_deref()),
13907            Some("deepseek-v4-flash")
13908        );
13909    }
13910
13911    #[test]
13912    fn cli_route_execution_config_preserves_legacy_literal_custom_root_route() {
13913        let _lock = crate::test_support::lock_test_env();
13914        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
13915        let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
13916        let config = Config {
13917            provider: Some("custom".to_string()),
13918            api_key: Some("legacy-root-key".to_string()),
13919            base_url: Some("http://127.0.0.1:18183/v1".to_string()),
13920            default_text_model: Some("legacy-model".to_string()),
13921            ..Default::default()
13922        };
13923        let route = CliAutoRoute {
13924            provider: crate::config::ApiProvider::Custom,
13925            model: "routed-legacy-model".to_string(),
13926            reasoning_effort: None,
13927            auto_controls_reasoning: false,
13928            auto_model: false,
13929        };
13930
13931        let execution = config_for_cli_route(&config, &route);
13932
13933        assert!(execution.uses_legacy_literal_custom_route());
13934        assert!(
13935            execution
13936                .providers
13937                .as_ref()
13938                .is_none_or(|providers| !providers.custom.contains_key("custom"))
13939        );
13940        assert_eq!(execution.provider.as_deref(), Some("custom"));
13941        assert_eq!(execution.default_model(), "routed-legacy-model");
13942        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18183/v1");
13943        assert_eq!(execution.deepseek_api_key().unwrap(), "legacy-root-key");
13944        for _ in 0..2 {
13945            let identity = execution
13946                .resolve_provider_identity("custom")
13947                .expect("legacy identity remains repeatedly resolvable");
13948            assert_eq!(identity.key, "custom");
13949        }
13950        let client =
13951            crate::client::DeepSeekClient::new(&execution).expect("legacy execution client");
13952        assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
13953    }
13954
13955    #[test]
13956    fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
13957        let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
13958        let Some(Commands::Exec(args)) = cli.command else {
13959            panic!("expected exec command");
13960        };
13961
13962        assert_eq!(args.prompt, vec!["hello", "world"]);
13963    }
13964
13965    #[test]
13966    fn exec_keeps_model_flag_before_split_prompt_words() {
13967        let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
13968        let Some(Commands::Exec(args)) = cli.command else {
13969            panic!("expected exec command");
13970        };
13971
13972        assert_eq!(args.model.as_deref(), Some("auto"));
13973        assert_eq!(args.prompt, vec!["hello", "world"]);
13974    }
13975
13976    #[test]
13977    fn exec_keeps_flags_before_split_prompt_words() {
13978        let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
13979        let Some(Commands::Exec(args)) = cli.command else {
13980            panic!("expected exec command");
13981        };
13982
13983        assert!(args.json);
13984        assert_eq!(args.prompt, vec!["hello", "world"]);
13985    }
13986
13987    #[test]
13988    fn exec_parses_provider_flag_alongside_model() {
13989        // #4093: Fleet threads `--provider <id>` so a worker launches on its
13990        // profile-pinned provider even when the parent session is elsewhere.
13991        let cli = parse_cli(&[
13992            "codewhale",
13993            "exec",
13994            "--provider",
13995            "openrouter",
13996            "--model",
13997            "glm-5.2",
13998            "audit",
13999        ]);
14000        let Some(Commands::Exec(args)) = cli.command else {
14001            panic!("expected exec command");
14002        };
14003
14004        assert_eq!(args.provider.as_deref(), Some("openrouter"));
14005        assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14006        assert_eq!(args.prompt, vec!["audit"]);
14007        // The threaded id round-trips through the provider vocabulary the exec
14008        // handler validates against — never a model-id sniff (EPIC #2608).
14009        assert_eq!(
14010            crate::config::ApiProvider::parse(args.provider.as_deref().unwrap()),
14011            Some(crate::config::ApiProvider::Openrouter)
14012        );
14013    }
14014
14015    #[test]
14016    fn exec_provider_override_accepts_configured_custom_provider() {
14017        let mut custom = std::collections::HashMap::new();
14018        custom.insert(
14019            "lm-studio".to_string(),
14020            crate::config::ProviderConfig {
14021                kind: Some("openai-compatible".to_string()),
14022                base_url: Some("http://127.0.0.1:1234/v1".to_string()),
14023                model: Some("qwen-2.5-7b".to_string()),
14024                api_key: Some("lm-studio".to_string()),
14025                ..Default::default()
14026            },
14027        );
14028        let mut config = Config {
14029            provider: Some("deepseek".to_string()),
14030            providers: Some(crate::config::ProvidersConfig {
14031                custom,
14032                ..Default::default()
14033            }),
14034            ..Default::default()
14035        };
14036
14037        apply_exec_provider_override(&mut config, "lm-studio")
14038            .expect("configured custom provider should be accepted");
14039
14040        assert_eq!(config.provider.as_deref(), Some("lm-studio"));
14041        assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14042    }
14043
14044    #[test]
14045    fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
14046        let mut config = Config {
14047            provider: Some("deepseek".to_string()),
14048            providers: Some(crate::config::ProvidersConfig {
14049                custom: std::collections::HashMap::from([(
14050                    "CUSTOM".to_string(),
14051                    crate::config::ProviderConfig {
14052                        kind: Some("openai-compatible".to_string()),
14053                        base_url: Some("http://127.0.0.1:5678/v1".to_string()),
14054                        model: Some("case-model".to_string()),
14055                        api_key: Some("case-key".to_string()),
14056                        ..Default::default()
14057                    },
14058                )]),
14059                ..Default::default()
14060            }),
14061            ..Default::default()
14062        };
14063
14064        apply_exec_provider_override(&mut config, "CUSTOM")
14065            .expect("exact case-colliding custom provider");
14066        assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
14067        assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14068        assert_eq!(
14069            config.provider_identity_for(crate::config::ApiProvider::Custom),
14070            "CUSTOM"
14071        );
14072        let route = crate::route_runtime::resolve_runtime_route(
14073            &config,
14074            crate::config::ApiProvider::Custom,
14075            Some("case-model"),
14076        )
14077        .expect("resolve exact case-colliding route")
14078        .validate()
14079        .expect("preflight exact case-colliding route");
14080        assert_eq!(route.identity.key, "CUSTOM");
14081        assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
14082    }
14083
14084    #[test]
14085    fn exec_provider_override_rejects_unknown_provider() {
14086        let mut config = Config {
14087            provider: Some("deepseek".to_string()),
14088            ..Default::default()
14089        };
14090
14091        let err = apply_exec_provider_override(&mut config, "lm-studio")
14092            .expect_err("unconfigured custom provider should fail closed");
14093        let message = err.to_string();
14094
14095        assert!(message.contains("Unrecognized --provider"));
14096        assert!(message.contains("[providers.<name>] custom provider"));
14097        assert_eq!(config.provider.as_deref(), Some("deepseek"));
14098    }
14099
14100    #[test]
14101    fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
14102        let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14103
14104        let mut restored = custom_exec_config("custom-b");
14105        let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
14106            .expect("plain resume restores saved route");
14107        assert_eq!(restored.provider.as_deref(), Some("custom-a"));
14108        assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
14109
14110        let mut explicit_provider = custom_exec_config("custom-a");
14111        apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
14112        let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
14113            .expect("explicit provider wins");
14114        assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
14115        assert_eq!(model, "model-b");
14116
14117        let mut explicit_model = custom_exec_config("custom-b");
14118        let model =
14119            resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
14120                .expect("explicit model keeps saved provider");
14121        assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
14122        assert_eq!(model, "override-model");
14123
14124        let mut missing = custom_exec_config("custom-b");
14125        missing
14126            .providers
14127            .as_mut()
14128            .expect("providers")
14129            .custom
14130            .remove("custom-a");
14131        let before = missing.provider.clone();
14132        let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
14133            .expect_err("removed saved provider must fail closed");
14134        assert!(err.to_string().contains("will not fall back"), "{err}");
14135        assert_eq!(missing.provider, before);
14136    }
14137
14138    #[test]
14139    fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
14140        let (started_tx, started_rx) = std::sync::mpsc::channel();
14141        let (tx, rx) = std::sync::mpsc::channel();
14142
14143        let (reader, expected_after_restore) = {
14144            let lock = crate::test_support::lock_test_env();
14145            let expected_after_restore = exec_model_env_override();
14146            let temporary =
14147                crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
14148            let reader = std::thread::spawn(move || {
14149                started_tx.send(()).expect("signal model read start");
14150                tx.send(exec_model_env_override())
14151                    .expect("send resolved model override");
14152            });
14153
14154            started_rx
14155                .recv_timeout(std::time::Duration::from_secs(2))
14156                .expect("reader reached model read");
14157            assert!(
14158                rx.recv_timeout(std::time::Duration::from_millis(50))
14159                    .is_err(),
14160                "a foreign reader observed another test's temporary model override"
14161            );
14162            drop(temporary);
14163            drop(lock);
14164            (reader, expected_after_restore)
14165        };
14166
14167        let observed = rx
14168            .recv_timeout(std::time::Duration::from_secs(2))
14169            .expect("reader resumed after model override was restored");
14170        reader.join().expect("reader thread");
14171        assert_eq!(observed, expected_after_restore);
14172    }
14173
14174    #[tokio::test]
14175    async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
14176        let config = custom_exec_config("custom-a");
14177
14178        let route =
14179            resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
14180                .await
14181                .expect("forced route");
14182        let execution = config_for_cli_route(&config, &route);
14183
14184        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14185        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14186        assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14187    }
14188
14189    #[tokio::test]
14190    async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
14191        let mut config = custom_exec_config("custom-a");
14192        config
14193            .providers
14194            .as_mut()
14195            .expect("providers")
14196            .custom
14197            .get_mut("custom-a")
14198            .expect("custom A")
14199            .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
14200        let model = resolve_exec_model(&config, None);
14201        let force = should_force_configured_exec_route(false, None, None);
14202
14203        assert!(force, "configured/default exec route must be authoritative");
14204        assert!(!should_force_configured_exec_route(
14205            false,
14206            None,
14207            Some(crate::config::ZAI_GLM_5_2_MODEL)
14208        ));
14209        assert!(should_force_configured_exec_route(
14210            false,
14211            Some("custom-a"),
14212            Some(crate::config::ZAI_GLM_5_2_MODEL)
14213        ));
14214        assert!(should_force_configured_exec_route(
14215            true,
14216            None,
14217            Some("override-model")
14218        ));
14219
14220        let route = resolve_cli_exec_route(&config, &model, "audit", force)
14221            .await
14222            .expect("no-flag configured route");
14223        let execution = config_for_cli_route(&config, &route);
14224        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14225        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14226        assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14227    }
14228
14229    #[tokio::test]
14230    async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
14231        let mut config = custom_exec_config("custom-a");
14232        config
14233            .providers
14234            .as_mut()
14235            .expect("providers")
14236            .custom
14237            .get_mut("custom-a")
14238            .expect("custom A")
14239            .model = Some("model-a".to_string());
14240        config.default_text_model = Some("stale-root-deepseek-model".to_string());
14241        let model = resolve_review_model(&config, None);
14242        assert_eq!(model, "model-a");
14243        assert_eq!(
14244            resolve_review_model(&config, Some("explicit-review-model")),
14245            "explicit-review-model"
14246        );
14247
14248        let route = resolve_cli_exec_route(&config, &model, "review diff", true)
14249            .await
14250            .expect("configured review route");
14251        let execution = config_for_cli_route(&config, &route);
14252        let provider = execution.provider_identity_for(route.provider);
14253
14254        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14255        assert_eq!(provider, "custom-a");
14256        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14257        let output = crate::tools::review::ReviewOutput::from_str("{}");
14258        let receipt = crate::tools::review::build_review_receipt(
14259            "working tree",
14260            "diff --git a/a b/a",
14261            provider,
14262            &route.model,
14263            &output,
14264            "{}",
14265            Vec::new(),
14266        );
14267        assert_eq!(receipt.provider, "custom-a");
14268        let serialized = serde_json::to_string(&receipt).expect("review receipt");
14269        assert!(!serialized.contains("127.0.0.1"));
14270        assert!(!serialized.contains("local-test-key"));
14271    }
14272
14273    #[tokio::test]
14274    async fn configured_workflow_default_keeps_named_custom_route() {
14275        let config = custom_exec_config("custom-a");
14276        let model = config.default_model();
14277
14278        let route = resolve_cli_exec_route(
14279            &config,
14280            &model,
14281            "Run a checked-in Workflow through the host runtime",
14282            true,
14283        )
14284        .await
14285        .expect("configured workflow route");
14286        let execution = config_for_cli_route(&config, &route);
14287
14288        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14289        assert_eq!(execution.provider_identity_for(route.provider), "custom-a");
14290        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14291        let client = crate::client::DeepSeekClient::new(&execution).expect("workflow client");
14292        assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
14293    }
14294
14295    #[test]
14296    fn exec_json_receipts_keep_exact_named_custom_provider() {
14297        let config = custom_exec_config("custom-a");
14298        let provider = config.provider_identity_for(crate::config::ApiProvider::Custom);
14299        let one_shot =
14300            one_shot_exec_json_receipt(provider.clone(), "model-a".to_string(), "done".to_string());
14301        assert_eq!(one_shot["provider"], "custom-a");
14302
14303        let agent = serde_json::to_value(ExecSummary {
14304            mode: "agent".to_string(),
14305            provider,
14306            model: "model-a".to_string(),
14307            ..ExecSummary::default()
14308        })
14309        .expect("agent exec JSON receipt");
14310        assert_eq!(agent["provider"], "custom-a");
14311        let serialized = serde_json::to_string(&agent).expect("serialize receipt");
14312        assert!(!serialized.contains("127.0.0.1"));
14313        assert!(!serialized.contains("local-test-key"));
14314    }
14315
14316    #[test]
14317    fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
14318        let named = crate::config::ProviderIdentity {
14319            provider: crate::config::ApiProvider::Custom,
14320            key: "lm-studio".to_string(),
14321            exact_id: Some("lm-studio".to_string()),
14322        };
14323        let literal = crate::config::ProviderIdentity {
14324            provider: crate::config::ApiProvider::Custom,
14325            key: "custom".to_string(),
14326            exact_id: Some("custom".to_string()),
14327        };
14328        let root = crate::config::ProviderIdentity {
14329            provider: crate::config::ApiProvider::Custom,
14330            key: "custom".to_string(),
14331            exact_id: None,
14332        };
14333        let built_in = crate::config::ProviderIdentity {
14334            provider: crate::config::ApiProvider::Deepseek,
14335            key: "deepseek".to_string(),
14336            exact_id: Some("deepseek".to_string()),
14337        };
14338
14339        assert_eq!(
14340            exec_stream_provider_route(&named),
14341            ("custom".to_string(), Some("lm-studio".to_string()))
14342        );
14343        assert_eq!(
14344            exec_stream_provider_route(&literal),
14345            ("custom".to_string(), Some("custom".to_string()))
14346        );
14347        assert_eq!(
14348            exec_stream_provider_route(&root),
14349            ("custom".to_string(), None)
14350        );
14351        assert_eq!(
14352            exec_stream_provider_route(&built_in),
14353            ("deepseek".to_string(), None)
14354        );
14355    }
14356
14357    #[test]
14358    fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
14359        let saved_a = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14360        let mut config = custom_exec_config("custom-a");
14361        apply_exec_provider_override(&mut config, "custom-b").expect("custom B");
14362        let model = resolve_exec_resume_route(&mut config, &saved_a, true, None)
14363            .expect("explicit provider route");
14364        let mut persisted = saved_a;
14365        stamp_exec_session_metadata(
14366            &mut persisted,
14367            &model,
14368            crate::config::ApiProvider::Custom.as_str(),
14369            Some("custom-b"),
14370            Path::new("/tmp/exec-resume"),
14371        );
14372
14373        let mut next_config = custom_exec_config("custom-a");
14374        let resumed_model = resolve_exec_resume_route(&mut next_config, &persisted, false, None)
14375            .expect("next plain resume");
14376
14377        assert_eq!(persisted.metadata.model_provider, "custom");
14378        assert_eq!(
14379            persisted.metadata.model_provider_id.as_deref(),
14380            Some("custom-b")
14381        );
14382        assert_eq!(persisted.metadata.model, "model-b");
14383        assert_eq!(next_config.provider.as_deref(), Some("custom-b"));
14384        assert_eq!(resumed_model, "model-b");
14385    }
14386
14387    #[test]
14388    fn exec_persistence_omits_id_for_legacy_root_custom_route() {
14389        let mut saved = session_manager::create_saved_session_with_mode(
14390            &[],
14391            "legacy-root-model",
14392            Path::new("/tmp/exec-root"),
14393            0,
14394            None,
14395            Some("exec"),
14396        );
14397        stamp_exec_session_metadata(
14398            &mut saved,
14399            "legacy-root-model",
14400            crate::config::ApiProvider::Custom.as_str(),
14401            None,
14402            Path::new("/tmp/exec-root"),
14403        );
14404
14405        assert_eq!(saved.metadata.model_provider, "custom");
14406        assert_eq!(saved.metadata.model_provider_id, None);
14407        assert!(
14408            !serde_json::to_string(&saved)
14409                .expect("serialize exec session")
14410                .contains("model_provider_id")
14411        );
14412    }
14413
14414    #[test]
14415    fn exec_parses_reasoning_effort_flag_alongside_provider() {
14416        let cli = parse_cli(&[
14417            "codewhale",
14418            "exec",
14419            "--provider",
14420            "openrouter",
14421            "--model",
14422            "glm-5.2",
14423            "--reasoning-effort",
14424            "max",
14425            "audit",
14426        ]);
14427        let Some(Commands::Exec(args)) = cli.command else {
14428            panic!("expected exec command");
14429        };
14430
14431        assert_eq!(args.provider.as_deref(), Some("openrouter"));
14432        assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14433        assert_eq!(args.reasoning_effort.as_deref(), Some("max"));
14434        assert_eq!(args.prompt, vec!["audit"]);
14435    }
14436
14437    #[test]
14438    fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
14439        assert_eq!(
14440            normalize_cli_reasoning_effort("xhigh").unwrap().as_deref(),
14441            Some("max")
14442        );
14443        assert_eq!(normalize_cli_reasoning_effort("default").unwrap(), None);
14444        assert!(normalize_cli_reasoning_effort("expensive").is_err());
14445    }
14446
14447    #[test]
14448    fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() {
14449        let config = Config {
14450            provider: Some("moonshot".to_string()),
14451            providers: Some(crate::config::ProvidersConfig {
14452                moonshot: crate::config::ProviderConfig {
14453                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
14454                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
14455                    ..Default::default()
14456                },
14457                ..Default::default()
14458            }),
14459            ..Default::default()
14460        };
14461
14462        for (prompt, expected) in [
14463            ("lookup the public docs", "low"),
14464            ("debug this error", "max"),
14465            ("review this ordinary change", "high"),
14466        ] {
14467            assert_eq!(
14468                cli_reasoning_effort_value_for_prompt(
14469                    &config,
14470                    crate::config::KIMI_CODE_K3_MODEL,
14471                    crate::tui::app::ReasoningEffort::Auto,
14472                    prompt,
14473                )
14474                .as_deref(),
14475                Some(expected),
14476                "prompt selector must resolve Auto for `{prompt}`"
14477            );
14478        }
14479
14480        assert_eq!(
14481            cli_reasoning_effort_value_for_prompt(
14482                &config,
14483                crate::config::KIMI_CODE_K3_MODEL,
14484                crate::tui::app::ReasoningEffort::Off,
14485                "debug must not override an explicit effort",
14486            )
14487            .as_deref(),
14488            Some("low"),
14489            "membership K3 still applies its exact-route always-thinking floor"
14490        );
14491    }
14492
14493    #[test]
14494    fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
14495        use crate::tui::app::ReasoningEffort;
14496
14497        let fixed_model_auto_reasoning = CliAutoRoute {
14498            provider: crate::config::ApiProvider::Deepseek,
14499            model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
14500            reasoning_effort: Some(ReasoningEffort::Auto),
14501            auto_controls_reasoning: true,
14502            auto_model: false,
14503        };
14504        let auto_model_fixed_reasoning = CliAutoRoute {
14505            provider: crate::config::ApiProvider::OpenaiCodex,
14506            model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
14507            reasoning_effort: Some(ReasoningEffort::High),
14508            auto_controls_reasoning: false,
14509            auto_model: true,
14510        };
14511
14512        assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
14513        assert!(!fixed_model_auto_reasoning.auto_model);
14514        assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
14515        assert!(auto_model_fixed_reasoning.auto_model);
14516    }
14517
14518    #[test]
14519    fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
14520        let mut config = Config {
14521            reasoning_effort: Some("max".to_string()),
14522            reasoning_effort_inferred_from_legacy_alias: true,
14523            ..Default::default()
14524        };
14525        let settings = crate::settings::Settings {
14526            reasoning_effort: Some("low".to_string()),
14527            ..Default::default()
14528        };
14529
14530        apply_saved_reasoning_preference(&mut config, &settings);
14531
14532        assert_eq!(config.reasoning_effort(), Some("low"));
14533        assert!(config.reasoning_effort_is_explicit());
14534    }
14535
14536    /// `run_exec_agent` must hand the engine a concrete tier, never the literal
14537    /// `"auto"` sentinel, for a fixed-model Auto launch.
14538    #[test]
14539    fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
14540        let config = Config {
14541            provider: Some("zai".to_string()),
14542            ..Default::default()
14543        };
14544
14545        let resolved = cli_reasoning_effort_value_for_prompt(
14546            &config,
14547            crate::config::ZAI_GLM_5_2_MODEL,
14548            crate::tui::app::ReasoningEffort::Auto,
14549            "debug this failing integration test",
14550        )
14551        .expect("Auto must resolve to a concrete tier");
14552
14553        assert_ne!(
14554            resolved, "auto",
14555            "the literal auto sentinel must never reach a provider"
14556        );
14557        assert!(
14558            matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
14559            "unexpected resolved tier: {resolved}"
14560        );
14561    }
14562
14563    #[test]
14564    fn exec_accepts_resume_session_flags_for_harnesses() {
14565        let cli = parse_cli(&[
14566            "codewhale",
14567            "exec",
14568            "--resume",
14569            "abc123",
14570            "--output-format",
14571            "stream-json",
14572            "follow up",
14573        ]);
14574        let Some(Commands::Exec(args)) = cli.command else {
14575            panic!("expected exec command");
14576        };
14577
14578        assert_eq!(args.resume.as_deref(), Some("abc123"));
14579        assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
14580        assert_eq!(args.prompt, vec!["follow up"]);
14581    }
14582
14583    #[test]
14584    fn exec_accepts_session_id_alias() {
14585        let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]);
14586        let Some(Commands::Exec(args)) = cli.command else {
14587            panic!("expected exec command");
14588        };
14589
14590        assert_eq!(args.session_id.as_deref(), Some("abc123"));
14591        assert_eq!(args.output_format, ExecOutputFormat::Text);
14592    }
14593
14594    #[test]
14595    fn exec_parses_tool_gate_and_hardening_flags() {
14596        let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#;
14597        let cli = parse_cli(&[
14598            "codewhale",
14599            "exec",
14600            "--allowed-tools",
14601            "File,Git",
14602            "--disallowed-tools",
14603            "Bash",
14604            "--max-turns",
14605            "7",
14606            "--append-system-prompt",
14607            "extra rules",
14608            "--tool-authority-json",
14609            envelope,
14610            "do the thing",
14611        ]);
14612        let Some(Commands::Exec(args)) = cli.command else {
14613            panic!("expected exec command");
14614        };
14615
14616        assert_eq!(
14617            args.allowed_tools.as_deref(),
14618            Some(&["File".to_string(), "Git".to_string()][..])
14619        );
14620        assert_eq!(
14621            args.disallowed_tools.as_deref(),
14622            Some(&["Bash".to_string()][..])
14623        );
14624        assert_eq!(args.max_turns, Some(7));
14625        assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
14626        assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
14627        assert_eq!(args.prompt, vec!["do the thing"]);
14628    }
14629
14630    #[test]
14631    fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
14632        assert!(validate_exec_tool_authority_resume(None, true).is_ok());
14633        assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
14634        let error = validate_exec_tool_authority_resume(Some("{}"), true)
14635            .expect_err("authority must remain bound to its fresh Fleet launch")
14636            .to_string();
14637        assert!(error.contains("cannot be combined with exec --resume"));
14638    }
14639
14640    #[test]
14641    fn exec_auto_does_not_authorize_sandbox_elevation() {
14642        let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
14643        let Some(Commands::Exec(args)) = cli.command else {
14644            panic!("expected exec command");
14645        };
14646
14647        assert!(!exec_sandbox_elevation_authorized(
14648            args.allow_sandbox_elevation,
14649            args.sandbox.as_deref()
14650        ));
14651    }
14652
14653    #[test]
14654    fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
14655        let danger = parse_cli(&[
14656            "codewhale",
14657            "exec",
14658            "--auto",
14659            "--sandbox",
14660            "danger-full-access",
14661            "run it",
14662        ]);
14663        let Some(Commands::Exec(args)) = danger.command else {
14664            panic!("expected exec command");
14665        };
14666        assert!(exec_sandbox_elevation_authorized(
14667            args.allow_sandbox_elevation,
14668            args.sandbox.as_deref()
14669        ));
14670
14671        let flag = parse_cli(&[
14672            "codewhale",
14673            "exec",
14674            "--auto",
14675            "--allow-sandbox-elevation",
14676            "run it",
14677        ]);
14678        let Some(Commands::Exec(args)) = flag.command else {
14679            panic!("expected exec command");
14680        };
14681        assert!(exec_sandbox_elevation_authorized(
14682            args.allow_sandbox_elevation,
14683            args.sandbox.as_deref()
14684        ));
14685    }
14686
14687    #[test]
14688    fn exec_sandbox_denial_stream_event_is_typed() {
14689        let event = ExecStreamEvent::SandboxDenied {
14690            tool_id: "call_1".to_string(),
14691            tool_name: "exec_shell".to_string(),
14692            reason: "write blocked".to_string(),
14693            outcome: "approval_required".to_string(),
14694        };
14695        let value: serde_json::Value =
14696            serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
14697                .expect("valid json");
14698        assert_eq!(value["type"], "sandbox_denied");
14699        assert_eq!(value["outcome"], "approval_required");
14700    }
14701
14702    #[test]
14703    fn exec_help_separates_agent_mode_from_sandbox_elevation() {
14704        let mut cli = Cli::command();
14705        let help = cli
14706            .find_subcommand_mut("exec")
14707            .expect("exec command")
14708            .render_help()
14709            .to_string();
14710        assert!(help.contains("--auto"));
14711        assert!(help.contains("--sandbox"));
14712        assert!(help.contains("--allow-sandbox-elevation"));
14713        assert!(help.contains("does not change the"));
14714        assert!(help.contains("explicitly authorize sandbox elevation"));
14715    }
14716
14717    #[test]
14718    fn exec_shell_only_tool_surface_env_sets_shell_allowlist() {
14719        let _env_lock = crate::test_support::lock_test_env();
14720        let _surface =
14721            crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, " shell-only ");
14722
14723        let allowed_tools = resolve_exec_allowed_tools(None, exec_tool_surface_from_env())
14724            .expect("shell-only surface should set an allowlist");
14725
14726        assert_eq!(allowed_tools, vec!["bash".to_string()]);
14727    }
14728
14729    #[test]
14730    fn exec_explicit_allowed_tools_override_shell_only_env() {
14731        let _env_lock = crate::test_support::lock_test_env();
14732        let _surface =
14733            crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "shell-only");
14734        let explicit = vec![" File ".to_string(), "GIT".to_string()];
14735
14736        let allowed_tools =
14737            resolve_exec_allowed_tools(Some(&explicit), exec_tool_surface_from_env())
14738                .expect("explicit allowlist should be preserved");
14739
14740        assert_eq!(allowed_tools, vec!["file".to_string(), "git".to_string()]);
14741    }
14742
14743    #[test]
14744    fn exec_full_tool_surface_env_leaves_allowlist_unset() {
14745        let _env_lock = crate::test_support::lock_test_env();
14746        let _surface = crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "full");
14747
14748        assert_eq!(
14749            resolve_exec_allowed_tools(None, exec_tool_surface_from_env()),
14750            None
14751        );
14752    }
14753
14754    #[test]
14755    fn exec_unknown_tool_surface_env_warns_without_allowlist() {
14756        assert!(should_warn_unknown_exec_tool_surface("shell_onyl"));
14757        assert!(!should_warn_unknown_exec_tool_surface("shell-only"));
14758        assert!(!should_warn_unknown_exec_tool_surface("native-tools"));
14759        assert!(!should_warn_unknown_exec_tool_surface("full"));
14760        assert!(!should_warn_unknown_exec_tool_surface(" "));
14761        assert_eq!(parse_exec_tool_surface("shell_onyl"), None);
14762    }
14763
14764    #[test]
14765    fn exec_rejects_zero_max_turns() {
14766        let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
14767            .expect_err("max-turns must be >= 1");
14768        assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
14769    }
14770
14771    #[test]
14772    fn exec_omits_the_headless_turn_cap_by_default() {
14773        let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
14774        let Some(Commands::Exec(args)) = cli.command else {
14775            panic!("expected exec command");
14776        };
14777
14778        assert_eq!(args.max_turns, None);
14779        assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
14780        assert_eq!(exec_max_steps(Some(7)), 7);
14781    }
14782
14783    #[test]
14784    fn exec_accepts_continue_for_latest_workspace_session() {
14785        let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);
14786        let Some(Commands::Exec(args)) = cli.command else {
14787            panic!("expected exec command");
14788        };
14789
14790        assert!(args.continue_session);
14791    }
14792
14793    #[test]
14794    fn sessions_footer_points_to_resume_subcommand() {
14795        let cli = parse_cli(&["codewhale", "resume", "abc123"]);
14796        let Some(Commands::Resume { session_id, last }) = cli.command else {
14797            panic!("expected resume command");
14798        };
14799
14800        assert_eq!(session_id.as_deref(), Some("abc123"));
14801        assert!(!last);
14802        assert_eq!(sessions_resume_command(), "codewhale resume");
14803        assert!(!sessions_resume_command().contains("--resume"));
14804    }
14805
14806    #[test]
14807    fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() {
14808        use std::cell::Cell;
14809
14810        #[derive(Clone, Copy)]
14811        enum Expected {
14812            Plain,
14813            Resume,
14814            Fork,
14815            Exec,
14816            Serve,
14817        }
14818
14819        let cases: &[(&[&str], Expected)] = &[
14820            (&["codewhale"], Expected::Plain),
14821            (&["codewhale", "resume", "--last"], Expected::Resume),
14822            (&["codewhale", "fork", "--last"], Expected::Fork),
14823            (&["codewhale", "exec", "probe"], Expected::Exec),
14824            (&["codewhale", "serve", "--mcp"], Expected::Serve),
14825        ];
14826
14827        for (args, expected) in cases {
14828            let phase = Cell::new(0);
14829            let (_cli, command) = prepare_cli_startup(
14830                parse_cli(args),
14831                || {
14832                    assert_eq!(phase.get(), 0, "plugin init order for {args:?}");
14833                    phase.set(1);
14834                },
14835                || {
14836                    assert_eq!(phase.get(), 1, "dotenv load order for {args:?}");
14837                    phase.set(2);
14838                },
14839            );
14840
14841            assert_eq!(phase.get(), 2, "startup phases for {args:?}");
14842            let correct_variant = matches!(
14843                (expected, command.as_ref()),
14844                (Expected::Plain, None)
14845                    | (Expected::Resume, Some(Commands::Resume { .. }))
14846                    | (Expected::Fork, Some(Commands::Fork { .. }))
14847                    | (Expected::Exec, Some(Commands::Exec(_)))
14848                    | (Expected::Serve, Some(Commands::Serve(_)))
14849            );
14850            assert!(correct_variant, "unexpected command for {args:?}");
14851        }
14852    }
14853
14854    #[test]
14855    fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() {
14856        let _lock = crate::test_support::lock_test_env();
14857        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14858        let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key");
14859        let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
14860        let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
14861        let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL");
14862        let tmp = tempfile::TempDir::new().expect("temp workspace");
14863        let dotenv = tmp.path().join(".env");
14864        std::fs::write(
14865            &dotenv,
14866            "DEEPSEEK_API_KEY=workspace-key\n\
14867             NVIDIA_API_KEY=repo-must-not-override-shell\n\
14868             CODEWHALE_HOME=./attacker-home\n\
14869             CODEWHALE_CONFIG_PATH=./attacker.toml\n\
14870             DEEPSEEK_ALLOW_SHELL=true\n",
14871        )
14872        .expect("write dotenv");
14873
14874        let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load");
14875
14876        assert_eq!(
14877            std::env::var("DEEPSEEK_API_KEY").as_deref(),
14878            Ok("workspace-key")
14879        );
14880        assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key"));
14881        assert!(std::env::var_os("CODEWHALE_HOME").is_none());
14882        assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none());
14883        assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none());
14884        assert_eq!(
14885            report.loaded,
14886            BTreeSet::from(["DEEPSEEK_API_KEY".to_string()])
14887        );
14888        assert_eq!(
14889            report.ignored,
14890            BTreeSet::from([
14891                "CODEWHALE_CONFIG_PATH".to_string(),
14892                "CODEWHALE_HOME".to_string(),
14893                "DEEPSEEK_ALLOW_SHELL".to_string(),
14894            ])
14895        );
14896    }
14897
14898    #[test]
14899    fn workspace_dotenv_rejects_ambient_variable_substitution() {
14900        let _lock = crate::test_support::lock_test_env();
14901        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14902        let _ambient = crate::test_support::EnvVarGuard::set(
14903            "CODEWHALE_JS_SECRET_LEAK_TEST",
14904            "ambient-secret-must-not-expand",
14905        );
14906        let tmp = tempfile::TempDir::new().expect("temp workspace");
14907        let dotenv = tmp.path().join(".env");
14908        std::fs::write(
14909            &dotenv,
14910            "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n",
14911        )
14912        .expect("write dotenv");
14913
14914        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
14915            .expect_err("expansion must fail closed")
14916            .to_string();
14917
14918        assert!(error.contains("variable expansion"));
14919        assert!(!error.contains("ambient-secret-must-not-expand"));
14920        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
14921    }
14922
14923    #[test]
14924    fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() {
14925        let _lock = crate::test_support::lock_test_env();
14926        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14927        let _ambient = crate::test_support::EnvVarGuard::set(
14928            "CODEWHALE_JS_SECRET_LEAK_TEST",
14929            "ambient-secret-must-not-expand",
14930        );
14931        let tmp = tempfile::TempDir::new().expect("temp workspace");
14932        let dotenv = tmp.path().join(".env");
14933        std::fs::write(
14934            &dotenv,
14935            "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n",
14936        )
14937        .expect("write dotenv");
14938
14939        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
14940            .expect_err("multiline expansion must fail closed")
14941            .to_string();
14942
14943        assert!(error.contains("variable expansion"));
14944        assert!(!error.contains("ambient-secret-must-not-expand"));
14945        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
14946    }
14947
14948    #[test]
14949    fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() {
14950        let _lock = crate::test_support::lock_test_env();
14951        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14952        let _ambient = crate::test_support::EnvVarGuard::set(
14953            "CODEWHALE_JS_SECRET_LEAK_TEST",
14954            "ambient-secret-must-not-expand",
14955        );
14956        let tmp = tempfile::TempDir::new().expect("temp workspace");
14957        let dotenv = tmp.path().join(".env");
14958        std::fs::write(
14959            &dotenv,
14960            "# unmatched quote in ignored comment: '\n\
14961             DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n",
14962        )
14963        .expect("write dotenv");
14964
14965        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
14966            .expect_err("comment quote must not hide expansion")
14967            .to_string();
14968
14969        assert!(error.contains("variable expansion"));
14970        assert!(!error.contains("ambient-secret-must-not-expand"));
14971        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
14972    }
14973
14974    #[test]
14975    fn workspace_dotenv_allows_single_quoted_literal_dollar() {
14976        let _lock = crate::test_support::lock_test_env();
14977        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14978        let tmp = tempfile::TempDir::new().expect("temp workspace");
14979        let dotenv = tmp.path().join(".env");
14980        std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv");
14981
14982        load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load");
14983
14984        assert_eq!(
14985            std::env::var("DEEPSEEK_API_KEY").as_deref(),
14986            Ok("$literal-value")
14987        );
14988    }
14989
14990    #[test]
14991    fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() {
14992        let _lock = crate::test_support::lock_test_env();
14993        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
14994        let tmp = tempfile::TempDir::new().expect("temp workspace");
14995        let dotenv = tmp.path().join(".env");
14996        std::fs::write(
14997            &dotenv,
14998            "DEEPSEEK_API_KEY=must-not-survive\nBROKEN=\"unterminated\n",
14999        )
15000        .expect("write dotenv");
15001
15002        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15003            .expect_err("parse failure must be transactional")
15004            .to_string();
15005
15006        assert!(error.contains("could not be parsed safely"), "{error}");
15007        assert!(!error.contains("must-not-survive"));
15008        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15009    }
15010
15011    #[test]
15012    fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() {
15013        for provider in codewhale_config::provider::providers_sorted_for_display() {
15014            for key in provider.env_vars() {
15015                assert!(
15016                    is_workspace_dotenv_credential_key(key),
15017                    "provider credential {key} must remain supported"
15018                );
15019            }
15020        }
15021        for key in [
15022            "CODEWHALE_HOME",
15023            "CODEWHALE_CONFIG_PATH",
15024            "DEEPSEEK_CONFIG_PATH",
15025            "DEEPSEEK_PROFILE",
15026            "DEEPSEEK_MANAGED_CONFIG_PATH",
15027            "DEEPSEEK_REQUIREMENTS_PATH",
15028            "DEEPSEEK_PROVIDER",
15029            "DEEPSEEK_BASE_URL",
15030            "DEEPSEEK_MODEL",
15031            "DEEPSEEK_APPROVAL_POLICY",
15032            "DEEPSEEK_SANDBOX_MODE",
15033            "DEEPSEEK_ALLOW_SHELL",
15034            "DEEPSEEK_YOLO",
15035            "DEEPSEEK_MCP_CONFIG",
15036            "CODEWHALE_RUNTIME_TOKEN",
15037            "PATH",
15038            "NODE_OPTIONS",
15039            "PYTHONPATH",
15040            "LD_PRELOAD",
15041            "DYLD_INSERT_LIBRARIES",
15042        ] {
15043            assert!(
15044                !is_workspace_dotenv_credential_key(key),
15045                "control-plane variable {key} must not load from a workspace"
15046            );
15047        }
15048    }
15049
15050    #[cfg(unix)]
15051    #[test]
15052    fn workspace_dotenv_does_not_follow_symbolic_links() {
15053        use std::os::unix::fs::symlink;
15054
15055        let tmp = tempfile::TempDir::new().expect("temp workspace");
15056        let external = tmp.path().join("external-credentials");
15057        let dotenv = tmp.path().join(".env");
15058        std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15059            .expect("write external fixture");
15060        symlink(&external, &dotenv).expect("create dotenv symlink");
15061
15062        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15063            .expect_err("symlink must fail closed")
15064            .to_string();
15065
15066        assert!(error.contains("securely open"), "{error}");
15067        assert!(!error.contains("external-secret"));
15068    }
15069
15070    #[cfg(unix)]
15071    #[test]
15072    fn workspace_dotenv_rejects_hard_links_to_external_files() {
15073        let tmp = tempfile::TempDir::new().expect("temp workspace");
15074        let external = tmp.path().join("external-credentials");
15075        let dotenv = tmp.path().join(".env");
15076        std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15077            .expect("write external fixture");
15078        std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link");
15079
15080        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15081            .expect_err("hard link must fail closed")
15082            .to_string();
15083
15084        assert!(error.contains("multiple filesystem links"), "{error}");
15085        assert!(!error.contains("external-secret"));
15086    }
15087
15088    #[cfg(unix)]
15089    #[test]
15090    fn workspace_dotenv_rejects_fifo_without_blocking_startup() {
15091        use std::ffi::CString;
15092        use std::os::unix::ffi::OsStrExt;
15093        use std::sync::mpsc;
15094        use std::time::Duration;
15095
15096        let tmp = tempfile::TempDir::new().expect("temp workspace");
15097        let dotenv = tmp.path().join(".env");
15098        let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path");
15099        // SAFETY: `c_path` is a live, NUL-terminated path and the requested
15100        // mode grants access only to the current user.
15101        let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) };
15102        assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
15103
15104        let (tx, rx) = mpsc::channel();
15105        let worker_path = dotenv.clone();
15106        let worker = std::thread::spawn(move || {
15107            let result = load_workspace_dotenv_credentials_from_path(&worker_path)
15108                .map(|_| "unexpected success".to_string())
15109                .unwrap_or_else(|error| error.to_string());
15110            tx.send(result).expect("send loader result");
15111        });
15112
15113        let error = match rx.recv_timeout(Duration::from_secs(1)) {
15114            Ok(error) => error,
15115            Err(timeout) => {
15116                // Release a regressed blocking reader so the test can fail
15117                // promptly instead of leaving a stuck process behind.
15118                let _writer = std::fs::OpenOptions::new()
15119                    .write(true)
15120                    .open(&dotenv)
15121                    .expect("open fifo writer to release blocked reader");
15122                let _ = rx.recv_timeout(Duration::from_secs(1));
15123                worker.join().expect("join released loader");
15124                panic!("workspace .env FIFO blocked startup: {timeout}");
15125            }
15126        };
15127        worker.join().expect("join loader");
15128
15129        assert!(error.contains("not a regular file"), "{error}");
15130    }
15131
15132    #[test]
15133    fn exec_json_conflicts_with_stream_json_output() {
15134        let err = Cli::try_parse_from([
15135            "codewhale",
15136            "exec",
15137            "--json",
15138            "--output-format",
15139            "stream-json",
15140            "hello",
15141        ])
15142        .expect_err("json summary and stream-json must not mix");
15143
15144        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
15145    }
15146
15147    #[test]
15148    fn exec_stream_turn_usage_event_serializes_reported_fields() {
15149        let event = ExecStreamEvent::TurnUsage {
15150            turn: 2,
15151            input_tokens: 1200,
15152            output_tokens: 180,
15153            reasoning_tokens: Some(90),
15154            prompt_cache_hit_tokens: Some(900),
15155            prompt_cache_miss_tokens: Some(300),
15156            prompt_cache_write_tokens: Some(0),
15157            reasoning_replay_tokens: Some(40),
15158            duration_ms: 1834,
15159        };
15160
15161        let value = exec_stream_value(&event).expect("serializes");
15162        let json = serde_json::to_string(&value).expect("serializes");
15163        assert!(!json.contains('\n'));
15164        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15165        assert_eq!(parsed["type"], "turn_usage");
15166        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15167        assert_eq!(parsed["schema_version"], 1);
15168        assert_eq!(parsed["turn"], 2);
15169        assert_eq!(parsed["input_tokens"], 1200);
15170        assert_eq!(parsed["output_tokens"], 180);
15171        assert_eq!(parsed["reasoning_tokens"], 90);
15172        assert_eq!(parsed["prompt_cache_hit_tokens"], 900);
15173        assert_eq!(parsed["prompt_cache_miss_tokens"], 300);
15174        assert_eq!(parsed["prompt_cache_write_tokens"], 0);
15175        assert_eq!(parsed["reasoning_replay_tokens"], 40);
15176        assert_eq!(parsed["duration_ms"], 1834);
15177    }
15178
15179    #[test]
15180    fn exec_stream_turn_usage_event_omits_unreported_fields() {
15181        // Honest absence: optional token fields the provider did not report
15182        // are dropped from the object entirely — never emitted as null and
15183        // never backfilled with fabricated zeros.
15184        let event = ExecStreamEvent::TurnUsage {
15185            turn: 1,
15186            input_tokens: 11,
15187            output_tokens: 3,
15188            reasoning_tokens: None,
15189            prompt_cache_hit_tokens: None,
15190            prompt_cache_miss_tokens: None,
15191            prompt_cache_write_tokens: None,
15192            reasoning_replay_tokens: None,
15193            duration_ms: 250,
15194        };
15195
15196        let value = exec_stream_value(&event).expect("serializes");
15197        let parsed = value;
15198        assert_eq!(parsed["type"], "turn_usage");
15199        assert_eq!(parsed["input_tokens"], 11);
15200        assert_eq!(parsed["output_tokens"], 3);
15201        assert_eq!(parsed["duration_ms"], 250);
15202        let object = parsed.as_object().expect("event object");
15203        for absent in [
15204            "reasoning_tokens",
15205            "prompt_cache_hit_tokens",
15206            "prompt_cache_miss_tokens",
15207            "prompt_cache_write_tokens",
15208            "reasoning_replay_tokens",
15209        ] {
15210            assert!(!object.contains_key(absent), "{absent} leaked: {parsed}");
15211        }
15212    }
15213
15214    #[test]
15215    fn exec_stream_pre_existing_event_type_tags_are_unchanged() {
15216        // Contract guard for existing stream consumers (bench harness, fleet
15217        // ledger): the pre-turn_usage event vocabulary keeps its exact tags.
15218        let cases: Vec<(ExecStreamEvent, &str)> = vec![
15219            (
15220                ExecStreamEvent::Content {
15221                    content: "hi".to_string(),
15222                },
15223                "content",
15224            ),
15225            (
15226                ExecStreamEvent::ToolUse {
15227                    name: "read_file".to_string(),
15228                    id: "call_1".to_string(),
15229                    input: serde_json::json!({}),
15230                    started_at: "2026-08-03T00:00:00Z".to_string(),
15231                },
15232                "tool_use",
15233            ),
15234            (
15235                ExecStreamEvent::ToolResult {
15236                    id: "call_1".to_string(),
15237                    name: "read_file".to_string(),
15238                    output: "ok".to_string(),
15239                    status: "success".to_string(),
15240                    started_at: "2026-08-03T00:00:00Z".to_string(),
15241                    completed_at: "2026-08-03T00:00:01Z".to_string(),
15242                    duration_ms: 1,
15243                    side_effect_status: "unknown".to_string(),
15244                    error_category: None,
15245                    truncated: None,
15246                    artifact: None,
15247                    result_metadata: None,
15248                },
15249                "tool_result",
15250            ),
15251            (
15252                ExecStreamEvent::SandboxDenied {
15253                    tool_id: "call_1".to_string(),
15254                    tool_name: "exec_shell".to_string(),
15255                    reason: "denied".to_string(),
15256                    outcome: "approval_required".to_string(),
15257                },
15258                "sandbox_denied",
15259            ),
15260            (
15261                ExecStreamEvent::WorkflowEvent {
15262                    run_id: "workflow_1".to_string(),
15263                    event: serde_json::json!({"type": "task_completed"}),
15264                },
15265                "workflow_event",
15266            ),
15267            (
15268                ExecStreamEvent::SessionCapture {
15269                    content: "x".to_string(),
15270                },
15271                "session_capture",
15272            ),
15273            (
15274                ExecStreamEvent::Error {
15275                    error: "boom".to_string(),
15276                },
15277                "error",
15278            ),
15279            (ExecStreamEvent::Done, "done"),
15280        ];
15281
15282        for (event, expected_type) in cases {
15283            let value = exec_stream_value(&event).expect("serializes");
15284            assert_eq!(value["type"], expected_type, "event tag drifted");
15285            assert_eq!(value["schema"], "codewhale.exec-stream");
15286            assert_eq!(value["schema_version"], 1);
15287        }
15288    }
15289
15290    #[test]
15291    fn exec_stream_events_are_json_lines() {
15292        let event = ExecStreamEvent::ToolResult {
15293            id: "call_1".to_string(),
15294            name: "read_file".to_string(),
15295            output: "line 1\nline 2".to_string(),
15296            status: "success".to_string(),
15297            started_at: "2026-07-13T00:00:00Z".to_string(),
15298            completed_at: "2026-07-13T00:00:01Z".to_string(),
15299            duration_ms: 1000,
15300            side_effect_status: "not_started".to_string(),
15301            error_category: None,
15302            truncated: Some(false),
15303            artifact: None,
15304            result_metadata: None,
15305        };
15306
15307        let value = exec_stream_value(&event).expect("serializes");
15308        let json = serde_json::to_string(&value).expect("serializes");
15309        assert!(!json.contains('\n'));
15310        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15311        assert_eq!(parsed["type"], "tool_result");
15312        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15313        assert_eq!(parsed["schema_version"], 1);
15314        assert_eq!(parsed["duration_ms"], 1000);
15315        assert_eq!(parsed["side_effect_status"], "not_started");
15316    }
15317
15318    #[test]
15319    fn workflow_receipt_stream_event_is_one_json_line() {
15320        let event = ExecStreamEvent::WorkflowEvent {
15321            run_id: "workflow_1234".to_string(),
15322            event: serde_json::json!({
15323                "type": "handoff_promoted",
15324                "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15325                "gate_id": "review-gate",
15326                "kind": "review_report",
15327                "from_role": "reviewer",
15328                "to_role": "verifier",
15329                "producer_task_id": "agent_1"
15330            }),
15331        };
15332
15333        let value = exec_stream_value(&event).expect("serializes");
15334        let json = serde_json::to_string(&value).expect("serializes");
15335        assert!(!json.contains('\n'));
15336        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15337        assert_eq!(parsed["type"], "workflow_event");
15338        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15339        assert_eq!(parsed["schema_version"], 1);
15340        assert_eq!(parsed["run_id"], "workflow_1234");
15341        assert_eq!(parsed["event"]["type"], "handoff_promoted");
15342        assert_eq!(
15343            parsed["event"]["artifact_id"],
15344            "workflow_1234:agent_1:review-gate:review_report"
15345        );
15346        assert_eq!(parsed["event"]["gate_id"], "review-gate");
15347        assert_eq!(parsed["event"]["kind"], "review_report");
15348        assert_eq!(parsed["event"]["from_role"], "reviewer");
15349        assert_eq!(parsed["event"]["to_role"], "verifier");
15350        assert_eq!(parsed["event"]["producer_task_id"], "agent_1");
15351        assert!(parsed["event"].get("payload").is_none(), "{parsed}");
15352
15353        let consumed = ExecStreamEvent::WorkflowEvent {
15354            run_id: "workflow_1234".to_string(),
15355            event: serde_json::json!({
15356                "type": "handoff_consumed",
15357                "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15358                "kind": "review_report",
15359                "from_role": "reviewer",
15360                "to_role": "verifier",
15361                "consumer_task_id": "agent_2"
15362            }),
15363        };
15364        let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt");
15365        assert_eq!(consumed["type"], "workflow_event");
15366        assert_eq!(consumed["schema"], "codewhale.exec-stream");
15367        assert_eq!(consumed["schema_version"], 1);
15368        assert_eq!(consumed["event"]["type"], "handoff_consumed");
15369        assert_eq!(
15370            consumed["event"]["artifact_id"],
15371            "workflow_1234:agent_1:review-gate:review_report"
15372        );
15373        assert_eq!(consumed["event"]["consumer_task_id"], "agent_2");
15374        assert!(consumed["event"].get("payload").is_none(), "{consumed}");
15375    }
15376
15377    #[test]
15378    fn exec_stream_metadata_redacts_resume_breadcrumbs() {
15379        let raw_session_id = "abc123fullsecret";
15380        let event = ExecStreamEvent::Metadata {
15381            meta: Box::new(ExecStreamMeta {
15382                receipt_kind: "terminal",
15383                provider: "deepseek".to_string(),
15384                provider_id: None,
15385                model: "deepseek-v4-flash".to_string(),
15386                route_source: "explicit_or_configured".to_string(),
15387                input_tokens: Some(123),
15388                output_tokens: Some(45),
15389                prompt_cache_hit_tokens: Some(10),
15390                prompt_cache_miss_tokens: None,
15391                prompt_cache_write_tokens: None,
15392                reasoning_tokens: Some(3),
15393                duration_ms: 2500,
15394                retry_count: None,
15395                approval_posture: "ask".to_string(),
15396                sandbox_posture: "configured_default".to_string(),
15397                binary_sha256: Some("sha256:binary".to_string()),
15398                config_sha256: None,
15399                prompt_sha256: "sha256:prompt".to_string(),
15400                tool_catalog_sha256: Some("sha256:tools".to_string()),
15401                input_analysis: ExecStreamInputAnalysis::default(),
15402                visible_final_answer_chars: 17,
15403                session_id: exec_stream_session_ref(raw_session_id),
15404                resume_command: exec_stream_resume_hint(raw_session_id),
15405                workspace: "/tmp/work".to_string(),
15406                message_count: 4,
15407                status: Some("completed".to_string()),
15408                termination_reason: Some("resolved".to_string()),
15409                error_category: None,
15410            }),
15411        };
15412
15413        let json = serde_json::to_string(&event).expect("serializes");
15414        assert!(!json.contains('\n'));
15415        assert!(!json.contains(raw_session_id));
15416        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15417        assert_eq!(parsed["type"], "metadata");
15418        assert_ne!(parsed["meta"]["session_id"], raw_session_id);
15419        assert!(
15420            parsed["meta"]["session_id"]
15421                .as_str()
15422                .unwrap()
15423                .starts_with("<redacted:")
15424        );
15425        assert_eq!(
15426            parsed["meta"]["resume_command"],
15427            "codewhale exec --resume <redacted-session-id>"
15428        );
15429        assert_eq!(parsed["meta"]["workspace"], "/tmp/work");
15430        assert_eq!(parsed["meta"]["message_count"], 4);
15431        assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17);
15432
15433        let capture = ExecStreamEvent::SessionCapture {
15434            content: exec_stream_session_ref(raw_session_id),
15435        };
15436        let capture_json = serde_json::to_string(&capture).expect("serializes");
15437        assert!(!capture_json.contains(raw_session_id));
15438        let parsed_capture: serde_json::Value =
15439            serde_json::from_str(&capture_json).expect("valid json");
15440        assert_eq!(parsed_capture["type"], "session_capture");
15441        assert_ne!(parsed_capture["content"], raw_session_id);
15442    }
15443
15444    #[test]
15445    fn exec_stream_input_analysis_reports_prompt_composition() {
15446        let system = SystemPrompt::Text("system rules".to_string());
15447        let messages = vec![
15448            Message {
15449                role: "user".to_string(),
15450                content: vec![ContentBlock::Text {
15451                    text: "run tests".to_string(),
15452                    cache_control: None,
15453                }],
15454            },
15455            Message {
15456                role: "assistant".to_string(),
15457                content: vec![
15458                    ContentBlock::Thinking {
15459                        thinking: "checking context".to_string(),
15460                        signature: None,
15461                    },
15462                    ContentBlock::Text {
15463                        text: "working".to_string(),
15464                        cache_control: None,
15465                    },
15466                    ContentBlock::ToolUse {
15467                        id: "call-1".to_string(),
15468                        name: "exec_shell".to_string(),
15469                        input: serde_json::json!({"command": "cargo test"}),
15470                        caller: None,
15471                    },
15472                ],
15473            },
15474            Message {
15475                role: "user".to_string(),
15476                content: vec![ContentBlock::ToolResult {
15477                    tool_use_id: "call-1".to_string(),
15478                    content: "stdout line\nstderr line".to_string(),
15479                    is_error: Some(false),
15480                    content_blocks: Some(vec![serde_json::json!({
15481                        "type": "text",
15482                        "text": "structured output"
15483                    })]),
15484                }],
15485            },
15486        ];
15487
15488        let analysis = exec_stream_input_analysis(&messages, Some(&system));
15489
15490        assert_eq!(analysis.user_message_count, 2);
15491        assert_eq!(analysis.assistant_message_count, 1);
15492        assert_eq!(analysis.tool_message_count, 0);
15493        assert_eq!(analysis.tool_use_count, 1);
15494        assert_eq!(analysis.tool_result_count, 1);
15495        assert_eq!(analysis.thinking_chars, "checking context".chars().count());
15496        assert!(analysis.text_chars >= "run testsworking".chars().count());
15497        assert!(analysis.tool_use_input_chars > 0);
15498        assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count());
15499        assert!(analysis.estimated_system_tokens > 0);
15500        assert!(analysis.estimated_message_content_tokens > 0);
15501        assert!(
15502            analysis.estimated_request_tokens
15503                >= analysis.estimated_system_tokens
15504                    + analysis.estimated_message_content_tokens
15505                    + analysis.estimated_framing_tokens
15506        );
15507    }
15508
15509    #[test]
15510    fn review_receipt_check_public_json_omits_private_details() {
15511        let validation = crate::tools::review::ReviewReceiptValidation {
15512            passed: false,
15513            reason: "secret reason with /tmp/private/receipt.json".to_string(),
15514            diff_fingerprint: "sha256:current".to_string(),
15515            receipt_fingerprint: Some("sha256:current".to_string()),
15516            receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")),
15517            unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk {
15518                unresolved: true,
15519                level: "error".to_string(),
15520                summary: "secret summary".to_string(),
15521            }),
15522        };
15523
15524        let public = review_receipt_validation_public_json(&validation);
15525        let encoded = serde_json::to_string(&public).expect("public json");
15526
15527        assert_eq!(public["passed"], false);
15528        assert_eq!(public["status"], "unresolved_risk");
15529        assert_eq!(public["risk_level"], "error");
15530        assert!(!encoded.contains("secret"));
15531        assert!(!encoded.contains("/tmp/private"));
15532    }
15533
15534    #[test]
15535    fn exec_text_session_breadcrumbs_use_compact_ids() {
15536        let session_id = "1234567890abcdef";
15537
15538        assert_eq!(exec_saved_session_line(session_id), "session: 12345678");
15539        assert_eq!(
15540            exec_resumed_session_line(session_id),
15541            "resumed session: 12345678"
15542        );
15543        assert!(!exec_saved_session_line(session_id).contains(session_id));
15544        assert!(!exec_resumed_session_line(session_id).contains(session_id));
15545    }
15546
15547    #[test]
15548    fn alternate_screen_defaults_on_in_auto_mode() {
15549        let cli = parse_cli(&["codewhale"]);
15550        let config = Config::default();
15551
15552        assert!(should_use_alt_screen(&cli, &config));
15553    }
15554
15555    #[test]
15556    fn removed_no_alt_screen_flag_is_rejected() {
15557        // Negative test: the retired compatibility flag must not be silently
15558        // accepted and must not reach the alternate-screen decision at all.
15559        let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"])
15560            .expect_err("--no-alt-screen must no longer parse");
15561        assert_eq!(
15562            error.kind(),
15563            clap::error::ErrorKind::UnknownArgument,
15564            "retired flag should fail as an unknown argument, not be absorbed"
15565        );
15566    }
15567
15568    #[test]
15569    fn config_never_is_accepted_but_keeps_alternate_screen() {
15570        let cli = parse_cli(&["codewhale"]);
15571        let config = Config {
15572            tui: Some(crate::config::TuiConfig {
15573                alternate_screen: Some("never".to_string()),
15574                mouse_capture: None,
15575                terminal_probe_timeout_ms: None,
15576                stream_chunk_timeout_secs: None,
15577                status_items: None,
15578                osc8_links: None,
15579                composer_arrows_scroll: None,
15580                notification_condition: None,
15581                header_items: None,
15582            }),
15583            ..Config::default()
15584        };
15585
15586        assert!(should_use_alt_screen(&cli, &config));
15587    }
15588
15589    #[test]
15590    #[cfg(not(windows))]
15591    fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
15592        let cli = parse_cli(&["codewhale"]);
15593        let config = Config::default();
15594
15595        assert!(should_use_mouse_capture_with(
15596            &cli, &config, true, None, None, None
15597        ));
15598    }
15599
15600    #[test]
15601    #[cfg(windows)]
15602    fn mouse_capture_defaults_off_on_legacy_windows_console() {
15603        // Legacy conhost (no `WT_SESSION` and no `ConEmuPID`) keeps the
15604        // v0.8.x default-off behavior: mouse-mode reporting on legacy console
15605        // can leak SGR escapes into the composer.
15606        let cli = parse_cli(&["codewhale"]);
15607        let config = Config::default();
15608
15609        assert!(!should_use_mouse_capture_with(
15610            &cli, &config, true, None, None, None
15611        ));
15612    }
15613
15614    // #1169: Windows Terminal sets `WT_SESSION` and handles mouse-mode
15615    // reporting cleanly, so default-on there gives users in-app text
15616    // selection (and the side-effect of clamping selection to the
15617    // transcript region instead of the terminal painting across the
15618    // sidebar via native selection).
15619    #[test]
15620    #[cfg(windows)]
15621    fn mouse_capture_defaults_on_in_windows_terminal() {
15622        let cli = parse_cli(&["codewhale"]);
15623        let config = Config::default();
15624
15625        assert!(should_use_mouse_capture_with(
15626            &cli,
15627            &config,
15628            true,
15629            None,
15630            Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
15631            None,
15632        ));
15633    }
15634
15635    // ConEmu/Cmder sets `ConEmuPID` and handles VT mouse-mode reporting
15636    // cleanly; default mouse capture on there so users get in-app scrolling.
15637    #[test]
15638    #[cfg(windows)]
15639    fn mouse_capture_defaults_on_in_conemu() {
15640        let cli = parse_cli(&["codewhale"]);
15641        let config = Config::default();
15642
15643        assert!(should_use_mouse_capture_with(
15644            &cli,
15645            &config,
15646            true,
15647            None,
15648            None,
15649            Some("12345"),
15650        ));
15651    }
15652
15653    #[test]
15654    fn no_mouse_capture_flag_disables_mouse_capture() {
15655        let cli = parse_cli(&["codewhale", "--no-mouse-capture"]);
15656        let config = Config::default();
15657
15658        assert!(!should_use_mouse_capture_with(
15659            &cli, &config, true, None, None, None
15660        ));
15661    }
15662
15663    #[test]
15664    fn config_can_disable_default_mouse_capture() {
15665        let cli = parse_cli(&["codewhale"]);
15666        let config = Config {
15667            tui: Some(crate::config::TuiConfig {
15668                alternate_screen: None,
15669                mouse_capture: Some(false),
15670                terminal_probe_timeout_ms: None,
15671                stream_chunk_timeout_secs: None,
15672                status_items: None,
15673                osc8_links: None,
15674                composer_arrows_scroll: None,
15675                notification_condition: None,
15676                header_items: None,
15677            }),
15678            ..Config::default()
15679        };
15680
15681        assert!(!should_use_mouse_capture_with(
15682            &cli, &config, true, None, None, None
15683        ));
15684    }
15685
15686    #[test]
15687    fn mouse_capture_flag_enables_mouse_capture() {
15688        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
15689        let config = Config::default();
15690
15691        assert!(should_use_mouse_capture_with(
15692            &cli, &config, true, None, None, None
15693        ));
15694    }
15695
15696    #[test]
15697    fn config_can_enable_mouse_capture() {
15698        let cli = parse_cli(&["codewhale"]);
15699        let config = Config {
15700            tui: Some(crate::config::TuiConfig {
15701                alternate_screen: None,
15702                mouse_capture: Some(true),
15703                terminal_probe_timeout_ms: None,
15704                stream_chunk_timeout_secs: None,
15705                status_items: None,
15706                osc8_links: None,
15707                composer_arrows_scroll: None,
15708                notification_condition: None,
15709                header_items: None,
15710            }),
15711            ..Config::default()
15712        };
15713
15714        assert!(should_use_mouse_capture_with(
15715            &cli, &config, true, None, None, None
15716        ));
15717    }
15718
15719    #[test]
15720    fn mouse_capture_is_off_without_alternate_screen() {
15721        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
15722        let config = Config::default();
15723
15724        assert!(!should_use_mouse_capture_with(
15725            &cli, &config, false, None, None, None
15726        ));
15727    }
15728
15729    // Issue #878 / #898: JetBrains JediTerm advertises mouse support but
15730    // forwards SGR mouse-event escapes as raw input characters, producing
15731    // the "input box auto-fills with garbled characters when I move the
15732    // mouse" failure mode in PyCharm/IDEA terminals. Default the capture
15733    // off when we see TERMINAL_EMULATOR=JetBrains-JediTerm; explicit
15734    // config / --mouse-capture still wins.
15735
15736    #[test]
15737    fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
15738        let cli = parse_cli(&["codewhale"]);
15739        let config = Config::default();
15740
15741        assert!(!should_use_mouse_capture_with(
15742            &cli,
15743            &config,
15744            true,
15745            Some("JetBrains-JediTerm"),
15746            None,
15747            None,
15748        ));
15749    }
15750
15751    #[test]
15752    fn jetbrains_default_off_is_case_insensitive() {
15753        let cli = parse_cli(&["codewhale"]);
15754        let config = Config::default();
15755
15756        // JetBrains has occasionally varied the casing across releases;
15757        // a case-insensitive match keeps the protection in place.
15758        assert!(!should_use_mouse_capture_with(
15759            &cli,
15760            &config,
15761            true,
15762            Some("jetbrains-jediterm"),
15763            None,
15764            None,
15765        ));
15766    }
15767
15768    #[test]
15769    fn mouse_capture_flag_overrides_jetbrains_default() {
15770        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
15771        let config = Config::default();
15772
15773        assert!(should_use_mouse_capture_with(
15774            &cli,
15775            &config,
15776            true,
15777            Some("JetBrains-JediTerm"),
15778            None,
15779            None,
15780        ));
15781    }
15782
15783    #[test]
15784    fn config_mouse_capture_true_overrides_jetbrains_default() {
15785        let cli = parse_cli(&["codewhale"]);
15786        let config = Config {
15787            tui: Some(crate::config::TuiConfig {
15788                alternate_screen: None,
15789                mouse_capture: Some(true),
15790                terminal_probe_timeout_ms: None,
15791                stream_chunk_timeout_secs: None,
15792                status_items: None,
15793                osc8_links: None,
15794                composer_arrows_scroll: None,
15795                notification_condition: None,
15796                header_items: None,
15797            }),
15798            ..Config::default()
15799        };
15800
15801        assert!(should_use_mouse_capture_with(
15802            &cli,
15803            &config,
15804            true,
15805            Some("JetBrains-JediTerm"),
15806            None,
15807            None,
15808        ));
15809    }
15810}
15811
15812#[cfg(test)]
15813mod interactive_startup_tests {
15814    use super::*;
15815
15816    #[test]
15817    fn interactive_tui_defaults_agent_shell_to_approval_gated_on() {
15818        let default_config = Config::default();
15819        assert!(
15820            interactive_tui_allow_shell(false, &default_config),
15821            "interactive Agent mode should expose shell tools by default so approvals can gate commands"
15822        );
15823
15824        let disabled = Config {
15825            allow_shell: Some(false),
15826            ..Config::default()
15827        };
15828        assert!(
15829            !interactive_tui_allow_shell(false, &disabled),
15830            "explicit allow_shell=false still hides shell tools"
15831        );
15832
15833        assert!(
15834            interactive_tui_allow_shell(true, &disabled),
15835            "YOLO forces shell access for its no-guardrails contract"
15836        );
15837    }
15838}
15839
15840#[cfg(test)]
15841mod project_config_tests {
15842    use super::*;
15843    use std::fs;
15844    use tempfile::tempdir;
15845
15846    /// Write a `<workspace>/.deepseek/config.toml` and return the workspace
15847    /// root so the merge function can find it.
15848    fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
15849        let tmp = tempdir().expect("tempdir");
15850        let project_dir = tmp.path().join(".deepseek");
15851        fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
15852        fs::write(project_dir.join("config.toml"), body).expect("write project config");
15853        tmp
15854    }
15855
15856    #[cfg(unix)]
15857    #[test]
15858    fn project_overlay_rejects_symlinked_primary_config() {
15859        let workspace = tempdir().expect("workspace tempdir");
15860        let outside = tempdir().expect("outside tempdir");
15861        let primary_dir = workspace.path().join(codewhale_config::CODEWHALE_APP_DIR);
15862        let legacy_dir = workspace.path().join(codewhale_config::LEGACY_APP_DIR);
15863        fs::create_dir_all(&primary_dir).expect("mkdir primary");
15864        fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
15865        let outside_config = outside.path().join("config.toml");
15866        fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
15867        fs::write(legacy_dir.join("config.toml"), "model = \"legacy-model\"\n")
15868            .expect("write legacy config");
15869        std::os::unix::fs::symlink(&outside_config, primary_dir.join("config.toml"))
15870            .expect("symlink project config");
15871        let mut config = Config {
15872            default_text_model: Some("base-model".to_string()),
15873            ..Config::default()
15874        };
15875
15876        merge_project_config(&mut config, workspace.path());
15877
15878        assert_eq!(
15879            config.default_text_model.as_deref(),
15880            Some("base-model"),
15881            "symlinked primary project config should stop the project overlay"
15882        );
15883    }
15884
15885    fn with_home_dir<T>(home: &Path, f: impl FnOnce() -> T) -> T {
15886        let prev_home = std::env::var_os("HOME");
15887        let prev_userprofile = std::env::var_os("USERPROFILE");
15888        unsafe {
15889            std::env::set_var("HOME", home);
15890            std::env::set_var("USERPROFILE", home);
15891        }
15892        let result = f();
15893        unsafe {
15894            match prev_home {
15895                Some(value) => std::env::set_var("HOME", value),
15896                None => std::env::remove_var("HOME"),
15897            }
15898            match prev_userprofile {
15899                Some(value) => std::env::set_var("USERPROFILE", value),
15900                None => std::env::remove_var("USERPROFILE"),
15901            }
15902        }
15903        result
15904    }
15905
15906    #[test]
15907    fn project_overlay_skips_when_workspace_is_home_directory() {
15908        let _guard = crate::test_support::lock_test_env();
15909        let tmp = tempdir().expect("tempdir");
15910        let project_dir = tmp.path().join(codewhale_config::CODEWHALE_APP_DIR);
15911        fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
15912        fs::write(
15913            project_dir.join("config.toml"),
15914            r#"model = "project-override-model""#,
15915        )
15916        .expect("write project config");
15917
15918        with_home_dir(tmp.path(), || {
15919            let mut config = Config {
15920                default_text_model: Some("deepseek-v4-flash".to_string()),
15921                ..Config::default()
15922            };
15923
15924            merge_project_config(&mut config, tmp.path());
15925
15926            assert_eq!(
15927                config.default_text_model.as_deref(),
15928                Some("deepseek-v4-flash")
15929            );
15930        });
15931    }
15932
15933    #[test]
15934    fn project_overlay_overrides_model_but_denies_provider() {
15935        // #417: `provider` is on the deny-list; only the `model`
15936        // override applies. The denied key emits a stderr warning
15937        // (verified by integration runs; here we assert the post-
15938        // merge state).
15939        let tmp = workspace_with_project_config(
15940            r#"
15941provider = "nvidia-nim"
15942model = "deepseek-ai/deepseek-v4-pro"
15943"#,
15944        );
15945        let mut config = Config::default();
15946        merge_project_config(&mut config, tmp.path());
15947        assert_eq!(
15948            config.provider, None,
15949            "#417: project-scope `provider` must be denied"
15950        );
15951        assert_eq!(
15952            config.default_text_model.as_deref(),
15953            Some("deepseek-ai/deepseek-v4-pro"),
15954            "model is allowed at project scope"
15955        );
15956    }
15957
15958    #[test]
15959    fn project_overlay_denies_dangerous_credentials_and_redirects() {
15960        // #417: `api_key` / `base_url` / `provider` / `mcp_config_path`
15961        // and MCP OAuth callback settings are all on the deny-list. A
15962        // malicious project must not be able to redirect prompts, hijack MCP
15963        // servers, or influence OAuth callback behavior via these.
15964        let tmp = workspace_with_project_config(
15965            r#"
15966api_key = "ATTACKER_KEY"
15967base_url = "https://evil.example.com"
15968provider = "nvidia-nim"
15969mcp_config_path = "/tmp/attacker-mcp.json"
15970mcp_oauth_callback_port = 9999
15971mcp_oauth_callback_url = "http://evil.example.com/callback"
15972"#,
15973        );
15974        let mut config = Config {
15975            api_key: Some("USER_KEY".to_string()),
15976            base_url: Some("https://api.deepseek.com".to_string()),
15977            mcp_oauth_callback_port: Some(1455),
15978            mcp_oauth_callback_url: Some("http://127.0.0.1:1455/callback".to_string()),
15979            ..Config::default()
15980        };
15981        merge_project_config(&mut config, tmp.path());
15982        assert_eq!(
15983            config.api_key.as_deref(),
15984            Some("USER_KEY"),
15985            "user api_key must survive project-config attack"
15986        );
15987        assert_eq!(
15988            config.base_url.as_deref(),
15989            Some("https://api.deepseek.com"),
15990            "user base_url must survive project-config attack"
15991        );
15992        assert_eq!(
15993            config.provider, None,
15994            "project-scope provider must be denied"
15995        );
15996        assert_eq!(
15997            config.mcp_config_path, None,
15998            "project-scope mcp_config_path must be denied"
15999        );
16000        assert_eq!(
16001            config.mcp_oauth_callback_port,
16002            Some(1455),
16003            "project-scope mcp_oauth_callback_port must be denied"
16004        );
16005        assert_eq!(
16006            config.mcp_oauth_callback_url.as_deref(),
16007            Some("http://127.0.0.1:1455/callback"),
16008            "project-scope mcp_oauth_callback_url must be denied"
16009        );
16010    }
16011
16012    #[test]
16013    fn project_overlay_overrides_approval_and_sandbox() {
16014        let tmp = workspace_with_project_config(
16015            r#"
16016approval_policy = "never"
16017sandbox_mode = "read-only"
16018"#,
16019        );
16020        let mut config = Config::default();
16021        merge_project_config(&mut config, tmp.path());
16022        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16023        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16024    }
16025
16026    #[test]
16027    fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
16028        // #417 value-deny: the loosest values (`approval_policy = "auto"`,
16029        // `sandbox_mode = "danger-full-access"`) are pure escalation.
16030        // Even when the user hasn't set these fields, the project
16031        // can't push the session to the loosest posture.
16032        let tmp = workspace_with_project_config(
16033            r#"
16034approval_policy = "auto"
16035sandbox_mode = "danger-full-access"
16036model = "deepseek-v4-pro"
16037"#,
16038        );
16039        let mut config = Config::default();
16040        merge_project_config(&mut config, tmp.path());
16041        assert_eq!(
16042            config.approval_policy, None,
16043            "project-scope `approval_policy = \"auto\"` must be denied"
16044        );
16045        assert_eq!(
16046            config.sandbox_mode, None,
16047            "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
16048        );
16049        // Non-escalation overrides on the same merge succeed —
16050        // the deny is per-key, not per-file.
16051        assert_eq!(
16052            config.default_text_model.as_deref(),
16053            Some("deepseek-v4-pro"),
16054            "non-escalation overrides should still apply"
16055        );
16056    }
16057
16058    #[test]
16059    fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
16060        // Belt-and-suspenders: if the user has `approval_policy = "never"`
16061        // and the project tries `approval_policy = "auto"`, the deny
16062        // keeps the user's strict value rather than falling through to
16063        // None.
16064        let tmp = workspace_with_project_config(
16065            r#"
16066approval_policy = "auto"
16067"#,
16068        );
16069        let mut config = Config {
16070            approval_policy: Some("never".to_string()),
16071            ..Config::default()
16072        };
16073        merge_project_config(&mut config, tmp.path());
16074        assert_eq!(
16075            config.approval_policy.as_deref(),
16076            Some("never"),
16077            "user's strict approval_policy must survive a project escalation attempt"
16078        );
16079    }
16080
16081    #[test]
16082    fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
16083        let tmp = workspace_with_project_config(
16084            r#"
16085approval_policy = "on-request"
16086sandbox_mode = "workspace-write"
16087"#,
16088        );
16089        let mut config = Config {
16090            approval_policy: Some("never".to_string()),
16091            sandbox_mode: Some("read-only".to_string()),
16092            ..Config::default()
16093        };
16094        merge_project_config(&mut config, tmp.path());
16095        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16096        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16097    }
16098
16099    #[test]
16100    fn project_overlay_can_tighten_user_policy() {
16101        let tmp = workspace_with_project_config(
16102            r#"
16103approval_policy = "never"
16104sandbox_mode = "read-only"
16105"#,
16106        );
16107        let mut config = Config {
16108            approval_policy: Some("on-request".to_string()),
16109            sandbox_mode: Some("workspace-write".to_string()),
16110            ..Config::default()
16111        };
16112        merge_project_config(&mut config, tmp.path());
16113        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16114        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16115    }
16116
16117    #[test]
16118    fn project_overlay_can_tighten_saved_full_access_posture() {
16119        let tmp = workspace_with_project_config(
16120            r#"
16121approval_policy = "on-request"
16122"#,
16123        );
16124        let mut config = Config::default();
16125
16126        merge_project_config_with_approval_baseline(&mut config, tmp.path(), Some("full-access"));
16127
16128        assert_eq!(
16129            config.approval_policy.as_deref(),
16130            Some("on-request"),
16131            "a project may tighten the saved Full Access baseline to Ask"
16132        );
16133    }
16134
16135    #[test]
16136    fn project_overlay_overrides_max_subagents_and_can_disable_shell() {
16137        let tmp = workspace_with_project_config(
16138            r#"
16139max_subagents = 4
16140allow_shell = false
16141"#,
16142        );
16143        let mut config = Config::default();
16144        merge_project_config(&mut config, tmp.path());
16145        assert_eq!(config.max_subagents, Some(4));
16146        assert_eq!(config.allow_shell, Some(false));
16147    }
16148
16149    #[test]
16150    fn project_overlay_cannot_enable_shell() {
16151        let tmp = workspace_with_project_config(
16152            r#"
16153allow_shell = true
16154"#,
16155        );
16156        let mut config = Config {
16157            allow_shell: Some(false),
16158            ..Config::default()
16159        };
16160        merge_project_config(&mut config, tmp.path());
16161        assert_eq!(
16162            config.allow_shell,
16163            Some(false),
16164            "project overlay must not loosen shell access"
16165        );
16166    }
16167
16168    #[test]
16169    fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
16170        let tmp = tempdir().expect("tempdir");
16171        let workspace = tmp.path().join("project");
16172        fs::create_dir_all(&workspace).expect("mkdir workspace");
16173        let raw = format!(
16174            "[workspace.'{}']\nallow_shell = true\n",
16175            workspace.display()
16176        );
16177        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16178
16179        let mut config = Config::default();
16180        merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16181
16182        assert_eq!(config.allow_shell, Some(true));
16183    }
16184
16185    #[test]
16186    fn exec_no_project_config_skips_user_workspace_overlay() {
16187        // #4641: `codewhale --no-project-config exec` must skip the
16188        // workspace-specific `[workspace]`/`[projects]` overlay so a headless
16189        // launch sees a reproducible config surface. This documents the overlay
16190        // the `Commands::Exec` gate skips; the end-to-end wiring is proven by
16191        // `tests/verifiers_harness_contract.rs`.
16192        let tmp = tempdir().expect("tempdir");
16193        let workspace = tmp.path().join("project");
16194        fs::create_dir_all(&workspace).expect("mkdir workspace");
16195        let raw = format!(
16196            "[workspace.'{}']\nallow_shell = true\n",
16197            workspace.display()
16198        );
16199        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16200
16201        // Default (flag off): the overlay applies.
16202        let mut applied = Config::default();
16203        let no_project_config = false;
16204        if !no_project_config {
16205            merge_user_workspace_config_from_doc(&mut applied, &doc, &workspace);
16206        }
16207        assert_eq!(applied.allow_shell, Some(true));
16208
16209        // `--no-project-config`: Exec skips the overlay, leaving config untouched.
16210        let mut skipped = Config::default();
16211        let no_project_config = true;
16212        if !no_project_config {
16213            merge_user_workspace_config_from_doc(&mut skipped, &doc, &workspace);
16214        }
16215        assert_eq!(skipped.allow_shell, None);
16216    }
16217
16218    #[test]
16219    fn user_workspace_overlay_accepts_legacy_projects_table() {
16220        let tmp = tempdir().expect("tempdir");
16221        let workspace = tmp.path().join("project");
16222        fs::create_dir_all(&workspace).expect("mkdir workspace");
16223        let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display());
16224        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16225
16226        let mut config = Config::default();
16227        merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16228
16229        assert_eq!(config.allow_shell, Some(true));
16230    }
16231
16232    #[test]
16233    fn user_workspace_overlay_ignores_non_matching_workspace() {
16234        let tmp = tempdir().expect("tempdir");
16235        let configured_workspace = tmp.path().join("configured");
16236        let active_workspace = tmp.path().join("active");
16237        fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
16238        fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
16239        let raw = format!(
16240            "[workspace.'{}']\nallow_shell = true\n",
16241            configured_workspace.display()
16242        );
16243        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16244
16245        let mut config = Config::default();
16246        merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
16247
16248        assert_eq!(config.allow_shell, None);
16249    }
16250
16251    #[test]
16252    fn user_workspace_overlay_preserves_allow_shell_env_override() {
16253        let _guard = crate::test_support::lock_test_env();
16254        let tmp = tempdir().expect("tempdir");
16255        let workspace = tmp.path().join("project");
16256        fs::create_dir_all(&workspace).expect("mkdir workspace");
16257        let config_path = tmp.path().join("config.toml");
16258        fs::write(
16259            &config_path,
16260            format!(
16261                "[workspace.'{}']\nallow_shell = true\n",
16262                workspace.display()
16263            ),
16264        )
16265        .expect("write config");
16266
16267        unsafe {
16268            std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
16269        }
16270        let mut config = Config {
16271            allow_shell: Some(false),
16272            ..Config::default()
16273        };
16274        merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16275        unsafe {
16276            std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
16277        }
16278
16279        assert_eq!(config.allow_shell, Some(false));
16280    }
16281
16282    #[test]
16283    fn user_workspace_overlay_does_not_override_managed_config() {
16284        let tmp = tempdir().expect("tempdir");
16285        let workspace = tmp.path().join("project");
16286        fs::create_dir_all(&workspace).expect("mkdir workspace");
16287        let config_path = tmp.path().join("config.toml");
16288        fs::write(
16289            &config_path,
16290            format!(
16291                "[workspace.'{}']\nallow_shell = true\n",
16292                workspace.display()
16293            ),
16294        )
16295        .expect("write config");
16296
16297        let mut config = Config {
16298            allow_shell: Some(false),
16299            managed_config_path: Some("managed.toml".to_string()),
16300            ..Config::default()
16301        };
16302        merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16303
16304        assert_eq!(config.allow_shell, Some(false));
16305    }
16306
16307    #[test]
16308    fn windows_config_path_compare_normalizes_mixed_separators() {
16309        assert_eq!(
16310            normalize_windows_config_path_str(r"C:\Users\me\repo"),
16311            normalize_windows_config_path_str(r"C:/Users/me/repo/")
16312        );
16313    }
16314
16315    #[test]
16316    fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() {
16317        assert_eq!(
16318            normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"),
16319            normalize_windows_config_path_str(r"C:/Users/me/repo")
16320        );
16321        assert_eq!(
16322            normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"),
16323            normalize_windows_config_path_str(r"\\server/share/repo/")
16324        );
16325    }
16326
16327    #[test]
16328    fn project_overlay_clamps_max_subagents_to_safe_range() {
16329        let tmp = workspace_with_project_config(
16330            r#"
16331max_subagents = 500
16332"#,
16333        );
16334        let mut config = Config::default();
16335        merge_project_config(&mut config, tmp.path());
16336        assert_eq!(
16337            config.max_subagents,
16338            Some(crate::config::MAX_SUBAGENTS),
16339            "should clamp to MAX_SUBAGENTS"
16340        );
16341    }
16342
16343    #[test]
16344    fn project_overlay_ignores_negative_max_subagents() {
16345        let tmp = workspace_with_project_config(
16346            r#"
16347max_subagents = -3
16348"#,
16349        );
16350        let mut config = Config::default();
16351        merge_project_config(&mut config, tmp.path());
16352        assert_eq!(config.max_subagents, None, "negative should be ignored");
16353    }
16354
16355    #[test]
16356    fn project_overlay_skips_missing_config_file() {
16357        let tmp = tempdir().expect("tempdir");
16358        let mut config = Config {
16359            provider: Some("codewhale".to_string()),
16360            ..Config::default()
16361        };
16362        merge_project_config(&mut config, tmp.path());
16363        // Untouched.
16364        assert_eq!(config.provider.as_deref(), Some("codewhale"));
16365    }
16366
16367    #[test]
16368    fn project_overlay_skips_malformed_toml() {
16369        let tmp = workspace_with_project_config("this is not valid TOML !!");
16370        let mut config = Config {
16371            provider: Some("codewhale".to_string()),
16372            ..Config::default()
16373        };
16374        merge_project_config(&mut config, tmp.path());
16375        // Untouched on parse error — better to fall back to global than crash.
16376        assert_eq!(config.provider.as_deref(), Some("codewhale"));
16377    }
16378
16379    #[test]
16380    fn project_overlay_ignores_empty_string_values() {
16381        let tmp = workspace_with_project_config(
16382            r#"
16383provider = ""
16384model = ""
16385"#,
16386        );
16387        let mut config = Config {
16388            provider: Some("codewhale".to_string()),
16389            default_text_model: Some("deepseek-v4-pro".to_string()),
16390            ..Config::default()
16391        };
16392        merge_project_config(&mut config, tmp.path());
16393        // Empty strings are ignored — they're rarely a deliberate override.
16394        assert_eq!(config.provider.as_deref(), Some("codewhale"));
16395        assert_eq!(
16396            config.default_text_model.as_deref(),
16397            Some("deepseek-v4-pro")
16398        );
16399    }
16400
16401    #[test]
16402    fn project_overlay_ignores_project_instructions_array() {
16403        let tmp = workspace_with_project_config(
16404            r#"
16405instructions = ["./AGENTS.md", "./extra.md"]
16406"#,
16407        );
16408        let user = vec!["~/global.md".to_string()];
16409        let mut config = Config {
16410            instructions: Some(user.clone()),
16411            ..Config::default()
16412        };
16413        merge_project_config(&mut config, tmp.path());
16414        assert_eq!(
16415            config.instructions.as_deref(),
16416            Some(user.as_slice()),
16417            "project overlay must not replace user-owned instructions"
16418        );
16419    }
16420
16421    #[test]
16422    fn project_overlay_empty_instructions_array_preserves_user_list() {
16423        let tmp = workspace_with_project_config(
16424            r#"
16425instructions = []
16426"#,
16427        );
16428        let user = vec!["~/global.md".to_string(), "~/team-prefs.md".to_string()];
16429        let mut config = Config {
16430            instructions: Some(user.clone()),
16431            ..Config::default()
16432        };
16433        merge_project_config(&mut config, tmp.path());
16434        assert_eq!(
16435            config.instructions.as_deref(),
16436            Some(user.as_slice()),
16437            "project overlay must not clear user-owned instructions"
16438        );
16439    }
16440
16441    #[test]
16442    fn project_overlay_preserves_user_instructions_when_field_absent() {
16443        let tmp = workspace_with_project_config(
16444            r#"
16445provider = "deepseek"
16446"#,
16447        );
16448        let user = vec!["~/global.md".to_string()];
16449        let mut config = Config {
16450            instructions: Some(user.clone()),
16451            ..Config::default()
16452        };
16453        merge_project_config(&mut config, tmp.path());
16454        // No `instructions` key in the project file → user list intact.
16455        assert_eq!(
16456            config.instructions.as_deref(),
16457            Some(user.as_slice()),
16458            "absent project field must not clobber the user list"
16459        );
16460    }
16461
16462    #[test]
16463    fn project_overlay_ignores_new_instructions_when_user_has_none() {
16464        let tmp = workspace_with_project_config(
16465            r#"
16466instructions = ["./AGENTS.md", "", "  ", "./extra.md"]
16467"#,
16468        );
16469        let mut config = Config::default();
16470        merge_project_config(&mut config, tmp.path());
16471        assert_eq!(
16472            config.instructions.as_deref(),
16473            None,
16474            "project overlay must not introduce instruction paths"
16475        );
16476    }
16477}
16478
16479#[cfg(test)]
16480mod doctor_mcp_tests {
16481    use super::*;
16482
16483    fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
16484        McpServerConfig {
16485            command: command.map(String::from),
16486            args: args.iter().map(|s| s.to_string()).collect(),
16487            env: std::collections::HashMap::new(),
16488            cwd: None,
16489            url: url.map(String::from),
16490            transport: None,
16491            connect_timeout: None,
16492            execute_timeout: None,
16493            read_timeout: None,
16494            disabled: false,
16495            enabled: true,
16496            required: false,
16497            enabled_tools: Vec::new(),
16498            disabled_tools: Vec::new(),
16499            headers: std::collections::HashMap::new(),
16500            env_headers: std::collections::HashMap::new(),
16501            bearer_token_env_var: None,
16502            scopes: Vec::new(),
16503            oauth: None,
16504            oauth_resource: None,
16505            reviewed_plugin: None,
16506        }
16507    }
16508
16509    #[test]
16510    fn test_no_command_or_url_is_error() {
16511        let server = make_server(None, &[], None);
16512        assert!(matches!(
16513            doctor_check_mcp_server(&server),
16514            McpServerDoctorStatus::Error(_)
16515        ));
16516    }
16517
16518    #[test]
16519    fn test_url_server_is_ok() {
16520        let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
16521        match doctor_check_mcp_server(&server) {
16522            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
16523            other => panic!("Expected Ok, got {other:?}"),
16524        }
16525    }
16526
16527    #[test]
16528    fn test_command_server_is_ok() {
16529        let executable = std::env::current_exe().expect("current test executable");
16530        let executable = executable.to_string_lossy();
16531        let server = make_server(Some(&executable), &["server.js"], None);
16532        match doctor_check_mcp_server(&server) {
16533            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
16534            other => panic!("Expected Ok, got {other:?}"),
16535        }
16536    }
16537
16538    #[test]
16539    fn test_relative_stdio_path_arg_without_cwd_warns() {
16540        let executable = std::env::current_exe().expect("current test executable");
16541        let executable = executable.to_string_lossy();
16542        let server = make_server(Some(&executable), &["server/mcp_server.py"], None);
16543        match doctor_check_mcp_server(&server) {
16544            McpServerDoctorStatus::Warning(detail) => {
16545                assert!(detail.contains("relative path argument"));
16546                assert!(detail.contains("cwd"));
16547            }
16548            other => panic!("Expected Warning for relative path argument, got {other:?}"),
16549        }
16550    }
16551
16552    #[test]
16553    fn test_relative_stdio_path_arg_with_cwd_is_ok() {
16554        let executable = std::env::current_exe().expect("current test executable");
16555        let executable = executable.to_string_lossy();
16556        let mut server = make_server(Some(&executable), &["server/mcp_server.py"], None);
16557        server.cwd = Some(PathBuf::from("/tmp/codewhale-project"));
16558        match doctor_check_mcp_server(&server) {
16559            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
16560            other => panic!("Expected Ok when cwd anchors relative path, got {other:?}"),
16561        }
16562    }
16563
16564    #[test]
16565    fn test_self_hosted_absolute_is_ok() {
16566        let executable = std::env::current_exe().expect("current test executable");
16567        let executable = executable.to_string_lossy();
16568        let server = make_server(Some(&executable), &["serve", "--mcp"], None);
16569        match doctor_check_mcp_server(&server) {
16570            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio server")),
16571            McpServerDoctorStatus::Warning(detail) => {
16572                panic!("Absolute path should not warn: {detail}")
16573            }
16574            McpServerDoctorStatus::Error(detail) => panic!("unexpected error: {detail}"),
16575        }
16576    }
16577
16578    #[cfg(test)]
16579    mod mcp_auth_guidance_tests {
16580        #[test]
16581        fn mcp_auth_hint_is_actionable_for_connect_failures() {
16582            let hint = crate::mcp::oauth::auth_required_login_hint("nordic-mcp");
16583            assert_eq!(
16584                hint,
16585                "MCP server 'nordic-mcp' requires OAuth authentication. Run `codewhale mcp login nordic-mcp` to authenticate."
16586            );
16587        }
16588    }
16589
16590    #[test]
16591    fn test_empty_command_is_error() {
16592        let server = make_server(Some(""), &[], None);
16593        assert!(matches!(
16594            doctor_check_mcp_server(&server),
16595            McpServerDoctorStatus::Error(_)
16596        ));
16597    }
16598
16599    #[test]
16600    fn doctor_json_separates_configuration_from_live_health() {
16601        let server = make_server(None, &[], Some("http://127.0.0.1:3000/mcp"));
16602        let report = doctor_mcp_server_json("tools-only", &server);
16603
16604        assert_eq!(report["check_scope"], "configuration");
16605        assert_eq!(report["checks"]["configuration"]["status"], "valid");
16606        assert_eq!(report["checks"]["command"]["status"], "not_applicable");
16607        assert_eq!(
16608            report["checks"]["process_reachable"]["status"],
16609            "not_checked"
16610        );
16611        assert_eq!(
16612            report["checks"]["protocol_initialized"]["status"],
16613            "not_checked"
16614        );
16615        assert_eq!(
16616            report["checks"]["backend_tool_health"]["status"],
16617            "not_checked"
16618        );
16619        assert!(!report.to_string().contains("healthy"));
16620    }
16621
16622    #[cfg(unix)]
16623    #[test]
16624    fn static_mcp_check_never_starts_the_configured_command() {
16625        use std::os::unix::fs::PermissionsExt;
16626
16627        let temp = tempfile::tempdir().expect("tempdir");
16628        let marker = temp.path().join("started");
16629        let script = temp.path().join("mcp-server");
16630        std::fs::write(
16631            &script,
16632            format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
16633        )
16634        .expect("write test server");
16635        let mut permissions = std::fs::metadata(&script)
16636            .expect("script metadata")
16637            .permissions();
16638        permissions.set_mode(0o755);
16639        std::fs::set_permissions(&script, permissions).expect("make script executable");
16640
16641        let script = script.to_string_lossy();
16642        let server = make_server(Some(&script), &[], None);
16643        assert!(matches!(
16644            doctor_check_mcp_server(&server),
16645            McpServerDoctorStatus::Ok(_)
16646        ));
16647        assert!(!marker.exists(), "static doctor check started MCP server");
16648    }
16649}
16650
16651#[cfg(test)]
16652mod doctor_live_probe_tests {
16653    use super::*;
16654
16655    #[test]
16656    fn local_provider_probe_requires_explicit_opt_in() {
16657        assert!(!doctor_should_probe_api(
16658            crate::config::ApiProvider::Ollama,
16659            "http://127.0.0.1:11434/v1",
16660            crate::doctor::DoctorProbeRequest::default(),
16661        ));
16662        assert!(doctor_should_probe_api(
16663            crate::config::ApiProvider::Ollama,
16664            "http://127.0.0.1:11434/v1",
16665            crate::doctor::DoctorProbeRequest {
16666                probe_local: true,
16667                ..crate::doctor::DoctorProbeRequest::default()
16668            },
16669        ));
16670    }
16671
16672    #[test]
16673    fn custom_loopback_probe_also_requires_explicit_opt_in() {
16674        assert!(!doctor_should_probe_api(
16675            crate::config::ApiProvider::Custom,
16676            "http://localhost:8000/v1",
16677            crate::doctor::DoctorProbeRequest::default(),
16678        ));
16679    }
16680
16681    #[test]
16682    fn oauth_routes_skip_live_probe_to_keep_doctor_non_mutating() {
16683        let codex = Config {
16684            provider: Some("openai-codex".to_string()),
16685            ..Config::default()
16686        };
16687        assert!(!doctor_should_probe_auth(&codex));
16688
16689        let xai = Config {
16690            provider: Some("xai".to_string()),
16691            providers: Some(crate::config::ProvidersConfig {
16692                xai: crate::config::ProviderConfig {
16693                    auth_mode: Some("oauth".to_string()),
16694                    ..Default::default()
16695                },
16696                ..Default::default()
16697            }),
16698            ..Config::default()
16699        };
16700        assert!(!doctor_should_probe_auth(&xai));
16701        assert!(doctor_should_probe_auth(&Config::default()));
16702    }
16703}
16704
16705#[cfg(test)]
16706mod setup_helper_tests {
16707    use super::*;
16708    use std::collections::BTreeSet;
16709    use tempfile::TempDir;
16710
16711    #[test]
16712    fn init_tools_dir_creates_readme_and_example() {
16713        let tmp = TempDir::new().unwrap();
16714        let dir = tmp.path().join("tools");
16715        let (returned_dir, readme_status, example_status) =
16716            init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
16717
16718        assert_eq!(returned_dir, dir);
16719        assert!(matches!(readme_status, WriteStatus::Created));
16720        assert!(matches!(example_status, WriteStatus::Created));
16721        assert!(dir.join("README.md").exists());
16722        assert!(dir.join("example.sh").exists());
16723
16724        let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
16725        assert!(
16726            readme.contains("# name:"),
16727            "README must show frontmatter convention"
16728        );
16729
16730        let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
16731        assert!(example.starts_with("#!/usr/bin/env sh"));
16732        assert!(example.contains("# name: example"));
16733        assert!(example.contains("# description:"));
16734    }
16735
16736    #[test]
16737    fn init_tools_dir_skips_existing_without_force() {
16738        let tmp = TempDir::new().unwrap();
16739        let dir = tmp.path().join("tools");
16740        let _ = init_tools_dir(&dir, false).unwrap();
16741        let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
16742        assert!(matches!(readme_status, WriteStatus::SkippedExists));
16743        assert!(matches!(example_status, WriteStatus::SkippedExists));
16744    }
16745
16746    #[test]
16747    fn init_tools_dir_force_overwrites() {
16748        let tmp = TempDir::new().unwrap();
16749        let dir = tmp.path().join("tools");
16750        let _ = init_tools_dir(&dir, false).unwrap();
16751        std::fs::write(dir.join("example.sh"), "stale").unwrap();
16752        let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
16753        assert!(matches!(example_status, WriteStatus::Overwritten));
16754        let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
16755        assert_ne!(example, "stale");
16756    }
16757
16758    #[test]
16759    fn init_plugins_dir_creates_readme_and_example_layout() {
16760        let tmp = TempDir::new().unwrap();
16761        let dir = tmp.path().join("plugins");
16762        let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
16763            init_plugins_dir(&dir, false).unwrap();
16764
16765        assert_eq!(readme_path, dir.join("README.md"));
16766        assert_eq!(manifest_path, dir.join("example").join("plugin.toml"));
16767        assert_eq!(
16768            skill_path,
16769            dir.join("example/skills/hello").join("SKILL.md")
16770        );
16771        assert!(matches!(readme_status, WriteStatus::Created));
16772        assert!(matches!(manifest_status, WriteStatus::Created));
16773        assert!(matches!(skill_status, WriteStatus::Created));
16774        assert!(readme_path.exists());
16775        assert!(manifest_path.exists());
16776        assert!(skill_path.exists());
16777
16778        let manifest = std::fs::read_to_string(&manifest_path).unwrap();
16779        assert!(manifest.contains("schema_version = 1"));
16780        assert!(manifest.contains("name = \"example\""));
16781        let validated =
16782            crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
16783                .expect("scaffolded plugin should validate");
16784        assert_eq!(validated.inventory.skills, 1);
16785    }
16786
16787    #[test]
16788    fn collect_clean_targets_finds_all_checkpoint_json_files() {
16789        let tmp = TempDir::new().unwrap();
16790        let dir = tmp.path();
16791        std::fs::write(dir.join("latest.json"), "{}").unwrap();
16792        std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
16793        // Per-session crash checkpoint files are clean targets too.
16794        std::fs::write(dir.join("some-session-id.json"), "{}").unwrap();
16795        // Non-JSON files and subdirectories are left alone.
16796        std::fs::write(dir.join("notes.txt"), "keep").unwrap();
16797        std::fs::create_dir_all(dir.join("subdir")).unwrap();
16798
16799        let plan = collect_clean_targets(dir);
16800        assert_eq!(plan.targets.len(), 3);
16801        assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
16802        assert!(
16803            plan.targets
16804                .iter()
16805                .any(|p| p.ends_with("offline_queue.json"))
16806        );
16807        assert!(
16808            plan.targets
16809                .iter()
16810                .any(|p| p.ends_with("some-session-id.json"))
16811        );
16812        assert!(!plan.targets.iter().any(|p| p.ends_with("notes.txt")));
16813    }
16814
16815    #[test]
16816    fn execute_clean_plan_removes_files_and_returns_them() {
16817        let tmp = TempDir::new().unwrap();
16818        let dir = tmp.path();
16819        let latest = dir.join("latest.json");
16820        let queue = dir.join("offline_queue.json");
16821        std::fs::write(&latest, "{}").unwrap();
16822        std::fs::write(&queue, "[]").unwrap();
16823
16824        let plan = collect_clean_targets(dir);
16825        let removed = execute_clean_plan(&plan).unwrap();
16826        assert_eq!(removed.len(), 2);
16827        assert!(!latest.exists());
16828        assert!(!queue.exists());
16829    }
16830
16831    #[test]
16832    fn run_setup_clean_dry_run_lists_targets_without_force() {
16833        let tmp = TempDir::new().unwrap();
16834        let dir = tmp.path();
16835        std::fs::write(dir.join("latest.json"), "{}").unwrap();
16836        run_setup_clean(dir, false).unwrap();
16837        // Without --force, files must remain on disk.
16838        assert!(dir.join("latest.json").exists());
16839    }
16840
16841    #[test]
16842    fn run_setup_clean_force_removes_files() {
16843        let tmp = TempDir::new().unwrap();
16844        let dir = tmp.path();
16845        std::fs::write(dir.join("latest.json"), "{}").unwrap();
16846        std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
16847        run_setup_clean(dir, true).unwrap();
16848        assert!(!dir.join("latest.json").exists());
16849        assert!(!dir.join("offline_queue.json").exists());
16850    }
16851
16852    #[test]
16853    fn run_setup_clean_handles_missing_dir() {
16854        let tmp = TempDir::new().unwrap();
16855        let dir = tmp.path().join("does-not-exist");
16856        // Should print and return Ok without error.
16857        run_setup_clean(&dir, true).unwrap();
16858        assert!(!dir.exists());
16859    }
16860
16861    fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
16862        let prev_home = std::env::var_os("HOME");
16863        let prev_userprofile = std::env::var_os("USERPROFILE");
16864        unsafe {
16865            std::env::set_var("HOME", home);
16866            std::env::set_var("USERPROFILE", home);
16867        }
16868        let result = f();
16869        unsafe {
16870            match prev_home {
16871                Some(value) => std::env::set_var("HOME", value),
16872                None => std::env::remove_var("HOME"),
16873            }
16874            match prev_userprofile {
16875                Some(value) => std::env::set_var("USERPROFILE", value),
16876                None => std::env::remove_var("USERPROFILE"),
16877            }
16878        }
16879        result
16880    }
16881
16882    #[test]
16883    fn plain_launch_preserves_checkpoint_but_starts_fresh() {
16884        let _guard = crate::test_support::lock_test_env();
16885        let tmp = TempDir::new().unwrap();
16886        let workspace = tmp.path().join("workspace");
16887        std::fs::create_dir_all(&workspace).unwrap();
16888
16889        with_home(tmp.path(), || {
16890            let manager = SessionManager::default_location().expect("manager");
16891            let messages = vec![Message {
16892                role: "user".to_string(),
16893                content: vec![ContentBlock::Text {
16894                    text: "in flight".to_string(),
16895                    cache_control: None,
16896                }],
16897            }];
16898            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
16899            let session_id = session.metadata.id.clone();
16900            manager.save_checkpoint(&session).expect("save checkpoint");
16901
16902            preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
16903
16904            assert!(
16905                manager
16906                    .load_session_checkpoint(&session_id)
16907                    .expect("load checkpoint")
16908                    .is_some(),
16909                "normal launch must leave the per-session checkpoint in place \
16910                 (it may belong to a live session; `--continue` consumes it)"
16911            );
16912            // #4479: checkpoint is no longer promoted to session file.
16913            assert!(
16914                manager
16915                    .load_session_checkpoint(&session_id)
16916                    .expect("load checkpoint")
16917                    .is_some(),
16918                "checkpoint stays in checkpoints/ for --continue"
16919            );
16920        });
16921    }
16922
16923    #[test]
16924    fn plain_launch_consumes_legacy_checkpoint_after_preserving_it() {
16925        let _guard = crate::test_support::lock_test_env();
16926        let tmp = TempDir::new().unwrap();
16927        let workspace = tmp.path().join("workspace");
16928        std::fs::create_dir_all(&workspace).unwrap();
16929
16930        with_home(tmp.path(), || {
16931            let manager = SessionManager::default_location().expect("manager");
16932            let session = create_saved_session(
16933                &[Message {
16934                    role: "user".to_string(),
16935                    content: vec![ContentBlock::Text {
16936                        text: "legacy in flight".to_string(),
16937                        cache_control: None,
16938                    }],
16939                }],
16940                "test-model",
16941                &workspace,
16942                0,
16943                None,
16944            );
16945            let session_id = session.metadata.id.clone();
16946            write_legacy_checkpoint(&manager, &session);
16947
16948            preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
16949
16950            assert!(
16951                manager
16952                    .load_legacy_checkpoint()
16953                    .expect("load legacy checkpoint")
16954                    .is_none(),
16955                "normal launch should consume the legacy single-slot checkpoint"
16956            );
16957            // #4479: checkpoint is no longer promoted to session file.
16958            assert!(
16959                manager
16960                    .load_session_checkpoint(&session_id)
16961                    .expect("load checkpoint")
16962                    .is_some(),
16963                "checkpoint stays in checkpoints/ for --continue"
16964            );
16965        });
16966    }
16967
16968    #[test]
16969    fn continue_recovers_same_workspace_checkpoint() {
16970        let _guard = crate::test_support::lock_test_env();
16971        let tmp = TempDir::new().unwrap();
16972        let workspace = tmp.path().join("workspace");
16973        std::fs::create_dir_all(&workspace).unwrap();
16974
16975        with_home(tmp.path(), || {
16976            let manager = SessionManager::default_location().expect("manager");
16977            let messages = vec![Message {
16978                role: "user".to_string(),
16979                content: vec![ContentBlock::Text {
16980                    text: "continue me".to_string(),
16981                    cache_control: None,
16982                }],
16983            }];
16984            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
16985            let session_id = session.metadata.id.clone();
16986            manager.save_checkpoint(&session).expect("save checkpoint");
16987
16988            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
16989
16990            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
16991            assert!(
16992                manager
16993                    .load_session_checkpoint(&session_id)
16994                    .expect("load checkpoint")
16995                    .is_none(),
16996                "--continue should consume the per-session checkpoint"
16997            );
16998            assert!(manager.load_session(&session_id).is_ok());
16999        });
17000    }
17001
17002    /// Write a legacy single-slot checkpoint file the way pre-cutover
17003    /// binaries did. The current binary only reads this slot.
17004    fn write_legacy_checkpoint(manager: &SessionManager, session: &session_manager::SavedSession) {
17005        let checkpoints = manager.sessions_dir().join("checkpoints");
17006        std::fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
17007        let content = serde_json::to_string_pretty(session).expect("serialize checkpoint");
17008        std::fs::write(checkpoints.join("latest.json"), content).expect("write legacy checkpoint");
17009    }
17010
17011    #[test]
17012    fn continue_recovers_legacy_checkpoint_and_migrates_it() {
17013        let _guard = crate::test_support::lock_test_env();
17014        let tmp = TempDir::new().unwrap();
17015        let workspace = tmp.path().join("workspace");
17016        std::fs::create_dir_all(&workspace).unwrap();
17017
17018        with_home(tmp.path(), || {
17019            let manager = SessionManager::default_location().expect("manager");
17020            let messages = vec![Message {
17021                role: "user".to_string(),
17022                content: vec![ContentBlock::Text {
17023                    text: "legacy continue".to_string(),
17024                    cache_control: None,
17025                }],
17026            }];
17027            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17028            let session_id = session.metadata.id.clone();
17029            write_legacy_checkpoint(&manager, &session);
17030
17031            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17032
17033            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17034            assert!(
17035                manager.load_session(&session_id).is_ok(),
17036                "recovered legacy checkpoint must be loadable as a session"
17037            );
17038            assert!(
17039                manager
17040                    .load_session_checkpoint(&session_id)
17041                    .expect("load per-session checkpoint")
17042                    .is_some(),
17043                "legacy recovery must migrate to a per-session checkpoint file"
17044            );
17045            assert!(
17046                manager
17047                    .load_legacy_checkpoint()
17048                    .expect("load legacy checkpoint")
17049                    .is_some(),
17050                "legacy latest.json stays in place for one more release"
17051            );
17052        });
17053    }
17054
17055    #[test]
17056    fn continue_refuses_checkpoint_from_other_workspace() {
17057        let _guard = crate::test_support::lock_test_env();
17058        let tmp = TempDir::new().unwrap();
17059        let launch_workspace = tmp.path().join("launch-workspace");
17060        let other_workspace = tmp.path().join("other-workspace");
17061        std::fs::create_dir_all(&launch_workspace).unwrap();
17062        std::fs::create_dir_all(&other_workspace).unwrap();
17063
17064        with_home(tmp.path(), || {
17065            let manager = SessionManager::default_location().expect("manager");
17066            let messages = vec![Message {
17067                role: "user".to_string(),
17068                content: vec![ContentBlock::Text {
17069                    text: "belongs elsewhere".to_string(),
17070                    cache_control: None,
17071                }],
17072            }];
17073            let session = create_saved_session(&messages, "test-model", &other_workspace, 0, None);
17074            let session_id = session.metadata.id.clone();
17075            manager.save_checkpoint(&session).expect("save checkpoint");
17076
17077            let recovered = recover_interrupted_checkpoint_for_resume(&launch_workspace);
17078
17079            assert_eq!(recovered, None, "workspace mismatch must refuse recovery");
17080            assert!(
17081                manager
17082                    .load_session_checkpoint(&session_id)
17083                    .expect("load checkpoint")
17084                    .is_some(),
17085                "another workspace's checkpoint file must be left untouched"
17086            );
17087        });
17088    }
17089
17090    #[test]
17091    fn continue_twice_does_not_clobber_newer_session_with_stale_legacy_checkpoint() {
17092        let _guard = crate::test_support::lock_test_env();
17093        let tmp = TempDir::new().unwrap();
17094        let workspace = tmp.path().join("workspace");
17095        std::fs::create_dir_all(&workspace).unwrap();
17096
17097        with_home(tmp.path(), || {
17098            let manager = SessionManager::default_location().expect("manager");
17099            let stale = create_saved_session(
17100                &[Message {
17101                    role: "user".to_string(),
17102                    content: vec![ContentBlock::Text {
17103                        text: "crash-time state".to_string(),
17104                        cache_control: None,
17105                    }],
17106                }],
17107                "test-model",
17108                &workspace,
17109                0,
17110                None,
17111            );
17112            let session_id = stale.metadata.id.clone();
17113            write_legacy_checkpoint(&manager, &stale);
17114
17115            // The session advanced after the checkpoint was taken: a newer
17116            // regular session file exists for the same id.
17117            let mut advanced = stale.clone();
17118            advanced.messages.push(Message {
17119                role: "assistant".to_string(),
17120                content: vec![ContentBlock::Text {
17121                    text: "post-recovery progress".to_string(),
17122                    cache_control: None,
17123                }],
17124            });
17125            advanced.metadata.message_count = advanced.messages.len();
17126            advanced.metadata.updated_at = stale.metadata.updated_at + chrono::Duration::hours(1);
17127            manager.save_session(&advanced).expect("save newer session");
17128
17129            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17130
17131            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17132            let persisted = manager.load_session(&session_id).expect("load session");
17133            assert_eq!(
17134                persisted.messages.len(),
17135                advanced.messages.len(),
17136                "stale checkpoint content must not overwrite the newer session"
17137            );
17138        });
17139    }
17140
17141    #[test]
17142    fn dotenv_status_points_to_example_when_present() {
17143        let tmp = TempDir::new().unwrap();
17144        std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
17145
17146        assert_eq!(
17147            dotenv_status_line(tmp.path()),
17148            ".env not present in workspace (run `cp .env.example .env` and edit)"
17149        );
17150
17151        std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
17152        assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
17153    }
17154
17155    #[test]
17156    fn env_example_is_trackable_and_every_key_is_wired() {
17157        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
17158        let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
17159        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
17160
17161        assert!(gitignore.contains("!.env.example"));
17162
17163        let keys = documented_env_keys(&env_example);
17164        for required in [
17165            "DEEPSEEK_API_KEY",
17166            "NVIDIA_API_KEY",
17167            "NVIDIA_NIM_API_KEY",
17168            "ATLASCLOUD_API_KEY",
17169        ] {
17170            assert!(
17171                keys.contains(required),
17172                ".env.example is missing {required}"
17173            );
17174        }
17175
17176        for key in &keys {
17177            assert!(
17178                is_workspace_dotenv_credential_key(key),
17179                ".env.example documents non-credential control setting {key}"
17180            );
17181        }
17182
17183        let sources = [
17184            include_str!("config.rs"),
17185            include_str!("logging.rs"),
17186            include_str!("../../config/src/lib.rs"),
17187            include_str!("../../config/src/provider.rs"),
17188            include_str!("../../cli/src/main.rs"),
17189        ]
17190        .join("\n");
17191
17192        for key in keys {
17193            assert!(
17194                sources.contains(&key),
17195                ".env.example documents {key}, but no source file references it"
17196            );
17197        }
17198    }
17199
17200    fn documented_env_keys(content: &str) -> BTreeSet<String> {
17201        content
17202            .lines()
17203            .filter_map(|line| {
17204                let trimmed = line.trim();
17205                let uncommented = trimmed
17206                    .strip_prefix('#')
17207                    .map(str::trim_start)
17208                    .unwrap_or(trimmed);
17209                let (key, _) = uncommented.split_once('=')?;
17210                let key = key.trim();
17211                let is_env_key = key
17212                    .chars()
17213                    .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
17214                    && key.chars().any(|ch| ch == '_');
17215                is_env_key.then(|| key.to_string())
17216            })
17217            .collect()
17218    }
17219
17220    #[test]
17221    fn custom_provider_env_source_precedes_saved_secret_store() {
17222        let _lock = crate::test_support::lock_test_env();
17223        let temp = TempDir::new().expect("temp home");
17224        let codewhale_home = temp.path().join("codewhale-home");
17225        std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17226        let _home =
17227            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17228        let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17229        let _declared_env =
17230            crate::test_support::EnvVarGuard::set("QA_CUSTOM_API_KEY", "declared-env-key");
17231        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17232        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17233        codewhale_secrets::Secrets::auto_detect()
17234            .set("custom", "saved-custom-secret")
17235            .expect("save secret");
17236
17237        let mut custom = std::collections::HashMap::new();
17238        custom.insert(
17239            "qa-gateway".to_string(),
17240            crate::config::ProviderConfig {
17241                kind: Some("openai-compatible".to_string()),
17242                base_url: Some("https://gateway.example.test/v1".to_string()),
17243                model: Some("qa-model".to_string()),
17244                api_key_env: Some("QA_CUSTOM_API_KEY".to_string()),
17245                ..Default::default()
17246            },
17247        );
17248        let config = Config {
17249            provider: Some("qa-gateway".to_string()),
17250            providers: Some(crate::config::ProvidersConfig {
17251                custom,
17252                ..Default::default()
17253            }),
17254            ..Config::default()
17255        };
17256
17257        assert_eq!(resolve_api_key_source(&config), ApiKeySource::EnvDeclared);
17258        assert_eq!(
17259            config.deepseek_api_key().expect("custom key"),
17260            "declared-env-key"
17261        );
17262    }
17263
17264    #[test]
17265    fn named_custom_provider_does_not_report_generic_secret_store() {
17266        let _lock = crate::test_support::lock_test_env();
17267        let temp = TempDir::new().expect("temp home");
17268        let codewhale_home = temp.path().join("codewhale-home");
17269        std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17270        let _home =
17271            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17272        let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17273        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17274        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17275        codewhale_secrets::Secrets::auto_detect()
17276            .set("custom", "unrelated-custom-secret")
17277            .expect("save secret");
17278
17279        let mut custom = std::collections::HashMap::new();
17280        custom.insert(
17281            "qa-gateway".to_string(),
17282            crate::config::ProviderConfig {
17283                kind: Some("openai-compatible".to_string()),
17284                base_url: Some("https://gateway.example.test/v1".to_string()),
17285                model: Some("qa-model".to_string()),
17286                auth_mode: Some("api_key".to_string()),
17287                ..Default::default()
17288            },
17289        );
17290        let config = Config {
17291            provider: Some("qa-gateway".to_string()),
17292            providers: Some(crate::config::ProvidersConfig {
17293                custom,
17294                ..Default::default()
17295            }),
17296            ..Config::default()
17297        };
17298
17299        assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17300        assert!(config.deepseek_api_key().is_err());
17301    }
17302
17303    #[test]
17304    fn custom_built_in_endpoint_does_not_report_ambient_provider_key() {
17305        let _lock = crate::test_support::lock_test_env();
17306        let _openrouter =
17307            crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17308        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17309        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17310        let mut providers = crate::config::ProvidersConfig::default();
17311        providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
17312        let config = Config {
17313            provider: Some("openrouter".to_string()),
17314            providers: Some(providers),
17315            ..Config::default()
17316        };
17317
17318        assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17319        assert!(config.deepseek_api_key().is_err());
17320    }
17321
17322    #[test]
17323    fn auth_mode_none_reports_distinct_no_auth_source_and_scheme() {
17324        let _lock = crate::test_support::lock_test_env();
17325        let _openrouter =
17326            crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17327        let mut providers = crate::config::ProvidersConfig::default();
17328        providers.openrouter.auth_mode = Some("none".to_string());
17329        providers.openrouter.api_key = Some("configured-key".to_string());
17330        let config = Config {
17331            provider: Some("openrouter".to_string()),
17332            providers: Some(providers),
17333            ..Config::default()
17334        };
17335
17336        assert_eq!(resolve_api_key_source(&config), ApiKeySource::NoAuth);
17337        assert_eq!(doctor_api_key_source_label(ApiKeySource::NoAuth), "none");
17338        assert_eq!(doctor_auth_scheme(&config), "none");
17339        assert_eq!(config.deepseek_api_key().expect("no-auth route"), "");
17340    }
17341
17342    #[test]
17343    fn resolve_api_key_source_prefers_config_over_env() {
17344        let _guard = crate::test_support::lock_test_env();
17345        let prev = std::env::var("DEEPSEEK_API_KEY").ok();
17346        let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
17347        unsafe {
17348            std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
17349            std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
17350        }
17351        let cfg = Config {
17352            api_key: Some("fresh-config-key".to_string()),
17353            ..Config::default()
17354        };
17355        let source = resolve_api_key_source(&cfg);
17356        match prev {
17357            Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
17358            None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
17359        }
17360        match prev_source {
17361            Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
17362            None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
17363        }
17364        assert_eq!(source, ApiKeySource::ConfigDeclared);
17365    }
17366
17367    #[test]
17368    fn resolve_api_key_source_reports_active_provider_env_from_metadata() {
17369        let _guard = crate::test_support::lock_test_env();
17370        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17371        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17372        let _anthropic_key =
17373            crate::test_support::EnvVarGuard::set("ANTHROPIC_API_KEY", "test-anthropic-key");
17374        let cfg = Config {
17375            provider: Some("anthropic".to_string()),
17376            ..Config::default()
17377        };
17378
17379        let source = resolve_api_key_source(&cfg);
17380
17381        assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
17382    }
17383
17384    #[test]
17385    fn resolve_api_key_source_ignores_unresolved_provider_command_metadata() {
17386        let _guard = crate::test_support::lock_test_env();
17387        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17388        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17389        let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17390        let mut providers = crate::config::ProvidersConfig::default();
17391        providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17392            source: codewhale_config::AuthSourceKind::Command,
17393            command: vec!["secret-tool".to_string(), "lookup".to_string()],
17394            timeout_ms: Some(2000),
17395            secret_id: None,
17396        });
17397        let cfg = Config {
17398            provider: Some("openai".to_string()),
17399            providers: Some(providers),
17400            ..Config::default()
17401        };
17402
17403        let source = resolve_api_key_source(&cfg);
17404
17405        assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
17406        assert!(cfg.deepseek_api_key().is_err());
17407    }
17408
17409    #[test]
17410    fn resolve_api_key_source_ignores_unresolved_provider_secret_metadata() {
17411        let _guard = crate::test_support::lock_test_env();
17412        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17413        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17414        let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17415        let mut providers = crate::config::ProvidersConfig::default();
17416        providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17417            source: codewhale_config::AuthSourceKind::Secret,
17418            command: Vec::new(),
17419            timeout_ms: None,
17420            secret_id: Some("codewhale/openai".to_string()),
17421        });
17422        let cfg = Config {
17423            provider: Some("openai".to_string()),
17424            providers: Some(providers),
17425            ..Config::default()
17426        };
17427
17428        let source = resolve_api_key_source(&cfg);
17429
17430        assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
17431        assert!(cfg.deepseek_api_key().is_err());
17432    }
17433
17434    #[test]
17435    fn resolve_api_key_source_ignores_root_deepseek_key_for_other_provider() {
17436        let _guard = crate::test_support::lock_test_env();
17437        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17438        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17439        let _openrouter_key = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
17440        let cfg = Config {
17441            provider: Some("openrouter".to_string()),
17442            api_key: Some("legacy-deepseek-root-key".to_string()),
17443            ..Config::default()
17444        };
17445
17446        let source = resolve_api_key_source(&cfg);
17447
17448        assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
17449    }
17450
17451    #[test]
17452    fn provider_status_helpers_use_provider_metadata() {
17453        assert_eq!(
17454            provider_config_table_key(crate::config::ApiProvider::Anthropic),
17455            "anthropic"
17456        );
17457        assert_eq!(
17458            provider_config_table_key(crate::config::ApiProvider::SiliconflowCn),
17459            "siliconflow_cn"
17460        );
17461    }
17462
17463    #[test]
17464    fn skills_count_for_returns_zero_for_missing_dir() {
17465        let tmp = TempDir::new().unwrap();
17466        let dir = tmp.path().join("nope");
17467        assert_eq!(skills_count_for(&dir), 0);
17468    }
17469
17470    #[test]
17471    fn skills_count_for_counts_valid_skill_dirs() {
17472        let tmp = TempDir::new().unwrap();
17473        let dir = tmp.path().join("skills");
17474        let skill_dir = dir.join("getting-started");
17475        std::fs::create_dir_all(&skill_dir).unwrap();
17476        std::fs::write(
17477            skill_dir.join("SKILL.md"),
17478            "---\nname: getting-started\ndescription: hi\n---\nbody",
17479        )
17480        .unwrap();
17481        assert_eq!(skills_count_for(&dir), 1);
17482    }
17483}
17484
17485#[cfg(test)]
17486mod pr_prompt_tests {
17487    use super::*;
17488
17489    fn sample_pr() -> GhPullRequest {
17490        GhPullRequest {
17491            title: "Add cool feature".to_string(),
17492            body: "Closes #99.\n\nAlso:\n- bullet a\n- bullet b".to_string(),
17493            base: "main".to_string(),
17494            head: "feat/cool".to_string(),
17495            url: "https://github.com/example/repo/pull/123".to_string(),
17496        }
17497    }
17498
17499    #[test]
17500    fn format_pr_prompt_includes_title_url_branches_body_and_diff() {
17501        let prompt = format_pr_prompt(123, &sample_pr(), "diff --git a/x b/x\n+y");
17502        assert!(prompt.contains("Review PR #123 — Add cool feature"));
17503        assert!(prompt.contains("URL: https://github.com/example/repo/pull/123"));
17504        assert!(prompt.contains("Branches: main ← feat/cool"));
17505        assert!(prompt.contains("Closes #99."));
17506        assert!(prompt.contains("- bullet a"));
17507        assert!(prompt.contains("```diff"));
17508        assert!(prompt.contains("diff --git a/x b/x"));
17509    }
17510
17511    #[test]
17512    fn format_pr_prompt_handles_empty_body_and_unknown_branches() {
17513        let pr = GhPullRequest {
17514            title: String::new(),
17515            body: "   ".to_string(),
17516            base: String::new(),
17517            head: String::new(),
17518            url: String::new(),
17519        };
17520        let prompt = format_pr_prompt(7, &pr, "(diff body)");
17521        // Empty title falls back to a placeholder.
17522        assert!(prompt.contains("(PR #7)"));
17523        // Empty body renders the explicit placeholder.
17524        assert!(prompt.contains("(no description)"));
17525        assert!(prompt.contains("Branches: (unknown)"));
17526        assert!(prompt.contains("URL: (unavailable)"));
17527    }
17528
17529    #[test]
17530    fn format_pr_prompt_truncates_oversize_diff_at_a_codepoint_boundary() {
17531        // 300 KiB of `X` bytes with a multibyte char near the cap.
17532        let mut diff = "X".repeat(190 * 1024);
17533        diff.push_str(&"🚀".repeat(5_000));
17534        let prompt = format_pr_prompt(1, &sample_pr(), &diff);
17535        assert!(prompt.contains("[…diff truncated"));
17536        assert!(prompt.contains("at 200 KiB"));
17537        // Ensure we didn't slice mid-codepoint — the result still
17538        // round-trips as valid UTF-8 (it's a String, so this is by
17539        // construction; the test pins behaviour against silent panics
17540        // if the cut logic regresses).
17541        assert!(prompt.is_ascii() || prompt.contains('🚀'));
17542    }
17543
17544    #[test]
17545    fn is_command_available_detects_present_and_absent_binaries() {
17546        // `sh` is part of the POSIX baseline on every Unix runner and
17547        // ships with `git-bash` on Windows CI. It should be present.
17548        // (Skip on Windows CI without git-bash because the runner
17549        // could legitimately lack `sh.exe`.)
17550        #[cfg(unix)]
17551        assert!(is_command_available("sh"), "POSIX `sh` should be on PATH");
17552
17553        // A deliberately-implausible name to confirm the negative
17554        // branch — `--version` on this would exec(3) → ENOENT.
17555        assert!(
17556            !is_command_available("this-command-cannot-exist-codewhale-tui-test-ENOENT-marker"),
17557            "missing command should return false, not panic"
17558        );
17559    }
17560}
17561
17562#[cfg(test)]
17563mod telemetry_surface_tests {
17564    use super::*;
17565    use clap::Parser;
17566    use codewhale_telemetry::{SessionSource, Surface};
17567
17568    fn command_of(args: &[&str]) -> Option<Commands> {
17569        Cli::try_parse_from(args)
17570            .expect("CLI args should parse")
17571            .command
17572    }
17573
17574    #[test]
17575    fn every_surface_is_named_by_the_subcommand_not_the_executable() {
17576        // One binary, five surfaces. `current_exe()` would call all of them
17577        // the same thing, which is why nothing derives the surface from it.
17578        assert_eq!(telemetry_surface(None), Surface::Tui);
17579        assert_eq!(
17580            telemetry_surface(command_of(&["codewhale-tui", "resume", "--last"]).as_ref()),
17581            Surface::Tui
17582        );
17583        assert_eq!(
17584            telemetry_surface(command_of(&["codewhale-tui", "fork", "--last"]).as_ref()),
17585            Surface::Tui
17586        );
17587        assert_eq!(
17588            telemetry_surface(command_of(&["codewhale-tui", "exec", "hello"]).as_ref()),
17589            Surface::Exec
17590        );
17591        assert_eq!(
17592            telemetry_surface(command_of(&["codewhale-tui", "serve", "--http"]).as_ref()),
17593            Surface::Serve
17594        );
17595        assert_eq!(
17596            telemetry_surface(command_of(&["codewhale-tui", "serve", "--mcp"]).as_ref()),
17597            Surface::McpServer
17598        );
17599        assert_eq!(
17600            telemetry_surface(command_of(&["codewhale-tui", "doctor"]).as_ref()),
17601            Surface::Cli
17602        );
17603    }
17604
17605    #[test]
17606    fn the_session_source_distinguishes_resume_and_fork_from_a_fresh_launch() {
17607        assert_eq!(telemetry_session_source(None), SessionSource::Interactive);
17608        assert_eq!(
17609            telemetry_session_source(command_of(&["codewhale-tui", "resume", "--last"]).as_ref()),
17610            SessionSource::Resume
17611        );
17612        assert_eq!(
17613            telemetry_session_source(command_of(&["codewhale-tui", "fork", "--last"]).as_ref()),
17614            SessionSource::Fork
17615        );
17616        assert_eq!(
17617            telemetry_session_source(command_of(&["codewhale-tui", "serve", "--http"]).as_ref()),
17618            SessionSource::Api
17619        );
17620        assert_eq!(
17621            telemetry_session_source(command_of(&["codewhale-tui", "doctor"]).as_ref()),
17622            SessionSource::Unknown
17623        );
17624    }
17625
17626    #[test]
17627    fn a_session_end_built_without_arming_writes_nothing() {
17628        // `telemetry_session_end` is pure: it reads the counters and the exit
17629        // class and builds a value. Nothing about building it may touch disk,
17630        // because the signal path builds it on every run including runs that
17631        // never armed.
17632        let event = telemetry_session_end();
17633        assert!(matches!(
17634            event,
17635            codewhale_telemetry::Event::SessionEnd { .. }
17636        ));
17637        // And handing it to `record_blocking` while unarmed is a no-op.
17638        codewhale_telemetry::record_blocking(event);
17639        assert!(!codewhale_telemetry::is_armed());
17640    }
17641
17642    #[test]
17643    fn canceled_run_reports_exit_class_error_not_signal() {
17644        // A cancelled turn and a SIGINT both exit 130, so an exit-class derived
17645        // from the exit code would report every Esc as a signal. The exec path
17646        // states the class from the termination reason instead; this pins the
17647        // predicate that site applies (`!is_success()` ⇒ `Error`) and the exit
17648        // code collision that makes it necessary.
17649        use crate::core::termination::RunTerminationReason;
17650        assert_eq!(RunTerminationReason::Canceled.process_exit_code(), 130);
17651        assert_eq!(
17652            codewhale_telemetry::ExitClass::Signal.as_str(),
17653            "signal",
17654            "the SIGINT path's class is a distinct value, not a synonym for error"
17655        );
17656        assert!(!RunTerminationReason::Canceled.is_success());
17657        assert!(RunTerminationReason::Resolved.is_success());
17658        // The exit class is read from the process-wide atomic, and an unarmed
17659        // process reports `Clean` rather than inventing one from an exit code.
17660        assert!(!codewhale_telemetry::is_armed());
17661        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
17662        assert_eq!(
17663            codewhale_telemetry::exit_class(),
17664            codewhale_telemetry::ExitClass::Clean
17665        );
17666    }
17667}
17668
17669#[cfg(test)]
17670#[path = "tests/telemetry_counters.rs"]
17671mod telemetry_counter_tests;