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 agy_credentials;
25mod approval_log;
26mod artifacts;
27mod audit;
28mod auto_reasoning;
29mod automation_manager;
30mod child_env;
31mod client;
32mod codex_model_cache;
33mod command_safety;
34mod commands;
35mod compaction;
36mod composer_history;
37mod composer_stash;
38mod config;
39mod config_persistence;
40mod config_ui;
41mod context_budget;
42mod context_report;
43mod continual_harness;
44mod core;
45mod cost_status;
46mod deepseek_theme;
47mod dependencies;
48mod doctor;
49mod dsh_credentials;
50mod elapsed;
51mod error_taxonomy;
52mod eval;
53mod execpolicy;
54mod external_credentials;
55mod fast_hash;
56mod features;
57mod fleet;
58mod goal_loop;
59mod hashing;
60mod hooks;
61mod image_attach;
62mod integrations;
63mod lane_control;
64mod llm_client;
65mod llm_response_cache;
66mod localization;
67mod logging;
68mod lsp;
69mod mcp;
70mod mcp_server;
71mod model_catalog;
72mod model_context;
73mod model_inventory;
74mod model_profile;
75mod model_registry;
76mod model_routing;
77mod models;
78mod models_dev_live;
79mod native_memory;
80mod network_policy;
81mod oauth;
82mod palette;
83mod plugins;
84mod prefix_cache;
85mod pricing;
86mod project_context;
87mod project_context_cache;
88mod prompt_zones;
89mod prompts;
90mod provider_lake;
91mod provider_readiness;
92mod purge;
93mod regex_cache;
94mod remote_control;
95mod remote_setup;
96pub mod repl;
97mod repo_law;
98mod request_manifest;
99mod request_tuning;
100mod resource_telemetry;
101mod retry_status;
102pub mod rlm;
103mod route_billing;
104mod route_budget;
105mod route_receipt;
106mod route_runtime;
107mod runtime_api;
108mod runtime_handoff;
109mod runtime_log;
110mod runtime_policy;
111mod runtime_threads;
112mod safe_label;
113mod sandbox;
114mod scorecard;
115#[allow(dead_code)]
116mod session_diagnostics;
117// Acceptance matrix for #2934 / #4397. Test-only: the table documents the
118// contract for reviewers and is enforced by the tests beside it, so it does
119// not need to exist in a shipped binary.
120#[cfg(test)]
121#[path = "main/tests.rs"]
122mod doctor_loader_tests;
123#[cfg(test)]
124mod session_control_acceptance;
125#[allow(dead_code)]
126mod session_manager;
127mod session_peek;
128mod session_projection;
129mod session_resume;
130pub mod session_tree;
131mod settings;
132mod shell_dispatcher;
133mod skill_state;
134mod skills;
135mod snapshot;
136mod startup_trace;
137mod task_manager;
138mod telemetry_notice;
139#[cfg(test)]
140mod test_support;
141// TLS bootstrap and platform client builders live in codewhale-release;
142// `crate::tls::*` keeps resolving for every caller.
143use codewhale_release::tls;
144mod todo_snapshot;
145mod tool_history_repair;
146mod tool_inspection;
147mod tool_output_receipts;
148mod tools;
149mod tui;
150mod turn_route_plan;
151mod utils;
152mod vision;
153mod work_graph;
154mod worker_profile;
155mod working_set;
156mod workspace_discovery;
157mod workspace_trust;
158mod xai_oauth;
159
160use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
161use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
162use crate::features::{Feature, render_feature_table};
163use crate::llm_client::LlmClient;
164use crate::mcp::{
165    McpCommandAvailability, McpConfig, McpPool, McpServerConfig, McpServerOAuthConfig,
166    is_relative_stdio_path_arg,
167};
168use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
169use crate::session_manager::{SessionManager, create_saved_session, truncate_id};
170use crate::tui::history::{summarize_tool_args, summarize_tool_output};
171
172#[cfg(windows)]
173fn configure_windows_console_utf8() {
174    use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
175
176    const CP_UTF8: u32 = 65001;
177    unsafe {
178        let _ = SetConsoleCP(CP_UTF8);
179        let _ = SetConsoleOutputCP(CP_UTF8);
180    }
181}
182
183#[cfg(not(windows))]
184fn configure_windows_console_utf8() {}
185
186fn install_rustls_crypto_provider() {
187    crate::tls::ensure_rustls_crypto_provider();
188}
189
190#[derive(Parser, Debug)]
191#[command(
192    name = "codewhale-tui",
193    bin_name = "codewhale-tui",
194    author,
195    version = env!("CODEWHALE_BUILD_VERSION"),
196    about = "Codewhale terminal coding agent",
197    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."
198)]
199struct Cli {
200    /// Subcommand to run
201    #[command(subcommand)]
202    command: Option<Commands>,
203
204    #[command(flatten)]
205    feature_toggles: FeatureToggles,
206
207    /// Initial prompt to submit in the interactive TUI. Use `exec` for non-interactive runs.
208    #[arg(short, long, value_name = "PROMPT", num_args = 1..)]
209    prompt: Vec<String>,
210
211    /// Legacy compatibility alias for Act + Full Access.
212    #[arg(long, hide = true)]
213    yolo: bool,
214
215    /// Maximum number of concurrent sub-agents (1-128; default 64)
216    #[arg(long)]
217    max_subagents: Option<usize>,
218
219    /// Path to config file
220    #[arg(long)]
221    config: Option<PathBuf>,
222
223    /// Enable verbose logging
224    #[arg(short, long)]
225    verbose: bool,
226
227    /// Config profile name
228    #[arg(long)]
229    profile: Option<String>,
230
231    /// Workspace directory for file operations
232    #[arg(short, long)]
233    workspace: Option<PathBuf>,
234
235    /// Resume a previous session by ID or prefix
236    #[arg(short, long)]
237    resume: Option<String>,
238
239    /// Continue the most recent session in this workspace
240    #[arg(short = 'c', long = "continue")]
241    continue_session: bool,
242
243    /// Enable TUI mouse capture for internal scrolling, transcript selection,
244    /// and scrollbar dragging
245    /// (default off on Windows)
246    #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
247    mouse_capture: bool,
248
249    /// Disable TUI mouse capture so terminal-native text selection works
250    #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
251    no_mouse_capture: bool,
252
253    /// Skip onboarding screens
254    #[arg(long)]
255    skip_onboarding: bool,
256
257    /// Start account-owned web remote control for this interactive session.
258    #[arg(long, hide = true)]
259    remote_control: bool,
260
261    /// Start a fresh session, ignoring any crash-recovery checkpoint
262    #[arg(long = "fresh")]
263    fresh: bool,
264
265    /// Skip loading project-level config from $WORKSPACE/.codewhale/config.toml
266    #[arg(long = "no-project-config")]
267    no_project_config: bool,
268}
269
270#[derive(Subcommand, Debug, Clone)]
271#[allow(clippy::large_enum_variant)]
272enum Commands {
273    /// Run system diagnostics and check configuration
274    Doctor(DoctorArgs),
275    /// Summarize failure signals from a local JSONL session log without raw content
276    SessionDiagnostics(SessionDiagnosticsArgs),
277    /// Bootstrap MCP config and/or skills directories
278    Setup(SetupArgs),
279    /// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge)
280    RemoteSetup(remote_setup::RemoteSetupArgs),
281    /// Generate shell completions
282    Completions {
283        /// Shell to generate completions for
284        #[arg(value_enum)]
285        shell: Shell,
286    },
287    /// List saved sessions
288    Sessions {
289        /// Maximum number of sessions to display
290        #[arg(short, long, default_value = "20")]
291        limit: usize,
292        /// Search sessions by title
293        #[arg(short, long)]
294        search: Option<String>,
295    },
296    /// Create default AGENTS.md in current directory
297    Init,
298    /// Save an API key to the shared user config
299    Login {
300        /// API key to store (otherwise read from stdin)
301        #[arg(long)]
302        api_key: Option<String>,
303    },
304    /// Remove the saved API key
305    Logout,
306    /// Manage provider authentication flows.
307    Auth(TuiAuthArgs),
308    /// List available models from the configured API endpoint
309    Models(ModelsArgs),
310    /// Generate speech audio with Xiaomi MiMo TTS models
311    #[command(visible_alias = "tts")]
312    Speech(SpeechArgs),
313    /// Run a non-interactive prompt. Use --auto for agent-with-tools mode.
314    Exec(ExecArgs),
315    /// Manage local Agent Fleet runs and workers
316    Fleet(FleetArgs),
317    /// Internal model-free Workflow tool dispatcher used by Lane Runtime.
318    #[command(name = "workflow-tool", hide = true)]
319    WorkflowTool(WorkflowToolArgs),
320    /// Run a code review over a git diff
321    Review(ReviewArgs),
322    /// Open the TUI pre-seeded with a GitHub PR's title, body, and diff
323    Pr {
324        /// PR number
325        #[arg(value_name = "NUMBER")]
326        number: u32,
327        /// Repository in `owner/name` form. Defaults to the current
328        /// workspace's `gh` config (i.e. the repo gh thinks you're in).
329        #[arg(short = 'R', long)]
330        repo: Option<String>,
331        /// Skip `gh pr checkout` even if gh is available. By default
332        /// the working tree is left as-is — checkout is opt-in via
333        /// `--checkout` because dirty trees fail it loudly.
334        #[arg(long, default_value_t = false)]
335        checkout: bool,
336    },
337    /// Apply a patch file (or stdin) to the working tree
338    Apply(ApplyArgs),
339    /// Run the offline evaluation harness (no network/LLM calls)
340    Eval(EvalArgs),
341    /// Score a run's token/cache/cost from recorded turns; flag regressions vs a baseline
342    Scorecard(ScorecardArgs),
343    /// Manage MCP servers
344    Mcp {
345        #[command(subcommand)]
346        command: McpCommand,
347    },
348    /// Inspect feature flags
349    Features(FeaturesCli),
350    /// Connect third-party harnesses through Codewhale (currently: DeepSeek Harness `dsh`)
351    Integrations {
352        #[command(subcommand)]
353        command: IntegrationsCommand,
354    },
355    /// Run a command inside the sandbox
356    Sandbox(SandboxArgs),
357    /// Run a local server (e.g. MCP)
358    Serve(ServeArgs),
359    /// Resume a previous session by ID (use --last for most recent)
360    Resume {
361        /// Conversation/session id (UUID or prefix)
362        #[arg(value_name = "SESSION_ID")]
363        session_id: Option<String>,
364        /// Continue the most recent session in this workspace without a picker
365        #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
366        last: bool,
367    },
368    /// Fork a previous session by ID (use --last for most recent)
369    Fork {
370        /// Conversation/session id (UUID or prefix)
371        #[arg(value_name = "SESSION_ID")]
372        session_id: Option<String>,
373        /// Fork the most recent session in this workspace without a picker
374        #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
375        last: bool,
376    },
377}
378
379#[derive(Args, Debug, Clone)]
380#[command(after_help = "\
381Examples:
382  codewhale exec \"explain this function\"
383  codewhale exec --auto \"list crates/ with ls\"
384  codewhale exec --auto --output-format stream-json \"fix the failing test\"
385
386Plain `codewhale exec` is a one-shot model response. Use `--auto` for
387non-interactive agent-with-tools execution. `--auto` does not change the
388sandbox posture or elevate a denied tool. Use `--sandbox danger-full-access`
389or `--allow-sandbox-elevation` to explicitly authorize sandbox elevation.
390")]
391struct ExecArgs {
392    /// Override model for this run
393    #[arg(long)]
394    model: Option<String>,
395    /// Override the provider for this run (e.g. `deepseek`, `openrouter`).
396    /// Non-secret identifier only — credentials still resolve from the
397    /// environment/config. Fleet uses this to launch a worker on its
398    /// profile-pinned provider even when the parent session is on another
399    /// one (#4093).
400    #[arg(long)]
401    provider: Option<String>,
402    /// Override reasoning/thinking effort for this run.
403    /// Accepted values: auto, off, low, medium, high, max.
404    #[arg(long = "reasoning-effort", value_name = "EFFORT")]
405    reasoning_effort: Option<String>,
406    /// Enable agent-with-tools mode with automatic tool approvals. This does
407    /// not authorize sandbox elevation.
408    #[arg(long, default_value_t = false)]
409    auto: bool,
410    /// Sandbox policy for this exec run; independent from --auto.
411    #[arg(long, value_name = "POLICY")]
412    sandbox: Option<String>,
413    /// Explicitly allow a denied tool to retry with danger-full-access.
414    #[arg(long, default_value_t = false)]
415    allow_sandbox_elevation: bool,
416    /// Emit machine-readable JSON output
417    #[arg(long, default_value_t = false, conflicts_with = "output_format")]
418    json: bool,
419    /// Resume a previous session by ID or prefix
420    #[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["session_id", "continue_session"])]
421    resume: Option<String>,
422    /// Resume a previous session by ID or prefix
423    #[arg(long = "session-id", value_name = "SESSION_ID", conflicts_with_all = ["resume", "continue_session"])]
424    session_id: Option<String>,
425    /// Continue the most recent session for this workspace
426    #[arg(long = "continue", default_value_t = false, conflicts_with_all = ["resume", "session_id"])]
427    continue_session: bool,
428    /// Output format for exec mode
429    #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)]
430    output_format: ExecOutputFormat,
431    /// Comma-separated list of canonical tools to allow (all others denied).
432    /// Names are case-insensitive: Bash, File, Git, Run, etc.
433    #[arg(long, value_delimiter = ',')]
434    allowed_tools: Option<Vec<String>>,
435    /// Comma-separated list of tools to deny (deny wins over allow).
436    #[arg(long, value_delimiter = ',')]
437    disallowed_tools: Option<Vec<String>>,
438    /// Maximum number of model steps before the run ends. Omitted means unlimited.
439    #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
440    max_turns: Option<u32>,
441    /// Extra text appended to the system prompt for this run.
442    #[arg(long)]
443    append_system_prompt: Option<String>,
444    /// Internal Fleet worker authority envelope. Non-secret, versioned JSON.
445    #[arg(long, value_name = "JSON", hide = true)]
446    tool_authority_json: Option<String>,
447    /// Prompt to send to the model
448    #[arg(
449        value_name = "PROMPT",
450        required = true,
451        trailing_var_arg = true,
452        allow_hyphen_values = true
453    )]
454    prompt: Vec<String>,
455}
456
457#[derive(Args, Debug, Clone)]
458struct WorkflowToolArgs {
459    /// Authority provenance stamped by the public `workflow run` command.
460    #[arg(long, value_name = "SOURCE")]
461    approval_source: String,
462    /// Exact Workflow tool input serialized as one JSON object.
463    #[arg(long, value_name = "JSON")]
464    input_json: String,
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
468enum ExecOutputFormat {
469    Text,
470    #[value(name = "stream-json")]
471    StreamJson,
472}
473
474#[derive(Args, Debug, Clone)]
475struct TuiAuthArgs {
476    #[command(subcommand)]
477    command: TuiAuthCommand,
478}
479
480#[derive(Subcommand, Debug, Clone)]
481enum TuiAuthCommand {
482    /// Sign in to xAI/Grok with an SSH-friendly device code.
483    #[command(name = "xai-device")]
484    XaiDevice,
485}
486
487const CODEWHALE_TOOL_SURFACE_ENV: &str = "CODEWHALE_TOOL_SURFACE";
488const SHELL_ONLY_EXEC_TOOLS: &[&str] = &["bash"];
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491enum ExecToolSurface {
492    ShellOnly,
493}
494
495fn exec_tool_surface_from_env() -> Option<ExecToolSurface> {
496    std::env::var(CODEWHALE_TOOL_SURFACE_ENV)
497        .ok()
498        .and_then(|value| {
499            if should_warn_unknown_exec_tool_surface(&value) {
500                eprintln!(
501                    "warning: unrecognized {CODEWHALE_TOOL_SURFACE_ENV}; leaving exec tool surface unchanged. Use `shell-only`, `full`, or `native-tools`."
502                );
503            }
504            parse_exec_tool_surface(&value)
505        })
506}
507
508fn parse_exec_tool_surface(value: &str) -> Option<ExecToolSurface> {
509    match value.trim().to_ascii_lowercase().as_str() {
510        "shell-only" | "shell_only" | "shell" => Some(ExecToolSurface::ShellOnly),
511        "full" | "native-tools" | "native_tools" | "" => None,
512        _ => None,
513    }
514}
515
516fn should_warn_unknown_exec_tool_surface(value: &str) -> bool {
517    let normalized = value.trim().to_ascii_lowercase();
518    !matches!(
519        normalized.as_str(),
520        "" | "shell-only" | "shell_only" | "shell" | "full" | "native-tools" | "native_tools"
521    )
522}
523
524fn normalize_exec_tool_names(tools: &[String]) -> Vec<String> {
525    tools
526        .iter()
527        .map(|name| name.to_ascii_lowercase().trim().to_string())
528        .collect()
529}
530
531fn shell_only_exec_allowed_tools() -> Vec<String> {
532    SHELL_ONLY_EXEC_TOOLS
533        .iter()
534        .map(|name| (*name).to_string())
535        .collect()
536}
537
538fn resolve_exec_allowed_tools(
539    cli_allowed_tools: Option<&[String]>,
540    env_tool_surface: Option<ExecToolSurface>,
541) -> Option<Vec<String>> {
542    if let Some(tools) = cli_allowed_tools {
543        return Some(normalize_exec_tool_names(tools));
544    }
545
546    env_tool_surface.map(|ExecToolSurface::ShellOnly| shell_only_exec_allowed_tools())
547}
548
549#[derive(Args, Debug, Clone)]
550struct FleetArgs {
551    #[command(subcommand)]
552    command: FleetCommand,
553}
554
555#[derive(Subcommand, Debug, Clone)]
556enum FleetCommand {
557    /// Initialize the local fleet ledger for this workspace
558    Init,
559    /// Create a run from a task spec and start the foreground manager loop
560    Run(FleetRunArgs),
561    /// List durable Fleet runs from this workspace's ledger
562    List,
563    /// Show queued/running/completed/failed/stale fleet counts
564    Status,
565    /// Inspect one worker's status, heartbeat, latest event, and artifacts
566    Inspect {
567        /// Worker id printed by `codewhale fleet run`
568        worker_id: String,
569    },
570    /// Print bounded log artifacts for one worker
571    Logs {
572        /// Worker id printed by `codewhale fleet run`
573        worker_id: String,
574    },
575    /// List artifact refs for one worker
576    Artifacts {
577        /// Worker id printed by `codewhale fleet run`
578        worker_id: String,
579    },
580    /// Interrupt a running worker task and record a terminal cancellation
581    Interrupt {
582        /// Worker id printed by `codewhale fleet run`
583        worker_id: String,
584    },
585    /// Restart the latest task for a worker
586    Restart {
587        /// Worker id printed by `codewhale fleet run`
588        worker_id: String,
589    },
590    /// Resume a run from durable ledger state, reconciling orphaned/stale leases
591    Resume {
592        /// Run id printed by `codewhale fleet run`
593        run_id: String,
594        /// Seconds without heartbeat before a leased task is treated as stale
595        #[arg(long, default_value_t = 300)]
596        stale_after_seconds: u64,
597    },
598    /// Stop all queued and running fleet work
599    Stop {
600        /// Confirm stopping all queued and running fleet tasks
601        #[arg(long, required = true)]
602        all: bool,
603    },
604    /// Render a redacted fleet alert payload without sending it
605    AlertDryRun(FleetAlertDryRunArgs),
606}
607
608#[derive(Args, Debug, Clone)]
609struct FleetRunArgs {
610    /// JSON or TOML task spec to enqueue
611    #[arg(value_name = "TASK_SPEC")]
612    task_spec: PathBuf,
613    /// Maximum local workers to lease concurrently
614    #[arg(long, default_value_t = 4)]
615    max_workers: usize,
616    /// Seconds without heartbeat before a running task is counted stale
617    #[arg(long, default_value_t = 300)]
618    stale_after_seconds: u64,
619    /// Schedule once and return instead of staying in the manager loop
620    #[arg(long, hide = true, default_value_t = false)]
621    once: bool,
622}
623
624#[derive(Args, Debug, Clone)]
625struct FleetAlertDryRunArgs {
626    /// Alert event class to render
627    #[arg(long, value_enum)]
628    event: FleetAlertEventArg,
629    /// Fleet run id
630    #[arg(long)]
631    run_id: String,
632    /// Worker id, when the event belongs to one worker
633    #[arg(long)]
634    worker_id: Option<String>,
635    /// Task id, when the event belongs to one task
636    #[arg(long)]
637    task_id: Option<String>,
638    /// Short human-readable reason for the alert
639    #[arg(long, default_value = "manual fleet alert dry-run")]
640    reason: String,
641    /// Status label to include in the payload
642    #[arg(long)]
643    status: Option<String>,
644    /// Adapter payload shape to render
645    #[arg(long, value_enum, default_value_t = FleetAlertAdapterArg::Slack)]
646    adapter: FleetAlertAdapterArg,
647    /// Environment variable containing the Slack webhook URL
648    #[arg(long, default_value = "CODEWHALE_FLEET_SLACK_WEBHOOK")]
649    slack_webhook_env: String,
650    /// Environment variable containing the generic webhook URL
651    #[arg(long, default_value = "CODEWHALE_FLEET_WEBHOOK_URL")]
652    webhook_url_env: String,
653    /// Optional environment variable containing the generic webhook secret
654    #[arg(long)]
655    webhook_secret_env: Option<String>,
656    /// Environment variable containing the PagerDuty routing key
657    #[arg(long, default_value = "CODEWHALE_FLEET_PAGERDUTY_ROUTING_KEY")]
658    pagerduty_routing_key_env: String,
659    /// PagerDuty severity to render
660    #[arg(long, default_value = "error")]
661    pagerduty_severity: String,
662}
663
664#[derive(ValueEnum, Debug, Clone, Copy)]
665enum FleetAlertEventArg {
666    Stale,
667    RestartExhausted,
668    NeedsHuman,
669    BudgetExceeded,
670    VerifierFailed,
671    RunCompleted,
672}
673
674#[derive(ValueEnum, Debug, Clone, Copy)]
675enum FleetAlertAdapterArg {
676    Slack,
677    Webhook,
678    PagerDuty,
679}
680
681/// Spawn a tokio task that listens for terminating signals (SIGINT
682/// always; SIGTERM and SIGHUP on Unix) and, on receipt, restores the
683/// terminal modes and exits with the conventional 128 + signal code.
684/// Multiple deliveries are tolerated: once the cleanup runs, a second
685/// signal short-circuits to plain exit so a stuck cleanup can never
686/// trap a frustrated user pressing Ctrl+C repeatedly.
687///
688/// See the call site in `main` for the rationale (#1583).
689///
690/// Registration is synchronous, before the spawn: a `tokio::spawn`ed task does
691/// not run until the scheduler first polls it, so registering the signal
692/// streams *inside* it leaves a window — unbounded under load — where SIGINT
693/// still has its default disposition and kills the process outright. That is
694/// the very outcome this handler exists to prevent, and it produced a real
695/// terminated-by-signal exit (no code, no terminal restore, no `session_end`).
696/// After this function returns, the signals are armed.
697fn spawn_signal_cleanup_task() {
698    let signals = TerminatingSignals::register();
699    tokio::spawn(async move {
700        let exit_code = signals.wait().await;
701        // If we get here a fatal signal arrived. Restore the terminal
702        // and exit. A second signal during cleanup re-enters this
703        // path and aborts via `std::process::exit` directly.
704        static CLEANED_UP: std::sync::atomic::AtomicBool =
705            std::sync::atomic::AtomicBool::new(false);
706        if !CLEANED_UP.swap(true, std::sync::atomic::Ordering::SeqCst) {
707            #[cfg(unix)]
708            crate::tools::shell::abort_pending_persistent_process_groups_for_exit();
709            crate::tui::ui::emergency_restore_terminal();
710            // Nothing async survives the `exit` below, so this is the last
711            // chance to say how the session ended. `record_blocking` is one
712            // `O_APPEND` write with no lock: taking the compaction lock here
713            // would let a second Codewhale process sharing CODEWHALE_HOME hang
714            // Ctrl-C, and the second-signal short-circuit below has to stay
715            // reachable. A no-op unless this process was armed.
716            //
717            // The class is stated, not derived: `RunTerminationReason::Canceled`
718            // also exits 130, so `exit_code` cannot tell a signal from an
719            // Esc-cancelled turn.
720            record_signal_session_end();
721        }
722        std::process::exit(exit_code);
723    });
724}
725
726/// When this process's armed telemetry session began. Set once, at arming, and
727/// read from both the ordinary teardown and the signal path.
728static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
729    std::sync::OnceLock::new();
730
731/// Build `session_end` from what this process actually accumulated.
732///
733/// The exit class is read from the process-wide atomic and never derived from
734/// an exit code: `RunTerminationReason::Canceled` maps to 130, the same value
735/// the SIGINT path uses, so a code-based derivation would report every
736/// Esc-cancelled turn as a signal.
737///
738/// The cold-start bucket is `None` unless the interactive event loop actually
739/// began, which is what keeps it absent rather than invented on the surfaces
740/// that have no event loop.
741fn telemetry_session_end() -> codewhale_telemetry::Event {
742    let counters = codewhale_telemetry::session_counters();
743    codewhale_telemetry::Event::SessionEnd {
744        duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
745            TELEMETRY_SESSION_START
746                .get()
747                .map_or(0, |start| start.elapsed().as_secs()),
748        ),
749        exit_class: codewhale_telemetry::exit_class(),
750        cold_start_bucket: crate::startup_trace::cold_start_ms()
751            .map(codewhale_telemetry::ColdStartBucket::from_millis),
752        providers: counters.providers(),
753        counters: counters.counters(),
754        errors: counters.errors(),
755        turn_wall: counters.turn_wall(),
756    }
757}
758
759/// Close the session synchronously, from the signal handler.
760///
761/// A no-op unless this process was armed.
762fn record_signal_session_end() {
763    codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
764    codewhale_telemetry::record_blocking(telemetry_session_end());
765}
766
767/// Terminating-signal streams, registered up front and awaited later.
768///
769/// Splitting registration from the await is the point: the OS disposition
770/// changes when `register` returns, not when the waiting task is first polled.
771#[cfg(unix)]
772struct TerminatingSignals {
773    sigint: Option<tokio::signal::unix::Signal>,
774    sigterm: Option<tokio::signal::unix::Signal>,
775    sighup: Option<tokio::signal::unix::Signal>,
776}
777
778#[cfg(unix)]
779impl TerminatingSignals {
780    /// Install the handlers. Failing to install any individual stream is
781    /// non-fatal: we still want the others to work.
782    fn register() -> Self {
783        use tokio::signal::unix::{SignalKind, signal};
784        Self {
785            sigint: signal(SignalKind::interrupt()).ok(),
786            sigterm: signal(SignalKind::terminate()).ok(),
787            sighup: signal(SignalKind::hangup()).ok(),
788        }
789    }
790
791    /// Resolve with 128 + signal number for whichever arrives first. The
792    /// fallback never-resolving future keeps `select!` well-typed when a
793    /// stream failed to register.
794    async fn wait(mut self) -> i32 {
795        tokio::select! {
796            _ = async { match self.sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130,
797            _ = async { match self.sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143,
798            _ = async { match self.sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129,
799        }
800    }
801}
802
803/// Windows: `ctrl_c` covers both Ctrl+C and Ctrl+Break (CTRL_C_EVENT /
804/// CTRL_BREAK_EVENT). Console-close, logoff, and shutdown events are not
805/// currently routed through tokio.
806#[cfg(not(unix))]
807struct TerminatingSignals {
808    ctrl_c: Option<tokio::signal::windows::CtrlC>,
809}
810
811#[cfg(not(unix))]
812impl TerminatingSignals {
813    fn register() -> Self {
814        Self {
815            ctrl_c: tokio::signal::windows::ctrl_c().ok(),
816        }
817    }
818
819    async fn wait(mut self) -> i32 {
820        match self.ctrl_c.as_mut() {
821            Some(s) => {
822                s.recv().await;
823            }
824            None => std::future::pending::<()>().await,
825        }
826        130
827    }
828}
829
830fn join_prompt_parts(parts: &[String]) -> String {
831    parts.join(" ")
832}
833
834fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String {
835    explicit_model
836        .map(str::trim)
837        .filter(|model| !model.is_empty())
838        .map(ToOwned::to_owned)
839        .or_else(exec_model_env_override)
840        .unwrap_or_else(|| config.default_model())
841}
842
843fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> {
844    let provider_arg = provider_arg.trim();
845    if provider_arg.is_empty() {
846        return Ok(());
847    }
848    if config
849        .providers
850        .as_ref()
851        .and_then(|providers| providers.custom_provider_config(provider_arg))
852        .is_some()
853    {
854        config.provider = Some(provider_arg.to_string());
855        return Ok(());
856    }
857    if let Some(provider) = crate::config::ApiProvider::parse(provider_arg) {
858        config.provider = Some(provider.as_str().to_string());
859        return Ok(());
860    }
861    bail!(
862        "Unrecognized --provider {provider_arg:?}. Known providers: {} \
863         or a configured [providers.<name>] custom provider",
864        crate::config::ApiProvider::names_hint()
865    );
866}
867
868fn exec_model_env_override() -> Option<String> {
869    let read = || {
870        ["CODEWHALE_MODEL", "DEEPSEEK_MODEL"]
871            .into_iter()
872            .find_map(|key| {
873                std::env::var(key)
874                    .ok()
875                    .map(|model| model.trim().to_string())
876                    .filter(|model| !model.is_empty())
877            })
878    };
879    #[cfg(test)]
880    {
881        crate::test_support::with_test_env_lock(read)
882    }
883    #[cfg(not(test))]
884    {
885        read()
886    }
887}
888
889fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
890    (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
891}
892
893fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
894    if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
895        return Ok(Some(id.clone()));
896    }
897    if !args.continue_session {
898        return Ok(None);
899    }
900    latest_session_id_for_workspace(workspace)?.map_or_else(
901        || {
902            bail!(
903                "No saved sessions found for workspace {}. Use `codewhale sessions` to list sessions, or pass `codewhale exec --resume <SESSION_ID> ...`.",
904                workspace.display()
905            )
906        },
907        |id| Ok(Some(id)),
908    )
909}
910
911fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
912    let session_ref = exec_stream_session_ref(session_id);
913    SessionManager::default_location()
914        .context("could not open session manager for resume")?
915        .load_session_by_prefix(session_id)
916        .with_context(|| format!("could not load session {session_ref}"))
917}
918
919/// Select the route for `exec --resume` before any engine/client is built.
920///
921/// Precedence is intentionally field-aware:
922/// - no explicit `--provider` or `--model`: restore the saved provider/model;
923/// - explicit `--provider`: keep that route and use its configured/default model
924///   unless `--model` is also present;
925/// - explicit `--model` alone: restore the saved provider, then use that model.
926fn resolve_exec_resume_route(
927    config: &mut Config,
928    saved: &session_manager::SavedSession,
929    explicit_provider: bool,
930    explicit_model: Option<&str>,
931) -> Result<String> {
932    if !explicit_provider {
933        let saved_provider_identity = saved
934            .metadata
935            .model_provider_id
936            .as_deref()
937            .filter(|identity| !identity.trim().is_empty())
938            .unwrap_or(&saved.metadata.model_provider);
939        let identity = config
940            .resolve_persisted_provider_identity(
941                Some(&saved.metadata.model_provider),
942                saved.metadata.model_provider_id.as_deref(),
943            )
944            .map_err(anyhow::Error::msg)
945            .with_context(|| {
946                format!(
947                    "saved session provider '{}' is unavailable; Codewhale will not fall back",
948                    saved_provider_identity
949                )
950            })?;
951        config.scope_to_provider_identity(&identity);
952    }
953
954    if let Some(model) = explicit_model {
955        return Ok(resolve_exec_model(config, Some(model)));
956    }
957    if explicit_provider {
958        return Ok(resolve_exec_model(config, None));
959    }
960    Ok(saved.metadata.model.clone())
961}
962
963#[derive(Args, Debug, Clone, Default)]
964struct SetupArgs {
965    /// Initialize MCP configuration at the configured path
966    #[arg(long, default_value_t = false)]
967    mcp: bool,
968    /// Initialize skills directory and an example skill
969    #[arg(long, default_value_t = false)]
970    skills: bool,
971    /// Initialize tools directory with a self-describing example script
972    #[arg(long, default_value_t = false)]
973    tools: bool,
974    /// Initialize plugins directory with a self-describing example
975    #[arg(long, default_value_t = false)]
976    plugins: bool,
977    /// Initialize MCP config, skills, tools, and plugins
978    #[arg(long, default_value_t = false)]
979    all: bool,
980    /// Create a local workspace skills directory (./skills)
981    #[arg(long, default_value_t = false)]
982    local: bool,
983    /// Overwrite existing template files
984    #[arg(long, default_value_t = false)]
985    force: bool,
986    /// Print a compact, read-only status report (no network calls)
987    #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
988    status: bool,
989    /// Remove regenerable session checkpoints (latest + offline_queue)
990    #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
991    clean: bool,
992}
993
994#[derive(Args, Debug, Clone, Default)]
995struct DoctorArgs {
996    /// Emit machine-readable structural JSON output (always offline)
997    #[arg(long, default_value_t = false)]
998    json: bool,
999    /// Emit only the diagnostic context source map as JSON
1000    #[arg(long, default_value_t = false, conflicts_with = "json")]
1001    context_json: bool,
1002    /// Opt in to probing a local provider endpoint (may start a local service)
1003    #[arg(
1004        long,
1005        default_value_t = false,
1006        conflicts_with_all = ["json", "context_json"]
1007    )]
1008    probe_local: bool,
1009    /// Opt in to probing the configured hosted provider API
1010    #[arg(
1011        long,
1012        default_value_t = false,
1013        conflicts_with_all = ["json", "context_json"]
1014    )]
1015    probe_api: bool,
1016    /// Opt in to contacting the release service for an update check
1017    #[arg(
1018        long,
1019        default_value_t = false,
1020        conflicts_with_all = ["json", "context_json"]
1021    )]
1022    check_updates: bool,
1023    /// Opt in to starting enabled MCP servers and checking process/protocol reachability
1024    #[arg(
1025        long,
1026        default_value_t = false,
1027        conflicts_with_all = ["json", "context_json"]
1028    )]
1029    probe_mcp: bool,
1030    /// Opt in to a credential-free transport probe of the selected search provider
1031    #[arg(
1032        long,
1033        default_value_t = false,
1034        conflicts_with_all = ["json", "context_json"]
1035    )]
1036    probe_search: bool,
1037}
1038
1039#[derive(Args, Debug, Clone)]
1040struct SessionDiagnosticsArgs {
1041    /// JSONL session log to inspect
1042    #[arg(value_name = "JSONL")]
1043    path: PathBuf,
1044    /// Emit machine-readable JSON with redacted source handles
1045    #[arg(long, default_value_t = false)]
1046    json: bool,
1047}
1048
1049#[derive(Args, Debug, Clone)]
1050struct ScorecardArgs {
1051    /// JSON file with the recorded turns to score: an array of
1052    /// `{ "turn_id", "provider", "model", "billing_surface", "usage": {…} }`.
1053    /// `turn_end` hooks emit this route provenance plus `created_at`; persisted
1054    /// runtime exports may instead use `id`, `effective_provider`,
1055    /// `effective_model`, and `effective_billing_surface`.
1056    /// Shell-only hook rows marked `model_backed: false` are excluded. Legacy
1057    /// rows without provider remain readable but their cost is unavailable.
1058    #[arg(long, value_name = "FILE")]
1059    input: PathBuf,
1060    /// Optional baseline scorecard-metrics JSON to compare against. When set,
1061    /// the command exits non-zero if any metric regresses past the threshold.
1062    #[arg(long, value_name = "FILE")]
1063    baseline: Option<PathBuf>,
1064    /// Regression threshold, in percent increase over the baseline.
1065    #[arg(long, default_value_t = 5.0)]
1066    threshold: f64,
1067    /// Emit machine-readable JSON instead of the human summary.
1068    #[arg(long, default_value_t = false)]
1069    json: bool,
1070}
1071
1072#[derive(Args, Debug, Clone)]
1073struct EvalArgs {
1074    /// Intentionally fail a specific step (list, read, search, edit, patch, shell)
1075    #[arg(long, value_name = "STEP")]
1076    fail_step: Option<String>,
1077    /// Shell command to run during the exec step
1078    #[arg(long, default_value = "printf eval-harness")]
1079    shell_command: String,
1080    /// Token that must appear in shell output for validation
1081    #[arg(long, default_value = "eval-harness")]
1082    shell_expect_token: String,
1083    /// Maximum characters stored per step output summary
1084    #[arg(long, default_value_t = 240)]
1085    max_output_chars: usize,
1086    /// Emit machine-readable JSON output
1087    #[arg(long, default_value_t = false)]
1088    json: bool,
1089    /// Append one JSONL fixture line per step to `<DIR>/<scenario>.jsonl`.
1090    /// Mock LLM tests can later replay these fixtures.
1091    #[arg(long, value_name = "DIR")]
1092    record: Option<PathBuf>,
1093}
1094
1095#[derive(Args, Debug, Clone, Default)]
1096struct ModelsArgs {
1097    /// Print models as pretty JSON
1098    #[arg(long, default_value_t = false)]
1099    json: bool,
1100}
1101
1102#[derive(Args, Debug, Clone)]
1103struct SpeechArgs {
1104    /// Text to synthesize. This is sent as the assistant message content.
1105    #[arg(value_name = "TEXT")]
1106    text: String,
1107
1108    /// Output audio path. Defaults to `speech.<format>` in `--output-dir`,
1109    /// `[speech].output_dir`, or the current directory.
1110    #[arg(short, long, value_name = "FILE")]
1111    output: Option<PathBuf>,
1112
1113    /// Directory for the default `speech.<format>` output file when `-o`/`--output` is omitted.
1114    #[arg(long = "output-dir", value_name = "DIR")]
1115    output_dir: Option<PathBuf>,
1116
1117    /// TTS model. Defaults to built-in voices, or is inferred from --voice-prompt/--clone-voice.
1118    #[arg(long)]
1119    model: Option<String>,
1120
1121    /// Built-in voice ID, or a data:audio/...;base64,... URI for voice clone.
1122    #[arg(long)]
1123    voice: Option<String>,
1124
1125    /// Natural language style instruction; not spoken verbatim.
1126    #[arg(long)]
1127    instruction: Option<String>,
1128
1129    /// Voice design prompt. Implies mimo-v2.5-tts-voicedesign when --model is omitted.
1130    #[arg(long = "voice-prompt")]
1131    voice_prompt: Option<String>,
1132
1133    /// MP3/WAV sample used for voice cloning. Implies mimo-v2.5-tts-voiceclone when --model is omitted.
1134    #[arg(long = "clone-voice", value_name = "FILE")]
1135    clone_voice: Option<PathBuf>,
1136
1137    /// Output audio format requested from the API
1138    #[arg(long, default_value = "wav")]
1139    format: String,
1140
1141    /// Emit machine-readable JSON output
1142    #[arg(long, default_value_t = false)]
1143    json: bool,
1144}
1145
1146#[derive(Args, Debug, Default, Clone)]
1147struct FeatureToggles {
1148    /// Enable a feature (repeatable). Equivalent to `features.<name>=true`.
1149    #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1150    enable: Vec<String>,
1151
1152    /// Disable a feature (repeatable). Equivalent to `features.<name>=false`.
1153    #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1154    disable: Vec<String>,
1155}
1156
1157impl FeatureToggles {
1158    fn apply(&self, config: &mut Config) -> Result<()> {
1159        for feature in &self.enable {
1160            config.set_feature(feature, true)?;
1161        }
1162        for feature in &self.disable {
1163            config.set_feature(feature, false)?;
1164        }
1165        Ok(())
1166    }
1167}
1168
1169#[derive(Args, Debug, Clone)]
1170struct ReviewArgs {
1171    /// Review staged changes instead of the working tree
1172    #[arg(long, conflicts_with = "base")]
1173    staged: bool,
1174    /// Base ref to diff against (e.g. origin/main)
1175    #[arg(long)]
1176    base: Option<String>,
1177    /// Limit diff to a specific path
1178    #[arg(long)]
1179    path: Option<PathBuf>,
1180    /// Override model for this review
1181    #[arg(long)]
1182    model: Option<String>,
1183    /// Maximum diff characters to include
1184    #[arg(long, default_value_t = 200_000)]
1185    max_chars: usize,
1186    /// Write a durable pre-push review receipt after a successful review
1187    #[arg(long, default_value_t = false)]
1188    write_receipt: bool,
1189    /// Validate the current diff against a durable review receipt without calling a model
1190    #[arg(long, default_value_t = false)]
1191    check_receipt: bool,
1192    /// Override where the review receipt is written or read
1193    #[arg(long)]
1194    receipt_path: Option<PathBuf>,
1195    /// Emit machine-readable JSON output
1196    #[arg(long, default_value_t = false)]
1197    json: bool,
1198}
1199
1200#[derive(Args, Debug, Clone)]
1201struct ApplyArgs {
1202    /// Patch file to apply (defaults to stdin)
1203    #[arg(value_name = "PATCH_FILE")]
1204    patch_file: Option<PathBuf>,
1205}
1206
1207#[derive(Args, Debug, Clone)]
1208struct ServeArgs {
1209    /// Start MCP server over stdio
1210    #[arg(long)]
1211    mcp: bool,
1212    /// Start runtime HTTP/SSE API server
1213    #[arg(long)]
1214    http: bool,
1215    /// Start runtime HTTP/SSE API server with the built-in mobile control page
1216    #[arg(long)]
1217    mobile: bool,
1218    /// Start the embedded loopback-only browser client and open it
1219    #[arg(long)]
1220    web: bool,
1221    /// Show a QR code for the mobile URL in the terminal (requires --mobile)
1222    #[arg(long, requires = "mobile")]
1223    qr: bool,
1224    /// Start ACP server over stdio for editor clients such as Zed
1225    #[arg(long)]
1226    acp: bool,
1227    /// Bind host for HTTP server (default localhost; --mobile defaults to 0.0.0.0)
1228    #[arg(long)]
1229    host: Option<String>,
1230    /// Bind port for HTTP server
1231    #[arg(long, default_value_t = 7878)]
1232    port: u16,
1233    /// Background task worker count (1-8)
1234    #[arg(long, default_value_t = 2)]
1235    workers: usize,
1236    /// Additional CORS origin to allow (repeatable). Stacks on top of the
1237    /// built-in defaults (localhost:3000, localhost:1420, tauri://localhost).
1238    /// Also reads `CODEWHALE_CORS_ORIGINS` (comma-separated), then
1239    /// `DEEPSEEK_CORS_ORIGINS` as an alias, and `[runtime_api] cors_origins`
1240    /// from `config.toml`. Whalescale#255.
1241    #[arg(long = "cors-origin", value_name = "URL")]
1242    cors_origin: Vec<String>,
1243    /// Require this bearer token for `/v1/*` runtime API routes. Also reads
1244    /// `CODEWHALE_RUNTIME_TOKEN` when omitted, then `DEEPSEEK_RUNTIME_TOKEN`
1245    /// as an alias.
1246    #[arg(long = "auth-token", value_name = "TOKEN")]
1247    auth_token: Option<String>,
1248    /// Disable runtime API auth when no token is configured. Only use on a trusted loopback.
1249    #[arg(long = "insecure")]
1250    insecure_no_auth: bool,
1251}
1252
1253#[derive(Debug, Clone, PartialEq, Eq)]
1254struct ServeBindHost {
1255    host: String,
1256    mobile_rebound_to_lan: bool,
1257}
1258
1259fn resolve_serve_bind_host(mobile: bool, host: Option<String>) -> ServeBindHost {
1260    match (mobile, host) {
1261        (true, None) => ServeBindHost {
1262            host: "0.0.0.0".to_string(),
1263            mobile_rebound_to_lan: true,
1264        },
1265        (_, Some(host)) => ServeBindHost {
1266            host,
1267            mobile_rebound_to_lan: false,
1268        },
1269        (false, None) => ServeBindHost {
1270            host: "127.0.0.1".to_string(),
1271            mobile_rebound_to_lan: false,
1272        },
1273    }
1274}
1275
1276fn validate_serve_mode_selection(
1277    mcp: bool,
1278    http: bool,
1279    mobile: bool,
1280    web: bool,
1281    acp: bool,
1282) -> Result<bool> {
1283    if http && mobile {
1284        bail!("--http and --mobile are mutually exclusive; choose one");
1285    }
1286    if web && (http || mobile) {
1287        bail!("--web is mutually exclusive with --http and --mobile");
1288    }
1289    let http_selected = http || mobile || web;
1290    let selected_modes = [mcp, http_selected, acp]
1291        .into_iter()
1292        .filter(|selected| *selected)
1293        .count();
1294    if selected_modes != 1 {
1295        bail!("Choose exactly one server mode: --mcp, --http/--mobile/--web, or --acp");
1296    }
1297    Ok(http_selected)
1298}
1299
1300#[derive(Subcommand, Debug, Clone)]
1301enum McpCommand {
1302    /// List configured MCP servers
1303    List,
1304    /// Create a template MCP config at the configured path
1305    Init {
1306        /// Overwrite an existing MCP config file
1307        #[arg(long, default_value_t = false)]
1308        force: bool,
1309    },
1310    /// Connect to MCP servers and report status
1311    Connect {
1312        /// Optional server name to connect to
1313        #[arg(value_name = "SERVER")]
1314        server: Option<String>,
1315    },
1316    /// List tools discovered from MCP servers
1317    Tools {
1318        /// Optional server name to list tools for
1319        #[arg(value_name = "SERVER")]
1320        server: Option<String>,
1321    },
1322    /// Add an MCP server entry
1323    Add {
1324        /// Server name
1325        name: String,
1326        /// Command to launch stdio server
1327        #[arg(long, conflicts_with = "url")]
1328        command: Option<String>,
1329        /// URL for streamable HTTP/SSE server
1330        #[arg(long, conflicts_with = "command")]
1331        url: Option<String>,
1332        /// Explicit URL transport override. Use "sse" for legacy SSE endpoints.
1333        #[arg(long, requires = "url")]
1334        transport: Option<String>,
1335        /// Environment variable containing a bearer token for URL-based servers
1336        #[arg(long, requires = "url")]
1337        bearer_token_env_var: Option<String>,
1338        /// OAuth client ID for servers that do not support dynamic registration
1339        #[arg(long, requires = "url")]
1340        oauth_client_id: Option<String>,
1341        /// OAuth resource parameter to append to the authorization URL
1342        #[arg(long, requires = "url")]
1343        oauth_resource: Option<String>,
1344        /// OAuth scope to request during login. Repeat or comma-separate.
1345        #[arg(long = "scope", requires = "url", value_delimiter = ',')]
1346        scopes: Vec<String>,
1347        /// Arguments for command-based servers
1348        #[arg(long = "arg")]
1349        args: Vec<String>,
1350    },
1351    /// Authenticate to a URL-based MCP server using OAuth
1352    Login {
1353        /// Server name
1354        name: String,
1355        /// OAuth scope to request. Repeat or comma-separate; defaults to config/discovery.
1356        #[arg(long = "scope", value_delimiter = ',')]
1357        scopes: Vec<String>,
1358    },
1359    /// Delete stored OAuth credentials for a URL-based MCP server
1360    Logout {
1361        /// Server name
1362        name: String,
1363    },
1364    /// Remove an MCP server entry
1365    Remove {
1366        /// Server name
1367        name: String,
1368    },
1369    /// Enable an MCP server
1370    Enable {
1371        /// Server name
1372        name: String,
1373    },
1374    /// Disable an MCP server
1375    Disable {
1376        /// Server name
1377        name: String,
1378    },
1379    /// Validate MCP config and required servers
1380    Validate,
1381    /// Register this Codewhale binary as a local MCP stdio server.
1382    ///
1383    /// This adds a config entry that runs `codewhale serve --mcp` (stdio protocol).
1384    /// For the HTTP/SSE runtime API, use `codewhale serve --http` directly instead.
1385    #[command(
1386        name = "add-self",
1387        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."
1388    )]
1389    AddSelf {
1390        /// Server name in mcp.json (default: "codewhale")
1391        #[arg(long, default_value = "codewhale")]
1392        name: String,
1393        /// Workspace directory for the MCP server
1394        #[arg(long)]
1395        workspace: Option<String>,
1396    },
1397}
1398
1399#[derive(Subcommand, Debug, Clone)]
1400pub(crate) enum IntegrationsCommand {
1401    /// Official DeepSeek Harness (`dsh`) connected through Codewhale
1402    Dsh {
1403        #[command(subcommand)]
1404        command: DshIntegrationCommand,
1405    },
1406}
1407
1408#[derive(Subcommand, Debug, Clone)]
1409pub(crate) enum DshIntegrationCommand {
1410    /// Detect dsh and report the integration state without writing anything
1411    Status {
1412        /// Emit machine-readable JSON
1413        #[arg(long, default_value_t = false)]
1414        json: bool,
1415    },
1416    /// Show exactly what `connect`/`update` would write, without writing it
1417    Plan {
1418        #[arg(long, default_value_t = false)]
1419        json: bool,
1420        /// DSH profile the overlay targets (`web` or `headless`)
1421        #[arg(long, default_value = "web")]
1422        profile: String,
1423        /// Mirror Codewhale full access as DSH danger-full-access (only when Codewhale itself runs with full access)
1424        #[arg(long, default_value_t = false)]
1425        allow_full_access: bool,
1426        /// Record the Codewhale palette (skin) decision for the bundle profile; applied via DSH's `overrideTokens`, never through the overlay
1427        #[arg(long, default_value_t = false)]
1428        skin: bool,
1429    },
1430    /// Write the overlay and receipt under $CODEWHALE_HOME/integrations/dsh
1431    Connect {
1432        #[arg(long, default_value = "web")]
1433        profile: String,
1434        #[arg(long, default_value_t = false)]
1435        allow_full_access: bool,
1436        #[arg(long, default_value_t = false)]
1437        skin: bool,
1438        /// Confirm the disclosed plan without an interactive prompt (required when stdin is not a terminal)
1439        #[arg(long, default_value_t = false)]
1440        yes: bool,
1441    },
1442    /// Re-derive the overlay from the current Codewhale route
1443    Update {
1444        #[arg(long)]
1445        profile: Option<String>,
1446        #[arg(long, default_value_t = false)]
1447        allow_full_access: bool,
1448        /// Turn the bundle-profile skin on/off (`--skin false`; defaults to the previous choice)
1449        #[arg(long)]
1450        skin: Option<bool>,
1451        /// Turn the ambient ocean scene behind the DSH web UI on/off (`--ocean false`; defaults to the previous choice, initially on; needs the skin)
1452        #[arg(long)]
1453        ocean: Option<bool>,
1454        #[arg(long, default_value_t = false)]
1455        yes: bool,
1456    },
1457    /// Run dsh with the Codewhale overlay; extra args go to the dsh app
1458    Launch {
1459        /// Override the recorded profile (`web` or `headless`)
1460        #[arg(long)]
1461        profile: Option<String>,
1462        /// Print the exact command instead of running it
1463        #[arg(long, default_value_t = false)]
1464        dry_run: bool,
1465        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1466        args: Vec<String>,
1467    },
1468    /// Keep the overlay but refuse launches
1469    Disable,
1470    /// Allow launches again
1471    Enable,
1472    /// Delete Codewhale-owned files only; $DSH_HOME is never touched
1473    Remove {
1474        #[arg(long, default_value_t = false)]
1475        yes: bool,
1476    },
1477    /// Documented DSH plugin path: install the Codewhale bundle into a dedicated `codewhale` DSH profile via `dsh plugin add` (pnpm required)
1478    InstallBundle {
1479        /// Which shipped DSH app the dedicated profile boots (`web` or `headless`)
1480        #[arg(long, default_value = "web")]
1481        app: String,
1482        #[arg(long, default_value_t = false)]
1483        yes: bool,
1484    },
1485    /// `dsh plugin --profile codewhale remove codewhale-dsh-bundle`, then delete only Codewhale-owned bundle files
1486    RemoveBundle {
1487        #[arg(long, default_value_t = false)]
1488        yes: bool,
1489    },
1490}
1491
1492#[derive(Args, Debug, Clone)]
1493struct FeaturesCli {
1494    #[command(subcommand)]
1495    command: FeaturesSubcommand,
1496}
1497
1498#[derive(Subcommand, Debug, Clone)]
1499enum FeaturesSubcommand {
1500    /// List known feature flags and their state
1501    List,
1502}
1503
1504#[derive(Args, Debug, Clone)]
1505struct SandboxArgs {
1506    #[command(subcommand)]
1507    command: SandboxCommand,
1508}
1509
1510#[derive(Subcommand, Debug, Clone)]
1511enum SandboxCommand {
1512    /// Run a command with sandboxing
1513    Run {
1514        /// Sandbox policy (danger-full-access, read-only, external-sandbox, workspace-write)
1515        #[arg(long, default_value = "workspace-write")]
1516        policy: String,
1517        /// Allow outbound network access
1518        #[arg(long)]
1519        network: bool,
1520        /// Additional writable roots (repeatable)
1521        #[arg(long, value_name = "PATH")]
1522        writable_root: Vec<PathBuf>,
1523        /// Exclude TMPDIR from writable paths
1524        #[arg(long)]
1525        exclude_tmpdir: bool,
1526        /// Exclude /tmp from writable paths
1527        #[arg(long)]
1528        exclude_slash_tmp: bool,
1529        /// Command working directory
1530        #[arg(long)]
1531        cwd: Option<PathBuf>,
1532        /// Timeout in milliseconds
1533        #[arg(long, default_value_t = 60_000)]
1534        timeout_ms: u64,
1535        /// Command and arguments to run
1536        #[arg(required = true, trailing_var_arg = true)]
1537        command: Vec<String>,
1538    },
1539}
1540
1541const CODEWHALE_MAIN_STACK_BYTES: usize = 16 * 1024 * 1024;
1542
1543/// Entry point for the single binary. Takes argv including binary name at 0,
1544/// parses with clap, and runs the TUI/runtime dispatch. Returns process exit
1545/// code for the caller to exit with.
1546pub fn run(args: Vec<String>) -> std::process::ExitCode {
1547    match run_with_args(args) {
1548        Ok(()) => std::process::ExitCode::SUCCESS,
1549        Err(err) => {
1550            eprintln!("error: {err}");
1551            for cause in err.chain().skip(1) {
1552                eprintln!("  caused by: {cause}");
1553            }
1554            std::process::ExitCode::FAILURE
1555        }
1556    }
1557}
1558
1559/// Internal implementation that mirrors the old `main()` but takes explicit
1560/// args instead of reading `std::env::args()`. Used by `run()` and tested
1561/// directly.
1562fn run_with_args(args: Vec<String>) -> Result<()> {
1563    // Match the dispatcher entrypoint: Unix shells and supervisors may inherit
1564    // SIGPIPE ignored, which turns short pipelines such as `codewhale doctor |
1565    // head` into BrokenPipe panics once this delegated TUI binary prints.
1566    #[cfg(unix)]
1567    unsafe {
1568        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1569    }
1570
1571    startup_trace::mark_process_start();
1572    configure_windows_console_utf8();
1573    install_rustls_crypto_provider();
1574
1575    // ── Process hardening (#2183) ─────────────────────────────────────────
1576    // MUST run before Tokio is booted and before any threads are spawned.
1577    // See crates/tui/src/sandbox/process_hardening.rs for ordering rationale.
1578    crate::sandbox::process_hardening::apply_process_hardening();
1579
1580    // ── Fatal-signal terminal guard (#5424) ───────────────────────────────
1581    // Abort-class deaths (stack overflow, allocation failure, double panic)
1582    // skip the panic hook AND every Drop guard, leaving mouse capture and
1583    // the kitty keyboard stack leaking into the user's shell. A classic
1584    // sigaction handler restores the terminal and stamps a marker before
1585    // re-raising. Also before any threads exist.
1586    crate::tui::ui::fatal_signal_guard::install_fatal_signal_guard();
1587
1588    // Set up process panic hook before anything else — writes crash dumps
1589    // to ~/.deepseek/crashes/ even if the panic happens before tokio is up,
1590    // and restores the terminal so a panicked TUI doesn't leave the user's
1591    // shell stuck in alt-screen mode.
1592    let orig_hook = std::panic::take_hook();
1593    std::panic::set_hook(Box::new(move |panic_info| {
1594        // Restore the terminal first so the panic message itself, plus the
1595        // user's shell after exit, are visible. Best-effort — we may not be
1596        // in raw / alt-screen mode if the panic happens pre-TUI. Shared
1597        // with the signal handler installed below so both exit paths leave
1598        // the terminal in the same well-defined state.
1599        crate::tui::ui::emergency_restore_terminal();
1600
1601        let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1602            s.to_string()
1603        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1604            s.clone()
1605        } else {
1606            format!("{:?}", panic_info.payload())
1607        };
1608        let location = panic_info
1609            .location()
1610            .map(|loc| loc.to_string())
1611            .unwrap_or_else(|| "unknown".to_string());
1612        tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
1613
1614        // Telemetry, if and only if this process was armed. This hook is
1615        // installed before `Cli::parse()` and long before any config is
1616        // resolved, so it cannot consult a resolved value — but it can consult
1617        // a `OnceLock` that is by construction empty until resolution
1618        // completes. A user who never opted in panics without writing a byte
1619        // and without creating a directory.
1620        //
1621        // The site is allowlist-reduced and `msg` is deliberately not read: a
1622        // slicing panic embeds the entire string being sliced, and this tree
1623        // slices user and model text in dozens of places.
1624        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
1625        if let Some(site) = panic_info
1626            .location()
1627            .map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
1628        {
1629            codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
1630        }
1631        // Write crash dump best-effort
1632        if let Some(home) = crate::config::effective_home_dir() {
1633            let crash_dir = home.join(".deepseek").join("crashes");
1634            let _ = std::fs::create_dir_all(&crash_dir);
1635            use chrono::Utc;
1636            let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1637            let path = crash_dir.join(format!("{ts}-process-panic.log"));
1638            let contents =
1639                format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
1640            let _ = std::fs::write(&path, contents);
1641        }
1642        // Invoke the original hook (prints to stderr, etc.)
1643        orig_hook(panic_info);
1644    }));
1645
1646    // Parse and freeze every startup authority before Tokio or any other
1647    // worker thread exists. A workspace `.env` is intentionally a narrow
1648    // credential convenience surface: it must never redirect product state,
1649    // configuration, MCP, trust, sandbox, executable lookup, or plugin
1650    // discovery. Plugin discovery therefore runs first, and the loader below
1651    // admits only built-in provider credential names from a stable file read.
1652    let cli = match Cli::try_parse_from(args) {
1653        Ok(c) => c,
1654        Err(e) => {
1655            e.exit();
1656        }
1657    };
1658    // #5098: project-scope fleet agent profiles (`.codewhale/agents/*.toml`)
1659    // join the dispatch roster under the same trust decision as the rest of
1660    // project-level config — `--no-project-config` opts the layer out for
1661    // every roster read in this process.
1662    crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
1663    let workspace = resolve_workspace(&cli);
1664    let mut plugin_discovery = None;
1665    let mut plugin_registry = None;
1666    let (cli, command) = prepare_cli_startup(
1667        cli,
1668        || {
1669            let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
1670            plugin_registry = Some(discovery.registry_for_workspace(&workspace));
1671            plugin_discovery = Some(discovery);
1672        },
1673        warn_on_workspace_dotenv_result,
1674    );
1675    let plugin_discovery = plugin_discovery
1676        .expect("plugin discovery initialization must precede workspace dotenv loading");
1677    let plugin_registry = plugin_registry
1678        .expect("plugin discovery initialization must precede workspace dotenv loading");
1679
1680    // The interactive runtime intentionally carries a large state machine:
1681    // terminal rendering, modal dispatch, provider setup, and fleet/workflow
1682    // events all share one async owner. Debug builds retain enough stack
1683    // temporaries that nesting a modal event over the TUI loop can exceed the
1684    // platform main-thread default (8 MiB on macOS). Give that owner an
1685    // explicit stack while keeping process hardening and the global panic hook
1686    // above this boundary, before Tokio or any worker thread exists.
1687    let runtime_thread = std::thread::Builder::new()
1688        .name("codewhale-main".to_string())
1689        .stack_size(CODEWHALE_MAIN_STACK_BYTES)
1690        .spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
1691        .context("Failed to start the Codewhale runtime thread")?;
1692    match runtime_thread.join() {
1693        Ok(result) => result,
1694        Err(payload) => {
1695            let message = payload
1696                .downcast_ref::<&str>()
1697                .map(|value| (*value).to_string())
1698                .or_else(|| payload.downcast_ref::<String>().cloned())
1699                .unwrap_or_else(|| "unknown panic payload".to_string());
1700            Err(anyhow!("Codewhale runtime thread panicked: {message}"))
1701        }
1702    }
1703}
1704
1705fn run_async_main(
1706    cli: Cli,
1707    command: Option<Commands>,
1708    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1709    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1710) -> Result<()> {
1711    build_runtime()?.block_on(run_async_main_inner(
1712        cli,
1713        command,
1714        plugin_discovery,
1715        plugin_registry,
1716    ))
1717}
1718
1719/// Build the runtime that owns every async task in this binary.
1720///
1721/// `#[tokio::main]` used to expand here, which left every worker thread on
1722/// tokio's 2 MiB default while only the `codewhale-main` owner thread above
1723/// received `CODEWHALE_MAIN_STACK_BYTES`. The engine does not run on that owner
1724/// thread — `core::engine::spawn_engine` hands `Engine::run` to
1725/// `utils::spawn_supervised`, a bare `tokio::spawn` — so the explicit stack
1726/// never applied where the depth actually is.
1727///
1728/// A debug-build `agent` dispatch (turn_loop -> FuturesUnordered ->
1729/// execute_full_with_context -> AgentTool::execute -> spawn_subagent_from_input)
1730/// measured a stack high-water mark between 2.25 and 2.5 MiB and aborted the
1731/// whole process on the guard page. A Rust stack overflow is not a panic: it
1732/// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the
1733/// process dies with 134 mid-dispatch.
1734///
1735/// This is behavior-identical to the old `#[tokio::main]` expansion apart from
1736/// the stack size, and it makes the knob greppable.
1737pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
1738    tokio::runtime::Builder::new_multi_thread()
1739        .enable_all()
1740        .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
1741        .build()
1742        .context("Failed to build the Codewhale Tokio runtime")
1743}
1744
1745/// Which product surface this process is serving.
1746///
1747/// A function of the parsed subcommand, never of the executable: this one
1748/// binary serves at least five surfaces, so `current_exe()` would label all of
1749/// them the same.
1750fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
1751    use codewhale_telemetry::Surface;
1752    match command {
1753        None | Some(Commands::Resume { .. } | Commands::Fork { .. } | Commands::Pr { .. }) => {
1754            Surface::Tui
1755        }
1756        Some(Commands::Exec(_)) => Surface::Exec,
1757        Some(Commands::Serve(args)) => {
1758            if args.mcp {
1759                Surface::McpServer
1760            } else {
1761                Surface::Serve
1762            }
1763        }
1764        Some(_) => Surface::Cli,
1765    }
1766}
1767
1768/// How this session was started, for `session_start`.
1769fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
1770    use codewhale_telemetry::SessionSource;
1771    match command {
1772        None | Some(Commands::Pr { .. }) => SessionSource::Interactive,
1773        Some(Commands::Resume { .. }) => SessionSource::Resume,
1774        Some(Commands::Fork { .. }) => SessionSource::Fork,
1775        Some(Commands::Serve(_)) => SessionSource::Api,
1776        Some(_) => SessionSource::Unknown,
1777    }
1778}
1779
1780/// Read-only commands must not create telemetry state as a side effect.
1781fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
1782    matches!(
1783        command,
1784        Some(Commands::Doctor(_) | Commands::SessionDiagnostics(_) | Commands::Sessions { .. })
1785    ) || matches!(command, Some(Commands::Setup(args)) if args.status)
1786}
1787
1788/// Resolve the emit predicate and arm, once, before anything can record.
1789///
1790/// This is the read that v1 of the design was missing entirely:
1791/// `resolve_runtime_options` had no non-test caller in this crate, so neither
1792/// `telemetry = false` in the config file nor `CODEWHALE_TELEMETRY=0` was ever
1793/// consulted by a process that would have emitted.
1794///
1795/// `CliRuntimeOverrides::default()` is correct here. The dispatcher has already
1796/// applied the kill-switch floor and forwarded the *resolved* value through
1797/// `CODEWHALE_TELEMETRY`, which `EnvRuntimeOverrides::load()` picks up — and
1798/// re-reading `CODEWHALE_TELEMETRY` inside the telemetry crate would fork
1799/// `parse_bool`, the `DEEPSEEK_TELEMETRY` alias, and the floor into a second
1800/// source of truth.
1801fn arm_telemetry_with_setup(
1802    config_path: Option<PathBuf>,
1803    surface: codewhale_telemetry::Surface,
1804    source: codewhale_telemetry::SessionSource,
1805    setup_override: Option<&codewhale_config::SetupState>,
1806) {
1807    let Ok(store) = codewhale_config::ConfigStore::load(config_path) else {
1808        return;
1809    };
1810    let resolved = store
1811        .config
1812        .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1813    let setup = if let Some(setup) = setup_override {
1814        setup.clone()
1815    } else {
1816        let Some(setup) = codewhale_telemetry::load_setup_state_for_decision() else {
1817            // An existing unreadable privacy record may contain a decline.
1818            // Failing closed is safer than replacing it with default-on.
1819            return;
1820        };
1821        setup
1822    };
1823    let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
1824        codewhale_telemetry::decide(&resolved, &setup, surface)
1825    else {
1826        return;
1827    };
1828    codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
1829    let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
1830    codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
1831}
1832
1833fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
1834    if telemetry_command_is_read_only(command) {
1835        return;
1836    }
1837    arm_telemetry_with_setup(
1838        cli.config.clone(),
1839        telemetry_surface(command),
1840        telemetry_session_source(command),
1841        None,
1842    );
1843}
1844
1845/// Apply the choice made in the native TUI disclosure.
1846///
1847/// The in-memory setup state is authoritative for this process. In particular,
1848/// a Disable choice reaches `decide` as an opt-out even when neither durable
1849/// write landed, so the current launch cannot arm and any existing buffer is
1850/// wiped whenever the telemetry home remains reachable.
1851pub(crate) fn apply_tui_telemetry_decision(
1852    pending: &crate::telemetry_notice::PendingTelemetryNotice,
1853    setup: &codewhale_config::SetupState,
1854) {
1855    arm_telemetry_with_setup(
1856        pending.config_path.clone(),
1857        codewhale_telemetry::Surface::Tui,
1858        pending.session_source,
1859        Some(setup),
1860    );
1861}
1862
1863/// Close the armed session and flush, bounded.
1864///
1865/// Short CLI (`config`, `doctor`, `auth`, …) records `session_end`, seals the
1866/// local queue within a much smaller deadline, and returns. The 3s network
1867/// flush is a TUI/exec concern: a hung TLS handshake must not hold
1868/// `codewhale config list`. A configured endpoint remains buffered for the
1869/// next interactive session; an explicitly empty endpoint writes its local
1870/// dry-run batch immediately.
1871async fn finish_telemetry(outcome: &Result<()>, surface: codewhale_telemetry::Surface) {
1872    if !codewhale_telemetry::is_armed() {
1873        return;
1874    }
1875    // Only escalate: the panic hook and the signal path have already spoken if
1876    // they ran, and a stated class must not be overwritten by an inferred one.
1877    if outcome.is_err()
1878        && codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
1879    {
1880        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
1881    }
1882    codewhale_telemetry::record(telemetry_session_end());
1883    if surface == codewhale_telemetry::Surface::Cli {
1884        let _ =
1885            codewhale_telemetry::persist_local_blocking(codewhale_telemetry::CLI_PERSIST_TIMEOUT);
1886        return;
1887    }
1888    // `shutdown_blocking` parks a thread waiting on the writer, so it goes to
1889    // the blocking pool, and it is bounded there. The persistence actor's
1890    // unbounded `let _ = task.await` next door is not a pattern to copy here: a
1891    // hung TLS handshake would hold the process open past the last frame.
1892    let _ = tokio::time::timeout(
1893        codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
1894        tokio::task::spawn_blocking(|| {
1895            codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
1896        }),
1897    )
1898    .await;
1899}
1900
1901async fn run_async_main_inner(
1902    cli: Cli,
1903    command: Option<Commands>,
1904    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1905    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1906) -> Result<()> {
1907    // Install signal handlers that restore the terminal before the process
1908    // exits. Without this, Ctrl+C delivered while raw mode / kitty keyboard
1909    // enhancement / alt-screen are active (or in the brief windows around
1910    // startup and teardown where they're being toggled) leaves the user's shell
1911    // receiving raw CSI sequences like `^[[>5u` until they run `reset` (#1583).
1912    //
1913    // Once the TUI's raw mode is engaged the terminal driver delivers Ctrl+C as
1914    // the byte 0x03 rather than SIGINT, so the in-TUI key handler — not this
1915    // handler — is what processes user interrupts during normal operation. This
1916    // handler exists for the gaps: pre-TUI subcommands (--version, doctor,
1917    // login, …), the moments around enable_raw_mode / disable_raw_mode, the
1918    // external-editor suspend path, and SIGTERM / SIGHUP from the OS.
1919    //
1920    // It goes up before arming and before the notice: arming is the first
1921    // externally observable thing this process does (it creates the telemetry
1922    // buffer), and the notice is the first thing that can sit waiting on a
1923    // human. A Ctrl-C in either window must still restore the terminal and exit
1924    // 130 rather than kill the process outright. Recording a `session_end` from
1925    // the signal path is a no-op until `arm_telemetry` runs, so installing
1926    // ahead of it collects nothing.
1927    spawn_signal_cleanup_task();
1928
1929    // A due interactive disclosure belongs to the first native TUI frame. In
1930    // that one case arming is deferred until its decision event; every other
1931    // surface keeps the ordinary pre-dispatch predicate. This is what lets an
1932    // immediate Disable choice stop this very session without printing or
1933    // blocking on a shell questionnaire first.
1934    let surface = telemetry_surface(command.as_ref());
1935    let telemetry_notice_plan = if surface == codewhale_telemetry::Surface::Tui {
1936        crate::telemetry_notice::plan_if_due(
1937            cli.config.clone(),
1938            telemetry_session_source(command.as_ref()),
1939        )
1940    } else {
1941        crate::telemetry_notice::TelemetryNoticePlan::NotDue
1942    };
1943    let should_arm_before_dispatch = surface != codewhale_telemetry::Surface::Tui
1944        || telemetry_notice_plan.should_arm_before_tui();
1945    let pending_telemetry_notice = telemetry_notice_plan.into_pending();
1946    if should_arm_before_dispatch {
1947        arm_telemetry(&cli, command.as_ref());
1948    }
1949    let outcome = run_async_main_dispatch(
1950        cli,
1951        command,
1952        plugin_discovery,
1953        plugin_registry,
1954        pending_telemetry_notice,
1955    )
1956    .await;
1957    finish_telemetry(&outcome, surface).await;
1958    outcome
1959}
1960
1961async fn run_async_main_dispatch(
1962    cli: Cli,
1963    command: Option<Commands>,
1964    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1965    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1966    mut pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
1967) -> Result<()> {
1968    logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
1969
1970    // Install any user prompt overrides from the config directory before an
1971    // engine can compose a system prompt. The override cells are
1972    // first-call-wins; doing this once here keeps every downstream turn
1973    // consistent. Missing files are a no-op (bundled defaults). See #3638.
1974    crate::prompts::load_prompt_overrides_from_config_home();
1975
1976    // Plugins own one read-only discovery snapshot per process. Initialize it
1977    // before the subcommand match so plain launch, resume, fork, exec, serve,
1978    // and every other runtime surface use the same plugin trust decision
1979    // (#3916, #4399). Discovery never enables, trusts, executes, or persists a
1980    // bundle.
1981
1982    // Handle subcommands first
1983    if let Some(command) = command {
1984        return match command {
1985            Commands::Doctor(args) => {
1986                let config = match load_doctor_config_from_cli(&cli, &args) {
1987                    Ok(config) => config,
1988                    Err(error) if args.json => return run_doctor_json_config_error(&error),
1989                    Err(_) => {
1990                        bail!(
1991                            "doctor configuration validation failed; details omitted because configuration errors may contain credential material"
1992                        )
1993                    }
1994                };
1995                let workspace = resolve_workspace(&cli);
1996                if args.context_json {
1997                    run_doctor_context_json(&config, &workspace)
1998                } else if args.json {
1999                    run_doctor_json(
2000                        &config,
2001                        &workspace,
2002                        cli.config.as_deref(),
2003                        plugin_registry.as_ref(),
2004                    )
2005                } else {
2006                    let probes = crate::doctor::DoctorProbeRequest {
2007                        check_updates: args.check_updates,
2008                        probe_api: args.probe_api,
2009                        probe_local: args.probe_local,
2010                        probe_mcp: args.probe_mcp,
2011                        probe_search: args.probe_search,
2012                    };
2013                    run_doctor(
2014                        &config,
2015                        &workspace,
2016                        cli.config.as_deref(),
2017                        probes,
2018                        plugin_registry.as_ref(),
2019                    )
2020                    .await;
2021                    Ok(())
2022                }
2023            }
2024            Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
2025            Commands::Setup(args) => {
2026                let config = load_config_from_cli(&cli)?;
2027                let workspace = resolve_workspace(&cli);
2028                run_setup(&config, &workspace, args, plugin_registry.as_ref())
2029            }
2030            Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args),
2031            Commands::Completions { shell } => {
2032                generate_completions(shell);
2033                Ok(())
2034            }
2035            Commands::Sessions { limit, search } => list_sessions(limit, search),
2036            Commands::Init => init_project(),
2037            Commands::Login { api_key } => run_login(api_key),
2038            Commands::Logout => run_logout(),
2039            Commands::Auth(args) => match args.command {
2040                TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
2041            },
2042            Commands::Models(args) => {
2043                let config = load_config_from_cli(&cli)?;
2044                run_models(&config, args).await
2045            }
2046            Commands::Speech(args) => {
2047                let config = load_config_from_cli(&cli)?;
2048                run_speech(&config, args).await
2049            }
2050            Commands::Exec(args) => {
2051                let config = load_config_from_cli(&cli)?;
2052                let workspace = cli.workspace.clone().unwrap_or_else(|| {
2053                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2054                });
2055                let mut config = config.clone();
2056                // #4641: `--no-project-config` skips the workspace-specific
2057                // `[workspace]`/`[projects]` user-config overlay so a headless
2058                // launch (e.g. a future Verifiers harness) sees a reproducible
2059                // config surface that depends only on the explicit `--config`.
2060                if !cli.no_project_config {
2061                    merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
2062                }
2063                if let Some(sandbox) = args.sandbox.as_deref() {
2064                    let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
2065                    config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
2066                }
2067                // Honour CODEWHALE_BASE_URL / DEEPSEEK_BASE_URL forwarded by
2068                // the CLI dispatcher from --base-url.
2069                if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
2070                    .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
2071                {
2072                    let trimmed = env_url.trim();
2073                    if !trimmed.is_empty() {
2074                        config.base_url = Some(trimmed.to_string());
2075                    }
2076                }
2077                // Honour `--provider` (#4093): a Fleet worker whose profile pins
2078                // a provider launches on that provider even when the parent
2079                // session is on another one. This sets ONLY the non-secret
2080                // provider identity (`config.provider`); credentials/base URL
2081                // still resolve from the worker's own env/config, and for a
2082                // non-DeepSeek provider the legacy root `base_url` above is
2083                // ignored by `deepseek_base_url()`. Must precede model
2084                // resolution so an `auto`/default model resolves to the
2085                // overridden provider's default.
2086                let explicit_provider = args
2087                    .provider
2088                    .as_deref()
2089                    .map(str::trim)
2090                    .filter(|provider| !provider.is_empty());
2091                if let Some(provider_arg) = explicit_provider {
2092                    apply_exec_provider_override(&mut config, provider_arg)?;
2093                }
2094                if let Some(reasoning_arg) = args
2095                    .reasoning_effort
2096                    .as_deref()
2097                    .map(str::trim)
2098                    .filter(|value| !value.is_empty())
2099                {
2100                    config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
2101                    config.reasoning_effort_inferred_from_legacy_alias = false;
2102                }
2103                let prompt = join_prompt_parts(&args.prompt);
2104                let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
2105                validate_exec_tool_authority_resume(
2106                    args.tool_authority_json.as_deref(),
2107                    resume_session_id.is_some(),
2108                )?;
2109                let resume_session = resume_session_id
2110                    .as_deref()
2111                    .map(load_exec_resume_session)
2112                    .transpose()?;
2113                let explicit_model = args
2114                    .model
2115                    .as_deref()
2116                    .map(str::trim)
2117                    .filter(|model| !model.is_empty());
2118                let model = if let Some(saved) = resume_session.as_ref() {
2119                    resolve_exec_resume_route(
2120                        &mut config,
2121                        saved,
2122                        explicit_provider.is_some(),
2123                        explicit_model,
2124                    )?
2125                } else {
2126                    resolve_exec_model(&config, explicit_model)
2127                };
2128                let force_configured_route = should_force_configured_exec_route(
2129                    resume_session.is_some(),
2130                    explicit_provider,
2131                    explicit_model,
2132                );
2133                // The `deepseek` launcher forwards `--yolo` to this binary via
2134                // the DEEPSEEK_YOLO env var (which the config loader folds into
2135                // `config.yolo`), not as a CLI flag. Honour either source.
2136                let yolo = cli.yolo || config.yolo.unwrap_or(false);
2137                let env_tool_surface = exec_tool_surface_from_env();
2138                let needs_engine = args.auto
2139                    || yolo
2140                    || resume_session_id.is_some()
2141                    || args.output_format == ExecOutputFormat::StreamJson
2142                    || args.max_turns.is_some()
2143                    || args.allowed_tools.is_some()
2144                    || args.disallowed_tools.is_some()
2145                    || args.append_system_prompt.is_some()
2146                    || args.tool_authority_json.is_some()
2147                    || args.sandbox.is_some()
2148                    || args.allow_sandbox_elevation
2149                    || env_tool_surface.is_some();
2150                if needs_engine {
2151                    let provider = config.api_provider();
2152                    let max_subagents = cli.max_subagents.map_or_else(
2153                        || config.max_subagents_for_provider(provider),
2154                        |value| value.clamp(1, MAX_SUBAGENTS),
2155                    );
2156                    let auto_mode = args.auto || yolo;
2157                    let max_turns = exec_max_steps(args.max_turns);
2158                    let allowed_tools =
2159                        resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface);
2160                    let disallowed_tools = args
2161                        .disallowed_tools
2162                        .as_deref()
2163                        .map(normalize_exec_tool_names);
2164                    run_exec_agent(
2165                        &config,
2166                        &model,
2167                        &prompt,
2168                        workspace,
2169                        max_subagents,
2170                        auto_mode,
2171                        args.allow_sandbox_elevation,
2172                        args.sandbox.as_deref(),
2173                        auto_mode,
2174                        args.json,
2175                        resume_session,
2176                        force_configured_route,
2177                        args.output_format,
2178                        max_turns,
2179                        allowed_tools,
2180                        disallowed_tools,
2181                        args.append_system_prompt.clone(),
2182                        args.tool_authority_json.clone(),
2183                        std::sync::Arc::clone(&plugin_registry),
2184                    )
2185                    .await
2186                } else if args.json {
2187                    run_one_shot_json(&config, &model, &prompt, force_configured_route).await
2188                } else {
2189                    run_one_shot(&config, &model, &prompt, force_configured_route).await
2190                }
2191            }
2192            Commands::Fleet(args) => {
2193                let config = load_config_from_cli(&cli)?;
2194                let workspace = resolve_workspace(&cli);
2195                run_fleet_command(&workspace, &config, args).await
2196            }
2197            Commands::WorkflowTool(args) => {
2198                run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
2199            }
2200            Commands::Review(args) => {
2201                let config = load_config_from_cli(&cli)?;
2202                run_review(&config, args).await
2203            }
2204            Commands::Pr {
2205                number,
2206                repo,
2207                checkout,
2208            } => {
2209                let config = load_config_from_cli(&cli)?;
2210                run_pr(
2211                    &cli,
2212                    &config,
2213                    number,
2214                    repo.as_deref(),
2215                    checkout,
2216                    pending_telemetry_notice.take(),
2217                    Arc::clone(&plugin_registry),
2218                )
2219                .await
2220            }
2221            Commands::Apply(args) => run_apply(args),
2222            Commands::Eval(args) => run_eval(args),
2223            Commands::Scorecard(args) => run_scorecard(args),
2224            Commands::Mcp { command } => {
2225                let config = load_config_from_cli(&cli)?;
2226                let workspace = resolve_workspace(&cli);
2227                run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
2228            }
2229            Commands::Features(command) => {
2230                let config = load_config_from_cli(&cli)?;
2231                run_features_command(&config, command)
2232            }
2233            Commands::Integrations { command } => {
2234                // Identity derivation is structural: credential-bearing
2235                // environment values never enter this path.
2236                let config = load_structural_config_from_cli(&cli)?;
2237                let workspace = resolve_workspace(&cli);
2238                integrations::cli::run(&config, &workspace, command)
2239            }
2240            Commands::Sandbox(args) => run_sandbox_command(args),
2241            Commands::Serve(args) => {
2242                let workspace = cli.workspace.clone().unwrap_or_else(|| {
2243                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2244                });
2245                let http_selected = validate_serve_mode_selection(
2246                    args.mcp,
2247                    args.http,
2248                    args.mobile,
2249                    args.web,
2250                    args.acp,
2251                )?;
2252                if args.mcp {
2253                    tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
2254                } else if http_selected {
2255                    let (config, config_profile) =
2256                        load_config_from_cli_with_effective_profile(&cli)?;
2257                    let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
2258                    let bind_host = resolve_serve_bind_host(args.mobile, args.host);
2259                    if args.web && bind_host.host != "127.0.0.1" {
2260                        bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2261                    }
2262                    if bind_host.mobile_rebound_to_lan {
2263                        println!(
2264                            "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."
2265                        );
2266                    }
2267                    runtime_api::run_http_server(
2268                        config,
2269                        workspace,
2270                        std::sync::Arc::clone(&plugin_discovery),
2271                        runtime_api::RuntimeApiOptions {
2272                            host: bind_host.host,
2273                            port: args.port,
2274                            workers: args.workers.clamp(1, 8),
2275                            cors_origins,
2276                            auth_token: args.auth_token,
2277                            insecure_no_auth: args.insecure_no_auth,
2278                            mobile: args.mobile,
2279                            web: args.web,
2280                            show_qr: args.qr,
2281                            config_path: cli.config.clone(),
2282                            config_profile,
2283                        },
2284                    )
2285                    .await
2286                } else if args.acp {
2287                    let config = load_config_from_cli(&cli)?;
2288                    let model = config.default_model();
2289                    acp_server::run_acp_server(config, model, workspace).await
2290                } else {
2291                    unreachable!("server mode count checked above")
2292                }
2293            }
2294            Commands::Resume { session_id, last } => {
2295                let config = load_config_from_cli(&cli)?;
2296                let workspace = resolve_workspace(&cli);
2297                let resume_id = resolve_session_id(session_id, last, &workspace)?;
2298                run_interactive(
2299                    &cli,
2300                    &config,
2301                    Some(resume_id),
2302                    None,
2303                    pending_telemetry_notice.take(),
2304                    std::sync::Arc::clone(&plugin_registry),
2305                )
2306                .await
2307            }
2308            Commands::Fork { session_id, last } => {
2309                let config = load_config_from_cli(&cli)?;
2310                let workspace = resolve_workspace(&cli);
2311                let new_session_id = fork_session(&config, session_id, last, &workspace)?;
2312                run_interactive(
2313                    &cli,
2314                    &config,
2315                    Some(new_session_id),
2316                    None,
2317                    pending_telemetry_notice.take(),
2318                    std::sync::Arc::clone(&plugin_registry),
2319                )
2320                .await
2321            }
2322        };
2323    }
2324
2325    // Top-level prompt mode: submit the initial prompt, then keep the TUI alive
2326    // for follow-up messages. Use `codewhale exec` for explicit non-interactive
2327    // one-shot behavior (#2370).
2328    let config = load_config_from_cli(&cli)?;
2329    if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
2330        return run_interactive(
2331            &cli,
2332            &config,
2333            None,
2334            Some(initial_input),
2335            pending_telemetry_notice.take(),
2336            std::sync::Arc::clone(&plugin_registry),
2337        )
2338        .await;
2339    }
2340
2341    // Handle session resume. Plain `codewhale` starts fresh: interrupted
2342    // snapshots are preserved for explicit resume, but never auto-attached.
2343    let mut startup_notice = None;
2344    let resume_session_id = if cli.continue_session {
2345        let workspace = resolve_workspace(&cli);
2346        recover_interrupted_checkpoint_for_resume(&workspace)
2347            .or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
2348    } else if let Some(id) = cli.resume.clone() {
2349        Some(id)
2350    } else if !cli.fresh {
2351        let workspace = resolve_workspace(&cli);
2352        preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
2353        // Opt-in auto-resume (#2934). Off by default, so the historical
2354        // "plain `codewhale` starts fresh" behaviour is unchanged unless the
2355        // user asked for something else. The decision never resumes an
2356        // archived, unreadable, or foreign-workspace session; every fallback
2357        // carries a receipt rather than silently starting blank.
2358        let (session_id, notice) = resolve_auto_resume(&workspace);
2359        startup_notice = notice;
2360        session_id
2361    } else {
2362        None
2363    };
2364
2365    // Default: Interactive TUI
2366    // --yolo starts in YOLO mode (auto-approve; shell enabled)
2367    run_interactive_with_notice(
2368        &cli,
2369        &config,
2370        resume_session_id,
2371        None,
2372        startup_notice,
2373        pending_telemetry_notice.take(),
2374        plugin_registry,
2375    )
2376    .await
2377}
2378
2379/// Resolve the opt-in auto-resume setting into a session id plus a receipt.
2380///
2381/// Deliberately scoped to the plain interactive launch. `codewhale "do X"`
2382/// (top-level prompt) and `codewhale exec` are not covered: silently prefixing
2383/// a one-shot task with a prior conversation would change what is sent to the
2384/// model, which is not a layout preference the user opted into.
2385fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
2386    use crate::session_resume::{ResumeRequest, decide_auto_resume};
2387
2388    let enabled = crate::settings::Settings::load_persisted()
2389        .map(|settings| settings.session_auto_resume)
2390        .unwrap_or(false);
2391    if !enabled {
2392        return (None, None);
2393    }
2394    let Ok(manager) = SessionManager::default_location() else {
2395        return (None, None);
2396    };
2397    let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
2398    (
2399        decision.session_id().map(str::to_string),
2400        decision.status_message(),
2401    )
2402}
2403
2404fn prepare_cli_startup(
2405    cli: Cli,
2406    initialize_plugins: impl FnOnce(),
2407    load_dotenv: impl FnOnce(),
2408) -> (Cli, Option<Commands>) {
2409    initialize_plugins();
2410    let command = cli.command.clone();
2411    let should_load_dotenv = match command.as_ref() {
2412        Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
2413        _ => true,
2414    };
2415    if should_load_dotenv {
2416        load_dotenv();
2417    }
2418    (cli, command)
2419}
2420
2421const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
2422
2423#[derive(Debug, Default)]
2424struct WorkspaceDotenvReport {
2425    path: PathBuf,
2426    loaded: BTreeSet<String>,
2427    ignored: BTreeSet<String>,
2428}
2429
2430/// Load the narrow, data-plane subset of a workspace `.env` before Tokio.
2431///
2432/// Repository content is not product authority. In particular, a committed
2433/// `.env` must not be able to redirect `CODEWHALE_HOME`, config/profile files,
2434/// MCP servers, plugin trust, executable lookup, sandbox/approval posture, or
2435/// network destinations. Shell-exported values and config/CLI arguments remain
2436/// the explicit surfaces for those controls.
2437fn warn_on_workspace_dotenv_result() {
2438    match load_workspace_dotenv_credentials() {
2439        Ok(Some(report)) if !report.ignored.is_empty() => {
2440            eprintln!(
2441                "Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
2442                report.path.display(),
2443                display_env_key_set(&report.ignored)
2444            );
2445        }
2446        Ok(_) => {}
2447        Err(error) => {
2448            // The error intentionally contains no file contents or parsed
2449            // values. A malformed or unsafe workspace file fails closed while
2450            // shell/config credentials remain available.
2451            eprintln!("Codewhale did not load workspace .env: {error}");
2452        }
2453    }
2454}
2455
2456fn display_env_key_set(keys: &BTreeSet<String>) -> String {
2457    const MAX_DISPLAYED: usize = 12;
2458    let mut labels = keys
2459        .iter()
2460        .take(MAX_DISPLAYED)
2461        .map(|key| {
2462            if key
2463                .chars()
2464                .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
2465            {
2466                key.as_str()
2467            } else {
2468                "<invalid-name>"
2469            }
2470        })
2471        .collect::<Vec<_>>();
2472    if keys.len() > MAX_DISPLAYED {
2473        labels.push("...");
2474    }
2475    labels.join(", ")
2476}
2477
2478fn load_workspace_dotenv_credentials() -> Result<Option<WorkspaceDotenvReport>> {
2479    let Some(path) = find_workspace_dotenv()? else {
2480        return Ok(None);
2481    };
2482    load_workspace_dotenv_credentials_from_path(&path).map(Some)
2483}
2484
2485fn find_workspace_dotenv() -> Result<Option<PathBuf>> {
2486    let cwd = std::env::current_dir().context("could not resolve the current workspace")?;
2487    let boundary = cwd
2488        .ancestors()
2489        .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
2490        .unwrap_or(cwd.as_path());
2491
2492    for ancestor in cwd.ancestors() {
2493        let candidate = ancestor.join(".env");
2494        match std::fs::symlink_metadata(&candidate) {
2495            Ok(_) => return Ok(Some(candidate)),
2496            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2497            Err(error) => {
2498                return Err(anyhow!(
2499                    "could not inspect {}: {error}",
2500                    candidate.display()
2501                ));
2502            }
2503        }
2504        if ancestor == boundary {
2505            break;
2506        }
2507    }
2508    Ok(None)
2509}
2510
2511fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
2512    let contents = read_stable_workspace_dotenv(path)?;
2513    let text = std::str::from_utf8(&contents)
2514        .map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
2515    if dotenv_has_variable_expansion(text) {
2516        bail!(
2517            "{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
2518            path.display()
2519        );
2520    }
2521
2522    let mut report = WorkspaceDotenvReport {
2523        path: path.to_path_buf(),
2524        ..WorkspaceDotenvReport::default()
2525    };
2526    let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
2527        .collect::<std::result::Result<Vec<_>, _>>()
2528        .map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
2529    for entry in entries {
2530        let (key, value) = entry;
2531        if !is_workspace_dotenv_credential_key(&key) {
2532            report.ignored.insert(key);
2533            continue;
2534        }
2535        if std::env::var_os(&key).is_some() {
2536            continue;
2537        }
2538
2539        // SAFETY: this loader runs synchronously in `main` before the runtime
2540        // owner or Tokio workers are spawned. No concurrent environment reader
2541        // exists inside Codewhale, and later startup code treats this process
2542        // environment as immutable.
2543        unsafe { std::env::set_var(&key, value) };
2544        report.loaded.insert(key);
2545    }
2546    Ok(report)
2547}
2548
2549fn is_workspace_dotenv_credential_key(key: &str) -> bool {
2550    codewhale_config::provider::providers_sorted_for_display()
2551        .into_iter()
2552        .any(|provider| provider.env_vars().contains(&key))
2553        || matches!(
2554            key,
2555            "DEEPSEEK_SEARCH_API_KEY"
2556                | "SOFYA_API_KEY"
2557                | "METASO_API_KEY"
2558                | "BAIDU_SEARCH_API_KEY"
2559                | "DEEPSEEK_SANDBOX_API_KEY"
2560        )
2561}
2562
2563fn dotenv_has_variable_expansion(contents: &str) -> bool {
2564    let mut escaped = false;
2565    let mut single_quoted = false;
2566    let mut double_quoted = false;
2567    let mut comment = false;
2568
2569    for ch in contents.chars() {
2570        if comment {
2571            // Reject expansion markers even in comments. This is deliberately
2572            // conservative, and ignoring other comment text prevents an
2573            // unmatched quote there from changing how the next line is read.
2574            if ch == '$' {
2575                return true;
2576            }
2577            if ch == '\n' {
2578                comment = false;
2579                escaped = false;
2580            }
2581            continue;
2582        }
2583        if single_quoted {
2584            if ch == '\'' {
2585                single_quoted = false;
2586            }
2587            continue;
2588        }
2589        if escaped {
2590            escaped = false;
2591            continue;
2592        }
2593        if ch == '\\' {
2594            escaped = true;
2595            continue;
2596        }
2597        if ch == '\'' && !double_quoted {
2598            single_quoted = true;
2599            continue;
2600        }
2601        if ch == '"' {
2602            double_quoted = !double_quoted;
2603            continue;
2604        }
2605        if ch == '#' && !double_quoted {
2606            comment = true;
2607            continue;
2608        }
2609        if ch == '$' {
2610            return true;
2611        }
2612    }
2613    false
2614}
2615
2616fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
2617    let mut file = open_workspace_dotenv_without_following_links(path)?;
2618    let metadata = file
2619        .metadata()
2620        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2621    if !metadata.is_file() {
2622        bail!("{} is not a regular file", path.display());
2623    }
2624    if workspace_dotenv_has_multiple_links(&file, &metadata)? {
2625        bail!(
2626            "{} has multiple filesystem links, not a unique workspace-owned file",
2627            path.display()
2628        );
2629    }
2630    if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
2631        bail!(
2632            "{} exceeds the {} byte workspace .env limit",
2633            path.display(),
2634            MAX_WORKSPACE_DOTENV_BYTES
2635        );
2636    }
2637
2638    let mut contents = Vec::with_capacity(metadata.len() as usize);
2639    (&mut file)
2640        .take(MAX_WORKSPACE_DOTENV_BYTES + 1)
2641        .read_to_end(&mut contents)
2642        .map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
2643    if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
2644        bail!(
2645            "{} exceeds the {} byte workspace .env limit",
2646            path.display(),
2647            MAX_WORKSPACE_DOTENV_BYTES
2648        );
2649    }
2650    Ok(contents)
2651}
2652
2653#[cfg(unix)]
2654fn workspace_dotenv_has_multiple_links(
2655    _file: &std::fs::File,
2656    metadata: &std::fs::Metadata,
2657) -> Result<bool> {
2658    use std::os::unix::fs::MetadataExt;
2659
2660    Ok(metadata.nlink() > 1)
2661}
2662
2663#[cfg(windows)]
2664fn workspace_dotenv_has_multiple_links(
2665    file: &std::fs::File,
2666    _metadata: &std::fs::Metadata,
2667) -> Result<bool> {
2668    use std::os::windows::io::AsRawHandle;
2669    use windows::Win32::Foundation::HANDLE;
2670    use windows::Win32::Storage::FileSystem::{
2671        BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
2672    };
2673
2674    let mut information = BY_HANDLE_FILE_INFORMATION::default();
2675    // SAFETY: `file` owns a live kernel handle for the already-open `.env`;
2676    // `information` remains writable for the duration of this synchronous
2677    // call. No path lookup or re-open occurs here.
2678    unsafe {
2679        GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
2680            .map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
2681    }
2682    Ok(information.nNumberOfLinks > 1)
2683}
2684
2685#[cfg(not(any(unix, windows)))]
2686fn workspace_dotenv_has_multiple_links(
2687    _file: &std::fs::File,
2688    _metadata: &std::fs::Metadata,
2689) -> Result<bool> {
2690    Ok(false)
2691}
2692
2693#[cfg(unix)]
2694fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2695    use std::os::unix::fs::OpenOptionsExt;
2696
2697    std::fs::OpenOptions::new()
2698        .read(true)
2699        // `O_NONBLOCK` is inert for regular files but prevents a FIFO named
2700        // `.env` from hanging startup before the metadata check can reject it.
2701        .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
2702        .open(path)
2703        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2704}
2705
2706#[cfg(windows)]
2707fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2708    use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
2709
2710    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
2711    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2712    let file = std::fs::OpenOptions::new()
2713        .read(true)
2714        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
2715        .open(path)
2716        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
2717    let metadata = file
2718        .metadata()
2719        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2720    if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
2721        bail!(
2722            "{} is a reparse point, not a workspace-owned file",
2723            path.display()
2724        );
2725    }
2726    Ok(file)
2727}
2728
2729#[cfg(not(any(unix, windows)))]
2730fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2731    let metadata = std::fs::symlink_metadata(path)
2732        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2733    if metadata.file_type().is_symlink() {
2734        bail!(
2735            "{} is a symbolic link, not a workspace-owned file",
2736            path.display()
2737        );
2738    }
2739    std::fs::File::open(path)
2740        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2741}
2742
2743/// Generate shell completions for the given shell
2744fn generate_completions(shell: Shell) {
2745    let mut cmd = Cli::command();
2746    let name = cmd.get_name().to_string();
2747    generate(shell, &mut cmd, name, &mut io::stdout());
2748}
2749
2750/// Run the offline evaluation harness (no network/LLM calls).
2751fn run_eval(args: EvalArgs) -> Result<()> {
2752    let fail_step = match args.fail_step.as_deref() {
2753        Some(value) => ScenarioStepKind::parse(value)
2754            .map(Some)
2755            .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
2756        None => None,
2757    };
2758
2759    let config = EvalHarnessConfig {
2760        fail_step,
2761        shell_command: args.shell_command,
2762        shell_expect_token: args.shell_expect_token,
2763        max_output_chars: args.max_output_chars,
2764        record_dir: args.record.clone(),
2765        ..EvalHarnessConfig::default()
2766    };
2767
2768    let harness = EvalHarness::new(config);
2769    let run = harness.run().context("evaluation harness failed")?;
2770    let report = run.to_report();
2771
2772    if args.json {
2773        let json = serde_json::to_string_pretty(&report)?;
2774        println!("{json}");
2775    } else {
2776        println!("Offline Eval Harness");
2777        println!("scenario: {}", report.scenario_name);
2778        println!("workspace: {}", report.workspace_root.display());
2779        println!("success: {}", report.metrics.success);
2780        println!("steps: {}", report.metrics.steps);
2781        println!("tool_errors: {}", report.metrics.tool_errors);
2782        println!("duration_ms: {}", report.metrics.duration.as_millis());
2783
2784        if !report.metrics.per_tool.is_empty() {
2785            println!("per_tool:");
2786            for (kind, stats) in &report.metrics.per_tool {
2787                println!(
2788                    "  {} invocations={} errors={} duration_ms={}",
2789                    kind.tool_name(),
2790                    stats.invocations,
2791                    stats.errors,
2792                    stats.total_duration.as_millis()
2793                );
2794            }
2795        }
2796
2797        let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
2798        if !failed_steps.is_empty() {
2799            println!("failed_steps:");
2800            for step in failed_steps {
2801                let error = step.error.as_deref().unwrap_or("unknown error");
2802                println!(
2803                    "  {} tool={} error={}",
2804                    step.kind.tool_name(),
2805                    step.tool_name,
2806                    error
2807                );
2808            }
2809        }
2810    }
2811
2812    if report.metrics.success {
2813        Ok(())
2814    } else {
2815        bail!("offline evaluation harness reported failure")
2816    }
2817}
2818
2819/// Score a run's token/cache/cost from recorded turns and (optionally) flag
2820/// regressions against a committed baseline. Offline: reads recorded usage from
2821/// a JSON file, reuses the pricing layer, never calls a model. Exits non-zero
2822/// when a baseline is supplied and a metric regresses past the threshold, so it
2823/// can be wired as a release gate (#3388).
2824fn run_scorecard(args: ScorecardArgs) -> Result<()> {
2825    use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
2826
2827    let raw = std::fs::read_to_string(&args.input)
2828        .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
2829    let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
2830        .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
2831
2832    let card = Scorecard::from_recorded_turns(&recorded);
2833
2834    let regressions = match &args.baseline {
2835        Some(path) => {
2836            let baseline_raw = std::fs::read_to_string(path)
2837                .with_context(|| format!("failed to read baseline {}", path.display()))?;
2838            let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
2839                .with_context(|| format!("failed to parse baseline {}", path.display()))?;
2840            card.metrics.regressions_against(&baseline, args.threshold)
2841        }
2842        None => Vec::new(),
2843    };
2844
2845    if args.json {
2846        let out = serde_json::json!({
2847            "per_turn": card.per_turn,
2848            "metrics": card.metrics,
2849            "regressions": regressions,
2850        });
2851        println!("{}", serde_json::to_string_pretty(&out)?);
2852    } else {
2853        print!("{}", card.to_summary());
2854        for r in &regressions {
2855            println!(
2856                "REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
2857                r.metric, r.baseline, r.current, r.pct_increase
2858            );
2859        }
2860    }
2861
2862    if regressions.is_empty() {
2863        Ok(())
2864    } else {
2865        bail!(
2866            "{} metric(s) regressed past the {:.1}% threshold",
2867            regressions.len(),
2868            args.threshold
2869        )
2870    }
2871}
2872
2873async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
2874    use crate::fleet::alerts::{
2875        FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
2876        FleetEnvSecretResolver,
2877    };
2878    use crate::fleet::control as fleet_control;
2879    use crate::fleet::executor::FleetExecutor;
2880    use crate::fleet::manager::{FleetManager, FleetStatusSnapshot, FleetWorkerInspection};
2881    use codewhale_lane::{ControlOperation, ControlSurface};
2882    use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
2883
2884    // Every label and every row below comes from the shared Fleet control
2885    // surface, so `codewhale fleet …` and `/fleet …` cannot drift in how they
2886    // describe the same durable ledger (#1888, #4022).
2887    fn print_status(status: &FleetStatusSnapshot) {
2888        println!("{}", fleet_control::render_fleet_status_snapshot(status));
2889    }
2890
2891    fn print_inspection(inspection: &FleetWorkerInspection) {
2892        println!("{}", fleet_control::render_inspection(inspection));
2893    }
2894
2895    fn print_artifacts(inspection: &FleetWorkerInspection) {
2896        println!("{}", fleet_control::render_artifacts(inspection));
2897    }
2898
2899    /// Print one shared control receipt on the CLI surface.
2900    fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
2901        if receipt.is_error() {
2902            eprintln!("{}", receipt.render());
2903            let detail = receipt
2904                .failure
2905                .as_ref()
2906                .map(ToString::to_string)
2907                .unwrap_or_else(|| receipt.outcome.as_str().to_string());
2908            bail!("{}: {detail}", receipt.operation_id);
2909        }
2910        println!("{}", receipt.render());
2911        Ok(())
2912    }
2913
2914    fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
2915        let mut printed = false;
2916        for artifact in inspection
2917            .artifacts
2918            .iter()
2919            .filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
2920        {
2921            let path = workspace.join(&artifact.path);
2922            println!("== {} ==", artifact.path.display());
2923            let contents = std::fs::read_to_string(&path)
2924                .with_context(|| format!("reading fleet log {}", path.display()))?;
2925            let preview: String = contents.chars().take(16 * 1024).collect();
2926            // Worker logs can contain captured terminal bytes (a child TUI's
2927            // mouse-tracking handshake, SGR, OSC). Printing them raw would
2928            // re-arm mouse reporting in the caller's shell and leave it
2929            // executing escape fragments after this command exits.
2930            let mut safe_preview = String::with_capacity(preview.len());
2931            crate::tui::osc8::strip_ansi_into(&preview, &mut safe_preview);
2932            print!("{safe_preview}");
2933            if contents.chars().count() > preview.chars().count() {
2934                println!("\n[truncated]");
2935            } else if !preview.ends_with('\n') {
2936                println!();
2937            }
2938            printed = true;
2939        }
2940        if !printed {
2941            println!("logs: none");
2942        }
2943        Ok(())
2944    }
2945
2946    fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
2947        match arg {
2948            FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
2949            FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
2950            FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
2951            FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
2952            FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
2953            FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
2954        }
2955    }
2956
2957    fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
2958        if let Some(status) = override_status {
2959            return status;
2960        }
2961        match class {
2962            FleetAlertEventClass::Stale => "stale",
2963            FleetAlertEventClass::RestartExhausted => "failed",
2964            FleetAlertEventClass::NeedsHuman => "needs_human",
2965            FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
2966            FleetAlertEventClass::VerifierFailed => "verifier_failed",
2967            FleetAlertEventClass::RunCompleted => "completed",
2968        }
2969        .to_string()
2970    }
2971
2972    fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
2973        match args.adapter {
2974            FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
2975                webhook_env: args.slack_webhook_env.clone(),
2976                channel: None,
2977            },
2978            FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
2979                url_env: args.webhook_url_env.clone(),
2980                secret_env: args.webhook_secret_env.clone(),
2981            },
2982            FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
2983                routing_key_env: args.pagerduty_routing_key_env.clone(),
2984                severity: args.pagerduty_severity.clone(),
2985            },
2986        }
2987    }
2988
2989    let fleet_config = config.fleet_config();
2990    let provider = config.api_provider();
2991    let max_subagents = config.max_subagents_for_provider(provider);
2992    let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout(
2993        workspace.to_path_buf(),
2994        max_subagents,
2995        config
2996            .max_admitted_subagents_for_provider(provider)
2997            .max(max_subagents),
2998        Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
2999        config.launch_concurrency_for_provider(provider),
3000        config.subagent_token_budget_for_provider(provider),
3001    );
3002    // Probe the durable ledger *before* opening the manager: FleetManager::open
3003    // creates `.codewhale/fleet.jsonl` as a side effect, so a later probe would
3004    // always find a ledger and the CLI would report availability differently
3005    // from the slash surface for the same workspace (#4022).
3006    let fleet_context = fleet_control::fleet_control_context(workspace);
3007    // Probing is not enough on its own: `FleetManager::open` *creates* the
3008    // ledger, and it used to run for every subcommand before this match. That
3009    // made `codewhale fleet status` in a ledgerless workspace print
3010    // "no_fleet_ledger" while simultaneously creating the file it said was
3011    // missing — and the next invocation then reported an empty ledger as if a
3012    // Fleet had existed all along. Refuse the control verbs here, before the
3013    // manager exists, so the CLI and `/fleet` agree and neither surface
3014    // conjures the store it is reporting on (#4022).
3015    if let Some(operation) = match &args.command {
3016        FleetCommand::List => Some(ControlOperation::FleetList),
3017        FleetCommand::Status => Some(ControlOperation::FleetStatus),
3018        FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
3019        FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
3020        _ => None,
3021    } {
3022        let descriptor = operation.descriptor();
3023        let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
3024        if !availability.is_available() {
3025            return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
3026                descriptor,
3027                ControlSurface::Cli,
3028                availability,
3029            ));
3030        }
3031    }
3032
3033    // The configured route is the operator: fleet workers without a
3034    // task/profile model pin inherit the session's active model.
3035    let manager = FleetManager::open(workspace)?
3036        .with_exec_config(fleet_config.exec.clone())
3037        .with_fleet_config(fleet_config)
3038        .with_sub_agent_manager(coordination_manager)
3039        .with_session_model(config.default_model())
3040        .with_route_config(config.clone());
3041    match args.command {
3042        FleetCommand::Init => {
3043            println!("fleet ledger: {}", manager.ledger_path().display());
3044            Ok(())
3045        }
3046        FleetCommand::Run(args) => {
3047            let max_workers = args.max_workers.clamp(1, 128);
3048            let manager =
3049                manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
3050            let report = manager.create_run_from_task_spec_path(&args.task_spec, max_workers)?;
3051            println!(
3052                "fleet run: {} tasks={} leased={} queued={}",
3053                report.run_id.0, report.task_count, report.leased, report.queued
3054            );
3055            for warning in &report.warnings {
3056                println!("warning: {warning}");
3057            }
3058            println!("workers:");
3059            for worker_id in &report.worker_ids {
3060                println!("  {worker_id}");
3061            }
3062            if args.once {
3063                print_status(&manager.run_status(&report.run_id)?);
3064                return Ok(());
3065            }
3066            println!(
3067                "manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
3068            );
3069            let mut executor = FleetExecutor::new(workspace);
3070            let codewhale_binary = fleet::executor::configured_codewhale_binary();
3071            let status = manager
3072                .run_to_completion(
3073                    &report.run_id,
3074                    max_workers,
3075                    &mut executor,
3076                    &codewhale_binary,
3077                    None,
3078                    Duration::from_secs(2),
3079                )
3080                .await?;
3081            print_status(&status);
3082            Ok(())
3083        }
3084        FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3085            ControlSurface::Cli,
3086            workspace,
3087            fleet_context,
3088            &manager,
3089            ControlOperation::FleetList,
3090            None,
3091        )),
3092        FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3093            ControlSurface::Cli,
3094            workspace,
3095            fleet_context,
3096            &manager,
3097            ControlOperation::FleetStatus,
3098            None,
3099        )),
3100        FleetCommand::Inspect { worker_id } => {
3101            print_inspection(&manager.inspect_worker(&worker_id)?);
3102            Ok(())
3103        }
3104        FleetCommand::Logs { worker_id } => {
3105            let inspection = manager.inspect_worker(&worker_id)?;
3106            print_logs(workspace, &inspection)
3107        }
3108        FleetCommand::Artifacts { worker_id } => {
3109            let inspection = manager.inspect_worker(&worker_id)?;
3110            print_artifacts(&inspection);
3111            Ok(())
3112        }
3113        FleetCommand::Interrupt { worker_id } => {
3114            emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3115                ControlSurface::Cli,
3116                workspace,
3117                fleet_context,
3118                &manager,
3119                ControlOperation::FleetInterrupt,
3120                Some(&worker_id),
3121            ))
3122        }
3123        FleetCommand::Restart { worker_id } => {
3124            let report = manager.restart_worker(&worker_id)?;
3125            print_inspection(&report.inspection);
3126            println!(
3127                "manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
3128                report.run_id.0
3129            );
3130            let mut executor = FleetExecutor::new(workspace);
3131            let codewhale_binary = fleet::executor::configured_codewhale_binary();
3132            let status = manager
3133                .run_to_completion(
3134                    &report.run_id,
3135                    report.max_workers,
3136                    &mut executor,
3137                    &codewhale_binary,
3138                    None,
3139                    Duration::from_secs(2),
3140                )
3141                .await?;
3142            print_status(&status);
3143            Ok(())
3144        }
3145        FleetCommand::Resume {
3146            run_id,
3147            stale_after_seconds,
3148        } => {
3149            let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
3150            emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3151                ControlSurface::Cli,
3152                workspace,
3153                fleet_context,
3154                &manager,
3155                ControlOperation::FleetResume,
3156                Some(&run_id),
3157            ))
3158        }
3159        FleetCommand::Stop { all } => {
3160            if !all {
3161                bail!("pass --all to stop all fleet work");
3162            }
3163            let stopped = manager.stop_all()?;
3164            println!("stopped: {stopped}");
3165            Ok(())
3166        }
3167        FleetCommand::AlertDryRun(args) => {
3168            let class = alert_event_class(args.event);
3169            let adapter = alert_adapter(&args);
3170            let event = FleetAlertEvent {
3171                class,
3172                run_id: FleetRunId::from(args.run_id.clone()),
3173                worker_id: args.worker_id.clone(),
3174                task_id: args.task_id.clone(),
3175                status: alert_status(class, args.status.clone()),
3176                reason: args.reason.clone(),
3177            };
3178            let dispatcher = FleetAlertDispatcher::new(
3179                FleetAlertConfig::dry_run_for_adapter(adapter),
3180                FleetEnvSecretResolver,
3181            );
3182            let deliveries = dispatcher.dispatch(&event)?;
3183            for delivery in deliveries {
3184                println!(
3185                    "{}",
3186                    serde_json::to_string_pretty(&delivery.redacted_payload)?
3187                );
3188            }
3189            Ok(())
3190        }
3191    }
3192}
3193
3194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3195enum WriteStatus {
3196    Created,
3197    Overwritten,
3198    SkippedExists,
3199}
3200
3201fn ensure_parent_dir(path: &Path) -> Result<()> {
3202    if let Some(parent) = path.parent()
3203        && !parent.as_os_str().is_empty()
3204    {
3205        std::fs::create_dir_all(parent)
3206            .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
3207    }
3208    Ok(())
3209}
3210
3211fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
3212    ensure_parent_dir(path)?;
3213
3214    if path.exists() && !force {
3215        return Ok(WriteStatus::SkippedExists);
3216    }
3217
3218    let status = if path.exists() {
3219        WriteStatus::Overwritten
3220    } else {
3221        WriteStatus::Created
3222    };
3223
3224    std::fs::write(path, contents)
3225        .with_context(|| format!("Failed to write template at {}", path.display()))?;
3226
3227    Ok(status)
3228}
3229
3230fn mcp_template_json() -> Result<String> {
3231    let mut cfg = McpConfig::default();
3232    cfg.servers.insert(
3233        "example".to_string(),
3234        McpServerConfig {
3235            command: Some("node".to_string()),
3236            args: vec!["./path/to/your-mcp-server.js".to_string()],
3237            env: std::collections::HashMap::new(),
3238            cwd: None,
3239            url: None,
3240            transport: None,
3241            connect_timeout: None,
3242            execute_timeout: None,
3243            read_timeout: None,
3244            disabled: true,
3245            enabled: true,
3246            required: false,
3247            enabled_tools: Vec::new(),
3248            disabled_tools: Vec::new(),
3249            headers: std::collections::HashMap::new(),
3250            env_headers: std::collections::HashMap::new(),
3251            bearer_token_env_var: None,
3252            scopes: Vec::new(),
3253            oauth: None,
3254            oauth_resource: None,
3255            reviewed_plugin: None,
3256        },
3257    );
3258    serde_json::to_string_pretty(&cfg)
3259        .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
3260}
3261
3262fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
3263    let template = mcp_template_json()?;
3264    write_template_file(path, &template, force)
3265}
3266
3267fn skills_template(name: &str) -> String {
3268    format!(
3269        "\
3270---\n\
3271name: {name}\n\
3272description: Quick repo diagnostics and setup guidance\n\
3273allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
3274---\n\n\
3275When this skill is active:\n\
32761. Run the diagnostics tool to report workspace and sandbox status.\n\
32772. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
32783. Prefer small, validated changes and summarize what you verified.\n\
3279"
3280    )
3281}
3282
3283fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
3284    std::fs::create_dir_all(skills_dir)
3285        .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
3286
3287    let skill_name = "getting-started";
3288    let skill_path = skills_dir.join(skill_name).join("SKILL.md");
3289    ensure_parent_dir(&skill_path)?;
3290
3291    let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
3292    Ok((skill_path, status))
3293}
3294
3295fn tools_readme_template() -> &'static str {
3296    "# Local tools\n\n\
3297     Drop self-describing scripts here so they can be discovered by\n\
3298     `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
3299     When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
3300     `~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
3301     registered as model-visible tools.\n\n\
3302     Each script should start with a frontmatter-style header so the\n\
3303     description is visible without executing the file and the agent knows\n\
3304     the tool name, description, and input schema:\n\n\
3305     ```\n\
3306     # name: my-tool\n\
3307     # description: One-line summary of what this tool does\n\
3308     # usage: my-tool [args...]\n\
3309     ```\n\n\
3310     The directory is intentionally not auto-loaded into the agent's tool\n\
3311     catalog. Wire individual tools through MCP, hooks, or skills when you\n\
3312     want them available inside a session.\n"
3313}
3314
3315fn tools_example_script() -> &'static str {
3316    "#!/usr/bin/env sh\n\
3317     # name: example\n\
3318     # description: Print a confirmation that local tool discovery works\n\
3319     # usage: example [name]\n\
3320     printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n"
3321}
3322
3323fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
3324    std::fs::create_dir_all(tools_dir)
3325        .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
3326
3327    let readme_path = tools_dir.join("README.md");
3328    let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
3329
3330    let example_path = tools_dir.join("example.sh");
3331    let example_status = write_template_file(&example_path, tools_example_script(), force)?;
3332
3333    Ok((tools_dir.to_path_buf(), readme_status, example_status))
3334}
3335
3336fn plugins_readme_template() -> &'static str {
3337    "# Local plugins\n\n\
3338     Each Codewhale plugin bundle lives in its own subdirectory with a\n\
3339     versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
3340     under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
3341     untrusted, and disabled by default.\n\n\
3342     A v0.9.1 bundle layout looks like:\n\n\
3343     ```\n\
3344     plugins/\n\
3345       my-plugin/\n\
3346         plugin.toml\n\
3347         skills/\n\
3348           my-skill/SKILL.md\n\
3349     ```\n\n\
3350     Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
3351     Enablement opens a content- and capability-bound trust review;\n\
3352     confirm the displayed `/plugin trust` command to create an owner-only,\n\
3353     content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
3354     authentication must name environment sources; never store secret values\n\
3355     in `plugin.toml`.\n\n\
3356     Codewhale activates declarative Skills, MCP servers, Commands, Agent\n\
3357     profiles, and Hooks through their existing engines. LSP, native\n\
3358     extensions, filesystem grants, and lifecycle mutation stay inventoried\n\
3359     and inactive; a mixed bundle can still activate supported components.\n\
3360     Marketplace catalogs, install, update, and uninstall all feed this same\n\
3361     disabled-and-untrusted review path; none grants automatic trust. Codewhale\n\
3362     does not scan other applications for ambient plugins.\n"
3363}
3364
3365fn plugin_example_manifest_template() -> &'static str {
3366    "schema_version = 1\n\n\
3367     [plugin]\n\
3368     name = \"example\"\n\
3369     version = \"0.1.0\"\n\
3370     description = \"Starter Codewhale plugin bundle\"\n\n\
3371     [skills]\n\
3372     path = \"skills\"\n"
3373}
3374
3375fn plugin_example_skill_template() -> &'static str {
3376    "---\n\
3377     name: hello\n\
3378     description: Explain that the example plugin bundle is active.\n\
3379     ---\n\n\
3380     Tell the user this instruction came from the namespaced\n\
3381     `example:hello` plugin skill. Do not perform side effects.\n"
3382}
3383
3384fn init_plugins_dir(
3385    plugins_dir: &Path,
3386    force: bool,
3387) -> Result<(
3388    PathBuf,
3389    PathBuf,
3390    PathBuf,
3391    WriteStatus,
3392    WriteStatus,
3393    WriteStatus,
3394)> {
3395    std::fs::create_dir_all(plugins_dir)
3396        .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
3397
3398    let readme_path = plugins_dir.join("README.md");
3399    let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
3400
3401    let manifest_path = plugins_dir.join("example").join("plugin.toml");
3402    ensure_parent_dir(&manifest_path)?;
3403    let manifest_status =
3404        write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
3405
3406    let skill_path = plugins_dir
3407        .join("example")
3408        .join("skills")
3409        .join("hello")
3410        .join("SKILL.md");
3411    ensure_parent_dir(&skill_path)?;
3412    let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
3413
3414    Ok((
3415        readme_path,
3416        manifest_path,
3417        skill_path,
3418        readme_status,
3419        manifest_status,
3420        skill_status,
3421    ))
3422}
3423
3424/// Resolve the user-supplied CORS origins for `codewhale serve --http`.
3425///
3426/// Sources, in priority order (later sources extend earlier ones):
3427/// 1. `--cors-origin URL` flags (repeatable)
3428/// 2. `CODEWHALE_CORS_ORIGINS` env var (comma-separated),
3429///    then `DEEPSEEK_CORS_ORIGINS` as an alias
3430/// 3. `[runtime_api] cors_origins = [...]` in `config.toml`
3431///
3432/// The runtime API always allows the built-in dev defaults
3433/// (localhost:3000, localhost:1420, tauri://localhost). User entries are
3434/// appended on top — empty strings are skipped, and duplicates are deduped
3435/// while preserving first-seen order. Whalescale#255 / #561.
3436fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
3437    let mut out: Vec<String> = Vec::new();
3438    let mut push = |raw: &str| {
3439        let trimmed = raw.trim();
3440        if trimmed.is_empty() {
3441            return;
3442        }
3443        if !out.iter().any(|existing| existing == trimmed) {
3444            out.push(trimmed.to_string());
3445        }
3446    };
3447    for o in flag_origins {
3448        push(o);
3449    }
3450    if let Ok(env_value) =
3451        std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
3452    {
3453        for piece in env_value.split(',') {
3454            push(piece);
3455        }
3456    }
3457    if let Some(rt) = &config.runtime_api
3458        && let Some(list) = &rt.cors_origins
3459    {
3460        for o in list {
3461            push(o);
3462        }
3463    }
3464    out
3465}
3466
3467fn deepseek_home_dir() -> PathBuf {
3468    codewhale_config::codewhale_home().unwrap_or_else(|_| {
3469        crate::config::effective_home_dir()
3470            .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
3471    })
3472}
3473
3474/// Resolve the default tools directory. Mirrors `default_skills_dir` shape.
3475fn default_tools_dir() -> PathBuf {
3476    deepseek_home_dir().join("tools")
3477}
3478
3479/// Resolve the default plugins directory.
3480fn default_plugins_dir() -> PathBuf {
3481    deepseek_home_dir().join("plugins")
3482}
3483
3484/// Default location for crash/offline-queue checkpoints managed by the TUI.
3485fn default_checkpoints_dir() -> PathBuf {
3486    deepseek_home_dir().join("sessions").join("checkpoints")
3487}
3488
3489#[derive(Debug, Clone, PartialEq, Eq)]
3490struct CleanPlan {
3491    targets: Vec<PathBuf>,
3492}
3493
3494fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
3495    // Every `*.json` file in the checkpoints directory is checkpoint state:
3496    // per-session crash checkpoints (`<session_id>.json`), the legacy
3497    // single-slot checkpoint (`latest.json`), and the offline input queue
3498    // (`offline_queue.json`). Non-JSON files and subdirectories are left
3499    // alone.
3500    let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
3501        .map(|entries| {
3502            entries
3503                .filter_map(|entry| entry.ok().map(|e| e.path()))
3504                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
3505                .collect()
3506        })
3507        .unwrap_or_default();
3508    targets.sort();
3509    CleanPlan { targets }
3510}
3511
3512fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
3513    let mut removed = Vec::with_capacity(plan.targets.len());
3514    for path in &plan.targets {
3515        std::fs::remove_file(path)
3516            .with_context(|| format!("Failed to remove {}", path.display()))?;
3517        removed.push(path.clone());
3518    }
3519    Ok(removed)
3520}
3521
3522fn run_setup(
3523    config: &Config,
3524    workspace: &Path,
3525    args: SetupArgs,
3526    plugins: &crate::plugins::PluginRegistry,
3527) -> Result<()> {
3528    if args.status {
3529        return run_setup_status(config, workspace, plugins);
3530    }
3531    if args.clean {
3532        return run_setup_clean(&default_checkpoints_dir(), args.force);
3533    }
3534
3535    use crate::palette;
3536    use colored::Colorize;
3537
3538    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3539    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3540
3541    let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
3542    let run_mcp = args.mcp || args.all || !any_explicit;
3543    let run_skills = args.skills || args.all || !any_explicit;
3544    let run_tools = args.tools || args.all;
3545    let run_plugins = args.plugins || args.all;
3546
3547    println!(
3548        "{}",
3549        "Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
3550    );
3551    println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
3552    println!("Workspace: {}", crate::utils::display_path(workspace));
3553
3554    if run_mcp {
3555        let mcp_path = config.mcp_config_path();
3556        let status = init_mcp_config(&mcp_path, args.force)?;
3557        match status {
3558            WriteStatus::Created => {
3559                println!("  ✓ Created MCP config at {}", mcp_path.display());
3560            }
3561            WriteStatus::Overwritten => {
3562                println!("  ✓ Overwrote MCP config at {}", mcp_path.display());
3563            }
3564            WriteStatus::SkippedExists => {
3565                println!("  · MCP config already exists at {}", mcp_path.display());
3566            }
3567        }
3568        println!(
3569            "    Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
3570        );
3571    }
3572
3573    if run_skills {
3574        let skills_dir = if args.local {
3575            workspace.join("skills")
3576        } else {
3577            config.skills_dir()
3578        };
3579        let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
3580        match status {
3581            WriteStatus::Created => {
3582                println!("  ✓ Created example skill at {}", skill_path.display());
3583            }
3584            WriteStatus::Overwritten => {
3585                println!("  ✓ Overwrote example skill at {}", skill_path.display());
3586            }
3587            WriteStatus::SkippedExists => {
3588                println!(
3589                    "  · Example skill already exists at {}",
3590                    skill_path.display()
3591                );
3592            }
3593        }
3594        if args.local {
3595            println!(
3596                "    Local skills dir enabled for this workspace: {}",
3597                crate::utils::display_path(&skills_dir)
3598            );
3599        } else {
3600            println!(
3601                "    Skills dir: {}",
3602                crate::utils::display_path(&skills_dir)
3603            );
3604        }
3605        println!("    Next: run the TUI and use `/skills` then `/skill getting-started`.");
3606    }
3607
3608    if run_tools {
3609        let tools_dir = default_tools_dir();
3610        let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
3611        report_write_status("Tools README", &dir.join("README.md"), readme_status);
3612        report_write_status("Example tool", &dir.join("example.sh"), example_status);
3613        println!("    Tools dir: {}", crate::utils::display_path(&dir));
3614        println!("    Next: drop scripts here; surface them via skills/MCP when ready.");
3615    }
3616
3617    if run_plugins {
3618        let plugins_dir = default_plugins_dir();
3619        let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
3620            init_plugins_dir(&plugins_dir, args.force)?;
3621        report_write_status("Plugins README", &readme_path, readme_status);
3622        report_write_status("Example plugin manifest", &manifest_path, manifest_status);
3623        report_write_status("Example plugin skill", &skill_path, skill_status);
3624        println!(
3625            "    Plugins dir: {}",
3626            crate::utils::display_path(&plugins_dir)
3627        );
3628        println!("    Next: run `/plugin validate`, review `example`, then trust and enable it.");
3629    }
3630
3631    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3632        config.prefer_bwrap.unwrap_or(false),
3633    );
3634    if let Some(kind) = sandbox {
3635        println!("  ✓ Sandbox available: {kind}");
3636    } else {
3637        println!("  · Sandbox not available on this platform (best-effort only).");
3638    }
3639
3640    Ok(())
3641}
3642
3643fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
3644    match status {
3645        WriteStatus::Created => {
3646            println!("  ✓ Created {label} at {}", path.display());
3647        }
3648        WriteStatus::Overwritten => {
3649            println!("  ✓ Overwrote {label} at {}", path.display());
3650        }
3651        WriteStatus::SkippedExists => {
3652            println!("  · {label} already exists at {}", path.display());
3653        }
3654    }
3655}
3656
3657/// Source of the resolved API key, used only by static doctor/setup reports.
3658///
3659/// These reports must not migrate a legacy secret store or acquire a
3660/// write-capable credential handle just to label a source.
3661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3662enum ApiKeySource {
3663    ConfigDeclared,
3664    EnvDeclared,
3665    ExternalAuthDeclared,
3666    SecretStoreUnprobed,
3667    SecretStoreUnavailable,
3668    OAuth,
3669    ExternalConsent,
3670    NoAuth,
3671    LocalRuntime,
3672    Unknown,
3673}
3674
3675/// What structural diagnostics can truthfully say about credential
3676/// availability without consulting environment values or durable stores.
3677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3678enum CredentialAvailability {
3679    Present,
3680    NotRequired,
3681    Unknown,
3682    NotProbed,
3683    Unavailable,
3684}
3685
3686impl CredentialAvailability {
3687    fn label(self) -> &'static str {
3688        match self {
3689            Self::Present => "present",
3690            Self::NotRequired => "not_required",
3691            Self::Unknown => "unknown",
3692            Self::NotProbed => "not_probed",
3693            Self::Unavailable => "unavailable",
3694        }
3695    }
3696
3697    fn certifies_ready(self) -> bool {
3698        matches!(self, Self::Present | Self::NotRequired)
3699    }
3700}
3701
3702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3703struct CredentialDiagnostic {
3704    source: ApiKeySource,
3705    availability: CredentialAvailability,
3706}
3707
3708impl CredentialDiagnostic {
3709    const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
3710        Self {
3711            source,
3712            availability,
3713        }
3714    }
3715}
3716
3717fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
3718    let provider = config.api_provider();
3719    let base_url = config.deepseek_base_url();
3720    let auth_mode = config.auth_mode_for_provider(provider);
3721    if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
3722        return CredentialDiagnostic::new(
3723            ApiKeySource::NoAuth,
3724            CredentialAvailability::NotRequired,
3725        );
3726    }
3727    if !crate::config::auth_mode_requires_api_key(auth_mode.as_deref())
3728        && (crate::config::provider_route_is_keyless_self_hosted(provider, &base_url)
3729            || crate::config::base_url_uses_local_host(&base_url))
3730    {
3731        return CredentialDiagnostic::new(
3732            ApiKeySource::LocalRuntime,
3733            CredentialAvailability::NotRequired,
3734        );
3735    }
3736    let custom_endpoint = config.provider_uses_custom_endpoint(provider);
3737    if !custom_endpoint && provider == crate::config::ApiProvider::OpenaiCodex {
3738        return config
3739            .external_credential_consent_status(provider)
3740            .filter(|status| status.route_state == "active")
3741            .map_or_else(
3742                || {
3743                    CredentialDiagnostic::new(
3744                        ApiKeySource::OAuth,
3745                        CredentialAvailability::NotProbed,
3746                    )
3747                },
3748                |_| {
3749                    CredentialDiagnostic::new(
3750                        ApiKeySource::ExternalConsent,
3751                        CredentialAvailability::NotProbed,
3752                    )
3753                },
3754            );
3755    }
3756    if !custom_endpoint
3757        && provider == crate::config::ApiProvider::Xai
3758        && auth_mode
3759            .as_deref()
3760            .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3761    {
3762        return config
3763            .external_credential_consent_status(provider)
3764            .filter(|status| status.route_state == "active")
3765            .map_or_else(
3766                || {
3767                    CredentialDiagnostic::new(
3768                        ApiKeySource::OAuth,
3769                        CredentialAvailability::NotProbed,
3770                    )
3771                },
3772                |_| {
3773                    CredentialDiagnostic::new(
3774                        ApiKeySource::ExternalConsent,
3775                        CredentialAvailability::NotProbed,
3776                    )
3777                },
3778            );
3779    }
3780    let provider_config = config.provider_config();
3781    let provider_config_key_kind = provider_config
3782        .and_then(|entry| entry.api_key.as_deref())
3783        .map(crate::config::classify_config_api_key_value);
3784    let root_key_applies = matches!(
3785        provider,
3786        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
3787    ) || (provider == crate::config::ApiProvider::Custom
3788        && config.uses_legacy_literal_custom_route());
3789    let root_key_kind = root_key_applies
3790        .then_some(config.api_key.as_deref())
3791        .flatten()
3792        .map(crate::config::classify_config_api_key_value);
3793
3794    if matches!(
3795        provider_config_key_kind,
3796        Some(crate::config::ConfigApiKeyValueKind::Literal)
3797    ) || matches!(
3798        root_key_kind,
3799        Some(crate::config::ConfigApiKeyValueKind::Literal)
3800    ) {
3801        CredentialDiagnostic::new(
3802            ApiKeySource::ConfigDeclared,
3803            CredentialAvailability::Present,
3804        )
3805    } else if config
3806        .provider_config()
3807        .and_then(|entry| entry.api_key_env.as_deref())
3808        .is_some_and(|name| !name.trim().is_empty())
3809    {
3810        CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
3811    } else if config
3812        .provider_config()
3813        .and_then(|entry| entry.auth.as_ref())
3814        .is_some()
3815    {
3816        CredentialDiagnostic::new(
3817            ApiKeySource::ExternalAuthDeclared,
3818            CredentialAvailability::NotProbed,
3819        )
3820    } else if matches!(
3821        provider_config_key_kind,
3822        Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3823    ) || matches!(
3824        root_key_kind,
3825        Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3826    ) {
3827        if config.should_skip_secret_store_for_provider(provider) {
3828            return CredentialDiagnostic::new(
3829                ApiKeySource::SecretStoreUnavailable,
3830                CredentialAvailability::Unavailable,
3831            );
3832        }
3833        // The sentinel is a declaration that runtime resolution should use
3834        // the secret-store layer, never a literal key. Doctor does not read it.
3835        CredentialDiagnostic::new(
3836            ApiKeySource::SecretStoreUnprobed,
3837            CredentialAvailability::NotProbed,
3838        )
3839    } else if !config.should_skip_secret_store_for_provider(provider) {
3840        // No literal config declaration was found, but this route can continue
3841        // through the durable store and ambient provider environment. Ordinary
3842        // doctor deliberately does not inspect either source.
3843        CredentialDiagnostic::new(
3844            ApiKeySource::SecretStoreUnprobed,
3845            CredentialAvailability::NotProbed,
3846        )
3847    } else {
3848        CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
3849    }
3850}
3851
3852#[cfg(test)]
3853fn resolve_api_key_source(config: &Config) -> ApiKeySource {
3854    resolve_credential_diagnostic(config).source
3855}
3856
3857fn provider_config_table_key(provider: crate::config::ApiProvider) -> &'static str {
3858    provider
3859        .metadata()
3860        .map(|metadata| metadata.provider_config_key())
3861        .unwrap_or("deepseek_cn")
3862}
3863
3864fn count_dir_entries(dir: &Path) -> usize {
3865    std::fs::read_dir(dir)
3866        .map(|entries| entries.filter_map(std::result::Result::ok).count())
3867        .unwrap_or(0)
3868}
3869
3870fn skills_count_for(dir: &Path) -> usize {
3871    if !dir.exists() {
3872        return 0;
3873    }
3874    crate::skills::SkillRegistry::discover(dir).len()
3875}
3876
3877fn run_setup_status(
3878    config: &Config,
3879    workspace: &Path,
3880    plugins: &crate::plugins::PluginRegistry,
3881) -> Result<()> {
3882    use crate::palette;
3883    use colored::Colorize;
3884
3885    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3886    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3887
3888    println!(
3889        "{}",
3890        "Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
3891    );
3892    println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
3893    println!("workspace: {}", workspace.display());
3894
3895    let credential = resolve_credential_diagnostic(config);
3896    match credential.source {
3897        ApiKeySource::ConfigDeclared => println!(
3898            "  {} api_key: literal config value structurally present",
3899            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3900        ),
3901        ApiKeySource::EnvDeclared => println!(
3902            "  {} api_key: environment source declared (value not inspected)",
3903            "·".dimmed()
3904        ),
3905        ApiKeySource::ExternalAuthDeclared => println!(
3906            "  {} api_key: external auth source declared (value not inspected)",
3907            "·".dimmed()
3908        ),
3909        ApiKeySource::SecretStoreUnprobed => println!(
3910            "  {} api_key: secret store eligible (store not probed)",
3911            "·".dimmed()
3912        ),
3913        ApiKeySource::SecretStoreUnavailable => println!(
3914            "  {} api_key: secret-store sentinel declared, but this route cannot use that store",
3915            "!".truecolor(sky_r, sky_g, sky_b)
3916        ),
3917        ApiKeySource::OAuth => println!(
3918            "  {} oauth: Codewhale-owned route selected (token availability not probed)",
3919            "·".dimmed()
3920        ),
3921        ApiKeySource::ExternalConsent => println!(
3922            "  {} oauth: external read-only consent configured (credential file not probed)",
3923            "·".dimmed()
3924        ),
3925        ApiKeySource::NoAuth => println!(
3926            "  {} api_key: disabled for this route",
3927            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3928        ),
3929        ApiKeySource::LocalRuntime => println!(
3930            "  {} api_key: not required for this local runtime",
3931            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3932        ),
3933        ApiKeySource::Unknown => println!(
3934            "  {} api_key: unknown (credential environment and durable stores not inspected)",
3935            "·".dimmed()
3936        ),
3937    }
3938    println!(
3939        "  · credential availability: {}",
3940        credential.availability.label()
3941    );
3942    println!(
3943        "  · base_url: {}",
3944        crate::doctor::structural_url_authority(&config.deepseek_base_url())
3945    );
3946    let model = config
3947        .default_text_model
3948        .clone()
3949        .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
3950    println!("  · default_text_model: {model}");
3951    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
3952    println!("  · default_mode: {default_mode} ({default_mode_source})");
3953
3954    let mcp_path = config.mcp_config_path();
3955    let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
3956    let mcp_count =
3957        match crate::mcp::load_config_with_workspace_and_plugins(&mcp_path, workspace, plugins) {
3958            Ok(cfg) => cfg.servers.len(),
3959            Err(_) => 0,
3960        };
3961    let mcp_present = if mcp_path.exists() { "" } else { "  (missing)" };
3962    let project_mcp_present = if project_mcp_path.exists() {
3963        ""
3964    } else {
3965        "  (missing)"
3966    };
3967    println!(
3968        "  · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
3969        mcp_path.display(),
3970        project_mcp_path.display()
3971    );
3972
3973    let skills_dir = config.skills_dir();
3974    println!(
3975        "  · skills: {} at {}",
3976        skills_count_for(&skills_dir),
3977        crate::utils::display_path(&skills_dir)
3978    );
3979
3980    let tools_dir = default_tools_dir();
3981    let tools_present = if tools_dir.exists() {
3982        ""
3983    } else {
3984        "  (missing — run `setup --tools`)"
3985    };
3986    println!(
3987        "  · tools: {} entries at {}{tools_present}",
3988        if tools_dir.exists() {
3989            count_dir_entries(&tools_dir)
3990        } else {
3991            0
3992        },
3993        crate::utils::display_path(&tools_dir)
3994    );
3995
3996    let plugins_dir = default_plugins_dir();
3997    let plugins_present = if plugins_dir.exists() {
3998        ""
3999    } else {
4000        "  (missing — run `setup --plugins`)"
4001    };
4002    println!(
4003        "  · plugins: {} entries at {}{plugins_present}",
4004        if plugins_dir.exists() {
4005            count_dir_entries(&plugins_dir)
4006        } else {
4007            0
4008        },
4009        crate::utils::display_path(&plugins_dir)
4010    );
4011
4012    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
4013        config.prefer_bwrap.unwrap_or(false),
4014    );
4015    match sandbox {
4016        Some(kind) => println!(
4017            "  {} sandbox: {kind}",
4018            "✓".truecolor(aqua_r, aqua_g, aqua_b)
4019        ),
4020        None => println!(
4021            "  {} sandbox: unavailable (commands run best-effort)",
4022            "!".truecolor(sky_r, sky_g, sky_b)
4023        ),
4024    }
4025
4026    println!("  {} {}", "·".dimmed(), dotenv_status_line(workspace));
4027
4028    println!();
4029    println!("Run `codewhale doctor --json` for a machine-readable check.");
4030    Ok(())
4031}
4032
4033fn dotenv_status_line(workspace: &Path) -> String {
4034    let dotenv = workspace.join(".env");
4035    if dotenv.exists() {
4036        return format!(
4037            ".env present at {} (literal provider credentials only)",
4038            dotenv.display()
4039        );
4040    }
4041
4042    if workspace.join(".env.example").exists() {
4043        return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
4044    }
4045
4046    ".env not present in workspace".to_string()
4047}
4048
4049fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
4050    use colored::Colorize;
4051
4052    if !checkpoints_dir.exists() {
4053        println!(
4054            "Nothing to clean — checkpoints dir does not exist: {}",
4055            checkpoints_dir.display()
4056        );
4057        return Ok(());
4058    }
4059
4060    let plan = collect_clean_targets(checkpoints_dir);
4061    if plan.targets.is_empty() {
4062        println!(
4063            "Nothing to clean — no checkpoint files in {}",
4064            checkpoints_dir.display()
4065        );
4066        return Ok(());
4067    }
4068
4069    if !force {
4070        println!(
4071            "Would remove {} checkpoint file(s) (use --force to apply):",
4072            plan.targets.len()
4073        );
4074        for path in &plan.targets {
4075            println!("  · {}", path.display());
4076        }
4077        return Ok(());
4078    }
4079
4080    let removed = execute_clean_plan(&plan)?;
4081    println!("{}", "Cleaned checkpoints:".bold());
4082    for path in &removed {
4083        println!("  ✓ {}", path.display());
4084    }
4085    Ok(())
4086}
4087
4088fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
4089    let contents = std::fs::read_to_string(&args.path).with_context(|| {
4090        format!(
4091            "read session diagnostic JSONL from {}",
4092            crate::utils::display_path(&args.path)
4093        )
4094    })?;
4095    let summary = crate::session_diagnostics::analyze_session_failure_jsonl(&contents);
4096    if args.json {
4097        println!("{}", serde_json::to_string_pretty(&summary)?);
4098    } else {
4099        println!(
4100            "{}",
4101            crate::session_diagnostics::format_redacted_failure_summary(&summary)
4102        );
4103    }
4104    Ok(())
4105}
4106
4107/// Live API checks are explicit. Local endpoints have a separate opt-in because
4108/// an HTTP request can wake a desktop-managed daemon (notably Ollama.app).
4109fn doctor_should_probe_api(
4110    provider: crate::config::ApiProvider,
4111    base_url: &str,
4112    probes: crate::doctor::DoctorProbeRequest,
4113) -> bool {
4114    let local = crate::config::provider_route_is_keyless_self_hosted(provider, base_url)
4115        || crate::config::base_url_uses_local_host(base_url);
4116    probes.should_probe_api(local)
4117}
4118
4119/// Doctor must never turn credential inspection into a refresh/write path.
4120/// OAuth connectivity is exercised by an ordinary user request instead;
4121/// doctor limits itself to non-mutating readiness inspection.
4122fn doctor_should_probe_auth(config: &Config) -> bool {
4123    let provider = config.api_provider();
4124    if provider == crate::config::ApiProvider::OpenaiCodex
4125        && !config.provider_uses_custom_endpoint(provider)
4126    {
4127        return false;
4128    }
4129    let auth_mode = config.auth_mode_for_provider(provider);
4130    if provider == crate::config::ApiProvider::Xai
4131        && auth_mode
4132            .as_deref()
4133            .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
4134    {
4135        return false;
4136    }
4137    !(provider == crate::config::ApiProvider::Moonshot
4138        && auth_mode
4139            .as_deref()
4140            .is_some_and(crate::config::auth_mode_uses_kimi_imported_token))
4141}
4142
4143/// Run system diagnostics
4144async fn run_doctor(
4145    config: &Config,
4146    workspace: &Path,
4147    config_path_override: Option<&Path>,
4148    probes: crate::doctor::DoctorProbeRequest,
4149    plugins: &crate::plugins::PluginRegistry,
4150) {
4151    use crate::palette;
4152    use colored::Colorize;
4153
4154    let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
4155    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
4156    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
4157    let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
4158
4159    println!(
4160        "{}",
4161        "codewhale Doctor"
4162            .truecolor(accent_r, accent_g, accent_b)
4163            .bold()
4164    );
4165    println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
4166    println!();
4167
4168    // Version info
4169    println!("{}", "Version Information:".bold());
4170    println!("  codewhale-tui: {}", env!("CODEWHALE_BUILD_VERSION"));
4171    println!("  rust: {}", rustc_version());
4172    println!();
4173
4174    println!("{}", "Updates:".bold());
4175    crate::doctor::print_update_report(probes).await;
4176    println!();
4177
4178    // Configuration summary
4179    let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
4180        Ok(paths) => paths,
4181        Err(error) => {
4182            println!("{}", "Resolved User Paths:".bold());
4183            println!(
4184                "  {} unavailable: {error:#}",
4185                "✗".truecolor(red_r, red_g, red_b)
4186            );
4187            return;
4188        }
4189    };
4190    println!("{}", "Configuration:".bold());
4191    let config_path = &doctor_paths.config;
4192
4193    if config_path.exists() {
4194        println!(
4195            "  {} config.toml found at {}",
4196            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4197            crate::utils::display_path(config_path)
4198        );
4199        // Secret hygiene: name the keys, never the values. Plain-text config
4200        // is not a secret store.
4201        if let Ok(raw) = std::fs::read_to_string(config_path) {
4202            let flagged = crate::doctor::config_credential_shaped_keys(&raw);
4203            if !flagged.is_empty() {
4204                println!(
4205                    "  {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
4206                    "!".truecolor(sky_r, sky_g, sky_b),
4207                    flagged.join(", ")
4208                );
4209            }
4210        }
4211    } else {
4212        println!(
4213            "  {} config.toml not found at {} (using defaults/env)",
4214            "!".truecolor(sky_r, sky_g, sky_b),
4215            crate::utils::display_path(config_path)
4216        );
4217    }
4218    println!("  workspace: {}", crate::utils::display_path(workspace));
4219    println!("  {}", doctor_search_provider_line(config));
4220
4221    println!();
4222    println!("{}", "Resolved User Paths (read-only):".bold());
4223    for (label, path) in doctor_paths.entries() {
4224        println!("  · {label}: {}", crate::utils::display_path(path));
4225    }
4226
4227    let secret_backend = codewhale_secrets::diagnose_secret_backend();
4228    println!();
4229    println!("{}", "Secret Backend (structural only):".bold());
4230    for line in crate::doctor::secret_backend_human_lines(&secret_backend) {
4231        println!("  · {line}");
4232    }
4233
4234    // State root (v0.8.44)
4235    println!();
4236    println!("{}", "State Root:".bold());
4237    let (code_home, legacy_home) = doctor_state_roots();
4238    let active_root = if code_home.exists() {
4239        &code_home
4240    } else if legacy_home.exists() {
4241        &legacy_home
4242    } else {
4243        &code_home
4244    };
4245    println!("  active: {}", crate::utils::display_path(active_root));
4246    if active_root != &code_home {
4247        println!(
4248            "  note: legacy {} found; start Codewhale once to trigger safe migration where available.",
4249            crate::utils::display_path(&legacy_home)
4250        );
4251    }
4252    if legacy_home.exists() && code_home.exists() {
4253        println!(
4254            "  dual roots: {} (primary) + {} (legacy)",
4255            crate::utils::display_path(&code_home),
4256            crate::utils::display_path(&legacy_home)
4257        );
4258    }
4259    let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
4260    let session_recovery = doctor_session_recovery_report(
4261        &code_home,
4262        &legacy_home,
4263        codewhale_config::codewhale_home_is_explicit(),
4264    );
4265    print_doctor_legacy_state_report(
4266        &legacy_state_report,
4267        &session_recovery,
4268        (aqua_r, aqua_g, aqua_b),
4269        (sky_r, sky_g, sky_b),
4270    );
4271
4272    let (setup_state, setup_source) = doctor_setup_state(config, workspace);
4273    print_doctor_setup_report(
4274        config,
4275        workspace,
4276        &setup_state,
4277        setup_source,
4278        (aqua_r, aqua_g, aqua_b),
4279        (sky_r, sky_g, sky_b),
4280    );
4281    print_doctor_fleet_roster_layers(config, workspace);
4282
4283    // Check API keys
4284    println!();
4285    println!("{}", "API Keys:".bold());
4286
4287    // Per-provider state: env + config file only (no values printed).
4288    // Keep doctor/status prompt-free and credential-value-free even for
4289    // unsigned rebuilt binaries.
4290    for provider in crate::config::ApiProvider::all().iter().copied() {
4291        let slot = provider.as_str();
4292        let provider_config = config.provider_config_for(provider);
4293        let config_declared = provider_config.is_some_and(|entry| {
4294            entry.api_key.as_deref().is_some_and(|key| {
4295                crate::config::classify_config_api_key_value(key)
4296                    == crate::config::ConfigApiKeyValueKind::Literal
4297            })
4298        }) || (matches!(provider, crate::config::ApiProvider::Deepseek)
4299            && config.api_key.as_deref().is_some_and(|key| {
4300                crate::config::classify_config_api_key_value(key)
4301                    == crate::config::ConfigApiKeyValueKind::Literal
4302            }));
4303        let env_source_declared = provider_config
4304            .and_then(|entry| entry.api_key_env.as_deref())
4305            .is_some_and(|name| !name.trim().is_empty());
4306        let icon = if config_declared || env_source_declared {
4307            "·".truecolor(aqua_r, aqua_g, aqua_b)
4308        } else {
4309            "·".dimmed()
4310        };
4311        println!(
4312            "  {} {slot}: env_source={}, config_source={}",
4313            icon,
4314            if env_source_declared {
4315                "declared (value not inspected)"
4316            } else {
4317                "not inspected"
4318            },
4319            if config_declared {
4320                "declared (value not inspected)"
4321            } else {
4322                "not declared"
4323            }
4324        );
4325    }
4326    println!("  · credential precedence is unchanged; doctor does not inspect credential values");
4327    println!();
4328    println!(
4329        "{}",
4330        "External credential consent (configuration only):".bold()
4331    );
4332    for line in doctor_external_credential_consent_lines(config) {
4333        println!("  {line}");
4334    }
4335
4336    println!();
4337    println!(
4338        "{}",
4339        "DeepSeek Harness integration (read-only detection):".bold()
4340    );
4341    for line in doctor_dsh_integration_lines(config, workspace) {
4342        println!("  {line}");
4343    }
4344
4345    let credential = resolve_credential_diagnostic(config);
4346    let source_label = match credential.source {
4347        ApiKeySource::ConfigDeclared => "literal config value structurally present",
4348        ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
4349        ApiKeySource::ExternalAuthDeclared => {
4350            "external auth source declared; credential not resolved"
4351        }
4352        ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
4353        ApiKeySource::SecretStoreUnavailable => {
4354            "secret-store sentinel declared, but this route cannot use that store"
4355        }
4356        ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
4357        ApiKeySource::ExternalConsent => "external consent configured; token file not read",
4358        ApiKeySource::NoAuth => "no-auth route",
4359        ApiKeySource::LocalRuntime => "local runtime; credentials not required",
4360        ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
4361    };
4362    println!(
4363        "  {} active provider credential source: {source_label}",
4364        "·".dimmed()
4365    );
4366    println!(
4367        "  · active provider credential availability: {}",
4368        credential.availability.label()
4369    );
4370
4371    // API connectivity test
4372    println!();
4373    println!("{}", "API Connectivity:".bold());
4374    let api_target = doctor_api_target(config);
4375    // Configured-vs-active honesty (DGF-01): doctor describes the route a
4376    // session launched NOW would resolve. It cannot see inside an already
4377    // running session, which keeps the route it resolved at its own launch.
4378    println!(
4379        "  · 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)"
4380    );
4381    println!("  · provider: {}", api_target.provider);
4382    println!(
4383        "  · base_url: {}",
4384        crate::doctor::structural_url_authority(&api_target.base_url)
4385    );
4386    match api_target.resolution {
4387        DoctorModelResolution::Resolved => {
4388            println!("  · model: {} (resolved)", api_target.model);
4389        }
4390        DoctorModelResolution::ConfiguredOnly => {
4391            println!(
4392                "  · model: {} (configured; route resolution unavailable)",
4393                api_target.model
4394            );
4395        }
4396    }
4397    let tls_status = doctor_tls_status(config);
4398    if !tls_status.certificate_verification {
4399        println!("  ! {}", tls_status.message);
4400        println!("    Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
4401    }
4402    let strict_tool_mode = doctor_strict_tool_mode_status(config);
4403    let strict_icon = match strict_tool_mode.status {
4404        "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
4405        "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
4406        _ => "·".dimmed(),
4407    };
4408    println!(
4409        "  {} strict_tool_mode: {}",
4410        strict_icon, strict_tool_mode.message
4411    );
4412    if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
4413        println!(
4414            "    Use the {} endpoint for DeepSeek strict schemas.",
4415            crate::doctor::structural_url_authority(recommended)
4416        );
4417    }
4418    let capability = crate::config::provider_capability(config.api_provider(), &api_target.model);
4419    if let Some(alias) = capability.alias_deprecation.as_ref() {
4420        println!(
4421            "  ! model alias {} retires {}; switch to {}",
4422            alias.alias, alias.retirement_date, alias.replacement
4423        );
4424    }
4425    let live_api_requested =
4426        doctor_should_probe_api(config.api_provider(), &api_target.base_url, probes);
4427    let endpoint_is_local = crate::config::provider_route_is_keyless_self_hosted(
4428        config.api_provider(),
4429        &api_target.base_url,
4430    ) || crate::config::base_url_uses_local_host(&api_target.base_url);
4431    if doctor_should_probe_auth(config) && live_api_requested {
4432        print!("  {} Testing connection...", "·".dimmed());
4433        use std::io::Write;
4434        std::io::stdout().flush().ok();
4435
4436        // Resolve a credential through the diagnostic-only store first, then
4437        // probe with an in-memory clone. Constructing the normal client from
4438        // the original config could otherwise trigger its legacy secret-store
4439        // migration while a user merely asks doctor to test connectivity.
4440        let connectivity_result = match config.with_read_only_api_key_for_diagnostic() {
4441            Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
4442            Err(error) => Err(error),
4443        };
4444        match connectivity_result {
4445            Ok(()) => {
4446                println!(
4447                    "\r  {} API connection successful",
4448                    "✓".truecolor(aqua_r, aqua_g, aqua_b)
4449                );
4450            }
4451            Err(e) => {
4452                let error_msg = e.to_string();
4453                println!(
4454                    "\r  {} API connection failed",
4455                    "✗".truecolor(red_r, red_g, red_b)
4456                );
4457                if error_msg.contains("401") || error_msg.contains("Unauthorized") {
4458                    println!(
4459                        "    Invalid API key. Check `codewhale auth status`, DEEPSEEK_API_KEY, or config.toml"
4460                    );
4461                } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
4462                    println!(
4463                        "    API key lacks permissions. Verify key is active at platform.deepseek.com"
4464                    );
4465                } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
4466                    for line in doctor_timeout_recovery_lines(config) {
4467                        println!("    {line}");
4468                    }
4469                } else if error_msg.contains("dns") || error_msg.contains("resolve") {
4470                    println!("    DNS resolution failed. Check your network connection");
4471                } else if error_msg.contains("connect") {
4472                    println!("    Connection failed. Check firewall settings or try again");
4473                } else if crate::doctor::is_keyless_ds4_route(config) {
4474                    println!("    {error_msg}");
4475                } else {
4476                    println!(
4477                        "    Error details omitted because provider failures can contain credential material."
4478                    );
4479                }
4480            }
4481        }
4482    } else if !doctor_should_probe_auth(config) {
4483        println!(
4484            "  {} Live OAuth connectivity not checked by non-mutating doctor",
4485            "·".dimmed()
4486        );
4487        println!(
4488            "    Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
4489        );
4490    } else {
4491        if endpoint_is_local {
4492            println!(
4493                "  {} Live connectivity not checked for this local endpoint",
4494                "·".dimmed()
4495            );
4496            println!(
4497                "    Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
4498            );
4499        } else {
4500            println!(
4501                "  {} Live hosted connectivity not checked (offline default)",
4502                "·".dimmed()
4503            );
4504            println!("    Run `codewhale doctor --probe-api` to opt in.");
4505        }
4506    }
4507
4508    println!();
4509    println!("{}", "Search Provider Reachability:".bold());
4510    let search_probe = crate::doctor::doctor_search_probe(config, probes).await;
4511    for line in crate::doctor::doctor_search_probe_lines(&search_probe) {
4512        println!("  {line}");
4513    }
4514
4515    // MCP configuration
4516    println!();
4517    println!("{}", "MCP Servers (configuration only):".bold());
4518    println!("  · Static check only; no server process was started.");
4519    let features = config.features();
4520    if features.enabled(Feature::Mcp) {
4521        println!(
4522            "  {} MCP feature flag enabled",
4523            "✓".truecolor(aqua_r, aqua_g, aqua_b)
4524        );
4525    } else {
4526        println!(
4527            "  {} MCP feature flag disabled",
4528            "!".truecolor(sky_r, sky_g, sky_b)
4529        );
4530    }
4531
4532    let mcp_config_path = config.mcp_config_path();
4533    let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
4534    if mcp_config_path.exists() {
4535        println!(
4536            "  {} MCP config found at {}",
4537            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4538            crate::utils::display_path(&mcp_config_path)
4539        );
4540    } else {
4541        println!(
4542            "  {} MCP config not found at {}",
4543            "·".dimmed(),
4544            crate::utils::display_path(&mcp_config_path)
4545        );
4546    }
4547    if project_mcp_config_path.exists() {
4548        println!(
4549            "  {} Project MCP config found at {}",
4550            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4551            crate::utils::display_path(&project_mcp_config_path)
4552        );
4553    } else {
4554        println!(
4555            "  {} Project MCP config not found at {}",
4556            "·".dimmed(),
4557            crate::utils::display_path(&project_mcp_config_path)
4558        );
4559    }
4560
4561    match crate::mcp::load_config_with_workspace_and_plugins(&mcp_config_path, workspace, plugins) {
4562        Ok(cfg) if cfg.servers.is_empty() => {
4563            println!("  {} 0 merged server(s) configured", "·".dimmed());
4564            if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
4565                println!("    Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
4566            }
4567        }
4568        Ok(cfg) => {
4569            println!(
4570                "  {} {} merged server(s) configured",
4571                "·".dimmed(),
4572                cfg.servers.len()
4573            );
4574            for (name, server) in &cfg.servers {
4575                let status = doctor_check_mcp_server(server);
4576                let icon = match &status {
4577                    McpServerDoctorStatus::Ok(detail) => {
4578                        format!(
4579                            "  {} {name}: configuration valid; {}",
4580                            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4581                            detail
4582                        )
4583                    }
4584                    McpServerDoctorStatus::Warning(detail) => {
4585                        format!(
4586                            "  {} {name}: configuration warning; {}",
4587                            "!".truecolor(sky_r, sky_g, sky_b),
4588                            detail
4589                        )
4590                    }
4591                    McpServerDoctorStatus::Error(detail) => {
4592                        format!(
4593                            "  {} {name}: configuration invalid; {}",
4594                            "✗".truecolor(red_r, red_g, red_b),
4595                            detail
4596                        )
4597                    }
4598                };
4599                println!("{icon}");
4600                if !server.is_enabled() {
4601                    println!("      disabled; live health not checked");
4602                } else {
4603                    println!(
4604                        "      process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
4605                    );
4606                }
4607            }
4608            if probes.should_probe_mcp() {
4609                println!();
4610                println!(
4611                    "  {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
4612                    "!".truecolor(sky_r, sky_g, sky_b)
4613                );
4614                match crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
4615                    &mcp_config_path,
4616                    workspace,
4617                    std::sync::Arc::new(plugins.clone()),
4618                ) {
4619                    Ok(mut pool) => {
4620                        let errors = pool.connect_all().await;
4621                        let failed = errors
4622                            .iter()
4623                            .map(|(name, _)| name.as_str())
4624                            .collect::<std::collections::BTreeSet<_>>();
4625                        for (name, server) in &cfg.servers {
4626                            if !server.is_enabled() {
4627                                continue;
4628                            }
4629                            if failed.contains(name.as_str()) {
4630                                println!(
4631                                    "      {} {name}: process/protocol unreachable; error details omitted",
4632                                    "✗".truecolor(red_r, red_g, red_b)
4633                                );
4634                            } else {
4635                                println!(
4636                                    "      {} {name}: process reachable and protocol initialized; backend tool health not checked",
4637                                    "✓".truecolor(aqua_r, aqua_g, aqua_b)
4638                                );
4639                            }
4640                        }
4641                    }
4642                    Err(_) => println!(
4643                        "      {} live MCP probe could not load merged configuration; details omitted",
4644                        "✗".truecolor(red_r, red_g, red_b)
4645                    ),
4646                }
4647            } else {
4648                println!(
4649                    "    Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
4650                );
4651            }
4652        }
4653        Err(_) => {
4654            println!(
4655                "  {} MCP configuration could not be loaded; details omitted",
4656                "✗".truecolor(red_r, red_g, red_b)
4657            );
4658        }
4659    }
4660
4661    // Skills configuration
4662    println!();
4663    println!("{}", "Skills:".bold());
4664    let global_skills_dir = config.skills_dir();
4665    let agents_skills_dir = workspace.join(".agents").join("skills");
4666    let local_skills_dir = workspace.join("skills");
4667    let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
4668    // #432: cross-tool skill discovery dirs. Presence is reported here
4669    // even though they sit lower in the precedence chain so users can
4670    // see at a glance whether a `.opencode/skills/`, `.claude/skills/`,
4671    // `.cursor/skills/`, or global agentskills.io directory is contributing
4672    // to the merged catalogue.
4673    let opencode_skills_dir = workspace.join(".opencode").join("skills");
4674    let claude_skills_dir = workspace.join(".claude").join("skills");
4675    let selected_skills_dir = if agents_skills_dir.exists() {
4676        agents_skills_dir.clone()
4677    } else if local_skills_dir.exists() {
4678        local_skills_dir.clone()
4679    } else if config.skills_dir.is_none()
4680        && let Some(global_agents) = agents_global_skills_dir.as_ref()
4681        && global_agents.exists()
4682    {
4683        global_agents.clone()
4684    } else {
4685        global_skills_dir.clone()
4686    };
4687
4688    let describe_dir = |dir: &Path| -> usize {
4689        std::fs::read_dir(dir)
4690            .map(|entries| entries.filter_map(std::result::Result::ok).count())
4691            .unwrap_or(0)
4692    };
4693
4694    if local_skills_dir.exists() {
4695        println!(
4696            "  {} local skills dir found at {} ({} items)",
4697            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4698            crate::utils::display_path(&local_skills_dir),
4699            describe_dir(&local_skills_dir)
4700        );
4701    } else {
4702        println!(
4703            "  {} local skills dir not found at {}",
4704            "·".dimmed(),
4705            crate::utils::display_path(&local_skills_dir)
4706        );
4707    }
4708
4709    if agents_skills_dir.exists() {
4710        println!(
4711            "  {} .agents skills dir found at {} ({} items)",
4712            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4713            crate::utils::display_path(&agents_skills_dir),
4714            describe_dir(&agents_skills_dir)
4715        );
4716    } else {
4717        println!(
4718            "  {} .agents skills dir not found at {}",
4719            "·".dimmed(),
4720            crate::utils::display_path(&agents_skills_dir)
4721        );
4722    }
4723
4724    if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
4725        if agents_global_skills_dir.exists() {
4726            println!(
4727                "  {} global .agents skills dir found at {} ({} items)",
4728                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4729                crate::utils::display_path(agents_global_skills_dir),
4730                describe_dir(agents_global_skills_dir)
4731            );
4732        } else {
4733            println!(
4734                "  {} global .agents skills dir not found at {}",
4735                "·".dimmed(),
4736                crate::utils::display_path(agents_global_skills_dir)
4737            );
4738        }
4739    }
4740
4741    if global_skills_dir.exists() {
4742        println!(
4743            "  {} global skills dir found at {} ({} items)",
4744            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4745            crate::utils::display_path(&global_skills_dir),
4746            describe_dir(&global_skills_dir)
4747        );
4748    } else {
4749        println!(
4750            "  {} global skills dir not found at {}",
4751            "·".dimmed(),
4752            crate::utils::display_path(&global_skills_dir)
4753        );
4754    }
4755
4756    // #432: only print interop dirs when they're populated — empty
4757    // .opencode/.claude folders are common and would just clutter
4758    // the report with false-positive "absent" lines.
4759    if opencode_skills_dir.exists() {
4760        println!(
4761            "  {} .opencode skills dir found at {} ({} items)",
4762            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4763            crate::utils::display_path(&opencode_skills_dir),
4764            describe_dir(&opencode_skills_dir)
4765        );
4766    }
4767    if claude_skills_dir.exists() {
4768        println!(
4769            "  {} .claude skills dir found at {} ({} items)",
4770            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4771            crate::utils::display_path(&claude_skills_dir),
4772            describe_dir(&claude_skills_dir)
4773        );
4774    }
4775
4776    println!(
4777        "  {} selected skills dir: {}",
4778        "·".dimmed(),
4779        crate::utils::display_path(&selected_skills_dir)
4780    );
4781    if !agents_skills_dir.exists()
4782        && !local_skills_dir.exists()
4783        && !agents_global_skills_dir
4784            .as_ref()
4785            .is_some_and(|dir| dir.exists())
4786        && !global_skills_dir.exists()
4787    {
4788        println!("    Run `codewhale setup --skills` (or add --local for ./skills).");
4789    }
4790
4791    // Tools directory
4792    println!();
4793    println!("{}", "Tools:".bold());
4794    let tools_dir = default_tools_dir();
4795    if tools_dir.exists() {
4796        let count = count_dir_entries(&tools_dir);
4797        println!(
4798            "  {} tools dir found at {} ({} items)",
4799            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4800            crate::utils::display_path(&tools_dir),
4801            count
4802        );
4803    } else {
4804        println!(
4805            "  {} tools dir not found at {}",
4806            "·".dimmed(),
4807            crate::utils::display_path(&tools_dir)
4808        );
4809        println!("    Run `codewhale setup --tools` to scaffold a starter dir.");
4810    }
4811
4812    // Plugins directory
4813    println!();
4814    println!("{}", "Plugins:".bold());
4815    let plugins_dir = default_plugins_dir();
4816    if plugins_dir.exists() {
4817        let count = count_dir_entries(&plugins_dir);
4818        println!(
4819            "  {} plugins dir found at {} ({} items)",
4820            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4821            crate::utils::display_path(&plugins_dir),
4822            count
4823        );
4824    } else {
4825        println!(
4826            "  {} plugins dir not found at {}",
4827            "·".dimmed(),
4828            crate::utils::display_path(&plugins_dir)
4829        );
4830        println!("    Run `codewhale setup --plugins` to scaffold a starter dir.");
4831    }
4832
4833    // Storage surfaces (#422 / #440 / #500)
4834    println!();
4835    println!("{}", "Storage:".bold());
4836    if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
4837        let (present, count) = if spillover_root.is_dir() {
4838            (true, count_dir_entries(&spillover_root))
4839        } else {
4840            (false, 0)
4841        };
4842        if present {
4843            println!(
4844                "  {} tool-output spillover at {} ({} file{})",
4845                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4846                crate::utils::display_path(&spillover_root),
4847                count,
4848                if count == 1 { "" } else { "s" }
4849            );
4850        } else {
4851            println!(
4852                "  {} tool-output spillover dir not yet created at {}",
4853                "·".dimmed(),
4854                crate::utils::display_path(&spillover_root)
4855            );
4856        }
4857    }
4858    let stash = crate::composer_stash::diagnostic_stash_report();
4859    if let Some(stash_path) = stash.path.as_ref() {
4860        if let Some(error) = stash.error.as_deref() {
4861            println!(
4862                "  {} composer stash was not inspected at {}: {error}",
4863                "!".truecolor(sky_r, sky_g, sky_b),
4864                crate::utils::display_path(stash_path),
4865            );
4866        } else if stash.present {
4867            println!(
4868                "  {} composer stash at {} ({} parked draft{})",
4869                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4870                crate::utils::display_path(stash_path),
4871                stash.count,
4872                if stash.count == 1 { "" } else { "s" }
4873            );
4874        } else {
4875            println!(
4876                "  {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
4877                "·".dimmed()
4878            );
4879        }
4880    } else if let Some(error) = stash.error.as_deref() {
4881        println!(
4882            "  {} composer stash was not inspected: {error}",
4883            "!".truecolor(sky_r, sky_g, sky_b),
4884        );
4885    }
4886
4887    // Tool dependencies — probe external binaries that individual
4888    // tools rely on (Python for code_execution, pdftotext for PDF
4889    // reading) so users see explicit ✓/✗ rather than the tool failing
4890    // at execution time with "program not found". New in v0.8.31.
4891    println!();
4892    println!("{}", "Tool Dependencies:".bold());
4893
4894    match crate::dependencies::resolve_python_interpreter() {
4895        Some(name) => println!(
4896            "  {} Python: {} → code_execution tool registered",
4897            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4898            name
4899        ),
4900        None => {
4901            println!(
4902                "  {} Python: not found (tried {:?})",
4903                "✗".truecolor(red_r, red_g, red_b),
4904                crate::dependencies::PYTHON_CANDIDATES,
4905            );
4906            println!("    code_execution tool is NOT advertised to the model on this install.");
4907            println!("    Install Python 3 and ensure one of those names is on PATH:");
4908            match std::env::consts::OS {
4909                "macos" => {
4910                    println!("      brew install python@3.12   (or download from python.org)")
4911                }
4912                "linux" => println!(
4913                    "      sudo apt install python3    (Debian/Ubuntu) — or your distro's equivalent"
4914                ),
4915                "windows" => {
4916                    println!("      winget install Python.Python.3   (or download from python.org)")
4917                }
4918                other => println!("      install Python 3 for {other} from python.org"),
4919            }
4920        }
4921    }
4922
4923    match crate::dependencies::resolve_node() {
4924        Some(_) => println!(
4925            "  {} Node.js: present → js_execution tool registered",
4926            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4927        ),
4928        None => {
4929            println!(
4930                "  {} Node.js: not found (tried `node`)",
4931                "✗".truecolor(red_r, red_g, red_b),
4932            );
4933            println!("    js_execution tool is NOT advertised to the model on this install.");
4934            println!("    Install Node 18+ and ensure `node` is on PATH:");
4935            match std::env::consts::OS {
4936                "macos" => println!("      brew install node   (or download from nodejs.org)"),
4937                "linux" => println!(
4938                    "      sudo apt install nodejs    (Debian/Ubuntu) — or your distro's equivalent"
4939                ),
4940                "windows" => {
4941                    println!("      winget install OpenJS.NodeJS   (or download from nodejs.org)")
4942                }
4943                other => println!("      install Node.js for {other} from nodejs.org"),
4944            }
4945        }
4946    }
4947
4948    match crate::dependencies::resolve_pandoc() {
4949        Some(_) => println!(
4950            "  {} pandoc: present → pandoc_convert tool registered",
4951            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4952        ),
4953        None => {
4954            println!("  {} pandoc: not found (optional)", "·".dimmed(),);
4955            println!(
4956                "    pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:"
4957            );
4958            match std::env::consts::OS {
4959                "macos" => println!("      brew install pandoc"),
4960                "linux" => println!(
4961                    "      sudo apt install pandoc    (Debian/Ubuntu) — or your distro's equivalent"
4962                ),
4963                "windows" => {
4964                    println!("      winget install JohnMacFarlane.Pandoc")
4965                }
4966                other => println!("      install pandoc for {other} from pandoc.org"),
4967            }
4968        }
4969    }
4970
4971    match crate::dependencies::resolve_tesseract() {
4972        Some(_) => {
4973            if cfg!(target_os = "macos") {
4974                println!(
4975                    "  {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
4976                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4977                );
4978            } else {
4979                println!(
4980                    "  {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
4981                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4982                );
4983            }
4984        }
4985        None => {
4986            if cfg!(target_os = "macos") {
4987                println!(
4988                    "  {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
4989                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4990                );
4991                println!(
4992                    "    tesseract not found (optional; install only for alternate OCR packs)."
4993                );
4994            } else {
4995                println!("  {} tesseract: not found (optional)", "·".dimmed(),);
4996                println!(
4997                    "    image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
4998                );
4999                match std::env::consts::OS {
5000                    "macos" => println!("      brew install tesseract"),
5001                    "linux" => println!(
5002                        "      sudo apt install tesseract-ocr    (Debian/Ubuntu) — or your distro's equivalent"
5003                    ),
5004                    "windows" => println!("      winget install UB-Mannheim.TesseractOCR"),
5005                    other => {
5006                        println!("      install tesseract for {other} from tesseract-ocr.github.io")
5007                    }
5008                }
5009            }
5010        }
5011    }
5012
5013    // PDF text extraction is an optional integration. Codewhale itself stays
5014    // a single required executable; file and web tools report a typed
5015    // failed `binary_unavailable` result when Poppler is not installed.
5016    match crate::dependencies::resolve_pdftotext() {
5017        Some(_) => println!(
5018            "  {} pdftotext: available → PDF text extraction enabled",
5019            "✓".truecolor(aqua_r, aqua_g, aqua_b),
5020        ),
5021        None => {
5022            println!(
5023                "  {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
5024                "·".dimmed(),
5025            );
5026            match std::env::consts::OS {
5027                "macos" => println!("    Install via: brew install poppler"),
5028                "linux" => {
5029                    println!("    Install via: sudo apt install poppler-utils   (Debian/Ubuntu)")
5030                }
5031                "windows" => println!(
5032                    "    Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
5033                ),
5034                _ => {}
5035            }
5036        }
5037    }
5038
5039    // Terminal-quirk overrides currently active. Mirrors the env
5040    // signals checked by `Settings::apply_env_overrides` so users
5041    // can see at a glance which a11y/compat overrides fired.
5042    println!();
5043    println!("{}", "Terminal Quirks:".bold());
5044    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
5045    let term_program_lc = term_program.to_ascii_lowercase();
5046    let mut any_quirk = false;
5047    if matches!(term_program.as_str(), "vscode" | "ghostty") {
5048        println!(
5049            "  {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
5050            "•".truecolor(sky_r, sky_g, sky_b),
5051            term_program
5052        );
5053        any_quirk = true;
5054    }
5055    if term_program == "Termius"
5056        || std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
5057        || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
5058    {
5059        println!(
5060            "  {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
5061            "•".truecolor(sky_r, sky_g, sky_b)
5062        );
5063        any_quirk = true;
5064    }
5065    if term_program_lc.contains("ptyxis")
5066        || std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
5067    {
5068        println!(
5069            "  {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
5070            "•".truecolor(sky_r, sky_g, sky_b)
5071        );
5072        any_quirk = true;
5073    }
5074    if crate::settings::detected_legacy_windows_console_host() {
5075        println!(
5076            "  {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
5077            "•".truecolor(sky_r, sky_g, sky_b)
5078        );
5079        any_quirk = true;
5080    }
5081    if !any_quirk {
5082        println!(
5083            "  {} no env-driven terminal-quirk overrides active",
5084            "·".dimmed()
5085        );
5086    }
5087
5088    // Platform and sandbox checks
5089    println!();
5090    println!("{}", "Platform:".bold());
5091    println!("  OS: {}", std::env::consts::OS);
5092    println!("  Arch: {}", std::env::consts::ARCH);
5093
5094    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
5095        config.prefer_bwrap.unwrap_or(false),
5096    );
5097    if let Some(kind) = sandbox {
5098        println!(
5099            "  {} sandbox available: {}",
5100            "✓".truecolor(aqua_r, aqua_g, aqua_b),
5101            kind
5102        );
5103    } else {
5104        println!(
5105            "  {} sandbox not available (commands run best-effort)",
5106            "!".truecolor(sky_r, sky_g, sky_b)
5107        );
5108    }
5109
5110    println!();
5111    println!(
5112        "{}",
5113        "All checks complete!"
5114            .truecolor(aqua_r, aqua_g, aqua_b)
5115            .bold()
5116    );
5117}
5118
5119const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
5120    "sessions",
5121    "tasks",
5122    "skills",
5123    "slop_ledger",
5124    "trophies",
5125    "catalog",
5126    "review-receipts",
5127    "config.toml",
5128    "settings.toml",
5129    "mcp.json",
5130];
5131const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
5132const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
5133
5134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5135enum DoctorLegacyStateStatus {
5136    PrimaryOnly,
5137    LegacyOnly,
5138    Both,
5139    Absent,
5140}
5141
5142impl DoctorLegacyStateStatus {
5143    fn as_str(self) -> &'static str {
5144        match self {
5145            Self::PrimaryOnly => "primary_only",
5146            Self::LegacyOnly => "legacy_only",
5147            Self::Both => "both",
5148            Self::Absent => "absent",
5149        }
5150    }
5151}
5152
5153#[derive(Debug, Clone)]
5154struct DoctorLegacyStateEntry {
5155    name: &'static str,
5156    primary_path: PathBuf,
5157    legacy_path: PathBuf,
5158    primary_present: bool,
5159    legacy_present: bool,
5160    status: DoctorLegacyStateStatus,
5161}
5162
5163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5164enum DoctorSessionRecoveryStatus {
5165    Isolated,
5166    NoLegacySessions,
5167    MigrationPending,
5168    MigrationIncomplete,
5169    MigrationComplete,
5170    ScanFailed,
5171}
5172
5173impl DoctorSessionRecoveryStatus {
5174    fn as_str(self) -> &'static str {
5175        match self {
5176            Self::Isolated => "isolated",
5177            Self::NoLegacySessions => "no_legacy_sessions",
5178            Self::MigrationPending => "migration_pending",
5179            Self::MigrationIncomplete => "migration_incomplete",
5180            Self::MigrationComplete => "migration_complete",
5181            Self::ScanFailed => "scan_failed",
5182        }
5183    }
5184}
5185
5186#[derive(Debug, Clone)]
5187struct DoctorRecoverableSessionEntry {
5188    name: PathBuf,
5189    source_path: PathBuf,
5190    destination_path: PathBuf,
5191}
5192
5193#[derive(Debug, Clone)]
5194struct DoctorSessionRecoveryReport {
5195    status: DoctorSessionRecoveryStatus,
5196    primary_sessions_path: PathBuf,
5197    legacy_sessions_path: PathBuf,
5198    codewhale_home_is_explicit: bool,
5199    legacy_session_file_count: usize,
5200    already_present_file_count: usize,
5201    recoverable_file_count: usize,
5202    /// Bounded filename/path sample; the total is `recoverable_file_count`.
5203    recoverable: Vec<DoctorRecoverableSessionEntry>,
5204    error: Option<String>,
5205}
5206
5207impl DoctorSessionRecoveryReport {
5208    fn needs_attention(&self) -> bool {
5209        matches!(
5210            self.status,
5211            DoctorSessionRecoveryStatus::MigrationPending
5212                | DoctorSessionRecoveryStatus::MigrationIncomplete
5213                | DoctorSessionRecoveryStatus::ScanFailed
5214        )
5215    }
5216}
5217
5218fn doctor_legacy_state_status(
5219    primary_present: bool,
5220    legacy_present: bool,
5221) -> DoctorLegacyStateStatus {
5222    match (primary_present, legacy_present) {
5223        (true, false) => DoctorLegacyStateStatus::PrimaryOnly,
5224        (false, true) => DoctorLegacyStateStatus::LegacyOnly,
5225        (true, true) => DoctorLegacyStateStatus::Both,
5226        (false, false) => DoctorLegacyStateStatus::Absent,
5227    }
5228}
5229
5230fn doctor_state_roots() -> (PathBuf, PathBuf) {
5231    let code_home =
5232        codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
5233    let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
5234        code_home.join(codewhale_config::LEGACY_APP_DIR)
5235    } else {
5236        codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
5237    };
5238    (code_home, legacy_home)
5239}
5240
5241fn doctor_legacy_state_report(
5242    primary_root: &Path,
5243    legacy_root: &Path,
5244) -> Vec<DoctorLegacyStateEntry> {
5245    DOCTOR_LEGACY_STATE_ITEMS
5246        .iter()
5247        .copied()
5248        .map(|name| {
5249            let primary_path = primary_root.join(name);
5250            let legacy_path = legacy_root.join(name);
5251            let primary_present = primary_path.exists();
5252            let legacy_present = legacy_path.exists();
5253            let status = doctor_legacy_state_status(primary_present, legacy_present);
5254            DoctorLegacyStateEntry {
5255                name,
5256                primary_path,
5257                legacy_path,
5258                primary_present,
5259                legacy_present,
5260                status,
5261            }
5262        })
5263        .collect()
5264}
5265
5266/// Compare legacy and primary session filenames without opening session files.
5267///
5268/// This is deliberately separate from `SessionManager::default_location()`:
5269/// constructing the manager can trigger the additive legacy migration, while
5270/// doctor must remain a read-only diagnostic. Session history is stored as
5271/// top-level JSON files. Directories (including `checkpoints`) and symlinks
5272/// observed during the scan are ignored, so the diagnostic does not
5273/// intentionally traverse checkpoint internals or link targets. These checks
5274/// are best-effort observations, not a race-free no-follow guarantee.
5275/// A matching filename is only a regular-file counterpart check: doctor does
5276/// not parse or compare session descriptors.
5277fn doctor_session_recovery_report(
5278    primary_root: &Path,
5279    legacy_root: &Path,
5280    codewhale_home_is_explicit: bool,
5281) -> DoctorSessionRecoveryReport {
5282    let primary_sessions_path = primary_root.join("sessions");
5283    let legacy_sessions_path = legacy_root.join("sessions");
5284    let mut report = DoctorSessionRecoveryReport {
5285        status: DoctorSessionRecoveryStatus::NoLegacySessions,
5286        primary_sessions_path,
5287        legacy_sessions_path,
5288        codewhale_home_is_explicit,
5289        legacy_session_file_count: 0,
5290        already_present_file_count: 0,
5291        recoverable_file_count: 0,
5292        recoverable: Vec::new(),
5293        error: None,
5294    };
5295
5296    if codewhale_home_is_explicit {
5297        report.status = DoctorSessionRecoveryStatus::Isolated;
5298        return report;
5299    }
5300
5301    let legacy_root_is_present =
5302        match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
5303            Ok(present) => present,
5304            Err(error) => {
5305                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5306                report.error = Some(error);
5307                return report;
5308            }
5309        };
5310    if !legacy_root_is_present {
5311        return report;
5312    }
5313    if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
5314        report.status = DoctorSessionRecoveryStatus::ScanFailed;
5315        report.error = Some(error);
5316        return report;
5317    }
5318
5319    let legacy_sessions_are_present = match doctor_session_directory_is_safe(
5320        &report.legacy_sessions_path,
5321        "legacy sessions root",
5322    ) {
5323        Ok(present) => present,
5324        Err(error) => {
5325            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5326            report.error = Some(error);
5327            return report;
5328        }
5329    };
5330    if !legacy_sessions_are_present {
5331        return report;
5332    }
5333    let primary_sessions_are_present = match doctor_session_directory_is_safe(
5334        &report.primary_sessions_path,
5335        "primary sessions root",
5336    ) {
5337        Ok(present) => present,
5338        Err(error) => {
5339            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5340            report.error = Some(error);
5341            return report;
5342        }
5343    };
5344
5345    let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
5346        Ok(entries) => entries,
5347        Err(err) => {
5348            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5349            report.error = Some(format!(
5350                "could not inspect legacy session filenames at {}: {err}",
5351                crate::utils::display_path(&report.legacy_sessions_path)
5352            ));
5353            return report;
5354        }
5355    };
5356
5357    for entry in entries {
5358        let entry = match entry {
5359            Ok(entry) => entry,
5360            Err(err) => {
5361                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5362                report.error = Some(format!(
5363                    "could not inspect an entry under {}: {err}",
5364                    crate::utils::display_path(&report.legacy_sessions_path)
5365                ));
5366                return report;
5367            }
5368        };
5369        let file_type = match entry.file_type() {
5370            Ok(file_type) => file_type,
5371            Err(err) => {
5372                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5373                report.error = Some(format!(
5374                    "could not inspect legacy session entry metadata under {}: {err}",
5375                    crate::utils::display_path(&report.legacy_sessions_path)
5376                ));
5377                return report;
5378            }
5379        };
5380        if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
5381            continue;
5382        }
5383
5384        report.legacy_session_file_count += 1;
5385        let name = PathBuf::from(entry.file_name());
5386        let destination_path = report.primary_sessions_path.join(&name);
5387        match std::fs::symlink_metadata(&destination_path) {
5388            Ok(metadata) if metadata.file_type().is_file() => {
5389                report.already_present_file_count += 1;
5390            }
5391            Ok(metadata) => {
5392                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5393                let shape = if metadata.file_type().is_symlink() {
5394                    "destination session entry is a symlink"
5395                } else {
5396                    "destination session entry is not a regular file"
5397                };
5398                report.error = Some(format!(
5399                    "could not inspect destination session metadata at {}: {shape}",
5400                    crate::utils::display_path(&destination_path)
5401                ));
5402                return report;
5403            }
5404            Err(err) if err.kind() == io::ErrorKind::NotFound => {
5405                report.recoverable_file_count += 1;
5406                record_doctor_recoverable_session(
5407                    &mut report.recoverable,
5408                    DoctorRecoverableSessionEntry {
5409                        source_path: entry.path(),
5410                        destination_path,
5411                        name,
5412                    },
5413                );
5414            }
5415            Err(err) => {
5416                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5417                report.error = Some(format!(
5418                    "could not inspect destination metadata at {}: {err}",
5419                    crate::utils::display_path(&destination_path)
5420                ));
5421                return report;
5422            }
5423        }
5424    }
5425
5426    report.status = if report.legacy_session_file_count == 0 {
5427        DoctorSessionRecoveryStatus::NoLegacySessions
5428    } else if report.recoverable_file_count == 0 {
5429        DoctorSessionRecoveryStatus::MigrationComplete
5430    } else if primary_sessions_are_present {
5431        DoctorSessionRecoveryStatus::MigrationIncomplete
5432    } else {
5433        DoctorSessionRecoveryStatus::MigrationPending
5434    };
5435    report
5436}
5437
5438/// Validate a session-state directory from observed metadata.
5439///
5440/// `doctor` only compares top-level filenames. It rejects a state-root or
5441/// sessions-root symlink observed during inspection rather than using it for a
5442/// recovery suggestion. This is a best-effort observation, not a race-free
5443/// no-follow guarantee. Missing paths are normal on a fresh install and are
5444/// reported as `false`.
5445fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
5446    let metadata = match std::fs::symlink_metadata(path) {
5447        Ok(metadata) => metadata,
5448        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
5449        Err(error) => {
5450            return Err(format!(
5451                "could not inspect {label} at {}: {error}",
5452                crate::utils::display_path(path)
5453            ));
5454        }
5455    };
5456    if metadata.file_type().is_symlink() {
5457        return Err(format!(
5458            "could not inspect {label} at {}: path is a symlink",
5459            crate::utils::display_path(path)
5460        ));
5461    }
5462    if !metadata.file_type().is_dir() {
5463        return Err(format!(
5464            "could not inspect {label} at {}: path is not a directory",
5465            crate::utils::display_path(path)
5466        ));
5467    }
5468    Ok(true)
5469}
5470
5471/// Keep the report bounded while preserving a deterministic, lexical sample.
5472/// `read_dir` order is platform- and filesystem-dependent, so retaining the
5473/// first entries encountered would make the JSON and human receipts drift.
5474fn record_doctor_recoverable_session(
5475    recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
5476    entry: DoctorRecoverableSessionEntry,
5477) {
5478    let insert_at = recoverable
5479        .binary_search_by(|existing| existing.name.cmp(&entry.name))
5480        .unwrap_or_else(|index| index);
5481    if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
5482        && insert_at == recoverable.len()
5483    {
5484        return;
5485    }
5486    recoverable.insert(insert_at, entry);
5487    if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
5488        recoverable.pop();
5489    }
5490}
5491
5492fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
5493    entry.name != "sessions"
5494        && matches!(
5495            entry.status,
5496            DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
5497        )
5498}
5499
5500fn print_doctor_legacy_state_report(
5501    report: &[DoctorLegacyStateEntry],
5502    session_recovery: &DoctorSessionRecoveryReport,
5503    ok_rgb: (u8, u8, u8),
5504    warn_rgb: (u8, u8, u8),
5505) {
5506    use colored::Colorize;
5507
5508    let attention: Vec<_> = report
5509        .iter()
5510        .filter(|entry| legacy_state_needs_attention(entry))
5511        .collect();
5512    if attention.is_empty()
5513        && !session_recovery.needs_attention()
5514        && session_recovery.status != DoctorSessionRecoveryStatus::Isolated
5515    {
5516        println!(
5517            "  {} legacy state: no known .deepseek entries need migration",
5518            "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5519        );
5520    } else if !attention.is_empty() {
5521        println!(
5522            "  {} legacy state needs review:",
5523            "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5524        );
5525        for entry in attention {
5526            match entry.status {
5527                DoctorLegacyStateStatus::LegacyOnly => {
5528                    println!(
5529                        "    {} {} exists but {} is missing",
5530                        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5531                        crate::utils::display_path(&entry.legacy_path),
5532                        crate::utils::display_path(&entry.primary_path),
5533                    );
5534                }
5535                DoctorLegacyStateStatus::Both => {
5536                    println!(
5537                        "    {} {} exists alongside primary {}; legacy data may still need review",
5538                        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5539                        crate::utils::display_path(&entry.legacy_path),
5540                        crate::utils::display_path(&entry.primary_path),
5541                    );
5542                }
5543                DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
5544            }
5545        }
5546        println!(
5547            "    Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
5548        );
5549    }
5550
5551    print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
5552}
5553
5554fn print_doctor_session_recovery_report(
5555    report: &DoctorSessionRecoveryReport,
5556    ok_rgb: (u8, u8, u8),
5557    warn_rgb: (u8, u8, u8),
5558) {
5559    use colored::Colorize;
5560
5561    match report.status {
5562        DoctorSessionRecoveryStatus::Isolated => {
5563            println!(
5564                "  {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
5565                "·".dimmed()
5566            );
5567            println!(
5568                "    This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
5569            );
5570        }
5571        DoctorSessionRecoveryStatus::NoLegacySessions => {
5572            println!(
5573                "  {} legacy sessions: no top-level session JSON files found",
5574                "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5575            );
5576        }
5577        DoctorSessionRecoveryStatus::MigrationComplete => {
5578            println!(
5579                "  {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
5580                "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
5581                report.legacy_session_file_count,
5582                crate::utils::display_path(&report.primary_sessions_path),
5583            );
5584        }
5585        DoctorSessionRecoveryStatus::MigrationPending
5586        | DoctorSessionRecoveryStatus::MigrationIncomplete => {
5587            let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
5588                "migration is incomplete"
5589            } else {
5590                "migration has not completed"
5591            };
5592            println!(
5593                "  {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
5594                "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5595                report.recoverable_file_count,
5596                crate::utils::display_path(&report.primary_sessions_path),
5597            );
5598            for entry in report
5599                .recoverable
5600                .iter()
5601                .take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
5602            {
5603                println!(
5604                    "    {} {} -> {}",
5605                    "·".dimmed(),
5606                    crate::utils::display_path(&entry.source_path),
5607                    crate::utils::display_path(&entry.destination_path),
5608                );
5609            }
5610            if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
5611                println!(
5612                    "    · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
5613                    report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
5614                );
5615            }
5616            println!("    Safe recovery:");
5617            println!(
5618                "      1. Back up {} and {} (if present).",
5619                crate::utils::display_path(&report.legacy_sessions_path),
5620                crate::utils::display_path(&report.primary_sessions_path),
5621            );
5622            println!(
5623                "      2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
5624            );
5625            println!(
5626                "      3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
5627            );
5628        }
5629        DoctorSessionRecoveryStatus::ScanFailed => {
5630            println!(
5631                "  {} legacy sessions: recovery diagnostic could not complete",
5632                "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5633            );
5634            if let Some(error) = report.error.as_deref() {
5635                println!("    {error}");
5636            }
5637            println!(
5638                "    Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
5639            );
5640        }
5641    }
5642    if report.status != DoctorSessionRecoveryStatus::Isolated {
5643        println!(
5644            "    Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
5645        );
5646    }
5647}
5648
5649fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
5650    use serde_json::json;
5651
5652    let recoverable: Vec<_> = report
5653        .recoverable
5654        .iter()
5655        .take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
5656        .map(|entry| {
5657            json!({
5658                "name": entry.name.display().to_string(),
5659                "source_path": entry.source_path.display().to_string(),
5660                "destination_path": entry.destination_path.display().to_string(),
5661            })
5662        })
5663        .collect();
5664
5665    json!({
5666        "status": report.status.as_str(),
5667        "needs_attention": report.needs_attention(),
5668        "read_only": true,
5669        "chat_contents_read": false,
5670        "checkpoint_internals_scanned": false,
5671        "session_descriptors_compared": false,
5672        "counterpart_check": "top_level_filename_and_regular_file_only",
5673        "codewhale_home_is_explicit": report.codewhale_home_is_explicit,
5674        "legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
5675        "primary_sessions_path": report.primary_sessions_path.display().to_string(),
5676        "legacy_session_file_count": report.legacy_session_file_count,
5677        "already_present_file_count": report.already_present_file_count,
5678        "recoverable_file_count": report.recoverable_file_count,
5679        "recoverable_files": recoverable,
5680        "recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
5681        "error": report.error,
5682        "recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
5683            Some("codewhale sessions")
5684        } else {
5685            None
5686        },
5687    })
5688}
5689
5690fn doctor_legacy_state_json(
5691    primary_root: &Path,
5692    legacy_root: &Path,
5693    report: &[DoctorLegacyStateEntry],
5694    session_recovery: &DoctorSessionRecoveryReport,
5695) -> serde_json::Value {
5696    use serde_json::json;
5697
5698    let legacy_only = report
5699        .iter()
5700        .filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
5701        .count();
5702    let both = report
5703        .iter()
5704        .filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
5705        .count();
5706    let entries: Vec<_> = report
5707        .iter()
5708        .map(|entry| {
5709            json!({
5710                "name": entry.name,
5711                "primary_path": entry.primary_path.display().to_string(),
5712                "legacy_path": entry.legacy_path.display().to_string(),
5713                "primary_present": entry.primary_present,
5714                "legacy_present": entry.legacy_present,
5715                "status": entry.status.as_str(),
5716            })
5717        })
5718        .collect();
5719
5720    json!({
5721        "primary_root": primary_root.display().to_string(),
5722        "legacy_root": legacy_root.display().to_string(),
5723        "needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
5724        "legacy_only_count": legacy_only,
5725        "dual_present_count": both,
5726        "session_recovery": doctor_session_recovery_json(session_recovery),
5727        "entries": entries,
5728    })
5729}
5730
5731fn doctor_setup_state(
5732    config: &Config,
5733    workspace: &Path,
5734) -> (codewhale_config::SetupState, &'static str) {
5735    if let Ok(Some(state)) = codewhale_config::SetupState::load() {
5736        return (state, "persisted");
5737    }
5738
5739    (
5740        codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
5741            config, workspace,
5742        )),
5743        "derived",
5744    )
5745}
5746
5747fn doctor_inherited_setup_facts(
5748    config: &Config,
5749    workspace: &Path,
5750) -> codewhale_config::InheritedConfigFacts {
5751    let user_constitution = codewhale_config::UserConstitution::load().ok();
5752    let user_constitution_validity = user_constitution.as_ref().map_or(
5753        codewhale_config::ConstitutionValidity::Unknown,
5754        codewhale_config::UserConstitutionLoad::validity,
5755    );
5756    let has_user_constitution = user_constitution
5757        .as_ref()
5758        .is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
5759    let has_expert_override = codewhale_config::codewhale_home()
5760        .ok()
5761        .map(|home| home.join(Path::new(crate::prompts::CONSTITUTION_OVERRIDE_FILE)))
5762        .is_some_and(|path| path.exists());
5763
5764    codewhale_config::InheritedConfigFacts {
5765        language: None,
5766        has_provider_route: !config.default_model().trim().is_empty(),
5767        has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
5768        trust_chosen: !crate::tui::onboarding::needs_trust(workspace),
5769        has_expert_override,
5770        has_user_constitution,
5771        user_constitution_validity,
5772    }
5773}
5774
5775fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
5776    resolve_credential_diagnostic(config)
5777        .availability
5778        .certifies_ready()
5779}
5780
5781fn print_doctor_setup_report(
5782    config: &Config,
5783    workspace: &Path,
5784    state: &codewhale_config::SetupState,
5785    source: &str,
5786    ok_rgb: (u8, u8, u8),
5787    warn_rgb: (u8, u8, u8),
5788) {
5789    use colored::Colorize;
5790
5791    let credential = resolve_credential_diagnostic(config);
5792    // Setup completion is persisted independently from credential probing.
5793    // Ordinary doctor deliberately does not read environment values or the
5794    // durable secret store, so `not_probed` must not erase a completed lane.
5795    let first_run_ready = state.first_run_ready();
5796    let update_ready = state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION);
5797    let operate_ready = state.operate_ready();
5798    let first_run_icon = if first_run_ready {
5799        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5800    } else {
5801        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5802    };
5803    let update_icon = if update_ready {
5804        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5805    } else {
5806        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5807    };
5808    let operate_icon = if operate_ready {
5809        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5810    } else {
5811        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5812    };
5813
5814    println!();
5815    println!("{}", "Setup State:".bold());
5816    println!("  · source: {source}");
5817    println!(
5818        "  · credential: source={}, availability={}",
5819        doctor_api_key_source_label(credential.source),
5820        credential.availability.label()
5821    );
5822    println!(
5823        "  {first_run_icon} first-run: {}",
5824        doctor_ready_label(first_run_ready)
5825    );
5826    println!(
5827        "  {update_icon} update checkpoint {}: {}",
5828        crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
5829        doctor_ready_label(update_ready)
5830    );
5831    println!(
5832        "  {operate_icon} operate/fleet: {}",
5833        doctor_ready_label(operate_ready)
5834    );
5835    println!(
5836        "  · constitution autonomy: {} (guidance only)",
5837        doctor_constitution_autonomy_preference_id()
5838    );
5839    println!(
5840        "  · runtime posture: {}",
5841        doctor_runtime_posture_line(config, workspace)
5842    );
5843    let consistency = doctor_setup_consistency(state, source);
5844    if consistency["status"] == "inconsistent" {
5845        let issues = consistency["issues"]
5846            .as_array()
5847            .map(|issues| {
5848                issues
5849                    .iter()
5850                    .filter_map(serde_json::Value::as_str)
5851                    .collect::<Vec<_>>()
5852                    .join(", ")
5853            })
5854            .unwrap_or_default();
5855        println!(
5856            "  {} consistency: half-applied setup detected ({issues}) — {}",
5857            "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5858            consistency["repair"].as_str().unwrap_or("/setup"),
5859        );
5860    }
5861    println!(
5862        "  · 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)"
5863    );
5864    for step in codewhale_config::SetupStep::ALL {
5865        let entry = state.steps.get(&step);
5866        let required = entry.is_some_and(|entry| entry.required);
5867        let version = entry.and_then(|entry| entry.version.as_deref());
5868        let result = entry.and_then(|entry| entry.result.as_deref());
5869        let required_label = if required { "required" } else { "optional" };
5870        let version_label = version.unwrap_or("unversioned");
5871        let result_label = result.unwrap_or("no result");
5872        println!(
5873            "    · {}: {} ({required_label}, {version_label}, {result_label})",
5874            setup_step_id(step),
5875            setup_status_id(state.status(step))
5876        );
5877    }
5878}
5879
5880/// #5098: print every profile id that exists in more than one roster layer
5881/// so a personal/config edit that loses to project is visible without
5882/// opening `/fleet`.
5883fn print_doctor_fleet_roster_layers(config: &Config, workspace: &Path) {
5884    use colored::Colorize;
5885
5886    let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
5887    println!();
5888    println!("{}", "Fleet roster layers:".bold());
5889    let lines = roster.doctor_layer_lines();
5890    if lines.is_empty() {
5891        println!("  · no profile id is defined in more than one layer");
5892        return;
5893    }
5894    for line in lines {
5895        if let Some(layer) = line.strip_prefix("  ") {
5896            println!("      {layer}");
5897        } else {
5898            println!("  · {line}");
5899        }
5900    }
5901}
5902
5903fn doctor_ready_label(ready: bool) -> &'static str {
5904    if ready { "ready" } else { "needs action" }
5905}
5906
5907/// Detect half-applied setup persistence (#3410).
5908///
5909/// The setup transaction writes `constitution.json` and `setup_state.json`
5910/// together, so a persisted state that points at a user-global constitution
5911/// which is missing or unusable on disk means a write was interrupted or a
5912/// file was removed out-of-band. Stale `.tmp*` files in `$CODEWHALE_HOME`
5913/// are the other fingerprint of an interrupted atomic write.
5914fn doctor_setup_consistency(
5915    state: &codewhale_config::SetupState,
5916    source: &str,
5917) -> serde_json::Value {
5918    use serde_json::json;
5919
5920    let mut issues: Vec<&'static str> = Vec::new();
5921
5922    if source == "persisted"
5923        && matches!(
5924            state.constitution_source,
5925            codewhale_config::ConstitutionSource::UserGlobal
5926        )
5927    {
5928        match codewhale_config::UserConstitution::load() {
5929            Ok(codewhale_config::UserConstitutionLoad::Missing) => {
5930                issues.push("setup_state_points_at_missing_user_constitution");
5931            }
5932            Ok(codewhale_config::UserConstitutionLoad::Empty) => {
5933                issues.push("user_constitution_empty");
5934            }
5935            Ok(codewhale_config::UserConstitutionLoad::Invalid(_)) => {
5936                issues.push("user_constitution_invalid");
5937            }
5938            Ok(codewhale_config::UserConstitutionLoad::Unreadable(_)) | Err(_) => {
5939                issues.push("user_constitution_unreadable");
5940            }
5941            Ok(codewhale_config::UserConstitutionLoad::Loaded(_)) => {}
5942        }
5943    }
5944
5945    if doctor_home_has_stale_setup_temp_files() {
5946        issues.push("stale_setup_temp_files_in_codewhale_home");
5947    }
5948
5949    json!({
5950        "status": if issues.is_empty() { "consistent" } else { "inconsistent" },
5951        "issues": issues,
5952        "repair": "/constitution to rebuild standing law, /setup to re-run the checkpoint",
5953    })
5954}
5955
5956fn doctor_home_has_stale_setup_temp_files() -> bool {
5957    let Ok(home) = codewhale_config::codewhale_home() else {
5958        return false;
5959    };
5960    let Ok(entries) = std::fs::read_dir(&home) else {
5961        return false;
5962    };
5963    entries.flatten().any(|entry| {
5964        entry.file_name().to_string_lossy().starts_with(".tmp")
5965            && entry.file_type().is_ok_and(|kind| kind.is_file())
5966    })
5967}
5968
5969fn doctor_constitution_autonomy_preference() -> codewhale_config::AutonomyPreference {
5970    codewhale_config::UserConstitution::load()
5971        .ok()
5972        .and_then(|load| {
5973            load.constitution()
5974                .map(|constitution| constitution.autonomy_preference)
5975        })
5976        .unwrap_or(codewhale_config::AutonomyPreference::Unspecified)
5977}
5978
5979fn doctor_constitution_autonomy_preference_id() -> &'static str {
5980    autonomy_preference_id(doctor_constitution_autonomy_preference())
5981}
5982
5983fn autonomy_preference_id(preference: codewhale_config::AutonomyPreference) -> &'static str {
5984    match preference {
5985        codewhale_config::AutonomyPreference::Unspecified => "unspecified",
5986        codewhale_config::AutonomyPreference::Cautious => "cautious",
5987        codewhale_config::AutonomyPreference::Balanced => "balanced",
5988        codewhale_config::AutonomyPreference::Autonomous => "autonomous",
5989    }
5990}
5991
5992fn doctor_runtime_default_mode() -> (String, &'static str) {
5993    match crate::settings::Settings::load_read_only() {
5994        Ok(settings) => (settings.default_mode, "settings"),
5995        Err(_) => (crate::settings::Settings::default().default_mode, "default"),
5996    }
5997}
5998
5999/// TUI settings posture used when `config.approval_policy` is unset.
6000/// Doctor must surface this separately so a saved Full Access baseline is not
6001/// misreported as the config default `approval_policy=on-request`.
6002fn doctor_runtime_permission_posture() -> (String, &'static str) {
6003    match crate::settings::Settings::load_read_only() {
6004        Ok(settings) => match settings.permission_posture {
6005            Some(posture) => (posture, "settings"),
6006            None => ("unset".to_string(), "default"),
6007        },
6008        Err(_) => ("unset".to_string(), "default"),
6009    }
6010}
6011
6012fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
6013    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
6014    let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
6015    let approval = config.approval_policy.as_deref().unwrap_or("on-request");
6016    let approval_source = if config.approval_policy.is_some() {
6017        "config"
6018    } else {
6019        "default"
6020    };
6021    let allow_shell = config.interactive_allow_shell();
6022    let allow_shell_source = if config.allow_shell.is_some() {
6023        "config"
6024    } else {
6025        "interactive default"
6026    };
6027    let sandbox = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6028    let sandbox_source = if config.sandbox_mode.is_some() {
6029        "config"
6030    } else {
6031        "default"
6032    };
6033    let network = config
6034        .network
6035        .as_ref()
6036        .map_or("prompt", |policy| policy.default.as_str());
6037    let network_source = if config.network.is_some() {
6038        "config"
6039    } else {
6040        "default"
6041    };
6042    let trust = if crate::tui::onboarding::needs_trust(workspace) {
6043        "workspace not elevated"
6044    } else {
6045        "workspace trusted"
6046    };
6047    let (telemetry_on, telemetry_source) = doctor_runtime_telemetry(config);
6048    let telemetry = if telemetry_on { "on" } else { "off" };
6049
6050    format!(
6051        "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}), telemetry={telemetry} ({telemetry_source}), trust={trust}"
6052    )
6053}
6054
6055/// Resolved telemetry consent and where it came from (#5441).
6056///
6057/// Telemetry ships ON by default, and no posture surface reported that — a
6058/// user who never opted in saw nothing saying "telemetry: on (default)".
6059/// Truth change only: the resolution itself is [`codewhale_config`]'s.
6060fn doctor_runtime_telemetry(config: &Config) -> (bool, &'static str) {
6061    let (on, source) = codewhale_config::resolved_telemetry_consent(config.telemetry);
6062    (on, source.as_str())
6063}
6064
6065fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6066    use serde_json::json;
6067
6068    let provider = config.api_provider();
6069    // Doctor reports configured routing posture only. In particular it must
6070    // never consume an external-file grant merely to label Fleet readiness.
6071    let credential = resolve_credential_diagnostic(config);
6072    let has_credentials_or_local = credential.availability.certifies_ready();
6073    let subagents_enabled = config.subagents_enabled_for_provider(provider);
6074    let disabled_reason = if subagents_enabled {
6075        None
6076    } else {
6077        Some(
6078            config
6079                .subagents_disabled_reason()
6080                .unwrap_or("disabled for active provider"),
6081        )
6082    };
6083    let max_subagents = config.max_subagents_for_provider(provider);
6084    let launch_concurrency = config.launch_concurrency_for_provider(provider);
6085    let max_admitted = config.max_admitted_subagents_for_provider(provider);
6086    let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
6087    let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
6088    let mut built_in_members = 0usize;
6089    let mut plugin_members = 0usize;
6090    let mut config_members = 0usize;
6091    let mut personal_members = 0usize;
6092    let mut workspace_members = 0usize;
6093    for member in roster.members() {
6094        match member.origin {
6095            crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
6096            crate::fleet::roster::ProfileOrigin::Plugin => plugin_members += 1,
6097            crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
6098            crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
6099            crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
6100        }
6101    }
6102    let roster_members = roster.members().len();
6103    let custom_members = plugin_members + config_members + personal_members + workspace_members;
6104    let roster_ready = roster_members > 0;
6105    let runtime_ready =
6106        subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
6107    let multi_layer: Vec<serde_json::Value> = roster
6108        .multi_layer_report()
6109        .into_iter()
6110        .map(|entry| {
6111            json!({
6112                "id": entry.id,
6113                "effective": entry.effective.to_string(),
6114                "effective_path": entry.effective_path.display().to_string(),
6115                "layers": entry
6116                    .layers
6117                    .iter()
6118                    .map(|layer| {
6119                        json!({
6120                            "origin": layer.origin.to_string(),
6121                            "path": layer.source.display().to_string(),
6122                            "wins": layer.wins,
6123                        })
6124                    })
6125                    .collect::<Vec<_>>(),
6126            })
6127        })
6128        .collect();
6129
6130    json!({
6131        "ready": has_credentials_or_local && runtime_ready && roster_ready,
6132        "provider": {
6133            "id": config.provider_identity_for(provider),
6134            "auth": {
6135                "present_or_local": has_credentials_or_local,
6136                "source": doctor_api_key_source_label(credential.source),
6137                "availability": credential.availability.label(),
6138            },
6139        },
6140        "worker_runtime": {
6141            "ready": runtime_ready,
6142            "enabled": subagents_enabled,
6143            "disabled_reason": disabled_reason,
6144            "max_subagents": max_subagents,
6145            "launch_concurrency": launch_concurrency,
6146            "max_admitted": max_admitted,
6147            "max_spawn_depth": max_spawn_depth,
6148            "host_enforced_workflow_receipts": true,
6149        },
6150        "roster": {
6151            "ready": roster_ready,
6152            "total": roster_members,
6153            "built_in": built_in_members,
6154            "config": config_members,
6155            "personal": personal_members,
6156            "workspace": workspace_members,
6157            "custom": custom_members,
6158            "starter_roster_available": built_in_members > 0,
6159            "readiness_rule": "built-in starter roster or custom roster",
6160            "multi_layer": multi_layer,
6161        },
6162        "concurrency": {
6163            "launch_concurrency": launch_concurrency,
6164            "max_subagents": max_subagents,
6165            "max_admitted": max_admitted,
6166            "plan_limit_probed": false,
6167        },
6168    })
6169}
6170
6171fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
6172    use serde_json::json;
6173
6174    let provider = config.api_provider();
6175    let credential = resolve_credential_diagnostic(config);
6176    let auth_present_or_local = credential.availability.certifies_ready();
6177    let credential_help = provider.credential_help();
6178    let credential_url = credential_help
6179        .credential_url
6180        .map(crate::doctor::structural_url_authority);
6181    let credential_docs_url = credential_help
6182        .docs_url
6183        .map(crate::doctor::structural_url_authority);
6184
6185    json!({
6186        "provider": {
6187            "id": config.provider_identity_for(provider),
6188            "display": provider.display_name(),
6189        },
6190        "model": {
6191            "resolved": config.default_model(),
6192        },
6193        "auth": {
6194            "present_or_local": auth_present_or_local,
6195            "source": doctor_api_key_source_label(credential.source),
6196            "availability": credential.availability.label(),
6197            "env_vars": provider.env_vars(),
6198            "credential_mode": credential_help.acquisition.as_str(),
6199            "credential_url": credential_url,
6200            "credential_docs_url": credential_docs_url,
6201            "credential_guidance": credential_help.guidance,
6202            "oauth_only": credential_help.acquisition
6203                == codewhale_config::provider::CredentialAcquisition::OAuth,
6204        },
6205        "health": {
6206            "live_validation": false,
6207            "next_action": if auth_present_or_local {
6208                "/model"
6209            } else {
6210                "/setup provider or /provider setup <name>"
6211            },
6212        },
6213    })
6214}
6215
6216fn doctor_dsh_integration_report(
6217    config: &Config,
6218    workspace: &Path,
6219) -> anyhow::Result<crate::integrations::dsh::DshStatusReport> {
6220    use crate::integrations::dsh;
6221    let paths = dsh::DshPaths::from_process()?;
6222    let detection = dsh::detect::detect(&dsh::DetectEnv::from_process(), &dsh::ProcessRunner);
6223    let identity = dsh::codewhale_route_identity(config, workspace);
6224    dsh::compute_status(
6225        &paths,
6226        detection,
6227        identity,
6228        false,
6229        dsh::bundle_availability_now(),
6230    )
6231}
6232
6233fn doctor_dsh_integration_lines(config: &Config, workspace: &Path) -> Vec<String> {
6234    match doctor_dsh_integration_report(config, workspace) {
6235        Ok(report) => {
6236            let mut lines = vec![
6237                format!("state: {}", report.state.label()),
6238                crate::integrations::dsh::status_line(&report),
6239                format!(
6240                    "owned files: {} (overlay {})",
6241                    crate::utils::display_path(&report.paths_root),
6242                    if report.overlay_present {
6243                        "present"
6244                    } else {
6245                        "absent"
6246                    }
6247                ),
6248            ];
6249            if !report.shadowing_namespaces.is_empty() {
6250                lines.push(format!(
6251                    "dsh settings.yaml sections that can shadow the overlay: {}",
6252                    report.shadowing_namespaces.join(", ")
6253                ));
6254            }
6255            lines
6256        }
6257        Err(error) => vec![format!("unavailable: {error}")],
6258    }
6259}
6260
6261fn doctor_dsh_integration_json(config: &Config, workspace: &Path) -> serde_json::Value {
6262    match doctor_dsh_integration_report(config, workspace) {
6263        Ok(report) => serde_json::json!({
6264            "state": report.state.label(),
6265            "summary": crate::integrations::dsh::status_line(&report),
6266            "dsh_version": report.detection.version,
6267            "compatibility": report.detection.compatibility.label(),
6268            "overlay_present": report.overlay_present,
6269            "shadowing_namespaces": report.shadowing_namespaces,
6270        }),
6271        Err(error) => serde_json::json!({ "state": "unavailable", "error": error.to_string() }),
6272    }
6273}
6274
6275fn doctor_external_credential_consent_statuses(
6276    config: &Config,
6277) -> Vec<codewhale_config::ExternalCredentialConsentStatus> {
6278    [
6279        crate::config::ApiProvider::OpenaiCodex,
6280        crate::config::ApiProvider::Xai,
6281        crate::config::ApiProvider::Deepseek,
6282    ]
6283    .into_iter()
6284    .filter_map(|provider| config.external_credential_consent_status(provider))
6285    .collect()
6286}
6287
6288fn doctor_external_credential_consent_lines(config: &Config) -> Vec<String> {
6289    doctor_external_credential_consent_statuses(config)
6290        .into_iter()
6291        .flat_map(|status| {
6292            let mut lines = vec![
6293                format!(
6294                    "{}: access={}, provider={}, source={}, owner={}, path={}, version={}, state={}, ambient_path_changed={}",
6295                    status.provider,
6296                    status.access.as_str(),
6297                    status.provider,
6298                    status.source.as_str(),
6299                    status.owner,
6300                    codewhale_config::quote_os_path(&status.path),
6301                    status.consent_version,
6302                    status.route_state,
6303                    status.ambient_path_changed,
6304                ),
6305                format!("  semantics: {}", status.semantics),
6306                format!("  revoke: {}", status.revoke_command),
6307            ];
6308            if let Some(warning) = status.ambient_path_warning() {
6309                lines.push(format!("  {warning}"));
6310            }
6311            lines
6312        })
6313        .collect()
6314}
6315
6316fn doctor_external_credential_consent_json(config: &Config) -> serde_json::Value {
6317    serde_json::Value::Array(
6318        doctor_external_credential_consent_statuses(config)
6319            .into_iter()
6320            .map(|status| {
6321                serde_json::json!({
6322                    "provider": status.provider,
6323                    "access": status.access.as_str(),
6324                    "source": status.source.as_str(),
6325                    "owner": status.owner,
6326                    "path": codewhale_config::quote_os_path(&status.path),
6327                    "consent_version": status.consent_version,
6328                    "scope_valid": status.scope_valid,
6329                    "ambient_path_changed": status.ambient_path_changed,
6330                    "ambient_path_warning": status.ambient_path_warning(),
6331                    "route_state": status.route_state,
6332                    "semantics": status.semantics,
6333                    "revoke_command": status.revoke_command,
6334                })
6335            })
6336            .collect(),
6337    )
6338}
6339
6340fn doctor_setup_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6341    use serde_json::json;
6342
6343    let (state, source) = doctor_setup_state(config, workspace);
6344    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
6345    let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
6346    let approval_policy = config.approval_policy.as_deref().unwrap_or("on-request");
6347    let approval_policy_source = if config.approval_policy.is_some() {
6348        "config"
6349    } else {
6350        "default"
6351    };
6352    let allow_shell = config.interactive_allow_shell();
6353    let allow_shell_source = if config.allow_shell.is_some() {
6354        "config"
6355    } else {
6356        "interactive_default"
6357    };
6358    let sandbox_mode = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6359    let sandbox_mode_source = if config.sandbox_mode.is_some() {
6360        "config"
6361    } else {
6362        "default"
6363    };
6364    let network_default = config
6365        .network
6366        .as_ref()
6367        .map_or("prompt", |policy| policy.default.as_str());
6368    let network_source = if config.network.is_some() {
6369        "config"
6370    } else {
6371        "default"
6372    };
6373    let (telemetry_value, telemetry_source) = doctor_runtime_telemetry(config);
6374    let workspace_trusted = !crate::tui::onboarding::needs_trust(workspace);
6375    let credential = resolve_credential_diagnostic(config);
6376    let credential_ready = credential.availability.certifies_ready();
6377    let steps: Vec<_> = codewhale_config::SetupStep::ALL
6378        .into_iter()
6379        .map(|step| {
6380            let entry = state.steps.get(&step);
6381            json!({
6382                "step": setup_step_id(step),
6383                "status": setup_status_id(state.status(step)),
6384                "required": entry.is_some_and(|entry| entry.required),
6385                "version": entry.and_then(|entry| entry.version.clone()),
6386                "result": entry.and_then(|entry| entry.result.clone()),
6387            })
6388        })
6389        .collect();
6390
6391    json!({
6392        "source": source,
6393        "schema_version": state.schema_version,
6394        "inherited": state.inherited,
6395        "checkpoint_version": crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
6396        "first_run_ready": state.first_run_ready(),
6397        "update_ready": state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION),
6398        "operate_ready": state.operate_ready(),
6399        "credential": {
6400            "ready": credential_ready,
6401            "source": doctor_api_key_source_label(credential.source),
6402            "availability": credential.availability.label(),
6403        },
6404        "constitution": {
6405            "choice": constitution_choice_id(state.constitution_choice),
6406            "source": constitution_source_id(state.constitution_source),
6407            "validity": constitution_validity_id(state.constitution_validity),
6408            "checkpoint_completed_for": state.constitution_checkpoint_completed_for.clone(),
6409            "language": state.constitution_language.clone(),
6410            "preview_hash_present": state.constitution_preview_hash.is_some(),
6411            "preview_version": state.constitution_preview_version,
6412            "autonomy_preference": doctor_constitution_autonomy_preference_id(),
6413        },
6414        "runtime_posture_source": runtime_posture_source_id(state.runtime_posture_source),
6415        "runtime_posture": {
6416            "source": runtime_posture_source_id(state.runtime_posture_source),
6417            "default_mode": {
6418                "value": default_mode,
6419                "source": default_mode_source,
6420            },
6421            "permission_posture": {
6422                "value": permission_posture,
6423                "source": permission_posture_source,
6424            },
6425            "approval_policy": {
6426                "value": approval_policy,
6427                "source": approval_policy_source,
6428            },
6429            "allow_shell": {
6430                "value": allow_shell,
6431                "source": allow_shell_source,
6432            },
6433            "sandbox_mode": {
6434                "value": sandbox_mode,
6435                "source": sandbox_mode_source,
6436            },
6437            "network_default": {
6438                "value": network_default,
6439                "source": network_source,
6440            },
6441            "telemetry": {
6442                "value": telemetry_value,
6443                "source": telemetry_source,
6444            },
6445            "workspace_trust": {
6446                "trusted": workspace_trusted,
6447                "source": "workspace",
6448            },
6449        },
6450        "provider_model": doctor_provider_model_report_json(config),
6451        "operate_fleet": doctor_operate_fleet_report_json(config, workspace),
6452        "consistency": doctor_setup_consistency(&state, source),
6453        "next_actions": {
6454            "constitution": "/constitution",
6455            "setup_report": "/setup report",
6456            "provider_model": "/setup provider, /provider setup <name>, or /model",
6457            "runtime_posture": "/config",
6458            "operate_fleet": "/setup fleet (readiness), /fleet setup (explicit profile authoring)",
6459            "hotbar": "/setup hotbar",
6460            "tools_mcp": "/setup tools",
6461            "remote_runtime": "/setup remote",
6462            "persistence": "/setup persistence",
6463        },
6464        "steps": steps,
6465    })
6466}
6467
6468fn setup_step_id(step: codewhale_config::SetupStep) -> &'static str {
6469    match step {
6470        codewhale_config::SetupStep::Language => "language",
6471        codewhale_config::SetupStep::ProviderModel => "provider_model",
6472        codewhale_config::SetupStep::TrustSandbox => "trust_sandbox",
6473        codewhale_config::SetupStep::ToolsMcp => "tools_mcp",
6474        codewhale_config::SetupStep::Hotbar => "hotbar",
6475        codewhale_config::SetupStep::RemoteRuntime => "remote_runtime",
6476        codewhale_config::SetupStep::Persistence => "persistence",
6477        codewhale_config::SetupStep::Constitution => "constitution",
6478        codewhale_config::SetupStep::OperateFleet => "operate_fleet",
6479        codewhale_config::SetupStep::Verification => "verification",
6480    }
6481}
6482
6483fn setup_status_id(status: codewhale_config::StepStatus) -> &'static str {
6484    match status {
6485        codewhale_config::StepStatus::NotStarted => "not_started",
6486        codewhale_config::StepStatus::Recommended => "recommended",
6487        codewhale_config::StepStatus::Optional => "optional",
6488        codewhale_config::StepStatus::Deferred => "deferred",
6489        codewhale_config::StepStatus::InProgress => "in_progress",
6490        codewhale_config::StepStatus::Verified => "verified",
6491        codewhale_config::StepStatus::NeedsAction => "needs_action",
6492        codewhale_config::StepStatus::Failed => "failed",
6493        codewhale_config::StepStatus::Skipped => "skipped",
6494    }
6495}
6496
6497fn constitution_choice_id(choice: codewhale_config::ConstitutionChoice) -> &'static str {
6498    match choice {
6499        codewhale_config::ConstitutionChoice::Unset => "unset",
6500        codewhale_config::ConstitutionChoice::Bundled => "bundled",
6501        codewhale_config::ConstitutionChoice::GuidedCustom => "guided_custom",
6502        codewhale_config::ConstitutionChoice::ExpertOverride => "expert_override",
6503        codewhale_config::ConstitutionChoice::Deferred => "deferred",
6504    }
6505}
6506
6507fn constitution_source_id(source: codewhale_config::ConstitutionSource) -> &'static str {
6508    match source {
6509        codewhale_config::ConstitutionSource::Bundled => "bundled",
6510        codewhale_config::ConstitutionSource::UserGlobal => "user_global",
6511        codewhale_config::ConstitutionSource::ExpertOverride => "expert_override",
6512    }
6513}
6514
6515fn constitution_validity_id(validity: codewhale_config::ConstitutionValidity) -> &'static str {
6516    match validity {
6517        codewhale_config::ConstitutionValidity::Unknown => "unknown",
6518        codewhale_config::ConstitutionValidity::Valid => "valid",
6519        codewhale_config::ConstitutionValidity::Invalid => "invalid",
6520        codewhale_config::ConstitutionValidity::Empty => "empty",
6521        codewhale_config::ConstitutionValidity::Unreadable => "unreadable",
6522    }
6523}
6524
6525fn runtime_posture_source_id(source: codewhale_config::RuntimePostureSource) -> &'static str {
6526    match source {
6527        codewhale_config::RuntimePostureSource::Unset => "unset",
6528        codewhale_config::RuntimePostureSource::Inherited => "inherited",
6529        codewhale_config::RuntimePostureSource::Confirmed => "confirmed",
6530    }
6531}
6532
6533/// Emit a bounded, secret-redacted JSON failure when configuration cannot be
6534/// loaded or validated. Invalid configuration must not be forced through the
6535/// normal doctor report because its route/capability facts would be misleading.
6536fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
6537    let safe_message = error
6538        .downcast_ref::<crate::config::SafeConfigDiagnostic>()
6539        .map(ToString::to_string);
6540    let report = serde_json::json!({
6541        "status": "error",
6542        "error": {
6543            "kind": "config_validation",
6544            "message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
6545        },
6546    });
6547    println!("{}", serde_json::to_string_pretty(&report)?);
6548
6549    // Keep stderr generic: the actionable, redacted error is already on
6550    // stdout, and Rust's Result termination must never redisclose a secret.
6551    bail!("doctor configuration validation failed; see JSON output")
6552}
6553
6554/// Machine-readable counterpart to `run_doctor`. This report is always
6555/// structural and offline; live probe flags conflict with `--json`.
6556fn run_doctor_json(
6557    config: &Config,
6558    workspace: &Path,
6559    config_path_override: Option<&Path>,
6560    plugins: &crate::plugins::PluginRegistry,
6561) -> Result<()> {
6562    use serde_json::json;
6563
6564    let doctor_paths = crate::doctor::DoctorPathReport::resolve(config_path_override)?;
6565    let config_path = &doctor_paths.config;
6566    let secret_backend = codewhale_secrets::diagnose_secret_backend();
6567
6568    let credential = resolve_credential_diagnostic(config);
6569
6570    let mcp_config_path = config.mcp_config_path();
6571    let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
6572    let mcp_present = mcp_config_path.exists();
6573    let project_mcp_present = project_mcp_config_path.exists();
6574    let mcp_summary = match crate::mcp::load_config_with_workspace_and_plugins(
6575        &mcp_config_path,
6576        workspace,
6577        plugins,
6578    ) {
6579        Ok(cfg) => {
6580            let servers: Vec<serde_json::Value> = cfg
6581                .servers
6582                .iter()
6583                .map(|(name, server)| doctor_mcp_server_json(name, server))
6584                .collect();
6585            json!({
6586                "config_path": mcp_config_path.display().to_string(),
6587                "present": mcp_present,
6588                "project_config_path": project_mcp_config_path.display().to_string(),
6589                "project_present": project_mcp_present,
6590                "probe_scope": "configuration",
6591                "live_health_checked": false,
6592                "servers": servers,
6593            })
6594        }
6595        Err(_) => json!({
6596            "config_path": mcp_config_path.display().to_string(),
6597            "present": mcp_present,
6598            "project_config_path": project_mcp_config_path.display().to_string(),
6599            "project_present": project_mcp_present,
6600            "probe_scope": "configuration",
6601            "live_health_checked": false,
6602            "servers": [],
6603            "error": "configuration_unavailable_details_omitted",
6604        }),
6605    };
6606
6607    let global_skills_dir = config.skills_dir();
6608    let agents_skills_dir = workspace.join(".agents").join("skills");
6609    let local_skills_dir = workspace.join("skills");
6610    let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
6611    // #432: cross-tool skill discovery dirs surface in the JSON
6612    // report so external dashboards can see whether any
6613    // `.opencode/skills/`, `.claude/skills/`, `.cursor/skills/`, or
6614    // global agentskills.io content is contributing to the merged catalogue.
6615    let opencode_skills_dir = workspace.join(".opencode").join("skills");
6616    let claude_skills_dir = workspace.join(".claude").join("skills");
6617    let selected_skills_dir = if agents_skills_dir.exists() {
6618        agents_skills_dir.clone()
6619    } else if local_skills_dir.exists() {
6620        local_skills_dir.clone()
6621    } else if config.skills_dir.is_none()
6622        && let Some(global_agents) = agents_global_skills_dir.as_ref()
6623        && global_agents.exists()
6624    {
6625        global_agents.clone()
6626    } else {
6627        global_skills_dir.clone()
6628    };
6629    let agents_global_summary = agents_global_skills_dir
6630        .as_ref()
6631        .map(|path| {
6632            json!({
6633                "path": path.display().to_string(),
6634                "present": path.exists(),
6635                "count": skills_count_for(path),
6636            })
6637        })
6638        .unwrap_or_else(|| {
6639            json!({
6640                "path": null,
6641                "present": false,
6642                "count": 0,
6643            })
6644        });
6645
6646    let tools_dir = default_tools_dir();
6647    let plugins_dir = default_plugins_dir();
6648
6649    // Memory feature state (#489). Operators ask "is memory on?" and
6650    // "where does it live?" — surface both here so the question can be
6651    // answered without booting the TUI. Both inputs are checked: the
6652    // config flag and the env-var override that the runtime would
6653    // honour. (The dedicated `Config::memory_enabled()` accessor lives
6654    // on the memory-MVP branch (#518); this duplicates the same logic
6655    // until the two PRs land and it can be replaced with a single
6656    // method call.)
6657    let memory_path = config.memory_path();
6658    let memory_enabled_env = std::env::var("CODEWHALE_MEMORY")
6659        .or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
6660        .ok()
6661        .map(|raw| {
6662            matches!(
6663                raw.trim().to_ascii_lowercase().as_str(),
6664                "1" | "on" | "true" | "yes" | "y" | "enabled"
6665            )
6666        })
6667        .unwrap_or(false);
6668    let memory_summary = json!({
6669        // The MVP feature is opt-in by default; this defaults to false
6670        // on branches without the [memory] section in `Config`.
6671        "enabled": memory_enabled_env,
6672        "path": memory_path.display().to_string(),
6673        "file_present": memory_path.exists(),
6674    });
6675    let api_target = doctor_api_target(config);
6676    let strict_tool_mode = doctor_strict_tool_mode_status(config);
6677    let tls_status = doctor_tls_status(config);
6678    let (code_home, legacy_home) = doctor_state_roots();
6679    let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
6680    let session_recovery = doctor_session_recovery_report(
6681        &code_home,
6682        &legacy_home,
6683        codewhale_config::codewhale_home_is_explicit(),
6684    );
6685
6686    let stash = crate::composer_stash::diagnostic_stash_report();
6687    let report = json!({
6688        "version": env!("CARGO_PKG_VERSION"),
6689        "config_path": config_path.display().to_string(),
6690        "config_present": config_path.exists(),
6691        "paths": doctor_paths,
6692        "secret_backend": secret_backend,
6693        "workspace": workspace.display().to_string(),
6694        "legacy_state": doctor_legacy_state_json(
6695            &code_home,
6696            &legacy_home,
6697            &legacy_state_report,
6698            &session_recovery,
6699        ),
6700        "setup": doctor_setup_report_json(config, workspace),
6701        "api_key": {
6702            "source": doctor_api_key_source_label(credential.source),
6703            "availability": credential.availability.label(),
6704        },
6705        "external_credentials": doctor_external_credential_consent_json(config),
6706        "dsh_integration": doctor_dsh_integration_json(config, workspace),
6707        "base_url": crate::doctor::structural_url_authority(&api_target.base_url),
6708        "default_text_model": api_target.model,
6709        // DGF-01: this report describes the route a session launched now
6710        // would resolve; a running session keeps its launch-time route.
6711        "route_scope": "configured_at_launch",
6712        "model_resolution": match api_target.resolution {
6713            DoctorModelResolution::Resolved => "resolved",
6714            DoctorModelResolution::ConfiguredOnly => "configured_unresolved",
6715        },
6716        "route": doctor_route_report(config),
6717        "strict_tool_mode": doctor_strict_tool_mode_report_json(&strict_tool_mode),
6718        "tls": {
6719            "certificate_verification": tls_status.certificate_verification,
6720            "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
6721            "provider": tls_status.provider,
6722            "message": tls_status.message,
6723        },
6724        "search_provider": doctor_search_provider_json(config),
6725        "memory": memory_summary,
6726        "mcp": mcp_summary,
6727        "skills": {
6728            "selected": selected_skills_dir.display().to_string(),
6729            "global": {
6730                "path": global_skills_dir.display().to_string(),
6731                "present": global_skills_dir.exists(),
6732                "count": skills_count_for(&global_skills_dir),
6733            },
6734            "agents": {
6735                "path": agents_skills_dir.display().to_string(),
6736                "present": agents_skills_dir.exists(),
6737                "count": skills_count_for(&agents_skills_dir),
6738            },
6739            "agents_global": agents_global_summary,
6740            "local": {
6741                "path": local_skills_dir.display().to_string(),
6742                "present": local_skills_dir.exists(),
6743                "count": skills_count_for(&local_skills_dir),
6744            },
6745            "opencode": {
6746                "path": opencode_skills_dir.display().to_string(),
6747                "present": opencode_skills_dir.exists(),
6748                "count": skills_count_for(&opencode_skills_dir),
6749            },
6750            "claude": {
6751                "path": claude_skills_dir.display().to_string(),
6752                "present": claude_skills_dir.exists(),
6753                "count": skills_count_for(&claude_skills_dir),
6754            },
6755        },
6756        "tools": {
6757            "path": tools_dir.display().to_string(),
6758            "present": tools_dir.exists(),
6759            "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
6760        },
6761        "plugins": {
6762            "path": plugins_dir.display().to_string(),
6763            "present": plugins_dir.exists(),
6764            "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
6765        },
6766        "storage": {
6767            "spillover": {
6768                "path": crate::tools::truncate::spillover_root()
6769                    .map(|p| p.display().to_string())
6770                    .unwrap_or_default(),
6771                "present": crate::tools::truncate::spillover_root()
6772                    .is_some_and(|p| p.is_dir()),
6773                "count": crate::tools::truncate::spillover_root()
6774                    .filter(|p| p.is_dir())
6775                    .map(|p| count_dir_entries(&p))
6776                    .unwrap_or(0),
6777            },
6778            "stash": {
6779                "path": stash
6780                    .path
6781                    .as_ref()
6782                    .map(|path| path.display().to_string())
6783                    .unwrap_or_default(),
6784                "present": stash.present,
6785                "count": stash.count,
6786                "error": stash.error,
6787            },
6788        },
6789        "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
6790            config.prefer_bwrap.unwrap_or(false),
6791        ) {
6792            Some(kind) => json!({"available": true, "kind": kind.to_string()}),
6793            None => json!({"available": false, "kind": null}),
6794        },
6795        "platform": {
6796            "os": std::env::consts::OS,
6797            "arch": std::env::consts::ARCH,
6798        },
6799        "api_connectivity": {
6800            "checked": false,
6801            "status": "not_probed",
6802            "note": "JSON doctor is offline; use `codewhale doctor --probe-api` or `--probe-local` for an explicit live check.",
6803        },
6804        "capability": provider_capability_report(config),
6805    });
6806
6807    println!("{}", serde_json::to_string_pretty(&report)?);
6808    Ok(())
6809}
6810
6811fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
6812    let report = crate::context_report::build_headless_context_report(config, workspace);
6813    println!("{}", crate::context_report::context_report_json(&report));
6814    Ok(())
6815}
6816
6817/// Build the `capability` section for the machine-readable doctor report.
6818///
6819/// Returns a JSON value with the resolved provider, resolved model, context
6820/// window, max output, thinking support, cache telemetry support, and request
6821/// payload mode.
6822fn provider_capability_report(config: &Config) -> serde_json::Value {
6823    use serde_json::json;
6824
6825    let provider = config.api_provider();
6826    let configured_model = config.default_model();
6827    let route_result =
6828        crate::route_runtime::resolve_runtime_route(config, provider, Some(&configured_model));
6829    let route_error = route_result
6830        .is_err()
6831        .then_some("route_resolution_failed_details_omitted");
6832    let route = route_result.ok();
6833    let resolved_model = route
6834        .as_ref()
6835        .map_or(configured_model.as_str(), |route| route.model.as_str());
6836    let cap = crate::config::provider_capability(provider, resolved_model);
6837    let route_profile = route.as_ref().map(|route| {
6838        crate::model_profile::resolved_capability_profile_for_route(
6839            provider,
6840            resolved_model,
6841            route.candidate.capabilities(),
6842            route.candidate.limits(),
6843        )
6844    });
6845    let context_window = route
6846        .as_ref()
6847        .map_or(cap.context_window, |route| route.context_window.tokens);
6848    let context_window_source = route.as_ref().map_or(
6849        crate::route_runtime::ContextWindowSource::Fallback.label(),
6850        |route| route.context_window.source.label(),
6851    );
6852    // `null` when neither the resolved route nor the compatibility matrix
6853    // publishes an output ceiling — doctor must not invent one.
6854    let max_output = route_profile
6855        .as_ref()
6856        .and_then(|profile| profile.max_output)
6857        .or(cap.max_output);
6858    let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
6859        crate::config::is_exact_kimi_code_k3_route(
6860            provider,
6861            &route.candidate.endpoint().base_url,
6862            route.candidate.wire_model_id().as_str(),
6863        )
6864    });
6865    let thinking_supported = is_exact_kimi_code_k3
6866        || route_profile
6867            .as_ref()
6868            .map_or(cap.thinking_supported, |profile| {
6869                profile.supports_reasoning()
6870            });
6871    let cache_telemetry_supported = route_profile
6872        .as_ref()
6873        .map_or(cap.cache_telemetry_supported, |profile| {
6874            profile.prompt_caching.is_supported()
6875        });
6876    let request_payload_mode = route_profile
6877        .as_ref()
6878        .map_or(cap.request_payload_mode, |profile| {
6879            profile.request_payload_mode
6880        });
6881    let alias_deprecation = config.active_deepseek_alias_deprecation();
6882
6883    json!({
6884        "resolved_provider": config.provider_identity_for(provider),
6885        "resolved_model": resolved_model,
6886        "context_window": context_window,
6887        "context_window_source": context_window_source,
6888        "max_output": max_output,
6889        "thinking_supported": thinking_supported,
6890        "cache_telemetry_supported": cache_telemetry_supported,
6891        "request_payload_mode": serde_json::to_value(request_payload_mode).unwrap_or_default(),
6892        "route_error": route_error,
6893        "alias_deprecation": alias_deprecation,
6894    })
6895}
6896
6897fn doctor_route_report(config: &Config) -> serde_json::Value {
6898    use serde_json::json;
6899
6900    let target = doctor_api_target(config);
6901    let provider = config.api_provider();
6902    let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
6903    let route_result =
6904        crate::route_runtime::resolve_runtime_route(config, provider, Some(&target.model));
6905    let route_error = route_result
6906        .is_err()
6907        .then_some("route_resolution_failed_details_omitted");
6908    let context_window = route_result
6909        .ok()
6910        .map(|route| {
6911        json!({
6912            "tokens": route.context_window.tokens,
6913            "source": route.context_window.source.label(),
6914        })
6915    })
6916    .unwrap_or_else(|| {
6917        json!({
6918            "tokens": crate::config::provider_capability(provider, &target.model).context_window,
6919            "source": crate::route_runtime::ContextWindowSource::Fallback.label(),
6920        })
6921    });
6922
6923    let route_identity =
6924        crate::config::moonshot_k3_route_display_name(&target.base_url, &target.model);
6925    let credential = resolve_credential_diagnostic(config);
6926
6927    json!({
6928        "provider": target.provider,
6929        "provider_source": doctor_provider_source(config),
6930        "provider_config_table": doctor_provider_config_table(config, provider),
6931        "model": target.model,
6932        "route_identity": route_identity,
6933        "wire_protocol": doctor_wire_protocol(provider),
6934        "base_url": {
6935            "redacted": redacted_base_url,
6936            "class": doctor_base_url_class(provider, &target.base_url),
6937            "fingerprint": crate::utils::redacted_identifier_for_log(&target.base_url),
6938        },
6939        "auth": {
6940            "scheme": doctor_auth_scheme(config),
6941            "source": doctor_api_key_source_label(credential.source),
6942            "availability": credential.availability.label(),
6943        },
6944        "context_window": context_window,
6945        "route_error": route_error,
6946    })
6947}
6948
6949fn doctor_provider_config_table(config: &Config, provider: crate::config::ApiProvider) -> String {
6950    if provider != crate::config::ApiProvider::Custom {
6951        return provider_config_table_key(provider).to_string();
6952    }
6953    if config.uses_legacy_literal_custom_route() {
6954        "root (legacy literal custom)".to_string()
6955    } else {
6956        format!("providers.{}", config.provider_identity_for(provider))
6957    }
6958}
6959
6960fn doctor_provider_source(config: &Config) -> &'static str {
6961    if config
6962        .provider
6963        .as_ref()
6964        .is_some_and(|provider| !provider.trim().is_empty())
6965    {
6966        "config"
6967    } else {
6968        "default"
6969    }
6970}
6971
6972fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str {
6973    let policy = provider
6974        .metadata()
6975        .map(|metadata| metadata.wire_policy())
6976        .unwrap_or(codewhale_config::provider::WirePolicy::Fixed(
6977            codewhale_config::provider::WireFormat::ChatCompletions,
6978        ));
6979    match policy.fixed() {
6980        Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions",
6981        Some(codewhale_config::provider::WireFormat::Responses) => "responses",
6982        Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages",
6983        None => "model_aware",
6984    }
6985}
6986
6987fn doctor_base_url_class(provider: crate::config::ApiProvider, base_url: &str) -> &'static str {
6988    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
6989    if normalized.starts_with("http://localhost")
6990        || normalized.starts_with("http://127.0.0.1")
6991        || normalized.starts_with("http://[::1]")
6992    {
6993        return "local";
6994    }
6995    if normalized
6996        == provider
6997            .default_base_url()
6998            .trim_end_matches('/')
6999            .to_ascii_lowercase()
7000    {
7001        "default"
7002    } else {
7003        "custom"
7004    }
7005}
7006
7007fn doctor_auth_scheme(config: &Config) -> &'static str {
7008    let provider = config.api_provider();
7009    if crate::config::auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
7010    {
7011        "none"
7012    } else if provider == crate::config::ApiProvider::Anthropic {
7013        "x-api-key"
7014    } else if provider == crate::config::ApiProvider::XiaomiMimo
7015        && doctor_xiaomi_mimo_base_url_uses_token_plan(&config.deepseek_base_url())
7016    {
7017        "api-key"
7018    } else if provider == crate::config::ApiProvider::XiaomiMimo {
7019        // The alternate MiMo scheme depends on a credential prefix. Ordinary
7020        // doctor does not read credentials merely to make this label precise.
7021        "unknown"
7022    } else if matches!(
7023        provider,
7024        crate::config::ApiProvider::Sglang
7025            | crate::config::ApiProvider::Vllm
7026            | crate::config::ApiProvider::Ollama
7027    ) {
7028        "optional_bearer"
7029    } else {
7030        "bearer"
7031    }
7032}
7033
7034fn doctor_xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
7035    let normalized = base_url.trim_end_matches('/');
7036    [
7037        crate::config::XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
7038        crate::config::XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
7039        crate::config::XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
7040    ]
7041    .iter()
7042    .any(|candidate| normalized.eq_ignore_ascii_case(candidate.trim_end_matches('/')))
7043}
7044
7045fn doctor_api_key_source_label(source: ApiKeySource) -> &'static str {
7046    match source {
7047        ApiKeySource::ConfigDeclared => "config_declared",
7048        ApiKeySource::EnvDeclared => "env_declared",
7049        ApiKeySource::ExternalAuthDeclared => "external_auth_declared",
7050        ApiKeySource::SecretStoreUnprobed => "secret_store_unprobed",
7051        ApiKeySource::SecretStoreUnavailable => "secret_store_unavailable",
7052        ApiKeySource::OAuth => "oauth_unprobed",
7053        ApiKeySource::ExternalConsent => "external_consent",
7054        ApiKeySource::NoAuth => "none",
7055        ApiKeySource::LocalRuntime => "local_runtime",
7056        ApiKeySource::Unknown => "unknown",
7057    }
7058}
7059
7060fn doctor_search_provider_line(config: &Config) -> String {
7061    let search_provider = config.search_provider_resolution();
7062    let switch_hint = if matches!(
7063        (search_provider.provider, search_provider.source),
7064        (
7065            crate::config::SearchProvider::Firecrawl,
7066            crate::config::SearchProviderSource::Default
7067        )
7068    ) {
7069        "; set [search] provider = \"baidu\" | \"metaso\" | \"volcengine\" for China"
7070    } else {
7071        ""
7072    };
7073
7074    format!(
7075        "search_provider: {} (source: {}{})",
7076        search_provider.provider.as_str(),
7077        search_provider.source.as_str(),
7078        switch_hint
7079    )
7080}
7081
7082fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
7083    use serde_json::json;
7084
7085    let search_provider = config.search_provider_resolution();
7086    json!({
7087        "provider": search_provider.provider.as_str(),
7088        "source": search_provider.source.as_str(),
7089        "reachability": "not_checked",
7090        "reachability_reason": "offline_json",
7091    })
7092}
7093
7094/// Whether the model in a [`DoctorApiTarget`] is the wire id the engine
7095/// resolver produced, or only the raw configured value because resolution
7096/// failed. Doctor never prints resolution error details — the JSON route
7097/// report already redacts them for the same reason.
7098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7099enum DoctorModelResolution {
7100    Resolved,
7101    ConfiguredOnly,
7102}
7103
7104#[derive(Debug, Clone, PartialEq, Eq)]
7105struct DoctorApiTarget {
7106    provider: String,
7107    base_url: String,
7108    model: String,
7109    resolution: DoctorModelResolution,
7110}
7111
7112#[derive(Debug, Clone, PartialEq, Eq)]
7113struct DoctorStrictToolModeStatus {
7114    enabled: bool,
7115    status: &'static str,
7116    function_strict_sent: bool,
7117    message: String,
7118    recommended_base_url: Option<String>,
7119}
7120
7121fn doctor_api_target(config: &Config) -> DoctorApiTarget {
7122    let provider = config.api_provider();
7123    // Report the model through the same resolver the live client uses at
7124    // session launch (`client.rs` → `resolve_runtime_route`), so doctor's
7125    // answer matches what a session started now would actually serve —
7126    // saved provider models, alias normalization, and roster preference
7127    // included — instead of re-deriving a config default that can diverge
7128    // from the engine (DGF-01, dogfood 2026-08-02).
7129    let (model, resolution) =
7130        match crate::route_runtime::resolve_runtime_route(config, provider, None) {
7131            Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved),
7132            Err(_) => (
7133                config.default_model(),
7134                DoctorModelResolution::ConfiguredOnly,
7135            ),
7136        };
7137    DoctorApiTarget {
7138        provider: config.provider_identity_for(provider),
7139        base_url: config.deepseek_base_url(),
7140        model,
7141        resolution,
7142    }
7143}
7144
7145fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus {
7146    if !config.strict_tool_mode.unwrap_or(false) {
7147        return DoctorStrictToolModeStatus {
7148            enabled: false,
7149            status: "disabled",
7150            function_strict_sent: false,
7151            message: "disabled".to_string(),
7152            recommended_base_url: None,
7153        };
7154    }
7155
7156    let target = doctor_api_target(config);
7157    match known_deepseek_base_url_kind(&target.base_url) {
7158        Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus {
7159            enabled: true,
7160            status: "ready",
7161            function_strict_sent: true,
7162            message: "enabled; DeepSeek strict schemas use the beta endpoint".to_string(),
7163            recommended_base_url: None,
7164        },
7165        Some(DeepSeekBaseUrlKind::NonBeta) => {
7166            let recommended = recommended_strict_base_url(config, &target.base_url);
7167            DoctorStrictToolModeStatus {
7168                enabled: true,
7169                status: "fallback_non_beta",
7170                function_strict_sent: false,
7171                message:
7172                    "enabled, but function.strict is stripped for this non-beta DeepSeek endpoint"
7173                        .to_string(),
7174                recommended_base_url: Some(recommended.to_string()),
7175            }
7176        }
7177        None => DoctorStrictToolModeStatus {
7178            enabled: true,
7179            status: "custom_endpoint",
7180            function_strict_sent: true,
7181            message: "enabled; function.strict will be sent to this custom endpoint".to_string(),
7182            recommended_base_url: None,
7183        },
7184    }
7185}
7186
7187fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> serde_json::Value {
7188    serde_json::json!({
7189        "enabled": status.enabled,
7190        "status": status.status,
7191        "function_strict_sent": status.function_strict_sent,
7192        "message": status.message,
7193        "recommended_base_url": status
7194            .recommended_base_url
7195            .as_deref()
7196            .map(crate::doctor::structural_url_authority),
7197    })
7198}
7199
7200#[derive(Debug, Clone, PartialEq, Eq)]
7201struct DoctorTlsStatus {
7202    certificate_verification: bool,
7203    insecure_skip_tls_verify: bool,
7204    provider: String,
7205    message: String,
7206}
7207
7208fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
7209    let provider = config.provider_identity_for(config.api_provider());
7210    let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
7211    let message = if insecure_skip_tls_verify {
7212        format!(
7213            "TLS certificate verification cannot be disabled for provider {provider}; use SSL_CERT_FILE with a trusted custom CA bundle"
7214        )
7215    } else {
7216        "TLS certificate verification enabled".to_string()
7217    };
7218    DoctorTlsStatus {
7219        certificate_verification: true,
7220        insecure_skip_tls_verify,
7221        provider,
7222        message,
7223    }
7224}
7225
7226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7227enum DeepSeekBaseUrlKind {
7228    Beta,
7229    NonBeta,
7230}
7231
7232fn known_deepseek_base_url_kind(base_url: &str) -> Option<DeepSeekBaseUrlKind> {
7233    let normalized = base_url.trim_end_matches('/');
7234    if normalized.eq_ignore_ascii_case("https://api.deepseek.com/beta")
7235        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/beta")
7236    {
7237        Some(DeepSeekBaseUrlKind::Beta)
7238    } else if normalized.eq_ignore_ascii_case("https://api.deepseek.com")
7239        || normalized.eq_ignore_ascii_case("https://api.deepseek.com/v1")
7240        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com")
7241        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/v1")
7242    {
7243        Some(DeepSeekBaseUrlKind::NonBeta)
7244    } else {
7245        None
7246    }
7247}
7248
7249fn recommended_strict_base_url(_config: &Config, _base_url: &str) -> &'static str {
7250    crate::config::DEFAULT_DEEPSEEK_BASE_URL
7251}
7252
7253fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
7254    let target = doctor_api_target(config);
7255    let mut lines = vec![format!(
7256        "Connection timed out while reaching {}.",
7257        crate::doctor::structural_url_authority(&target.base_url)
7258    )];
7259
7260    match config.api_provider() {
7261        crate::config::ApiProvider::Deepseek
7262            if target.base_url.contains("api.deepseek.com")
7263                && !target.base_url.contains("api.deepseeki.com") =>
7264        {
7265            lines.push(
7266                "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.codewhale/config.toml and rerun `codewhale doctor`."
7267                    .to_string(),
7268            );
7269        }
7270        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
7271            lines.push(
7272                "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
7273                    .to_string(),
7274            );
7275        }
7276        _ => {
7277            lines.push(
7278                "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
7279                    .to_string(),
7280            );
7281        }
7282    }
7283
7284    lines.push(
7285        "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
7286            .to_string(),
7287    );
7288    lines
7289}
7290
7291fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
7292    match command.command {
7293        FeaturesSubcommand::List => {
7294            print!("{}", render_feature_table(&config.features()));
7295            Ok(())
7296        }
7297    }
7298}
7299
7300async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
7301    use crate::client::DeepSeekClient;
7302
7303    let client = DeepSeekClient::new(config)?;
7304    let mut models = client.list_models().await?;
7305    models.sort_by(|a, b| a.id.cmp(&b.id));
7306
7307    if args.json {
7308        println!("{}", serde_json::to_string_pretty(&models)?);
7309        return Ok(());
7310    }
7311
7312    if models.is_empty() {
7313        println!("No models returned by the API.");
7314        return Ok(());
7315    }
7316
7317    let default_model = config.default_model();
7318
7319    println!("Available models (default: {default_model})");
7320    for model in models {
7321        let marker = if model.id == default_model { "*" } else { " " };
7322        if let Some(owner) = model.owned_by {
7323            println!("{marker} {} ({owner})", model.id);
7324        } else {
7325            println!("{marker} {}", model.id);
7326        }
7327    }
7328
7329    Ok(())
7330}
7331
7332async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
7333    use crate::client::{DeepSeekClient, SpeechSynthesisRequest};
7334    use crate::config::ApiProvider;
7335    use crate::tools::speech::{
7336        DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions,
7337        default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri,
7338        infer_speech_model, normalize_speech_format,
7339    };
7340
7341    let SpeechArgs {
7342        text,
7343        output,
7344        output_dir,
7345        model,
7346        voice,
7347        instruction,
7348        voice_prompt,
7349        clone_voice,
7350        format,
7351        json: json_output,
7352    } = args;
7353
7354    if config.api_provider() != ApiProvider::XiaomiMimo {
7355        bail!(
7356            "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
7357            config.api_provider().as_str()
7358        );
7359    }
7360
7361    if text.trim().is_empty() {
7362        bail!("Speech text cannot be empty");
7363    }
7364    let voice_is_data_uri = voice
7365        .as_deref()
7366        .map(str::trim)
7367        .is_some_and(|value| value.starts_with("data:audio/"));
7368    if clone_voice.is_some() && voice.is_some() {
7369        bail!("Use either --clone-voice or --voice for cloned voice data, not both");
7370    }
7371    let model = infer_speech_model(
7372        model.as_deref(),
7373        clone_voice.is_some() || voice_is_data_uri,
7374        voice_prompt.is_some(),
7375    );
7376    let model_lower = model.to_ascii_lowercase();
7377    if !model_lower.contains("tts") {
7378        bail!(
7379            "speech requires a TTS model (examples: {}); got {model}",
7380            SPEECH_MODEL_EXAMPLES.join(", ")
7381        );
7382    }
7383    let is_voice_design = model_lower.contains("voicedesign");
7384    let is_voice_clone = model_lower.contains("voiceclone");
7385
7386    let instruction = combine_speech_instructions(instruction, voice_prompt);
7387    if is_voice_design
7388        && instruction
7389            .as_deref()
7390            .is_none_or(|value| value.trim().is_empty())
7391    {
7392        bail!(
7393            "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice"
7394        );
7395    }
7396
7397    let voice = if let Some(clone_path) = clone_voice {
7398        Some(encode_voice_clone_sample_data_uri(&clone_path)?)
7399    } else if is_voice_design {
7400        None
7401    } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) {
7402        Some(value)
7403    } else if is_voice_clone {
7404        bail!("mimo-v2.5-tts-voiceclone requires --clone-voice <mp3|wav> or --voice <data-uri>");
7405    } else {
7406        Some(DEFAULT_VOICE.to_string())
7407    };
7408    let format = normalize_speech_format(&format).with_context(|| {
7409        format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)")
7410    })?;
7411    let output = output.unwrap_or_else(|| {
7412        output_dir
7413            .or_else(|| config.speech_output_dir())
7414            .unwrap_or_default()
7415            .join(default_speech_output_name(&format))
7416    });
7417
7418    let client = DeepSeekClient::new(config)?;
7419    let response = client
7420        .synthesize_speech(SpeechSynthesisRequest {
7421            model: model.clone(),
7422            text,
7423            instruction,
7424            audio_format: format.clone(),
7425            voice,
7426        })
7427        .await?;
7428
7429    if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
7430        std::fs::create_dir_all(parent)
7431            .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
7432    }
7433    std::fs::write(&output, &response.audio_bytes)
7434        .with_context(|| format!("Failed to write audio file {}", output.display()))?;
7435
7436    if json_output {
7437        println!(
7438            "{}",
7439            serde_json::to_string_pretty(&serde_json::json!({
7440                "mode": "speech",
7441                "success": true,
7442                "model": response.model,
7443                "format": response.audio_format,
7444                "output": output.display().to_string(),
7445                "bytes": response.audio_bytes.len(),
7446                "voice": response.voice.as_deref().map(describe_speech_voice),
7447                "transcript": response.transcript,
7448            }))?
7449        );
7450    } else {
7451        println!(
7452            "Generated speech: {} ({} bytes, model: {}, format: {})",
7453            output.display(),
7454            response.audio_bytes.len(),
7455            response.model,
7456            response.audio_format
7457        );
7458    }
7459
7460    Ok(())
7461}
7462
7463#[cfg(test)]
7464mod speech_cli_tests {
7465    use super::*;
7466    use crate::tools::speech::{
7467        default_speech_output_name, infer_speech_model, normalize_speech_format,
7468    };
7469
7470    #[test]
7471    fn normalizes_documented_speech_formats() {
7472        assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav"));
7473        assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16"));
7474        assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16"));
7475        assert_eq!(normalize_speech_format("flac"), None);
7476    }
7477
7478    #[test]
7479    fn default_speech_output_tracks_requested_format() {
7480        assert_eq!(
7481            PathBuf::from(default_speech_output_name("mp3")),
7482            PathBuf::from("speech.mp3")
7483        );
7484        assert_eq!(
7485            PathBuf::from("audio").join(default_speech_output_name("pcm")),
7486            PathBuf::from("audio").join("speech.pcm16")
7487        );
7488    }
7489
7490    #[test]
7491    fn speech_command_parses_cli_passthrough_smoke() {
7492        let cli = Cli::try_parse_from([
7493            "codewhale-tui",
7494            "speech",
7495            "hello",
7496            "--model",
7497            "tts",
7498            "--format",
7499            "pcm",
7500            "--output-dir",
7501            "audio",
7502            "--voice",
7503            "Mia",
7504        ])
7505        .expect("speech command parses");
7506
7507        let Some(Commands::Speech(args)) = cli.command else {
7508            panic!("expected speech command");
7509        };
7510        assert_eq!(args.text, "hello");
7511        assert_eq!(
7512            infer_speech_model(args.model.as_deref(), false, false),
7513            "mimo-v2.5-tts"
7514        );
7515        assert_eq!(
7516            normalize_speech_format(&args.format).as_deref(),
7517            Some("pcm16")
7518        );
7519        assert_eq!(args.output_dir, Some(PathBuf::from("audio")));
7520        assert_eq!(args.voice.as_deref(), Some("Mia"));
7521    }
7522}
7523
7524/// Test API connectivity by making a minimal request
7525async fn test_api_connectivity(config: &Config) -> Result<()> {
7526    use crate::client::DeepSeekClient;
7527    use crate::models::{ContentBlock, Message, MessageRequest};
7528
7529    let client = DeepSeekClient::new(config)?;
7530    let model = client.model().to_string();
7531
7532    if crate::doctor::is_keyless_ds4_route(config) {
7533        return crate::doctor::probe_ds4_models(config).await;
7534    }
7535
7536    // Minimal request: single word prompt, 1 max token
7537    let request = MessageRequest {
7538        model: model.clone(),
7539        messages: vec![Message {
7540            role: "user".to_string(),
7541            content: vec![ContentBlock::Text {
7542                text: "hi".to_string(),
7543                cache_control: None,
7544            }],
7545        }],
7546        max_tokens: 1,
7547        system: None,
7548        tools: None,
7549        tool_choice: None,
7550        metadata: None,
7551        thinking: None,
7552        // This is a one-token transport probe, not a reasoning task.
7553        reasoning_effort: Some("off".to_string()),
7554        stream: Some(false),
7555        temperature: None,
7556        top_p: None,
7557    };
7558
7559    // Use tokio timeout to catch hanging requests
7560    let timeout_duration = std::time::Duration::from_secs(15);
7561    match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
7562        Ok(Ok(_response)) => Ok(()),
7563        Ok(Err(e)) => Err(e),
7564        Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
7565    }
7566}
7567
7568fn rustc_version() -> String {
7569    let Some(mut cmd) = crate::dependencies::RustC::command() else {
7570        return "unknown".to_string();
7571    };
7572    let Ok(output) = cmd.arg("--version").output() else {
7573        return "unknown".to_string();
7574    };
7575    String::from_utf8(output.stdout)
7576        .map(|s| s.trim().to_string())
7577        .unwrap_or_else(|_| "unknown".to_string())
7578}
7579
7580/// List saved sessions
7581fn sessions_resume_command() -> &'static str {
7582    "codewhale resume"
7583}
7584
7585fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
7586    use crate::palette;
7587    use colored::Colorize;
7588    use session_manager::{SessionManager, format_session_line};
7589
7590    let (action_r, action_g, action_b) = palette::WHALE_ACTION_RGB;
7591    let (human_r, human_g, human_b) = palette::WHALE_HUMAN_RGB;
7592    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7593    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7594
7595    let manager = SessionManager::default_location()?;
7596
7597    let sessions = if let Some(query) = search {
7598        manager.search_sessions(&query)?
7599    } else {
7600        manager.list_sessions()?
7601    };
7602
7603    if sessions.is_empty() {
7604        println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
7605        println!(
7606            "Start a new session with: {}",
7607            "codewhale".truecolor(human_r, human_g, human_b)
7608        );
7609        return Ok(());
7610    }
7611
7612    println!(
7613        "{}",
7614        "Saved Sessions"
7615            .truecolor(action_r, action_g, action_b)
7616            .bold()
7617    );
7618    println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
7619    println!();
7620
7621    for (i, session) in sessions.iter().take(limit).enumerate() {
7622        let line = format_session_line(session);
7623        if i == 0 {
7624            println!("  {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
7625        } else {
7626            println!("    {line}");
7627        }
7628    }
7629
7630    let total = sessions.len();
7631    if total > limit {
7632        println!();
7633        println!(
7634            "  {} more session(s). Use --limit to show more.",
7635            total - limit
7636        );
7637    }
7638
7639    println!();
7640    println!(
7641        "Resume with: {} {}",
7642        sessions_resume_command().truecolor(action_r, action_g, action_b),
7643        "<session-id>".dimmed()
7644    );
7645    println!(
7646        "Continue latest in this workspace: {}",
7647        "codewhale --continue".truecolor(action_r, action_g, action_b)
7648    );
7649
7650    Ok(())
7651}
7652
7653/// Initialize a new project with AGENTS.md
7654fn init_project() -> Result<()> {
7655    use crate::palette;
7656    use colored::Colorize;
7657    use project_context::create_default_agents_md;
7658
7659    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7660    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7661    let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
7662
7663    let workspace = std::env::current_dir()?;
7664    let agents_path = workspace.join("AGENTS.md");
7665
7666    if agents_path.exists() {
7667        println!(
7668            "{} AGENTS.md already exists at {}",
7669            "!".truecolor(sky_r, sky_g, sky_b),
7670            agents_path.display()
7671        );
7672        return Ok(());
7673    }
7674
7675    match create_default_agents_md(&workspace) {
7676        Ok(path) => {
7677            println!(
7678                "{} Created {}",
7679                "✓".truecolor(aqua_r, aqua_g, aqua_b),
7680                path.display()
7681            );
7682            println!();
7683            println!("Edit this file to customize how the AI agent works with your project.");
7684            println!("The instructions will be loaded automatically when you run codewhale.");
7685        }
7686        Err(e) => {
7687            println!(
7688                "{} Failed to create AGENTS.md: {}",
7689                "✗".truecolor(red_r, red_g, red_b),
7690                e
7691            );
7692        }
7693    }
7694
7695    Ok(())
7696}
7697
7698fn resolve_workspace(cli: &Cli) -> PathBuf {
7699    cli.workspace
7700        .clone()
7701        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
7702}
7703
7704fn load_config_from_cli(cli: &Cli) -> Result<Config> {
7705    load_config_from_cli_with_effective_profile(cli).map(|(config, _)| config)
7706}
7707
7708/// Doctor is a structural report unless the user explicitly asks it to probe
7709/// a provider endpoint. Keep credential-bearing environment values out of the
7710/// regular diagnostic configuration so an unrelated renderer or error path
7711/// cannot disclose them.
7712fn load_doctor_config_from_cli(cli: &Cli, args: &DoctorArgs) -> Result<Config> {
7713    if args.probe_api || args.probe_local {
7714        return load_config_from_cli(cli);
7715    }
7716    load_structural_config_from_cli(cli)
7717}
7718
7719fn load_structural_config_from_cli(cli: &Cli) -> Result<Config> {
7720    let profile = effective_config_profile(cli);
7721    let mut config = Config::load_structural(cli.config.clone(), profile.as_deref())?;
7722    if let Ok(settings) = crate::settings::Settings::load_read_only() {
7723        apply_saved_reasoning_preference(&mut config, &settings);
7724    }
7725    cli.feature_toggles.apply(&mut config)?;
7726    Ok(config)
7727}
7728
7729fn effective_config_profile(cli: &Cli) -> Option<String> {
7730    cli.profile
7731        .clone()
7732        .or_else(|| std::env::var("CODEWHALE_PROFILE").ok())
7733        .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok())
7734}
7735
7736fn load_config_from_cli_with_effective_profile(cli: &Cli) -> Result<(Config, Option<String>)> {
7737    let profile = effective_config_profile(cli);
7738    let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
7739    // Config loading is shared by diagnostics and mutating runtimes. Read the
7740    // saved preference without migrating or creating state here; interactive
7741    // startup performs any permitted migration later through `Settings::load`.
7742    if let Ok(settings) = crate::settings::Settings::load_read_only() {
7743        apply_saved_reasoning_preference(&mut config, &settings);
7744    }
7745    cli.feature_toggles.apply(&mut config)?;
7746    Ok((config, profile))
7747}
7748
7749/// Apply the same reasoning-preference precedence as interactive `App`
7750/// construction to non-TUI runtimes.
7751///
7752/// `/model` and the config editor persist this preference in `settings.toml`.
7753/// Exec, review, workflow, ACP, and runtime-thread launches all begin with a
7754/// `Config`, so copying the saved value here keeps those entry points from
7755/// silently falling back to a route classifier or an older config.toml value.
7756fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::settings::Settings) {
7757    let Some(reasoning_effort) = settings.reasoning_effort.as_ref() else {
7758        return;
7759    };
7760    config.reasoning_effort = Some(reasoning_effort.clone());
7761    config.reasoning_effort_inferred_from_legacy_alias = false;
7762}
7763
7764fn read_api_key_from_stdin() -> Result<String> {
7765    let mut stdin = io::stdin();
7766    if stdin.is_terminal() {
7767        bail!("No API key provided. Pass --api-key or pipe one via stdin.");
7768    }
7769    let mut buffer = String::new();
7770    stdin.read_to_string(&mut buffer)?;
7771    let api_key = buffer.trim().to_string();
7772    if api_key.is_empty() {
7773        bail!("No API key provided via stdin.");
7774    }
7775    Ok(api_key)
7776}
7777
7778fn run_login(api_key: Option<String>) -> Result<()> {
7779    let api_key = match api_key {
7780        Some(key) => key,
7781        None => read_api_key_from_stdin()?,
7782    };
7783    let saved = config::save_api_key(&api_key)?;
7784    println!("Saved API key to {}", saved.describe());
7785    Ok(())
7786}
7787
7788fn run_logout() -> Result<()> {
7789    config::clear_api_key()?;
7790    println!("Cleared saved API key.");
7791    Ok(())
7792}
7793
7794async fn run_xai_device_auth(config_path: Option<&Path>) -> Result<()> {
7795    let pending = xai_oauth::device_code_login().await?;
7796    let activation = xai_oauth::activate_device_login(pending, config_path, None)?;
7797    println!(
7798        "xAI OAuth is ready; activated {} via {}",
7799        codewhale_config::quote_os_path(&activation.auth_path),
7800        codewhale_config::quote_os_path(&activation.config_path)
7801    );
7802    Ok(())
7803}
7804
7805fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
7806    if last {
7807        return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
7808            anyhow!(
7809                "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume <SESSION_ID>` to resume one explicitly.",
7810                workspace.display()
7811            )
7812        });
7813    }
7814    if let Some(id) = session_id {
7815        return Ok(id);
7816    }
7817    pick_session_id()
7818}
7819
7820fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
7821    let manager = SessionManager::default_location()?;
7822    Ok(manager
7823        .get_latest_session_for_workspace(workspace)?
7824        .map(|session| session.id))
7825}
7826
7827fn fork_session(
7828    config: &Config,
7829    session_id: Option<String>,
7830    last: bool,
7831    workspace: &Path,
7832) -> Result<String> {
7833    let manager = SessionManager::default_location()?;
7834    let saved = if last {
7835        let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
7836            bail!(
7837                "No saved sessions found for workspace {}.",
7838                workspace.display()
7839            );
7840        };
7841        manager.load_session(&meta.id)?
7842    } else {
7843        let id = resolve_session_id(session_id, false, workspace)?;
7844        manager.load_session_by_prefix(&id)?
7845    };
7846    let saved_provider_identity = saved
7847        .metadata
7848        .model_provider_id
7849        .as_deref()
7850        .filter(|identity| !identity.trim().is_empty())
7851        .unwrap_or(&saved.metadata.model_provider);
7852    let provider_identity = config
7853        .resolve_persisted_provider_identity(
7854            Some(&saved.metadata.model_provider),
7855            saved.metadata.model_provider_id.as_deref(),
7856        )
7857        .map_err(anyhow::Error::msg)
7858        .with_context(|| {
7859            format!(
7860                "saved session provider '{}' is unavailable; fork will not fall back",
7861                saved_provider_identity
7862            )
7863        })?;
7864
7865    let system_prompt = saved
7866        .system_prompt
7867        .as_ref()
7868        .map(|text| SystemPrompt::Text(text.clone()));
7869    let mut forked = create_saved_session(
7870        &saved.messages,
7871        &saved.metadata.model,
7872        &saved.metadata.workspace,
7873        saved.metadata.total_tokens,
7874        system_prompt.as_ref(),
7875    );
7876    forked.metadata.set_model_provider_route(
7877        provider_identity.provider.as_str(),
7878        provider_identity.persisted_id(),
7879    );
7880    forked.metadata.copy_cost_from(&saved.metadata);
7881    forked.metadata.mark_forked_from(&saved.metadata);
7882    manager.save_session(&forked)?;
7883
7884    let source_title = saved.metadata.title.trim();
7885    let source_label = if source_title.is_empty() {
7886        "session".to_string()
7887    } else {
7888        format!("\"{source_title}\"")
7889    };
7890    println!(
7891        "Forked {source_label} ({source_id}) → new session {new_id}",
7892        source_id = truncate_id(&saved.metadata.id),
7893        new_id = truncate_id(&forked.metadata.id),
7894    );
7895
7896    Ok(forked.metadata.id)
7897}
7898
7899fn pick_session_id() -> Result<String> {
7900    let manager = SessionManager::default_location()?;
7901    let sessions = manager.list_sessions()?;
7902    if sessions.is_empty() {
7903        bail!("No saved sessions found.");
7904    }
7905
7906    println!("Select a session to resume:");
7907    for (idx, session) in sessions.iter().enumerate() {
7908        println!("  {:>2}. {} ({})", idx + 1, session.title, session.id);
7909    }
7910    print!("Enter a number (or press Enter to cancel): ");
7911    io::stdout().flush()?;
7912
7913    let mut input = String::new();
7914    io::stdin().read_line(&mut input)?;
7915    let input = input.trim();
7916    if input.is_empty() {
7917        bail!("No session selected.");
7918    }
7919    let idx: usize = input
7920        .parse()
7921        .map_err(|_| anyhow::anyhow!("Invalid input"))?;
7922    let session = sessions
7923        .get(idx.saturating_sub(1))
7924        .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
7925    Ok(session.id.clone())
7926}
7927
7928async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
7929    use crate::client::DeepSeekClient;
7930
7931    let diff = collect_diff(&args)?;
7932    if diff.trim().is_empty() {
7933        bail!("No diff to review.");
7934    }
7935    validate_review_receipt_args(&args)?;
7936    if args.check_receipt {
7937        return run_review_receipt_check(&diff, &args);
7938    }
7939
7940    let model = resolve_review_model(config, args.model.as_deref());
7941    let route = resolve_cli_exec_route(config, &model, &diff, args.model.is_none()).await?;
7942    let execution_config = config_for_cli_route(config, &route);
7943    let route_provider = execution_config.provider_identity_for(route.provider);
7944    let model = route.model.clone();
7945    let user_prompt =
7946        format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
7947    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
7948        cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
7949    });
7950
7951    let system = SystemPrompt::Text(
7952        "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
7953Provide findings ordered by severity with file references, then open questions, then a brief summary."
7954            .to_string(),
7955    );
7956    let client = DeepSeekClient::new(&execution_config)?;
7957    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
7958    let request = MessageRequest {
7959        model: model.clone(),
7960        messages: vec![Message {
7961            role: "user".to_string(),
7962            content: vec![ContentBlock::Text {
7963                text: user_prompt,
7964                cache_control: None,
7965            }],
7966        }],
7967        max_tokens: client.effective_max_output_tokens(&request_route.model),
7968        system: Some(system),
7969        tools: None,
7970        tool_choice: None,
7971        metadata: None,
7972        thinking: None,
7973        reasoning_effort,
7974        stream: Some(false),
7975        temperature: None,
7976        top_p: None,
7977    };
7978
7979    let response = client.create_message(request).await?;
7980    let review_stop_reason = response.stop_reason.clone();
7981    let review_incomplete = crate::models::is_incomplete_stop_reason(review_stop_reason.as_deref());
7982    let mut output = String::new();
7983    for block in response.content {
7984        if let ContentBlock::Text { text, .. } = block {
7985            output.push_str(&text);
7986        }
7987    }
7988    // A truncated review must not become a receipt or a success. The partial
7989    // text is still printed for diagnostics below.
7990    let receipt = if args.write_receipt && !review_incomplete {
7991        let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
7992        let receipt = crate::tools::review::build_review_receipt(
7993            review_target_label(&args),
7994            &diff,
7995            &route_provider,
7996            &model,
7997            &parsed_output,
7998            &output,
7999            Vec::new(),
8000        );
8001        let path =
8002            crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref())?;
8003        Some((path, receipt))
8004    } else {
8005        None
8006    };
8007    let review_error = review_incomplete.then(|| {
8008        format!(
8009            "Model response incomplete: provider stop reason `{}`; the partial review was not accepted.",
8010            crate::models::stop_reason_detail(review_stop_reason.as_deref())
8011        )
8012    });
8013    if args.json {
8014        println!(
8015            "{}",
8016            serde_json::to_string_pretty(&serde_json::json!({
8017                "mode": "review",
8018                "provider": route_provider,
8019                "model": model,
8020                "success": !review_incomplete,
8021                "content": output,
8022                "stop_reason": review_stop_reason,
8023                "error": review_error,
8024                "receipt_path": receipt
8025                    .as_ref()
8026                    .map(|(path, _)| path.display().to_string()),
8027                "receipt": receipt.as_ref().map(|(_, receipt)| receipt),
8028            }))?
8029        );
8030        if let Some(error) = review_error {
8031            anyhow::bail!(error);
8032        }
8033    } else {
8034        println!("{output}");
8035        if let Some((path, _)) = receipt {
8036            eprintln!("Review receipt written: {}", path.display());
8037        }
8038        if let Some(error) = review_error {
8039            anyhow::bail!(error);
8040        }
8041    }
8042    Ok(())
8043}
8044
8045fn resolve_review_model(config: &Config, explicit_model: Option<&str>) -> String {
8046    explicit_model
8047        .map(str::trim)
8048        .filter(|model| !model.is_empty())
8049        .map(str::to_string)
8050        .unwrap_or_else(|| config.default_model())
8051}
8052
8053fn validate_review_receipt_args(args: &ReviewArgs) -> Result<()> {
8054    if args.receipt_path.is_some() && !args.write_receipt && !args.check_receipt {
8055        bail!("--receipt-path requires --write-receipt or --check-receipt");
8056    }
8057    if args.write_receipt && args.check_receipt {
8058        bail!("--write-receipt and --check-receipt are mutually exclusive");
8059    }
8060    Ok(())
8061}
8062
8063fn run_review_receipt_check(diff: &str, args: &ReviewArgs) -> Result<()> {
8064    let (path, receipt) = if let Some(path) = args.receipt_path.as_ref() {
8065        (
8066            path.clone(),
8067            crate::tools::review::read_review_receipt(path)
8068                .with_context(|| format!("failed to read review receipt {}", path.display()))?,
8069        )
8070    } else {
8071        crate::tools::review::latest_review_receipt_for_diff(diff)?.ok_or_else(|| {
8072            anyhow!(
8073                "No review receipt found for the current diff. Run `codewhale review --write-receipt` first, or pass --receipt-path."
8074            )
8075        })?
8076    };
8077    let validation =
8078        crate::tools::review::validate_review_receipt_for_diff(diff, &receipt, Some(path.clone()));
8079
8080    if args.json {
8081        println!(
8082            "{}",
8083            serde_json::to_string_pretty(&serde_json::json!({
8084                "mode": "review_receipt_check",
8085                "success": validation.passed,
8086                "validation": review_receipt_validation_public_json(&validation),
8087            }))?
8088        );
8089    } else if validation.passed {
8090        println!("Review receipt valid: {}", path.display());
8091    }
8092
8093    if !validation.passed {
8094        bail!("Review receipt check failed: {}", validation.reason);
8095    }
8096    Ok(())
8097}
8098
8099fn review_receipt_validation_public_json(
8100    validation: &crate::tools::review::ReviewReceiptValidation,
8101) -> serde_json::Value {
8102    let unresolved_risk = validation.unresolved_risk.as_ref();
8103    serde_json::json!({
8104        "passed": validation.passed,
8105        "status": review_receipt_validation_status(validation),
8106        "diff_fingerprint": validation.diff_fingerprint.as_str(),
8107        "receipt_fingerprint": validation.receipt_fingerprint.as_deref(),
8108        "unresolved": unresolved_risk.is_some_and(|risk| risk.unresolved),
8109        "risk_level": unresolved_risk.map(|risk| risk.level.as_str()),
8110    })
8111}
8112
8113fn review_receipt_validation_status(
8114    validation: &crate::tools::review::ReviewReceiptValidation,
8115) -> &'static str {
8116    if validation.passed {
8117        "valid"
8118    } else if validation
8119        .receipt_fingerprint
8120        .as_deref()
8121        .is_some_and(|fingerprint| fingerprint != validation.diff_fingerprint.as_str())
8122    {
8123        "diff_mismatch"
8124    } else if validation
8125        .unresolved_risk
8126        .as_ref()
8127        .is_some_and(|risk| risk.unresolved)
8128    {
8129        "unresolved_risk"
8130    } else if validation
8131        .reason
8132        .starts_with("unsupported review receipt schema version")
8133    {
8134        "unsupported_schema"
8135    } else if validation.reason.starts_with("review receipt check ") {
8136        "check_failed"
8137    } else {
8138        "invalid"
8139    }
8140}
8141
8142/// `codewhale pr <N>` (#451) — fetch a GitHub PR via `gh`, format
8143/// title + body + diff as the composer's first message, and launch
8144/// the interactive TUI. Falls back gracefully if `gh` is missing.
8145async fn run_pr(
8146    cli: &Cli,
8147    config: &Config,
8148    number: u32,
8149    repo: Option<&str>,
8150    checkout: bool,
8151    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
8152    plugin_registry: Arc<crate::plugins::PluginRegistry>,
8153) -> Result<()> {
8154    if !is_command_available("gh") {
8155        bail!(
8156            "`gh` CLI not found on PATH. Install GitHub CLI \
8157             (https://cli.github.com) and authenticate (`gh auth login`) \
8158             so `codewhale pr <N>` can fetch PR metadata and the diff."
8159        );
8160    }
8161
8162    let view = run_gh_pr_view(number, repo)?;
8163    let diff = run_gh_pr_diff(number, repo)?;
8164
8165    if checkout {
8166        match run_gh_pr_checkout(number, repo) {
8167            Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
8168            Err(err) => eprintln!(
8169                "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
8170            ),
8171        }
8172    }
8173
8174    let prompt = format_pr_prompt(number, &view, &diff);
8175    let resume_session_id = if cli.continue_session {
8176        let workspace = resolve_workspace(cli);
8177        latest_session_id_for_workspace(&workspace).ok().flatten()
8178    } else {
8179        cli.resume.clone()
8180    };
8181    run_interactive(
8182        cli,
8183        config,
8184        resume_session_id,
8185        Some(tui::InitialInput::Prefill(prompt)),
8186        pending_telemetry_notice,
8187        plugin_registry,
8188    )
8189    .await
8190}
8191
8192/// Return true if `name` resolves to an executable on the current `PATH`.
8193///
8194/// Walks `$PATH` directly instead of probing with `--version`. The
8195/// previous implementation invoked `Command::new(name).arg("--version")`,
8196/// which fails on the Ubuntu CI runner because `/bin/sh` is `dash` —
8197/// `dash --version` exits with status 2 ("invalid option") even though
8198/// `sh` is plainly on PATH. macOS happens to ship bash as `sh`, which
8199/// does honor `--version`, so the bug was invisible locally and only
8200/// surfaced in CI logs.
8201///
8202/// Windows: also checks the `.exe` extension when `name` doesn't have
8203/// one, matching the platform's PATHEXT lookup behavior for the common
8204/// case.
8205fn is_command_available(name: &str) -> bool {
8206    let Some(path) = std::env::var_os("PATH") else {
8207        return false;
8208    };
8209    for dir in std::env::split_paths(&path) {
8210        let candidate = dir.join(name);
8211        if candidate.is_file() {
8212            return true;
8213        }
8214        #[cfg(windows)]
8215        {
8216            // PATHEXT gives `.exe`/`.cmd`/`.bat` etc. priority — we only
8217            // probe `.exe` because that's the case that actually trips
8218            // up the negative case (`gh` resolves as `gh.exe`).
8219            if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
8220                return true;
8221            }
8222        }
8223    }
8224    false
8225}
8226
8227#[derive(Debug, Clone, Default)]
8228struct GhPullRequest {
8229    title: String,
8230    body: String,
8231    base: String,
8232    head: String,
8233    url: String,
8234}
8235
8236fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
8237    let mut cmd = crate::dependencies::Gh::command()
8238        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8239    cmd.arg("pr").arg("view").arg(number.to_string());
8240    if let Some(r) = repo {
8241        cmd.arg("--repo").arg(r);
8242    }
8243    cmd.arg("--json")
8244        .arg("title,body,baseRefName,headRefName,url");
8245    let output = cmd
8246        .output()
8247        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
8248    if !output.status.success() {
8249        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8250        bail!("gh pr view #{number} failed: {stderr}");
8251    }
8252    let raw = String::from_utf8_lossy(&output.stdout).to_string();
8253    let value: serde_json::Value = serde_json::from_str(&raw)
8254        .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
8255    let pick = |key: &str| {
8256        value
8257            .get(key)
8258            .and_then(serde_json::Value::as_str)
8259            .unwrap_or_default()
8260            .to_string()
8261    };
8262    Ok(GhPullRequest {
8263        title: pick("title"),
8264        body: pick("body"),
8265        base: pick("baseRefName"),
8266        head: pick("headRefName"),
8267        url: pick("url"),
8268    })
8269}
8270
8271fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
8272    let mut cmd = crate::dependencies::Gh::command()
8273        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8274    cmd.arg("pr").arg("diff").arg(number.to_string());
8275    if let Some(r) = repo {
8276        cmd.arg("--repo").arg(r);
8277    }
8278    let output = cmd
8279        .output()
8280        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
8281    if !output.status.success() {
8282        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8283        bail!("gh pr diff #{number} failed: {stderr}");
8284    }
8285    Ok(String::from_utf8_lossy(&output.stdout).to_string())
8286}
8287
8288fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
8289    let mut cmd = crate::dependencies::Gh::command()
8290        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8291    cmd.arg("pr").arg("checkout").arg(number.to_string());
8292    if let Some(r) = repo {
8293        cmd.arg("--repo").arg(r);
8294    }
8295    let output = cmd
8296        .output()
8297        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
8298    if !output.status.success() {
8299        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8300        bail!("gh pr checkout #{number} failed: {stderr}");
8301    }
8302    Ok(())
8303}
8304
8305/// Format the PR review prompt that lands in the composer. Caps the
8306/// diff at 200 KiB so a massive PR doesn't blow the model's context
8307/// window before the user even hits Enter — they can always ask the
8308/// model to fetch more via `gh pr diff #N` from inside the session.
8309fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
8310    const MAX_DIFF_BYTES: usize = 200 * 1024;
8311    let diff_section = if diff.len() > MAX_DIFF_BYTES {
8312        let cut = (0..=MAX_DIFF_BYTES)
8313            .rev()
8314            .find(|&i| diff.is_char_boundary(i))
8315            .unwrap_or(0);
8316        format!(
8317            "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
8318            &diff[..cut],
8319            MAX_DIFF_BYTES / 1024
8320        )
8321    } else {
8322        diff.to_string()
8323    };
8324    let body = if view.body.trim().is_empty() {
8325        "(no description)".to_string()
8326    } else {
8327        view.body.trim().to_string()
8328    };
8329    let title = if view.title.trim().is_empty() {
8330        format!("(PR #{number})")
8331    } else {
8332        view.title.trim().to_string()
8333    };
8334    let branches = match (view.base.is_empty(), view.head.is_empty()) {
8335        (false, false) => format!("{} ← {}", view.base, view.head),
8336        (false, true) => view.base.clone(),
8337        (true, false) => view.head.clone(),
8338        _ => "(unknown)".to_string(),
8339    };
8340    format!(
8341        "Review PR #{number} — {title}\n\
8342         \n\
8343         URL: {url}\n\
8344         Branches: {branches}\n\
8345         \n\
8346         ## Description\n\
8347         \n\
8348         {body}\n\
8349         \n\
8350         ## Diff\n\
8351         \n\
8352         ```diff\n\
8353         {diff_section}\n\
8354         ```\n",
8355        url = if view.url.is_empty() {
8356            "(unavailable)"
8357        } else {
8358            view.url.as_str()
8359        },
8360    )
8361}
8362
8363fn collect_diff(args: &ReviewArgs) -> Result<String> {
8364    let mut cmd = crate::dependencies::Git::command()
8365        .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
8366    cmd.arg("diff");
8367    if args.staged {
8368        cmd.arg("--cached");
8369    }
8370    if let Some(base) = &args.base {
8371        cmd.arg(format!("{base}...HEAD"));
8372    }
8373    if let Some(path) = &args.path {
8374        cmd.arg("--").arg(path);
8375    }
8376
8377    let output = cmd
8378        .output()
8379        .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
8380    if !output.status.success() {
8381        let stderr = String::from_utf8_lossy(&output.stderr);
8382        bail!("git diff failed: {}", stderr.trim());
8383    }
8384    let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
8385    if diff.len() > args.max_chars {
8386        diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
8387    }
8388    Ok(diff)
8389}
8390
8391fn review_target_label(args: &ReviewArgs) -> String {
8392    let mut label = if args.staged {
8393        "staged".to_string()
8394    } else if let Some(base) = args
8395        .base
8396        .as_deref()
8397        .map(str::trim)
8398        .filter(|base| !base.is_empty())
8399    {
8400        format!("base:{base}")
8401    } else {
8402        "working-tree".to_string()
8403    };
8404    if let Some(path) = &args.path {
8405        label.push(' ');
8406        label.push_str(path.to_string_lossy().as_ref());
8407    }
8408    label
8409}
8410
8411fn run_apply(args: ApplyArgs) -> Result<()> {
8412    let patch = if let Some(path) = args.patch_file {
8413        std::fs::read_to_string(&path)
8414            .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
8415    } else {
8416        read_patch_from_stdin()?
8417    };
8418    if patch.trim().is_empty() {
8419        bail!("Patch is empty.");
8420    }
8421
8422    let mut tmp = NamedTempFile::new()?;
8423    tmp.write_all(patch.as_bytes())?;
8424    let tmp_path = tmp.path().to_path_buf();
8425
8426    let output = crate::dependencies::Git::command()
8427        .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?
8428        .arg("apply")
8429        .arg("--whitespace=nowarn")
8430        .arg(&tmp_path)
8431        .output()
8432        .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?;
8433
8434    if !output.status.success() {
8435        let stderr = String::from_utf8_lossy(&output.stderr);
8436        bail!("git apply failed: {}", stderr.trim());
8437    }
8438    println!("Applied patch successfully.");
8439    Ok(())
8440}
8441
8442fn read_patch_from_stdin() -> Result<String> {
8443    let mut stdin = io::stdin();
8444    if stdin.is_terminal() {
8445        bail!("No patch file provided and stdin is empty.");
8446    }
8447    let mut buffer = String::new();
8448    stdin.read_to_string(&mut buffer)?;
8449    Ok(buffer)
8450}
8451
8452async fn run_mcp_command(
8453    config: &Config,
8454    workspace: &Path,
8455    command: McpCommand,
8456    plugins: &crate::plugins::PluginRegistry,
8457) -> Result<()> {
8458    let config_path = config.mcp_config_path();
8459    match command {
8460        McpCommand::Init { force } => {
8461            let status = init_mcp_config(&config_path, force)?;
8462            match status {
8463                WriteStatus::Created => {
8464                    println!("Created MCP config at {}", config_path.display());
8465                }
8466                WriteStatus::Overwritten => {
8467                    println!("Overwrote MCP config at {}", config_path.display());
8468                }
8469                WriteStatus::SkippedExists => {
8470                    println!(
8471                        "MCP config already exists at {} (use --force to overwrite)",
8472                        config_path.display()
8473                    );
8474                }
8475            }
8476            println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`.");
8477            Ok(())
8478        }
8479        McpCommand::List => {
8480            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8481                &config_path,
8482                workspace,
8483                plugins,
8484            )?;
8485            if cfg.servers.is_empty() {
8486                println!(
8487                    "No MCP servers configured in {} or {}",
8488                    config_path.display(),
8489                    crate::mcp::workspace_mcp_config_path(workspace).display()
8490                );
8491                return Ok(());
8492            }
8493            println!("MCP servers ({}):", cfg.servers.len());
8494            for (name, server) in cfg.servers {
8495                let status = if server.enabled && !server.disabled {
8496                    "enabled"
8497                } else {
8498                    "disabled"
8499                };
8500                let auth_status = crate::mcp::oauth::auth_status_for_server(&name, &server).await;
8501                let auth = if auth_status == crate::mcp::oauth::McpAuthStatus::Unsupported {
8502                    String::new()
8503                } else {
8504                    format!(
8505                        " auth={}",
8506                        auth_status
8507                            .to_string()
8508                            .to_ascii_lowercase()
8509                            .replace(' ', "-")
8510                    )
8511                };
8512                let args = if server.args.is_empty() {
8513                    "".to_string()
8514                } else {
8515                    format!(" {}", server.args.join(" "))
8516                };
8517                let cmd_str = if let Some(cmd) = server.command {
8518                    format!("{cmd}{args}")
8519                } else if let Some(url) = server.url {
8520                    url
8521                } else {
8522                    "unknown".to_string()
8523                };
8524                let required = if server.required { " required" } else { "" };
8525                println!("  - {name} [{status}{required}{auth}] {cmd_str}");
8526            }
8527            Ok(())
8528        }
8529        McpCommand::Connect { server } => {
8530            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8531                &config_path,
8532                workspace,
8533                std::sync::Arc::new(plugins.clone()),
8534            )?;
8535            if let Some(name) = server {
8536                if let Err(err) = pool.get_or_connect(&name).await {
8537                    if crate::mcp::oauth::error_looks_auth_required(&err) {
8538                        let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8539                        return Err(err).context(hint);
8540                    }
8541                    return Err(err);
8542                }
8543                println!("Connected to MCP server: {name}");
8544            } else {
8545                let errors = pool.connect_all().await;
8546                if errors.is_empty() {
8547                    println!("Connected to all configured MCP servers.");
8548                } else {
8549                    for (name, err) in errors {
8550                        eprintln!("Failed to connect {name}: {err:#}");
8551                        if crate::mcp::oauth::error_looks_auth_required(&err) {
8552                            eprintln!("  {}", crate::mcp::oauth::auth_required_login_hint(&name));
8553                        }
8554                    }
8555                }
8556            }
8557            Ok(())
8558        }
8559        McpCommand::Tools { server } => {
8560            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8561                &config_path,
8562                workspace,
8563                std::sync::Arc::new(plugins.clone()),
8564            )?;
8565            if let Some(name) = server {
8566                let conn = match pool.get_or_connect(&name).await {
8567                    Ok(conn) => conn,
8568                    Err(err) => {
8569                        if crate::mcp::oauth::error_looks_auth_required(&err) {
8570                            let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8571                            return Err(err).context(hint);
8572                        }
8573                        return Err(err);
8574                    }
8575                };
8576                if conn.tools().is_empty() {
8577                    println!("No tools found for MCP server: {name}");
8578                } else {
8579                    println!("Tools for {name}:");
8580                    for tool in conn.tools() {
8581                        println!(
8582                            "  - {}{}",
8583                            tool.name,
8584                            crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8585                        );
8586                    }
8587                }
8588            } else {
8589                let errors = pool.connect_all().await;
8590                for (name, err) in errors {
8591                    eprintln!("Failed to connect {name}: {err:#}");
8592                    if crate::mcp::oauth::error_looks_auth_required(&err) {
8593                        eprintln!("  {}", crate::mcp::oauth::auth_required_login_hint(&name));
8594                    }
8595                }
8596                let tools = pool.all_tools();
8597                if tools.is_empty() {
8598                    println!("No MCP tools discovered.");
8599                } else {
8600                    println!("MCP tools:");
8601                    for (name, tool) in tools {
8602                        println!(
8603                            "  - {}{}",
8604                            name,
8605                            crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8606                        );
8607                    }
8608                }
8609            }
8610            Ok(())
8611        }
8612        McpCommand::Add {
8613            name,
8614            command,
8615            url,
8616            transport,
8617            bearer_token_env_var,
8618            oauth_client_id,
8619            oauth_resource,
8620            scopes,
8621            args,
8622        } => {
8623            if command.is_none() && url.is_none() {
8624                bail!("Provide either --command or --url for `mcp add`.");
8625            }
8626            if let Some(transport) = transport.as_deref()
8627                && !transport.trim().eq_ignore_ascii_case("sse")
8628            {
8629                bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
8630            }
8631            let added_server = McpServerConfig {
8632                command,
8633                args,
8634                env: std::collections::HashMap::new(),
8635                cwd: None,
8636                url,
8637                transport,
8638                connect_timeout: None,
8639                execute_timeout: None,
8640                read_timeout: None,
8641                disabled: false,
8642                enabled: true,
8643                required: false,
8644                enabled_tools: Vec::new(),
8645                disabled_tools: Vec::new(),
8646                headers: std::collections::HashMap::new(),
8647                env_headers: std::collections::HashMap::new(),
8648                bearer_token_env_var,
8649                scopes,
8650                oauth: oauth_client_id.map(|client_id| McpServerOAuthConfig {
8651                    client_id: Some(client_id),
8652                }),
8653                oauth_resource,
8654                reviewed_plugin: None,
8655            };
8656            let can_suggest_oauth = added_server.url.is_some()
8657                && added_server.bearer_token_env_var.is_none()
8658                && added_server
8659                    .headers
8660                    .keys()
8661                    .all(|key| !key.trim().eq_ignore_ascii_case("authorization"))
8662                && added_server
8663                    .env_headers
8664                    .keys()
8665                    .all(|key| !key.trim().eq_ignore_ascii_case("authorization"));
8666            let mut cfg = load_mcp_config(&config_path)?;
8667            cfg.servers.insert(name.clone(), added_server.clone());
8668            save_mcp_config(&config_path, &cfg)?;
8669            println!("Added MCP server '{name}' in {}", config_path.display());
8670            if can_suggest_oauth
8671                && crate::mcp::oauth::oauth_login_support(&added_server)
8672                    .await
8673                    .is_ok_and(|support| support.is_some())
8674            {
8675                println!(
8676                    "OAuth is available for '{name}'. Run `codewhale mcp login {name}` to authenticate."
8677                );
8678            }
8679            Ok(())
8680        }
8681        McpCommand::Login { name, scopes } => {
8682            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8683                &config_path,
8684                workspace,
8685                plugins,
8686            )?;
8687            let server = cfg
8688                .servers
8689                .get(&name)
8690                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8691            let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
8692            crate::mcp::oauth::perform_oauth_login_for_server(
8693                &name,
8694                server,
8695                explicit_scopes,
8696                config.mcp_oauth_callback_port,
8697                config.mcp_oauth_callback_url.as_deref(),
8698            )
8699            .await?;
8700            println!("Stored OAuth credentials for MCP server '{name}'.");
8701            Ok(())
8702        }
8703        McpCommand::Logout { name } => {
8704            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8705                &config_path,
8706                workspace,
8707                plugins,
8708            )?;
8709            let server = cfg
8710                .servers
8711                .get(&name)
8712                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8713            if crate::mcp::oauth::delete_oauth_tokens_for_server(&name, server)? {
8714                println!("Deleted stored OAuth credentials for MCP server '{name}'.");
8715            } else {
8716                println!("No stored OAuth credentials found for MCP server '{name}'.");
8717            }
8718            Ok(())
8719        }
8720        McpCommand::Remove { name } => {
8721            let mut cfg = load_mcp_config(&config_path)?;
8722            if cfg.servers.remove(&name).is_none() {
8723                bail!("MCP server '{name}' not found");
8724            }
8725            save_mcp_config(&config_path, &cfg)?;
8726            println!("Removed MCP server '{name}'");
8727            Ok(())
8728        }
8729        McpCommand::Enable { name } => {
8730            let mut cfg = load_mcp_config(&config_path)?;
8731            let server = cfg
8732                .servers
8733                .get_mut(&name)
8734                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8735            server.enabled = true;
8736            server.disabled = false;
8737            save_mcp_config(&config_path, &cfg)?;
8738            println!("Enabled MCP server '{name}'");
8739            Ok(())
8740        }
8741        McpCommand::Disable { name } => {
8742            let mut cfg = load_mcp_config(&config_path)?;
8743            let server = cfg
8744                .servers
8745                .get_mut(&name)
8746                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8747            server.enabled = false;
8748            server.disabled = true;
8749            save_mcp_config(&config_path, &cfg)?;
8750            println!("Disabled MCP server '{name}'");
8751            Ok(())
8752        }
8753        McpCommand::Validate => {
8754            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8755                &config_path,
8756                workspace,
8757                std::sync::Arc::new(plugins.clone()),
8758            )?;
8759            let errors = pool.connect_all().await;
8760            if errors.is_empty() {
8761                println!("MCP config is valid. All enabled servers connected.");
8762                return Ok(());
8763            }
8764            eprintln!("MCP validation failed:");
8765            for (name, err) in errors {
8766                eprintln!("  - {name}: {err:#}");
8767            }
8768            bail!("one or more MCP servers failed validation");
8769        }
8770        McpCommand::AddSelf { name, workspace } => {
8771            let exe_path = std::env::current_exe()
8772                .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
8773            let exe_str = exe_path.to_string_lossy().to_string();
8774
8775            let mut args = vec!["serve".to_string(), "--mcp".to_string()];
8776            if let Some(ref ws) = workspace {
8777                args.push("--workspace".to_string());
8778                args.push(ws.clone());
8779            }
8780
8781            let mut cfg = load_mcp_config(&config_path)?;
8782            if cfg.servers.contains_key(&name) {
8783                bail!(
8784                    "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.",
8785                    config_path.display()
8786                );
8787            }
8788            cfg.servers.insert(
8789                name.clone(),
8790                McpServerConfig {
8791                    command: Some(exe_str.clone()),
8792                    args,
8793                    env: std::collections::HashMap::new(),
8794                    cwd: None,
8795                    url: None,
8796                    transport: None,
8797                    connect_timeout: None,
8798                    execute_timeout: None,
8799                    read_timeout: None,
8800                    disabled: false,
8801                    enabled: true,
8802                    required: false,
8803                    enabled_tools: Vec::new(),
8804                    disabled_tools: Vec::new(),
8805                    headers: std::collections::HashMap::new(),
8806                    env_headers: std::collections::HashMap::new(),
8807                    bearer_token_env_var: None,
8808                    scopes: Vec::new(),
8809                    oauth: None,
8810                    oauth_resource: None,
8811                    reviewed_plugin: None,
8812                },
8813            );
8814            save_mcp_config(&config_path, &cfg)?;
8815            println!(
8816                "Registered Codewhale as MCP server '{name}' in {}",
8817                config_path.display()
8818            );
8819            println!("  command: {exe_str}");
8820            println!(
8821                "  args:    serve --mcp{}",
8822                workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
8823            );
8824            println!();
8825            println!("Tip: Use `codewhale mcp validate` to test the connection.");
8826            println!("     Use `codewhale serve --http` for the HTTP/SSE runtime API instead.");
8827            Ok(())
8828        }
8829    }
8830}
8831
8832fn load_mcp_config(path: &Path) -> Result<McpConfig> {
8833    if !path.exists() {
8834        return Ok(McpConfig::default());
8835    }
8836    let contents = std::fs::read_to_string(path)
8837        .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
8838    let cfg: McpConfig = serde_json::from_str(&contents).map_err(|_| {
8839        anyhow::anyhow!(
8840            "Failed to parse MCP config {}; file contents were omitted",
8841            codewhale_config::quote_os_path(path)
8842        )
8843    })?;
8844    Ok(cfg)
8845}
8846
8847/// Diagnostic status for an MCP server entry.
8848#[derive(Debug)]
8849enum McpServerDoctorStatus {
8850    Ok(String),
8851    Warning(String),
8852    Error(String),
8853}
8854
8855impl McpServerDoctorStatus {
8856    fn legacy_status(&self) -> &'static str {
8857        match self {
8858            Self::Ok(_) => "ok",
8859            Self::Warning(_) => "warning",
8860            Self::Error(_) => "error",
8861        }
8862    }
8863
8864    fn configuration_status(&self) -> &'static str {
8865        match self {
8866            Self::Ok(_) => "valid",
8867            Self::Warning(_) => "warning",
8868            Self::Error(_) => "invalid",
8869        }
8870    }
8871
8872    fn detail(&self) -> &str {
8873        match self {
8874            Self::Ok(detail) | Self::Warning(detail) | Self::Error(detail) => detail,
8875        }
8876    }
8877}
8878
8879/// Inspect command availability without starting the configured MCP server.
8880fn doctor_mcp_command_status(server: &McpServerConfig) -> McpCommandAvailability {
8881    if server.url.is_some() {
8882        return McpCommandAvailability::NotApplicable;
8883    }
8884    match server.command.as_deref() {
8885        Some("") => McpCommandAvailability::Missing,
8886        Some(_) | None => McpCommandAvailability::NotChecked,
8887    }
8888}
8889
8890fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::Value {
8891    use serde_json::json;
8892
8893    let status = doctor_check_mcp_server(server);
8894    json!({
8895        "name": name,
8896        "enabled": server.enabled && !server.disabled,
8897        // Compatibility field retained for existing doctor JSON consumers.
8898        // Its scope is now explicit in `checks.configuration` below.
8899        "status": status.legacy_status(),
8900        "detail": status.detail(),
8901        "transport": if server.url.is_some() { "http" } else { "stdio" },
8902        "endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
8903        "command_configured": server.command.is_some(),
8904        "args_count": server.args.len(),
8905        "env_count": server.env.len(),
8906        "headers_count": server.headers.len(),
8907        "env_headers_count": server.env_headers.len(),
8908        "check_scope": "configuration",
8909        "checks": {
8910            "configuration": {
8911                "status": status.configuration_status(),
8912                "detail": status.detail(),
8913            },
8914            "command": {
8915                "status": doctor_mcp_command_status(server).as_str(),
8916            },
8917            "process_reachable": {
8918                "status": "not_checked",
8919            },
8920            "protocol_initialized": {
8921                "status": "not_checked",
8922            },
8923            "backend_tool_health": {
8924                "status": "not_checked",
8925            },
8926        },
8927    })
8928}
8929
8930/// Check an MCP server config entry for common issues.
8931fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
8932    // No command or URL — incomplete entry.
8933    if server.command.is_none() && server.url.is_none() {
8934        return McpServerDoctorStatus::Error("no command or url configured".to_string());
8935    }
8936
8937    // URL-based server: omit userinfo, query, and fragment entirely.
8938    if let Some(ref url) = server.url {
8939        let authority = crate::doctor::structural_url_authority(url);
8940        return if authority.starts_with("unparseable") {
8941            McpServerDoctorStatus::Warning(
8942                "HTTP/SSE server URL is invalid; configured value omitted".to_string(),
8943            )
8944        } else {
8945            McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {authority}"))
8946        };
8947    }
8948
8949    // Command-based: validate command path exists.
8950    let cmd = server.command.as_deref().unwrap_or("");
8951    if cmd.is_empty() {
8952        return McpServerDoctorStatus::Error("empty command".to_string());
8953    }
8954
8955    if server.cwd.is_none() {
8956        if is_relative_stdio_path_arg(cmd) {
8957            return McpServerDoctorStatus::Warning(
8958                "stdio server uses a relative command without cwd; command value omitted"
8959                    .to_string(),
8960            );
8961        }
8962        if server
8963            .args
8964            .iter()
8965            .any(|arg| is_relative_stdio_path_arg(arg) && !is_scoped_npm_package_arg(cmd, arg))
8966        {
8967            return McpServerDoctorStatus::Warning(
8968                "stdio server uses a relative path argument without cwd; argument values omitted"
8969                    .to_string(),
8970            );
8971        }
8972    }
8973
8974    McpServerDoctorStatus::Ok(format!(
8975        "stdio server configured (command omitted; {} argument(s), {} environment binding(s))",
8976        server.args.len(),
8977        server.env.len()
8978    ))
8979}
8980
8981/// `@scope/package@version` is an npm package spec, not a relative filesystem
8982/// path, even though it contains `/`. Keep this exception tied to the npx
8983/// launcher so similarly shaped arguments to other commands retain the
8984/// relative-path warning.
8985fn is_scoped_npm_package_arg(command: &str, argument: &str) -> bool {
8986    let launcher = Path::new(command)
8987        .file_name()
8988        .and_then(|name| name.to_str())
8989        .unwrap_or(command);
8990    if !launcher.eq_ignore_ascii_case("npx") && !launcher.eq_ignore_ascii_case("npx.cmd") {
8991        return false;
8992    }
8993
8994    let Some(scoped) = argument.strip_prefix('@') else {
8995        return false;
8996    };
8997    let Some((scope, package_and_version)) = scoped.split_once('/') else {
8998        return false;
8999    };
9000    if scope.is_empty()
9001        || package_and_version.is_empty()
9002        || package_and_version.contains('/')
9003        || package_and_version.contains('\\')
9004    {
9005        return false;
9006    }
9007
9008    let (package, version) = match package_and_version.split_once('@') {
9009        Some((package, version)) => (package, Some(version)),
9010        None => (package_and_version, None),
9011    };
9012    let valid_name = |value: &str| {
9013        !value.is_empty()
9014            && !value.starts_with(['.', '_'])
9015            && value
9016                .chars()
9017                .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
9018    };
9019    valid_name(scope)
9020        && valid_name(package)
9021        && version.is_none_or(|value| {
9022            !value.is_empty()
9023                && !value.contains(['@', '/', '\\'])
9024                && !value.chars().any(char::is_whitespace)
9025        })
9026}
9027
9028fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
9029    if let Some(parent) = path.parent() {
9030        std::fs::create_dir_all(parent).with_context(|| {
9031            format!("Failed to create MCP config directory {}", parent.display())
9032        })?;
9033    }
9034    let rendered = serde_json::to_string_pretty(cfg)
9035        .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
9036    crate::utils::write_atomic(path, rendered.as_bytes())
9037        .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
9038    Ok(())
9039}
9040
9041fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
9042    use crate::sandbox::{CommandSpec, SandboxManager};
9043
9044    let SandboxCommand::Run {
9045        policy,
9046        network,
9047        writable_root,
9048        exclude_tmpdir,
9049        exclude_slash_tmp,
9050        cwd,
9051        timeout_ms,
9052        command,
9053    } = args.command;
9054
9055    let policy = parse_sandbox_policy(
9056        &policy,
9057        network,
9058        writable_root,
9059        exclude_tmpdir,
9060        exclude_slash_tmp,
9061    )?;
9062    let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9063    let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
9064
9065    let (program, args) = command
9066        .split_first()
9067        .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
9068    let spec =
9069        CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
9070    let manager = SandboxManager::new();
9071    let exec_env = manager.prepare(&spec);
9072
9073    let mut cmd = Command::new(exec_env.program());
9074    cmd.args(exec_env.args())
9075        .current_dir(&exec_env.cwd)
9076        .stdout(Stdio::piped())
9077        .stderr(Stdio::piped());
9078    child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
9079
9080    let mut child = cmd
9081        .spawn()
9082        .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
9083    let stdout_handle = child
9084        .stdout
9085        .take()
9086        .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
9087    let stderr_handle = child
9088        .stderr
9089        .take()
9090        .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
9091
9092    let timeout = exec_env.timeout;
9093    let stdout_thread = std::thread::spawn(move || {
9094        let mut reader = stdout_handle;
9095        let mut buf = Vec::new();
9096        let _ = reader.read_to_end(&mut buf);
9097        buf
9098    });
9099    let stderr_thread = std::thread::spawn(move || {
9100        let mut reader = stderr_handle;
9101        let mut buf = Vec::new();
9102        let _ = reader.read_to_end(&mut buf);
9103        buf
9104    });
9105
9106    if let Some(status) = child.wait_timeout(timeout)? {
9107        let stdout = stdout_thread.join().unwrap_or_default();
9108        let stderr = stderr_thread.join().unwrap_or_default();
9109        let stderr_str = String::from_utf8_lossy(&stderr);
9110        let exit_code = status.code().unwrap_or(-1);
9111        let sandbox_type = exec_env.sandbox_type;
9112        let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
9113
9114        if !stdout.is_empty() {
9115            print!("{}", String::from_utf8_lossy(&stdout));
9116        }
9117        if !stderr.is_empty() {
9118            eprint!("{stderr_str}");
9119        }
9120        if sandbox_denied {
9121            eprintln!(
9122                "{}",
9123                SandboxManager::denial_message(sandbox_type, &stderr_str)
9124            );
9125        }
9126
9127        if !status.success() {
9128            bail!("Command failed with exit code {exit_code}");
9129        }
9130    } else {
9131        let _ = child.kill();
9132        let _ = child.wait();
9133        bail!("Command timed out after {}ms", timeout.as_millis());
9134    }
9135    Ok(())
9136}
9137
9138fn parse_sandbox_policy(
9139    policy: &str,
9140    network: bool,
9141    writable_root: Vec<PathBuf>,
9142    exclude_tmpdir: bool,
9143    exclude_slash_tmp: bool,
9144) -> Result<crate::sandbox::SandboxPolicy> {
9145    use crate::sandbox::SandboxPolicy;
9146
9147    match policy {
9148        "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
9149        "read-only" => Ok(SandboxPolicy::ReadOnly),
9150        "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
9151            network_access: network,
9152        }),
9153        "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
9154            writable_roots: writable_root,
9155            network_access: network,
9156            exclude_tmpdir,
9157            exclude_slash_tmp,
9158        }),
9159        other => bail!("Unknown sandbox policy: {other}"),
9160    }
9161}
9162
9163fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
9164    true
9165}
9166
9167fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
9168    let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
9169    let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
9170    let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
9171    should_use_mouse_capture_with(
9172        cli,
9173        config,
9174        use_alt_screen,
9175        terminal_emulator.as_deref(),
9176        wt_session.as_deref(),
9177        conemu_pid.as_deref(),
9178    )
9179}
9180
9181fn should_use_mouse_capture_with(
9182    cli: &Cli,
9183    config: &Config,
9184    use_alt_screen: bool,
9185    terminal_emulator: Option<&str>,
9186    wt_session: Option<&str>,
9187    conemu_pid: Option<&str>,
9188) -> bool {
9189    if !use_alt_screen || cli.no_mouse_capture {
9190        return false;
9191    }
9192    if cli.mouse_capture {
9193        return true;
9194    }
9195    config
9196        .tui
9197        .as_ref()
9198        .and_then(|tui| tui.mouse_capture)
9199        .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
9200}
9201
9202/// Whether to enable terminal mouse capture by default for this platform/host.
9203///
9204/// On Windows the default depends on the host: Windows Terminal (which sets
9205/// `WT_SESSION`) and ConEmu/Cmder (which set `ConEmuPID`) handle mouse-mode
9206/// reporting cleanly, so default-on there gives users in-app text selection
9207/// and keeps the application's selection clamped to the transcript area
9208/// (#1169). Legacy conhost (CMD without either env var) stays default-off
9209/// because its mouse-mode reporting can leak SGR escape sequences as raw
9210/// text into the composer (#878 / #898).
9211///
9212/// Off elsewhere only for JetBrains' JediTerm, which advertises mouse
9213/// support but forwards the same SGR escape sequences as raw input. The
9214/// user can still opt back in with `[tui] mouse_capture = true` in
9215/// `~/.codewhale/config.toml` or `--mouse-capture`.
9216fn default_mouse_capture_enabled(
9217    terminal_emulator: Option<&str>,
9218    wt_session: Option<&str>,
9219    conemu_pid: Option<&str>,
9220) -> bool {
9221    if cfg!(windows) {
9222        return wt_session.is_some() || conemu_pid.is_some();
9223    }
9224    if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
9225        return false;
9226    }
9227    true
9228}
9229
9230/// A loadable crash-recovery checkpoint candidate: session content, file
9231/// age, and which slot it came from (per-session file or the legacy single
9232/// slot).
9233struct RecentCheckpoint {
9234    session: session_manager::SavedSession,
9235    age: std::time::Duration,
9236    source: session_manager::CheckpointSource,
9237}
9238
9239const CHECKPOINT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
9240
9241/// Load all recent crash-recovery checkpoints, pruning stale ones first.
9242///
9243/// Candidates are the per-session checkpoint files plus the legacy
9244/// single-slot `checkpoints/latest.json` (compatibility read). Files older
9245/// than 24 hours are removed; unreadable files are skipped. The result is
9246/// sorted most recent first.
9247fn load_recent_checkpoints(manager: &session_manager::SessionManager) -> Vec<RecentCheckpoint> {
9248    let refs = manager.list_checkpoints().unwrap_or_default();
9249    let mut recent = Vec::new();
9250    for checkpoint_ref in refs {
9251        let Ok(age) = std::time::SystemTime::now().duration_since(checkpoint_ref.modified) else {
9252            continue;
9253        };
9254        if age > CHECKPOINT_MAX_AGE {
9255            let _ = match &checkpoint_ref.source {
9256                session_manager::CheckpointSource::Session(id) => {
9257                    manager.clear_session_checkpoint(id)
9258                }
9259                session_manager::CheckpointSource::Legacy => manager.clear_legacy_checkpoint(),
9260            };
9261            continue;
9262        }
9263        let loaded = match &checkpoint_ref.source {
9264            session_manager::CheckpointSource::Session(id) => manager.load_session_checkpoint(id),
9265            session_manager::CheckpointSource::Legacy => manager.load_legacy_checkpoint(),
9266        };
9267        let Ok(Some(session)) = loaded else {
9268            continue;
9269        };
9270        recent.push(RecentCheckpoint {
9271            session,
9272            age,
9273            source: checkpoint_ref.source,
9274        });
9275    }
9276    // `list_checkpoints` sorts newest-first already; keep it explicit here so
9277    // selection does not silently depend on the manager's ordering.
9278    recent.sort_by_key(|c| c.age);
9279    recent
9280}
9281
9282fn checkpoint_age_label(age: std::time::Duration) -> String {
9283    if age.as_secs() < 60 {
9284        format!("{}s ago", age.as_secs())
9285    } else if age.as_secs() < 3600 {
9286        format!("{}m ago", age.as_secs() / 60)
9287    } else {
9288        format!("{}h ago", age.as_secs() / 3600)
9289    }
9290}
9291
9292/// Check for a crash-recovery checkpoint and return the session ID if explicit
9293/// recovery was requested *and* the checkpoint belongs to the current
9294/// workspace.
9295///
9296/// Candidates are all per-session checkpoint files plus the legacy
9297/// single-slot `checkpoints/latest.json`; each must be younger than 24 hours
9298/// **and its workspace must match the resolved launch workspace after
9299/// canonicalisation** — the newest matching candidate wins. If no candidate
9300/// matches, a one-line notice points at `codewhale sessions`, and nothing is
9301/// auto-loaded: another workspace's checkpoint file is never touched (it may
9302/// belong to a live session there).
9303fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
9304    let manager = session_manager::SessionManager::default_location().ok()?;
9305    let candidates = load_recent_checkpoints(&manager);
9306    if candidates.is_empty() {
9307        return None;
9308    }
9309
9310    // Refuse to silently restore a session from another workspace. Compare
9311    // against the resolved launch workspace, not the shell cwd, so callers
9312    // using `--workspace` cannot accidentally recover a checkpoint from the
9313    // directory their shell happened to be in.
9314    let (matching, mismatched): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|candidate| {
9315        session_manager::workspace_scope_matches(
9316            &candidate.session.metadata.workspace,
9317            launch_workspace,
9318        )
9319    });
9320
9321    let Some(best) = matching.into_iter().next() else {
9322        if let Some(newest) = mismatched.first() {
9323            eprintln!(
9324                "Note: an interrupted session from another workspace ({}) is \
9325                 available. Run `codewhale sessions` to list saved sessions. Starting \
9326                 fresh in {}.",
9327                newest.session.metadata.workspace.display(),
9328                launch_workspace.display(),
9329            );
9330        }
9331        return None;
9332    };
9333
9334    let session_id = best.session.metadata.id.clone();
9335
9336    // Persist the checkpoint as a regular session so the TUI can load it by
9337    // id — unless a newer regular session file for the same id already
9338    // exists (e.g. `--continue` ran before and the session advanced since).
9339    // A stale checkpoint must never overwrite newer durable session state.
9340    if !saved_session_is_newer(&manager, &best.session)
9341        && manager.save_session(&best.session).is_err()
9342    {
9343        return None;
9344    }
9345
9346    match &best.source {
9347        session_manager::CheckpointSource::Session(id) => {
9348            // Consume the per-session checkpoint now that it is recovered.
9349            let _ = manager.clear_session_checkpoint(id);
9350        }
9351        session_manager::CheckpointSource::Legacy => {
9352            // Migrate the legacy slot to a per-session file (never
9353            // overwriting an existing one) and leave `latest.json` in place
9354            // so an older binary can still find it; its writer is already
9355            // gone and the file ages out within 24 hours.
9356            let _ = manager.write_session_checkpoint_if_absent(&best.session);
9357        }
9358    }
9359
9360    let age_str = checkpoint_age_label(best.age);
9361    eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
9362
9363    Some(session_id)
9364}
9365
9366/// Whether a regular session file for the checkpoint's id already exists and
9367/// is at least as recent as the checkpoint. When it is, persisting the
9368/// checkpoint over it would replace newer durable state with older in-flight
9369/// state.
9370fn saved_session_is_newer(
9371    manager: &session_manager::SessionManager,
9372    checkpoint: &session_manager::SavedSession,
9373) -> bool {
9374    manager
9375        .load_session(&checkpoint.metadata.id)
9376        .is_ok_and(|existing| existing.metadata.updated_at >= checkpoint.metadata.updated_at)
9377}
9378
9379/// Preserve an interrupted checkpoint on a normal fresh launch without
9380/// attaching it to the new TUI instance. This keeps "open another codewhale in
9381/// the same folder" from re-entering the previous in-flight session while still
9382/// leaving an explicit resume path.
9383///
9384/// Only the newest recent checkpoint drives the notice. The legacy
9385/// single-slot file is persisted as a regular session and consumed (today's
9386/// behavior for that slot); per-session checkpoint files are persisted but
9387/// left in place — they may belong to a live session in another terminal,
9388/// and `--continue` reads them directly.
9389fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
9390    let Some(manager) = session_manager::SessionManager::default_location().ok() else {
9391        return;
9392    };
9393    let Some(newest) = load_recent_checkpoints(&manager).into_iter().next() else {
9394        return;
9395    };
9396
9397    let session_workspace = newest.session.metadata.workspace.clone();
9398    // #4479: removed save_session call — checkpoint should not be auto-promoted to session
9399    if newest.source == session_manager::CheckpointSource::Legacy {
9400        // Migrate legacy single-slot checkpoint to per-session format
9401        // before clearing the legacy file, or the data is unrecoverable.
9402        let _ = manager.save_checkpoint(&newest.session);
9403        let _ = manager.clear_legacy_checkpoint();
9404    }
9405
9406    let age_str = checkpoint_age_label(newest.age);
9407    if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
9408        eprintln!(
9409            "Found an in-flight session snapshot ({age_str}). Starting a new \
9410             session. Run `codewhale --continue` to resume it."
9411        );
9412    } else {
9413        eprintln!(
9414            "Note: an interrupted session from another workspace ({}) is \
9415             available. Run `codewhale sessions` to list saved sessions. Starting \
9416             fresh in {}.",
9417            session_workspace.display(),
9418            launch_workspace.display(),
9419        );
9420    }
9421}
9422
9423/// Load project-level config from `$WORKSPACE/.codewhale/config.toml`, with
9424/// legacy `$WORKSPACE/.deepseek/config.toml` fallback, then apply its fields as
9425/// overrides on top of the global config (#485).
9426/// Only explicitly set fields in the project file are applied; everything
9427/// else falls back to the global value.
9428#[cfg(test)]
9429fn merge_project_config(config: &mut Config, workspace: &Path) {
9430    merge_project_config_with_approval_baseline(config, workspace, None);
9431}
9432
9433/// Apply project config while evaluating approval tightening against the
9434/// user's effective interactive baseline. `Config::approval_policy` remains
9435/// authoritative when present; the saved TUI posture is used only when the
9436/// root config leaves approval unset.
9437fn merge_project_config_with_approval_baseline(
9438    config: &mut Config,
9439    workspace: &Path,
9440    saved_permission_posture: Option<&str>,
9441) {
9442    // When the workspace is the user's home directory, the project-scope
9443    // config file is also the global config file. Skip the merge to avoid
9444    // redundant processing and a misleading "project-scope config key
9445    // ignored" warning on every launch from ~.
9446    if let Some(home) = effective_home_dir()
9447        && let (Ok(w), Ok(h)) = (
9448            std::fs::canonicalize(workspace),
9449            std::fs::canonicalize(&home),
9450        )
9451        && w == h
9452    {
9453        return;
9454    }
9455
9456    // v0.8.44: prefer .codewhale/config.toml, fall back to .deepseek/
9457    let path = workspace
9458        .join(codewhale_config::CODEWHALE_APP_DIR)
9459        .join("config.toml");
9460    let raw = match read_project_config_file(&path) {
9461        Ok(Some(r)) => r,
9462        Ok(None) => {
9463            let legacy = workspace
9464                .join(codewhale_config::LEGACY_APP_DIR)
9465                .join("config.toml");
9466            match read_project_config_file(&legacy) {
9467                Ok(Some(r)) => r,
9468                Ok(None) => return,
9469                Err(err) => {
9470                    eprintln!(
9471                        "warning: failed to read project-scope config {}: {err}",
9472                        legacy.display()
9473                    );
9474                    return;
9475                }
9476            }
9477        }
9478        Err(err) => {
9479            eprintln!(
9480                "warning: failed to read project-scope config {}: {err}",
9481                path.display()
9482            );
9483            return;
9484        }
9485    };
9486    let project: toml::Value = match toml::from_str(&raw) {
9487        Ok(v) => v,
9488        Err(_) => return,
9489    };
9490    let table = match project.as_table() {
9491        Some(t) => t,
9492        None => return,
9493    };
9494
9495    // #417: dangerous keys are denied at project scope. A malicious
9496    // `<workspace>/.deepseek/config.toml` could otherwise:
9497    // * `api_key` / `base_url` / `provider` — exfiltrate prompts to a
9498    //   look-alike endpoint by swapping the user's credentials and
9499    //   target host with project-controlled values.
9500    // * `mcp_config_path` — point the loader at an MCP config that
9501    //   spawns arbitrary stdio servers under the user's identity.
9502    // * `mcp_oauth_callback_*` — choose local OAuth redirect listener
9503    //   behavior for user-owned MCP credentials.
9504    //
9505    // The overlay path is non-interactive; users can't visually
9506    // confirm a rogue project config is hijacking these. We surface
9507    // a stderr warning on first encounter so a user who *did* expect
9508    // the override has a chance to notice the deny instead of silent
9509    // discard.
9510    const DENY_AT_PROJECT_SCOPE: &[&str] = &[
9511        "api_key",
9512        "base_url",
9513        "provider",
9514        "mcp_config_path",
9515        "mcp_oauth_callback_port",
9516        "mcp_oauth_callback_url",
9517    ];
9518    for key in DENY_AT_PROJECT_SCOPE {
9519        if table.contains_key(*key) {
9520            eprintln!(
9521                "warning: project-scope config key `{key}` is ignored — \
9522                 set it in `~/.codewhale/config.toml` instead. \
9523                 (See #417 for the deny-list rationale.)"
9524            );
9525        }
9526    }
9527
9528    // String fields a project may legitimately override (model,
9529    // approval/sandbox tightening, notes path, reasoning effort).
9530    for (key, field) in [
9531        ("model", &mut config.default_text_model),
9532        ("reasoning_effort", &mut config.reasoning_effort),
9533        ("notes_path", &mut config.notes_path),
9534    ] {
9535        if let Some(v) = table.get(key).and_then(toml::Value::as_str)
9536            && !v.is_empty()
9537        {
9538            *field = Some(v.to_string());
9539        }
9540    }
9541
9542    if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
9543        && !v.is_empty()
9544    {
9545        let saved_approval_baseline =
9546            crate::config::approval_policy_baseline_from_permission_posture(
9547                saved_permission_posture,
9548            );
9549        let approval_baseline = config
9550            .approval_policy
9551            .as_deref()
9552            .or(saved_approval_baseline);
9553        if codewhale_config::project_approval_policy_is_allowed(approval_baseline, v) {
9554            config.approval_policy = Some(v.to_string());
9555        } else {
9556            eprintln!(
9557                "warning: project-scope `approval_policy = \"{v}\"` is ignored — \
9558                 project config can only tighten the user's approval policy. \
9559                 (See #417.)"
9560            );
9561        }
9562    }
9563
9564    if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
9565        && !v.is_empty()
9566    {
9567        if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
9568            config.sandbox_mode = Some(v.to_string());
9569        } else {
9570            eprintln!(
9571                "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
9572                 project config can only tighten the user's sandbox mode. \
9573                 (See #417.)"
9574            );
9575        }
9576    }
9577
9578    // Numeric / bool fields that benefit from per-project overrides.
9579    if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
9580        && v > 0
9581    {
9582        config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
9583    }
9584    if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
9585        if v {
9586            eprintln!(
9587                "warning: project-scope `allow_shell = true` is ignored — \
9588                 enable shell from user config for this workspace instead. \
9589                 (See #417.)"
9590            );
9591        } else {
9592            config.allow_shell = Some(false);
9593        }
9594    }
9595
9596    if table.contains_key("instructions") {
9597        eprintln!(
9598            "warning: project-scope `instructions` is ignored — \
9599             configure instruction files from user config instead. \
9600             (See #417.)"
9601        );
9602    }
9603}
9604
9605fn read_project_config_file(path: &Path) -> io::Result<Option<String>> {
9606    let metadata = match std::fs::symlink_metadata(path) {
9607        Ok(metadata) => metadata,
9608        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
9609        Err(err) => return Err(err),
9610    };
9611    let file_type = metadata.file_type();
9612    if file_type.is_symlink() {
9613        return Err(io::Error::new(
9614            io::ErrorKind::InvalidInput,
9615            "project-scope config must not be a symlink",
9616        ));
9617    }
9618    if !file_type.is_file() {
9619        return Ok(None);
9620    }
9621
9622    let mut file = open_project_config_file(path)?;
9623    let mut raw = String::new();
9624    file.read_to_string(&mut raw)?;
9625    Ok(Some(raw))
9626}
9627
9628#[cfg(unix)]
9629fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9630    use std::os::unix::fs::OpenOptionsExt;
9631
9632    std::fs::OpenOptions::new()
9633        .read(true)
9634        .custom_flags(libc::O_NOFOLLOW)
9635        .open(path)
9636}
9637
9638#[cfg(not(unix))]
9639fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9640    std::fs::File::open(path)
9641}
9642
9643fn merge_user_workspace_config(
9644    config: &mut Config,
9645    config_path: Option<PathBuf>,
9646    workspace: &Path,
9647) {
9648    if config.managed_config_path.is_some() || config.requirements_path.is_some() {
9649        return;
9650    }
9651    let allow_shell_before = config.allow_shell;
9652    let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
9653        || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
9654    let path = match crate::config::resolve_load_config_path(config_path) {
9655        Ok(Some(path)) => path,
9656        Ok(None) => return,
9657        Err(error) => {
9658            tracing::error!(
9659                error = %error,
9660                "failed to resolve workspace config overlay; refusing to substitute another file"
9661            );
9662            return;
9663        }
9664    };
9665    let raw = match read_user_config_file(&path) {
9666        Ok(Some(raw)) => raw,
9667        Ok(None) => return,
9668        Err(error) => {
9669            eprintln!(
9670                "warning: could not read user config at {}: {error}. \
9671                 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9672                 revert to defaults for this session. Fix or remove the file to \
9673                 restore them.",
9674                path.display()
9675            );
9676            return;
9677        }
9678    };
9679    let doc = match toml::from_str::<toml::Value>(&raw) {
9680        Ok(doc) => doc,
9681        Err(error) => {
9682            eprintln!(
9683                "warning: could not parse user config at {}: {error}. \
9684                 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9685                 revert to defaults for this session. Fix the TOML syntax to \
9686                 restore them.",
9687                path.display()
9688            );
9689            return;
9690        }
9691    };
9692    merge_user_workspace_config_from_doc(config, &doc, workspace);
9693    if allow_shell_from_env {
9694        config.allow_shell = allow_shell_before;
9695    }
9696}
9697
9698fn read_user_config_file(path: &Path) -> io::Result<Option<String>> {
9699    match std::fs::read_to_string(path) {
9700        Ok(raw) => Ok(Some(raw)),
9701        Err(error) if error.kind() == io::ErrorKind::NotFound => {
9702            match std::fs::symlink_metadata(path) {
9703                Err(metadata_error) if metadata_error.kind() == io::ErrorKind::NotFound => Ok(None),
9704                Err(metadata_error) => Err(metadata_error),
9705                _ => Err(error),
9706            }
9707        }
9708        Err(error) => Err(error),
9709    }
9710}
9711
9712fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
9713    for table_name in ["workspace", "projects"] {
9714        let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
9715            continue;
9716        };
9717        for (raw_path, entry) in entries {
9718            if !workspace_config_path_matches(raw_path, workspace) {
9719                continue;
9720            }
9721            if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
9722                config.allow_shell = Some(allow_shell);
9723            }
9724        }
9725    }
9726}
9727
9728fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
9729    let configured = crate::config::expand_path(raw_path);
9730    let configured = configured.canonicalize().unwrap_or(configured);
9731    let workspace = workspace
9732        .canonicalize()
9733        .unwrap_or_else(|_| workspace.to_path_buf());
9734    paths_equal_for_config(&configured, &workspace)
9735}
9736
9737#[cfg(windows)]
9738fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9739    normalize_windows_config_path_for_compare(left)
9740        == normalize_windows_config_path_for_compare(right)
9741}
9742
9743#[cfg(not(windows))]
9744fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9745    left == right
9746}
9747
9748#[cfg(windows)]
9749fn normalize_windows_config_path_for_compare(path: &Path) -> String {
9750    normalize_windows_config_path_str(&path.to_string_lossy())
9751}
9752
9753#[cfg(any(windows, test))]
9754fn normalize_windows_config_path_str(path: &str) -> String {
9755    let mut normalized = path.replace('/', "\\");
9756    if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") {
9757        normalized = format!("\\\\{rest}");
9758    } else if let Some(rest) = normalized.strip_prefix(r"\\?\") {
9759        normalized = rest.to_string();
9760    }
9761    while normalized.len() > 3 && normalized.ends_with('\\') {
9762        normalized.pop();
9763    }
9764    normalized.to_ascii_lowercase()
9765}
9766
9767fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool {
9768    yolo || config.interactive_allow_shell()
9769}
9770
9771async fn run_interactive(
9772    cli: &Cli,
9773    config: &Config,
9774    resume_session_id: Option<String>,
9775    initial_input: Option<tui::InitialInput>,
9776    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9777    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9778) -> Result<()> {
9779    run_interactive_with_notice(
9780        cli,
9781        config,
9782        resume_session_id,
9783        initial_input,
9784        None,
9785        pending_telemetry_notice,
9786        plugin_registry,
9787    )
9788    .await
9789}
9790
9791/// As [`run_interactive`], but carrying a one-line startup receipt to show in
9792/// the transcript — used by auto-resume to explain why it did or did not
9793/// reattach to a previous session (#2934).
9794async fn run_interactive_with_notice(
9795    cli: &Cli,
9796    config: &Config,
9797    resume_session_id: Option<String>,
9798    initial_input: Option<tui::InitialInput>,
9799    startup_notice: Option<String>,
9800    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9801    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9802) -> Result<()> {
9803    let initial_input = if cli.remote_control {
9804        Some(tui::InitialInput::RemoteControl)
9805    } else {
9806        initial_input
9807    };
9808    let workspace = cli
9809        .workspace
9810        .clone()
9811        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9812
9813    // Merge project-level config from $WORKSPACE/.codewhale/config.toml
9814    // or legacy $WORKSPACE/.deepseek/config.toml
9815    // unless --no-project-config was passed (#485).
9816    let mut merged_config = config.clone();
9817    merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
9818    if !cli.no_project_config {
9819        let saved_permission_posture = crate::settings::Settings::load_persisted()
9820            .ok()
9821            .and_then(|settings| settings.permission_posture);
9822        merge_project_config_with_approval_baseline(
9823            &mut merged_config,
9824            &workspace,
9825            saved_permission_posture.as_deref(),
9826        );
9827    }
9828    let config = &merged_config;
9829
9830    if !cli.skip_onboarding {
9831        match crate::config::ensure_config_file_exists(cli.config.clone()) {
9832            Ok(Some(path)) => logging::info(format!(
9833                "Created first-run config file at {}",
9834                path.display()
9835            )),
9836            Ok(None) => {}
9837            Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
9838        }
9839    }
9840
9841    // v0.8.44: migrate config from ~/.deepseek/ to ~/.codewhale/ on first
9842    // launch. Non-fatal — existing installs keep working either way.
9843    match codewhale_config::migrate_config_if_needed() {
9844        Ok(Some(migration)) => {
9845            eprintln!("{}", migration.user_notice());
9846        }
9847        Ok(None) => {}
9848        Err(err) => logging::warn(format!("Config migration skipped: {err}")),
9849    }
9850
9851    let model = config.default_model();
9852    let provider = config.api_provider();
9853    let max_subagents = cli.max_subagents.map_or_else(
9854        || config.max_subagents_for_provider(provider),
9855        |value| value.clamp(1, MAX_SUBAGENTS),
9856    );
9857    let use_alt_screen = should_use_alt_screen(cli, config);
9858    let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
9859    let use_bracketed_paste = crate::settings::Settings::load()
9860        .map(|s| s.effective_bracketed_paste())
9861        .unwrap_or_else(|_| !crate::settings::detected_legacy_windows_console_host());
9862
9863    // Auto-install bundled system skills (e.g. skill-creator) on first launch.
9864    // Errors are non-fatal: log a warning and continue.
9865    let skills_dir = config.skills_dir();
9866    if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
9867        logging::warn(format!("Failed to install system skills: {e}"));
9868    }
9869
9870    startup_trace::mark("interactive_config");
9871
9872    // Seed ProviderLake from the secret-free Models.dev disk cache before any
9873    // picker/inventory read, then kick a best-effort background refresh (#4187).
9874    // Failures are quiet: bundled catalog rows always remain available.
9875    crate::models_dev_live::maybe_load_persisted_cache();
9876    crate::models_dev_live::spawn_background_refresh();
9877    // Best-effort per-provider catalog refresh: fetches the active provider's
9878    // own /v1/models endpoint and merges live rows into the provider lake
9879    // alongside the Models.dev snapshot. Currently active for TelecomJS, whose
9880    // model list is not covered by the Models.dev catalog.
9881    crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
9882
9883    // Boot janitors — snapshot prune (7-day default), spillover prune
9884    // (#422), and managed-session cleanup (v0.8.44) — are best-effort disk
9885    // hygiene. On a large ~/.codewhale they were the dominant startup cost
9886    // (a git object walk plus thousands of stat/read calls), so they run on
9887    // a blocking worker while the TUI brings up its first frame (#3757).
9888    // All three were already documented as non-fatal.
9889    let snapshots = config.snapshots_config();
9890    let janitor_snapshots_enabled = snapshots.enabled;
9891    let janitor_max_age = snapshots.max_age();
9892    let janitor_workspace = workspace.clone();
9893    // Session cleanup races session restore: skip it entirely when a session
9894    // is being resumed/continued this launch (the just-resumed session could
9895    // be pruned before its first save bumps `updated_at`). It runs next
9896    // clean launch. When we do run it, exclude the explicit resume id too.
9897    let janitor_resume_id = resume_session_id.clone();
9898    let janitor_skip_session_cleanup = resume_session_id.is_some() || cli.continue_session;
9899    tokio::task::spawn_blocking(move || {
9900        if janitor_snapshots_enabled {
9901            session_manager::prune_workspace_snapshots(&janitor_workspace, janitor_max_age);
9902        }
9903
9904        match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
9905            Ok(0) => {}
9906            Ok(n) => tracing::debug!(
9907                target: "spillover",
9908                "boot prune removed {n} spillover file(s)"
9909            ),
9910            Err(err) => tracing::warn!(
9911                target: "spillover",
9912                ?err,
9913                "spillover prune skipped on boot"
9914            ),
9915        }
9916
9917        if !janitor_skip_session_cleanup
9918            && let Ok(manager) = session_manager::SessionManager::default_location()
9919        {
9920            let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
9921        }
9922    });
9923
9924    // The `deepseek` launcher forwards `--yolo` to this binary via the
9925    // DEEPSEEK_YOLO env var (config.yolo), not as a CLI flag. Honour either.
9926    let yolo = cli.yolo || config.yolo.unwrap_or(false);
9927
9928    tui::run_tui(
9929        config,
9930        tui::TuiOptions {
9931            model,
9932            workspace,
9933            config_path: cli.config.clone(),
9934            config_profile: effective_config_profile(cli),
9935            allow_shell: interactive_tui_allow_shell(yolo, config),
9936            use_alt_screen,
9937            use_mouse_capture,
9938            use_bracketed_paste,
9939            skills_dir,
9940            memory_path: config.memory_path(),
9941            notes_path: config.notes_path(),
9942            mcp_config_path: config.mcp_config_path(),
9943            use_memory: config.memory_enabled(),
9944            start_in_agent_mode: yolo,
9945            skip_onboarding: cli.skip_onboarding,
9946            yolo, // YOLO mode auto-approves all tool executions
9947            resume_session_id,
9948            initial_input,
9949            startup_notice,
9950            max_subagents,
9951        },
9952        plugin_registry,
9953        pending_telemetry_notice,
9954    )
9955    .await
9956}
9957
9958#[derive(Debug)]
9959struct CliAutoRoute {
9960    provider: crate::config::ApiProvider,
9961    model: String,
9962    reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
9963    /// Whether the runtime should continue resolving reasoning per prompt.
9964    ///
9965    /// This is independent from `auto_model`: an Auto model can carry a fixed
9966    /// saved effort, while a fixed Fleet model can still request Auto effort.
9967    auto_controls_reasoning: bool,
9968    auto_model: bool,
9969}
9970
9971fn cli_reasoning_effort_value(
9972    config: &Config,
9973    model: &str,
9974    effort: crate::tui::app::ReasoningEffort,
9975) -> Option<String> {
9976    effort
9977        .api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
9978        .map(str::to_string)
9979}
9980
9981fn cli_reasoning_effort_value_for_prompt(
9982    config: &Config,
9983    model: &str,
9984    effort: crate::tui::app::ReasoningEffort,
9985    prompt: &str,
9986) -> Option<String> {
9987    let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
9988        crate::auto_reasoning::select(false, prompt)
9989    } else {
9990        effort
9991    };
9992    cli_reasoning_effort_value(config, model, resolved)
9993}
9994
9995fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
9996    let trimmed = value.trim();
9997    if trimmed.is_empty() {
9998        return Ok(None);
9999    }
10000    if matches!(
10001        trimmed.to_ascii_lowercase().as_str(),
10002        "inherit" | "parent" | "same" | "current" | "default" | "unset"
10003    ) {
10004        return Ok(None);
10005    }
10006    crate::tui::app::ReasoningEffort::parse_strict(trimmed)
10007        .map(|effort| Some(effort.as_setting().to_string()))
10008        .map_err(anyhow::Error::msg)
10009}
10010
10011fn config_for_cli_route(config: &Config, route: &CliAutoRoute) -> Config {
10012    let mut execution_config = config.clone();
10013    execution_config.provider = Some(config.provider_identity_for(route.provider));
10014    execution_config.set_provider_model_override(route.provider, Some(route.model.clone()));
10015    if matches!(
10016        route.provider,
10017        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
10018    ) {
10019        execution_config.default_text_model = Some(route.model.clone());
10020    }
10021    execution_config
10022}
10023
10024async fn resolve_cli_auto_route(
10025    config: &Config,
10026    model: &str,
10027    prompt: &str,
10028) -> Result<CliAutoRoute> {
10029    if model.trim().eq_ignore_ascii_case("auto") {
10030        let selection =
10031            model_routing::resolve_auto_route_with_inventory(config, prompt, "", "auto", "auto")
10032                .await?;
10033        let preference = config
10034            .reasoning_effort()
10035            .filter(|_| config.reasoning_effort_is_explicit())
10036            .map(crate::tui::app::ReasoningEffort::from_setting);
10037        let (reasoning_effort, auto_controls_reasoning) =
10038            model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
10039        Ok(CliAutoRoute {
10040            provider: selection.provider,
10041            model: selection.model,
10042            reasoning_effort,
10043            auto_controls_reasoning,
10044            auto_model: true,
10045        })
10046    } else {
10047        if let Some(selection) = model_routing::resolve_explicit_route_with_inventory(config, model)
10048        {
10049            let auto_controls_reasoning = matches!(
10050                selection.reasoning_effort,
10051                Some(crate::tui::app::ReasoningEffort::Auto)
10052            );
10053            return Ok(CliAutoRoute {
10054                provider: selection.provider,
10055                model: selection.model,
10056                reasoning_effort: selection.reasoning_effort,
10057                auto_controls_reasoning,
10058                auto_model: false,
10059            });
10060        }
10061
10062        let candidate_providers = model_routing::explicit_route_candidate_providers(config, model);
10063        if !candidate_providers.is_empty() && !candidate_providers.contains(&config.api_provider())
10064        {
10065            let providers = candidate_providers
10066                .iter()
10067                .map(|provider| provider.as_str())
10068                .collect::<Vec<_>>()
10069                .join(", ");
10070            bail!(
10071                "model `{model}` is available from configured provider route(s): {providers}. \
10072                 Pass `--provider <provider>` with `--model {model}` to choose one explicitly. \
10073                 In the TUI, use `/provider`, `/model`, or `/setup` to resolve the route before sending."
10074            );
10075        }
10076
10077        // When --model is not `auto`, fall back to the reasoning_effort
10078        // declared in the user's config.toml. The previous hard-coded `None`
10079        // silently dropped the user's setting on every non-auto-route exec
10080        // call, which (for example) prevented vllm + Qwen3 users from
10081        // disabling thinking via `reasoning_effort = "off"` and caused
10082        // 30+ second SSE idle timeouts on trivial prompts.
10083        let reasoning_effort = config
10084            .reasoning_effort()
10085            .map(crate::tui::app::ReasoningEffort::from_setting);
10086        Ok(CliAutoRoute {
10087            provider: config.api_provider(),
10088            model: model.to_string(),
10089            auto_controls_reasoning: matches!(
10090                reasoning_effort,
10091                Some(crate::tui::app::ReasoningEffort::Auto)
10092            ),
10093            reasoning_effort,
10094            auto_model: false,
10095        })
10096    }
10097}
10098
10099async fn resolve_cli_exec_route(
10100    config: &Config,
10101    model: &str,
10102    prompt: &str,
10103    force_configured_route: bool,
10104) -> Result<CliAutoRoute> {
10105    if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
10106        let reasoning_effort = config
10107            .reasoning_effort()
10108            .map(crate::tui::app::ReasoningEffort::from_setting);
10109        return Ok(CliAutoRoute {
10110            provider: config.api_provider(),
10111            model: model.to_string(),
10112            auto_controls_reasoning: matches!(
10113                reasoning_effort,
10114                Some(crate::tui::app::ReasoningEffort::Auto)
10115            ),
10116            reasoning_effort,
10117            auto_model: false,
10118        });
10119    }
10120    resolve_cli_auto_route(config, model, prompt).await
10121}
10122
10123fn should_force_configured_exec_route(
10124    resuming: bool,
10125    explicit_provider: Option<&str>,
10126    explicit_model: Option<&str>,
10127) -> bool {
10128    // A configured/default model belongs to the configured provider route.
10129    // Cross-provider inventory inference is reserved for an explicit model
10130    // override without an explicit provider. Resume remains route-authoritative
10131    // even when its model is overridden because it restores the saved provider.
10132    resuming || explicit_provider.is_some() || explicit_model.is_none()
10133}
10134
10135async fn run_one_shot(
10136    config: &Config,
10137    model: &str,
10138    prompt: &str,
10139    force_configured_route: bool,
10140) -> Result<()> {
10141    use crate::client::DeepSeekClient;
10142    use crate::models::{
10143        ContentBlock, Message, MessageRequest, is_incomplete_stop_reason, stop_reason_detail,
10144    };
10145
10146    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10147    let execution_config = config_for_cli_route(config, &route);
10148    let client = DeepSeekClient::new(&execution_config)?;
10149    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
10150        cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt)
10151    });
10152    let model = route.model;
10153    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
10154
10155    let request = MessageRequest {
10156        model,
10157        messages: vec![Message {
10158            role: "user".to_string(),
10159            content: vec![ContentBlock::Text {
10160                text: prompt.to_string(),
10161                cache_control: None,
10162            }],
10163        }],
10164        max_tokens: client.effective_max_output_tokens(&request_route.model),
10165        system: None,
10166        tools: None,
10167        tool_choice: None,
10168        metadata: None,
10169        thinking: None,
10170        reasoning_effort,
10171        stream: Some(false),
10172        temperature: None,
10173        top_p: None,
10174    };
10175
10176    let response = client.create_message(request).await?;
10177    let stop_reason = response.stop_reason.clone();
10178
10179    for block in response.content {
10180        if let ContentBlock::Text { text, .. } = block {
10181            println!("{text}");
10182        }
10183    }
10184
10185    if is_incomplete_stop_reason(stop_reason.as_deref()) {
10186        anyhow::bail!(
10187            "Model response incomplete: provider stop reason `{}`; the partial response was printed but the command did not succeed.",
10188            stop_reason_detail(stop_reason.as_deref())
10189        );
10190    }
10191
10192    Ok(())
10193}
10194
10195async fn run_one_shot_json(
10196    config: &Config,
10197    model: &str,
10198    prompt: &str,
10199    force_configured_route: bool,
10200) -> Result<()> {
10201    use crate::client::DeepSeekClient;
10202    use crate::models::{
10203        ContentBlock, Message, MessageRequest, SystemPrompt, is_incomplete_stop_reason,
10204        stop_reason_detail,
10205    };
10206
10207    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10208    let execution_config = config_for_cli_route(config, &route);
10209    let provider = execution_config.provider_identity_for(route.provider);
10210    let client = DeepSeekClient::new(&execution_config)?;
10211    let model = route.model.clone();
10212    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
10213        cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt)
10214    });
10215    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
10216    let request = MessageRequest {
10217        model: model.clone(),
10218        messages: vec![Message {
10219            role: "user".to_string(),
10220            content: vec![ContentBlock::Text {
10221                text: prompt.to_string(),
10222                cache_control: None,
10223            }],
10224        }],
10225        max_tokens: client.effective_max_output_tokens(&request_route.model),
10226        system: Some(SystemPrompt::Text(
10227            "You are a coding assistant. Give concise, actionable responses.".to_string(),
10228        )),
10229        tools: None,
10230        tool_choice: None,
10231        metadata: None,
10232        thinking: None,
10233        reasoning_effort,
10234        stream: Some(false),
10235        temperature: None,
10236        top_p: None,
10237    };
10238
10239    let response = client.create_message(request).await?;
10240    let stop_reason = response.stop_reason.clone();
10241    let usage = response.usage.clone();
10242    let mut output = String::new();
10243    for block in response.content {
10244        if let ContentBlock::Text { text, .. } = block {
10245            output.push_str(&text);
10246        }
10247    }
10248    println!(
10249        "{}",
10250        serde_json::to_string_pretty(&one_shot_exec_json_receipt(
10251            provider,
10252            model,
10253            output,
10254            stop_reason.clone(),
10255            usage,
10256        ))?
10257    );
10258    if is_incomplete_stop_reason(stop_reason.as_deref()) {
10259        anyhow::bail!(
10260            "Model response incomplete: provider stop reason `{}`; the JSON receipt records success=false.",
10261            stop_reason_detail(stop_reason.as_deref())
10262        );
10263    }
10264    Ok(())
10265}
10266
10267fn one_shot_exec_json_receipt(
10268    provider: String,
10269    model: String,
10270    output: String,
10271    stop_reason: Option<String>,
10272    usage: crate::models::Usage,
10273) -> serde_json::Value {
10274    let incomplete = crate::models::is_incomplete_stop_reason(stop_reason.as_deref());
10275    let error = incomplete.then(|| {
10276        format!(
10277            "Model response incomplete: provider stop reason `{}`.",
10278            crate::models::stop_reason_detail(stop_reason.as_deref())
10279        )
10280    });
10281    serde_json::json!({
10282        "mode": "one-shot",
10283        "provider": provider,
10284        "model": model,
10285        "success": !incomplete,
10286        "output": output,
10287        "stop_reason": stop_reason,
10288        "usage": usage,
10289        "error": error,
10290    })
10291}
10292
10293fn exec_stream_provider_route(
10294    identity: &crate::config::ProviderIdentity,
10295) -> (String, Option<String>) {
10296    let provider = identity.provider.as_str().to_string();
10297    let provider_id = if identity.provider == crate::config::ApiProvider::Custom {
10298        identity.exact_id.clone()
10299    } else {
10300        None
10301    };
10302    (provider, provider_id)
10303}
10304
10305#[derive(serde::Serialize)]
10306struct ExecStreamMeta {
10307    receipt_kind: &'static str,
10308    provider: String,
10309    /// Exact configured provider-table id, when one selected the route.
10310    /// `None` deliberately distinguishes the legacy idless root custom route
10311    /// from literal `[providers.custom]`, whose exact id is `"custom"`.
10312    #[serde(skip_serializing_if = "Option::is_none")]
10313    provider_id: Option<String>,
10314    model: String,
10315    route_source: String,
10316    #[serde(skip_serializing_if = "Option::is_none")]
10317    input_tokens: Option<u32>,
10318    #[serde(skip_serializing_if = "Option::is_none")]
10319    output_tokens: Option<u32>,
10320    #[serde(skip_serializing_if = "Option::is_none")]
10321    prompt_cache_hit_tokens: Option<u32>,
10322    #[serde(skip_serializing_if = "Option::is_none")]
10323    prompt_cache_miss_tokens: Option<u32>,
10324    #[serde(skip_serializing_if = "Option::is_none")]
10325    prompt_cache_write_tokens: Option<u32>,
10326    #[serde(skip_serializing_if = "Option::is_none")]
10327    reasoning_tokens: Option<u32>,
10328    /// Resolved output ceiling the route actually requested (post-catalogue).
10329    #[serde(skip_serializing_if = "Option::is_none")]
10330    codewhale_max_output_tokens: Option<u32>,
10331    /// Provenance of that ceiling: `documented`, `uncatalogued`, or
10332    /// `route-declared`.
10333    #[serde(skip_serializing_if = "Option::is_none")]
10334    codewhale_max_output_tokens_source: Option<&'static str>,
10335    duration_ms: u64,
10336    #[serde(skip_serializing_if = "Option::is_none")]
10337    retry_count: Option<u32>,
10338    approval_posture: String,
10339    sandbox_posture: String,
10340    #[serde(skip_serializing_if = "Option::is_none")]
10341    binary_sha256: Option<String>,
10342    #[serde(skip_serializing_if = "Option::is_none")]
10343    config_sha256: Option<String>,
10344    prompt_sha256: String,
10345    #[serde(skip_serializing_if = "Option::is_none")]
10346    tool_catalog_sha256: Option<String>,
10347    input_analysis: ExecStreamInputAnalysis,
10348    visible_final_answer_chars: usize,
10349    session_id: String,
10350    resume_command: String,
10351    workspace: String,
10352    message_count: usize,
10353    #[serde(skip_serializing_if = "Option::is_none")]
10354    status: Option<String>,
10355    #[serde(skip_serializing_if = "Option::is_none")]
10356    termination_reason: Option<String>,
10357    #[serde(skip_serializing_if = "Option::is_none")]
10358    error_category: Option<String>,
10359    #[serde(skip_serializing_if = "Option::is_none")]
10360    error: Option<String>,
10361}
10362
10363#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)]
10364struct ExecStreamInputAnalysis {
10365    estimated_request_tokens: usize,
10366    estimated_message_content_tokens: usize,
10367    estimated_system_tokens: usize,
10368    estimated_framing_tokens: usize,
10369    user_message_count: usize,
10370    assistant_message_count: usize,
10371    tool_message_count: usize,
10372    tool_use_count: usize,
10373    tool_result_count: usize,
10374    text_chars: usize,
10375    thinking_chars: usize,
10376    tool_use_input_chars: usize,
10377    tool_result_chars: usize,
10378    text_estimated_tokens: usize,
10379    thinking_estimated_tokens: usize,
10380    tool_use_input_estimated_tokens: usize,
10381    tool_result_estimated_tokens: usize,
10382}
10383
10384#[derive(serde::Serialize)]
10385#[serde(tag = "type")]
10386// Keep receipts flat for stable JSONL consumers. Boxing the whole tool_result
10387// payload would introduce a nested object and break the stream schema.
10388#[allow(clippy::large_enum_variant)]
10389enum ExecStreamEvent {
10390    #[serde(rename = "content")]
10391    Content { content: String },
10392    #[serde(rename = "tool_use")]
10393    ToolUse {
10394        name: String,
10395        id: String,
10396        input: serde_json::Value,
10397        started_at: String,
10398    },
10399    #[serde(rename = "tool_result")]
10400    ToolResult {
10401        id: String,
10402        name: String,
10403        output: String,
10404        status: String,
10405        started_at: String,
10406        completed_at: String,
10407        duration_ms: u64,
10408        side_effect_status: String,
10409        #[serde(skip_serializing_if = "Option::is_none")]
10410        error_category: Option<String>,
10411        #[serde(skip_serializing_if = "Option::is_none")]
10412        truncated: Option<bool>,
10413        #[serde(skip_serializing_if = "Option::is_none")]
10414        artifact: Option<serde_json::Value>,
10415        #[serde(skip_serializing_if = "Option::is_none")]
10416        result_metadata: Option<serde_json::Value>,
10417    },
10418    /// A sub-agent was launched, and the model it was launched on.
10419    ///
10420    /// Without this, a delegated child is invisible to anything reading the
10421    /// stream: a parent turn on one route could spawn children billed on
10422    /// another and the only place it surfaced was the invoice. That is not
10423    /// hypothetical — the `Fast` loadout re-priced scout children onto a
10424    /// cheaper sibling until it was fixed, and nothing in the output said so.
10425    #[serde(rename = "agent_spawned")]
10426    AgentSpawned {
10427        id: String,
10428        model: String,
10429        spawn_depth: u32,
10430        #[serde(skip_serializing_if = "Option::is_none")]
10431        parent_run_id: Option<String>,
10432        /// Why the child got this route, when the spawn path resolved one.
10433        #[serde(skip_serializing_if = "Option::is_none")]
10434        route_source: Option<String>,
10435    },
10436    #[serde(rename = "sandbox_denied")]
10437    SandboxDenied {
10438        tool_id: String,
10439        tool_name: String,
10440        reason: String,
10441        outcome: String,
10442    },
10443    #[serde(rename = "workflow_event")]
10444    WorkflowEvent {
10445        run_id: String,
10446        event: serde_json::Value,
10447    },
10448    #[serde(rename = "session_capture")]
10449    SessionCapture { content: String },
10450    #[serde(rename = "service_released")]
10451    #[cfg(unix)]
10452    ServiceReleased {
10453        task_id: String,
10454        pid: u32,
10455        process_group_id: u32,
10456        ownership: String,
10457    },
10458    /// Per-model-call usage receipt. Field names mirror the terminal
10459    /// `metadata` receipt (`prompt_cache_hit_tokens` is the provider's
10460    /// cache-read count, `prompt_cache_write_tokens` the cache-creation
10461    /// count). Optional fields are omitted — never emitted as null or zero —
10462    /// when the provider does not report them; the whole event is skipped
10463    /// for model calls whose provider reported no usage at all.
10464    #[serde(rename = "turn_usage")]
10465    TurnUsage {
10466        /// 1-based index of the model call within this exec run.
10467        turn: u32,
10468        input_tokens: u32,
10469        output_tokens: u32,
10470        #[serde(skip_serializing_if = "Option::is_none")]
10471        reasoning_tokens: Option<u32>,
10472        #[serde(skip_serializing_if = "Option::is_none")]
10473        prompt_cache_hit_tokens: Option<u32>,
10474        #[serde(skip_serializing_if = "Option::is_none")]
10475        prompt_cache_miss_tokens: Option<u32>,
10476        #[serde(skip_serializing_if = "Option::is_none")]
10477        prompt_cache_write_tokens: Option<u32>,
10478        #[serde(skip_serializing_if = "Option::is_none")]
10479        reasoning_replay_tokens: Option<u32>,
10480        duration_ms: u64,
10481    },
10482    #[serde(rename = "metadata")]
10483    Metadata { meta: Box<ExecStreamMeta> },
10484    #[serde(rename = "done")]
10485    Done,
10486    #[serde(rename = "error")]
10487    Error { error: String },
10488}
10489
10490fn exec_sandbox_elevation_authorized(
10491    allow_sandbox_elevation: bool,
10492    explicit_sandbox: Option<&str>,
10493) -> bool {
10494    allow_sandbox_elevation
10495        || explicit_sandbox.is_some_and(|policy| policy.eq_ignore_ascii_case("danger-full-access"))
10496}
10497
10498fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> {
10499    println!("{}", serde_json::to_string(&exec_stream_value(event)?)?);
10500    Ok(())
10501}
10502
10503/// Process exit code `codewhale exec` uses when a turn ends on a retryable
10504/// infrastructure failure (provider/transport) rather than a genuine task
10505/// failure. 75 is `EX_TEMPFAIL` from sysexits.h — "temporary failure; the
10506/// invocation is expected to succeed on retry" — so bench harnesses and
10507/// supervisors can distinguish retryable infra exits from genuine task
10508/// failures (exit 1) without parsing the stream-json metadata.
10509const EXEC_EXIT_RETRYABLE_INFRA: i32 = 75; // EX_TEMPFAIL
10510
10511/// Map a terminal exec error category to the process exit code.
10512///
10513/// `network` / `timeout` mean the provider connection dropped or stalled
10514/// after every in-session retry budget was exhausted: the task itself
10515/// neither passed nor failed, and re-running the same command is safe.
10516/// `rate_limit` is deliberately NOT mapped to the retryable code — the same
10517/// category also covers quota exhaustion, which a blind retry would hammer.
10518fn exec_failure_exit_code(error_category: Option<&str>) -> i32 {
10519    match error_category {
10520        Some("network" | "timeout") => EXEC_EXIT_RETRYABLE_INFRA,
10521        _ => 1,
10522    }
10523}
10524
10525/// Should a mid-turn engine error event force the final exec summary into
10526/// failure? Only non-recoverable envelopes do. Recoverable warnings (stream
10527/// stall notices, transient retry noise) are emitted on the stream for
10528/// visibility, but the terminal `TurnComplete` event carries the
10529/// authoritative turn outcome — a warning must never fail a run whose turn
10530/// later completes.
10531fn exec_error_event_is_fatal(envelope: &crate::error_taxonomy::ErrorEnvelope) -> bool {
10532    !envelope.recoverable
10533}
10534
10535fn exec_stream_value(event: &ExecStreamEvent) -> Result<serde_json::Value> {
10536    let mut value = serde_json::to_value(event)?;
10537    if let Some(object) = value.as_object_mut() {
10538        object.insert("schema_version".to_string(), serde_json::json!(1));
10539        object.insert(
10540            "schema".to_string(),
10541            serde_json::json!("codewhale.exec-stream"),
10542        );
10543    }
10544    Ok(value)
10545}
10546
10547fn tool_error_receipt_category(error: &crate::tools::spec::ToolError) -> &'static str {
10548    use crate::tools::spec::ToolError;
10549    match error {
10550        ToolError::InvalidInput { .. } => "invalid_input",
10551        ToolError::MissingField { .. } => "missing_field",
10552        ToolError::PathEscape { .. } => "path_escape",
10553        ToolError::ExecutionFailed { .. } => "execution_failed",
10554        ToolError::Timeout { .. } => "timeout",
10555        ToolError::Cancelled { .. } => "cancelled",
10556        ToolError::NotAvailable { .. } => "not_available",
10557        ToolError::PermissionDenied { .. } => "permission_denied",
10558    }
10559}
10560
10561fn tool_artifact_receipt(metadata: Option<&serde_json::Value>) -> Option<serde_json::Value> {
10562    let object = metadata?.as_object()?;
10563    let mut artifact = serde_json::Map::new();
10564    for key in [
10565        "artifact_id",
10566        "artifact_path",
10567        "artifact_relative_path",
10568        "artifact_byte_size",
10569        "spillover_path",
10570        "content_digest",
10571        "original_byte_count",
10572        "retained_head_bytes",
10573        "retained_tail_bytes",
10574    ] {
10575        if let Some(value) = object.get(key) {
10576            artifact.insert(key.to_string(), value.clone());
10577        }
10578    }
10579    (!artifact.is_empty()).then_some(serde_json::Value::Object(artifact))
10580}
10581
10582fn current_binary_sha256() -> Option<String> {
10583    let bytes = std::fs::read(std::env::current_exe().ok()?).ok()?;
10584    Some(format!("sha256:{}", crate::hashing::sha256_hex(&bytes)))
10585}
10586
10587async fn run_workflow_tool_command(
10588    cli: &Cli,
10589    args: WorkflowToolArgs,
10590    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10591) -> Result<()> {
10592    match run_workflow_tool_command_inner(cli, args, plugin_registry).await {
10593        Ok(()) => Ok(()),
10594        Err(error) => {
10595            let _ = emit_exec_stream_event(&ExecStreamEvent::Error {
10596                error: format!("{error:#}"),
10597            });
10598            exit_workflow_tool_failure();
10599        }
10600    }
10601}
10602
10603async fn run_workflow_tool_command_inner(
10604    cli: &Cli,
10605    args: WorkflowToolArgs,
10606    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10607) -> Result<()> {
10608    use crate::tools::spec::ToolSpec;
10609
10610    if args.approval_source != "explicit-workflow-command" {
10611        bail!("workflow-tool requires --approval-source explicit-workflow-command");
10612    }
10613    let input: serde_json::Value = serde_json::from_str(&args.input_json)
10614        .context("--input-json must be a valid Workflow tool input object")?;
10615    if !input.is_object() {
10616        bail!("--input-json must be a JSON object");
10617    }
10618    if !input
10619        .get("action")
10620        .and_then(serde_json::Value::as_str)
10621        .is_some_and(|action| action.eq_ignore_ascii_case("run"))
10622    {
10623        bail!("workflow-tool accepts only action=run");
10624    }
10625
10626    let workspace = resolve_workspace(cli);
10627    let mut config = load_config_from_cli(cli)?;
10628    merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
10629    if let Ok(env_url) =
10630        std::env::var("CODEWHALE_BASE_URL").or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
10631    {
10632        let trimmed = env_url.trim();
10633        if !trimmed.is_empty() {
10634            config.base_url = Some(trimmed.to_string());
10635        }
10636    }
10637
10638    let model = resolve_exec_model(&config, None);
10639    let route = resolve_cli_exec_route(
10640        &config,
10641        &model,
10642        "Run a checked-in Workflow through the host runtime",
10643        true,
10644    )
10645    .await?;
10646    let execution_config = config_for_cli_route(&config, &route);
10647    let route_identity = execution_config
10648        .active_provider_identity(route.provider)
10649        .map_err(anyhow::Error::msg)
10650        .context("workflow terminal route lost its exact provider identity")?;
10651    let (route_provider, route_provider_id) = exec_stream_provider_route(&route_identity);
10652    let workflow_input_sha256 = format!(
10653        "sha256:{}",
10654        crate::hashing::sha256_hex(&serde_json::to_vec(&input)?)
10655    );
10656    let tool_id = format!("workflow_host_{}", &uuid::Uuid::new_v4().to_string()[..8]);
10657    let tool_started = Instant::now();
10658    let tool_started_at = chrono::Utc::now().to_rfc3339();
10659
10660    emit_exec_stream_event(&ExecStreamEvent::ToolUse {
10661        name: "workflow".to_string(),
10662        id: tool_id.clone(),
10663        input: input.clone(),
10664        started_at: tool_started_at.clone(),
10665    })?;
10666
10667    let (event_tx, event_rx) = tokio::sync::mpsc::channel(1024);
10668    let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
10669    let event_forwarder = tokio::spawn(forward_direct_workflow_events(event_rx, stop_rx));
10670    let (tool, context) = match build_direct_workflow_tool(
10671        &execution_config,
10672        &route,
10673        &workspace,
10674        event_tx,
10675        plugin_registry,
10676    )
10677    .await
10678    {
10679        Ok(built) => built,
10680        Err(err) => {
10681            let _ = stop_tx.send(());
10682            let _ = event_forwarder.await;
10683            exit_workflow_tool_error(&tool_id, err.to_string());
10684        }
10685    };
10686
10687    let result = tool.execute(input, &context).await;
10688    drop(tool);
10689    let _ = stop_tx.send(());
10690    event_forwarder
10691        .await
10692        .context("workflow event forwarder task failed")??;
10693
10694    let result = match result {
10695        Ok(result) => result,
10696        Err(err) => {
10697            let error = err.to_string();
10698            exit_workflow_tool_error(&tool_id, error);
10699        }
10700    };
10701
10702    let workflow_status =
10703        direct_workflow_status(&result.content).unwrap_or_else(|| "unknown".to_string());
10704    let completed = result.success && workflow_status == "completed";
10705    emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10706        id: tool_id,
10707        name: "workflow".to_string(),
10708        output: result.content.clone(),
10709        status: if completed { "success" } else { "error" }.to_string(),
10710        started_at: tool_started_at,
10711        completed_at: chrono::Utc::now().to_rfc3339(),
10712        duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10713        side_effect_status: result
10714            .metadata
10715            .as_ref()
10716            .and_then(|metadata| metadata.get("side_effect_status"))
10717            .and_then(serde_json::Value::as_str)
10718            .unwrap_or("unknown")
10719            .to_string(),
10720        error_category: (!completed).then(|| "tool_error".to_string()),
10721        truncated: result
10722            .metadata
10723            .as_ref()
10724            .and_then(|metadata| metadata.get("truncated"))
10725            .and_then(serde_json::Value::as_bool),
10726        artifact: tool_artifact_receipt(result.metadata.as_ref()),
10727        result_metadata: result.metadata.clone(),
10728    })?;
10729    emit_exec_stream_event(&ExecStreamEvent::Metadata {
10730        meta: Box::new(ExecStreamMeta {
10731            receipt_kind: "terminal",
10732            provider: route_provider,
10733            provider_id: route_provider_id,
10734            // No parent/operator model call occurs on this host-owned path;
10735            // child model/provider usage remains attributable in typed task
10736            // receipts rather than being misreported as one root model.
10737            model: "host-workflow".to_string(),
10738            route_source: "host_workflow".to_string(),
10739            input_tokens: None,
10740            output_tokens: None,
10741            prompt_cache_hit_tokens: None,
10742            prompt_cache_miss_tokens: None,
10743            prompt_cache_write_tokens: None,
10744            reasoning_tokens: None,
10745            codewhale_max_output_tokens: None,
10746            codewhale_max_output_tokens_source: None,
10747            duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10748            retry_count: None,
10749            approval_posture: "explicit_workflow_command".to_string(),
10750            sandbox_posture: "configured".to_string(),
10751            binary_sha256: current_binary_sha256(),
10752            config_sha256: None,
10753            prompt_sha256: workflow_input_sha256,
10754            tool_catalog_sha256: None,
10755            input_analysis: ExecStreamInputAnalysis::default(),
10756            visible_final_answer_chars: result.content.chars().count(),
10757            session_id: String::new(),
10758            resume_command: String::new(),
10759            workspace: workspace.display().to_string(),
10760            message_count: 0,
10761            status: Some(workflow_status.clone()),
10762            termination_reason: Some(if completed { "resolved" } else { "tool_error" }.to_string()),
10763            error_category: (!completed).then(|| "tool".to_string()),
10764            error: (!completed)
10765                .then(|| format!("workflow run ended with terminal status {workflow_status}")),
10766        }),
10767    })?;
10768    if !completed {
10769        let error = format!("workflow run ended with terminal status {workflow_status}");
10770        emit_exec_stream_event(&ExecStreamEvent::Error {
10771            error: error.clone(),
10772        })?;
10773        exit_workflow_tool_failure();
10774    }
10775    emit_exec_stream_event(&ExecStreamEvent::Done)?;
10776    Ok(())
10777}
10778
10779fn exit_workflow_tool_failure() -> ! {
10780    let _ = io::stdout().flush();
10781    std::process::exit(1)
10782}
10783
10784fn exit_workflow_tool_error(tool_id: &str, error: String) -> ! {
10785    let now = chrono::Utc::now().to_rfc3339();
10786    let _ = emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10787        id: tool_id.to_string(),
10788        name: "workflow".to_string(),
10789        output: error.clone(),
10790        status: "error".to_string(),
10791        started_at: now.clone(),
10792        completed_at: now,
10793        duration_ms: 0,
10794        side_effect_status: "unknown".to_string(),
10795        error_category: Some("execution_failed".to_string()),
10796        truncated: None,
10797        artifact: None,
10798        result_metadata: None,
10799    });
10800    let _ = emit_exec_stream_event(&ExecStreamEvent::Error { error });
10801    exit_workflow_tool_failure()
10802}
10803
10804async fn initialize_direct_workflow_mcp_pool(
10805    config: &Config,
10806    workspace: &Path,
10807    network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
10808    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10809) -> Option<(
10810    std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
10811    Vec<(String, String)>,
10812)> {
10813    if !config.features().enabled(Feature::Mcp) {
10814        return None;
10815    }
10816    let mut pool = crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
10817        &config.mcp_config_path(),
10818        workspace,
10819        plugin_registry,
10820    )
10821    .unwrap_or_else(|error| {
10822        tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
10823        crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
10824    });
10825    if let Some(policy) = network_policy {
10826        pool = pool.with_network_policy(policy);
10827    }
10828    let failures = pool
10829        .connect_all()
10830        .await
10831        .into_iter()
10832        .map(|(server, error)| (server, format!("{error:#}")))
10833        .collect();
10834    Some((std::sync::Arc::new(tokio::sync::Mutex::new(pool)), failures))
10835}
10836
10837async fn build_direct_workflow_tool(
10838    config: &Config,
10839    route: &CliAutoRoute,
10840    workspace: &Path,
10841    event_tx: tokio::sync::mpsc::Sender<crate::core::events::Event>,
10842    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10843) -> Result<(
10844    crate::tools::workflow::WorkflowTool,
10845    crate::tools::ToolContext,
10846)> {
10847    use std::sync::Arc;
10848
10849    use crate::client::DeepSeekClient;
10850    use crate::core::authority::shell_policy_for_mode;
10851    use crate::fleet::roster::FleetRoster;
10852    use crate::tools::AgentToolSurfaceOptions;
10853    use crate::tools::goal::new_shared_goal_state;
10854    use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager_with_timeout};
10855    use crate::tools::todo::new_shared_todo_list;
10856    use crate::tui::app::AppMode;
10857
10858    let provider = config.api_provider();
10859    if !config.subagents_enabled_for_provider(provider) {
10860        bail!(
10861            "Workflow dispatch requires sub-agents for provider {} ({})",
10862            provider.as_str(),
10863            config
10864                .subagents_disabled_reason()
10865                .unwrap_or("provider-specific sub-agent configuration disabled it")
10866        );
10867    }
10868
10869    let yolo = config.yolo.unwrap_or(false);
10870    let mode = if yolo {
10871        AppMode::Yolo
10872    } else {
10873        AppMode::Operate
10874    };
10875    let allow_shell = yolo || config.allow_shell();
10876    let shell_policy = shell_policy_for_mode(mode, allow_shell);
10877    let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
10878    let mut context = crate::tools::ToolContext::with_auto_approve(
10879        workspace.to_path_buf(),
10880        yolo,
10881        config.notes_path(),
10882        config.mcp_config_path(),
10883        yolo,
10884    )
10885    .with_features(config.features())
10886    .with_skills_config(
10887        config.skills_dir(),
10888        config.skills_config().scan_codewhale_only(),
10889    )
10890    .with_plugin_registry(std::sync::Arc::clone(&plugin_registry))
10891    .with_shell_policy(shell_policy)
10892    .with_trusted_external_paths(trusted.paths().to_vec())
10893    .with_elevated_sandbox_policy(crate::core::authority::sandbox_policy_for_turn(
10894        mode,
10895        if yolo {
10896            crate::tui::approval::ApprovalMode::Bypass
10897        } else {
10898            crate::tui::approval::ApprovalMode::Suggest
10899        },
10900        config.sandbox_mode.as_deref(),
10901        workspace,
10902    ));
10903    let network_policy = config.network.clone().map(|network| {
10904        crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
10905    });
10906    if let Some(policy) = network_policy.as_ref() {
10907        context = context.with_network_policy(policy.clone());
10908    }
10909    if config.memory_enabled() {
10910        context.memory_path = Some(config.memory_path());
10911    }
10912    context.search_provider = config.search_provider();
10913    context.search_api_key = config
10914        .search
10915        .as_ref()
10916        .and_then(|search| search.api_key.clone());
10917    context.search_base_url = config
10918        .search
10919        .as_ref()
10920        .and_then(|search| search.base_url.clone());
10921    if let Some(backend) = crate::sandbox::backend::create_backend(config)? {
10922        context = context.with_sandbox_backend(Arc::from(backend));
10923    }
10924
10925    let max_subagents = config.max_subagents_for_provider(provider);
10926    let manager = new_shared_subagent_manager_with_timeout(
10927        workspace.to_path_buf(),
10928        max_subagents,
10929        config
10930            .max_admitted_subagents_for_provider(provider)
10931            .max(max_subagents),
10932        Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
10933        config.launch_concurrency_for_provider(provider),
10934        config.subagent_token_budget_for_provider(provider),
10935    );
10936    let roster = Arc::new(FleetRoster::load(&config.fleet_config(), workspace));
10937    let mut role_models = roster.model_overrides();
10938    role_models.extend(config.subagent_model_overrides());
10939
10940    let features = config.features();
10941    let mut surface = AgentToolSurfaceOptions::new(shell_policy);
10942    surface.apply_patch_enabled = features.enabled(Feature::ApplyPatch);
10943    surface.web_search_enabled = features.enabled(Feature::WebSearch);
10944    surface.memory_tool_enabled = config.memory_enabled();
10945    surface.vision_config = features
10946        .enabled(Feature::VisionModel)
10947        .then(|| config.vision_model_config())
10948        .flatten();
10949    surface.speech_output_dir = config.speech_output_dir();
10950    surface.goal_state = Some(new_shared_goal_state());
10951
10952    let client = DeepSeekClient::new(config)?;
10953    // A FIXED model with `reasoning_effort = auto` (the shape a Fleet worker
10954    // subprocess launches with: `--model <exact> --reasoning-effort auto`) is
10955    // still Auto. Deriving the auto flag from `route.auto_model` alone left it
10956    // raw AND non-auto: the runtime carried the literal string `"auto"` while
10957    // nothing was allowed to resolve it. Auto is a reasoning decision, not a
10958    // model decision — it does not require `--model auto`.
10959    let reasoning_effort_auto = route.auto_controls_reasoning;
10960    let reasoning_effort = route
10961        .reasoning_effort
10962        .and_then(|effort| cli_reasoning_effort_value(config, &route.model, effort));
10963    let mcp_pool = if let Some((pool, failures)) =
10964        initialize_direct_workflow_mcp_pool(config, workspace, network_policy, plugin_registry)
10965            .await
10966    {
10967        for (server, error) in failures {
10968            tracing::warn!(
10969                server = %server,
10970                error = %error,
10971                "direct Workflow runtime could not connect MCP server"
10972            );
10973        }
10974        Some(pool)
10975    } else {
10976        None
10977    };
10978    let runtime = SubAgentRuntime::new(
10979        client,
10980        route.model.clone(),
10981        context.clone(),
10982        allow_shell,
10983        Some(event_tx),
10984        manager.clone(),
10985    )
10986    .with_locale_tag(
10987        crate::localization::resolve_locale(
10988            &crate::settings::Settings::load_persisted()
10989                .unwrap_or_default()
10990                .locale,
10991        )
10992        .tag(),
10993    )
10994    .with_role_models(role_models)
10995    .with_api_config(config.clone())
10996    .with_fleet_roster(roster)
10997    .with_auto_model(route.auto_model)
10998    .with_reasoning_effort(reasoning_effort, reasoning_effort_auto)
10999    .with_agent_tool_surface_options(surface)
11000    .with_max_spawn_depth(config.subagent_max_spawn_depth_for_provider(provider))
11001    .with_step_api_timeout(Duration::from_secs(
11002        config.subagent_api_timeout_secs_for_provider(provider),
11003    ))
11004    .with_speech_output_dir(config.speech_output_dir())
11005    .with_mcp_pool(mcp_pool)
11006    .with_todos(new_shared_todo_list())
11007    .with_parent_mode(mode);
11008
11009    Ok((
11010        crate::tools::workflow::WorkflowTool::new(manager, runtime).with_explicit_cli_approval(),
11011        context,
11012    ))
11013}
11014
11015async fn forward_direct_workflow_events(
11016    mut event_rx: tokio::sync::mpsc::Receiver<crate::core::events::Event>,
11017    mut stop_rx: tokio::sync::oneshot::Receiver<()>,
11018) -> Result<()> {
11019    loop {
11020        tokio::select! {
11021            biased;
11022            event = event_rx.recv() => match event {
11023                Some(event) => emit_direct_workflow_event(event)?,
11024                None => return Ok(()),
11025            },
11026            _ = &mut stop_rx => {
11027                while let Ok(event) = event_rx.try_recv() {
11028                    emit_direct_workflow_event(event)?;
11029                }
11030                return Ok(());
11031            }
11032        }
11033    }
11034}
11035
11036fn emit_direct_workflow_event(event: crate::core::events::Event) -> Result<()> {
11037    if let crate::core::events::Event::WorkflowUi { run_id, event, .. } = event {
11038        emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11039    }
11040    Ok(())
11041}
11042
11043fn direct_workflow_status(content: &str) -> Option<String> {
11044    serde_json::from_str::<serde_json::Value>(content)
11045        .ok()?
11046        .get("status")?
11047        .as_str()
11048        .map(str::to_ascii_lowercase)
11049}
11050
11051fn exec_stream_input_analysis(
11052    messages: &[Message],
11053    system: Option<&SystemPrompt>,
11054) -> ExecStreamInputAnalysis {
11055    let mut analysis = ExecStreamInputAnalysis {
11056        estimated_request_tokens: crate::compaction::estimate_input_tokens_conservative(
11057            messages, system,
11058        ),
11059        estimated_message_content_tokens: crate::compaction::estimate_tokens(messages),
11060        estimated_system_tokens: exec_stream_estimate_system_tokens(system),
11061        estimated_framing_tokens: messages.len().saturating_mul(12).saturating_add(48),
11062        ..ExecStreamInputAnalysis::default()
11063    };
11064
11065    for message in messages {
11066        match message.role.as_str() {
11067            "user" => analysis.user_message_count += 1,
11068            "assistant" => analysis.assistant_message_count += 1,
11069            "tool" => analysis.tool_message_count += 1,
11070            _ => {}
11071        }
11072
11073        for block in &message.content {
11074            match block {
11075                ContentBlock::Text { text, .. } => {
11076                    exec_stream_add_text_estimate(
11077                        text,
11078                        &mut analysis.text_chars,
11079                        &mut analysis.text_estimated_tokens,
11080                    );
11081                }
11082                ContentBlock::Thinking { thinking, .. } => {
11083                    exec_stream_add_text_estimate(
11084                        thinking,
11085                        &mut analysis.thinking_chars,
11086                        &mut analysis.thinking_estimated_tokens,
11087                    );
11088                }
11089                ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
11090                    analysis.tool_use_count += 1;
11091                    exec_stream_add_json_estimate(
11092                        input,
11093                        &mut analysis.tool_use_input_chars,
11094                        &mut analysis.tool_use_input_estimated_tokens,
11095                    );
11096                }
11097                ContentBlock::ToolResult {
11098                    content,
11099                    content_blocks,
11100                    ..
11101                } => {
11102                    analysis.tool_result_count += 1;
11103                    exec_stream_add_text_estimate(
11104                        content,
11105                        &mut analysis.tool_result_chars,
11106                        &mut analysis.tool_result_estimated_tokens,
11107                    );
11108                    if let Some(blocks) = content_blocks {
11109                        exec_stream_add_json_estimate(
11110                            blocks,
11111                            &mut analysis.tool_result_chars,
11112                            &mut analysis.tool_result_estimated_tokens,
11113                        );
11114                    }
11115                }
11116                ContentBlock::ToolSearchToolResult { content, .. }
11117                | ContentBlock::CodeExecutionToolResult { content, .. } => {
11118                    analysis.tool_result_count += 1;
11119                    exec_stream_add_json_estimate(
11120                        content,
11121                        &mut analysis.tool_result_chars,
11122                        &mut analysis.tool_result_estimated_tokens,
11123                    );
11124                }
11125                ContentBlock::ImageUrl { .. } => {}
11126            }
11127        }
11128    }
11129
11130    analysis
11131}
11132
11133fn exec_stream_add_text_estimate(text: &str, chars: &mut usize, tokens: &mut usize) {
11134    *chars = chars.saturating_add(text.chars().count());
11135    *tokens = tokens.saturating_add(crate::compaction::estimate_text_tokens_conservative(text));
11136}
11137
11138fn exec_stream_add_json_estimate<T: serde::Serialize>(
11139    value: &T,
11140    chars: &mut usize,
11141    tokens: &mut usize,
11142) {
11143    let text = serde_json::to_string(value).unwrap_or_default();
11144    exec_stream_add_text_estimate(&text, chars, tokens);
11145}
11146
11147fn exec_stream_estimate_system_tokens(system: Option<&SystemPrompt>) -> usize {
11148    match system {
11149        Some(SystemPrompt::Text(text)) => {
11150            crate::compaction::estimate_text_tokens_conservative(text)
11151        }
11152        Some(SystemPrompt::Blocks(blocks)) => blocks
11153            .iter()
11154            .map(|block| crate::compaction::estimate_text_tokens_conservative(&block.text))
11155            .sum(),
11156        None => 0,
11157    }
11158}
11159
11160fn exec_saved_session_line(session_id: &str) -> String {
11161    format!("session: {}", truncate_id(session_id))
11162}
11163
11164fn exec_resumed_session_line(session_id: &str) -> String {
11165    format!("resumed session: {}", truncate_id(session_id))
11166}
11167
11168fn exec_stream_session_ref(session_id: &str) -> String {
11169    crate::utils::redacted_identifier_for_log(session_id)
11170}
11171
11172fn exec_stream_resume_hint(session_id: &str) -> String {
11173    if session_id.trim().is_empty() {
11174        String::new()
11175    } else {
11176        "codewhale exec --resume <redacted-session-id>".to_string()
11177    }
11178}
11179
11180#[derive(Clone, Copy)]
11181struct PersistedProviderRoute<'a> {
11182    kind: &'a str,
11183    id: Option<&'a str>,
11184}
11185
11186fn persist_exec_session(
11187    messages: &[Message],
11188    model: &str,
11189    provider_route: PersistedProviderRoute<'_>,
11190    workspace: &Path,
11191    system_prompt: &Option<SystemPrompt>,
11192    session_id: Option<&str>,
11193    total_tokens: u64,
11194) -> Result<String> {
11195    let manager =
11196        SessionManager::default_location().context("could not open session manager for save")?;
11197    let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) {
11198        match manager.load_session(id) {
11199            Ok(existing) => session_manager::update_session(
11200                existing,
11201                messages,
11202                total_tokens,
11203                system_prompt.as_ref(),
11204            ),
11205            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
11206                session_manager::create_saved_session_with_id_and_mode(
11207                    id.to_string(),
11208                    messages,
11209                    model,
11210                    workspace,
11211                    total_tokens,
11212                    system_prompt.as_ref(),
11213                    Some("exec"),
11214                )
11215            }
11216            Err(err) => return Err(err).context("could not load existing exec session"),
11217        }
11218    } else {
11219        session_manager::create_saved_session_with_mode(
11220            messages,
11221            model,
11222            workspace,
11223            total_tokens,
11224            system_prompt.as_ref(),
11225            Some("exec"),
11226        )
11227    };
11228    stamp_exec_session_metadata(
11229        &mut saved,
11230        model,
11231        provider_route.kind,
11232        provider_route.id,
11233        workspace,
11234    );
11235    let id = saved.metadata.id.clone();
11236    manager
11237        .save_session(&saved)
11238        .context("could not save exec session")?;
11239    Ok(id)
11240}
11241
11242fn stamp_exec_session_metadata(
11243    saved: &mut session_manager::SavedSession,
11244    model: &str,
11245    model_provider_kind: &str,
11246    model_provider_id: Option<&str>,
11247    workspace: &Path,
11248) {
11249    saved.metadata.model = model.to_string();
11250    saved
11251        .metadata
11252        .set_model_provider_route(model_provider_kind, model_provider_id);
11253    saved.metadata.workspace = workspace.to_path_buf();
11254    saved.metadata.mode = Some("exec".to_string());
11255}
11256
11257#[derive(serde::Serialize)]
11258struct ExecToolEntry {
11259    name: String,
11260    success: bool,
11261    output: String,
11262}
11263
11264#[derive(serde::Serialize)]
11265struct ExecOutcome {
11266    kind: String,
11267    outcome: String,
11268    tool_name: String,
11269    reason: String,
11270}
11271
11272#[derive(serde::Serialize, Default)]
11273struct ExecSummary {
11274    mode: String,
11275    provider: String,
11276    model: String,
11277    prompt: String,
11278    output: String,
11279    tools: Vec<ExecToolEntry>,
11280    outcomes: Vec<ExecOutcome>,
11281    status: Option<String>,
11282    termination_reason: Option<String>,
11283    error_category: Option<String>,
11284    error: Option<String>,
11285    #[serde(skip_serializing_if = "Vec::is_empty")]
11286    released_services: Vec<crate::tools::shell::PersistentServiceReceipt>,
11287}
11288
11289fn validate_exec_tool_authority_resume(
11290    tool_authority_json: Option<&str>,
11291    resuming: bool,
11292) -> Result<()> {
11293    if tool_authority_json.is_some() && resuming {
11294        bail!(
11295            "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue"
11296        );
11297    }
11298    Ok(())
11299}
11300
11301fn exec_network_policy(
11302    config: &Config,
11303    outer_network_access: Option<bool>,
11304) -> Option<crate::network_policy::NetworkPolicyDecider> {
11305    // Fleet caps are an outer authority boundary: user configuration may
11306    // narrow them further, but it may never widen an explicit network denial.
11307    if outer_network_access == Some(false) {
11308        return Some(crate::network_policy::NetworkPolicyDecider::new(
11309            crate::network_policy::NetworkPolicy {
11310                default: crate::network_policy::DecisionToml::Deny,
11311                ..crate::network_policy::NetworkPolicy::default()
11312            },
11313            None,
11314        ));
11315    }
11316    config.network.clone().map(|toml_cfg| {
11317        crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
11318    })
11319}
11320
11321fn apply_fleet_engine_feature_caps(
11322    features: &mut crate::features::Features,
11323    fleet_authority_active: bool,
11324    outer_network_access: Option<bool>,
11325    shell_authority: crate::tools::spec::ToolShellAuthority,
11326) {
11327    if fleet_authority_active {
11328        features.disable(crate::features::Feature::Subagents);
11329        features.disable(crate::features::Feature::Mcp);
11330        if shell_authority != crate::tools::spec::ToolShellAuthority::ReadOnly {
11331            features.disable(crate::features::Feature::ShellTool);
11332        }
11333    }
11334    if outer_network_access == Some(false) {
11335        features.disable(crate::features::Feature::WebSearch);
11336    }
11337}
11338
11339/// Resolve the optional headless safety budget without imposing a hidden
11340/// default. Benchmarks and other long-running exec callers continue until the
11341/// model finishes unless they opt into a finite `--max-turns` value.
11342fn exec_max_steps(max_turns: Option<u32>) -> u32 {
11343    max_turns.unwrap_or(u32::MAX)
11344}
11345
11346#[allow(clippy::too_many_arguments)]
11347async fn run_exec_agent(
11348    config: &Config,
11349    model: &str,
11350    prompt: &str,
11351    workspace: PathBuf,
11352    max_subagents: usize,
11353    auto_approve: bool,
11354    allow_sandbox_elevation: bool,
11355    explicit_sandbox: Option<&str>,
11356    trust_mode: bool,
11357    json_output: bool,
11358    resume_session: Option<session_manager::SavedSession>,
11359    force_configured_route: bool,
11360    output_format: ExecOutputFormat,
11361    max_turns: u32,
11362    allowed_tools: Option<Vec<String>>,
11363    disallowed_tools: Option<Vec<String>>,
11364    append_system_prompt: Option<String>,
11365    tool_authority_json: Option<String>,
11366    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
11367) -> Result<()> {
11368    use crate::compaction::CompactionConfig;
11369    use crate::core::engine::{EngineConfig, spawn_engine};
11370    use crate::core::events::Event;
11371    use crate::core::ops::Op;
11372    use crate::tools::plan::new_shared_plan_state;
11373    use crate::tools::todo::new_shared_todo_list;
11374    use crate::tui::app::AppMode;
11375
11376    validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
11377    let fleet_authority = tool_authority_json
11378        .as_deref()
11379        .map(crate::tools::spec::ToolAuthorityEnvelope::from_json)
11380        .transpose()
11381        .map_err(anyhow::Error::msg)?;
11382    let fleet_authority_active = fleet_authority.is_some();
11383    let outer_network_access = fleet_authority
11384        .as_ref()
11385        .and_then(|authority| authority.network_access);
11386    let outer_shell_authority = fleet_authority
11387        .as_ref()
11388        .map(|authority| authority.shell)
11389        .unwrap_or_default();
11390    if let Some(envelope) = fleet_authority {
11391        crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?;
11392    }
11393
11394    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
11395    let execution_config = config_for_cli_route(config, &route);
11396    let auto_model = route.auto_model;
11397    let effective_provider = route.provider;
11398    let effective_model = route.model;
11399    let validated_route = crate::route_runtime::resolve_runtime_route(
11400        &execution_config,
11401        effective_provider,
11402        Some(&effective_model),
11403    )
11404    .map_err(anyhow::Error::msg)?
11405    .validate()
11406    .map_err(anyhow::Error::msg)?;
11407    let effective_provider_name = validated_route.identity.key.clone();
11408    let effective_provider_id = validated_route.identity.exact_id.clone();
11409    let (effective_provider_kind, effective_stream_provider_id) =
11410        exec_stream_provider_route(&validated_route.identity);
11411    let route_source = if auto_model {
11412        "auto_resolver"
11413    } else {
11414        "explicit_or_configured"
11415    }
11416    .to_string();
11417    let exec_started = Instant::now();
11418    let prompt_sha256 = format!("sha256:{}", crate::hashing::sha256_hex(prompt.as_bytes()));
11419    let binary_sha256 = current_binary_sha256();
11420    let approval_posture = if auto_approve { "auto_tools" } else { "ask" }.to_string();
11421    let sandbox_posture = explicit_sandbox.unwrap_or("configured_default").to_string();
11422    let active_route_limits =
11423        crate::route_budget::known_route_limits(validated_route.candidate.limits());
11424    let max_subagents = if max_subagents == config.max_subagents_for_provider(config.api_provider())
11425    {
11426        execution_config
11427            .max_subagents_for_provider(effective_provider)
11428            .clamp(1, MAX_SUBAGENTS)
11429    } else {
11430        max_subagents
11431    };
11432    // A FIXED model with `--reasoning-effort auto` (the exact shape a Fleet
11433    // worker subprocess launches with: `--model <exact> --reasoning-effort
11434    // auto`) is still Auto. `auto_model` is a *model* decision and is false
11435    // here, so deriving the auto flag from it left this path both raw and
11436    // non-auto: the literal string `"auto"` travelled to the engine while the
11437    // receipt claimed no Auto was in play.
11438    let reasoning_effort_auto = route.auto_controls_reasoning;
11439    // Resolve Auto against this run's prompt at the CLI boundary, exactly like
11440    // `run_one_shot`/`run_one_shot_json` and the interactive launch path do,
11441    // so the tier the engine (and the receipt below) sees is concrete.
11442    let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| {
11443        cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt)
11444    });
11445
11446    let settings = crate::settings::Settings::load().unwrap_or_default();
11447    let auto_compact_enabled = if crate::settings::Settings::auto_compact_explicitly_configured() {
11448        settings.auto_compact
11449    } else {
11450        crate::route_budget::auto_compact_default_for_route(
11451            effective_provider,
11452            &effective_model,
11453            active_route_limits,
11454        )
11455    };
11456    let compaction = CompactionConfig {
11457        enabled: auto_compact_enabled,
11458        model: effective_model.clone(),
11459        effective_context_window: Some(crate::route_budget::route_context_window_tokens(
11460            effective_provider,
11461            &effective_model,
11462            active_route_limits,
11463        )),
11464        token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
11465            effective_provider,
11466            &effective_model,
11467            active_route_limits,
11468            settings.auto_compact_threshold_percent,
11469        ),
11470        ..Default::default()
11471    };
11472
11473    let network_policy = exec_network_policy(&execution_config, outer_network_access);
11474
11475    let lsp_config = (!fleet_authority_active)
11476        .then(|| {
11477            execution_config
11478                .lsp
11479                .clone()
11480                .map(crate::config::LspConfigToml::into_runtime)
11481        })
11482        .flatten();
11483    let mut engine_features = execution_config.features();
11484    apply_fleet_engine_feature_caps(
11485        &mut engine_features,
11486        fleet_authority_active,
11487        outer_network_access,
11488        outer_shell_authority,
11489    );
11490    if crate::core::allowlist_is_native_file_and_shell_only(allowed_tools.as_deref()) {
11491        engine_features.disable(crate::features::Feature::Mcp);
11492    }
11493    let engine_plugin_registry = if fleet_authority_active {
11494        std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
11495    } else {
11496        plugin_registry
11497    };
11498    let exec_allow_shell = crate::tools::spec::fleet_exec_shell_enabled(
11499        fleet_authority_active,
11500        outer_shell_authority,
11501        disallowed_tools.as_deref(),
11502    ) || (!fleet_authority_active
11503        && (auto_approve || execution_config.allow_shell()));
11504    let persist_services_enabled = cfg!(unix)
11505        && !fleet_authority_active
11506        && exec_allow_shell
11507        && explicit_sandbox
11508            .is_some_and(|sandbox| sandbox.eq_ignore_ascii_case("danger-full-access"));
11509    let exec_shell_manager = crate::tools::shell::new_shared_shell_manager(workspace.clone());
11510    let runtime_services = crate::tools::spec::RuntimeToolServices {
11511        shell_manager: Some(exec_shell_manager.clone()),
11512        persist_services_enabled,
11513        ..crate::tools::spec::RuntimeToolServices::default()
11514    };
11515
11516    let engine_config = EngineConfig {
11517        model: effective_model.clone(),
11518        active_route_limits,
11519        workspace: workspace.clone(),
11520        subagent_state_root: None,
11521        plugin_registry: Some(std::sync::Arc::clone(&engine_plugin_registry)),
11522        allow_shell: exec_allow_shell,
11523        trust_mode,
11524        notes_path: execution_config.notes_path(),
11525        mcp_config_path: execution_config.mcp_config_path(),
11526        skills_dir: execution_config.skills_dir(),
11527        skills_scan_codewhale_only: execution_config.skills_config().scan_codewhale_only(),
11528        instructions: {
11529            let mut instrs: Vec<crate::prompts::InstructionSource> = execution_config
11530                .instructions_paths()
11531                .into_iter()
11532                .map(Into::into)
11533                .collect();
11534            if let Some(ref extra) = append_system_prompt {
11535                instrs.push(crate::prompts::InstructionSource::Inline {
11536                    name: "cli:append-system-prompt".into(),
11537                    content: extra.clone(),
11538                });
11539            }
11540            instrs
11541        },
11542        project_context_pack_enabled: execution_config.project_context_pack_enabled(),
11543        translation_enabled: false,
11544        max_steps: max_turns,
11545        max_subagents,
11546        max_admitted_subagents: execution_config
11547            .max_admitted_subagents_for_provider(effective_provider)
11548            .max(max_subagents),
11549        launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider),
11550        subagents_enabled: !fleet_authority_active
11551            && execution_config.subagents_enabled_for_provider(effective_provider),
11552        features: engine_features,
11553        auto_review_policy: execution_config.auto_review_policy(),
11554        compaction: compaction.clone(),
11555        todos: new_shared_todo_list(),
11556        plan_state: new_shared_plan_state(),
11557        goal_state: crate::tools::goal::new_shared_goal_state(),
11558        max_spawn_depth: if fleet_authority_active {
11559            0
11560        } else {
11561            execution_config.subagent_max_spawn_depth_for_provider(effective_provider)
11562        },
11563        subagent_token_budget: execution_config
11564            .subagent_token_budget_for_provider(effective_provider),
11565        network_policy,
11566        snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled,
11567        snapshots_max_workspace_bytes: execution_config
11568            .snapshots_config()
11569            .max_workspace_gb
11570            .saturating_mul(1024 * 1024 * 1024),
11571        lsp_config,
11572        runtime_services,
11573        subagent_model_overrides: execution_config.subagent_model_overrides(),
11574        fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load_with_plugins(
11575            &execution_config.fleet_config(),
11576            &workspace,
11577            engine_plugin_registry.as_ref(),
11578        )),
11579        subagent_api_timeout: std::time::Duration::from_secs(
11580            execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
11581        ),
11582        stream_chunk_timeout: std::time::Duration::from_secs(
11583            execution_config.stream_chunk_timeout_secs(),
11584        ),
11585        subagent_heartbeat_timeout: std::time::Duration::from_secs(
11586            execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
11587        ),
11588        prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
11589        bwrap_extensions: crate::sandbox::BwrapMountExtensions {
11590            read_only_roots: execution_config.bwrap_ro_roots.clone(),
11591            device_roots: execution_config.bwrap_dev_roots.clone(),
11592        },
11593        memory_enabled: execution_config.memory_enabled(),
11594        memory_path: execution_config.memory_path(),
11595        speech_output_dir: execution_config.speech_output_dir(),
11596        vision_config: execution_config.vision_model_config(),
11597        strict_tool_mode: execution_config.strict_tool_mode.unwrap_or(false),
11598        goal_objective: None,
11599        goal_token_budget: None,
11600        goal_status: crate::tools::goal::GoalStatus::Active,
11601        goal_max_continuations: execution_config.goal_max_continuations(),
11602        goal_continuation_delay_seconds: execution_config.goal_continuation_delay_seconds(),
11603        allowed_tools: allowed_tools.clone(),
11604        disallowed_tools: disallowed_tools.clone(),
11605        max_tool_calls: None,
11606        hook_executor: None,
11607        locale_tag: crate::localization::resolve_locale(&settings.locale)
11608            .tag()
11609            .to_string(),
11610        workshop: {
11611            crate::tools::large_output_router::WorkshopConfig::install_active(
11612                config.workshop.as_ref(),
11613            );
11614            config.workshop.clone()
11615        },
11616        search_provider: execution_config.search_provider(),
11617        search_api_key: execution_config
11618            .search
11619            .as_ref()
11620            .and_then(|s| s.api_key.clone()),
11621        search_base_url: execution_config
11622            .search
11623            .as_ref()
11624            .and_then(|s| s.base_url.clone()),
11625        tools_always_load: if fleet_authority_active {
11626            std::collections::HashSet::new()
11627        } else {
11628            execution_config.tools_always_load()
11629        },
11630        tools: if fleet_authority_active {
11631            None
11632        } else {
11633            execution_config.tools.clone()
11634        },
11635        verbosity: execution_config.verbosity.clone(),
11636        workspace_follow_symlinks: settings.workspace_follow_symlinks,
11637        exec_policy_engine: execution_config.exec_policy_engine.clone(),
11638        terminal_chrome_enabled: false,
11639        advisor_config: execution_config
11640            .advisor
11641            .as_ref()
11642            .map(crate::tools::subagent::AdvisorConfig::from_toml)
11643            .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
11644    };
11645
11646    let engine_handle = spawn_engine(engine_config, &execution_config);
11647    let mode = if auto_approve {
11648        AppMode::Yolo
11649    } else {
11650        AppMode::Agent
11651    };
11652
11653    let resuming_session = resume_session.is_some();
11654    let mut loaded_session_id = None;
11655    if let Some(saved) = resume_session {
11656        let saved_id = saved.metadata.id.clone();
11657        if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text {
11658            eprintln!(
11659                "Warning: session {} was created in a different workspace ({}). Resuming anyway.",
11660                truncate_id(&saved_id),
11661                saved.metadata.workspace.display(),
11662            );
11663        }
11664
11665        engine_handle
11666            .send(Op::SyncSession {
11667                session_id: Some(saved_id.clone()),
11668                messages: saved.messages,
11669                system_prompt: saved.system_prompt.map(SystemPrompt::Text),
11670                system_prompt_override: false,
11671                model: saved.metadata.model,
11672                workspace: saved.metadata.workspace,
11673                mode,
11674            })
11675            .await?;
11676        loaded_session_id = Some(saved_id.clone());
11677        if output_format == ExecOutputFormat::Text && !json_output {
11678            eprintln!("{}", exec_resumed_session_line(&saved_id));
11679        }
11680    }
11681
11682    engine_handle
11683        .send(Op::SendMessage {
11684            content: prompt.to_string(),
11685            mode,
11686            route: Box::new(validated_route.into_resolved()),
11687            compaction: Box::new(compaction.clone()),
11688            goal_objective: None,
11689            goal_token_budget: None,
11690            goal_status: crate::tools::goal::GoalStatus::Active,
11691            allowed_tools: allowed_tools.clone(),
11692            dynamic_tools: Vec::new(),
11693            hook_executor: None,
11694            reasoning_effort: effective_reasoning_effort,
11695            reasoning_effort_auto,
11696            auto_model,
11697            allow_shell: auto_approve || execution_config.allow_shell(),
11698            trust_mode,
11699            auto_approve,
11700            translation_enabled: false,
11701            approval_mode: if auto_approve {
11702                crate::tui::approval::ApprovalMode::Bypass
11703            } else {
11704                execution_config
11705                    .approval_policy
11706                    .as_deref()
11707                    .and_then(crate::tui::approval::ApprovalMode::from_config_value)
11708                    .unwrap_or_default()
11709            },
11710            verbosity: execution_config.verbosity.clone(),
11711            provenance: crate::core::ops::UserInputProvenance::ExternalUser,
11712        })
11713        .await?;
11714
11715    let mut summary = ExecSummary {
11716        mode: "agent".to_string(),
11717        provider: effective_provider_name.clone(),
11718        model: effective_model.clone(),
11719        prompt: prompt.to_string(),
11720        ..ExecSummary::default()
11721    };
11722    let can_elevate_sandbox =
11723        exec_sandbox_elevation_authorized(allow_sandbox_elevation, explicit_sandbox);
11724    let mut sandbox_denied = false;
11725    let mut approval_required = false;
11726    let mut tool_error_seen = false;
11727    let mut last_error_category = None;
11728    let mut reported_sandbox_contract = false;
11729
11730    let should_persist_session = resuming_session || output_format == ExecOutputFormat::StreamJson;
11731    let mut latest_session_id = loaded_session_id;
11732    let mut latest_messages: Vec<Message> = Vec::new();
11733    let mut latest_system_prompt: Option<SystemPrompt> = None;
11734    let mut latest_model = effective_model;
11735    let mut latest_workspace = workspace.clone();
11736    let mut tool_starts: HashMap<String, (Instant, String)> = HashMap::new();
11737    let mut turn_usage_seq: u32 = 0;
11738
11739    let mut stdout = io::stdout();
11740    let mut ends_with_newline = false;
11741    loop {
11742        let event = {
11743            let mut rx = engine_handle.rx_event.write().await;
11744            rx.recv().await
11745        };
11746
11747        let Some(event) = event else {
11748            break;
11749        };
11750
11751        match event {
11752            Event::MessageDelta { content, .. } => {
11753                summary.output.push_str(&content);
11754                if output_format == ExecOutputFormat::StreamJson {
11755                    emit_exec_stream_event(&ExecStreamEvent::Content { content })?;
11756                } else if !json_output {
11757                    print!("{content}");
11758                    stdout.flush()?;
11759                }
11760                ends_with_newline = summary.output.ends_with('\n');
11761            }
11762            Event::MessageComplete { .. }
11763                if output_format == ExecOutputFormat::Text
11764                    && !json_output
11765                    && !ends_with_newline =>
11766            {
11767                println!();
11768            }
11769            Event::ThinkingDelta { .. } => {
11770                // Exec stream-json intentionally omits reasoning deltas; the
11771                // TUI transcript retains its existing Activity Detail surface.
11772            }
11773            Event::ToolCallStarted { id, name, input } => {
11774                let started_at = chrono::Utc::now().to_rfc3339();
11775                tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
11776                if output_format == ExecOutputFormat::StreamJson {
11777                    emit_exec_stream_event(&ExecStreamEvent::ToolUse {
11778                        name,
11779                        id,
11780                        input,
11781                        started_at,
11782                    })?;
11783                } else if !json_output {
11784                    let summary = summarize_tool_args(&input);
11785                    if let Some(summary) = summary {
11786                        eprintln!("tool: {name} ({summary})");
11787                    } else {
11788                        eprintln!("tool: {name}");
11789                    }
11790                }
11791            }
11792            Event::ToolCallComplete {
11793                id, name, result, ..
11794            } => {
11795                let (duration_ms, started_at) = tool_starts
11796                    .remove(&id)
11797                    .map(|(started, timestamp)| {
11798                        (
11799                            u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
11800                            timestamp,
11801                        )
11802                    })
11803                    .unwrap_or_else(|| (0, chrono::Utc::now().to_rfc3339()));
11804                let receipt_name = name.clone();
11805                match result {
11806                    Ok(output) => {
11807                        tool_error_seen |= !output.success;
11808                        summary.tools.push(ExecToolEntry {
11809                            name: name.clone(),
11810                            success: output.success,
11811                            output: output.content.clone(),
11812                        });
11813                        if output_format == ExecOutputFormat::StreamJson {
11814                            emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11815                                id,
11816                                name: receipt_name,
11817                                output: output.content,
11818                                status: if output.success {
11819                                    "success".to_string()
11820                                } else {
11821                                    "error".to_string()
11822                                },
11823                                started_at,
11824                                completed_at: chrono::Utc::now().to_rfc3339(),
11825                                duration_ms,
11826                                side_effect_status: output
11827                                    .metadata
11828                                    .as_ref()
11829                                    .and_then(|metadata| metadata.get("side_effect_status"))
11830                                    .and_then(serde_json::Value::as_str)
11831                                    .unwrap_or("unknown")
11832                                    .to_string(),
11833                                error_category: (!output.success).then(|| {
11834                                    output
11835                                        .metadata
11836                                        .as_ref()
11837                                        .and_then(|metadata| metadata.get("error_category"))
11838                                        .and_then(serde_json::Value::as_str)
11839                                        .unwrap_or("tool_reported_failure")
11840                                        .to_string()
11841                                }),
11842                                truncated: output
11843                                    .metadata
11844                                    .as_ref()
11845                                    .and_then(|metadata| metadata.get("truncated"))
11846                                    .and_then(serde_json::Value::as_bool),
11847                                artifact: tool_artifact_receipt(output.metadata.as_ref()),
11848                                result_metadata: output.metadata,
11849                            })?;
11850                        } else if !json_output {
11851                            if name == "exec_shell" && !output.content.trim().is_empty() {
11852                                eprintln!("tool {name} completed");
11853                                eprintln!(
11854                                    "--- stdout/stderr ---\n{}\n---------------------",
11855                                    output.content
11856                                );
11857                            } else {
11858                                eprintln!(
11859                                    "tool {name} completed: {}",
11860                                    summarize_tool_output(&output.content)
11861                                );
11862                            }
11863                        }
11864                    }
11865                    Err(err) => {
11866                        tool_error_seen = true;
11867                        let error_text = err.to_string();
11868                        summary.tools.push(ExecToolEntry {
11869                            name: name.clone(),
11870                            success: false,
11871                            output: error_text.clone(),
11872                        });
11873                        if output_format == ExecOutputFormat::StreamJson {
11874                            emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11875                                id,
11876                                name: receipt_name,
11877                                output: error_text,
11878                                status: "error".to_string(),
11879                                started_at,
11880                                completed_at: chrono::Utc::now().to_rfc3339(),
11881                                duration_ms,
11882                                side_effect_status: "not_started_or_unknown".to_string(),
11883                                error_category: Some(tool_error_receipt_category(&err).to_string()),
11884                                truncated: None,
11885                                artifact: None,
11886                                result_metadata: None,
11887                            })?;
11888                        } else if !json_output {
11889                            eprintln!("tool {name} failed: {err}");
11890                        }
11891                    }
11892                }
11893            }
11894            Event::AgentSpawned { id, prompt, .. }
11895                if output_format == ExecOutputFormat::Text && !json_output =>
11896            {
11897                eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
11898            }
11899            Event::AgentProgress { id, status, .. }
11900                if output_format == ExecOutputFormat::Text && !json_output =>
11901            {
11902                eprintln!("sub-agent {id}: {status}");
11903            }
11904            Event::AgentComplete { id, result, .. }
11905                if output_format == ExecOutputFormat::Text && !json_output =>
11906            {
11907                eprintln!(
11908                    "sub-agent {id} completed: {}",
11909                    summarize_tool_output(&result)
11910                );
11911            }
11912            Event::AgentSpawned {
11913                id,
11914                parent_run_id,
11915                spawn_depth,
11916                model,
11917                route_source,
11918                ..
11919            } if output_format == ExecOutputFormat::StreamJson => {
11920                emit_exec_stream_event(&ExecStreamEvent::AgentSpawned {
11921                    id,
11922                    model,
11923                    spawn_depth,
11924                    parent_run_id,
11925                    route_source,
11926                })?;
11927            }
11928            Event::AgentSpawned { .. }
11929            | Event::AgentProgress { .. }
11930            | Event::AgentComplete { .. } => {}
11931            Event::WorkflowUi { run_id, event, .. }
11932                if output_format == ExecOutputFormat::StreamJson =>
11933            {
11934                emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11935            }
11936            Event::ApprovalRequired { id, .. } => {
11937                if auto_approve {
11938                    let _ = engine_handle.approve_tool_call(id).await;
11939                } else {
11940                    approval_required = true;
11941                    let _ = engine_handle.deny_tool_call(id).await;
11942                }
11943            }
11944            Event::ElevationRequired {
11945                tool_id,
11946                tool_name,
11947                denial_reason,
11948                ..
11949            } => {
11950                if can_elevate_sandbox {
11951                    let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
11952                    let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
11953                } else {
11954                    sandbox_denied = true;
11955                    approval_required = true;
11956                    summary.outcomes.push(ExecOutcome {
11957                        kind: "sandbox_denied".to_string(),
11958                        outcome: "approval_required".to_string(),
11959                        tool_name: tool_name.clone(),
11960                        reason: denial_reason.clone(),
11961                    });
11962                    if !reported_sandbox_contract {
11963                        eprintln!(
11964                            "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"
11965                        );
11966                        reported_sandbox_contract = true;
11967                    }
11968                    if output_format == ExecOutputFormat::StreamJson {
11969                        emit_exec_stream_event(&ExecStreamEvent::SandboxDenied {
11970                            tool_id: tool_id.clone(),
11971                            tool_name,
11972                            reason: denial_reason,
11973                            outcome: "approval_required".to_string(),
11974                        })?;
11975                    }
11976                    let _ = engine_handle.deny_tool_call(tool_id).await;
11977                }
11978            }
11979            Event::Error {
11980                envelope,
11981                recoverable: _,
11982            } => {
11983                // Only a non-recoverable envelope may force the run summary
11984                // into failure. Recoverable warnings (stream-stall notices,
11985                // transient retry noise) are still streamed for visibility,
11986                // but the terminal TurnComplete event carries the
11987                // authoritative turn outcome — letting a warning set
11988                // `summary.error` here would exit an otherwise-successful
11989                // `exec` run non-zero.
11990                if exec_error_event_is_fatal(&envelope) {
11991                    last_error_category = Some(envelope.category);
11992                    summary.error_category = Some(envelope.category.to_string());
11993                    summary.error = Some(envelope.message.clone());
11994                }
11995                if output_format == ExecOutputFormat::StreamJson {
11996                    emit_exec_stream_event(&ExecStreamEvent::Error {
11997                        error: envelope.message,
11998                    })?;
11999                } else if !json_output {
12000                    eprintln!("error: {}", envelope.message);
12001                }
12002            }
12003            Event::TurnUsage {
12004                usage, duration_ms, ..
12005            } => {
12006                if output_format == ExecOutputFormat::StreamJson {
12007                    turn_usage_seq = turn_usage_seq.saturating_add(1);
12008                    emit_exec_stream_event(&ExecStreamEvent::TurnUsage {
12009                        turn: turn_usage_seq,
12010                        input_tokens: usage.input_tokens,
12011                        output_tokens: usage.output_tokens,
12012                        reasoning_tokens: usage.reasoning_tokens,
12013                        prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
12014                        prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
12015                        prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
12016                        reasoning_replay_tokens: usage.reasoning_replay_tokens,
12017                        duration_ms,
12018                    })?;
12019                }
12020            }
12021            Event::TurnComplete {
12022                status,
12023                error,
12024                usage,
12025                tool_catalog,
12026                ..
12027            } => {
12028                let (terminal_status, terminal_error) = (status, error);
12029                #[cfg(unix)]
12030                let (mut terminal_status, mut terminal_error) = (terminal_status, terminal_error);
12031                if matches!(
12032                    terminal_status,
12033                    crate::core::events::TurnOutcomeStatus::Completed
12034                ) && terminal_error.is_none()
12035                {
12036                    #[cfg(unix)]
12037                    match exec_shell_manager.lock() {
12038                        Ok(mut manager) => match manager.commit_persistent_services() {
12039                            Ok(receipts) => {
12040                                for receipt in &receipts {
12041                                    if output_format == ExecOutputFormat::StreamJson {
12042                                        emit_exec_stream_event(
12043                                            &ExecStreamEvent::ServiceReleased {
12044                                                task_id: receipt.task_id.clone(),
12045                                                pid: receipt.pid,
12046                                                process_group_id: receipt.process_group_id,
12047                                                ownership: receipt.ownership.clone(),
12048                                            },
12049                                        )?;
12050                                    } else if !json_output {
12051                                        eprintln!(
12052                                            "persistent service released: {} pid={} pgid={} ownership={}",
12053                                            receipt.task_id,
12054                                            receipt.pid,
12055                                            receipt.process_group_id,
12056                                            receipt.ownership
12057                                        );
12058                                    }
12059                                }
12060                                summary.released_services.extend(receipts);
12061                            }
12062                            Err(error) => {
12063                                manager.abort_persistent_services();
12064                                terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
12065                                terminal_error = Some(format!(
12066                                    "Persistent service ownership transfer failed: {error}"
12067                                ));
12068                            }
12069                        },
12070                        Err(_) => {
12071                            terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
12072                            terminal_error = Some(
12073                                "Persistent service ownership transfer failed: shell manager lock poisoned"
12074                                    .to_string(),
12075                            );
12076                        }
12077                    }
12078                } else if let Ok(mut manager) = exec_shell_manager.lock() {
12079                    manager.abort_persistent_services();
12080                }
12081                summary.status = Some(format!("{terminal_status:?}").to_lowercase());
12082                if terminal_error.is_some() {
12083                    summary.error = terminal_error;
12084                }
12085                if sandbox_denied
12086                    && summary.error.is_none()
12087                    && matches!(
12088                        terminal_status,
12089                        crate::core::events::TurnOutcomeStatus::Failed
12090                    )
12091                {
12092                    summary.error = Some(
12093                        "exec turn failed after sandbox denial; explicit sandbox elevation was not authorized"
12094                            .to_string(),
12095                    );
12096                }
12097                if last_error_category.is_none() {
12098                    last_error_category = summary
12099                        .error
12100                        .as_deref()
12101                        .map(crate::error_taxonomy::classify_error_message);
12102                    summary.error_category =
12103                        last_error_category.map(|category| category.to_string());
12104                }
12105                let termination_reason = crate::core::termination::classify_turn_termination(
12106                    terminal_status,
12107                    last_error_category,
12108                    tool_error_seen,
12109                    approval_required,
12110                );
12111                summary.termination_reason = Some(termination_reason.as_str().to_string());
12112                // State the exit class here rather than inferring it later
12113                // from the process exit code: `Canceled` exits 130, the same
12114                // value the SIGINT path uses, so a code-based derivation would
12115                // report every Esc-cancelled turn as a signal. A no-op unless
12116                // this process was armed.
12117                if !termination_reason.is_success() {
12118                    codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
12119                }
12120                let saved_session_id = if should_persist_session && !latest_messages.is_empty() {
12121                    match persist_exec_session(
12122                        &latest_messages,
12123                        &latest_model,
12124                        PersistedProviderRoute {
12125                            kind: effective_provider.as_str(),
12126                            id: effective_provider_id.as_deref(),
12127                        },
12128                        &latest_workspace,
12129                        &latest_system_prompt,
12130                        latest_session_id.as_deref(),
12131                        u64::from(usage.input_tokens) + u64::from(usage.output_tokens),
12132                    ) {
12133                        Ok(id) => {
12134                            if output_format == ExecOutputFormat::Text && !json_output {
12135                                eprintln!("{}", exec_saved_session_line(&id));
12136                            }
12137                            Some(id)
12138                        }
12139                        Err(err) => {
12140                            if output_format == ExecOutputFormat::Text && !json_output {
12141                                eprintln!("warning: failed to save exec session: {err}");
12142                            }
12143                            latest_session_id.clone()
12144                        }
12145                    }
12146                } else {
12147                    latest_session_id.clone()
12148                };
12149                if output_format == ExecOutputFormat::StreamJson {
12150                    if let Some(id) = saved_session_id.as_ref() {
12151                        emit_exec_stream_event(&ExecStreamEvent::SessionCapture {
12152                            content: exec_stream_session_ref(id),
12153                        })?;
12154                    }
12155                    // Resolved output ceiling and its provenance, surfaced so a
12156                    // wrong ceiling is visible in the receipt rather than
12157                    // requiring packet capture.
12158                    let codewhale_max_output_tokens =
12159                        crate::route_budget::effective_max_output_tokens_for_route(
12160                            effective_provider,
12161                            &latest_model,
12162                            active_route_limits,
12163                        );
12164                    let codewhale_max_output_tokens_source =
12165                        crate::route_budget::output_ceiling_source(
12166                            effective_provider,
12167                            &latest_model,
12168                        )
12169                        .as_str();
12170                    emit_exec_stream_event(&ExecStreamEvent::Metadata {
12171                        meta: Box::new(ExecStreamMeta {
12172                            receipt_kind: "terminal",
12173                            provider: effective_provider_kind.clone(),
12174                            provider_id: effective_stream_provider_id.clone(),
12175                            model: latest_model.clone(),
12176                            route_source: route_source.clone(),
12177                            input_tokens: Some(usage.input_tokens),
12178                            output_tokens: Some(usage.output_tokens),
12179                            prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
12180                            prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
12181                            prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
12182                            reasoning_tokens: usage.reasoning_tokens,
12183                            codewhale_max_output_tokens: Some(codewhale_max_output_tokens),
12184                            codewhale_max_output_tokens_source: Some(
12185                                codewhale_max_output_tokens_source,
12186                            ),
12187                            duration_ms: u64::try_from(exec_started.elapsed().as_millis())
12188                                .unwrap_or(u64::MAX),
12189                            retry_count: None,
12190                            approval_posture: approval_posture.clone(),
12191                            sandbox_posture: sandbox_posture.clone(),
12192                            binary_sha256: binary_sha256.clone(),
12193                            config_sha256: None,
12194                            prompt_sha256: prompt_sha256.clone(),
12195                            tool_catalog_sha256: tool_catalog.as_ref().and_then(|catalog| {
12196                                serde_json::to_vec(catalog).ok().map(|bytes| {
12197                                    format!("sha256:{}", crate::hashing::sha256_hex(&bytes))
12198                                })
12199                            }),
12200                            input_analysis: exec_stream_input_analysis(
12201                                &latest_messages,
12202                                latest_system_prompt.as_ref(),
12203                            ),
12204                            visible_final_answer_chars: summary.output.chars().count(),
12205                            resume_command: saved_session_id
12206                                .as_deref()
12207                                .map(exec_stream_resume_hint)
12208                                .unwrap_or_default(),
12209                            session_id: saved_session_id
12210                                .as_deref()
12211                                .map(exec_stream_session_ref)
12212                                .unwrap_or_default(),
12213                            workspace: latest_workspace.display().to_string(),
12214                            message_count: latest_messages.len(),
12215                            status: summary.status.clone(),
12216                            termination_reason: summary.termination_reason.clone(),
12217                            error_category: summary.error_category.clone(),
12218                            error: summary.error.clone(),
12219                        }),
12220                    })?;
12221                    emit_exec_stream_event(&ExecStreamEvent::Done)?;
12222                }
12223                let _ = engine_handle.send(Op::Shutdown).await;
12224                break;
12225            }
12226            Event::SessionUpdated {
12227                session_id,
12228                messages,
12229                system_prompt,
12230                model,
12231                workspace,
12232            } => {
12233                latest_session_id = Some(session_id);
12234                latest_messages = messages;
12235                latest_system_prompt = system_prompt;
12236                latest_model = model;
12237                latest_workspace = workspace;
12238            }
12239            // #3027: surface the engine's max-steps notice in text mode so a
12240            // --max-turns run that stops early says why instead of going quiet.
12241            Event::Status { message }
12242                if output_format == ExecOutputFormat::Text
12243                    && !json_output
12244                    && message.contains("Maximum model steps") =>
12245            {
12246                eprintln!("{message}");
12247            }
12248            _ => {}
12249        }
12250    }
12251
12252    if summary.status.is_none() {
12253        if let Ok(mut manager) = exec_shell_manager.lock() {
12254            manager.abort_persistent_services();
12255        }
12256        let error = summary.error.clone().unwrap_or_else(|| {
12257            "Engine event channel closed before a terminal turn receipt".to_string()
12258        });
12259        let category = last_error_category
12260            .unwrap_or_else(|| crate::error_taxonomy::classify_error_message(&error));
12261        let termination_reason = crate::core::termination::classify_turn_termination(
12262            crate::core::events::TurnOutcomeStatus::Failed,
12263            Some(category),
12264            tool_error_seen,
12265            approval_required,
12266        );
12267        summary.status = Some("failed".to_string());
12268        summary.error_category = Some(category.to_string());
12269        summary.termination_reason = Some(termination_reason.as_str().to_string());
12270        summary.error = Some(error.clone());
12271        if output_format == ExecOutputFormat::StreamJson {
12272            emit_exec_stream_event(&ExecStreamEvent::Error { error })?;
12273        }
12274    }
12275
12276    if json_output {
12277        println!("{}", serde_json::to_string_pretty(&summary)?);
12278    }
12279
12280    if let Some(error) = summary.error.as_ref()
12281        && !error.trim().is_empty()
12282    {
12283        // Distinguish retryable infrastructure failures (provider/transport,
12284        // after all in-session retries are exhausted) from genuine task
12285        // failures so supervisors and bench harnesses can tell them apart at
12286        // the process level without parsing the stream. Genuine failures
12287        // keep the historical `bail!` → exit 1 path.
12288        let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
12289        if exit_code != 1 {
12290            eprintln!("Error: exec turn failed: {error}");
12291            let _ = io::stdout().flush();
12292            std::process::exit(exit_code);
12293        }
12294        bail!("exec turn failed: {error}");
12295    }
12296
12297    if matches!(
12298        summary.status.as_deref(),
12299        Some("failed" | "canceled" | "interrupted")
12300    ) {
12301        let status = summary.status.as_deref().unwrap_or("unknown");
12302        bail!("exec turn ended with status {status}");
12303    }
12304
12305    Ok(())
12306}
12307
12308#[cfg(test)]
12309mod serve_bind_host_tests {
12310    use super::*;
12311
12312    #[test]
12313    fn http_defaults_to_loopback() {
12314        assert_eq!(
12315            resolve_serve_bind_host(false, None),
12316            ServeBindHost {
12317                host: "127.0.0.1".to_string(),
12318                mobile_rebound_to_lan: false,
12319            }
12320        );
12321    }
12322
12323    #[test]
12324    fn mobile_default_rebinds_to_lan_with_warning_flag() {
12325        assert_eq!(
12326            resolve_serve_bind_host(true, None),
12327            ServeBindHost {
12328                host: "0.0.0.0".to_string(),
12329                mobile_rebound_to_lan: true,
12330            }
12331        );
12332    }
12333
12334    #[test]
12335    fn mobile_respects_explicit_loopback_host() {
12336        assert_eq!(
12337            resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
12338            ServeBindHost {
12339                host: "127.0.0.1".to_string(),
12340                mobile_rebound_to_lan: false,
12341            }
12342        );
12343    }
12344
12345    #[test]
12346    fn http_and_mobile_are_mutually_exclusive() {
12347        let err = validate_serve_mode_selection(false, true, true, false, false).unwrap_err();
12348        assert!(
12349            err.to_string()
12350                .contains("--http and --mobile are mutually exclusive")
12351        );
12352    }
12353
12354    #[test]
12355    fn web_is_a_distinct_loopback_runtime_mode() {
12356        assert!(validate_serve_mode_selection(false, false, false, true, false).unwrap());
12357        let err = validate_serve_mode_selection(false, true, false, true, false).unwrap_err();
12358        assert!(err.to_string().contains("--web is mutually exclusive"));
12359        assert_eq!(
12360            resolve_serve_bind_host(false, None),
12361            ServeBindHost {
12362                host: "127.0.0.1".to_string(),
12363                mobile_rebound_to_lan: false,
12364            }
12365        );
12366    }
12367}
12368
12369#[cfg(test)]
12370#[path = "tests/exec_exit_semantics.rs"]
12371mod exec_exit_semantics_tests;
12372#[cfg(test)]
12373mod doctor_legacy_state_tests {
12374    use super::*;
12375    use std::env;
12376    use std::ffi::OsString;
12377    use std::fs;
12378    use tempfile::TempDir;
12379
12380    struct EnvVarRestore {
12381        key: &'static str,
12382        previous: Option<OsString>,
12383    }
12384
12385    impl EnvVarRestore {
12386        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
12387            let previous = env::var_os(key);
12388            unsafe {
12389                env::set_var(key, value);
12390            }
12391            Self { key, previous }
12392        }
12393    }
12394
12395    impl Drop for EnvVarRestore {
12396        fn drop(&mut self) {
12397            unsafe {
12398                match &self.previous {
12399                    Some(value) => env::set_var(self.key, value),
12400                    None => env::remove_var(self.key),
12401                }
12402            }
12403        }
12404    }
12405
12406    fn roots(tmp: &TempDir) -> (PathBuf, PathBuf) {
12407        (tmp.path().join(".codewhale"), tmp.path().join(".deepseek"))
12408    }
12409
12410    fn entry<'a>(report: &'a [DoctorLegacyStateEntry], name: &str) -> &'a DoctorLegacyStateEntry {
12411        report
12412            .iter()
12413            .find(|entry| entry.name == name)
12414            .expect("legacy state entry should exist")
12415    }
12416
12417    #[test]
12418    fn doctor_legacy_state_report_marks_unmigrated_legacy_entries() {
12419        let tmp = TempDir::new().expect("tempdir");
12420        let (primary_root, legacy_root) = roots(&tmp);
12421        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12422        fs::create_dir_all(legacy_root.join("tasks")).expect("legacy tasks");
12423        fs::create_dir_all(&primary_root).expect("primary root");
12424        fs::write(legacy_root.join("config.toml"), "api_key = 'old'").expect("legacy config");
12425
12426        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12427        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12428
12429        assert_eq!(
12430            entry(&report, "sessions").status,
12431            DoctorLegacyStateStatus::LegacyOnly
12432        );
12433        assert_eq!(
12434            entry(&report, "config.toml").status,
12435            DoctorLegacyStateStatus::LegacyOnly
12436        );
12437        assert_eq!(
12438            entry(&report, "skills").status,
12439            DoctorLegacyStateStatus::Absent
12440        );
12441
12442        let json =
12443            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12444        assert_eq!(json["needs_attention"], true);
12445        assert_eq!(json["legacy_only_count"], 3);
12446        assert_eq!(json["dual_present_count"], 0);
12447    }
12448
12449    #[test]
12450    fn doctor_legacy_state_report_marks_dual_present_entries() {
12451        let tmp = TempDir::new().expect("tempdir");
12452        let (primary_root, legacy_root) = roots(&tmp);
12453        fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12454        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12455        fs::write(primary_root.join("mcp.json"), "{}").expect("primary mcp");
12456        fs::write(legacy_root.join("mcp.json"), "{}").expect("legacy mcp");
12457
12458        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12459        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12460
12461        assert_eq!(
12462            entry(&report, "sessions").status,
12463            DoctorLegacyStateStatus::Both
12464        );
12465        assert_eq!(
12466            entry(&report, "mcp.json").status,
12467            DoctorLegacyStateStatus::Both
12468        );
12469
12470        let json =
12471            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12472        assert_eq!(json["needs_attention"], true);
12473        assert_eq!(json["legacy_only_count"], 0);
12474        assert_eq!(json["dual_present_count"], 2);
12475    }
12476
12477    #[test]
12478    fn doctor_legacy_state_report_is_clear_when_only_primary_exists() {
12479        let tmp = TempDir::new().expect("tempdir");
12480        let (primary_root, legacy_root) = roots(&tmp);
12481        fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12482        fs::write(primary_root.join("settings.toml"), "default_mode = 'ask'")
12483            .expect("primary settings");
12484
12485        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12486        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12487
12488        assert_eq!(
12489            entry(&report, "sessions").status,
12490            DoctorLegacyStateStatus::PrimaryOnly
12491        );
12492        assert!(!report.iter().any(legacy_state_needs_attention));
12493
12494        let json =
12495            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12496        assert_eq!(json["needs_attention"], false);
12497        assert_eq!(json["legacy_only_count"], 0);
12498        assert_eq!(json["dual_present_count"], 0);
12499    }
12500
12501    #[test]
12502    fn doctor_legacy_state_report_is_clear_when_neither_root_exists() {
12503        let tmp = TempDir::new().expect("tempdir");
12504        let (primary_root, legacy_root) = roots(&tmp);
12505
12506        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12507        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12508
12509        assert!(
12510            report
12511                .iter()
12512                .all(|entry| entry.status == DoctorLegacyStateStatus::Absent)
12513        );
12514        assert!(!report.iter().any(legacy_state_needs_attention));
12515
12516        let json =
12517            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12518        assert_eq!(json["needs_attention"], false);
12519        assert_eq!(json["legacy_only_count"], 0);
12520        assert_eq!(json["dual_present_count"], 0);
12521    }
12522
12523    #[test]
12524    fn doctor_reports_incomplete_session_migration_without_mutating_files() {
12525        let tmp = TempDir::new().expect("tempdir");
12526        let (primary_root, legacy_root) = roots(&tmp);
12527        let primary_sessions = primary_root.join("sessions");
12528        let legacy_sessions = legacy_root.join("sessions");
12529        fs::create_dir_all(&primary_sessions).expect("primary sessions");
12530        fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
12531        fs::write(primary_sessions.join("already-there.json"), b"primary")
12532            .expect("primary session");
12533        fs::write(legacy_sessions.join("already-there.json"), b"legacy")
12534            .expect("legacy matching session");
12535        fs::write(
12536            legacy_sessions.join("recover-me.json"),
12537            b"not parsed by doctor",
12538        )
12539        .expect("legacy recoverable session");
12540        fs::write(
12541            legacy_sessions.join("checkpoints").join("latest.json"),
12542            b"checkpoint not inspected",
12543        )
12544        .expect("legacy checkpoint");
12545
12546        let legacy_before = fs::read(legacy_sessions.join("recover-me.json"))
12547            .expect("read legacy fixture before diagnostic");
12548        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12549
12550        assert_eq!(
12551            report.status,
12552            DoctorSessionRecoveryStatus::MigrationIncomplete
12553        );
12554        assert_eq!(report.legacy_session_file_count, 2);
12555        assert_eq!(report.already_present_file_count, 1);
12556        assert_eq!(report.recoverable_file_count, 1);
12557        assert_eq!(report.recoverable.len(), 1);
12558        assert_eq!(report.recoverable[0].name, PathBuf::from("recover-me.json"));
12559        assert!(
12560            !primary_sessions.join("recover-me.json").exists(),
12561            "doctor must not copy a recoverable session"
12562        );
12563        assert_eq!(
12564            fs::read(legacy_sessions.join("recover-me.json"))
12565                .expect("legacy file remains after diagnostic"),
12566            legacy_before,
12567            "doctor must not rewrite or delete the legacy source"
12568        );
12569
12570        let json = doctor_session_recovery_json(&report);
12571        assert_eq!(json["needs_attention"], true);
12572        assert_eq!(json["read_only"], true);
12573        assert_eq!(json["chat_contents_read"], false);
12574        assert_eq!(json["checkpoint_internals_scanned"], false);
12575        assert_eq!(json["recoverable_file_count"], 1);
12576        assert_eq!(json["recovery_command"], "codewhale sessions");
12577        assert_eq!(json["recoverable_files"][0]["name"], "recover-me.json");
12578        let serialized = json.to_string();
12579        assert!(
12580            !serialized.contains("not parsed by doctor"),
12581            "the report must not expose session contents"
12582        );
12583        assert!(
12584            !serialized.contains("checkpoint not inspected"),
12585            "the report must not expose checkpoint contents"
12586        );
12587    }
12588
12589    #[test]
12590    fn doctor_treats_preserved_legacy_sessions_as_complete_by_filename() {
12591        let tmp = TempDir::new().expect("tempdir");
12592        let (primary_root, legacy_root) = roots(&tmp);
12593        let primary_sessions = primary_root.join("sessions");
12594        let legacy_sessions = legacy_root.join("sessions");
12595        fs::create_dir_all(&primary_sessions).expect("primary sessions");
12596        fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12597        fs::write(primary_sessions.join("same-name.json"), b"primary").expect("primary session");
12598        fs::write(legacy_sessions.join("same-name.json"), b"legacy").expect("legacy session");
12599
12600        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12601
12602        assert_eq!(
12603            report.status,
12604            DoctorSessionRecoveryStatus::MigrationComplete
12605        );
12606        assert!(!report.needs_attention());
12607        assert_eq!(report.recoverable_file_count, 0);
12608        assert!(report.recoverable.is_empty());
12609        assert_eq!(report.already_present_file_count, 1);
12610        let json = doctor_session_recovery_json(&report);
12611        assert_eq!(json["session_descriptors_compared"], false);
12612        assert_eq!(
12613            json["counterpart_check"],
12614            "top_level_filename_and_regular_file_only"
12615        );
12616    }
12617
12618    #[test]
12619    fn doctor_bounds_recoverable_session_filename_samples() {
12620        let tmp = TempDir::new().expect("tempdir");
12621        let (primary_root, legacy_root) = roots(&tmp);
12622        let legacy_sessions = legacy_root.join("sessions");
12623        fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12624        for index in 0..DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
12625            fs::write(
12626                legacy_sessions.join(format!("late-{index:03}.json")),
12627                b"fixture",
12628            )
12629            .expect("legacy session fixture");
12630        }
12631        fs::write(legacy_sessions.join("early-000.json"), b"fixture")
12632            .expect("earliest legacy session fixture");
12633        fs::write(legacy_sessions.join("early-001.json"), b"fixture")
12634            .expect("second earliest legacy session fixture");
12635        let total = DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT + 2;
12636
12637        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12638        let json = doctor_session_recovery_json(&report);
12639
12640        assert_eq!(report.recoverable_file_count, total);
12641        assert_eq!(
12642            report.recoverable.len(),
12643            DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
12644        );
12645        assert_eq!(
12646            json["recoverable_files"].as_array().map(Vec::len),
12647            Some(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
12648        );
12649        assert_eq!(
12650            report.recoverable.first().map(|entry| entry.name.as_path()),
12651            Some(Path::new("early-000.json")),
12652            "the bounded sample must not depend on read_dir order"
12653        );
12654        assert_eq!(
12655            report.recoverable.last().map(|entry| entry.name.as_path()),
12656            Some(Path::new("late-097.json")),
12657            "the bounded sample must retain the lexical prefix"
12658        );
12659        assert_eq!(json["recoverable_files_truncated"], true);
12660    }
12661
12662    #[test]
12663    fn doctor_session_recovery_fails_closed_on_an_unreadable_path_shape() {
12664        let tmp = TempDir::new().expect("tempdir");
12665        let (primary_root, legacy_root) = roots(&tmp);
12666        fs::create_dir_all(&legacy_root).expect("legacy root");
12667        fs::write(legacy_root.join("sessions"), b"not a directory")
12668            .expect("invalid legacy sessions path");
12669
12670        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12671
12672        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12673        assert!(report.needs_attention());
12674        assert!(report.error.as_deref().is_some_and(|error| {
12675            error.contains("legacy sessions root") && error.contains("not a directory")
12676        }));
12677    }
12678
12679    #[test]
12680    fn doctor_session_recovery_rejects_a_non_directory_legacy_state_root() {
12681        let tmp = TempDir::new().expect("tempdir");
12682        let (primary_root, legacy_root) = roots(&tmp);
12683        fs::write(&legacy_root, b"not a state directory").expect("invalid legacy root");
12684
12685        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12686
12687        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12688        assert!(report.error.as_deref().is_some_and(|error| {
12689            error.contains("legacy state root") && error.contains("not a directory")
12690        }));
12691    }
12692
12693    #[test]
12694    fn doctor_session_recovery_rejects_a_non_directory_primary_state_root() {
12695        let tmp = TempDir::new().expect("tempdir");
12696        let (primary_root, legacy_root) = roots(&tmp);
12697        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12698        fs::write(&primary_root, b"not a state directory").expect("invalid primary root");
12699
12700        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12701
12702        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12703        assert!(report.error.as_deref().is_some_and(|error| {
12704            error.contains("primary state root") && error.contains("not a directory")
12705        }));
12706    }
12707
12708    #[test]
12709    fn doctor_session_recovery_rejects_a_non_directory_primary_sessions_root() {
12710        let tmp = TempDir::new().expect("tempdir");
12711        let (primary_root, legacy_root) = roots(&tmp);
12712        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12713        fs::create_dir_all(&primary_root).expect("primary root");
12714        fs::write(primary_root.join("sessions"), b"not a sessions directory")
12715            .expect("invalid primary sessions path");
12716
12717        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12718
12719        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12720        assert!(report.error.as_deref().is_some_and(|error| {
12721            error.contains("primary sessions root") && error.contains("not a directory")
12722        }));
12723    }
12724
12725    #[cfg(unix)]
12726    #[test]
12727    fn doctor_session_recovery_rejects_a_symlinked_legacy_sessions_root() {
12728        use std::os::unix::fs::symlink;
12729
12730        let tmp = TempDir::new().expect("tempdir");
12731        let (primary_root, legacy_root) = roots(&tmp);
12732        let external_sessions = tmp.path().join("external-sessions");
12733        fs::create_dir_all(&external_sessions).expect("external sessions");
12734        fs::write(
12735            external_sessions.join("must-not-be-enumerated.json"),
12736            b"session contents must stay unread",
12737        )
12738        .expect("external session fixture");
12739        fs::create_dir_all(&legacy_root).expect("legacy root");
12740        symlink(&external_sessions, legacy_root.join("sessions"))
12741            .expect("symlinked legacy sessions root");
12742
12743        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12744
12745        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12746        assert!(report.needs_attention());
12747        assert_eq!(report.legacy_session_file_count, 0);
12748        assert!(report.recoverable.is_empty());
12749        assert!(
12750            report
12751                .error
12752                .as_deref()
12753                .is_some_and(|error| error.contains("legacy sessions root")
12754                    && error.contains("path is a symlink"))
12755        );
12756    }
12757
12758    #[cfg(unix)]
12759    #[test]
12760    fn doctor_session_recovery_rejects_symlinked_primary_root_and_sessions_root() {
12761        use std::os::unix::fs::symlink;
12762
12763        let tmp = TempDir::new().expect("tempdir");
12764        let (primary_root, legacy_root) = roots(&tmp);
12765        let external_primary = tmp.path().join("external-primary");
12766        fs::create_dir_all(external_primary.join("sessions")).expect("external primary");
12767        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12768        symlink(&external_primary, &primary_root).expect("symlinked primary root");
12769
12770        let root_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12771        assert_eq!(root_report.status, DoctorSessionRecoveryStatus::ScanFailed);
12772        assert!(root_report.error.as_deref().is_some_and(|error| {
12773            error.contains("primary state root") && error.contains("path is a symlink")
12774        }));
12775
12776        fs::remove_file(&primary_root).expect("remove primary root symlink");
12777        fs::create_dir_all(&primary_root).expect("primary root");
12778        symlink(&external_primary, primary_root.join("sessions"))
12779            .expect("symlinked primary sessions root");
12780
12781        let sessions_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12782        assert_eq!(
12783            sessions_report.status,
12784            DoctorSessionRecoveryStatus::ScanFailed
12785        );
12786        assert!(sessions_report.error.as_deref().is_some_and(|error| {
12787            error.contains("primary sessions root") && error.contains("path is a symlink")
12788        }));
12789    }
12790
12791    #[test]
12792    fn explicit_codewhale_home_skips_session_recovery_scan() {
12793        let tmp = TempDir::new().expect("tempdir");
12794        let (primary_root, legacy_root) = roots(&tmp);
12795        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12796        fs::write(legacy_root.join("sessions").join("ambient.json"), b"legacy")
12797            .expect("legacy session");
12798
12799        let report = doctor_session_recovery_report(&primary_root, &legacy_root, true);
12800
12801        assert_eq!(report.status, DoctorSessionRecoveryStatus::Isolated);
12802        assert!(report.codewhale_home_is_explicit);
12803        assert_eq!(report.legacy_session_file_count, 0);
12804        assert_eq!(report.recoverable_file_count, 0);
12805        assert!(report.recoverable.is_empty());
12806        assert!(!report.needs_attention());
12807    }
12808
12809    #[test]
12810    fn doctor_state_roots_ignore_ambient_legacy_home_when_codewhale_home_is_explicit() {
12811        let _env_lock = crate::test_support::lock_test_env();
12812        let tmp = TempDir::new().expect("tempdir");
12813        let explicit_home = tmp.path().join("isolated-codewhale");
12814        let ambient_legacy = tmp.path().join(".deepseek");
12815        fs::create_dir_all(&ambient_legacy).expect("ambient legacy root");
12816        fs::write(
12817            ambient_legacy.join("config.toml"),
12818            "provider = 'deepseek'\n",
12819        )
12820        .expect("ambient legacy config");
12821        let _home = EnvVarRestore::set("HOME", tmp.path());
12822        let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
12823
12824        let (primary_root, legacy_root) = doctor_state_roots();
12825        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12826        let session_recovery = doctor_session_recovery_report(
12827            &primary_root,
12828            &legacy_root,
12829            codewhale_config::codewhale_home_is_explicit(),
12830        );
12831
12832        assert_eq!(primary_root, explicit_home);
12833        assert_eq!(
12834            legacy_root,
12835            primary_root.join(codewhale_config::LEGACY_APP_DIR)
12836        );
12837        assert!(
12838            report
12839                .iter()
12840                .all(|entry| entry.status == DoctorLegacyStateStatus::Absent),
12841            "doctor must not report ambient legacy state when CODEWHALE_HOME is explicit"
12842        );
12843        assert!(!report.iter().any(legacy_state_needs_attention));
12844        assert_eq!(
12845            session_recovery.status,
12846            DoctorSessionRecoveryStatus::Isolated
12847        );
12848        assert!(session_recovery.recoverable.is_empty());
12849    }
12850}
12851
12852#[cfg(test)]
12853mod doctor_setup_state_tests {
12854    use super::*;
12855    use std::fs;
12856    use tempfile::TempDir;
12857
12858    fn prepare_env(tmp: &TempDir) -> (crate::test_support::EnvVarGuard, PathBuf) {
12859        let codewhale_home = tmp.path().join(".codewhale");
12860        fs::create_dir_all(&codewhale_home).expect("codewhale home");
12861        (
12862            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str()),
12863            codewhale_home,
12864        )
12865    }
12866
12867    fn provider_step(report: &serde_json::Value) -> &serde_json::Value {
12868        report["steps"]
12869            .as_array()
12870            .expect("steps array")
12871            .iter()
12872            .find(|step| step["step"] == "provider_model")
12873            .expect("provider/model step")
12874    }
12875
12876    #[test]
12877    fn doctor_setup_consistency_flags_missing_user_constitution() {
12878        let _guard = crate::test_support::lock_test_env();
12879        let tmp = TempDir::new().expect("tempdir");
12880        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12881        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12882        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12883        let workspace = tmp.path().join("workspace");
12884        fs::create_dir_all(&workspace).expect("workspace");
12885
12886        let state = codewhale_config::SetupState {
12887            constitution_source: codewhale_config::ConstitutionSource::UserGlobal,
12888            ..Default::default()
12889        };
12890        state.save().expect("persist setup state");
12891
12892        let report = doctor_setup_report_json(&Config::default(), &workspace);
12893
12894        assert_eq!(report["source"], "persisted");
12895        assert_eq!(report["consistency"]["status"], "inconsistent");
12896        let issues = report["consistency"]["issues"].to_string();
12897        assert!(
12898            issues.contains("setup_state_points_at_missing_user_constitution"),
12899            "{issues}"
12900        );
12901    }
12902
12903    #[test]
12904    fn doctor_setup_consistency_flags_stale_temp_files() {
12905        let _guard = crate::test_support::lock_test_env();
12906        let tmp = TempDir::new().expect("tempdir");
12907        let (_home_guard, codewhale_home) = prepare_env(&tmp);
12908        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12909        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12910        let workspace = tmp.path().join("workspace");
12911        fs::create_dir_all(&workspace).expect("workspace");
12912        fs::write(codewhale_home.join(".tmpAbC123"), b"orphaned atomic write")
12913            .expect("stale temp file");
12914
12915        let report = doctor_setup_report_json(&Config::default(), &workspace);
12916
12917        assert_eq!(report["consistency"]["status"], "inconsistent");
12918        let issues = report["consistency"]["issues"].to_string();
12919        assert!(
12920            issues.contains("stale_setup_temp_files_in_codewhale_home"),
12921            "{issues}"
12922        );
12923    }
12924
12925    #[test]
12926    fn doctor_setup_consistency_reports_consistent_for_clean_home() {
12927        let _guard = crate::test_support::lock_test_env();
12928        let tmp = TempDir::new().expect("tempdir");
12929        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12930        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12931        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12932        let workspace = tmp.path().join("workspace");
12933        fs::create_dir_all(&workspace).expect("workspace");
12934
12935        let report = doctor_setup_report_json(&Config::default(), &workspace);
12936
12937        assert_eq!(report["consistency"]["status"], "consistent");
12938        assert_eq!(
12939            report["consistency"]["issues"]
12940                .as_array()
12941                .map(Vec::len)
12942                .unwrap_or_default(),
12943            0
12944        );
12945    }
12946
12947    #[test]
12948    fn doctor_setup_report_json_derives_state_without_sidecar() {
12949        let _guard = crate::test_support::lock_test_env();
12950        let tmp = TempDir::new().expect("tempdir");
12951        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12952        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12953        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12954        let workspace = tmp.path().join("workspace");
12955        fs::create_dir_all(&workspace).expect("workspace");
12956
12957        let report = doctor_setup_report_json(&Config::default(), &workspace);
12958
12959        assert_eq!(report["source"], "derived");
12960        assert_eq!(report["inherited"], true);
12961        assert_eq!(report["next_actions"]["constitution"], "/constitution");
12962        assert_eq!(report["next_actions"]["setup_report"], "/setup report");
12963        assert_eq!(
12964            report["next_actions"]["provider_model"],
12965            "/setup provider, /provider setup <name>, or /model"
12966        );
12967        assert_eq!(report["next_actions"]["runtime_posture"], "/config");
12968        assert_eq!(
12969            report["next_actions"]["operate_fleet"],
12970            "/setup fleet (readiness), /fleet setup (explicit profile authoring)"
12971        );
12972        assert_eq!(report["next_actions"]["hotbar"], "/setup hotbar");
12973        assert_eq!(report["next_actions"]["tools_mcp"], "/setup tools");
12974        assert_eq!(report["next_actions"]["remote_runtime"], "/setup remote");
12975        assert_eq!(report["next_actions"]["persistence"], "/setup persistence");
12976        assert_eq!(
12977            report["checkpoint_version"],
12978            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12979        );
12980        assert_eq!(report["update_ready"], false);
12981        assert_eq!(report["operate_ready"], false);
12982        assert_eq!(
12983            report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12984            false
12985        );
12986        assert_eq!(
12987            report["operate_fleet"]["roster"]["readiness_rule"],
12988            "built-in starter roster or custom roster"
12989        );
12990        assert_eq!(report["provider_model"]["provider"]["id"], "deepseek");
12991        assert_eq!(report["provider_model"]["provider"]["display"], "DeepSeek");
12992        assert_eq!(
12993            report["provider_model"]["model"]["resolved"],
12994            crate::config::DEFAULT_TEXT_MODEL
12995        );
12996        assert_eq!(
12997            report["provider_model"]["auth"]["source"],
12998            "secret_store_unprobed"
12999        );
13000        assert_eq!(
13001            report["provider_model"]["auth"]["availability"],
13002            "not_probed"
13003        );
13004        assert_eq!(
13005            report["provider_model"]["auth"]["credential_url"],
13006            "https://platform.deepseek.com"
13007        );
13008        assert_eq!(
13009            report["provider_model"]["auth"]["credential_mode"],
13010            "api_key"
13011        );
13012        assert_eq!(
13013            report["provider_model"]["auth"]["env_vars"][0],
13014            "DEEPSEEK_API_KEY"
13015        );
13016        assert_eq!(report["provider_model"]["health"]["live_validation"], false);
13017        assert_eq!(report["constitution"]["source"], "bundled");
13018        assert_eq!(report["constitution"]["autonomy_preference"], "unspecified");
13019        assert_eq!(report["runtime_posture"]["source"], "unset");
13020        assert_eq!(report["runtime_posture"]["default_mode"]["value"], "agent");
13021        assert_eq!(
13022            report["runtime_posture"]["approval_policy"]["value"],
13023            "on-request"
13024        );
13025        assert_eq!(report["runtime_posture"]["allow_shell"]["value"], true);
13026        assert_eq!(
13027            report["runtime_posture"]["sandbox_mode"]["value"],
13028            "mode-derived"
13029        );
13030        assert_eq!(
13031            report["runtime_posture"]["network_default"]["value"],
13032            "prompt"
13033        );
13034        assert_eq!(provider_step(&report)["status"], "needs_action");
13035    }
13036
13037    #[test]
13038    fn doctor_setup_provider_model_json_covers_cn_codex_and_local_matrix() {
13039        let _guard = crate::test_support::lock_test_env();
13040        let tmp = TempDir::new().expect("tempdir");
13041        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13042        let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
13043        let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
13044        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
13045        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
13046        let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
13047        let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
13048        let codex_auth_path = tmp.path().join("external-codex-auth.json");
13049        let codex_auth_raw = serde_json::json!({
13050            "tokens": {
13051                "access_token": crate::test_support::future_test_jwt("doctor"),
13052                "account_id": "acct-doctor-read-only",
13053                "refresh_token": "must-never-be-used",
13054                "unknown": {"preserve": true}
13055            }
13056        })
13057        .to_string();
13058        fs::write(&codex_auth_path, &codex_auth_raw).expect("Codex auth trap fixture");
13059        let _codex_auth =
13060            crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_auth_path);
13061        let workspace = tmp.path().join("workspace");
13062        fs::create_dir_all(&workspace).expect("workspace");
13063
13064        let cn_config = Config {
13065            provider: Some("deepseek-cn".to_string()),
13066            ..Config::default()
13067        };
13068        let cn_report = doctor_setup_report_json(&cn_config, &workspace);
13069        assert_eq!(cn_report["provider_model"]["provider"]["id"], "deepseek-cn");
13070        assert_eq!(
13071            cn_report["provider_model"]["provider"]["display"],
13072            "DeepSeek (legacy alias)"
13073        );
13074        assert_eq!(
13075            cn_report["provider_model"]["auth"]["env_vars"][0],
13076            "DEEPSEEK_API_KEY"
13077        );
13078        assert_eq!(
13079            cn_report["provider_model"]["auth"]["credential_url"],
13080            "https://platform.deepseek.com"
13081        );
13082        assert_eq!(cn_report["provider_model"]["auth"]["oauth_only"], false);
13083        assert_eq!(
13084            cn_report["provider_model"]["health"]["live_validation"],
13085            false
13086        );
13087
13088        let codex_config = Config {
13089            provider: Some("openai-codex".to_string()),
13090            ..Config::default()
13091        };
13092        crate::external_credentials::reset_side_effect_trap();
13093        let codex_report = doctor_setup_report_json(&codex_config, &workspace);
13094        assert_eq!(
13095            codex_report["provider_model"]["provider"]["id"],
13096            crate::config::ApiProvider::OpenaiCodex.as_str()
13097        );
13098        assert!(codex_report["provider_model"]["auth"]["credential_url"].is_null());
13099        assert_eq!(
13100            codex_report["provider_model"]["auth"]["credential_mode"],
13101            "oauth"
13102        );
13103        assert_eq!(codex_report["provider_model"]["auth"]["oauth_only"], true);
13104        assert_eq!(
13105            codex_report["provider_model"]["health"]["next_action"],
13106            "/setup provider or /provider setup <name>"
13107        );
13108        assert_eq!(
13109            crate::external_credentials::side_effect_trap_counts(),
13110            (0, 0),
13111            "doctor must not stat or read external credentials without consent"
13112        );
13113
13114        let mut consent = codewhale_config::ExternalCredentialConsentToml::read_only(
13115            codewhale_config::ProviderKind::OpenaiCodex,
13116            codewhale_config::ExternalCredentialSource::CodexCli,
13117            codex_auth_path.clone(),
13118        );
13119        let codex_read_only = Config {
13120            provider: Some("openai-codex".to_string()),
13121            providers: Some(crate::config::ProvidersConfig {
13122                openai_codex: crate::config::ProviderConfig {
13123                    auth_mode: Some("oauth".to_string()),
13124                    external_credentials: Some(consent.clone()),
13125                    ..Default::default()
13126                },
13127                ..Default::default()
13128            }),
13129            ..Config::default()
13130        };
13131        let changed_ambient_path = tmp.path().join("new-ambient-codex-auth.json");
13132        let _changed_codex_auth =
13133            crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &changed_ambient_path);
13134        crate::external_credentials::reset_side_effect_trap();
13135        let codex_read_only_report = doctor_setup_report_json(&codex_read_only, &workspace);
13136        assert_eq!(
13137            codex_read_only_report["provider_model"]["auth"]["present_or_local"],
13138            false
13139        );
13140        assert_eq!(
13141            codex_read_only_report["provider_model"]["auth"]["source"],
13142            "external_consent"
13143        );
13144        let status_json = doctor_external_credential_consent_json(&codex_read_only);
13145        let codex_status = status_json
13146            .as_array()
13147            .and_then(|rows| rows.first())
13148            .expect("Codex structural status");
13149        assert_eq!(codex_status["access"], "read_only");
13150        assert_eq!(codex_status["provider"], "openai-codex");
13151        assert_eq!(codex_status["source"], "codex_cli");
13152        assert_eq!(codex_status["route_state"], "active");
13153        assert_eq!(codex_status["ambient_path_changed"], true);
13154        assert!(
13155            codex_status["ambient_path_warning"]
13156                .as_str()
13157                .is_some_and(|warning| warning.contains("remains pinned"))
13158        );
13159        assert_eq!(
13160            codex_status["revoke_command"],
13161            "codewhale auth external-revoke --provider openai-codex"
13162        );
13163        let human = doctor_external_credential_consent_lines(&codex_read_only).join("\n");
13164        assert!(human.contains("path="), "{human}");
13165        assert!(human.contains("version=1"), "{human}");
13166        assert!(human.contains("no refresh, identity-provider or discovery requests"));
13167        assert!(human.contains("normal requests to the explicitly selected provider"));
13168        assert!(human.contains("consent remains pinned"), "{human}");
13169        assert!(
13170            human.contains(&codewhale_config::quote_os_path(&codex_auth_path)),
13171            "{human}"
13172        );
13173        assert!(!human.contains(&changed_ambient_path.display().to_string()));
13174        assert_eq!(
13175            crate::external_credentials::complete_side_effect_trap_counts(),
13176            (0, 0, 0, 0, 0),
13177            "doctor consent status is structural and must not inspect the file"
13178        );
13179        assert_eq!(
13180            fs::read_to_string(&codex_auth_path).expect("unchanged Codex auth fixture"),
13181            codex_auth_raw
13182        );
13183
13184        consent.access = codewhale_config::ExternalCredentialAccess::Managed;
13185        let codex_managed = Config {
13186            provider: Some("openai-codex".to_string()),
13187            providers: Some(crate::config::ProvidersConfig {
13188                openai_codex: crate::config::ProviderConfig {
13189                    auth_mode: Some("oauth".to_string()),
13190                    external_credentials: Some(consent),
13191                    ..Default::default()
13192                },
13193                ..Default::default()
13194            }),
13195            ..Config::default()
13196        };
13197        crate::external_credentials::reset_side_effect_trap();
13198        let codex_managed_report = doctor_setup_report_json(&codex_managed, &workspace);
13199        assert_eq!(
13200            codex_managed_report["provider_model"]["auth"]["present_or_local"],
13201            false
13202        );
13203        assert_eq!(
13204            crate::external_credentials::side_effect_trap_counts(),
13205            (0, 0),
13206            "unsupported managed mode must fail before external I/O"
13207        );
13208        assert_eq!(
13209            fs::read_to_string(&codex_auth_path).expect("unchanged managed auth fixture"),
13210            codex_auth_raw
13211        );
13212
13213        let local_config = Config {
13214            provider: Some("ollama".to_string()),
13215            ..Config::default()
13216        };
13217        let local_report = doctor_setup_report_json(&local_config, &workspace);
13218        assert_eq!(local_report["provider_model"]["provider"]["id"], "ollama");
13219        assert_eq!(
13220            local_report["provider_model"]["auth"]["present_or_local"],
13221            true
13222        );
13223        assert!(local_report["provider_model"]["auth"]["credential_url"].is_null());
13224        assert_eq!(
13225            local_report["provider_model"]["auth"]["credential_mode"],
13226            "local_optional"
13227        );
13228        assert_eq!(local_report["provider_model"]["auth"]["oauth_only"], false);
13229        assert_eq!(
13230            local_report["provider_model"]["health"]["next_action"],
13231            "/model"
13232        );
13233
13234        let kimi_config = Config {
13235            provider: Some("moonshot".to_string()),
13236            ..Config::default()
13237        };
13238        let kimi_report = doctor_setup_report_json(&kimi_config, &workspace);
13239        assert_eq!(
13240            kimi_report["provider_model"]["auth"]["credential_url"],
13241            "https://platform.kimi.ai"
13242        );
13243        assert_eq!(
13244            kimi_report["provider_model"]["auth"]["credential_docs_url"],
13245            "https://platform.kimi.ai"
13246        );
13247        assert_eq!(
13248            kimi_report["provider_model"]["auth"]["credential_mode"],
13249            "api_key"
13250        );
13251        assert!(
13252            kimi_report["provider_model"]["auth"]["credential_guidance"]
13253                .as_str()
13254                .is_some_and(|guidance| guidance.contains("OAuth is not available"))
13255        );
13256    }
13257
13258    #[test]
13259    fn doctor_setup_report_json_uses_persisted_state() {
13260        let _guard = crate::test_support::lock_test_env();
13261        let tmp = TempDir::new().expect("tempdir");
13262        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13263        let workspace = tmp.path().join("workspace");
13264        fs::create_dir_all(&workspace).expect("workspace");
13265        let mut state = codewhale_config::SetupState::default();
13266        state.set_step(
13267            codewhale_config::SetupStep::Language,
13268            codewhale_config::StepEntry::new(
13269                codewhale_config::StepStatus::Verified,
13270                true,
13271                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13272            ),
13273        );
13274        state.set_step(
13275            codewhale_config::SetupStep::ProviderModel,
13276            codewhale_config::StepEntry::new(
13277                codewhale_config::StepStatus::Verified,
13278                true,
13279                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13280            )
13281            .with_result("deepseek/deepseek-chat"),
13282        );
13283        state.set_step(
13284            codewhale_config::SetupStep::TrustSandbox,
13285            codewhale_config::StepEntry::new(
13286                codewhale_config::StepStatus::Verified,
13287                true,
13288                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13289            ),
13290        );
13291        state
13292            .complete_constitution_checkpoint(
13293                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13294                codewhale_config::ConstitutionChoice::Bundled,
13295            )
13296            .set_step(
13297                codewhale_config::SetupStep::Constitution,
13298                codewhale_config::StepEntry::new(
13299                    codewhale_config::StepStatus::Verified,
13300                    true,
13301                    crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13302                ),
13303            );
13304        state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13305        state.save().expect("persist setup state");
13306        codewhale_config::UserConstitution {
13307            autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
13308            ..Default::default()
13309        }
13310        .save()
13311        .expect("persist user constitution");
13312        let config = Config {
13313            api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13314            approval_policy: Some("never".to_string()),
13315            allow_shell: Some(false),
13316            sandbox_mode: Some("read-only".to_string()),
13317            network: Some(crate::config::NetworkPolicyToml {
13318                default: "deny".to_string(),
13319                ..Default::default()
13320            }),
13321            ..Config::default()
13322        };
13323
13324        let report = doctor_setup_report_json(&config, &workspace);
13325
13326        assert_eq!(report["source"], "persisted");
13327        assert_eq!(report["first_run_ready"], true);
13328        assert_eq!(report["update_ready"], true);
13329        assert_eq!(report["operate_ready"], false);
13330        assert_eq!(report["constitution"]["choice"], "bundled");
13331        assert_eq!(
13332            report["constitution"]["checkpoint_completed_for"],
13333            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
13334        );
13335        assert_eq!(report["constitution"]["autonomy_preference"], "balanced");
13336        assert_eq!(report["runtime_posture_source"], "confirmed");
13337        assert_eq!(report["runtime_posture"]["source"], "confirmed");
13338        assert_eq!(
13339            report["runtime_posture"]["approval_policy"]["value"],
13340            "never"
13341        );
13342        assert_eq!(
13343            report["runtime_posture"]["approval_policy"]["source"],
13344            "config"
13345        );
13346        assert_eq!(report["runtime_posture"]["allow_shell"]["value"], false);
13347        assert_eq!(report["runtime_posture"]["allow_shell"]["source"], "config");
13348        assert_eq!(
13349            report["runtime_posture"]["sandbox_mode"]["value"],
13350            "read-only"
13351        );
13352        assert_eq!(
13353            report["runtime_posture"]["sandbox_mode"]["source"],
13354            "config"
13355        );
13356        assert_eq!(
13357            report["runtime_posture"]["network_default"]["value"],
13358            "deny"
13359        );
13360        assert_eq!(
13361            report["runtime_posture"]["network_default"]["source"],
13362            "config"
13363        );
13364        assert_eq!(provider_step(&report)["result"], "deepseek/deepseek-chat");
13365
13366        let unprobed_config = Config {
13367            api_key: Some(crate::config::API_KEYRING_SENTINEL.to_string()),
13368            ..config.clone()
13369        };
13370        let unprobed_report = doctor_setup_report_json(&unprobed_config, &workspace);
13371        assert_eq!(unprobed_report["credential"]["ready"], false);
13372        assert_eq!(unprobed_report["credential"]["availability"], "not_probed");
13373        assert_eq!(unprobed_report["first_run_ready"], true);
13374        assert_eq!(unprobed_report["update_ready"], true);
13375    }
13376
13377    #[test]
13378    fn doctor_reports_settings_permission_posture_when_approval_policy_unset() {
13379        let _guard = crate::test_support::lock_test_env();
13380        let tmp = TempDir::new().expect("tempdir");
13381        let (_home_guard, codewhale_home) = prepare_env(&tmp);
13382        let workspace = tmp.path().join("workspace");
13383        fs::create_dir_all(&workspace).expect("workspace");
13384        fs::write(
13385            codewhale_home.join("settings.toml"),
13386            "permission_posture = \"full-access\"\n",
13387        )
13388        .expect("write settings.toml");
13389
13390        let config = Config::default();
13391        assert!(config.approval_policy.is_none());
13392
13393        let line = doctor_runtime_posture_line(&config, &workspace);
13394        assert!(
13395            line.contains("permission_posture=full-access (settings)"),
13396            "text doctor should report saved settings posture: {line}"
13397        );
13398        assert!(
13399            line.contains("approval_policy=on-request (default)"),
13400            "text doctor should keep unset config approval_policy default: {line}"
13401        );
13402
13403        let report = doctor_setup_report_json(&config, &workspace);
13404        assert_eq!(
13405            report["runtime_posture"]["permission_posture"]["value"],
13406            "full-access"
13407        );
13408        assert_eq!(
13409            report["runtime_posture"]["permission_posture"]["source"],
13410            "settings"
13411        );
13412        assert_eq!(
13413            report["runtime_posture"]["approval_policy"]["value"],
13414            "on-request"
13415        );
13416        assert_eq!(
13417            report["runtime_posture"]["approval_policy"]["source"],
13418            "default"
13419        );
13420    }
13421
13422    /// #5441: telemetry ships ON by default, and the runtime-posture doctor
13423    /// section must say so — with the source that decided it — instead of
13424    /// staying silent about the one default users never opted into.
13425    #[test]
13426    fn doctor_reports_resolved_telemetry_with_its_source() {
13427        let _guard = crate::test_support::lock_test_env();
13428        let tmp = TempDir::new().expect("tempdir");
13429        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13430        let _telemetry_env = crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY");
13431        let _telemetry_alias_env = crate::test_support::EnvVarGuard::remove("DEEPSEEK_TELEMETRY");
13432        let _telemetry_floor =
13433            crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY_FLOOR");
13434        let workspace = tmp.path().join("workspace");
13435        fs::create_dir_all(&workspace).expect("workspace");
13436
13437        // Nothing configured anywhere: the shipped default applies and is
13438        // named, in both the text line and the JSON posture section.
13439        let config = Config::default();
13440        assert!(config.telemetry.is_none());
13441        let line = doctor_runtime_posture_line(&config, &workspace);
13442        assert!(
13443            line.contains("telemetry=on (default)"),
13444            "doctor line should name the defaulted consent: {line}"
13445        );
13446        let report = doctor_setup_report_json(&config, &workspace);
13447        assert_eq!(report["runtime_posture"]["telemetry"]["value"], true);
13448        assert_eq!(report["runtime_posture"]["telemetry"]["source"], "default");
13449
13450        // A persisted opt-out is reported as the config file's decision.
13451        let config = Config {
13452            telemetry: Some(false),
13453            ..Config::default()
13454        };
13455        let line = doctor_runtime_posture_line(&config, &workspace);
13456        assert!(
13457            line.contains("telemetry=off (config)"),
13458            "doctor line should name the persisted opt-out: {line}"
13459        );
13460        let report = doctor_setup_report_json(&config, &workspace);
13461        assert_eq!(report["runtime_posture"]["telemetry"]["value"], false);
13462        assert_eq!(report["runtime_posture"]["telemetry"]["source"], "config");
13463    }
13464
13465    #[test]
13466    fn doctor_setup_report_json_fails_closed_without_operate_receipts() {
13467        let _guard = crate::test_support::lock_test_env();
13468        let tmp = TempDir::new().expect("tempdir");
13469        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13470        let workspace = tmp.path().join("workspace");
13471        fs::create_dir_all(&workspace).expect("workspace");
13472        let mut state = codewhale_config::SetupState::default();
13473        state.set_step(
13474            codewhale_config::SetupStep::Language,
13475            codewhale_config::StepEntry::new(
13476                codewhale_config::StepStatus::Verified,
13477                true,
13478                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13479            ),
13480        );
13481        state.set_step(
13482            codewhale_config::SetupStep::ProviderModel,
13483            codewhale_config::StepEntry::new(
13484                codewhale_config::StepStatus::Verified,
13485                true,
13486                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13487            ),
13488        );
13489        state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13490        state.complete_constitution_checkpoint(
13491            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13492            codewhale_config::ConstitutionChoice::Bundled,
13493        );
13494        state.set_step(
13495            codewhale_config::SetupStep::OperateFleet,
13496            codewhale_config::StepEntry::new(
13497                codewhale_config::StepStatus::Verified,
13498                false,
13499                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13500            )
13501            .with_result(
13502                "provider=ready, runtime=ready, roster=ready, concurrency=plan limit not probed",
13503            ),
13504        );
13505        state.save().expect("persist setup state");
13506
13507        let config = Config {
13508            api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13509            ..Config::default()
13510        };
13511        let report = doctor_setup_report_json(&config, &workspace);
13512
13513        assert_eq!(report["first_run_ready"], true);
13514        assert_eq!(report["operate_ready"], false);
13515        assert_eq!(
13516            report["operate_fleet"]["concurrency"]["plan_limit_probed"],
13517            false
13518        );
13519        assert!(
13520            report["operate_fleet"]["roster"]["built_in"]
13521                .as_u64()
13522                .is_some_and(|count| count > 0)
13523        );
13524        let operate_step = report["steps"]
13525            .as_array()
13526            .expect("steps array")
13527            .iter()
13528            .find(|step| step["step"] == "operate_fleet")
13529            .expect("operate/fleet step");
13530        assert_eq!(operate_step["status"], "verified");
13531        assert!(
13532            operate_step["result"]
13533                .as_str()
13534                .is_some_and(|result| result.contains("plan limit not probed"))
13535        );
13536    }
13537}
13538
13539#[cfg(test)]
13540mod doctor_endpoint_tests {
13541    use super::*;
13542
13543    #[test]
13544    fn doctor_api_target_reports_default_endpoint() {
13545        let config = Config::default();
13546
13547        let target = doctor_api_target(&config);
13548
13549        assert_eq!(target.provider, "deepseek");
13550        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13551        assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13552        assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13553    }
13554
13555    #[test]
13556    fn doctor_api_target_falls_back_to_configured_model_when_resolution_fails() {
13557        // `custom` with no custom provider table cannot resolve an identity;
13558        // doctor must fall back to the raw configured model and say so
13559        // instead of presenting an unresolved value as the engine's route.
13560        let config = Config {
13561            provider: Some("custom".to_string()),
13562            ..Default::default()
13563        };
13564
13565        let target = doctor_api_target(&config);
13566
13567        assert_eq!(target.resolution, DoctorModelResolution::ConfiguredOnly);
13568        assert_eq!(target.model, config.default_model());
13569    }
13570
13571    #[test]
13572    fn doctor_api_target_routes_deepseek_cn_alias_to_beta_endpoint() {
13573        let config = Config {
13574            provider: Some("deepseek-cn".to_string()),
13575            ..Default::default()
13576        };
13577
13578        let target = doctor_api_target(&config);
13579
13580        assert_eq!(target.provider, "deepseek-cn");
13581        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
13582        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13583        assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13584        assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13585    }
13586
13587    #[test]
13588    fn strict_tool_mode_doctor_reports_disabled_by_default() {
13589        let config = Config::default();
13590
13591        let status = doctor_strict_tool_mode_status(&config);
13592
13593        assert!(!status.enabled);
13594        assert_eq!(status.status, "disabled");
13595        assert!(!status.function_strict_sent);
13596        assert!(status.recommended_base_url.is_none());
13597    }
13598
13599    #[test]
13600    fn doctor_known_base_urls_are_ascii_case_insensitive() {
13601        assert!(doctor_xiaomi_mimo_base_url_uses_token_plan(
13602            "HTTPS://TOKEN-PLAN-CN.XIAOMIMIMO.COM/V1/"
13603        ));
13604        assert_eq!(
13605            known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/BETA/"),
13606            Some(DeepSeekBaseUrlKind::Beta)
13607        );
13608        assert_eq!(
13609            known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/V1/"),
13610            Some(DeepSeekBaseUrlKind::NonBeta)
13611        );
13612    }
13613
13614    #[test]
13615    fn strict_tool_mode_doctor_accepts_default_beta_endpoint() {
13616        let config = Config {
13617            strict_tool_mode: Some(true),
13618            ..Default::default()
13619        };
13620
13621        let status = doctor_strict_tool_mode_status(&config);
13622
13623        assert!(status.enabled);
13624        assert_eq!(status.status, "ready");
13625        assert!(status.function_strict_sent);
13626        assert!(status.message.contains("beta endpoint"));
13627        assert!(status.recommended_base_url.is_none());
13628    }
13629
13630    #[test]
13631    fn strict_tool_mode_doctor_warns_for_non_beta_deepseek_endpoint() {
13632        let config = Config {
13633            strict_tool_mode: Some(true),
13634            base_url: Some("https://api.deepseek.com".to_string()),
13635            ..Default::default()
13636        };
13637
13638        let status = doctor_strict_tool_mode_status(&config);
13639
13640        assert_eq!(status.status, "fallback_non_beta");
13641        assert!(!status.function_strict_sent);
13642        assert_eq!(
13643            status.recommended_base_url.as_deref(),
13644            Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL)
13645        );
13646        assert_eq!(
13647            doctor_strict_tool_mode_report_json(&status)["recommended_base_url"],
13648            "https://api.deepseek.com"
13649        );
13650    }
13651
13652    #[test]
13653    fn strict_tool_mode_doctor_accepts_deepseek_cn_alias_default_endpoint() {
13654        let config = Config {
13655            provider: Some("deepseek-cn".to_string()),
13656            strict_tool_mode: Some(true),
13657            ..Default::default()
13658        };
13659
13660        let status = doctor_strict_tool_mode_status(&config);
13661
13662        assert_eq!(status.status, "ready");
13663        assert!(status.function_strict_sent);
13664        assert!(status.message.contains("beta endpoint"));
13665        assert!(status.recommended_base_url.is_none());
13666    }
13667
13668    #[test]
13669    fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() {
13670        let config = Config {
13671            provider: Some("vllm".to_string()),
13672            strict_tool_mode: Some(true),
13673            ..Default::default()
13674        };
13675
13676        let status = doctor_strict_tool_mode_status(&config);
13677
13678        assert_eq!(status.status, "custom_endpoint");
13679        assert!(status.function_strict_sent);
13680        assert!(status.message.contains("custom endpoint"));
13681    }
13682
13683    #[test]
13684    fn doctor_tls_status_reports_verification_enabled_by_default() {
13685        let status = doctor_tls_status(&Config::default());
13686
13687        assert!(status.certificate_verification);
13688        assert!(!status.insecure_skip_tls_verify);
13689        assert_eq!(status.provider, "deepseek");
13690        assert!(status.message.contains("enabled"));
13691    }
13692
13693    #[test]
13694    fn doctor_tls_status_warns_when_active_provider_skips_verification() {
13695        let mut providers = crate::config::ProvidersConfig::default();
13696        providers.openai.insecure_skip_tls_verify = Some(true);
13697        let config = Config {
13698            provider: Some("openai".to_string()),
13699            providers: Some(providers),
13700            ..Default::default()
13701        };
13702
13703        let status = doctor_tls_status(&config);
13704
13705        assert!(status.certificate_verification);
13706        assert!(status.insecure_skip_tls_verify);
13707        assert_eq!(status.provider, "openai");
13708        assert!(status.message.contains("cannot be disabled"));
13709        assert!(status.message.contains("SSL_CERT_FILE"));
13710    }
13711
13712    #[test]
13713    fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
13714        let mut config = Config {
13715            default_text_model: Some("deepseek-chat".to_string()),
13716            ..Default::default()
13717        };
13718        crate::config::normalize_model_config_for_test(&mut config);
13719
13720        let report = provider_capability_report(&config);
13721
13722        assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13723        assert_eq!(report["context_window"], 1_000_000);
13724        assert_eq!(report["thinking_supported"], true);
13725        assert_eq!(report["alias_deprecation"]["alias"], "deepseek-chat");
13726        assert_eq!(
13727            report["alias_deprecation"]["replacement"],
13728            "deepseek-v4-flash"
13729        );
13730        assert_eq!(
13731            report["alias_deprecation"]["retirement_utc"],
13732            "2026-07-24T15:59:00Z"
13733        );
13734    }
13735
13736    #[test]
13737    fn provider_capability_report_preserves_custom_deepseek_alias_namespace() {
13738        let mut config = Config {
13739            base_url: Some("https://models.example/v1".to_string()),
13740            default_text_model: Some("deepseek-chat".to_string()),
13741            ..Default::default()
13742        };
13743        crate::config::normalize_model_config_for_test(&mut config);
13744
13745        let report = provider_capability_report(&config);
13746
13747        assert_eq!(report["resolved_model"], "deepseek-chat");
13748        assert!(report["alias_deprecation"].is_null());
13749    }
13750
13751    #[test]
13752    fn provider_capability_report_leaves_canonical_flash_alias_metadata_null() {
13753        let config = Config {
13754            default_text_model: Some("deepseek-v4-flash".to_string()),
13755            ..Default::default()
13756        };
13757
13758        let report = provider_capability_report(&config);
13759
13760        assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13761        assert!(report["alias_deprecation"].is_null());
13762    }
13763
13764    #[test]
13765    fn doctor_route_report_exposes_tokenhub_openai_compatible_route_without_secret() {
13766        let mut providers = crate::config::ProvidersConfig::default();
13767        providers.openai.api_key = Some("tokenhub-secret-value".to_string());
13768        providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
13769        providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
13770        let config = Config {
13771            provider: Some("openai".to_string()),
13772            providers: Some(providers),
13773            ..Default::default()
13774        };
13775
13776        let report = doctor_route_report(&config);
13777        let serialized = report.to_string();
13778
13779        assert_eq!(report["provider"], "openai");
13780        assert_eq!(report["provider_source"], "config");
13781        assert_eq!(report["provider_config_table"], "openai");
13782        assert_eq!(report["model"], "deepseek-ai/DeepSeek-V4-Pro");
13783        assert_eq!(report["wire_protocol"], "chat_completions");
13784        assert_eq!(
13785            report["base_url"]["redacted"],
13786            "https://tokenhub.tencentmaas.com"
13787        );
13788        assert_eq!(report["base_url"]["class"], "custom");
13789        assert_eq!(report["auth"]["scheme"], "bearer");
13790        assert_eq!(report["auth"]["source"], "config_declared");
13791        assert!(
13792            report["base_url"]["fingerprint"]
13793                .as_str()
13794                .is_some_and(|value| value.starts_with("<redacted:"))
13795        );
13796        assert!(!serialized.contains("tokenhub-secret-value"));
13797    }
13798
13799    #[test]
13800    fn doctor_route_report_exposes_siliconflow_cn_provider_route() {
13801        let mut providers = crate::config::ProvidersConfig::default();
13802        providers.siliconflow_cn.api_key = Some("sf-cn-secret-value".to_string());
13803        providers.siliconflow_cn.base_url =
13804            Some(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
13805        providers.siliconflow_cn.model = Some(crate::config::DEFAULT_SILICONFLOW_MODEL.to_string());
13806        let config = Config {
13807            provider: Some("siliconflow-CN".to_string()),
13808            providers: Some(providers),
13809            ..Default::default()
13810        };
13811
13812        let report = doctor_route_report(&config);
13813        let serialized = report.to_string();
13814
13815        assert_eq!(report["provider"], "siliconflow-CN");
13816        assert_eq!(report["provider_config_table"], "siliconflow_cn");
13817        assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
13818        assert_eq!(
13819            report["base_url"]["redacted"],
13820            crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
13821        );
13822        assert_eq!(report["base_url"]["class"], "default");
13823        assert_eq!(report["auth"]["scheme"], "bearer");
13824        assert_eq!(report["auth"]["source"], "config_declared");
13825        assert!(!serialized.contains("sf-cn-secret-value"));
13826    }
13827
13828    #[test]
13829    fn doctor_route_report_names_kimi_code_context_provenance() {
13830        let config = Config {
13831            provider: Some("moonshot".to_string()),
13832            providers: Some(crate::config::ProvidersConfig {
13833                moonshot: crate::config::ProviderConfig {
13834                    api_key: Some("kimi-plan-secret".to_string()),
13835                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13836                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13837                    ..Default::default()
13838                },
13839                ..Default::default()
13840            }),
13841            ..Default::default()
13842        };
13843
13844        let report = doctor_route_report(&config);
13845        let serialized = report.to_string();
13846
13847        assert_eq!(report["context_window"]["tokens"], 262_144);
13848        assert_eq!(
13849            report["context_window"]["source"],
13850            "static Kimi Code safe floor"
13851        );
13852        assert!(!serialized.contains("kimi-plan-secret"));
13853    }
13854
13855    #[test]
13856    fn provider_capability_report_uses_exact_kimi_code_route_facts() {
13857        let config = Config {
13858            provider: Some("moonshot".to_string()),
13859            providers: Some(crate::config::ProvidersConfig {
13860                moonshot: crate::config::ProviderConfig {
13861                    api_key: Some("kimi-plan-secret".to_string()),
13862                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13863                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13864                    ..Default::default()
13865                },
13866                ..Default::default()
13867            }),
13868            ..Default::default()
13869        };
13870
13871        let report = provider_capability_report(&config);
13872
13873        assert_eq!(report["resolved_model"], crate::config::KIMI_CODE_K3_MODEL);
13874        assert_eq!(report["context_window"], 262_144);
13875        assert_eq!(
13876            report["context_window_source"],
13877            "static Kimi Code safe floor"
13878        );
13879        assert_eq!(report["thinking_supported"], true);
13880    }
13881
13882    #[test]
13883    fn provider_capability_report_honors_kimi_code_context_override() {
13884        let config = Config {
13885            provider: Some("moonshot".to_string()),
13886            providers: Some(crate::config::ProvidersConfig {
13887                moonshot: crate::config::ProviderConfig {
13888                    api_key: Some("kimi-plan-secret".to_string()),
13889                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13890                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13891                    context_window: Some(1_048_576),
13892                    ..Default::default()
13893                },
13894                ..Default::default()
13895            }),
13896            ..Default::default()
13897        };
13898
13899        let report = provider_capability_report(&config);
13900
13901        assert_eq!(
13902            report["resolved_model"],
13903            crate::config::KIMI_CODE_K3_MODEL,
13904            "the configured window must preserve Kimi Code's bare wire id"
13905        );
13906        assert_eq!(report["context_window"], 1_048_576);
13907        assert_eq!(report["context_window_source"], "configured");
13908        assert_eq!(report["thinking_supported"], true);
13909    }
13910
13911    #[test]
13912    fn provider_capability_report_uses_direct_moonshot_k3_route_facts() {
13913        let config = Config {
13914            provider: Some("moonshot".to_string()),
13915            providers: Some(crate::config::ProvidersConfig {
13916                moonshot: crate::config::ProviderConfig {
13917                    api_key: Some("moonshot-secret".to_string()),
13918                    base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
13919                    model: Some("kimi-k3".to_string()),
13920                    ..Default::default()
13921                },
13922                ..Default::default()
13923            }),
13924            ..Default::default()
13925        };
13926
13927        let report = provider_capability_report(&config);
13928
13929        assert_eq!(report["resolved_model"], "kimi-k3");
13930        assert_eq!(report["context_window"], 1_048_576);
13931        assert_eq!(report["context_window_source"], "catalog");
13932        assert_eq!(report["max_output"], 1_048_576);
13933        assert_eq!(report["thinking_supported"], true);
13934    }
13935
13936    #[test]
13937    fn doctor_search_provider_line_includes_firecrawl_default_source_and_switch_hint() {
13938        let _guard = crate::test_support::lock_test_env();
13939        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13940        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13941
13942        let line = doctor_search_provider_line(&Config::default());
13943
13944        match prev {
13945            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13946            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13947        }
13948        assert!(line.contains("search_provider: firecrawl"));
13949        assert!(line.contains("source: default"));
13950        assert!(line.contains("[search] provider"));
13951        assert!(line.contains("provider = \"baidu\""));
13952    }
13953
13954    #[test]
13955    fn doctor_search_provider_json_reports_config_source() {
13956        let _guard = crate::test_support::lock_test_env();
13957        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13958        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13959        let config = Config {
13960            search: Some(crate::config::SearchConfig {
13961                provider: Some(crate::config::SearchProvider::DuckDuckGo),
13962                base_url: None,
13963                api_key: None,
13964            }),
13965            ..Default::default()
13966        };
13967
13968        let report = doctor_search_provider_json(&config);
13969
13970        match prev {
13971            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13972            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13973        }
13974        assert_eq!(report["provider"], "duckduckgo");
13975        assert_eq!(report["source"], "config");
13976        assert_eq!(report["reachability"], "not_checked");
13977        assert_eq!(report["reachability_reason"], "offline_json");
13978    }
13979
13980    #[test]
13981    fn doctor_search_provider_json_reports_env_override_source() {
13982        let _guard = crate::test_support::lock_test_env();
13983        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13984        unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", "tavily") };
13985
13986        let report = doctor_search_provider_json(&Config::default());
13987
13988        match prev {
13989            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13990            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13991        }
13992        assert_eq!(report["provider"], "tavily");
13993        assert_eq!(report["source"], "env override");
13994        assert_eq!(report["reachability"], "not_checked");
13995    }
13996
13997    #[test]
13998    fn doctor_search_provider_line_omits_switch_hint_when_bing_is_configured() {
13999        let _guard = crate::test_support::lock_test_env();
14000        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
14001        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
14002        let config = Config {
14003            search: Some(crate::config::SearchConfig {
14004                provider: Some(crate::config::SearchProvider::Bing),
14005                base_url: None,
14006                api_key: None,
14007            }),
14008            ..Default::default()
14009        };
14010
14011        let line = doctor_search_provider_line(&config);
14012
14013        match prev {
14014            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
14015            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
14016        }
14017        assert!(line.contains("search_provider: bing"));
14018        assert!(line.contains("source: config"));
14019        assert!(!line.contains("[search] provider"));
14020    }
14021
14022    #[test]
14023    fn timeout_recovery_keeps_default_deepseek_users_on_default_endpoint() {
14024        let config = Config::default();
14025
14026        let text = doctor_timeout_recovery_lines(&config).join("\n");
14027
14028        assert!(text.contains("api.deepseek.com"));
14029        assert!(text.contains("custom DeepSeek-compatible endpoint"));
14030        assert!(!text.contains("provider = \"deepseek-cn\""));
14031        assert!(text.contains("codewhale doctor --json"));
14032    }
14033
14034    #[test]
14035    fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
14036        let config = Config {
14037            provider: Some("vllm".to_string()),
14038            ..Default::default()
14039        };
14040
14041        let text = doctor_timeout_recovery_lines(&config).join("\n");
14042
14043        assert!(text.contains("/v1/models"));
14044        assert!(text.contains("/v1/chat/completions"));
14045        assert!(!text.contains("api.deepseeki.com"));
14046    }
14047}
14048
14049#[cfg(test)]
14050mod terminal_mode_tests {
14051    use super::*;
14052    use clap::Parser;
14053
14054    fn parse_cli(args: &[&str]) -> Cli {
14055        Cli::try_parse_from(args).expect("CLI args should parse")
14056    }
14057
14058    #[test]
14059    fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
14060        let config = Config {
14061            network: Some(crate::config::NetworkPolicyToml {
14062                default: "allow".to_string(),
14063                audit: false,
14064                ..crate::config::NetworkPolicyToml::default()
14065            }),
14066            ..Config::default()
14067        };
14068        let authority = crate::tools::spec::ToolAuthorityEnvelope {
14069            schema_version: 1,
14070            owner: "consultant-1".to_string(),
14071            authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
14072            network_access: Some(false),
14073            shell: crate::tools::spec::ToolShellAuthority::None,
14074            verification: crate::tools::spec::ToolVerificationAuthority::None,
14075            writable_roots: Vec::new(),
14076            writable_files: Vec::new(),
14077            coordination_contracts: Vec::new(),
14078        }
14079        .normalized()
14080        .expect("Consultant authority");
14081
14082        let policy = exec_network_policy(&config, authority.network_access)
14083            .expect("explicit network=false always installs a policy");
14084        assert_eq!(
14085            policy.evaluate("example.com", "web_search"),
14086            crate::network_policy::Decision::Deny,
14087            "the permissive user config must not widen Consultant network authority"
14088        );
14089        let mut features = crate::features::Features::default();
14090        features.enable(crate::features::Feature::ShellTool);
14091        features.enable(crate::features::Feature::WebSearch);
14092        apply_fleet_engine_feature_caps(
14093            &mut features,
14094            true,
14095            authority.network_access,
14096            authority.shell,
14097        );
14098        assert!(!features.enabled(crate::features::Feature::WebSearch));
14099        assert!(!features.enabled(crate::features::Feature::ShellTool));
14100
14101        let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
14102        assert_eq!(
14103            worker_policy.evaluate("example.com", "web_search"),
14104            crate::network_policy::Decision::Allow,
14105            "a network-capable role keeps the configured policy"
14106        );
14107    }
14108    #[test]
14109    fn hidden_remote_control_flag_starts_the_interactive_handoff() {
14110        let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
14111        assert!(cli.remote_control);
14112    }
14113
14114    #[test]
14115    fn plugin_registry_discovery_is_route_independent_and_read_only() {
14116        let _env_lock = crate::test_support::lock_test_env();
14117        let temp = tempfile::tempdir().unwrap();
14118        let workspace = temp.path().join("workspace");
14119        let codewhale_home = temp.path().join("home");
14120        std::fs::create_dir_all(&workspace).unwrap();
14121        let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
14122        let workspace_arg = workspace.to_string_lossy().into_owned();
14123
14124        for route in [
14125            Vec::<&str>::new(),
14126            vec!["resume", "--last"],
14127            vec!["fork", "--last"],
14128            vec!["exec", "hello"],
14129            vec!["serve", "--mcp"],
14130        ] {
14131            let mut args = vec![
14132                "codewhale-tui".to_string(),
14133                "--workspace".to_string(),
14134                workspace_arg.clone(),
14135            ];
14136            args.extend(route.into_iter().map(str::to_string));
14137            let cli = Cli::try_parse_from(args).expect("route should parse");
14138            let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
14139            let registry = discovery
14140                .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
14141            assert_eq!(registry.workspace(), workspace.as_path());
14142            assert!(
14143                !codewhale_home.join("plugins/state.json").exists(),
14144                "startup discovery must remain read-only"
14145            );
14146        }
14147    }
14148
14149    fn custom_exec_config(active: &str) -> Config {
14150        let mut custom = std::collections::HashMap::new();
14151        for (name, base_url, model) in [
14152            (
14153                "custom-a",
14154                "http://127.0.0.1:18181/v1",
14155                crate::config::ZAI_GLM_5_2_MODEL,
14156            ),
14157            ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
14158        ] {
14159            custom.insert(
14160                name.to_string(),
14161                crate::config::ProviderConfig {
14162                    kind: Some("openai-compatible".to_string()),
14163                    base_url: Some(base_url.to_string()),
14164                    model: Some(model.to_string()),
14165                    api_key: Some("local-test-key".to_string()),
14166                    ..Default::default()
14167                },
14168            );
14169        }
14170        Config {
14171            provider: Some(active.to_string()),
14172            providers: Some(crate::config::ProvidersConfig {
14173                custom,
14174                ..Default::default()
14175            }),
14176            ..Default::default()
14177        }
14178    }
14179
14180    #[test]
14181    fn doctor_json_surfaces_keep_exact_named_custom_provider() {
14182        let config = custom_exec_config("custom-a");
14183        let workspace = tempfile::tempdir().expect("doctor workspace");
14184
14185        let operate = doctor_operate_fleet_report_json(&config, workspace.path());
14186        let provider_model = doctor_provider_model_report_json(&config);
14187        let capability = provider_capability_report(&config);
14188        let route = doctor_route_report(&config);
14189
14190        assert_eq!(operate["provider"]["id"], "custom-a");
14191        assert_eq!(provider_model["provider"]["id"], "custom-a");
14192        assert_eq!(capability["resolved_provider"], "custom-a");
14193        assert_eq!(route["provider"], "custom-a");
14194        assert_eq!(route["provider_config_table"], "providers.custom-a");
14195        let serialized = serde_json::to_string(&serde_json::json!({
14196            "operate": operate,
14197            "provider_model": provider_model,
14198            "capability": capability,
14199            "route": route,
14200        }))
14201        .expect("doctor JSON");
14202        assert!(!serialized.contains("local-test-key"));
14203    }
14204
14205    #[test]
14206    fn doctor_operate_fleet_json_lists_multi_layer_profile_paths() {
14207        // #5098: doctor must name the winning layer and every losing path
14208        // when project and personal both define the same id.
14209        let _env_lock = crate::test_support::lock_test_env();
14210        let tmp = tempfile::TempDir::new().expect("tempdir");
14211        let home = tmp.path().join("home");
14212        let workspace = tmp.path().join("workspace");
14213        let personal = home.join("agents");
14214        let project = workspace.join(".codewhale").join("agents");
14215        std::fs::create_dir_all(&personal).expect("personal agents");
14216        std::fs::create_dir_all(&project).expect("project agents");
14217        std::fs::write(
14218            personal.join("builder.toml"),
14219            "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-flash\"\n",
14220        )
14221        .expect("personal builder");
14222        std::fs::write(
14223            project.join("builder.toml"),
14224            "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-pro\"\n",
14225        )
14226        .expect("project builder");
14227        let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
14228
14229        let operate = doctor_operate_fleet_report_json(&Config::default(), &workspace);
14230        let layers = operate["roster"]["multi_layer"]
14231            .as_array()
14232            .expect("multi_layer array");
14233        let builder = layers
14234            .iter()
14235            .find(|entry| entry["id"] == "builder")
14236            .expect("builder multi-layer entry");
14237        assert_eq!(builder["effective"], "project");
14238        let paths: Vec<&str> = builder["layers"]
14239            .as_array()
14240            .expect("layers")
14241            .iter()
14242            .filter_map(|layer| layer["path"].as_str())
14243            .collect();
14244        assert!(
14245            paths.iter().any(|path| path.ends_with("builder.toml")),
14246            "layer paths include the profile files: {builder}"
14247        );
14248        assert!(
14249            builder["layers"]
14250                .as_array()
14251                .expect("layers")
14252                .iter()
14253                .any(|layer| layer["origin"] == "personal" && layer["wins"] == false),
14254            "personal layer is listed as ignored: {builder}"
14255        );
14256        assert!(
14257            builder["layers"]
14258                .as_array()
14259                .expect("layers")
14260                .iter()
14261                .any(|layer| layer["origin"] == "project" && layer["wins"] == true),
14262            "project layer wins: {builder}"
14263        );
14264    }
14265
14266    fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
14267        let mut saved = session_manager::create_saved_session_with_mode(
14268            &[],
14269            model,
14270            Path::new("/tmp/exec-resume"),
14271            0,
14272            None,
14273            Some("exec"),
14274        );
14275        let kind = crate::config::ApiProvider::parse(provider)
14276            .unwrap_or(crate::config::ApiProvider::Custom)
14277            .as_str();
14278        let exact_id = (!provider
14279            .eq_ignore_ascii_case(crate::config::ApiProvider::Custom.as_str()))
14280        .then_some(provider);
14281        saved.metadata.set_model_provider_route(kind, exact_id);
14282        saved
14283    }
14284
14285    #[test]
14286    fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
14287        let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
14288
14289        assert_eq!(cli.prompt, vec!["hello", "world"]);
14290    }
14291
14292    #[test]
14293    fn prompt_flag_starts_interactive_submit_input() {
14294        let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
14295
14296        assert_eq!(
14297            top_level_prompt_initial_input(&cli.prompt),
14298            Some(tui::InitialInput::Submit("read the project".to_string()))
14299        );
14300    }
14301
14302    #[test]
14303    fn companion_binary_reports_its_own_name() {
14304        assert_eq!(Cli::command().get_name(), "codewhale-tui");
14305    }
14306
14307    #[test]
14308    fn xai_device_auth_subcommand_parses() {
14309        let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
14310        assert!(matches!(
14311            cli.command,
14312            Some(Commands::Auth(TuiAuthArgs {
14313                command: TuiAuthCommand::XaiDevice
14314            }))
14315        ));
14316    }
14317
14318    #[test]
14319    fn workflow_tool_internal_subcommand_parses_exact_json() {
14320        let cli = parse_cli(&[
14321            "codewhale-tui",
14322            "workflow-tool",
14323            "--approval-source",
14324            "explicit-workflow-command",
14325            "--input-json",
14326            r#"{"action":"run","source_path":"workflows/demo.js"}"#,
14327        ]);
14328        let Some(Commands::WorkflowTool(args)) = cli.command else {
14329            panic!("expected workflow-tool command");
14330        };
14331        assert!(args.input_json.contains("\"action\":\"run\""));
14332    }
14333
14334    #[tokio::test]
14335    async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
14336        use crate::tools::spec::ToolSpec;
14337
14338        let workspace = tempfile::tempdir().expect("workspace");
14339        let config = Config {
14340            provider: Some("vllm".to_string()),
14341            mcp_config_path: Some(
14342                workspace
14343                    .path()
14344                    .join("missing-mcp.json")
14345                    .display()
14346                    .to_string(),
14347            ),
14348            providers: Some(crate::config::ProvidersConfig {
14349                vllm: crate::config::ProviderConfig {
14350                    base_url: Some("http://127.0.0.1:9/v1".to_string()),
14351                    model: Some("offline-test-model".to_string()),
14352                    ..Default::default()
14353                },
14354                ..Default::default()
14355            }),
14356            ..Default::default()
14357        };
14358        let route = CliAutoRoute {
14359            provider: crate::config::ApiProvider::Vllm,
14360            model: "offline-test-model".to_string(),
14361            reasoning_effort: None,
14362            auto_controls_reasoning: false,
14363            auto_model: false,
14364        };
14365        let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
14366        let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14367        let (tool, context) =
14368            build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
14369                .await
14370                .expect("build direct workflow runtime");
14371
14372        let result = tool
14373            .execute(
14374                serde_json::json!({
14375                    "action": "run",
14376                    "script": "phase('offline'); return { ok: true };",
14377                    "token_budget": 1_000_000
14378                }),
14379                &context,
14380            )
14381            .await
14382            .expect("model-free workflow run");
14383        let payload: serde_json::Value =
14384            serde_json::from_str(&result.content).expect("workflow JSON");
14385
14386        assert_eq!(payload["status"], "completed");
14387        assert_eq!(payload["result"]["ok"], true);
14388        assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
14389        assert_eq!(
14390            payload["plan_approval"]["decision"],
14391            "approved_explicit_cli_command"
14392        );
14393        assert!(!context.auto_approve);
14394        assert!(!context.trust_mode);
14395        assert_eq!(
14396            context.shell_policy,
14397            crate::worker_profile::ShellPolicy::None
14398        );
14399        assert!(matches!(
14400            context.elevated_sandbox_policy,
14401            Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
14402        ));
14403        let mut event_types = Vec::new();
14404        while let Ok(event) = event_rx.try_recv() {
14405            if let crate::core::events::Event::WorkflowUi { event, .. } = event
14406                && let Some(kind) = event["type"].as_str()
14407            {
14408                event_types.push(kind.to_string());
14409            }
14410        }
14411        assert!(event_types.iter().any(|kind| kind == "run_started"));
14412        assert!(event_types.iter().any(|kind| kind == "run_completed"));
14413    }
14414
14415    #[tokio::test]
14416    async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
14417        let workspace = tempfile::tempdir().expect("workspace");
14418        let mcp_path = workspace.path().join("mcp.json");
14419        std::fs::write(
14420            &mcp_path,
14421            r#"{
14422                "mcpServers": {
14423                    "blocked": { "url": "https://blocked.invalid/mcp" }
14424                }
14425            }"#,
14426        )
14427        .expect("write MCP config");
14428        let config = Config {
14429            mcp_config_path: Some(mcp_path.display().to_string()),
14430            ..Default::default()
14431        };
14432        let policy = crate::network_policy::NetworkPolicyDecider::new(
14433            crate::network_policy::NetworkPolicy {
14434                default: crate::network_policy::DecisionToml::Deny,
14435                allow: Vec::new(),
14436                deny: Vec::new(),
14437                proxy: Vec::new(),
14438                proxy_fake_ip_cidrs: Vec::new(),
14439                audit: false,
14440            },
14441            None,
14442        );
14443
14444        let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14445        let (_pool, failures) =
14446            initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
14447                .await
14448                .expect("MCP feature enabled");
14449        assert_eq!(failures.len(), 1, "failures={failures:?}");
14450        assert_eq!(failures[0].0, "blocked");
14451        assert!(failures[0].1.contains("blocked by network policy"));
14452    }
14453
14454    #[test]
14455    fn exec_model_resolution_uses_provider_scoped_default() {
14456        let _env_lock = crate::test_support::lock_test_env();
14457        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14458        let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14459        let config = Config {
14460            provider: Some("openrouter".to_string()),
14461            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14462            providers: Some(crate::config::ProvidersConfig {
14463                openrouter: crate::config::ProviderConfig {
14464                    model: Some("arcee-ai/trinity-large-thinking".to_string()),
14465                    ..Default::default()
14466                },
14467                ..Default::default()
14468            }),
14469            ..Default::default()
14470        };
14471
14472        assert_eq!(
14473            resolve_exec_model(&config, None),
14474            "arcee-ai/trinity-large-thinking"
14475        );
14476        assert_eq!(
14477            resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
14478            "arcee-ai/trinity-large-thinking"
14479        );
14480    }
14481
14482    #[test]
14483    fn exec_model_resolution_prefers_codewhale_model_env_override() {
14484        let _env_lock = crate::test_support::lock_test_env();
14485        let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
14486        let _deepseek_model =
14487            crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
14488        let config = Config {
14489            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14490            ..Default::default()
14491        };
14492
14493        assert_eq!(resolve_exec_model(&config, None), "auto");
14494    }
14495
14496    #[test]
14497    fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
14498        let _env_lock = crate::test_support::lock_test_env();
14499        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14500        let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
14501        let config = Config {
14502            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14503            ..Default::default()
14504        };
14505
14506        assert_eq!(resolve_exec_model(&config, None), "auto");
14507    }
14508
14509    #[test]
14510    fn exec_model_resolution_uses_provider_safe_default_for_zai() {
14511        let _env_lock = crate::test_support::lock_test_env();
14512        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14513        let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14514        let config = Config {
14515            provider: Some("zai".to_string()),
14516            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14517            ..Default::default()
14518        };
14519
14520        assert_eq!(
14521            resolve_exec_model(&config, None),
14522            crate::config::DEFAULT_ZAI_MODEL
14523        );
14524    }
14525
14526    #[tokio::test]
14527    #[allow(clippy::await_holding_lock)]
14528    async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
14529        let _env_lock = crate::test_support::lock_test_env();
14530        let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14531        let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
14532        let config = Config {
14533            provider: Some("deepseek".to_string()),
14534            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14535            ..Default::default()
14536        };
14537
14538        let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14539            .await
14540            .expect("explicit GLM should route to the configured Z.ai provider");
14541
14542        assert_eq!(route.provider, crate::config::ApiProvider::Zai);
14543        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14544        assert!(!route.auto_model);
14545    }
14546
14547    #[tokio::test]
14548    #[allow(clippy::await_holding_lock)]
14549    async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
14550        let _env_lock = crate::test_support::lock_test_env();
14551        let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14552        let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
14553        let config = Config {
14554            provider: Some("deepseek".to_string()),
14555            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14556            ..Default::default()
14557        };
14558
14559        let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14560            .await
14561            .expect_err("ambiguous GLM route should ask for an explicit provider");
14562        let message = err.to_string();
14563
14564        assert!(message.contains("model `GLM-5.2` is available"));
14565        assert!(message.contains("openrouter"));
14566        assert!(message.contains("zai"));
14567        assert!(message.contains("--provider"));
14568        assert!(message.contains("/provider"));
14569        assert!(message.contains("/model"));
14570        assert!(message.contains("/setup"));
14571    }
14572
14573    #[tokio::test]
14574    async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
14575        let config = Config {
14576            provider: Some("vllm".to_string()),
14577            reasoning_effort: Some("low".to_string()),
14578            providers: Some(crate::config::ProvidersConfig {
14579                vllm: crate::config::ProviderConfig {
14580                    base_url: Some("http://127.0.0.1:18190/v1".to_string()),
14581                    model: Some("local-auto-model".to_string()),
14582                    ..Default::default()
14583                },
14584                ..Default::default()
14585            }),
14586            ..Default::default()
14587        };
14588
14589        let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
14590            .await
14591            .expect("Auto model route");
14592
14593        assert!(route.auto_model);
14594        assert_eq!(
14595            route.reasoning_effort,
14596            Some(crate::tui::app::ReasoningEffort::Low)
14597        );
14598        assert!(
14599            !route.auto_controls_reasoning,
14600            "a fixed saved tier must not be replaced per prompt"
14601        );
14602    }
14603
14604    #[test]
14605    fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
14606        let mut providers = crate::config::ProvidersConfig::default();
14607        providers.deepseek.model = Some("deepseek-v4-pro".to_string());
14608        let config = Config {
14609            provider: Some("deepseek".to_string()),
14610            providers: Some(providers),
14611            ..Default::default()
14612        };
14613        let route = CliAutoRoute {
14614            provider: crate::config::ApiProvider::Deepseek,
14615            model: "deepseek-v4-flash".to_string(),
14616            reasoning_effort: None,
14617            auto_controls_reasoning: true,
14618            auto_model: true,
14619        };
14620
14621        let execution_config = config_for_cli_route(&config, &route);
14622
14623        assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
14624        assert_eq!(
14625            execution_config
14626                .provider_config_for(crate::config::ApiProvider::Deepseek)
14627                .and_then(|entry| entry.model.as_deref()),
14628            Some("deepseek-v4-flash")
14629        );
14630    }
14631
14632    #[test]
14633    fn cli_route_execution_config_preserves_legacy_literal_custom_root_route() {
14634        let _lock = crate::test_support::lock_test_env();
14635        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
14636        let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
14637        let config = Config {
14638            provider: Some("custom".to_string()),
14639            api_key: Some("legacy-root-key".to_string()),
14640            base_url: Some("http://127.0.0.1:18183/v1".to_string()),
14641            default_text_model: Some("legacy-model".to_string()),
14642            ..Default::default()
14643        };
14644        let route = CliAutoRoute {
14645            provider: crate::config::ApiProvider::Custom,
14646            model: "routed-legacy-model".to_string(),
14647            reasoning_effort: None,
14648            auto_controls_reasoning: false,
14649            auto_model: false,
14650        };
14651
14652        let execution = config_for_cli_route(&config, &route);
14653
14654        assert!(execution.uses_legacy_literal_custom_route());
14655        assert!(
14656            execution
14657                .providers
14658                .as_ref()
14659                .is_none_or(|providers| !providers.custom.contains_key("custom"))
14660        );
14661        assert_eq!(execution.provider.as_deref(), Some("custom"));
14662        assert_eq!(execution.default_model(), "routed-legacy-model");
14663        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18183/v1");
14664        assert_eq!(execution.deepseek_api_key().unwrap(), "legacy-root-key");
14665        for _ in 0..2 {
14666            let identity = execution
14667                .resolve_provider_identity("custom")
14668                .expect("legacy identity remains repeatedly resolvable");
14669            assert_eq!(identity.key, "custom");
14670        }
14671        let client =
14672            crate::client::DeepSeekClient::new(&execution).expect("legacy execution client");
14673        assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
14674    }
14675
14676    #[test]
14677    fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
14678        let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
14679        let Some(Commands::Exec(args)) = cli.command else {
14680            panic!("expected exec command");
14681        };
14682
14683        assert_eq!(args.prompt, vec!["hello", "world"]);
14684    }
14685
14686    #[test]
14687    fn exec_keeps_model_flag_before_split_prompt_words() {
14688        let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
14689        let Some(Commands::Exec(args)) = cli.command else {
14690            panic!("expected exec command");
14691        };
14692
14693        assert_eq!(args.model.as_deref(), Some("auto"));
14694        assert_eq!(args.prompt, vec!["hello", "world"]);
14695    }
14696
14697    #[test]
14698    fn exec_keeps_flags_before_split_prompt_words() {
14699        let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
14700        let Some(Commands::Exec(args)) = cli.command else {
14701            panic!("expected exec command");
14702        };
14703
14704        assert!(args.json);
14705        assert_eq!(args.prompt, vec!["hello", "world"]);
14706    }
14707
14708    #[test]
14709    fn exec_parses_provider_flag_alongside_model() {
14710        // #4093: Fleet threads `--provider <id>` so a worker launches on its
14711        // profile-pinned provider even when the parent session is elsewhere.
14712        let cli = parse_cli(&[
14713            "codewhale",
14714            "exec",
14715            "--provider",
14716            "openrouter",
14717            "--model",
14718            "glm-5.2",
14719            "audit",
14720        ]);
14721        let Some(Commands::Exec(args)) = cli.command else {
14722            panic!("expected exec command");
14723        };
14724
14725        assert_eq!(args.provider.as_deref(), Some("openrouter"));
14726        assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14727        assert_eq!(args.prompt, vec!["audit"]);
14728        // The threaded id round-trips through the provider vocabulary the exec
14729        // handler validates against — never a model-id sniff (EPIC #2608).
14730        assert_eq!(
14731            crate::config::ApiProvider::parse(args.provider.as_deref().unwrap()),
14732            Some(crate::config::ApiProvider::Openrouter)
14733        );
14734    }
14735
14736    #[test]
14737    fn exec_provider_override_accepts_configured_custom_provider() {
14738        let mut custom = std::collections::HashMap::new();
14739        custom.insert(
14740            "lm-studio".to_string(),
14741            crate::config::ProviderConfig {
14742                kind: Some("openai-compatible".to_string()),
14743                base_url: Some("http://127.0.0.1:1234/v1".to_string()),
14744                model: Some("qwen-2.5-7b".to_string()),
14745                api_key: Some("lm-studio".to_string()),
14746                ..Default::default()
14747            },
14748        );
14749        let mut config = Config {
14750            provider: Some("deepseek".to_string()),
14751            providers: Some(crate::config::ProvidersConfig {
14752                custom,
14753                ..Default::default()
14754            }),
14755            ..Default::default()
14756        };
14757
14758        apply_exec_provider_override(&mut config, "lm-studio")
14759            .expect("configured custom provider should be accepted");
14760
14761        assert_eq!(config.provider.as_deref(), Some("lm-studio"));
14762        assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14763    }
14764
14765    #[test]
14766    fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
14767        let mut config = Config {
14768            provider: Some("deepseek".to_string()),
14769            providers: Some(crate::config::ProvidersConfig {
14770                custom: std::collections::HashMap::from([(
14771                    "CUSTOM".to_string(),
14772                    crate::config::ProviderConfig {
14773                        kind: Some("openai-compatible".to_string()),
14774                        base_url: Some("http://127.0.0.1:5678/v1".to_string()),
14775                        model: Some("case-model".to_string()),
14776                        api_key: Some("case-key".to_string()),
14777                        ..Default::default()
14778                    },
14779                )]),
14780                ..Default::default()
14781            }),
14782            ..Default::default()
14783        };
14784
14785        apply_exec_provider_override(&mut config, "CUSTOM")
14786            .expect("exact case-colliding custom provider");
14787        assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
14788        assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14789        assert_eq!(
14790            config.provider_identity_for(crate::config::ApiProvider::Custom),
14791            "CUSTOM"
14792        );
14793        let route = crate::route_runtime::resolve_runtime_route(
14794            &config,
14795            crate::config::ApiProvider::Custom,
14796            Some("case-model"),
14797        )
14798        .expect("resolve exact case-colliding route")
14799        .validate()
14800        .expect("preflight exact case-colliding route");
14801        assert_eq!(route.identity.key, "CUSTOM");
14802        assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
14803    }
14804
14805    #[test]
14806    fn exec_provider_override_rejects_unknown_provider() {
14807        let mut config = Config {
14808            provider: Some("deepseek".to_string()),
14809            ..Default::default()
14810        };
14811
14812        let err = apply_exec_provider_override(&mut config, "lm-studio")
14813            .expect_err("unconfigured custom provider should fail closed");
14814        let message = err.to_string();
14815
14816        assert!(message.contains("Unrecognized --provider"));
14817        assert!(message.contains("[providers.<name>] custom provider"));
14818        assert_eq!(config.provider.as_deref(), Some("deepseek"));
14819    }
14820
14821    #[test]
14822    fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
14823        let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14824
14825        let mut restored = custom_exec_config("custom-b");
14826        let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
14827            .expect("plain resume restores saved route");
14828        assert_eq!(restored.provider.as_deref(), Some("custom-a"));
14829        assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
14830
14831        let mut explicit_provider = custom_exec_config("custom-a");
14832        apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
14833        let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
14834            .expect("explicit provider wins");
14835        assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
14836        assert_eq!(model, "model-b");
14837
14838        let mut explicit_model = custom_exec_config("custom-b");
14839        let model =
14840            resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
14841                .expect("explicit model keeps saved provider");
14842        assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
14843        assert_eq!(model, "override-model");
14844
14845        let mut missing = custom_exec_config("custom-b");
14846        missing
14847            .providers
14848            .as_mut()
14849            .expect("providers")
14850            .custom
14851            .remove("custom-a");
14852        let before = missing.provider.clone();
14853        let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
14854            .expect_err("removed saved provider must fail closed");
14855        assert!(err.to_string().contains("will not fall back"), "{err}");
14856        assert_eq!(missing.provider, before);
14857    }
14858
14859    #[test]
14860    fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
14861        let (started_tx, started_rx) = std::sync::mpsc::channel();
14862        let (tx, rx) = std::sync::mpsc::channel();
14863
14864        let (reader, expected_after_restore) = {
14865            let lock = crate::test_support::lock_test_env();
14866            let expected_after_restore = exec_model_env_override();
14867            let temporary =
14868                crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
14869            let reader = std::thread::spawn(move || {
14870                started_tx.send(()).expect("signal model read start");
14871                tx.send(exec_model_env_override())
14872                    .expect("send resolved model override");
14873            });
14874
14875            started_rx
14876                .recv_timeout(std::time::Duration::from_secs(2))
14877                .expect("reader reached model read");
14878            assert!(
14879                rx.recv_timeout(std::time::Duration::from_millis(50))
14880                    .is_err(),
14881                "a foreign reader observed another test's temporary model override"
14882            );
14883            drop(temporary);
14884            drop(lock);
14885            (reader, expected_after_restore)
14886        };
14887
14888        let observed = rx
14889            .recv_timeout(std::time::Duration::from_secs(2))
14890            .expect("reader resumed after model override was restored");
14891        reader.join().expect("reader thread");
14892        assert_eq!(observed, expected_after_restore);
14893    }
14894
14895    #[tokio::test]
14896    async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
14897        let config = custom_exec_config("custom-a");
14898
14899        let route =
14900            resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
14901                .await
14902                .expect("forced route");
14903        let execution = config_for_cli_route(&config, &route);
14904
14905        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14906        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14907        assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14908    }
14909
14910    #[tokio::test]
14911    async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
14912        let mut config = custom_exec_config("custom-a");
14913        config
14914            .providers
14915            .as_mut()
14916            .expect("providers")
14917            .custom
14918            .get_mut("custom-a")
14919            .expect("custom A")
14920            .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
14921        let model = resolve_exec_model(&config, None);
14922        let force = should_force_configured_exec_route(false, None, None);
14923
14924        assert!(force, "configured/default exec route must be authoritative");
14925        assert!(!should_force_configured_exec_route(
14926            false,
14927            None,
14928            Some(crate::config::ZAI_GLM_5_2_MODEL)
14929        ));
14930        assert!(should_force_configured_exec_route(
14931            false,
14932            Some("custom-a"),
14933            Some(crate::config::ZAI_GLM_5_2_MODEL)
14934        ));
14935        assert!(should_force_configured_exec_route(
14936            true,
14937            None,
14938            Some("override-model")
14939        ));
14940
14941        let route = resolve_cli_exec_route(&config, &model, "audit", force)
14942            .await
14943            .expect("no-flag configured route");
14944        let execution = config_for_cli_route(&config, &route);
14945        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14946        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14947        assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14948    }
14949
14950    #[tokio::test]
14951    async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
14952        let mut config = custom_exec_config("custom-a");
14953        config
14954            .providers
14955            .as_mut()
14956            .expect("providers")
14957            .custom
14958            .get_mut("custom-a")
14959            .expect("custom A")
14960            .model = Some("model-a".to_string());
14961        config.default_text_model = Some("stale-root-deepseek-model".to_string());
14962        let model = resolve_review_model(&config, None);
14963        assert_eq!(model, "model-a");
14964        assert_eq!(
14965            resolve_review_model(&config, Some("explicit-review-model")),
14966            "explicit-review-model"
14967        );
14968
14969        let route = resolve_cli_exec_route(&config, &model, "review diff", true)
14970            .await
14971            .expect("configured review route");
14972        let execution = config_for_cli_route(&config, &route);
14973        let provider = execution.provider_identity_for(route.provider);
14974
14975        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14976        assert_eq!(provider, "custom-a");
14977        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14978        let output = crate::tools::review::ReviewOutput::from_str("{}");
14979        let receipt = crate::tools::review::build_review_receipt(
14980            "working tree",
14981            "diff --git a/a b/a",
14982            provider,
14983            &route.model,
14984            &output,
14985            "{}",
14986            Vec::new(),
14987        );
14988        assert_eq!(receipt.provider, "custom-a");
14989        let serialized = serde_json::to_string(&receipt).expect("review receipt");
14990        assert!(!serialized.contains("127.0.0.1"));
14991        assert!(!serialized.contains("local-test-key"));
14992    }
14993
14994    #[tokio::test]
14995    async fn configured_workflow_default_keeps_named_custom_route() {
14996        let config = custom_exec_config("custom-a");
14997        let model = config.default_model();
14998
14999        let route = resolve_cli_exec_route(
15000            &config,
15001            &model,
15002            "Run a checked-in Workflow through the host runtime",
15003            true,
15004        )
15005        .await
15006        .expect("configured workflow route");
15007        let execution = config_for_cli_route(&config, &route);
15008
15009        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
15010        assert_eq!(execution.provider_identity_for(route.provider), "custom-a");
15011        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
15012        let client = crate::client::DeepSeekClient::new(&execution).expect("workflow client");
15013        assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
15014    }
15015
15016    #[test]
15017    fn exec_json_receipts_keep_exact_named_custom_provider() {
15018        let config = custom_exec_config("custom-a");
15019        let provider = config.provider_identity_for(crate::config::ApiProvider::Custom);
15020        let one_shot = one_shot_exec_json_receipt(
15021            provider.clone(),
15022            "model-a".to_string(),
15023            "done".to_string(),
15024            Some("end_turn".to_string()),
15025            crate::models::Usage {
15026                input_tokens: 12,
15027                output_tokens: 3,
15028                ..Default::default()
15029            },
15030        );
15031        assert_eq!(one_shot["provider"], "custom-a");
15032        assert_eq!(one_shot["success"], true);
15033
15034        let truncated = one_shot_exec_json_receipt(
15035            provider.clone(),
15036            "model-a".to_string(),
15037            "partial".to_string(),
15038            Some("max_output_tokens".to_string()),
15039            crate::models::Usage {
15040                input_tokens: 20,
15041                output_tokens: 9,
15042                ..Default::default()
15043            },
15044        );
15045        assert_eq!(truncated["success"], false);
15046        assert_eq!(truncated["stop_reason"], "max_output_tokens");
15047        assert_eq!(truncated["usage"]["input_tokens"], 20);
15048        assert_eq!(truncated["usage"]["output_tokens"], 9);
15049        assert!(truncated["error"].as_str().is_some_and(|error| {
15050            error.contains("Model response incomplete") && error.contains("max_output_tokens")
15051        }));
15052
15053        let agent = serde_json::to_value(ExecSummary {
15054            mode: "agent".to_string(),
15055            provider,
15056            model: "model-a".to_string(),
15057            ..ExecSummary::default()
15058        })
15059        .expect("agent exec JSON receipt");
15060        assert_eq!(agent["provider"], "custom-a");
15061        let serialized = serde_json::to_string(&agent).expect("serialize receipt");
15062        assert!(!serialized.contains("127.0.0.1"));
15063        assert!(!serialized.contains("local-test-key"));
15064    }
15065
15066    #[test]
15067    fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
15068        let named = crate::config::ProviderIdentity {
15069            provider: crate::config::ApiProvider::Custom,
15070            key: "lm-studio".to_string(),
15071            exact_id: Some("lm-studio".to_string()),
15072            migrated_legacy_ollama_cloud_route: false,
15073        };
15074        let literal = crate::config::ProviderIdentity {
15075            provider: crate::config::ApiProvider::Custom,
15076            key: "custom".to_string(),
15077            exact_id: Some("custom".to_string()),
15078            migrated_legacy_ollama_cloud_route: false,
15079        };
15080        let root = crate::config::ProviderIdentity {
15081            provider: crate::config::ApiProvider::Custom,
15082            key: "custom".to_string(),
15083            exact_id: None,
15084            migrated_legacy_ollama_cloud_route: false,
15085        };
15086        let built_in = crate::config::ProviderIdentity {
15087            provider: crate::config::ApiProvider::Deepseek,
15088            key: "deepseek".to_string(),
15089            exact_id: Some("deepseek".to_string()),
15090            migrated_legacy_ollama_cloud_route: false,
15091        };
15092
15093        assert_eq!(
15094            exec_stream_provider_route(&named),
15095            ("custom".to_string(), Some("lm-studio".to_string()))
15096        );
15097        assert_eq!(
15098            exec_stream_provider_route(&literal),
15099            ("custom".to_string(), Some("custom".to_string()))
15100        );
15101        assert_eq!(
15102            exec_stream_provider_route(&root),
15103            ("custom".to_string(), None)
15104        );
15105        assert_eq!(
15106            exec_stream_provider_route(&built_in),
15107            ("deepseek".to_string(), None)
15108        );
15109    }
15110
15111    #[test]
15112    fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
15113        let saved_a = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
15114        let mut config = custom_exec_config("custom-a");
15115        apply_exec_provider_override(&mut config, "custom-b").expect("custom B");
15116        let model = resolve_exec_resume_route(&mut config, &saved_a, true, None)
15117            .expect("explicit provider route");
15118        let mut persisted = saved_a;
15119        stamp_exec_session_metadata(
15120            &mut persisted,
15121            &model,
15122            crate::config::ApiProvider::Custom.as_str(),
15123            Some("custom-b"),
15124            Path::new("/tmp/exec-resume"),
15125        );
15126
15127        let mut next_config = custom_exec_config("custom-a");
15128        let resumed_model = resolve_exec_resume_route(&mut next_config, &persisted, false, None)
15129            .expect("next plain resume");
15130
15131        assert_eq!(persisted.metadata.model_provider, "custom");
15132        assert_eq!(
15133            persisted.metadata.model_provider_id.as_deref(),
15134            Some("custom-b")
15135        );
15136        assert_eq!(persisted.metadata.model, "model-b");
15137        assert_eq!(next_config.provider.as_deref(), Some("custom-b"));
15138        assert_eq!(resumed_model, "model-b");
15139    }
15140
15141    #[test]
15142    fn exec_persistence_omits_id_for_legacy_root_custom_route() {
15143        let mut saved = session_manager::create_saved_session_with_mode(
15144            &[],
15145            "legacy-root-model",
15146            Path::new("/tmp/exec-root"),
15147            0,
15148            None,
15149            Some("exec"),
15150        );
15151        stamp_exec_session_metadata(
15152            &mut saved,
15153            "legacy-root-model",
15154            crate::config::ApiProvider::Custom.as_str(),
15155            None,
15156            Path::new("/tmp/exec-root"),
15157        );
15158
15159        assert_eq!(saved.metadata.model_provider, "custom");
15160        assert_eq!(saved.metadata.model_provider_id, None);
15161        assert!(
15162            !serde_json::to_string(&saved)
15163                .expect("serialize exec session")
15164                .contains("model_provider_id")
15165        );
15166    }
15167
15168    #[test]
15169    fn exec_parses_reasoning_effort_flag_alongside_provider() {
15170        let cli = parse_cli(&[
15171            "codewhale",
15172            "exec",
15173            "--provider",
15174            "openrouter",
15175            "--model",
15176            "glm-5.2",
15177            "--reasoning-effort",
15178            "max",
15179            "audit",
15180        ]);
15181        let Some(Commands::Exec(args)) = cli.command else {
15182            panic!("expected exec command");
15183        };
15184
15185        assert_eq!(args.provider.as_deref(), Some("openrouter"));
15186        assert_eq!(args.model.as_deref(), Some("glm-5.2"));
15187        assert_eq!(args.reasoning_effort.as_deref(), Some("max"));
15188        assert_eq!(args.prompt, vec!["audit"]);
15189    }
15190
15191    #[test]
15192    fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
15193        // The thinking ladder split these: `xhigh` is a tier the CLI can now
15194        // name, `ultracode` is still an alias and resolves to `ultra`.
15195        assert_eq!(
15196            normalize_cli_reasoning_effort("xhigh").unwrap().as_deref(),
15197            Some("xhigh")
15198        );
15199        assert_eq!(
15200            normalize_cli_reasoning_effort("ultracode")
15201                .unwrap()
15202                .as_deref(),
15203            Some("ultra")
15204        );
15205        assert_eq!(normalize_cli_reasoning_effort("default").unwrap(), None);
15206        assert!(normalize_cli_reasoning_effort("expensive").is_err());
15207    }
15208
15209    #[test]
15210    fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() {
15211        let config = Config {
15212            provider: Some("moonshot".to_string()),
15213            providers: Some(crate::config::ProvidersConfig {
15214                moonshot: crate::config::ProviderConfig {
15215                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
15216                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
15217                    ..Default::default()
15218                },
15219                ..Default::default()
15220            }),
15221            ..Default::default()
15222        };
15223
15224        for (prompt, expected) in [
15225            ("lookup the public docs", "low"),
15226            ("debug this error", "max"),
15227            ("review this ordinary change", "high"),
15228        ] {
15229            assert_eq!(
15230                cli_reasoning_effort_value_for_prompt(
15231                    &config,
15232                    crate::config::KIMI_CODE_K3_MODEL,
15233                    crate::tui::app::ReasoningEffort::Auto,
15234                    prompt,
15235                )
15236                .as_deref(),
15237                Some(expected),
15238                "prompt selector must resolve Auto for `{prompt}`"
15239            );
15240        }
15241
15242        assert_eq!(
15243            cli_reasoning_effort_value_for_prompt(
15244                &config,
15245                crate::config::KIMI_CODE_K3_MODEL,
15246                crate::tui::app::ReasoningEffort::Off,
15247                "debug must not override an explicit effort",
15248            )
15249            .as_deref(),
15250            Some("low"),
15251            "membership K3 still applies its exact-route always-thinking floor"
15252        );
15253    }
15254
15255    #[test]
15256    fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
15257        use crate::tui::app::ReasoningEffort;
15258
15259        let fixed_model_auto_reasoning = CliAutoRoute {
15260            provider: crate::config::ApiProvider::Deepseek,
15261            model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
15262            reasoning_effort: Some(ReasoningEffort::Auto),
15263            auto_controls_reasoning: true,
15264            auto_model: false,
15265        };
15266        let auto_model_fixed_reasoning = CliAutoRoute {
15267            provider: crate::config::ApiProvider::OpenaiCodex,
15268            model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
15269            reasoning_effort: Some(ReasoningEffort::High),
15270            auto_controls_reasoning: false,
15271            auto_model: true,
15272        };
15273
15274        assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
15275        assert!(!fixed_model_auto_reasoning.auto_model);
15276        assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
15277        assert!(auto_model_fixed_reasoning.auto_model);
15278    }
15279
15280    #[test]
15281    fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
15282        let mut config = Config {
15283            reasoning_effort: Some("max".to_string()),
15284            reasoning_effort_inferred_from_legacy_alias: true,
15285            ..Default::default()
15286        };
15287        let settings = crate::settings::Settings {
15288            reasoning_effort: Some("low".to_string()),
15289            ..Default::default()
15290        };
15291
15292        apply_saved_reasoning_preference(&mut config, &settings);
15293
15294        assert_eq!(config.reasoning_effort(), Some("low"));
15295        assert!(config.reasoning_effort_is_explicit());
15296    }
15297
15298    /// `run_exec_agent` must hand the engine a concrete tier, never the literal
15299    /// `"auto"` sentinel, for a fixed-model Auto launch.
15300    #[test]
15301    fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
15302        let config = Config {
15303            provider: Some("zai".to_string()),
15304            ..Default::default()
15305        };
15306
15307        let resolved = cli_reasoning_effort_value_for_prompt(
15308            &config,
15309            crate::config::ZAI_GLM_5_2_MODEL,
15310            crate::tui::app::ReasoningEffort::Auto,
15311            "debug this failing integration test",
15312        )
15313        .expect("Auto must resolve to a concrete tier");
15314
15315        assert_ne!(
15316            resolved, "auto",
15317            "the literal auto sentinel must never reach a provider"
15318        );
15319        assert!(
15320            matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
15321            "unexpected resolved tier: {resolved}"
15322        );
15323    }
15324
15325    #[test]
15326    fn exec_accepts_resume_session_flags_for_harnesses() {
15327        let cli = parse_cli(&[
15328            "codewhale",
15329            "exec",
15330            "--resume",
15331            "abc123",
15332            "--output-format",
15333            "stream-json",
15334            "follow up",
15335        ]);
15336        let Some(Commands::Exec(args)) = cli.command else {
15337            panic!("expected exec command");
15338        };
15339
15340        assert_eq!(args.resume.as_deref(), Some("abc123"));
15341        assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
15342        assert_eq!(args.prompt, vec!["follow up"]);
15343    }
15344
15345    #[test]
15346    fn exec_accepts_session_id_alias() {
15347        let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]);
15348        let Some(Commands::Exec(args)) = cli.command else {
15349            panic!("expected exec command");
15350        };
15351
15352        assert_eq!(args.session_id.as_deref(), Some("abc123"));
15353        assert_eq!(args.output_format, ExecOutputFormat::Text);
15354    }
15355
15356    #[test]
15357    fn exec_parses_tool_gate_and_hardening_flags() {
15358        let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#;
15359        let cli = parse_cli(&[
15360            "codewhale",
15361            "exec",
15362            "--allowed-tools",
15363            "File,Git",
15364            "--disallowed-tools",
15365            "Bash",
15366            "--max-turns",
15367            "7",
15368            "--append-system-prompt",
15369            "extra rules",
15370            "--tool-authority-json",
15371            envelope,
15372            "do the thing",
15373        ]);
15374        let Some(Commands::Exec(args)) = cli.command else {
15375            panic!("expected exec command");
15376        };
15377
15378        assert_eq!(
15379            args.allowed_tools.as_deref(),
15380            Some(&["File".to_string(), "Git".to_string()][..])
15381        );
15382        assert_eq!(
15383            args.disallowed_tools.as_deref(),
15384            Some(&["Bash".to_string()][..])
15385        );
15386        assert_eq!(args.max_turns, Some(7));
15387        assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
15388        assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
15389        assert_eq!(args.prompt, vec!["do the thing"]);
15390    }
15391
15392    #[test]
15393    fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
15394        assert!(validate_exec_tool_authority_resume(None, true).is_ok());
15395        assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
15396        let error = validate_exec_tool_authority_resume(Some("{}"), true)
15397            .expect_err("authority must remain bound to its fresh Fleet launch")
15398            .to_string();
15399        assert!(error.contains("cannot be combined with exec --resume"));
15400    }
15401
15402    #[test]
15403    fn exec_auto_does_not_authorize_sandbox_elevation() {
15404        let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
15405        let Some(Commands::Exec(args)) = cli.command else {
15406            panic!("expected exec command");
15407        };
15408
15409        assert!(!exec_sandbox_elevation_authorized(
15410            args.allow_sandbox_elevation,
15411            args.sandbox.as_deref()
15412        ));
15413    }
15414
15415    #[test]
15416    fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
15417        let danger = parse_cli(&[
15418            "codewhale",
15419            "exec",
15420            "--auto",
15421            "--sandbox",
15422            "danger-full-access",
15423            "run it",
15424        ]);
15425        let Some(Commands::Exec(args)) = danger.command else {
15426            panic!("expected exec command");
15427        };
15428        assert!(exec_sandbox_elevation_authorized(
15429            args.allow_sandbox_elevation,
15430            args.sandbox.as_deref()
15431        ));
15432
15433        let flag = parse_cli(&[
15434            "codewhale",
15435            "exec",
15436            "--auto",
15437            "--allow-sandbox-elevation",
15438            "run it",
15439        ]);
15440        let Some(Commands::Exec(args)) = flag.command else {
15441            panic!("expected exec command");
15442        };
15443        assert!(exec_sandbox_elevation_authorized(
15444            args.allow_sandbox_elevation,
15445            args.sandbox.as_deref()
15446        ));
15447    }
15448
15449    #[test]
15450    fn exec_sandbox_denial_stream_event_is_typed() {
15451        let event = ExecStreamEvent::SandboxDenied {
15452            tool_id: "call_1".to_string(),
15453            tool_name: "exec_shell".to_string(),
15454            reason: "write blocked".to_string(),
15455            outcome: "approval_required".to_string(),
15456        };
15457        let value: serde_json::Value =
15458            serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
15459                .expect("valid json");
15460        assert_eq!(value["type"], "sandbox_denied");
15461        assert_eq!(value["outcome"], "approval_required");
15462    }
15463
15464    #[test]
15465    fn exec_help_separates_agent_mode_from_sandbox_elevation() {
15466        let mut cli = Cli::command();
15467        let help = cli
15468            .find_subcommand_mut("exec")
15469            .expect("exec command")
15470            .render_help()
15471            .to_string();
15472        assert!(help.contains("--auto"));
15473        assert!(help.contains("--sandbox"));
15474        assert!(help.contains("--allow-sandbox-elevation"));
15475        assert!(help.contains("does not change the"));
15476        assert!(help.contains("explicitly authorize sandbox elevation"));
15477    }
15478
15479    #[test]
15480    fn exec_shell_only_tool_surface_env_sets_shell_allowlist() {
15481        let _env_lock = crate::test_support::lock_test_env();
15482        let _surface =
15483            crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, " shell-only ");
15484
15485        let allowed_tools = resolve_exec_allowed_tools(None, exec_tool_surface_from_env())
15486            .expect("shell-only surface should set an allowlist");
15487
15488        assert_eq!(allowed_tools, vec!["bash".to_string()]);
15489    }
15490
15491    #[test]
15492    fn exec_explicit_allowed_tools_override_shell_only_env() {
15493        let _env_lock = crate::test_support::lock_test_env();
15494        let _surface =
15495            crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "shell-only");
15496        let explicit = vec![" File ".to_string(), "GIT".to_string()];
15497
15498        let allowed_tools =
15499            resolve_exec_allowed_tools(Some(&explicit), exec_tool_surface_from_env())
15500                .expect("explicit allowlist should be preserved");
15501
15502        assert_eq!(allowed_tools, vec!["file".to_string(), "git".to_string()]);
15503    }
15504
15505    #[test]
15506    fn exec_full_tool_surface_env_leaves_allowlist_unset() {
15507        let _env_lock = crate::test_support::lock_test_env();
15508        let _surface = crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "full");
15509
15510        assert_eq!(
15511            resolve_exec_allowed_tools(None, exec_tool_surface_from_env()),
15512            None
15513        );
15514    }
15515
15516    #[test]
15517    fn exec_unknown_tool_surface_env_warns_without_allowlist() {
15518        assert!(should_warn_unknown_exec_tool_surface("shell_onyl"));
15519        assert!(!should_warn_unknown_exec_tool_surface("shell-only"));
15520        assert!(!should_warn_unknown_exec_tool_surface("native-tools"));
15521        assert!(!should_warn_unknown_exec_tool_surface("full"));
15522        assert!(!should_warn_unknown_exec_tool_surface(" "));
15523        assert_eq!(parse_exec_tool_surface("shell_onyl"), None);
15524    }
15525
15526    #[test]
15527    fn exec_rejects_zero_max_turns() {
15528        let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
15529            .expect_err("max-turns must be >= 1");
15530        assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
15531    }
15532
15533    #[test]
15534    fn exec_omits_the_headless_turn_cap_by_default() {
15535        let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
15536        let Some(Commands::Exec(args)) = cli.command else {
15537            panic!("expected exec command");
15538        };
15539
15540        assert_eq!(args.max_turns, None);
15541        assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
15542        assert_eq!(exec_max_steps(Some(7)), 7);
15543    }
15544
15545    #[test]
15546    fn exec_accepts_continue_for_latest_workspace_session() {
15547        let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);
15548        let Some(Commands::Exec(args)) = cli.command else {
15549            panic!("expected exec command");
15550        };
15551
15552        assert!(args.continue_session);
15553    }
15554
15555    #[test]
15556    fn sessions_footer_points_to_resume_subcommand() {
15557        let cli = parse_cli(&["codewhale", "resume", "abc123"]);
15558        let Some(Commands::Resume { session_id, last }) = cli.command else {
15559            panic!("expected resume command");
15560        };
15561
15562        assert_eq!(session_id.as_deref(), Some("abc123"));
15563        assert!(!last);
15564        assert_eq!(sessions_resume_command(), "codewhale resume");
15565        assert!(!sessions_resume_command().contains("--resume"));
15566    }
15567
15568    #[test]
15569    fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() {
15570        use std::cell::Cell;
15571
15572        #[derive(Clone, Copy)]
15573        enum Expected {
15574            Plain,
15575            Resume,
15576            Fork,
15577            Exec,
15578            Serve,
15579        }
15580
15581        let cases: &[(&[&str], Expected)] = &[
15582            (&["codewhale"], Expected::Plain),
15583            (&["codewhale", "resume", "--last"], Expected::Resume),
15584            (&["codewhale", "fork", "--last"], Expected::Fork),
15585            (&["codewhale", "exec", "probe"], Expected::Exec),
15586            (&["codewhale", "serve", "--mcp"], Expected::Serve),
15587        ];
15588
15589        for (args, expected) in cases {
15590            let phase = Cell::new(0);
15591            let (_cli, command) = prepare_cli_startup(
15592                parse_cli(args),
15593                || {
15594                    assert_eq!(phase.get(), 0, "plugin init order for {args:?}");
15595                    phase.set(1);
15596                },
15597                || {
15598                    assert_eq!(phase.get(), 1, "dotenv load order for {args:?}");
15599                    phase.set(2);
15600                },
15601            );
15602
15603            assert_eq!(phase.get(), 2, "startup phases for {args:?}");
15604            let correct_variant = matches!(
15605                (expected, command.as_ref()),
15606                (Expected::Plain, None)
15607                    | (Expected::Resume, Some(Commands::Resume { .. }))
15608                    | (Expected::Fork, Some(Commands::Fork { .. }))
15609                    | (Expected::Exec, Some(Commands::Exec(_)))
15610                    | (Expected::Serve, Some(Commands::Serve(_)))
15611            );
15612            assert!(correct_variant, "unexpected command for {args:?}");
15613        }
15614    }
15615
15616    #[test]
15617    fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() {
15618        let _lock = crate::test_support::lock_test_env();
15619        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15620        let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key");
15621        let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
15622        let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
15623        let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL");
15624        let tmp = tempfile::TempDir::new().expect("temp workspace");
15625        let dotenv = tmp.path().join(".env");
15626        std::fs::write(
15627            &dotenv,
15628            "DEEPSEEK_API_KEY=workspace-key\n\
15629             NVIDIA_API_KEY=repo-must-not-override-shell\n\
15630             CODEWHALE_HOME=./attacker-home\n\
15631             CODEWHALE_CONFIG_PATH=./attacker.toml\n\
15632             DEEPSEEK_ALLOW_SHELL=true\n",
15633        )
15634        .expect("write dotenv");
15635
15636        let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load");
15637
15638        assert_eq!(
15639            std::env::var("DEEPSEEK_API_KEY").as_deref(),
15640            Ok("workspace-key")
15641        );
15642        assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key"));
15643        assert!(std::env::var_os("CODEWHALE_HOME").is_none());
15644        assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none());
15645        assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none());
15646        assert_eq!(
15647            report.loaded,
15648            BTreeSet::from(["DEEPSEEK_API_KEY".to_string()])
15649        );
15650        assert_eq!(
15651            report.ignored,
15652            BTreeSet::from([
15653                "CODEWHALE_CONFIG_PATH".to_string(),
15654                "CODEWHALE_HOME".to_string(),
15655                "DEEPSEEK_ALLOW_SHELL".to_string(),
15656            ])
15657        );
15658    }
15659
15660    #[test]
15661    fn workspace_dotenv_rejects_ambient_variable_substitution() {
15662        let _lock = crate::test_support::lock_test_env();
15663        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15664        let _ambient = crate::test_support::EnvVarGuard::set(
15665            "CODEWHALE_JS_SECRET_LEAK_TEST",
15666            "ambient-secret-must-not-expand",
15667        );
15668        let tmp = tempfile::TempDir::new().expect("temp workspace");
15669        let dotenv = tmp.path().join(".env");
15670        std::fs::write(
15671            &dotenv,
15672            "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n",
15673        )
15674        .expect("write dotenv");
15675
15676        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15677            .expect_err("expansion must fail closed")
15678            .to_string();
15679
15680        assert!(error.contains("variable expansion"));
15681        assert!(!error.contains("ambient-secret-must-not-expand"));
15682        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15683    }
15684
15685    #[test]
15686    fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() {
15687        let _lock = crate::test_support::lock_test_env();
15688        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15689        let _ambient = crate::test_support::EnvVarGuard::set(
15690            "CODEWHALE_JS_SECRET_LEAK_TEST",
15691            "ambient-secret-must-not-expand",
15692        );
15693        let tmp = tempfile::TempDir::new().expect("temp workspace");
15694        let dotenv = tmp.path().join(".env");
15695        std::fs::write(
15696            &dotenv,
15697            "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n",
15698        )
15699        .expect("write dotenv");
15700
15701        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15702            .expect_err("multiline expansion must fail closed")
15703            .to_string();
15704
15705        assert!(error.contains("variable expansion"));
15706        assert!(!error.contains("ambient-secret-must-not-expand"));
15707        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15708    }
15709
15710    #[test]
15711    fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() {
15712        let _lock = crate::test_support::lock_test_env();
15713        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15714        let _ambient = crate::test_support::EnvVarGuard::set(
15715            "CODEWHALE_JS_SECRET_LEAK_TEST",
15716            "ambient-secret-must-not-expand",
15717        );
15718        let tmp = tempfile::TempDir::new().expect("temp workspace");
15719        let dotenv = tmp.path().join(".env");
15720        std::fs::write(
15721            &dotenv,
15722            "# unmatched quote in ignored comment: '\n\
15723             DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n",
15724        )
15725        .expect("write dotenv");
15726
15727        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15728            .expect_err("comment quote must not hide expansion")
15729            .to_string();
15730
15731        assert!(error.contains("variable expansion"));
15732        assert!(!error.contains("ambient-secret-must-not-expand"));
15733        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15734    }
15735
15736    #[test]
15737    fn workspace_dotenv_allows_single_quoted_literal_dollar() {
15738        let _lock = crate::test_support::lock_test_env();
15739        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15740        let tmp = tempfile::TempDir::new().expect("temp workspace");
15741        let dotenv = tmp.path().join(".env");
15742        std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv");
15743
15744        load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load");
15745
15746        assert_eq!(
15747            std::env::var("DEEPSEEK_API_KEY").as_deref(),
15748            Ok("$literal-value")
15749        );
15750    }
15751
15752    #[test]
15753    fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() {
15754        let _lock = crate::test_support::lock_test_env();
15755        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15756        let tmp = tempfile::TempDir::new().expect("temp workspace");
15757        let dotenv = tmp.path().join(".env");
15758        std::fs::write(
15759            &dotenv,
15760            "DEEPSEEK_API_KEY=must-not-survive\nBROKEN=\"unterminated\n",
15761        )
15762        .expect("write dotenv");
15763
15764        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15765            .expect_err("parse failure must be transactional")
15766            .to_string();
15767
15768        assert!(error.contains("could not be parsed safely"), "{error}");
15769        assert!(!error.contains("must-not-survive"));
15770        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15771    }
15772
15773    #[test]
15774    fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() {
15775        for provider in codewhale_config::provider::providers_sorted_for_display() {
15776            for key in provider.env_vars() {
15777                assert!(
15778                    is_workspace_dotenv_credential_key(key),
15779                    "provider credential {key} must remain supported"
15780                );
15781            }
15782        }
15783        for key in [
15784            "CODEWHALE_HOME",
15785            "CODEWHALE_CONFIG_PATH",
15786            "DEEPSEEK_CONFIG_PATH",
15787            "DEEPSEEK_PROFILE",
15788            "DEEPSEEK_MANAGED_CONFIG_PATH",
15789            "DEEPSEEK_REQUIREMENTS_PATH",
15790            "DEEPSEEK_PROVIDER",
15791            "DEEPSEEK_BASE_URL",
15792            "DEEPSEEK_MODEL",
15793            "DEEPSEEK_APPROVAL_POLICY",
15794            "DEEPSEEK_SANDBOX_MODE",
15795            "DEEPSEEK_ALLOW_SHELL",
15796            "DEEPSEEK_YOLO",
15797            "DEEPSEEK_MCP_CONFIG",
15798            "CODEWHALE_RUNTIME_TOKEN",
15799            "PATH",
15800            "NODE_OPTIONS",
15801            "PYTHONPATH",
15802            "LD_PRELOAD",
15803            "DYLD_INSERT_LIBRARIES",
15804        ] {
15805            assert!(
15806                !is_workspace_dotenv_credential_key(key),
15807                "control-plane variable {key} must not load from a workspace"
15808            );
15809        }
15810    }
15811
15812    #[cfg(unix)]
15813    #[test]
15814    fn workspace_dotenv_does_not_follow_symbolic_links() {
15815        use std::os::unix::fs::symlink;
15816
15817        let tmp = tempfile::TempDir::new().expect("temp workspace");
15818        let external = tmp.path().join("external-credentials");
15819        let dotenv = tmp.path().join(".env");
15820        std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15821            .expect("write external fixture");
15822        symlink(&external, &dotenv).expect("create dotenv symlink");
15823
15824        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15825            .expect_err("symlink must fail closed")
15826            .to_string();
15827
15828        assert!(error.contains("securely open"), "{error}");
15829        assert!(!error.contains("external-secret"));
15830    }
15831
15832    #[cfg(unix)]
15833    #[test]
15834    fn workspace_dotenv_rejects_hard_links_to_external_files() {
15835        let tmp = tempfile::TempDir::new().expect("temp workspace");
15836        let external = tmp.path().join("external-credentials");
15837        let dotenv = tmp.path().join(".env");
15838        std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15839            .expect("write external fixture");
15840        std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link");
15841
15842        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15843            .expect_err("hard link must fail closed")
15844            .to_string();
15845
15846        assert!(error.contains("multiple filesystem links"), "{error}");
15847        assert!(!error.contains("external-secret"));
15848    }
15849
15850    #[cfg(unix)]
15851    #[test]
15852    fn workspace_dotenv_rejects_fifo_without_blocking_startup() {
15853        use std::ffi::CString;
15854        use std::os::unix::ffi::OsStrExt;
15855        use std::sync::mpsc;
15856        use std::time::Duration;
15857
15858        let tmp = tempfile::TempDir::new().expect("temp workspace");
15859        let dotenv = tmp.path().join(".env");
15860        let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path");
15861        // SAFETY: `c_path` is a live, NUL-terminated path and the requested
15862        // mode grants access only to the current user.
15863        let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) };
15864        assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
15865
15866        let (tx, rx) = mpsc::channel();
15867        let worker_path = dotenv.clone();
15868        let worker = std::thread::spawn(move || {
15869            let result = load_workspace_dotenv_credentials_from_path(&worker_path)
15870                .map(|_| "unexpected success".to_string())
15871                .unwrap_or_else(|error| error.to_string());
15872            tx.send(result).expect("send loader result");
15873        });
15874
15875        let error = match rx.recv_timeout(Duration::from_secs(1)) {
15876            Ok(error) => error,
15877            Err(timeout) => {
15878                // Release a regressed blocking reader so the test can fail
15879                // promptly instead of leaving a stuck process behind.
15880                let _writer = std::fs::OpenOptions::new()
15881                    .write(true)
15882                    .open(&dotenv)
15883                    .expect("open fifo writer to release blocked reader");
15884                let _ = rx.recv_timeout(Duration::from_secs(1));
15885                worker.join().expect("join released loader");
15886                panic!("workspace .env FIFO blocked startup: {timeout}");
15887            }
15888        };
15889        worker.join().expect("join loader");
15890
15891        assert!(error.contains("not a regular file"), "{error}");
15892    }
15893
15894    #[test]
15895    fn exec_json_conflicts_with_stream_json_output() {
15896        let err = Cli::try_parse_from([
15897            "codewhale",
15898            "exec",
15899            "--json",
15900            "--output-format",
15901            "stream-json",
15902            "hello",
15903        ])
15904        .expect_err("json summary and stream-json must not mix");
15905
15906        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
15907    }
15908
15909    #[test]
15910    fn exec_stream_turn_usage_event_serializes_reported_fields() {
15911        let event = ExecStreamEvent::TurnUsage {
15912            turn: 2,
15913            input_tokens: 1200,
15914            output_tokens: 180,
15915            reasoning_tokens: Some(90),
15916            prompt_cache_hit_tokens: Some(900),
15917            prompt_cache_miss_tokens: Some(300),
15918            prompt_cache_write_tokens: Some(0),
15919            reasoning_replay_tokens: Some(40),
15920            duration_ms: 1834,
15921        };
15922
15923        let value = exec_stream_value(&event).expect("serializes");
15924        let json = serde_json::to_string(&value).expect("serializes");
15925        assert!(!json.contains('\n'));
15926        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15927        assert_eq!(parsed["type"], "turn_usage");
15928        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15929        assert_eq!(parsed["schema_version"], 1);
15930        assert_eq!(parsed["turn"], 2);
15931        assert_eq!(parsed["input_tokens"], 1200);
15932        assert_eq!(parsed["output_tokens"], 180);
15933        assert_eq!(parsed["reasoning_tokens"], 90);
15934        assert_eq!(parsed["prompt_cache_hit_tokens"], 900);
15935        assert_eq!(parsed["prompt_cache_miss_tokens"], 300);
15936        assert_eq!(parsed["prompt_cache_write_tokens"], 0);
15937        assert_eq!(parsed["reasoning_replay_tokens"], 40);
15938        assert_eq!(parsed["duration_ms"], 1834);
15939    }
15940
15941    #[test]
15942    fn exec_stream_turn_usage_event_omits_unreported_fields() {
15943        // Honest absence: optional token fields the provider did not report
15944        // are dropped from the object entirely — never emitted as null and
15945        // never backfilled with fabricated zeros.
15946        let event = ExecStreamEvent::TurnUsage {
15947            turn: 1,
15948            input_tokens: 11,
15949            output_tokens: 3,
15950            reasoning_tokens: None,
15951            prompt_cache_hit_tokens: None,
15952            prompt_cache_miss_tokens: None,
15953            prompt_cache_write_tokens: None,
15954            reasoning_replay_tokens: None,
15955            duration_ms: 250,
15956        };
15957
15958        let value = exec_stream_value(&event).expect("serializes");
15959        let parsed = value;
15960        assert_eq!(parsed["type"], "turn_usage");
15961        assert_eq!(parsed["input_tokens"], 11);
15962        assert_eq!(parsed["output_tokens"], 3);
15963        assert_eq!(parsed["duration_ms"], 250);
15964        let object = parsed.as_object().expect("event object");
15965        for absent in [
15966            "reasoning_tokens",
15967            "prompt_cache_hit_tokens",
15968            "prompt_cache_miss_tokens",
15969            "prompt_cache_write_tokens",
15970            "reasoning_replay_tokens",
15971        ] {
15972            assert!(!object.contains_key(absent), "{absent} leaked: {parsed}");
15973        }
15974    }
15975
15976    #[test]
15977    fn exec_stream_pre_existing_event_type_tags_are_unchanged() {
15978        // Contract guard for existing stream consumers (bench harness, fleet
15979        // ledger): the pre-turn_usage event vocabulary keeps its exact tags.
15980        let cases: Vec<(ExecStreamEvent, &str)> = vec![
15981            (
15982                ExecStreamEvent::Content {
15983                    content: "hi".to_string(),
15984                },
15985                "content",
15986            ),
15987            (
15988                ExecStreamEvent::ToolUse {
15989                    name: "read_file".to_string(),
15990                    id: "call_1".to_string(),
15991                    input: serde_json::json!({}),
15992                    started_at: "2026-08-03T00:00:00Z".to_string(),
15993                },
15994                "tool_use",
15995            ),
15996            (
15997                ExecStreamEvent::ToolResult {
15998                    id: "call_1".to_string(),
15999                    name: "read_file".to_string(),
16000                    output: "ok".to_string(),
16001                    status: "success".to_string(),
16002                    started_at: "2026-08-03T00:00:00Z".to_string(),
16003                    completed_at: "2026-08-03T00:00:01Z".to_string(),
16004                    duration_ms: 1,
16005                    side_effect_status: "unknown".to_string(),
16006                    error_category: None,
16007                    truncated: None,
16008                    artifact: None,
16009                    result_metadata: None,
16010                },
16011                "tool_result",
16012            ),
16013            (
16014                ExecStreamEvent::SandboxDenied {
16015                    tool_id: "call_1".to_string(),
16016                    tool_name: "exec_shell".to_string(),
16017                    reason: "denied".to_string(),
16018                    outcome: "approval_required".to_string(),
16019                },
16020                "sandbox_denied",
16021            ),
16022            (
16023                ExecStreamEvent::WorkflowEvent {
16024                    run_id: "workflow_1".to_string(),
16025                    event: serde_json::json!({"type": "task_completed"}),
16026                },
16027                "workflow_event",
16028            ),
16029            (
16030                ExecStreamEvent::SessionCapture {
16031                    content: "x".to_string(),
16032                },
16033                "session_capture",
16034            ),
16035            (
16036                ExecStreamEvent::Error {
16037                    error: "boom".to_string(),
16038                },
16039                "error",
16040            ),
16041            (ExecStreamEvent::Done, "done"),
16042        ];
16043
16044        for (event, expected_type) in cases {
16045            let value = exec_stream_value(&event).expect("serializes");
16046            assert_eq!(value["type"], expected_type, "event tag drifted");
16047            assert_eq!(value["schema"], "codewhale.exec-stream");
16048            assert_eq!(value["schema_version"], 1);
16049        }
16050    }
16051
16052    #[test]
16053    fn exec_stream_events_are_json_lines() {
16054        let event = ExecStreamEvent::ToolResult {
16055            id: "call_1".to_string(),
16056            name: "read_file".to_string(),
16057            output: "line 1\nline 2".to_string(),
16058            status: "success".to_string(),
16059            started_at: "2026-07-13T00:00:00Z".to_string(),
16060            completed_at: "2026-07-13T00:00:01Z".to_string(),
16061            duration_ms: 1000,
16062            side_effect_status: "not_started".to_string(),
16063            error_category: None,
16064            truncated: Some(false),
16065            artifact: None,
16066            result_metadata: None,
16067        };
16068
16069        let value = exec_stream_value(&event).expect("serializes");
16070        let json = serde_json::to_string(&value).expect("serializes");
16071        assert!(!json.contains('\n'));
16072        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
16073        assert_eq!(parsed["type"], "tool_result");
16074        assert_eq!(parsed["schema"], "codewhale.exec-stream");
16075        assert_eq!(parsed["schema_version"], 1);
16076        assert_eq!(parsed["duration_ms"], 1000);
16077        assert_eq!(parsed["side_effect_status"], "not_started");
16078    }
16079
16080    #[test]
16081    fn workflow_receipt_stream_event_is_one_json_line() {
16082        let event = ExecStreamEvent::WorkflowEvent {
16083            run_id: "workflow_1234".to_string(),
16084            event: serde_json::json!({
16085                "type": "handoff_promoted",
16086                "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
16087                "gate_id": "review-gate",
16088                "kind": "review_report",
16089                "from_role": "reviewer",
16090                "to_role": "verifier",
16091                "producer_task_id": "agent_1"
16092            }),
16093        };
16094
16095        let value = exec_stream_value(&event).expect("serializes");
16096        let json = serde_json::to_string(&value).expect("serializes");
16097        assert!(!json.contains('\n'));
16098        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
16099        assert_eq!(parsed["type"], "workflow_event");
16100        assert_eq!(parsed["schema"], "codewhale.exec-stream");
16101        assert_eq!(parsed["schema_version"], 1);
16102        assert_eq!(parsed["run_id"], "workflow_1234");
16103        assert_eq!(parsed["event"]["type"], "handoff_promoted");
16104        assert_eq!(
16105            parsed["event"]["artifact_id"],
16106            "workflow_1234:agent_1:review-gate:review_report"
16107        );
16108        assert_eq!(parsed["event"]["gate_id"], "review-gate");
16109        assert_eq!(parsed["event"]["kind"], "review_report");
16110        assert_eq!(parsed["event"]["from_role"], "reviewer");
16111        assert_eq!(parsed["event"]["to_role"], "verifier");
16112        assert_eq!(parsed["event"]["producer_task_id"], "agent_1");
16113        assert!(parsed["event"].get("payload").is_none(), "{parsed}");
16114
16115        let consumed = ExecStreamEvent::WorkflowEvent {
16116            run_id: "workflow_1234".to_string(),
16117            event: serde_json::json!({
16118                "type": "handoff_consumed",
16119                "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
16120                "kind": "review_report",
16121                "from_role": "reviewer",
16122                "to_role": "verifier",
16123                "consumer_task_id": "agent_2"
16124            }),
16125        };
16126        let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt");
16127        assert_eq!(consumed["type"], "workflow_event");
16128        assert_eq!(consumed["schema"], "codewhale.exec-stream");
16129        assert_eq!(consumed["schema_version"], 1);
16130        assert_eq!(consumed["event"]["type"], "handoff_consumed");
16131        assert_eq!(
16132            consumed["event"]["artifact_id"],
16133            "workflow_1234:agent_1:review-gate:review_report"
16134        );
16135        assert_eq!(consumed["event"]["consumer_task_id"], "agent_2");
16136        assert!(consumed["event"].get("payload").is_none(), "{consumed}");
16137    }
16138
16139    #[test]
16140    fn exec_stream_metadata_redacts_resume_breadcrumbs() {
16141        let raw_session_id = "abc123fullsecret";
16142        let event = ExecStreamEvent::Metadata {
16143            meta: Box::new(ExecStreamMeta {
16144                receipt_kind: "terminal",
16145                provider: "deepseek".to_string(),
16146                provider_id: None,
16147                model: "deepseek-v4-flash".to_string(),
16148                route_source: "explicit_or_configured".to_string(),
16149                input_tokens: Some(123),
16150                output_tokens: Some(45),
16151                prompt_cache_hit_tokens: Some(10),
16152                prompt_cache_miss_tokens: None,
16153                prompt_cache_write_tokens: None,
16154                reasoning_tokens: Some(3),
16155                codewhale_max_output_tokens: Some(384_000),
16156                codewhale_max_output_tokens_source: Some("documented"),
16157                duration_ms: 2500,
16158                retry_count: None,
16159                approval_posture: "ask".to_string(),
16160                sandbox_posture: "configured_default".to_string(),
16161                binary_sha256: Some("sha256:binary".to_string()),
16162                config_sha256: None,
16163                prompt_sha256: "sha256:prompt".to_string(),
16164                tool_catalog_sha256: Some("sha256:tools".to_string()),
16165                input_analysis: ExecStreamInputAnalysis::default(),
16166                visible_final_answer_chars: 17,
16167                session_id: exec_stream_session_ref(raw_session_id),
16168                resume_command: exec_stream_resume_hint(raw_session_id),
16169                workspace: "/tmp/work".to_string(),
16170                message_count: 4,
16171                status: Some("completed".to_string()),
16172                termination_reason: Some("resolved".to_string()),
16173                error_category: None,
16174                error: None,
16175            }),
16176        };
16177
16178        let json = serde_json::to_string(&event).expect("serializes");
16179        assert!(!json.contains('\n'));
16180        assert!(!json.contains(raw_session_id));
16181        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
16182        assert_eq!(parsed["type"], "metadata");
16183        assert_ne!(parsed["meta"]["session_id"], raw_session_id);
16184        assert!(
16185            parsed["meta"]["session_id"]
16186                .as_str()
16187                .unwrap()
16188                .starts_with("<redacted:")
16189        );
16190        assert_eq!(
16191            parsed["meta"]["resume_command"],
16192            "codewhale exec --resume <redacted-session-id>"
16193        );
16194        assert_eq!(parsed["meta"]["workspace"], "/tmp/work");
16195        assert_eq!(parsed["meta"]["message_count"], 4);
16196        assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17);
16197
16198        let capture = ExecStreamEvent::SessionCapture {
16199            content: exec_stream_session_ref(raw_session_id),
16200        };
16201        let capture_json = serde_json::to_string(&capture).expect("serializes");
16202        assert!(!capture_json.contains(raw_session_id));
16203        let parsed_capture: serde_json::Value =
16204            serde_json::from_str(&capture_json).expect("valid json");
16205        assert_eq!(parsed_capture["type"], "session_capture");
16206        assert_ne!(parsed_capture["content"], raw_session_id);
16207    }
16208
16209    #[test]
16210    fn exec_stream_input_analysis_reports_prompt_composition() {
16211        let system = SystemPrompt::Text("system rules".to_string());
16212        let messages = vec![
16213            Message {
16214                role: "user".to_string(),
16215                content: vec![ContentBlock::Text {
16216                    text: "run tests".to_string(),
16217                    cache_control: None,
16218                }],
16219            },
16220            Message {
16221                role: "assistant".to_string(),
16222                content: vec![
16223                    ContentBlock::thinking("checking context"),
16224                    ContentBlock::Text {
16225                        text: "working".to_string(),
16226                        cache_control: None,
16227                    },
16228                    ContentBlock::ToolUse {
16229                        id: "call-1".to_string(),
16230                        name: "exec_shell".to_string(),
16231                        input: serde_json::json!({"command": "cargo test"}),
16232                        caller: None,
16233                        thought_signature: None,
16234                    },
16235                ],
16236            },
16237            Message {
16238                role: "user".to_string(),
16239                content: vec![ContentBlock::ToolResult {
16240                    tool_use_id: "call-1".to_string(),
16241                    content: "stdout line\nstderr line".to_string(),
16242                    is_error: Some(false),
16243                    content_blocks: Some(vec![serde_json::json!({
16244                        "type": "text",
16245                        "text": "structured output"
16246                    })]),
16247                }],
16248            },
16249        ];
16250
16251        let analysis = exec_stream_input_analysis(&messages, Some(&system));
16252
16253        assert_eq!(analysis.user_message_count, 2);
16254        assert_eq!(analysis.assistant_message_count, 1);
16255        assert_eq!(analysis.tool_message_count, 0);
16256        assert_eq!(analysis.tool_use_count, 1);
16257        assert_eq!(analysis.tool_result_count, 1);
16258        assert_eq!(analysis.thinking_chars, "checking context".chars().count());
16259        assert!(analysis.text_chars >= "run testsworking".chars().count());
16260        assert!(analysis.tool_use_input_chars > 0);
16261        assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count());
16262        assert!(analysis.estimated_system_tokens > 0);
16263        assert!(analysis.estimated_message_content_tokens > 0);
16264        assert!(
16265            analysis.estimated_request_tokens
16266                >= analysis.estimated_system_tokens
16267                    + analysis.estimated_message_content_tokens
16268                    + analysis.estimated_framing_tokens
16269        );
16270    }
16271
16272    #[test]
16273    fn review_receipt_check_public_json_omits_private_details() {
16274        let validation = crate::tools::review::ReviewReceiptValidation {
16275            passed: false,
16276            reason: "secret reason with /tmp/private/receipt.json".to_string(),
16277            diff_fingerprint: "sha256:current".to_string(),
16278            receipt_fingerprint: Some("sha256:current".to_string()),
16279            receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")),
16280            unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk {
16281                unresolved: true,
16282                level: "error".to_string(),
16283                summary: "secret summary".to_string(),
16284            }),
16285        };
16286
16287        let public = review_receipt_validation_public_json(&validation);
16288        let encoded = serde_json::to_string(&public).expect("public json");
16289
16290        assert_eq!(public["passed"], false);
16291        assert_eq!(public["status"], "unresolved_risk");
16292        assert_eq!(public["risk_level"], "error");
16293        assert!(!encoded.contains("secret"));
16294        assert!(!encoded.contains("/tmp/private"));
16295    }
16296
16297    #[test]
16298    fn exec_text_session_breadcrumbs_use_compact_ids() {
16299        let session_id = "1234567890abcdef";
16300
16301        assert_eq!(exec_saved_session_line(session_id), "session: 12345678");
16302        assert_eq!(
16303            exec_resumed_session_line(session_id),
16304            "resumed session: 12345678"
16305        );
16306        assert!(!exec_saved_session_line(session_id).contains(session_id));
16307        assert!(!exec_resumed_session_line(session_id).contains(session_id));
16308    }
16309
16310    #[test]
16311    fn alternate_screen_defaults_on_in_auto_mode() {
16312        let cli = parse_cli(&["codewhale"]);
16313        let config = Config::default();
16314
16315        assert!(should_use_alt_screen(&cli, &config));
16316    }
16317
16318    #[test]
16319    fn removed_no_alt_screen_flag_is_rejected() {
16320        // Negative test: the retired compatibility flag must not be silently
16321        // accepted and must not reach the alternate-screen decision at all.
16322        let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"])
16323            .expect_err("--no-alt-screen must no longer parse");
16324        assert_eq!(
16325            error.kind(),
16326            clap::error::ErrorKind::UnknownArgument,
16327            "retired flag should fail as an unknown argument, not be absorbed"
16328        );
16329    }
16330
16331    #[test]
16332    fn config_never_is_accepted_but_keeps_alternate_screen() {
16333        let cli = parse_cli(&["codewhale"]);
16334        let config = Config {
16335            tui: Some(crate::config::TuiConfig {
16336                alternate_screen: Some("never".to_string()),
16337                mouse_capture: None,
16338                terminal_probe_timeout_ms: None,
16339                stream_chunk_timeout_secs: None,
16340                status_items: None,
16341                osc8_links: None,
16342                composer_arrows_scroll: None,
16343                notification_condition: None,
16344                header_items: None,
16345            }),
16346            ..Config::default()
16347        };
16348
16349        assert!(should_use_alt_screen(&cli, &config));
16350    }
16351
16352    #[test]
16353    #[cfg(not(windows))]
16354    fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
16355        let cli = parse_cli(&["codewhale"]);
16356        let config = Config::default();
16357
16358        assert!(should_use_mouse_capture_with(
16359            &cli, &config, true, None, None, None
16360        ));
16361    }
16362
16363    #[test]
16364    #[cfg(windows)]
16365    fn mouse_capture_defaults_off_on_legacy_windows_console() {
16366        // Legacy conhost (no `WT_SESSION` and no `ConEmuPID`) keeps the
16367        // v0.8.x default-off behavior: mouse-mode reporting on legacy console
16368        // can leak SGR escapes into the composer.
16369        let cli = parse_cli(&["codewhale"]);
16370        let config = Config::default();
16371
16372        assert!(!should_use_mouse_capture_with(
16373            &cli, &config, true, None, None, None
16374        ));
16375    }
16376
16377    // #1169: Windows Terminal sets `WT_SESSION` and handles mouse-mode
16378    // reporting cleanly, so default-on there gives users in-app text
16379    // selection (and the side-effect of clamping selection to the
16380    // transcript region instead of the terminal painting across the
16381    // sidebar via native selection).
16382    #[test]
16383    #[cfg(windows)]
16384    fn mouse_capture_defaults_on_in_windows_terminal() {
16385        let cli = parse_cli(&["codewhale"]);
16386        let config = Config::default();
16387
16388        assert!(should_use_mouse_capture_with(
16389            &cli,
16390            &config,
16391            true,
16392            None,
16393            Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
16394            None,
16395        ));
16396    }
16397
16398    // ConEmu/Cmder sets `ConEmuPID` and handles VT mouse-mode reporting
16399    // cleanly; default mouse capture on there so users get in-app scrolling.
16400    #[test]
16401    #[cfg(windows)]
16402    fn mouse_capture_defaults_on_in_conemu() {
16403        let cli = parse_cli(&["codewhale"]);
16404        let config = Config::default();
16405
16406        assert!(should_use_mouse_capture_with(
16407            &cli,
16408            &config,
16409            true,
16410            None,
16411            None,
16412            Some("12345"),
16413        ));
16414    }
16415
16416    #[test]
16417    fn no_mouse_capture_flag_disables_mouse_capture() {
16418        let cli = parse_cli(&["codewhale", "--no-mouse-capture"]);
16419        let config = Config::default();
16420
16421        assert!(!should_use_mouse_capture_with(
16422            &cli, &config, true, None, None, None
16423        ));
16424    }
16425
16426    #[test]
16427    fn config_can_disable_default_mouse_capture() {
16428        let cli = parse_cli(&["codewhale"]);
16429        let config = Config {
16430            tui: Some(crate::config::TuiConfig {
16431                alternate_screen: None,
16432                mouse_capture: Some(false),
16433                terminal_probe_timeout_ms: None,
16434                stream_chunk_timeout_secs: None,
16435                status_items: None,
16436                osc8_links: None,
16437                composer_arrows_scroll: None,
16438                notification_condition: None,
16439                header_items: None,
16440            }),
16441            ..Config::default()
16442        };
16443
16444        assert!(!should_use_mouse_capture_with(
16445            &cli, &config, true, None, None, None
16446        ));
16447    }
16448
16449    #[test]
16450    fn mouse_capture_flag_enables_mouse_capture() {
16451        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16452        let config = Config::default();
16453
16454        assert!(should_use_mouse_capture_with(
16455            &cli, &config, true, None, None, None
16456        ));
16457    }
16458
16459    #[test]
16460    fn config_can_enable_mouse_capture() {
16461        let cli = parse_cli(&["codewhale"]);
16462        let config = Config {
16463            tui: Some(crate::config::TuiConfig {
16464                alternate_screen: None,
16465                mouse_capture: Some(true),
16466                terminal_probe_timeout_ms: None,
16467                stream_chunk_timeout_secs: None,
16468                status_items: None,
16469                osc8_links: None,
16470                composer_arrows_scroll: None,
16471                notification_condition: None,
16472                header_items: None,
16473            }),
16474            ..Config::default()
16475        };
16476
16477        assert!(should_use_mouse_capture_with(
16478            &cli, &config, true, None, None, None
16479        ));
16480    }
16481
16482    #[test]
16483    fn mouse_capture_is_off_without_alternate_screen() {
16484        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16485        let config = Config::default();
16486
16487        assert!(!should_use_mouse_capture_with(
16488            &cli, &config, false, None, None, None
16489        ));
16490    }
16491
16492    // Issue #878 / #898: JetBrains JediTerm advertises mouse support but
16493    // forwards SGR mouse-event escapes as raw input characters, producing
16494    // the "input box auto-fills with garbled characters when I move the
16495    // mouse" failure mode in PyCharm/IDEA terminals. Default the capture
16496    // off when we see TERMINAL_EMULATOR=JetBrains-JediTerm; explicit
16497    // config / --mouse-capture still wins.
16498
16499    #[test]
16500    fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
16501        let cli = parse_cli(&["codewhale"]);
16502        let config = Config::default();
16503
16504        assert!(!should_use_mouse_capture_with(
16505            &cli,
16506            &config,
16507            true,
16508            Some("JetBrains-JediTerm"),
16509            None,
16510            None,
16511        ));
16512    }
16513
16514    #[test]
16515    fn jetbrains_default_off_is_case_insensitive() {
16516        let cli = parse_cli(&["codewhale"]);
16517        let config = Config::default();
16518
16519        // JetBrains has occasionally varied the casing across releases;
16520        // a case-insensitive match keeps the protection in place.
16521        assert!(!should_use_mouse_capture_with(
16522            &cli,
16523            &config,
16524            true,
16525            Some("jetbrains-jediterm"),
16526            None,
16527            None,
16528        ));
16529    }
16530
16531    #[test]
16532    fn mouse_capture_flag_overrides_jetbrains_default() {
16533        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16534        let config = Config::default();
16535
16536        assert!(should_use_mouse_capture_with(
16537            &cli,
16538            &config,
16539            true,
16540            Some("JetBrains-JediTerm"),
16541            None,
16542            None,
16543        ));
16544    }
16545
16546    #[test]
16547    fn config_mouse_capture_true_overrides_jetbrains_default() {
16548        let cli = parse_cli(&["codewhale"]);
16549        let config = Config {
16550            tui: Some(crate::config::TuiConfig {
16551                alternate_screen: None,
16552                mouse_capture: Some(true),
16553                terminal_probe_timeout_ms: None,
16554                stream_chunk_timeout_secs: None,
16555                status_items: None,
16556                osc8_links: None,
16557                composer_arrows_scroll: None,
16558                notification_condition: None,
16559                header_items: None,
16560            }),
16561            ..Config::default()
16562        };
16563
16564        assert!(should_use_mouse_capture_with(
16565            &cli,
16566            &config,
16567            true,
16568            Some("JetBrains-JediTerm"),
16569            None,
16570            None,
16571        ));
16572    }
16573}
16574
16575#[cfg(test)]
16576mod interactive_startup_tests {
16577    use super::*;
16578
16579    #[test]
16580    fn interactive_tui_defaults_agent_shell_to_approval_gated_on() {
16581        let default_config = Config::default();
16582        assert!(
16583            interactive_tui_allow_shell(false, &default_config),
16584            "interactive Agent mode should expose shell tools by default so approvals can gate commands"
16585        );
16586
16587        let disabled = Config {
16588            allow_shell: Some(false),
16589            ..Config::default()
16590        };
16591        assert!(
16592            !interactive_tui_allow_shell(false, &disabled),
16593            "explicit allow_shell=false still hides shell tools"
16594        );
16595
16596        assert!(
16597            interactive_tui_allow_shell(true, &disabled),
16598            "YOLO forces shell access for its no-guardrails contract"
16599        );
16600    }
16601}
16602
16603#[cfg(test)]
16604mod project_config_tests {
16605    use super::*;
16606    use std::fs;
16607    use tempfile::tempdir;
16608
16609    /// Write a `<workspace>/.deepseek/config.toml` and return the workspace
16610    /// root so the merge function can find it.
16611    fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
16612        let tmp = tempdir().expect("tempdir");
16613        let project_dir = tmp.path().join(".deepseek");
16614        fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
16615        fs::write(project_dir.join("config.toml"), body).expect("write project config");
16616        tmp
16617    }
16618
16619    #[cfg(unix)]
16620    #[test]
16621    fn project_overlay_rejects_symlinked_primary_config() {
16622        let workspace = tempdir().expect("workspace tempdir");
16623        let outside = tempdir().expect("outside tempdir");
16624        let primary_dir = workspace.path().join(codewhale_config::CODEWHALE_APP_DIR);
16625        let legacy_dir = workspace.path().join(codewhale_config::LEGACY_APP_DIR);
16626        fs::create_dir_all(&primary_dir).expect("mkdir primary");
16627        fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
16628        let outside_config = outside.path().join("config.toml");
16629        fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
16630        fs::write(legacy_dir.join("config.toml"), "model = \"legacy-model\"\n")
16631            .expect("write legacy config");
16632        std::os::unix::fs::symlink(&outside_config, primary_dir.join("config.toml"))
16633            .expect("symlink project config");
16634        let mut config = Config {
16635            default_text_model: Some("base-model".to_string()),
16636            ..Config::default()
16637        };
16638
16639        merge_project_config(&mut config, workspace.path());
16640
16641        assert_eq!(
16642            config.default_text_model.as_deref(),
16643            Some("base-model"),
16644            "symlinked primary project config should stop the project overlay"
16645        );
16646    }
16647
16648    fn with_home_dir<T>(home: &Path, f: impl FnOnce() -> T) -> T {
16649        let prev_home = std::env::var_os("HOME");
16650        let prev_userprofile = std::env::var_os("USERPROFILE");
16651        unsafe {
16652            std::env::set_var("HOME", home);
16653            std::env::set_var("USERPROFILE", home);
16654        }
16655        let result = f();
16656        unsafe {
16657            match prev_home {
16658                Some(value) => std::env::set_var("HOME", value),
16659                None => std::env::remove_var("HOME"),
16660            }
16661            match prev_userprofile {
16662                Some(value) => std::env::set_var("USERPROFILE", value),
16663                None => std::env::remove_var("USERPROFILE"),
16664            }
16665        }
16666        result
16667    }
16668
16669    #[test]
16670    fn project_overlay_skips_when_workspace_is_home_directory() {
16671        let _guard = crate::test_support::lock_test_env();
16672        let tmp = tempdir().expect("tempdir");
16673        let project_dir = tmp.path().join(codewhale_config::CODEWHALE_APP_DIR);
16674        fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
16675        fs::write(
16676            project_dir.join("config.toml"),
16677            r#"model = "project-override-model""#,
16678        )
16679        .expect("write project config");
16680
16681        with_home_dir(tmp.path(), || {
16682            let mut config = Config {
16683                default_text_model: Some("deepseek-v4-flash".to_string()),
16684                ..Config::default()
16685            };
16686
16687            merge_project_config(&mut config, tmp.path());
16688
16689            assert_eq!(
16690                config.default_text_model.as_deref(),
16691                Some("deepseek-v4-flash")
16692            );
16693        });
16694    }
16695
16696    #[test]
16697    fn project_overlay_overrides_model_but_denies_provider() {
16698        // #417: `provider` is on the deny-list; only the `model`
16699        // override applies. The denied key emits a stderr warning
16700        // (verified by integration runs; here we assert the post-
16701        // merge state).
16702        let tmp = workspace_with_project_config(
16703            r#"
16704provider = "nvidia-nim"
16705model = "deepseek-ai/deepseek-v4-pro"
16706"#,
16707        );
16708        let mut config = Config::default();
16709        merge_project_config(&mut config, tmp.path());
16710        assert_eq!(
16711            config.provider, None,
16712            "#417: project-scope `provider` must be denied"
16713        );
16714        assert_eq!(
16715            config.default_text_model.as_deref(),
16716            Some("deepseek-ai/deepseek-v4-pro"),
16717            "model is allowed at project scope"
16718        );
16719    }
16720
16721    #[test]
16722    fn project_overlay_denies_dangerous_credentials_and_redirects() {
16723        // #417: `api_key` / `base_url` / `provider` / `mcp_config_path`
16724        // and MCP OAuth callback settings are all on the deny-list. A
16725        // malicious project must not be able to redirect prompts, hijack MCP
16726        // servers, or influence OAuth callback behavior via these.
16727        let tmp = workspace_with_project_config(
16728            r#"
16729api_key = "ATTACKER_KEY"
16730base_url = "https://evil.example.com"
16731provider = "nvidia-nim"
16732mcp_config_path = "/tmp/attacker-mcp.json"
16733mcp_oauth_callback_port = 9999
16734mcp_oauth_callback_url = "http://evil.example.com/callback"
16735"#,
16736        );
16737        let mut config = Config {
16738            api_key: Some("USER_KEY".to_string()),
16739            base_url: Some("https://api.deepseek.com".to_string()),
16740            mcp_oauth_callback_port: Some(1455),
16741            mcp_oauth_callback_url: Some("http://127.0.0.1:1455/callback".to_string()),
16742            ..Config::default()
16743        };
16744        merge_project_config(&mut config, tmp.path());
16745        assert_eq!(
16746            config.api_key.as_deref(),
16747            Some("USER_KEY"),
16748            "user api_key must survive project-config attack"
16749        );
16750        assert_eq!(
16751            config.base_url.as_deref(),
16752            Some("https://api.deepseek.com"),
16753            "user base_url must survive project-config attack"
16754        );
16755        assert_eq!(
16756            config.provider, None,
16757            "project-scope provider must be denied"
16758        );
16759        assert_eq!(
16760            config.mcp_config_path, None,
16761            "project-scope mcp_config_path must be denied"
16762        );
16763        assert_eq!(
16764            config.mcp_oauth_callback_port,
16765            Some(1455),
16766            "project-scope mcp_oauth_callback_port must be denied"
16767        );
16768        assert_eq!(
16769            config.mcp_oauth_callback_url.as_deref(),
16770            Some("http://127.0.0.1:1455/callback"),
16771            "project-scope mcp_oauth_callback_url must be denied"
16772        );
16773    }
16774
16775    #[test]
16776    fn project_overlay_overrides_approval_and_sandbox() {
16777        let tmp = workspace_with_project_config(
16778            r#"
16779approval_policy = "never"
16780sandbox_mode = "read-only"
16781"#,
16782        );
16783        let mut config = Config::default();
16784        merge_project_config(&mut config, tmp.path());
16785        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16786        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16787    }
16788
16789    #[test]
16790    fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
16791        // #417 value-deny: the loosest values (`approval_policy = "auto"`,
16792        // `sandbox_mode = "danger-full-access"`) are pure escalation.
16793        // Even when the user hasn't set these fields, the project
16794        // can't push the session to the loosest posture.
16795        let tmp = workspace_with_project_config(
16796            r#"
16797approval_policy = "auto"
16798sandbox_mode = "danger-full-access"
16799model = "deepseek-v4-pro"
16800"#,
16801        );
16802        let mut config = Config::default();
16803        merge_project_config(&mut config, tmp.path());
16804        assert_eq!(
16805            config.approval_policy, None,
16806            "project-scope `approval_policy = \"auto\"` must be denied"
16807        );
16808        assert_eq!(
16809            config.sandbox_mode, None,
16810            "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
16811        );
16812        // Non-escalation overrides on the same merge succeed —
16813        // the deny is per-key, not per-file.
16814        assert_eq!(
16815            config.default_text_model.as_deref(),
16816            Some("deepseek-v4-pro"),
16817            "non-escalation overrides should still apply"
16818        );
16819    }
16820
16821    #[test]
16822    fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
16823        // Belt-and-suspenders: if the user has `approval_policy = "never"`
16824        // and the project tries `approval_policy = "auto"`, the deny
16825        // keeps the user's strict value rather than falling through to
16826        // None.
16827        let tmp = workspace_with_project_config(
16828            r#"
16829approval_policy = "auto"
16830"#,
16831        );
16832        let mut config = Config {
16833            approval_policy: Some("never".to_string()),
16834            ..Config::default()
16835        };
16836        merge_project_config(&mut config, tmp.path());
16837        assert_eq!(
16838            config.approval_policy.as_deref(),
16839            Some("never"),
16840            "user's strict approval_policy must survive a project escalation attempt"
16841        );
16842    }
16843
16844    #[test]
16845    fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
16846        let tmp = workspace_with_project_config(
16847            r#"
16848approval_policy = "on-request"
16849sandbox_mode = "workspace-write"
16850"#,
16851        );
16852        let mut config = Config {
16853            approval_policy: Some("never".to_string()),
16854            sandbox_mode: Some("read-only".to_string()),
16855            ..Config::default()
16856        };
16857        merge_project_config(&mut config, tmp.path());
16858        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16859        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16860    }
16861
16862    #[test]
16863    fn project_overlay_can_tighten_user_policy() {
16864        let tmp = workspace_with_project_config(
16865            r#"
16866approval_policy = "never"
16867sandbox_mode = "read-only"
16868"#,
16869        );
16870        let mut config = Config {
16871            approval_policy: Some("on-request".to_string()),
16872            sandbox_mode: Some("workspace-write".to_string()),
16873            ..Config::default()
16874        };
16875        merge_project_config(&mut config, tmp.path());
16876        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16877        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16878    }
16879
16880    #[test]
16881    fn project_overlay_can_tighten_saved_full_access_posture() {
16882        let tmp = workspace_with_project_config(
16883            r#"
16884approval_policy = "on-request"
16885"#,
16886        );
16887        let mut config = Config::default();
16888
16889        merge_project_config_with_approval_baseline(&mut config, tmp.path(), Some("full-access"));
16890
16891        assert_eq!(
16892            config.approval_policy.as_deref(),
16893            Some("on-request"),
16894            "a project may tighten the saved Full Access baseline to Ask"
16895        );
16896    }
16897
16898    #[test]
16899    fn project_overlay_overrides_max_subagents_and_can_disable_shell() {
16900        let tmp = workspace_with_project_config(
16901            r#"
16902max_subagents = 4
16903allow_shell = false
16904"#,
16905        );
16906        let mut config = Config::default();
16907        merge_project_config(&mut config, tmp.path());
16908        assert_eq!(config.max_subagents, Some(4));
16909        assert_eq!(config.allow_shell, Some(false));
16910    }
16911
16912    #[test]
16913    fn project_overlay_cannot_enable_shell() {
16914        let tmp = workspace_with_project_config(
16915            r#"
16916allow_shell = true
16917"#,
16918        );
16919        let mut config = Config {
16920            allow_shell: Some(false),
16921            ..Config::default()
16922        };
16923        merge_project_config(&mut config, tmp.path());
16924        assert_eq!(
16925            config.allow_shell,
16926            Some(false),
16927            "project overlay must not loosen shell access"
16928        );
16929    }
16930
16931    #[test]
16932    fn missing_user_config_is_absent_not_an_error() {
16933        let tmp = tempdir().expect("tempdir");
16934        let missing = tmp.path().join("config.toml");
16935
16936        assert_eq!(
16937            read_user_config_file(&missing).expect("missing config is a normal first-run state"),
16938            None
16939        );
16940    }
16941
16942    #[test]
16943    fn existing_unreadable_user_config_remains_an_error() {
16944        let tmp = tempdir().expect("tempdir");
16945        let unreadable = tmp.path().join("config.toml");
16946        fs::create_dir(&unreadable).expect("create directory at config path");
16947
16948        assert!(
16949            read_user_config_file(&unreadable).is_err(),
16950            "an existing path that cannot be read as a config must still warn"
16951        );
16952    }
16953
16954    #[cfg(unix)]
16955    #[test]
16956    fn dangling_user_config_symlink_remains_an_error() {
16957        use std::os::unix::fs::symlink;
16958
16959        let tmp = tempdir().expect("tempdir");
16960        let missing_target = tmp.path().join("missing-target.toml");
16961        let config_path = tmp.path().join("config.toml");
16962        symlink(&missing_target, &config_path).expect("create dangling config symlink");
16963
16964        assert!(
16965            read_user_config_file(&config_path).is_err(),
16966            "a dangling symlink is an existing but unreadable config and must still warn"
16967        );
16968    }
16969
16970    #[test]
16971    fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
16972        let tmp = tempdir().expect("tempdir");
16973        let workspace = tmp.path().join("project");
16974        fs::create_dir_all(&workspace).expect("mkdir workspace");
16975        let raw = format!(
16976            "[workspace.'{}']\nallow_shell = true\n",
16977            workspace.display()
16978        );
16979        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16980
16981        let mut config = Config::default();
16982        merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16983
16984        assert_eq!(config.allow_shell, Some(true));
16985    }
16986
16987    #[test]
16988    fn exec_no_project_config_skips_user_workspace_overlay() {
16989        // #4641: `codewhale --no-project-config exec` must skip the
16990        // workspace-specific `[workspace]`/`[projects]` overlay so a headless
16991        // launch sees a reproducible config surface. This documents the overlay
16992        // the `Commands::Exec` gate skips; the end-to-end wiring is proven by
16993        // `tests/verifiers_harness_contract.rs`.
16994        let tmp = tempdir().expect("tempdir");
16995        let workspace = tmp.path().join("project");
16996        fs::create_dir_all(&workspace).expect("mkdir workspace");
16997        let raw = format!(
16998            "[workspace.'{}']\nallow_shell = true\n",
16999            workspace.display()
17000        );
17001        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
17002
17003        // Default (flag off): the overlay applies.
17004        let mut applied = Config::default();
17005        let no_project_config = false;
17006        if !no_project_config {
17007            merge_user_workspace_config_from_doc(&mut applied, &doc, &workspace);
17008        }
17009        assert_eq!(applied.allow_shell, Some(true));
17010
17011        // `--no-project-config`: Exec skips the overlay, leaving config untouched.
17012        let mut skipped = Config::default();
17013        let no_project_config = true;
17014        if !no_project_config {
17015            merge_user_workspace_config_from_doc(&mut skipped, &doc, &workspace);
17016        }
17017        assert_eq!(skipped.allow_shell, None);
17018    }
17019
17020    #[test]
17021    fn user_workspace_overlay_accepts_legacy_projects_table() {
17022        let tmp = tempdir().expect("tempdir");
17023        let workspace = tmp.path().join("project");
17024        fs::create_dir_all(&workspace).expect("mkdir workspace");
17025        let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display());
17026        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
17027
17028        let mut config = Config::default();
17029        merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
17030
17031        assert_eq!(config.allow_shell, Some(true));
17032    }
17033
17034    #[test]
17035    fn user_workspace_overlay_ignores_non_matching_workspace() {
17036        let tmp = tempdir().expect("tempdir");
17037        let configured_workspace = tmp.path().join("configured");
17038        let active_workspace = tmp.path().join("active");
17039        fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
17040        fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
17041        let raw = format!(
17042            "[workspace.'{}']\nallow_shell = true\n",
17043            configured_workspace.display()
17044        );
17045        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
17046
17047        let mut config = Config::default();
17048        merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
17049
17050        assert_eq!(config.allow_shell, None);
17051    }
17052
17053    #[test]
17054    fn user_workspace_overlay_preserves_allow_shell_env_override() {
17055        let _guard = crate::test_support::lock_test_env();
17056        let tmp = tempdir().expect("tempdir");
17057        let workspace = tmp.path().join("project");
17058        fs::create_dir_all(&workspace).expect("mkdir workspace");
17059        let config_path = tmp.path().join("config.toml");
17060        fs::write(
17061            &config_path,
17062            format!(
17063                "[workspace.'{}']\nallow_shell = true\n",
17064                workspace.display()
17065            ),
17066        )
17067        .expect("write config");
17068
17069        unsafe {
17070            std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
17071        }
17072        let mut config = Config {
17073            allow_shell: Some(false),
17074            ..Config::default()
17075        };
17076        merge_user_workspace_config(&mut config, Some(config_path), &workspace);
17077        unsafe {
17078            std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
17079        }
17080
17081        assert_eq!(config.allow_shell, Some(false));
17082    }
17083
17084    #[test]
17085    fn user_workspace_overlay_does_not_override_managed_config() {
17086        let tmp = tempdir().expect("tempdir");
17087        let workspace = tmp.path().join("project");
17088        fs::create_dir_all(&workspace).expect("mkdir workspace");
17089        let config_path = tmp.path().join("config.toml");
17090        fs::write(
17091            &config_path,
17092            format!(
17093                "[workspace.'{}']\nallow_shell = true\n",
17094                workspace.display()
17095            ),
17096        )
17097        .expect("write config");
17098
17099        let mut config = Config {
17100            allow_shell: Some(false),
17101            managed_config_path: Some("managed.toml".to_string()),
17102            ..Config::default()
17103        };
17104        merge_user_workspace_config(&mut config, Some(config_path), &workspace);
17105
17106        assert_eq!(config.allow_shell, Some(false));
17107    }
17108
17109    #[test]
17110    fn windows_config_path_compare_normalizes_mixed_separators() {
17111        assert_eq!(
17112            normalize_windows_config_path_str(r"C:\Users\me\repo"),
17113            normalize_windows_config_path_str(r"C:/Users/me/repo/")
17114        );
17115    }
17116
17117    #[test]
17118    fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() {
17119        assert_eq!(
17120            normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"),
17121            normalize_windows_config_path_str(r"C:/Users/me/repo")
17122        );
17123        assert_eq!(
17124            normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"),
17125            normalize_windows_config_path_str(r"\\server/share/repo/")
17126        );
17127    }
17128
17129    #[test]
17130    fn project_overlay_clamps_max_subagents_to_safe_range() {
17131        let tmp = workspace_with_project_config(
17132            r#"
17133max_subagents = 500
17134"#,
17135        );
17136        let mut config = Config::default();
17137        merge_project_config(&mut config, tmp.path());
17138        assert_eq!(
17139            config.max_subagents,
17140            Some(crate::config::MAX_SUBAGENTS),
17141            "should clamp to MAX_SUBAGENTS"
17142        );
17143    }
17144
17145    #[test]
17146    fn project_overlay_ignores_negative_max_subagents() {
17147        let tmp = workspace_with_project_config(
17148            r#"
17149max_subagents = -3
17150"#,
17151        );
17152        let mut config = Config::default();
17153        merge_project_config(&mut config, tmp.path());
17154        assert_eq!(config.max_subagents, None, "negative should be ignored");
17155    }
17156
17157    #[test]
17158    fn project_overlay_skips_missing_config_file() {
17159        let tmp = tempdir().expect("tempdir");
17160        let mut config = Config {
17161            provider: Some("codewhale".to_string()),
17162            ..Config::default()
17163        };
17164        merge_project_config(&mut config, tmp.path());
17165        // Untouched.
17166        assert_eq!(config.provider.as_deref(), Some("codewhale"));
17167    }
17168
17169    #[test]
17170    fn project_overlay_skips_malformed_toml() {
17171        let tmp = workspace_with_project_config("this is not valid TOML !!");
17172        let mut config = Config {
17173            provider: Some("codewhale".to_string()),
17174            ..Config::default()
17175        };
17176        merge_project_config(&mut config, tmp.path());
17177        // Untouched on parse error — better to fall back to global than crash.
17178        assert_eq!(config.provider.as_deref(), Some("codewhale"));
17179    }
17180
17181    #[test]
17182    fn project_overlay_ignores_empty_string_values() {
17183        let tmp = workspace_with_project_config(
17184            r#"
17185provider = ""
17186model = ""
17187"#,
17188        );
17189        let mut config = Config {
17190            provider: Some("codewhale".to_string()),
17191            default_text_model: Some("deepseek-v4-pro".to_string()),
17192            ..Config::default()
17193        };
17194        merge_project_config(&mut config, tmp.path());
17195        // Empty strings are ignored — they're rarely a deliberate override.
17196        assert_eq!(config.provider.as_deref(), Some("codewhale"));
17197        assert_eq!(
17198            config.default_text_model.as_deref(),
17199            Some("deepseek-v4-pro")
17200        );
17201    }
17202
17203    #[test]
17204    fn project_overlay_ignores_project_instructions_array() {
17205        let tmp = workspace_with_project_config(
17206            r#"
17207instructions = ["./AGENTS.md", "./extra.md"]
17208"#,
17209        );
17210        let user = vec!["~/global.md".to_string()];
17211        let mut config = Config {
17212            instructions: Some(user.clone()),
17213            ..Config::default()
17214        };
17215        merge_project_config(&mut config, tmp.path());
17216        assert_eq!(
17217            config.instructions.as_deref(),
17218            Some(user.as_slice()),
17219            "project overlay must not replace user-owned instructions"
17220        );
17221    }
17222
17223    #[test]
17224    fn project_overlay_empty_instructions_array_preserves_user_list() {
17225        let tmp = workspace_with_project_config(
17226            r#"
17227instructions = []
17228"#,
17229        );
17230        let user = vec!["~/global.md".to_string(), "~/team-prefs.md".to_string()];
17231        let mut config = Config {
17232            instructions: Some(user.clone()),
17233            ..Config::default()
17234        };
17235        merge_project_config(&mut config, tmp.path());
17236        assert_eq!(
17237            config.instructions.as_deref(),
17238            Some(user.as_slice()),
17239            "project overlay must not clear user-owned instructions"
17240        );
17241    }
17242
17243    #[test]
17244    fn project_overlay_preserves_user_instructions_when_field_absent() {
17245        let tmp = workspace_with_project_config(
17246            r#"
17247provider = "deepseek"
17248"#,
17249        );
17250        let user = vec!["~/global.md".to_string()];
17251        let mut config = Config {
17252            instructions: Some(user.clone()),
17253            ..Config::default()
17254        };
17255        merge_project_config(&mut config, tmp.path());
17256        // No `instructions` key in the project file → user list intact.
17257        assert_eq!(
17258            config.instructions.as_deref(),
17259            Some(user.as_slice()),
17260            "absent project field must not clobber the user list"
17261        );
17262    }
17263
17264    #[test]
17265    fn project_overlay_ignores_new_instructions_when_user_has_none() {
17266        let tmp = workspace_with_project_config(
17267            r#"
17268instructions = ["./AGENTS.md", "", "  ", "./extra.md"]
17269"#,
17270        );
17271        let mut config = Config::default();
17272        merge_project_config(&mut config, tmp.path());
17273        assert_eq!(
17274            config.instructions.as_deref(),
17275            None,
17276            "project overlay must not introduce instruction paths"
17277        );
17278    }
17279}
17280
17281#[cfg(test)]
17282mod doctor_mcp_tests {
17283    use super::*;
17284
17285    fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
17286        McpServerConfig {
17287            command: command.map(String::from),
17288            args: args.iter().map(|s| s.to_string()).collect(),
17289            env: std::collections::HashMap::new(),
17290            cwd: None,
17291            url: url.map(String::from),
17292            transport: None,
17293            connect_timeout: None,
17294            execute_timeout: None,
17295            read_timeout: None,
17296            disabled: false,
17297            enabled: true,
17298            required: false,
17299            enabled_tools: Vec::new(),
17300            disabled_tools: Vec::new(),
17301            headers: std::collections::HashMap::new(),
17302            env_headers: std::collections::HashMap::new(),
17303            bearer_token_env_var: None,
17304            scopes: Vec::new(),
17305            oauth: None,
17306            oauth_resource: None,
17307            reviewed_plugin: None,
17308        }
17309    }
17310
17311    #[test]
17312    fn test_no_command_or_url_is_error() {
17313        let server = make_server(None, &[], None);
17314        assert!(matches!(
17315            doctor_check_mcp_server(&server),
17316            McpServerDoctorStatus::Error(_)
17317        ));
17318    }
17319
17320    #[test]
17321    fn test_url_server_is_ok() {
17322        let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
17323        match doctor_check_mcp_server(&server) {
17324            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
17325            other => panic!("Expected Ok, got {other:?}"),
17326        }
17327    }
17328
17329    #[test]
17330    fn test_command_server_is_ok() {
17331        let executable = std::env::current_exe().expect("current test executable");
17332        let executable = executable.to_string_lossy();
17333        let server = make_server(Some(&executable), &["server.js"], None);
17334        match doctor_check_mcp_server(&server) {
17335            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17336            other => panic!("Expected Ok, got {other:?}"),
17337        }
17338    }
17339
17340    #[test]
17341    fn test_relative_stdio_path_arg_without_cwd_warns() {
17342        let executable = std::env::current_exe().expect("current test executable");
17343        let executable = executable.to_string_lossy();
17344        let server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17345        match doctor_check_mcp_server(&server) {
17346            McpServerDoctorStatus::Warning(detail) => {
17347                assert!(detail.contains("relative path argument"));
17348                assert!(detail.contains("cwd"));
17349            }
17350            other => panic!("Expected Warning for relative path argument, got {other:?}"),
17351        }
17352    }
17353
17354    #[test]
17355    fn test_scoped_npm_package_spec_without_cwd_is_not_a_path_warning() {
17356        let absolute_npx = if cfg!(windows) {
17357            r"C:\Program Files\nodejs\npx.cmd"
17358        } else {
17359            "/opt/homebrew/bin/npx"
17360        };
17361        for command in ["npx", "npx.cmd", absolute_npx] {
17362            let server = make_server(
17363                Some(command),
17364                &["-y", "@playwright/mcp@0.0.79", "--isolated"],
17365                None,
17366            );
17367            match doctor_check_mcp_server(&server) {
17368                McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17369                other => panic!("Expected Ok for scoped npm package via {command}, got {other:?}"),
17370            }
17371        }
17372    }
17373
17374    #[test]
17375    fn test_scoped_npm_exception_does_not_hide_relative_paths() {
17376        for (command, argument) in [
17377            ("npx", "scripts/server.js"),
17378            ("npx", "@scope/package/extra"),
17379            ("npx", "@scope/package@"),
17380            ("npx", "@scope/package@@1.0.0"),
17381            ("npx", "@.scope/package"),
17382            ("npx.cmd", "@scope/_package"),
17383            ("node", "@scope/package@1.0.0"),
17384        ] {
17385            let server = make_server(Some(command), &[argument], None);
17386            assert!(
17387                matches!(
17388                    doctor_check_mcp_server(&server),
17389                    McpServerDoctorStatus::Warning(_)
17390                ),
17391                "Expected a relative-path warning for {command} {argument}"
17392            );
17393        }
17394    }
17395
17396    #[test]
17397    fn test_relative_stdio_path_arg_with_cwd_is_ok() {
17398        let executable = std::env::current_exe().expect("current test executable");
17399        let executable = executable.to_string_lossy();
17400        let mut server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17401        server.cwd = Some(PathBuf::from("/tmp/codewhale-project"));
17402        match doctor_check_mcp_server(&server) {
17403            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17404            other => panic!("Expected Ok when cwd anchors relative path, got {other:?}"),
17405        }
17406    }
17407
17408    #[test]
17409    fn test_self_hosted_absolute_is_ok() {
17410        let executable = std::env::current_exe().expect("current test executable");
17411        let executable = executable.to_string_lossy();
17412        let server = make_server(Some(&executable), &["serve", "--mcp"], None);
17413        match doctor_check_mcp_server(&server) {
17414            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio server")),
17415            McpServerDoctorStatus::Warning(detail) => {
17416                panic!("Absolute path should not warn: {detail}")
17417            }
17418            McpServerDoctorStatus::Error(detail) => panic!("unexpected error: {detail}"),
17419        }
17420    }
17421
17422    #[cfg(test)]
17423    mod mcp_auth_guidance_tests {
17424        #[test]
17425        fn mcp_auth_hint_is_actionable_for_connect_failures() {
17426            let hint = crate::mcp::oauth::auth_required_login_hint("nordic-mcp");
17427            assert_eq!(
17428                hint,
17429                "MCP server 'nordic-mcp' requires OAuth authentication. Run `codewhale mcp login nordic-mcp` to authenticate."
17430            );
17431        }
17432    }
17433
17434    #[test]
17435    fn test_empty_command_is_error() {
17436        let server = make_server(Some(""), &[], None);
17437        assert!(matches!(
17438            doctor_check_mcp_server(&server),
17439            McpServerDoctorStatus::Error(_)
17440        ));
17441    }
17442
17443    #[test]
17444    fn doctor_json_separates_configuration_from_live_health() {
17445        let server = make_server(None, &[], Some("http://127.0.0.1:3000/mcp"));
17446        let report = doctor_mcp_server_json("tools-only", &server);
17447
17448        assert_eq!(report["check_scope"], "configuration");
17449        assert_eq!(report["checks"]["configuration"]["status"], "valid");
17450        assert_eq!(report["checks"]["command"]["status"], "not_applicable");
17451        assert_eq!(
17452            report["checks"]["process_reachable"]["status"],
17453            "not_checked"
17454        );
17455        assert_eq!(
17456            report["checks"]["protocol_initialized"]["status"],
17457            "not_checked"
17458        );
17459        assert_eq!(
17460            report["checks"]["backend_tool_health"]["status"],
17461            "not_checked"
17462        );
17463        assert!(!report.to_string().contains("healthy"));
17464    }
17465
17466    #[cfg(unix)]
17467    #[test]
17468    fn static_mcp_check_never_starts_the_configured_command() {
17469        use std::os::unix::fs::PermissionsExt;
17470
17471        let temp = tempfile::tempdir().expect("tempdir");
17472        let marker = temp.path().join("started");
17473        let script = temp.path().join("mcp-server");
17474        std::fs::write(
17475            &script,
17476            format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
17477        )
17478        .expect("write test server");
17479        let mut permissions = std::fs::metadata(&script)
17480            .expect("script metadata")
17481            .permissions();
17482        permissions.set_mode(0o755);
17483        std::fs::set_permissions(&script, permissions).expect("make script executable");
17484
17485        let script = script.to_string_lossy();
17486        let server = make_server(Some(&script), &[], None);
17487        assert!(matches!(
17488            doctor_check_mcp_server(&server),
17489            McpServerDoctorStatus::Ok(_)
17490        ));
17491        assert!(!marker.exists(), "static doctor check started MCP server");
17492    }
17493}
17494
17495#[cfg(test)]
17496mod doctor_live_probe_tests {
17497    use super::*;
17498
17499    #[test]
17500    fn local_provider_probe_requires_explicit_opt_in() {
17501        assert!(!doctor_should_probe_api(
17502            crate::config::ApiProvider::Ollama,
17503            "http://127.0.0.1:11434/v1",
17504            crate::doctor::DoctorProbeRequest::default(),
17505        ));
17506        assert!(doctor_should_probe_api(
17507            crate::config::ApiProvider::Ollama,
17508            "http://127.0.0.1:11434/v1",
17509            crate::doctor::DoctorProbeRequest {
17510                probe_local: true,
17511                ..crate::doctor::DoctorProbeRequest::default()
17512            },
17513        ));
17514    }
17515
17516    #[test]
17517    fn ollama_cloud_probe_uses_hosted_opt_in_not_local_opt_in() {
17518        let cloud = codewhale_config::provider::OLLAMA_CLOUD_BASE_URL;
17519        assert!(!doctor_should_probe_api(
17520            crate::config::ApiProvider::OllamaCloud,
17521            cloud,
17522            crate::doctor::DoctorProbeRequest::default(),
17523        ));
17524        assert!(doctor_should_probe_api(
17525            crate::config::ApiProvider::OllamaCloud,
17526            cloud,
17527            crate::doctor::DoctorProbeRequest {
17528                probe_api: true,
17529                ..crate::doctor::DoctorProbeRequest::default()
17530            },
17531        ));
17532        assert!(!doctor_should_probe_api(
17533            crate::config::ApiProvider::OllamaCloud,
17534            cloud,
17535            crate::doctor::DoctorProbeRequest {
17536                probe_local: true,
17537                ..crate::doctor::DoctorProbeRequest::default()
17538            },
17539        ));
17540    }
17541
17542    #[test]
17543    fn custom_loopback_probe_also_requires_explicit_opt_in() {
17544        assert!(!doctor_should_probe_api(
17545            crate::config::ApiProvider::Custom,
17546            "http://localhost:8000/v1",
17547            crate::doctor::DoctorProbeRequest::default(),
17548        ));
17549    }
17550
17551    #[test]
17552    fn oauth_routes_skip_live_probe_to_keep_doctor_non_mutating() {
17553        let codex = Config {
17554            provider: Some("openai-codex".to_string()),
17555            ..Config::default()
17556        };
17557        assert!(!doctor_should_probe_auth(&codex));
17558
17559        let xai = Config {
17560            provider: Some("xai".to_string()),
17561            providers: Some(crate::config::ProvidersConfig {
17562                xai: crate::config::ProviderConfig {
17563                    auth_mode: Some("oauth".to_string()),
17564                    ..Default::default()
17565                },
17566                ..Default::default()
17567            }),
17568            ..Config::default()
17569        };
17570        assert!(!doctor_should_probe_auth(&xai));
17571        assert!(doctor_should_probe_auth(&Config::default()));
17572    }
17573}
17574
17575#[cfg(test)]
17576mod setup_helper_tests {
17577    use super::*;
17578    use std::collections::BTreeSet;
17579    use tempfile::TempDir;
17580
17581    #[test]
17582    fn init_tools_dir_creates_readme_and_example() {
17583        let tmp = TempDir::new().unwrap();
17584        let dir = tmp.path().join("tools");
17585        let (returned_dir, readme_status, example_status) =
17586            init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
17587
17588        assert_eq!(returned_dir, dir);
17589        assert!(matches!(readme_status, WriteStatus::Created));
17590        assert!(matches!(example_status, WriteStatus::Created));
17591        assert!(dir.join("README.md").exists());
17592        assert!(dir.join("example.sh").exists());
17593
17594        let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
17595        assert!(
17596            readme.contains("# name:"),
17597            "README must show frontmatter convention"
17598        );
17599
17600        let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17601        assert!(example.starts_with("#!/usr/bin/env sh"));
17602        assert!(example.contains("# name: example"));
17603        assert!(example.contains("# description:"));
17604    }
17605
17606    #[test]
17607    fn init_tools_dir_skips_existing_without_force() {
17608        let tmp = TempDir::new().unwrap();
17609        let dir = tmp.path().join("tools");
17610        let _ = init_tools_dir(&dir, false).unwrap();
17611        let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
17612        assert!(matches!(readme_status, WriteStatus::SkippedExists));
17613        assert!(matches!(example_status, WriteStatus::SkippedExists));
17614    }
17615
17616    #[test]
17617    fn init_tools_dir_force_overwrites() {
17618        let tmp = TempDir::new().unwrap();
17619        let dir = tmp.path().join("tools");
17620        let _ = init_tools_dir(&dir, false).unwrap();
17621        std::fs::write(dir.join("example.sh"), "stale").unwrap();
17622        let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
17623        assert!(matches!(example_status, WriteStatus::Overwritten));
17624        let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17625        assert_ne!(example, "stale");
17626    }
17627
17628    #[test]
17629    fn init_plugins_dir_creates_readme_and_example_layout() {
17630        let tmp = TempDir::new().unwrap();
17631        let dir = tmp.path().join("plugins");
17632        let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
17633            init_plugins_dir(&dir, false).unwrap();
17634
17635        assert_eq!(readme_path, dir.join("README.md"));
17636        assert_eq!(manifest_path, dir.join("example").join("plugin.toml"));
17637        assert_eq!(
17638            skill_path,
17639            dir.join("example/skills/hello").join("SKILL.md")
17640        );
17641        assert!(matches!(readme_status, WriteStatus::Created));
17642        assert!(matches!(manifest_status, WriteStatus::Created));
17643        assert!(matches!(skill_status, WriteStatus::Created));
17644        assert!(readme_path.exists());
17645        assert!(manifest_path.exists());
17646        assert!(skill_path.exists());
17647
17648        let manifest = std::fs::read_to_string(&manifest_path).unwrap();
17649        assert!(manifest.contains("schema_version = 1"));
17650        assert!(manifest.contains("name = \"example\""));
17651        let validated =
17652            crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
17653                .expect("scaffolded plugin should validate");
17654        assert_eq!(validated.inventory.skills, 1);
17655    }
17656
17657    #[test]
17658    fn collect_clean_targets_finds_all_checkpoint_json_files() {
17659        let tmp = TempDir::new().unwrap();
17660        let dir = tmp.path();
17661        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17662        std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17663        // Per-session crash checkpoint files are clean targets too.
17664        std::fs::write(dir.join("some-session-id.json"), "{}").unwrap();
17665        // Non-JSON files and subdirectories are left alone.
17666        std::fs::write(dir.join("notes.txt"), "keep").unwrap();
17667        std::fs::create_dir_all(dir.join("subdir")).unwrap();
17668
17669        let plan = collect_clean_targets(dir);
17670        assert_eq!(plan.targets.len(), 3);
17671        assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
17672        assert!(
17673            plan.targets
17674                .iter()
17675                .any(|p| p.ends_with("offline_queue.json"))
17676        );
17677        assert!(
17678            plan.targets
17679                .iter()
17680                .any(|p| p.ends_with("some-session-id.json"))
17681        );
17682        assert!(!plan.targets.iter().any(|p| p.ends_with("notes.txt")));
17683    }
17684
17685    #[test]
17686    fn execute_clean_plan_removes_files_and_returns_them() {
17687        let tmp = TempDir::new().unwrap();
17688        let dir = tmp.path();
17689        let latest = dir.join("latest.json");
17690        let queue = dir.join("offline_queue.json");
17691        std::fs::write(&latest, "{}").unwrap();
17692        std::fs::write(&queue, "[]").unwrap();
17693
17694        let plan = collect_clean_targets(dir);
17695        let removed = execute_clean_plan(&plan).unwrap();
17696        assert_eq!(removed.len(), 2);
17697        assert!(!latest.exists());
17698        assert!(!queue.exists());
17699    }
17700
17701    #[test]
17702    fn run_setup_clean_dry_run_lists_targets_without_force() {
17703        let tmp = TempDir::new().unwrap();
17704        let dir = tmp.path();
17705        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17706        run_setup_clean(dir, false).unwrap();
17707        // Without --force, files must remain on disk.
17708        assert!(dir.join("latest.json").exists());
17709    }
17710
17711    #[test]
17712    fn run_setup_clean_force_removes_files() {
17713        let tmp = TempDir::new().unwrap();
17714        let dir = tmp.path();
17715        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17716        std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17717        run_setup_clean(dir, true).unwrap();
17718        assert!(!dir.join("latest.json").exists());
17719        assert!(!dir.join("offline_queue.json").exists());
17720    }
17721
17722    #[test]
17723    fn run_setup_clean_handles_missing_dir() {
17724        let tmp = TempDir::new().unwrap();
17725        let dir = tmp.path().join("does-not-exist");
17726        // Should print and return Ok without error.
17727        run_setup_clean(&dir, true).unwrap();
17728        assert!(!dir.exists());
17729    }
17730
17731    fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
17732        let prev_home = std::env::var_os("HOME");
17733        let prev_userprofile = std::env::var_os("USERPROFILE");
17734        unsafe {
17735            std::env::set_var("HOME", home);
17736            std::env::set_var("USERPROFILE", home);
17737        }
17738        let result = f();
17739        unsafe {
17740            match prev_home {
17741                Some(value) => std::env::set_var("HOME", value),
17742                None => std::env::remove_var("HOME"),
17743            }
17744            match prev_userprofile {
17745                Some(value) => std::env::set_var("USERPROFILE", value),
17746                None => std::env::remove_var("USERPROFILE"),
17747            }
17748        }
17749        result
17750    }
17751
17752    #[test]
17753    fn plain_launch_preserves_checkpoint_but_starts_fresh() {
17754        let _guard = crate::test_support::lock_test_env();
17755        let tmp = TempDir::new().unwrap();
17756        let workspace = tmp.path().join("workspace");
17757        std::fs::create_dir_all(&workspace).unwrap();
17758
17759        with_home(tmp.path(), || {
17760            let manager = SessionManager::default_location().expect("manager");
17761            let messages = vec![Message {
17762                role: "user".to_string(),
17763                content: vec![ContentBlock::Text {
17764                    text: "in flight".to_string(),
17765                    cache_control: None,
17766                }],
17767            }];
17768            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17769            let session_id = session.metadata.id.clone();
17770            manager.save_checkpoint(&session).expect("save checkpoint");
17771
17772            preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17773
17774            assert!(
17775                manager
17776                    .load_session_checkpoint(&session_id)
17777                    .expect("load checkpoint")
17778                    .is_some(),
17779                "normal launch must leave the per-session checkpoint in place \
17780                 (it may belong to a live session; `--continue` consumes it)"
17781            );
17782            // #4479: checkpoint is no longer promoted to session file.
17783            assert!(
17784                manager
17785                    .load_session_checkpoint(&session_id)
17786                    .expect("load checkpoint")
17787                    .is_some(),
17788                "checkpoint stays in checkpoints/ for --continue"
17789            );
17790        });
17791    }
17792
17793    #[test]
17794    fn plain_launch_consumes_legacy_checkpoint_after_preserving_it() {
17795        let _guard = crate::test_support::lock_test_env();
17796        let tmp = TempDir::new().unwrap();
17797        let workspace = tmp.path().join("workspace");
17798        std::fs::create_dir_all(&workspace).unwrap();
17799
17800        with_home(tmp.path(), || {
17801            let manager = SessionManager::default_location().expect("manager");
17802            let session = create_saved_session(
17803                &[Message {
17804                    role: "user".to_string(),
17805                    content: vec![ContentBlock::Text {
17806                        text: "legacy in flight".to_string(),
17807                        cache_control: None,
17808                    }],
17809                }],
17810                "test-model",
17811                &workspace,
17812                0,
17813                None,
17814            );
17815            let session_id = session.metadata.id.clone();
17816            write_legacy_checkpoint(&manager, &session);
17817
17818            preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17819
17820            assert!(
17821                manager
17822                    .load_legacy_checkpoint()
17823                    .expect("load legacy checkpoint")
17824                    .is_none(),
17825                "normal launch should consume the legacy single-slot checkpoint"
17826            );
17827            // #4479: checkpoint is no longer promoted to session file.
17828            assert!(
17829                manager
17830                    .load_session_checkpoint(&session_id)
17831                    .expect("load checkpoint")
17832                    .is_some(),
17833                "checkpoint stays in checkpoints/ for --continue"
17834            );
17835        });
17836    }
17837
17838    #[test]
17839    fn continue_recovers_same_workspace_checkpoint() {
17840        let _guard = crate::test_support::lock_test_env();
17841        let tmp = TempDir::new().unwrap();
17842        let workspace = tmp.path().join("workspace");
17843        std::fs::create_dir_all(&workspace).unwrap();
17844
17845        with_home(tmp.path(), || {
17846            let manager = SessionManager::default_location().expect("manager");
17847            let messages = vec![Message {
17848                role: "user".to_string(),
17849                content: vec![ContentBlock::Text {
17850                    text: "continue me".to_string(),
17851                    cache_control: None,
17852                }],
17853            }];
17854            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17855            let session_id = session.metadata.id.clone();
17856            manager.save_checkpoint(&session).expect("save checkpoint");
17857
17858            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17859
17860            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17861            assert!(
17862                manager
17863                    .load_session_checkpoint(&session_id)
17864                    .expect("load checkpoint")
17865                    .is_none(),
17866                "--continue should consume the per-session checkpoint"
17867            );
17868            assert!(manager.load_session(&session_id).is_ok());
17869        });
17870    }
17871
17872    /// Write a legacy single-slot checkpoint file the way pre-cutover
17873    /// binaries did. The current binary only reads this slot.
17874    fn write_legacy_checkpoint(manager: &SessionManager, session: &session_manager::SavedSession) {
17875        let checkpoints = manager.sessions_dir().join("checkpoints");
17876        std::fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
17877        let content = serde_json::to_string_pretty(session).expect("serialize checkpoint");
17878        std::fs::write(checkpoints.join("latest.json"), content).expect("write legacy checkpoint");
17879    }
17880
17881    #[test]
17882    fn continue_recovers_legacy_checkpoint_and_migrates_it() {
17883        let _guard = crate::test_support::lock_test_env();
17884        let tmp = TempDir::new().unwrap();
17885        let workspace = tmp.path().join("workspace");
17886        std::fs::create_dir_all(&workspace).unwrap();
17887
17888        with_home(tmp.path(), || {
17889            let manager = SessionManager::default_location().expect("manager");
17890            let messages = vec![Message {
17891                role: "user".to_string(),
17892                content: vec![ContentBlock::Text {
17893                    text: "legacy continue".to_string(),
17894                    cache_control: None,
17895                }],
17896            }];
17897            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17898            let session_id = session.metadata.id.clone();
17899            write_legacy_checkpoint(&manager, &session);
17900
17901            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17902
17903            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17904            assert!(
17905                manager.load_session(&session_id).is_ok(),
17906                "recovered legacy checkpoint must be loadable as a session"
17907            );
17908            assert!(
17909                manager
17910                    .load_session_checkpoint(&session_id)
17911                    .expect("load per-session checkpoint")
17912                    .is_some(),
17913                "legacy recovery must migrate to a per-session checkpoint file"
17914            );
17915            assert!(
17916                manager
17917                    .load_legacy_checkpoint()
17918                    .expect("load legacy checkpoint")
17919                    .is_some(),
17920                "legacy latest.json stays in place for one more release"
17921            );
17922        });
17923    }
17924
17925    #[test]
17926    fn continue_refuses_checkpoint_from_other_workspace() {
17927        let _guard = crate::test_support::lock_test_env();
17928        let tmp = TempDir::new().unwrap();
17929        let launch_workspace = tmp.path().join("launch-workspace");
17930        let other_workspace = tmp.path().join("other-workspace");
17931        std::fs::create_dir_all(&launch_workspace).unwrap();
17932        std::fs::create_dir_all(&other_workspace).unwrap();
17933
17934        with_home(tmp.path(), || {
17935            let manager = SessionManager::default_location().expect("manager");
17936            let messages = vec![Message {
17937                role: "user".to_string(),
17938                content: vec![ContentBlock::Text {
17939                    text: "belongs elsewhere".to_string(),
17940                    cache_control: None,
17941                }],
17942            }];
17943            let session = create_saved_session(&messages, "test-model", &other_workspace, 0, None);
17944            let session_id = session.metadata.id.clone();
17945            manager.save_checkpoint(&session).expect("save checkpoint");
17946
17947            let recovered = recover_interrupted_checkpoint_for_resume(&launch_workspace);
17948
17949            assert_eq!(recovered, None, "workspace mismatch must refuse recovery");
17950            assert!(
17951                manager
17952                    .load_session_checkpoint(&session_id)
17953                    .expect("load checkpoint")
17954                    .is_some(),
17955                "another workspace's checkpoint file must be left untouched"
17956            );
17957        });
17958    }
17959
17960    #[test]
17961    fn continue_twice_does_not_clobber_newer_session_with_stale_legacy_checkpoint() {
17962        let _guard = crate::test_support::lock_test_env();
17963        let tmp = TempDir::new().unwrap();
17964        let workspace = tmp.path().join("workspace");
17965        std::fs::create_dir_all(&workspace).unwrap();
17966
17967        with_home(tmp.path(), || {
17968            let manager = SessionManager::default_location().expect("manager");
17969            let stale = create_saved_session(
17970                &[Message {
17971                    role: "user".to_string(),
17972                    content: vec![ContentBlock::Text {
17973                        text: "crash-time state".to_string(),
17974                        cache_control: None,
17975                    }],
17976                }],
17977                "test-model",
17978                &workspace,
17979                0,
17980                None,
17981            );
17982            let session_id = stale.metadata.id.clone();
17983            write_legacy_checkpoint(&manager, &stale);
17984
17985            // The session advanced after the checkpoint was taken: a newer
17986            // regular session file exists for the same id.
17987            let mut advanced = stale.clone();
17988            advanced.messages.push(Message {
17989                role: "assistant".to_string(),
17990                content: vec![ContentBlock::Text {
17991                    text: "post-recovery progress".to_string(),
17992                    cache_control: None,
17993                }],
17994            });
17995            advanced.metadata.message_count = advanced.messages.len();
17996            advanced.metadata.updated_at = stale.metadata.updated_at + chrono::Duration::hours(1);
17997            manager.save_session(&advanced).expect("save newer session");
17998
17999            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
18000
18001            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
18002            let persisted = manager.load_session(&session_id).expect("load session");
18003            assert_eq!(
18004                persisted.messages.len(),
18005                advanced.messages.len(),
18006                "stale checkpoint content must not overwrite the newer session"
18007            );
18008        });
18009    }
18010
18011    #[test]
18012    fn dotenv_status_points_to_example_when_present() {
18013        let tmp = TempDir::new().unwrap();
18014        std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
18015
18016        assert_eq!(
18017            dotenv_status_line(tmp.path()),
18018            ".env not present in workspace (run `cp .env.example .env` and edit)"
18019        );
18020
18021        std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
18022        assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
18023    }
18024
18025    #[test]
18026    fn env_example_is_trackable_and_every_key_is_wired() {
18027        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
18028        let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
18029        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
18030
18031        assert!(gitignore.contains("!.env.example"));
18032
18033        let keys = documented_env_keys(&env_example);
18034        for required in [
18035            "DEEPSEEK_API_KEY",
18036            "NVIDIA_API_KEY",
18037            "NVIDIA_NIM_API_KEY",
18038            "ATLASCLOUD_API_KEY",
18039        ] {
18040            assert!(
18041                keys.contains(required),
18042                ".env.example is missing {required}"
18043            );
18044        }
18045
18046        for key in &keys {
18047            assert!(
18048                is_workspace_dotenv_credential_key(key),
18049                ".env.example documents non-credential control setting {key}"
18050            );
18051        }
18052
18053        let sources = [
18054            include_str!("config.rs"),
18055            include_str!("logging.rs"),
18056            include_str!("../../config/src/lib.rs"),
18057            include_str!("../../config/src/provider.rs"),
18058            include_str!("../../cli/src/main.rs"),
18059        ]
18060        .join("\n");
18061
18062        for key in keys {
18063            assert!(
18064                sources.contains(&key),
18065                ".env.example documents {key}, but no source file references it"
18066            );
18067        }
18068    }
18069
18070    fn documented_env_keys(content: &str) -> BTreeSet<String> {
18071        content
18072            .lines()
18073            .filter_map(|line| {
18074                let trimmed = line.trim();
18075                let uncommented = trimmed
18076                    .strip_prefix('#')
18077                    .map(str::trim_start)
18078                    .unwrap_or(trimmed);
18079                let (key, _) = uncommented.split_once('=')?;
18080                let key = key.trim();
18081                let is_env_key = key
18082                    .chars()
18083                    .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
18084                    && key.chars().any(|ch| ch == '_');
18085                is_env_key.then(|| key.to_string())
18086            })
18087            .collect()
18088    }
18089
18090    #[test]
18091    fn custom_provider_env_source_precedes_saved_secret_store() {
18092        let _lock = crate::test_support::lock_test_env();
18093        let temp = TempDir::new().expect("temp home");
18094        let codewhale_home = temp.path().join("codewhale-home");
18095        std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
18096        let _home =
18097            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
18098        let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
18099        let _declared_env =
18100            crate::test_support::EnvVarGuard::set("QA_CUSTOM_API_KEY", "declared-env-key");
18101        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18102        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18103        codewhale_secrets::Secrets::auto_detect()
18104            .set("custom", "saved-custom-secret")
18105            .expect("save secret");
18106
18107        let mut custom = std::collections::HashMap::new();
18108        custom.insert(
18109            "qa-gateway".to_string(),
18110            crate::config::ProviderConfig {
18111                kind: Some("openai-compatible".to_string()),
18112                base_url: Some("https://gateway.example.test/v1".to_string()),
18113                model: Some("qa-model".to_string()),
18114                api_key_env: Some("QA_CUSTOM_API_KEY".to_string()),
18115                ..Default::default()
18116            },
18117        );
18118        let config = Config {
18119            provider: Some("qa-gateway".to_string()),
18120            providers: Some(crate::config::ProvidersConfig {
18121                custom,
18122                ..Default::default()
18123            }),
18124            ..Config::default()
18125        };
18126
18127        assert_eq!(resolve_api_key_source(&config), ApiKeySource::EnvDeclared);
18128        assert_eq!(
18129            config.deepseek_api_key().expect("custom key"),
18130            "declared-env-key"
18131        );
18132    }
18133
18134    #[test]
18135    fn named_custom_provider_does_not_report_generic_secret_store() {
18136        let _lock = crate::test_support::lock_test_env();
18137        let temp = TempDir::new().expect("temp home");
18138        let codewhale_home = temp.path().join("codewhale-home");
18139        std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
18140        let _home =
18141            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
18142        let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
18143        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18144        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18145        codewhale_secrets::Secrets::auto_detect()
18146            .set("custom", "unrelated-custom-secret")
18147            .expect("save secret");
18148
18149        let mut custom = std::collections::HashMap::new();
18150        custom.insert(
18151            "qa-gateway".to_string(),
18152            crate::config::ProviderConfig {
18153                kind: Some("openai-compatible".to_string()),
18154                base_url: Some("https://gateway.example.test/v1".to_string()),
18155                model: Some("qa-model".to_string()),
18156                auth_mode: Some("api_key".to_string()),
18157                ..Default::default()
18158            },
18159        );
18160        let config = Config {
18161            provider: Some("qa-gateway".to_string()),
18162            providers: Some(crate::config::ProvidersConfig {
18163                custom,
18164                ..Default::default()
18165            }),
18166            ..Config::default()
18167        };
18168
18169        assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
18170        assert!(config.deepseek_api_key().is_err());
18171    }
18172
18173    #[test]
18174    fn custom_built_in_endpoint_does_not_report_ambient_provider_key() {
18175        let _lock = crate::test_support::lock_test_env();
18176        let _openrouter =
18177            crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
18178        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18179        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18180        let mut providers = crate::config::ProvidersConfig::default();
18181        providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
18182        let config = Config {
18183            provider: Some("openrouter".to_string()),
18184            providers: Some(providers),
18185            ..Config::default()
18186        };
18187
18188        assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
18189        assert!(config.deepseek_api_key().is_err());
18190    }
18191
18192    #[test]
18193    fn ollama_doctor_credential_source_is_route_aware() {
18194        let local = Config {
18195            provider: Some("ollama".to_string()),
18196            ..Config::default()
18197        };
18198        assert_eq!(resolve_api_key_source(&local), ApiKeySource::LocalRuntime);
18199        assert_eq!(
18200            resolve_credential_diagnostic(&local).availability,
18201            CredentialAvailability::NotRequired
18202        );
18203
18204        let ollama_config = |base_url: &str| Config {
18205            provider: Some("ollama".to_string()),
18206            providers: Some(crate::config::ProvidersConfig {
18207                ollama: crate::config::ProviderConfig {
18208                    base_url: Some(base_url.to_string()),
18209                    ..Default::default()
18210                },
18211                ..Default::default()
18212            }),
18213            ..Config::default()
18214        };
18215        let cloud = ollama_config(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL);
18216        assert_eq!(
18217            cloud.api_provider(),
18218            crate::config::ApiProvider::OllamaCloud
18219        );
18220        assert_eq!(
18221            resolve_api_key_source(&cloud),
18222            ApiKeySource::SecretStoreUnprobed
18223        );
18224        assert_eq!(
18225            resolve_credential_diagnostic(&cloud).availability,
18226            CredentialAvailability::NotProbed
18227        );
18228        assert_eq!(doctor_auth_scheme(&cloud), "bearer");
18229        let report = doctor_route_report(&cloud);
18230        assert_eq!(report["provider"], "ollama-cloud");
18231        assert_eq!(report["provider_config_table"], "ollama_cloud");
18232
18233        let custom_remote = ollama_config("https://ollama-gateway.example.test/v1");
18234        assert_eq!(
18235            resolve_api_key_source(&custom_remote),
18236            ApiKeySource::Unknown
18237        );
18238        assert_eq!(
18239            resolve_credential_diagnostic(&custom_remote).availability,
18240            CredentialAvailability::Unknown
18241        );
18242    }
18243
18244    #[test]
18245    fn auth_mode_none_reports_distinct_no_auth_source_and_scheme() {
18246        let _lock = crate::test_support::lock_test_env();
18247        let _openrouter =
18248            crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
18249        let mut providers = crate::config::ProvidersConfig::default();
18250        providers.openrouter.auth_mode = Some("none".to_string());
18251        providers.openrouter.api_key = Some("configured-key".to_string());
18252        let config = Config {
18253            provider: Some("openrouter".to_string()),
18254            providers: Some(providers),
18255            ..Config::default()
18256        };
18257
18258        assert_eq!(resolve_api_key_source(&config), ApiKeySource::NoAuth);
18259        assert_eq!(doctor_api_key_source_label(ApiKeySource::NoAuth), "none");
18260        assert_eq!(doctor_auth_scheme(&config), "none");
18261        assert_eq!(config.deepseek_api_key().expect("no-auth route"), "");
18262    }
18263
18264    #[test]
18265    fn resolve_api_key_source_prefers_config_over_env() {
18266        let _guard = crate::test_support::lock_test_env();
18267        let prev = std::env::var("DEEPSEEK_API_KEY").ok();
18268        let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
18269        unsafe {
18270            std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
18271            std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
18272        }
18273        let cfg = Config {
18274            api_key: Some("fresh-config-key".to_string()),
18275            ..Config::default()
18276        };
18277        let source = resolve_api_key_source(&cfg);
18278        match prev {
18279            Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
18280            None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
18281        }
18282        match prev_source {
18283            Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
18284            None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
18285        }
18286        assert_eq!(source, ApiKeySource::ConfigDeclared);
18287    }
18288
18289    #[test]
18290    fn resolve_api_key_source_reports_active_provider_env_from_metadata() {
18291        let _guard = crate::test_support::lock_test_env();
18292        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18293        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18294        let _anthropic_key =
18295            crate::test_support::EnvVarGuard::set("ANTHROPIC_API_KEY", "test-anthropic-key");
18296        let cfg = Config {
18297            provider: Some("anthropic".to_string()),
18298            ..Config::default()
18299        };
18300
18301        let source = resolve_api_key_source(&cfg);
18302
18303        assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
18304    }
18305
18306    #[test]
18307    fn resolve_api_key_source_ignores_unresolved_provider_command_metadata() {
18308        let _guard = crate::test_support::lock_test_env();
18309        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18310        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18311        let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
18312        let mut providers = crate::config::ProvidersConfig::default();
18313        providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
18314            source: codewhale_config::AuthSourceKind::Command,
18315            command: vec!["secret-tool".to_string(), "lookup".to_string()],
18316            timeout_ms: Some(2000),
18317            secret_id: None,
18318        });
18319        let cfg = Config {
18320            provider: Some("openai".to_string()),
18321            providers: Some(providers),
18322            ..Config::default()
18323        };
18324
18325        let source = resolve_api_key_source(&cfg);
18326
18327        assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
18328        assert!(cfg.deepseek_api_key().is_err());
18329    }
18330
18331    #[test]
18332    fn resolve_api_key_source_ignores_unresolved_provider_secret_metadata() {
18333        let _guard = crate::test_support::lock_test_env();
18334        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18335        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18336        let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
18337        let mut providers = crate::config::ProvidersConfig::default();
18338        providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
18339            source: codewhale_config::AuthSourceKind::Secret,
18340            command: Vec::new(),
18341            timeout_ms: None,
18342            secret_id: Some("codewhale/openai".to_string()),
18343        });
18344        let cfg = Config {
18345            provider: Some("openai".to_string()),
18346            providers: Some(providers),
18347            ..Config::default()
18348        };
18349
18350        let source = resolve_api_key_source(&cfg);
18351
18352        assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
18353        assert!(cfg.deepseek_api_key().is_err());
18354    }
18355
18356    #[test]
18357    fn resolve_api_key_source_ignores_root_deepseek_key_for_other_provider() {
18358        let _guard = crate::test_support::lock_test_env();
18359        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18360        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18361        let _openrouter_key = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
18362        let cfg = Config {
18363            provider: Some("openrouter".to_string()),
18364            api_key: Some("legacy-deepseek-root-key".to_string()),
18365            ..Config::default()
18366        };
18367
18368        let source = resolve_api_key_source(&cfg);
18369
18370        assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
18371    }
18372
18373    #[test]
18374    fn provider_status_helpers_use_provider_metadata() {
18375        assert_eq!(
18376            provider_config_table_key(crate::config::ApiProvider::Anthropic),
18377            "anthropic"
18378        );
18379        assert_eq!(
18380            provider_config_table_key(crate::config::ApiProvider::SiliconflowCn),
18381            "siliconflow_cn"
18382        );
18383    }
18384
18385    #[test]
18386    fn skills_count_for_returns_zero_for_missing_dir() {
18387        let tmp = TempDir::new().unwrap();
18388        let dir = tmp.path().join("nope");
18389        assert_eq!(skills_count_for(&dir), 0);
18390    }
18391
18392    #[test]
18393    fn skills_count_for_counts_valid_skill_dirs() {
18394        let tmp = TempDir::new().unwrap();
18395        let dir = tmp.path().join("skills");
18396        let skill_dir = dir.join("getting-started");
18397        std::fs::create_dir_all(&skill_dir).unwrap();
18398        std::fs::write(
18399            skill_dir.join("SKILL.md"),
18400            "---\nname: getting-started\ndescription: hi\n---\nbody",
18401        )
18402        .unwrap();
18403        assert_eq!(skills_count_for(&dir), 1);
18404    }
18405}
18406
18407#[cfg(test)]
18408#[path = "tests/pr_prompt.rs"]
18409mod pr_prompt_tests;
18410
18411#[cfg(test)]
18412#[path = "tests/telemetry_surface.rs"]
18413mod telemetry_surface_tests;
18414
18415#[cfg(test)]
18416#[path = "tests/telemetry_counters.rs"]
18417mod telemetry_counter_tests;