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 artifacts;
26mod audit;
27mod auto_reasoning;
28mod automation_manager;
29mod child_env;
30mod client;
31mod codex_model_cache;
32mod command_safety;
33mod commands;
34mod compaction;
35mod composer_history;
36mod composer_stash;
37mod config;
38mod config_persistence;
39mod config_ui;
40mod context_budget;
41mod context_report;
42mod continual_harness;
43mod core;
44mod cost_status;
45mod deepseek_theme;
46mod dependencies;
47mod doctor;
48mod dsh_credentials;
49mod elapsed;
50mod error_taxonomy;
51mod eval;
52mod execpolicy;
53mod external_credentials;
54mod fast_hash;
55mod features;
56mod fleet;
57mod goal_loop;
58mod hashing;
59mod hooks;
60mod image_attach;
61mod integrations;
62mod lane_control;
63mod llm_client;
64mod llm_response_cache;
65mod localization;
66mod logging;
67mod lsp;
68mod mcp;
69mod mcp_server;
70mod model_catalog;
71mod model_context;
72mod model_inventory;
73mod model_profile;
74mod model_registry;
75mod model_routing;
76mod models;
77mod models_dev_live;
78mod native_memory;
79mod network_policy;
80mod oauth;
81mod palette;
82mod plugins;
83mod prefix_cache;
84mod pricing;
85mod project_context;
86mod project_context_cache;
87mod prompt_zones;
88mod prompts;
89mod provider_lake;
90mod provider_readiness;
91mod purge;
92mod regex_cache;
93mod remote_control;
94mod remote_setup;
95pub mod repl;
96mod repo_law;
97mod request_manifest;
98mod request_tuning;
99mod resource_telemetry;
100mod retry_status;
101pub mod rlm;
102mod route_billing;
103mod route_budget;
104mod route_receipt;
105mod route_runtime;
106mod runtime_api;
107mod runtime_handoff;
108mod runtime_log;
109mod runtime_policy;
110mod runtime_threads;
111mod safe_label;
112mod sandbox;
113mod scorecard;
114#[allow(dead_code)]
115mod session_diagnostics;
116// Acceptance matrix for #2934 / #4397. Test-only: the table documents the
117// contract for reviewers and is enforced by the tests beside it, so it does
118// not need to exist in a shipped binary.
119#[cfg(test)]
120#[path = "main/tests.rs"]
121mod doctor_loader_tests;
122#[cfg(test)]
123mod session_control_acceptance;
124#[allow(dead_code)]
125mod session_manager;
126mod session_peek;
127mod session_projection;
128mod session_resume;
129pub mod session_tree;
130mod settings;
131mod shell_dispatcher;
132mod skill_state;
133mod skills;
134mod snapshot;
135mod startup_trace;
136mod task_manager;
137mod telemetry_notice;
138#[cfg(test)]
139mod test_support;
140// TLS bootstrap and platform client builders live in codewhale-release;
141// `crate::tls::*` keeps resolving for every caller.
142use codewhale_release::tls;
143mod todo_snapshot;
144mod tool_history_repair;
145mod tool_inspection;
146mod tool_output_receipts;
147mod tools;
148mod tui;
149mod turn_route_plan;
150mod utils;
151mod vision;
152mod work_graph;
153mod worker_profile;
154mod working_set;
155mod workspace_discovery;
156mod workspace_trust;
157mod xai_oauth;
158
159use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, effective_home_dir};
160use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
161use crate::features::{Feature, render_feature_table};
162use crate::llm_client::LlmClient;
163use crate::mcp::{
164    McpCommandAvailability, McpConfig, McpPool, McpServerConfig, McpServerOAuthConfig,
165    is_relative_stdio_path_arg,
166};
167use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
168use crate::session_manager::{SessionManager, create_saved_session, truncate_id};
169use crate::tui::history::{summarize_tool_args, summarize_tool_output};
170
171#[cfg(windows)]
172fn configure_windows_console_utf8() {
173    use windows::Win32::System::Console::{SetConsoleCP, SetConsoleOutputCP};
174
175    const CP_UTF8: u32 = 65001;
176    unsafe {
177        let _ = SetConsoleCP(CP_UTF8);
178        let _ = SetConsoleOutputCP(CP_UTF8);
179    }
180}
181
182#[cfg(not(windows))]
183fn configure_windows_console_utf8() {}
184
185fn install_rustls_crypto_provider() {
186    crate::tls::ensure_rustls_crypto_provider();
187}
188
189#[derive(Parser, Debug)]
190#[command(
191    name = "codewhale-tui",
192    bin_name = "codewhale-tui",
193    author,
194    version = env!("DEEPSEEK_BUILD_VERSION"),
195    about = "Codewhale terminal coding agent",
196    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."
197)]
198struct Cli {
199    /// Subcommand to run
200    #[command(subcommand)]
201    command: Option<Commands>,
202
203    #[command(flatten)]
204    feature_toggles: FeatureToggles,
205
206    /// Initial prompt to submit in the interactive TUI. Use `exec` for non-interactive runs.
207    #[arg(short, long, value_name = "PROMPT", num_args = 1..)]
208    prompt: Vec<String>,
209
210    /// Legacy compatibility alias for Act + Full Access.
211    #[arg(long, hide = true)]
212    yolo: bool,
213
214    /// Maximum number of concurrent sub-agents (1-128; default 64)
215    #[arg(long)]
216    max_subagents: Option<usize>,
217
218    /// Path to config file
219    #[arg(long)]
220    config: Option<PathBuf>,
221
222    /// Enable verbose logging
223    #[arg(short, long)]
224    verbose: bool,
225
226    /// Config profile name
227    #[arg(long)]
228    profile: Option<String>,
229
230    /// Workspace directory for file operations
231    #[arg(short, long)]
232    workspace: Option<PathBuf>,
233
234    /// Resume a previous session by ID or prefix
235    #[arg(short, long)]
236    resume: Option<String>,
237
238    /// Continue the most recent session in this workspace
239    #[arg(short = 'c', long = "continue")]
240    continue_session: bool,
241
242    /// Enable TUI mouse capture for internal scrolling, transcript selection,
243    /// and scrollbar dragging
244    /// (default off on Windows)
245    #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
246    mouse_capture: bool,
247
248    /// Disable TUI mouse capture so terminal-native text selection works
249    #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
250    no_mouse_capture: bool,
251
252    /// Skip onboarding screens
253    #[arg(long)]
254    skip_onboarding: bool,
255
256    /// Start account-owned web remote control for this interactive session.
257    #[arg(long, hide = true)]
258    remote_control: bool,
259
260    /// Start a fresh session, ignoring any crash-recovery checkpoint
261    #[arg(long = "fresh")]
262    fresh: bool,
263
264    /// Skip loading project-level config from $WORKSPACE/.codewhale/config.toml
265    #[arg(long = "no-project-config")]
266    no_project_config: bool,
267}
268
269#[derive(Subcommand, Debug, Clone)]
270#[allow(clippy::large_enum_variant)]
271enum Commands {
272    /// Run system diagnostics and check configuration
273    Doctor(DoctorArgs),
274    /// Summarize failure signals from a local JSONL session log without raw content
275    SessionDiagnostics(SessionDiagnosticsArgs),
276    /// Bootstrap MCP config and/or skills directories
277    Setup(SetupArgs),
278    /// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge)
279    RemoteSetup(remote_setup::RemoteSetupArgs),
280    /// Generate shell completions
281    Completions {
282        /// Shell to generate completions for
283        #[arg(value_enum)]
284        shell: Shell,
285    },
286    /// List saved sessions
287    Sessions {
288        /// Maximum number of sessions to display
289        #[arg(short, long, default_value = "20")]
290        limit: usize,
291        /// Search sessions by title
292        #[arg(short, long)]
293        search: Option<String>,
294    },
295    /// Create default AGENTS.md in current directory
296    Init,
297    /// Save an API key to the shared user config
298    Login {
299        /// API key to store (otherwise read from stdin)
300        #[arg(long)]
301        api_key: Option<String>,
302    },
303    /// Remove the saved API key
304    Logout,
305    /// Manage provider authentication flows.
306    Auth(TuiAuthArgs),
307    /// List available models from the configured API endpoint
308    Models(ModelsArgs),
309    /// Generate speech audio with Xiaomi MiMo TTS models
310    #[command(visible_alias = "tts")]
311    Speech(SpeechArgs),
312    /// Run a non-interactive prompt. Use --auto for agent-with-tools mode.
313    Exec(ExecArgs),
314    /// Manage local Agent Fleet runs and workers
315    Fleet(FleetArgs),
316    /// Internal model-free Workflow tool dispatcher used by Lane Runtime.
317    #[command(name = "workflow-tool", hide = true)]
318    WorkflowTool(WorkflowToolArgs),
319    /// Run a code review over a git diff
320    Review(ReviewArgs),
321    /// Open the TUI pre-seeded with a GitHub PR's title, body, and diff
322    Pr {
323        /// PR number
324        #[arg(value_name = "NUMBER")]
325        number: u32,
326        /// Repository in `owner/name` form. Defaults to the current
327        /// workspace's `gh` config (i.e. the repo gh thinks you're in).
328        #[arg(short = 'R', long)]
329        repo: Option<String>,
330        /// Skip `gh pr checkout` even if gh is available. By default
331        /// the working tree is left as-is — checkout is opt-in via
332        /// `--checkout` because dirty trees fail it loudly.
333        #[arg(long, default_value_t = false)]
334        checkout: bool,
335    },
336    /// Apply a patch file (or stdin) to the working tree
337    Apply(ApplyArgs),
338    /// Run the offline evaluation harness (no network/LLM calls)
339    Eval(EvalArgs),
340    /// Score a run's token/cache/cost from recorded turns; flag regressions vs a baseline
341    Scorecard(ScorecardArgs),
342    /// Manage MCP servers
343    Mcp {
344        #[command(subcommand)]
345        command: McpCommand,
346    },
347    /// Inspect feature flags
348    Features(FeaturesCli),
349    /// Connect third-party harnesses through Codewhale (currently: DeepSeek Harness `dsh`)
350    Integrations {
351        #[command(subcommand)]
352        command: IntegrationsCommand,
353    },
354    /// Run a command inside the sandbox
355    Sandbox(SandboxArgs),
356    /// Run a local server (e.g. MCP)
357    Serve(ServeArgs),
358    /// Resume a previous session by ID (use --last for most recent)
359    Resume {
360        /// Conversation/session id (UUID or prefix)
361        #[arg(value_name = "SESSION_ID")]
362        session_id: Option<String>,
363        /// Continue the most recent session in this workspace without a picker
364        #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
365        last: bool,
366    },
367    /// Fork a previous session by ID (use --last for most recent)
368    Fork {
369        /// Conversation/session id (UUID or prefix)
370        #[arg(value_name = "SESSION_ID")]
371        session_id: Option<String>,
372        /// Fork the most recent session in this workspace without a picker
373        #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
374        last: bool,
375    },
376}
377
378#[derive(Args, Debug, Clone)]
379#[command(after_help = "\
380Examples:
381  codewhale exec \"explain this function\"
382  codewhale exec --auto \"list crates/ with ls\"
383  codewhale exec --auto --output-format stream-json \"fix the failing test\"
384
385Plain `codewhale exec` is a one-shot model response. Use `--auto` for
386non-interactive agent-with-tools execution. `--auto` does not change the
387sandbox posture or elevate a denied tool. Use `--sandbox danger-full-access`
388or `--allow-sandbox-elevation` to explicitly authorize sandbox elevation.
389")]
390struct ExecArgs {
391    /// Override model for this run
392    #[arg(long)]
393    model: Option<String>,
394    /// Override the provider for this run (e.g. `deepseek`, `openrouter`).
395    /// Non-secret identifier only — credentials still resolve from the
396    /// environment/config. Fleet uses this to launch a worker on its
397    /// profile-pinned provider even when the parent session is on another
398    /// one (#4093).
399    #[arg(long)]
400    provider: Option<String>,
401    /// Override reasoning/thinking effort for this run.
402    /// Accepted values: auto, off, low, medium, high, max.
403    #[arg(long = "reasoning-effort", value_name = "EFFORT")]
404    reasoning_effort: Option<String>,
405    /// Enable agent-with-tools mode with automatic tool approvals. This does
406    /// not authorize sandbox elevation.
407    #[arg(long, default_value_t = false)]
408    auto: bool,
409    /// Sandbox policy for this exec run; independent from --auto.
410    #[arg(long, value_name = "POLICY")]
411    sandbox: Option<String>,
412    /// Explicitly allow a denied tool to retry with danger-full-access.
413    #[arg(long, default_value_t = false)]
414    allow_sandbox_elevation: bool,
415    /// Emit machine-readable JSON output
416    #[arg(long, default_value_t = false, conflicts_with = "output_format")]
417    json: bool,
418    /// Resume a previous session by ID or prefix
419    #[arg(long, value_name = "SESSION_ID", conflicts_with_all = ["session_id", "continue_session"])]
420    resume: Option<String>,
421    /// Resume a previous session by ID or prefix
422    #[arg(long = "session-id", value_name = "SESSION_ID", conflicts_with_all = ["resume", "continue_session"])]
423    session_id: Option<String>,
424    /// Continue the most recent session for this workspace
425    #[arg(long = "continue", default_value_t = false, conflicts_with_all = ["resume", "session_id"])]
426    continue_session: bool,
427    /// Output format for exec mode
428    #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)]
429    output_format: ExecOutputFormat,
430    /// Comma-separated list of canonical tools to allow (all others denied).
431    /// Names are case-insensitive: Bash, File, Git, Run, etc.
432    #[arg(long, value_delimiter = ',')]
433    allowed_tools: Option<Vec<String>>,
434    /// Comma-separated list of tools to deny (deny wins over allow).
435    #[arg(long, value_delimiter = ',')]
436    disallowed_tools: Option<Vec<String>>,
437    /// Maximum number of model steps before the run ends. Omitted means unlimited.
438    #[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
439    max_turns: Option<u32>,
440    /// Extra text appended to the system prompt for this run.
441    #[arg(long)]
442    append_system_prompt: Option<String>,
443    /// Internal Fleet worker authority envelope. Non-secret, versioned JSON.
444    #[arg(long, value_name = "JSON", hide = true)]
445    tool_authority_json: Option<String>,
446    /// Prompt to send to the model
447    #[arg(
448        value_name = "PROMPT",
449        required = true,
450        trailing_var_arg = true,
451        allow_hyphen_values = true
452    )]
453    prompt: Vec<String>,
454}
455
456#[derive(Args, Debug, Clone)]
457struct WorkflowToolArgs {
458    /// Authority provenance stamped by the public `workflow run` command.
459    #[arg(long, value_name = "SOURCE")]
460    approval_source: String,
461    /// Exact Workflow tool input serialized as one JSON object.
462    #[arg(long, value_name = "JSON")]
463    input_json: String,
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
467enum ExecOutputFormat {
468    Text,
469    #[value(name = "stream-json")]
470    StreamJson,
471}
472
473#[derive(Args, Debug, Clone)]
474struct TuiAuthArgs {
475    #[command(subcommand)]
476    command: TuiAuthCommand,
477}
478
479#[derive(Subcommand, Debug, Clone)]
480enum TuiAuthCommand {
481    /// Sign in to xAI/Grok with an SSH-friendly device code.
482    #[command(name = "xai-device")]
483    XaiDevice,
484}
485
486const CODEWHALE_TOOL_SURFACE_ENV: &str = "CODEWHALE_TOOL_SURFACE";
487const SHELL_ONLY_EXEC_TOOLS: &[&str] = &["bash"];
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490enum ExecToolSurface {
491    ShellOnly,
492}
493
494fn exec_tool_surface_from_env() -> Option<ExecToolSurface> {
495    std::env::var(CODEWHALE_TOOL_SURFACE_ENV)
496        .ok()
497        .and_then(|value| {
498            if should_warn_unknown_exec_tool_surface(&value) {
499                eprintln!(
500                    "warning: unrecognized {CODEWHALE_TOOL_SURFACE_ENV}; leaving exec tool surface unchanged. Use `shell-only`, `full`, or `native-tools`."
501                );
502            }
503            parse_exec_tool_surface(&value)
504        })
505}
506
507fn parse_exec_tool_surface(value: &str) -> Option<ExecToolSurface> {
508    match value.trim().to_ascii_lowercase().as_str() {
509        "shell-only" | "shell_only" | "shell" => Some(ExecToolSurface::ShellOnly),
510        "full" | "native-tools" | "native_tools" | "" => None,
511        _ => None,
512    }
513}
514
515fn should_warn_unknown_exec_tool_surface(value: &str) -> bool {
516    let normalized = value.trim().to_ascii_lowercase();
517    !matches!(
518        normalized.as_str(),
519        "" | "shell-only" | "shell_only" | "shell" | "full" | "native-tools" | "native_tools"
520    )
521}
522
523fn normalize_exec_tool_names(tools: &[String]) -> Vec<String> {
524    tools
525        .iter()
526        .map(|name| name.to_ascii_lowercase().trim().to_string())
527        .collect()
528}
529
530fn shell_only_exec_allowed_tools() -> Vec<String> {
531    SHELL_ONLY_EXEC_TOOLS
532        .iter()
533        .map(|name| (*name).to_string())
534        .collect()
535}
536
537fn resolve_exec_allowed_tools(
538    cli_allowed_tools: Option<&[String]>,
539    env_tool_surface: Option<ExecToolSurface>,
540) -> Option<Vec<String>> {
541    if let Some(tools) = cli_allowed_tools {
542        return Some(normalize_exec_tool_names(tools));
543    }
544
545    env_tool_surface.map(|ExecToolSurface::ShellOnly| shell_only_exec_allowed_tools())
546}
547
548#[derive(Args, Debug, Clone)]
549struct FleetArgs {
550    #[command(subcommand)]
551    command: FleetCommand,
552}
553
554#[derive(Subcommand, Debug, Clone)]
555enum FleetCommand {
556    /// Initialize the local fleet ledger for this workspace
557    Init,
558    /// Create a run from a task spec and start the foreground manager loop
559    Run(FleetRunArgs),
560    /// List durable Fleet runs from this workspace's ledger
561    List,
562    /// Show queued/running/completed/failed/stale fleet counts
563    Status,
564    /// Inspect one worker's status, heartbeat, latest event, and artifacts
565    Inspect {
566        /// Worker id printed by `codewhale fleet run`
567        worker_id: String,
568    },
569    /// Print bounded log artifacts for one worker
570    Logs {
571        /// Worker id printed by `codewhale fleet run`
572        worker_id: String,
573    },
574    /// List artifact refs for one worker
575    Artifacts {
576        /// Worker id printed by `codewhale fleet run`
577        worker_id: String,
578    },
579    /// Interrupt a running worker task and record a terminal cancellation
580    Interrupt {
581        /// Worker id printed by `codewhale fleet run`
582        worker_id: String,
583    },
584    /// Restart the latest task for a worker
585    Restart {
586        /// Worker id printed by `codewhale fleet run`
587        worker_id: String,
588    },
589    /// Resume a run from durable ledger state, reconciling orphaned/stale leases
590    Resume {
591        /// Run id printed by `codewhale fleet run`
592        run_id: String,
593        /// Seconds without heartbeat before a leased task is treated as stale
594        #[arg(long, default_value_t = 300)]
595        stale_after_seconds: u64,
596    },
597    /// Stop all queued and running fleet work
598    Stop {
599        /// Confirm stopping all queued and running fleet tasks
600        #[arg(long, required = true)]
601        all: bool,
602    },
603    /// Render a redacted fleet alert payload without sending it
604    AlertDryRun(FleetAlertDryRunArgs),
605}
606
607#[derive(Args, Debug, Clone)]
608struct FleetRunArgs {
609    /// JSON or TOML task spec to enqueue
610    #[arg(value_name = "TASK_SPEC")]
611    task_spec: PathBuf,
612    /// Maximum local workers to lease concurrently
613    #[arg(long, default_value_t = 4)]
614    max_workers: usize,
615    /// Seconds without heartbeat before a running task is counted stale
616    #[arg(long, default_value_t = 300)]
617    stale_after_seconds: u64,
618    /// Schedule once and return instead of staying in the manager loop
619    #[arg(long, hide = true, default_value_t = false)]
620    once: bool,
621}
622
623#[derive(Args, Debug, Clone)]
624struct FleetAlertDryRunArgs {
625    /// Alert event class to render
626    #[arg(long, value_enum)]
627    event: FleetAlertEventArg,
628    /// Fleet run id
629    #[arg(long)]
630    run_id: String,
631    /// Worker id, when the event belongs to one worker
632    #[arg(long)]
633    worker_id: Option<String>,
634    /// Task id, when the event belongs to one task
635    #[arg(long)]
636    task_id: Option<String>,
637    /// Short human-readable reason for the alert
638    #[arg(long, default_value = "manual fleet alert dry-run")]
639    reason: String,
640    /// Status label to include in the payload
641    #[arg(long)]
642    status: Option<String>,
643    /// Adapter payload shape to render
644    #[arg(long, value_enum, default_value_t = FleetAlertAdapterArg::Slack)]
645    adapter: FleetAlertAdapterArg,
646    /// Environment variable containing the Slack webhook URL
647    #[arg(long, default_value = "CODEWHALE_FLEET_SLACK_WEBHOOK")]
648    slack_webhook_env: String,
649    /// Environment variable containing the generic webhook URL
650    #[arg(long, default_value = "CODEWHALE_FLEET_WEBHOOK_URL")]
651    webhook_url_env: String,
652    /// Optional environment variable containing the generic webhook secret
653    #[arg(long)]
654    webhook_secret_env: Option<String>,
655    /// Environment variable containing the PagerDuty routing key
656    #[arg(long, default_value = "CODEWHALE_FLEET_PAGERDUTY_ROUTING_KEY")]
657    pagerduty_routing_key_env: String,
658    /// PagerDuty severity to render
659    #[arg(long, default_value = "error")]
660    pagerduty_severity: String,
661}
662
663#[derive(ValueEnum, Debug, Clone, Copy)]
664enum FleetAlertEventArg {
665    Stale,
666    RestartExhausted,
667    NeedsHuman,
668    BudgetExceeded,
669    VerifierFailed,
670    RunCompleted,
671}
672
673#[derive(ValueEnum, Debug, Clone, Copy)]
674enum FleetAlertAdapterArg {
675    Slack,
676    Webhook,
677    PagerDuty,
678}
679
680/// Spawn a tokio task that listens for terminating signals (SIGINT
681/// always; SIGTERM and SIGHUP on Unix) and, on receipt, restores the
682/// terminal modes and exits with the conventional 128 + signal code.
683/// Multiple deliveries are tolerated: once the cleanup runs, a second
684/// signal short-circuits to plain exit so a stuck cleanup can never
685/// trap a frustrated user pressing Ctrl+C repeatedly.
686///
687/// See the call site in `main` for the rationale (#1583).
688///
689/// Registration is synchronous, before the spawn: a `tokio::spawn`ed task does
690/// not run until the scheduler first polls it, so registering the signal
691/// streams *inside* it leaves a window — unbounded under load — where SIGINT
692/// still has its default disposition and kills the process outright. That is
693/// the very outcome this handler exists to prevent, and it produced a real
694/// terminated-by-signal exit (no code, no terminal restore, no `session_end`).
695/// After this function returns, the signals are armed.
696fn spawn_signal_cleanup_task() {
697    let signals = TerminatingSignals::register();
698    tokio::spawn(async move {
699        let exit_code = signals.wait().await;
700        // If we get here a fatal signal arrived. Restore the terminal
701        // and exit. A second signal during cleanup re-enters this
702        // path and aborts via `std::process::exit` directly.
703        static CLEANED_UP: std::sync::atomic::AtomicBool =
704            std::sync::atomic::AtomicBool::new(false);
705        if !CLEANED_UP.swap(true, std::sync::atomic::Ordering::SeqCst) {
706            #[cfg(unix)]
707            crate::tools::shell::abort_pending_persistent_process_groups_for_exit();
708            crate::tui::ui::emergency_restore_terminal();
709            // Nothing async survives the `exit` below, so this is the last
710            // chance to say how the session ended. `record_blocking` is one
711            // `O_APPEND` write with no lock: taking the compaction lock here
712            // would let a second Codewhale process sharing CODEWHALE_HOME hang
713            // Ctrl-C, and the second-signal short-circuit below has to stay
714            // reachable. A no-op unless this process was armed.
715            //
716            // The class is stated, not derived: `RunTerminationReason::Canceled`
717            // also exits 130, so `exit_code` cannot tell a signal from an
718            // Esc-cancelled turn.
719            record_signal_session_end();
720        }
721        std::process::exit(exit_code);
722    });
723}
724
725/// When this process's armed telemetry session began. Set once, at arming, and
726/// read from both the ordinary teardown and the signal path.
727static TELEMETRY_SESSION_START: std::sync::OnceLock<std::time::Instant> =
728    std::sync::OnceLock::new();
729
730/// Build `session_end` from what this process actually accumulated.
731///
732/// The exit class is read from the process-wide atomic and never derived from
733/// an exit code: `RunTerminationReason::Canceled` maps to 130, the same value
734/// the SIGINT path uses, so a code-based derivation would report every
735/// Esc-cancelled turn as a signal.
736///
737/// The cold-start bucket is `None` unless the interactive event loop actually
738/// began, which is what keeps it absent rather than invented on the surfaces
739/// that have no event loop.
740fn telemetry_session_end() -> codewhale_telemetry::Event {
741    let counters = codewhale_telemetry::session_counters();
742    codewhale_telemetry::Event::SessionEnd {
743        duration_bucket: codewhale_telemetry::DurationBucket::from_secs(
744            TELEMETRY_SESSION_START
745                .get()
746                .map_or(0, |start| start.elapsed().as_secs()),
747        ),
748        exit_class: codewhale_telemetry::exit_class(),
749        cold_start_bucket: crate::startup_trace::cold_start_ms()
750            .map(codewhale_telemetry::ColdStartBucket::from_millis),
751        providers: counters.providers(),
752        counters: counters.counters(),
753        errors: counters.errors(),
754        turn_wall: counters.turn_wall(),
755    }
756}
757
758/// Close the session synchronously, from the signal handler.
759///
760/// A no-op unless this process was armed.
761fn record_signal_session_end() {
762    codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Signal);
763    codewhale_telemetry::record_blocking(telemetry_session_end());
764}
765
766/// Terminating-signal streams, registered up front and awaited later.
767///
768/// Splitting registration from the await is the point: the OS disposition
769/// changes when `register` returns, not when the waiting task is first polled.
770#[cfg(unix)]
771struct TerminatingSignals {
772    sigint: Option<tokio::signal::unix::Signal>,
773    sigterm: Option<tokio::signal::unix::Signal>,
774    sighup: Option<tokio::signal::unix::Signal>,
775}
776
777#[cfg(unix)]
778impl TerminatingSignals {
779    /// Install the handlers. Failing to install any individual stream is
780    /// non-fatal: we still want the others to work.
781    fn register() -> Self {
782        use tokio::signal::unix::{SignalKind, signal};
783        Self {
784            sigint: signal(SignalKind::interrupt()).ok(),
785            sigterm: signal(SignalKind::terminate()).ok(),
786            sighup: signal(SignalKind::hangup()).ok(),
787        }
788    }
789
790    /// Resolve with 128 + signal number for whichever arrives first. The
791    /// fallback never-resolving future keeps `select!` well-typed when a
792    /// stream failed to register.
793    async fn wait(mut self) -> i32 {
794        tokio::select! {
795            _ = async { match self.sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130,
796            _ = async { match self.sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143,
797            _ = async { match self.sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129,
798        }
799    }
800}
801
802/// Windows: `ctrl_c` covers both Ctrl+C and Ctrl+Break (CTRL_C_EVENT /
803/// CTRL_BREAK_EVENT). Console-close, logoff, and shutdown events are not
804/// currently routed through tokio.
805#[cfg(not(unix))]
806struct TerminatingSignals {
807    ctrl_c: Option<tokio::signal::windows::CtrlC>,
808}
809
810#[cfg(not(unix))]
811impl TerminatingSignals {
812    fn register() -> Self {
813        Self {
814            ctrl_c: tokio::signal::windows::ctrl_c().ok(),
815        }
816    }
817
818    async fn wait(mut self) -> i32 {
819        match self.ctrl_c.as_mut() {
820            Some(s) => {
821                s.recv().await;
822            }
823            None => std::future::pending::<()>().await,
824        }
825        130
826    }
827}
828
829fn join_prompt_parts(parts: &[String]) -> String {
830    parts.join(" ")
831}
832
833fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String {
834    explicit_model
835        .map(str::trim)
836        .filter(|model| !model.is_empty())
837        .map(ToOwned::to_owned)
838        .or_else(exec_model_env_override)
839        .unwrap_or_else(|| config.default_model())
840}
841
842fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> {
843    let provider_arg = provider_arg.trim();
844    if provider_arg.is_empty() {
845        return Ok(());
846    }
847    if config
848        .providers
849        .as_ref()
850        .and_then(|providers| providers.custom_provider_config(provider_arg))
851        .is_some()
852    {
853        config.provider = Some(provider_arg.to_string());
854        return Ok(());
855    }
856    if let Some(provider) = crate::config::ApiProvider::parse(provider_arg) {
857        config.provider = Some(provider.as_str().to_string());
858        return Ok(());
859    }
860    bail!(
861        "Unrecognized --provider {provider_arg:?}. Known providers: {} \
862         or a configured [providers.<name>] custom provider",
863        crate::config::ApiProvider::names_hint()
864    );
865}
866
867fn exec_model_env_override() -> Option<String> {
868    let read = || {
869        ["CODEWHALE_MODEL", "DEEPSEEK_MODEL"]
870            .into_iter()
871            .find_map(|key| {
872                std::env::var(key)
873                    .ok()
874                    .map(|model| model.trim().to_string())
875                    .filter(|model| !model.is_empty())
876            })
877    };
878    #[cfg(test)]
879    {
880        crate::test_support::with_test_env_lock(read)
881    }
882    #[cfg(not(test))]
883    {
884        read()
885    }
886}
887
888fn top_level_prompt_initial_input(parts: &[String]) -> Option<tui::InitialInput> {
889    (!parts.is_empty()).then(|| tui::InitialInput::Submit(join_prompt_parts(parts)))
890}
891
892fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<Option<String>> {
893    if let Some(id) = args.resume.as_ref().or(args.session_id.as_ref()) {
894        return Ok(Some(id.clone()));
895    }
896    if !args.continue_session {
897        return Ok(None);
898    }
899    latest_session_id_for_workspace(workspace)?.map_or_else(
900        || {
901            bail!(
902                "No saved sessions found for workspace {}. Use `codewhale sessions` to list sessions, or pass `codewhale exec --resume <SESSION_ID> ...`.",
903                workspace.display()
904            )
905        },
906        |id| Ok(Some(id)),
907    )
908}
909
910fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
911    let session_ref = exec_stream_session_ref(session_id);
912    SessionManager::default_location()
913        .context("could not open session manager for resume")?
914        .load_session_by_prefix(session_id)
915        .with_context(|| format!("could not load session {session_ref}"))
916}
917
918/// Select the route for `exec --resume` before any engine/client is built.
919///
920/// Precedence is intentionally field-aware:
921/// - no explicit `--provider` or `--model`: restore the saved provider/model;
922/// - explicit `--provider`: keep that route and use its configured/default model
923///   unless `--model` is also present;
924/// - explicit `--model` alone: restore the saved provider, then use that model.
925fn resolve_exec_resume_route(
926    config: &mut Config,
927    saved: &session_manager::SavedSession,
928    explicit_provider: bool,
929    explicit_model: Option<&str>,
930) -> Result<String> {
931    if !explicit_provider {
932        let saved_provider_identity = saved
933            .metadata
934            .model_provider_id
935            .as_deref()
936            .filter(|identity| !identity.trim().is_empty())
937            .unwrap_or(&saved.metadata.model_provider);
938        let identity = config
939            .resolve_persisted_provider_identity(
940                Some(&saved.metadata.model_provider),
941                saved.metadata.model_provider_id.as_deref(),
942            )
943            .map_err(anyhow::Error::msg)
944            .with_context(|| {
945                format!(
946                    "saved session provider '{}' is unavailable; Codewhale will not fall back",
947                    saved_provider_identity
948                )
949            })?;
950        config.scope_to_provider_identity(&identity);
951    }
952
953    if let Some(model) = explicit_model {
954        return Ok(resolve_exec_model(config, Some(model)));
955    }
956    if explicit_provider {
957        return Ok(resolve_exec_model(config, None));
958    }
959    Ok(saved.metadata.model.clone())
960}
961
962#[derive(Args, Debug, Clone, Default)]
963struct SetupArgs {
964    /// Initialize MCP configuration at the configured path
965    #[arg(long, default_value_t = false)]
966    mcp: bool,
967    /// Initialize skills directory and an example skill
968    #[arg(long, default_value_t = false)]
969    skills: bool,
970    /// Initialize tools directory with a self-describing example script
971    #[arg(long, default_value_t = false)]
972    tools: bool,
973    /// Initialize plugins directory with a self-describing example
974    #[arg(long, default_value_t = false)]
975    plugins: bool,
976    /// Initialize MCP config, skills, tools, and plugins
977    #[arg(long, default_value_t = false)]
978    all: bool,
979    /// Create a local workspace skills directory (./skills)
980    #[arg(long, default_value_t = false)]
981    local: bool,
982    /// Overwrite existing template files
983    #[arg(long, default_value_t = false)]
984    force: bool,
985    /// Print a compact, read-only status report (no network calls)
986    #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "clean"])]
987    status: bool,
988    /// Remove regenerable session checkpoints (latest + offline_queue)
989    #[arg(long, default_value_t = false, conflicts_with_all = ["mcp", "skills", "tools", "plugins", "all", "local", "status"])]
990    clean: bool,
991}
992
993#[derive(Args, Debug, Clone, Default)]
994struct DoctorArgs {
995    /// Emit machine-readable structural JSON output (always offline)
996    #[arg(long, default_value_t = false)]
997    json: bool,
998    /// Emit only the diagnostic context source map as JSON
999    #[arg(long, default_value_t = false, conflicts_with = "json")]
1000    context_json: bool,
1001    /// Opt in to probing a local provider endpoint (may start a local service)
1002    #[arg(
1003        long,
1004        default_value_t = false,
1005        conflicts_with_all = ["json", "context_json"]
1006    )]
1007    probe_local: bool,
1008    /// Opt in to probing the configured hosted provider API
1009    #[arg(
1010        long,
1011        default_value_t = false,
1012        conflicts_with_all = ["json", "context_json"]
1013    )]
1014    probe_api: bool,
1015    /// Opt in to contacting the release service for an update check
1016    #[arg(
1017        long,
1018        default_value_t = false,
1019        conflicts_with_all = ["json", "context_json"]
1020    )]
1021    check_updates: bool,
1022    /// Opt in to starting enabled MCP servers and checking process/protocol reachability
1023    #[arg(
1024        long,
1025        default_value_t = false,
1026        conflicts_with_all = ["json", "context_json"]
1027    )]
1028    probe_mcp: bool,
1029}
1030
1031#[derive(Args, Debug, Clone)]
1032struct SessionDiagnosticsArgs {
1033    /// JSONL session log to inspect
1034    #[arg(value_name = "JSONL")]
1035    path: PathBuf,
1036    /// Emit machine-readable JSON with redacted source handles
1037    #[arg(long, default_value_t = false)]
1038    json: bool,
1039}
1040
1041#[derive(Args, Debug, Clone)]
1042struct ScorecardArgs {
1043    /// JSON file with the recorded turns to score: an array of
1044    /// `{ "turn_id", "provider", "model", "billing_surface", "usage": {…} }`.
1045    /// `turn_end` hooks emit this route provenance plus `created_at`; persisted
1046    /// runtime exports may instead use `id`, `effective_provider`,
1047    /// `effective_model`, and `effective_billing_surface`.
1048    /// Shell-only hook rows marked `model_backed: false` are excluded. Legacy
1049    /// rows without provider remain readable but their cost is unavailable.
1050    #[arg(long, value_name = "FILE")]
1051    input: PathBuf,
1052    /// Optional baseline scorecard-metrics JSON to compare against. When set,
1053    /// the command exits non-zero if any metric regresses past the threshold.
1054    #[arg(long, value_name = "FILE")]
1055    baseline: Option<PathBuf>,
1056    /// Regression threshold, in percent increase over the baseline.
1057    #[arg(long, default_value_t = 5.0)]
1058    threshold: f64,
1059    /// Emit machine-readable JSON instead of the human summary.
1060    #[arg(long, default_value_t = false)]
1061    json: bool,
1062}
1063
1064#[derive(Args, Debug, Clone)]
1065struct EvalArgs {
1066    /// Intentionally fail a specific step (list, read, search, edit, patch, shell)
1067    #[arg(long, value_name = "STEP")]
1068    fail_step: Option<String>,
1069    /// Shell command to run during the exec step
1070    #[arg(long, default_value = "printf eval-harness")]
1071    shell_command: String,
1072    /// Token that must appear in shell output for validation
1073    #[arg(long, default_value = "eval-harness")]
1074    shell_expect_token: String,
1075    /// Maximum characters stored per step output summary
1076    #[arg(long, default_value_t = 240)]
1077    max_output_chars: usize,
1078    /// Emit machine-readable JSON output
1079    #[arg(long, default_value_t = false)]
1080    json: bool,
1081    /// Append one JSONL fixture line per step to `<DIR>/<scenario>.jsonl`.
1082    /// Mock LLM tests can later replay these fixtures.
1083    #[arg(long, value_name = "DIR")]
1084    record: Option<PathBuf>,
1085}
1086
1087#[derive(Args, Debug, Clone, Default)]
1088struct ModelsArgs {
1089    /// Print models as pretty JSON
1090    #[arg(long, default_value_t = false)]
1091    json: bool,
1092}
1093
1094#[derive(Args, Debug, Clone)]
1095struct SpeechArgs {
1096    /// Text to synthesize. This is sent as the assistant message content.
1097    #[arg(value_name = "TEXT")]
1098    text: String,
1099
1100    /// Output audio path. Defaults to `speech.<format>` in `--output-dir`,
1101    /// `[speech].output_dir`, or the current directory.
1102    #[arg(short, long, value_name = "FILE")]
1103    output: Option<PathBuf>,
1104
1105    /// Directory for the default `speech.<format>` output file when `-o`/`--output` is omitted.
1106    #[arg(long = "output-dir", value_name = "DIR")]
1107    output_dir: Option<PathBuf>,
1108
1109    /// TTS model. Defaults to built-in voices, or is inferred from --voice-prompt/--clone-voice.
1110    #[arg(long)]
1111    model: Option<String>,
1112
1113    /// Built-in voice ID, or a data:audio/...;base64,... URI for voice clone.
1114    #[arg(long)]
1115    voice: Option<String>,
1116
1117    /// Natural language style instruction; not spoken verbatim.
1118    #[arg(long)]
1119    instruction: Option<String>,
1120
1121    /// Voice design prompt. Implies mimo-v2.5-tts-voicedesign when --model is omitted.
1122    #[arg(long = "voice-prompt")]
1123    voice_prompt: Option<String>,
1124
1125    /// MP3/WAV sample used for voice cloning. Implies mimo-v2.5-tts-voiceclone when --model is omitted.
1126    #[arg(long = "clone-voice", value_name = "FILE")]
1127    clone_voice: Option<PathBuf>,
1128
1129    /// Output audio format requested from the API
1130    #[arg(long, default_value = "wav")]
1131    format: String,
1132
1133    /// Emit machine-readable JSON output
1134    #[arg(long, default_value_t = false)]
1135    json: bool,
1136}
1137
1138#[derive(Args, Debug, Default, Clone)]
1139struct FeatureToggles {
1140    /// Enable a feature (repeatable). Equivalent to `features.<name>=true`.
1141    #[arg(long = "enable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1142    enable: Vec<String>,
1143
1144    /// Disable a feature (repeatable). Equivalent to `features.<name>=false`.
1145    #[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
1146    disable: Vec<String>,
1147}
1148
1149impl FeatureToggles {
1150    fn apply(&self, config: &mut Config) -> Result<()> {
1151        for feature in &self.enable {
1152            config.set_feature(feature, true)?;
1153        }
1154        for feature in &self.disable {
1155            config.set_feature(feature, false)?;
1156        }
1157        Ok(())
1158    }
1159}
1160
1161#[derive(Args, Debug, Clone)]
1162struct ReviewArgs {
1163    /// Review staged changes instead of the working tree
1164    #[arg(long, conflicts_with = "base")]
1165    staged: bool,
1166    /// Base ref to diff against (e.g. origin/main)
1167    #[arg(long)]
1168    base: Option<String>,
1169    /// Limit diff to a specific path
1170    #[arg(long)]
1171    path: Option<PathBuf>,
1172    /// Override model for this review
1173    #[arg(long)]
1174    model: Option<String>,
1175    /// Maximum diff characters to include
1176    #[arg(long, default_value_t = 200_000)]
1177    max_chars: usize,
1178    /// Write a durable pre-push review receipt after a successful review
1179    #[arg(long, default_value_t = false)]
1180    write_receipt: bool,
1181    /// Validate the current diff against a durable review receipt without calling a model
1182    #[arg(long, default_value_t = false)]
1183    check_receipt: bool,
1184    /// Override where the review receipt is written or read
1185    #[arg(long)]
1186    receipt_path: Option<PathBuf>,
1187    /// Emit machine-readable JSON output
1188    #[arg(long, default_value_t = false)]
1189    json: bool,
1190}
1191
1192#[derive(Args, Debug, Clone)]
1193struct ApplyArgs {
1194    /// Patch file to apply (defaults to stdin)
1195    #[arg(value_name = "PATCH_FILE")]
1196    patch_file: Option<PathBuf>,
1197}
1198
1199#[derive(Args, Debug, Clone)]
1200struct ServeArgs {
1201    /// Start MCP server over stdio
1202    #[arg(long)]
1203    mcp: bool,
1204    /// Start runtime HTTP/SSE API server
1205    #[arg(long)]
1206    http: bool,
1207    /// Start runtime HTTP/SSE API server with the built-in mobile control page
1208    #[arg(long)]
1209    mobile: bool,
1210    /// Start the embedded loopback-only browser client and open it
1211    #[arg(long)]
1212    web: bool,
1213    /// Show a QR code for the mobile URL in the terminal (requires --mobile)
1214    #[arg(long, requires = "mobile")]
1215    qr: bool,
1216    /// Start ACP server over stdio for editor clients such as Zed
1217    #[arg(long)]
1218    acp: bool,
1219    /// Bind host for HTTP server (default localhost; --mobile defaults to 0.0.0.0)
1220    #[arg(long)]
1221    host: Option<String>,
1222    /// Bind port for HTTP server
1223    #[arg(long, default_value_t = 7878)]
1224    port: u16,
1225    /// Background task worker count (1-8)
1226    #[arg(long, default_value_t = 2)]
1227    workers: usize,
1228    /// Additional CORS origin to allow (repeatable). Stacks on top of the
1229    /// built-in defaults (localhost:3000, localhost:1420, tauri://localhost).
1230    /// Also reads `CODEWHALE_CORS_ORIGINS` (comma-separated), then
1231    /// `DEEPSEEK_CORS_ORIGINS` as an alias, and `[runtime_api] cors_origins`
1232    /// from `config.toml`. Whalescale#255.
1233    #[arg(long = "cors-origin", value_name = "URL")]
1234    cors_origin: Vec<String>,
1235    /// Require this bearer token for `/v1/*` runtime API routes. Also reads
1236    /// `CODEWHALE_RUNTIME_TOKEN` when omitted, then `DEEPSEEK_RUNTIME_TOKEN`
1237    /// as an alias.
1238    #[arg(long = "auth-token", value_name = "TOKEN")]
1239    auth_token: Option<String>,
1240    /// Disable runtime API auth when no token is configured. Only use on a trusted loopback.
1241    #[arg(long = "insecure")]
1242    insecure_no_auth: bool,
1243}
1244
1245#[derive(Debug, Clone, PartialEq, Eq)]
1246struct ServeBindHost {
1247    host: String,
1248    mobile_rebound_to_lan: bool,
1249}
1250
1251fn resolve_serve_bind_host(mobile: bool, host: Option<String>) -> ServeBindHost {
1252    match (mobile, host) {
1253        (true, None) => ServeBindHost {
1254            host: "0.0.0.0".to_string(),
1255            mobile_rebound_to_lan: true,
1256        },
1257        (_, Some(host)) => ServeBindHost {
1258            host,
1259            mobile_rebound_to_lan: false,
1260        },
1261        (false, None) => ServeBindHost {
1262            host: "127.0.0.1".to_string(),
1263            mobile_rebound_to_lan: false,
1264        },
1265    }
1266}
1267
1268fn validate_serve_mode_selection(
1269    mcp: bool,
1270    http: bool,
1271    mobile: bool,
1272    web: bool,
1273    acp: bool,
1274) -> Result<bool> {
1275    if http && mobile {
1276        bail!("--http and --mobile are mutually exclusive; choose one");
1277    }
1278    if web && (http || mobile) {
1279        bail!("--web is mutually exclusive with --http and --mobile");
1280    }
1281    let http_selected = http || mobile || web;
1282    let selected_modes = [mcp, http_selected, acp]
1283        .into_iter()
1284        .filter(|selected| *selected)
1285        .count();
1286    if selected_modes != 1 {
1287        bail!("Choose exactly one server mode: --mcp, --http/--mobile/--web, or --acp");
1288    }
1289    Ok(http_selected)
1290}
1291
1292#[derive(Subcommand, Debug, Clone)]
1293enum McpCommand {
1294    /// List configured MCP servers
1295    List,
1296    /// Create a template MCP config at the configured path
1297    Init {
1298        /// Overwrite an existing MCP config file
1299        #[arg(long, default_value_t = false)]
1300        force: bool,
1301    },
1302    /// Connect to MCP servers and report status
1303    Connect {
1304        /// Optional server name to connect to
1305        #[arg(value_name = "SERVER")]
1306        server: Option<String>,
1307    },
1308    /// List tools discovered from MCP servers
1309    Tools {
1310        /// Optional server name to list tools for
1311        #[arg(value_name = "SERVER")]
1312        server: Option<String>,
1313    },
1314    /// Add an MCP server entry
1315    Add {
1316        /// Server name
1317        name: String,
1318        /// Command to launch stdio server
1319        #[arg(long, conflicts_with = "url")]
1320        command: Option<String>,
1321        /// URL for streamable HTTP/SSE server
1322        #[arg(long, conflicts_with = "command")]
1323        url: Option<String>,
1324        /// Explicit URL transport override. Use "sse" for legacy SSE endpoints.
1325        #[arg(long, requires = "url")]
1326        transport: Option<String>,
1327        /// Environment variable containing a bearer token for URL-based servers
1328        #[arg(long, requires = "url")]
1329        bearer_token_env_var: Option<String>,
1330        /// OAuth client ID for servers that do not support dynamic registration
1331        #[arg(long, requires = "url")]
1332        oauth_client_id: Option<String>,
1333        /// OAuth resource parameter to append to the authorization URL
1334        #[arg(long, requires = "url")]
1335        oauth_resource: Option<String>,
1336        /// OAuth scope to request during login. Repeat or comma-separate.
1337        #[arg(long = "scope", requires = "url", value_delimiter = ',')]
1338        scopes: Vec<String>,
1339        /// Arguments for command-based servers
1340        #[arg(long = "arg")]
1341        args: Vec<String>,
1342    },
1343    /// Authenticate to a URL-based MCP server using OAuth
1344    Login {
1345        /// Server name
1346        name: String,
1347        /// OAuth scope to request. Repeat or comma-separate; defaults to config/discovery.
1348        #[arg(long = "scope", value_delimiter = ',')]
1349        scopes: Vec<String>,
1350    },
1351    /// Delete stored OAuth credentials for a URL-based MCP server
1352    Logout {
1353        /// Server name
1354        name: String,
1355    },
1356    /// Remove an MCP server entry
1357    Remove {
1358        /// Server name
1359        name: String,
1360    },
1361    /// Enable an MCP server
1362    Enable {
1363        /// Server name
1364        name: String,
1365    },
1366    /// Disable an MCP server
1367    Disable {
1368        /// Server name
1369        name: String,
1370    },
1371    /// Validate MCP config and required servers
1372    Validate,
1373    /// Register this Codewhale binary as a local MCP stdio server.
1374    ///
1375    /// This adds a config entry that runs `codewhale serve --mcp` (stdio protocol).
1376    /// For the HTTP/SSE runtime API, use `codewhale serve --http` directly instead.
1377    #[command(
1378        name = "add-self",
1379        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."
1380    )]
1381    AddSelf {
1382        /// Server name in mcp.json (default: "codewhale")
1383        #[arg(long, default_value = "codewhale")]
1384        name: String,
1385        /// Workspace directory for the MCP server
1386        #[arg(long)]
1387        workspace: Option<String>,
1388    },
1389}
1390
1391#[derive(Subcommand, Debug, Clone)]
1392pub(crate) enum IntegrationsCommand {
1393    /// Official DeepSeek Harness (`dsh`) connected through Codewhale
1394    Dsh {
1395        #[command(subcommand)]
1396        command: DshIntegrationCommand,
1397    },
1398}
1399
1400#[derive(Subcommand, Debug, Clone)]
1401pub(crate) enum DshIntegrationCommand {
1402    /// Detect dsh and report the integration state without writing anything
1403    Status {
1404        /// Emit machine-readable JSON
1405        #[arg(long, default_value_t = false)]
1406        json: bool,
1407    },
1408    /// Show exactly what `connect`/`update` would write, without writing it
1409    Plan {
1410        #[arg(long, default_value_t = false)]
1411        json: bool,
1412        /// DSH profile the overlay targets (`web` or `headless`)
1413        #[arg(long, default_value = "web")]
1414        profile: String,
1415        /// Mirror Codewhale full access as DSH danger-full-access (only when Codewhale itself runs with full access)
1416        #[arg(long, default_value_t = false)]
1417        allow_full_access: bool,
1418        /// Also export the Codewhale skin stylesheet (unsupported DSH overlay; never injected)
1419        #[arg(long, default_value_t = false)]
1420        skin: bool,
1421    },
1422    /// Write the overlay and receipt under $CODEWHALE_HOME/integrations/dsh
1423    Connect {
1424        #[arg(long, default_value = "web")]
1425        profile: String,
1426        #[arg(long, default_value_t = false)]
1427        allow_full_access: bool,
1428        #[arg(long, default_value_t = false)]
1429        skin: bool,
1430        /// Confirm the disclosed plan without an interactive prompt (required when stdin is not a terminal)
1431        #[arg(long, default_value_t = false)]
1432        yes: bool,
1433    },
1434    /// Re-derive the overlay from the current Codewhale route
1435    Update {
1436        #[arg(long)]
1437        profile: Option<String>,
1438        #[arg(long, default_value_t = false)]
1439        allow_full_access: bool,
1440        /// Keep/refresh the skin export (defaults to the previous choice)
1441        #[arg(long)]
1442        skin: Option<bool>,
1443        #[arg(long, default_value_t = false)]
1444        yes: bool,
1445    },
1446    /// Run dsh with the Codewhale overlay; extra args go to the dsh app
1447    Launch {
1448        /// Override the recorded profile (`web` or `headless`)
1449        #[arg(long)]
1450        profile: Option<String>,
1451        /// Print the exact command instead of running it
1452        #[arg(long, default_value_t = false)]
1453        dry_run: bool,
1454        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1455        args: Vec<String>,
1456    },
1457    /// Keep the overlay but refuse launches
1458    Disable,
1459    /// Allow launches again
1460    Enable,
1461    /// Delete Codewhale-owned files only; $DSH_HOME is never touched
1462    Remove {
1463        #[arg(long, default_value_t = false)]
1464        yes: bool,
1465    },
1466    /// Documented DSH plugin path: install the Codewhale bundle into a dedicated `codewhale` DSH profile via `dsh plugin add` (pnpm required)
1467    InstallBundle {
1468        /// Which shipped DSH app the dedicated profile boots (`web` or `headless`)
1469        #[arg(long, default_value = "web")]
1470        app: String,
1471        #[arg(long, default_value_t = false)]
1472        yes: bool,
1473    },
1474    /// `dsh plugin --profile codewhale remove codewhale-dsh-bundle`, then delete only Codewhale-owned bundle files
1475    RemoveBundle {
1476        #[arg(long, default_value_t = false)]
1477        yes: bool,
1478    },
1479}
1480
1481#[derive(Args, Debug, Clone)]
1482struct FeaturesCli {
1483    #[command(subcommand)]
1484    command: FeaturesSubcommand,
1485}
1486
1487#[derive(Subcommand, Debug, Clone)]
1488enum FeaturesSubcommand {
1489    /// List known feature flags and their state
1490    List,
1491}
1492
1493#[derive(Args, Debug, Clone)]
1494struct SandboxArgs {
1495    #[command(subcommand)]
1496    command: SandboxCommand,
1497}
1498
1499#[derive(Subcommand, Debug, Clone)]
1500enum SandboxCommand {
1501    /// Run a command with sandboxing
1502    Run {
1503        /// Sandbox policy (danger-full-access, read-only, external-sandbox, workspace-write)
1504        #[arg(long, default_value = "workspace-write")]
1505        policy: String,
1506        /// Allow outbound network access
1507        #[arg(long)]
1508        network: bool,
1509        /// Additional writable roots (repeatable)
1510        #[arg(long, value_name = "PATH")]
1511        writable_root: Vec<PathBuf>,
1512        /// Exclude TMPDIR from writable paths
1513        #[arg(long)]
1514        exclude_tmpdir: bool,
1515        /// Exclude /tmp from writable paths
1516        #[arg(long)]
1517        exclude_slash_tmp: bool,
1518        /// Command working directory
1519        #[arg(long)]
1520        cwd: Option<PathBuf>,
1521        /// Timeout in milliseconds
1522        #[arg(long, default_value_t = 60_000)]
1523        timeout_ms: u64,
1524        /// Command and arguments to run
1525        #[arg(required = true, trailing_var_arg = true)]
1526        command: Vec<String>,
1527    },
1528}
1529
1530const CODEWHALE_MAIN_STACK_BYTES: usize = 16 * 1024 * 1024;
1531
1532/// Entry point for the single binary. Takes argv including binary name at 0,
1533/// parses with clap, and runs the TUI/runtime dispatch. Returns process exit
1534/// code for the caller to exit with.
1535pub fn run(args: Vec<String>) -> std::process::ExitCode {
1536    match run_with_args(args) {
1537        Ok(()) => std::process::ExitCode::SUCCESS,
1538        Err(err) => {
1539            eprintln!("error: {err}");
1540            for cause in err.chain().skip(1) {
1541                eprintln!("  caused by: {cause}");
1542            }
1543            std::process::ExitCode::FAILURE
1544        }
1545    }
1546}
1547
1548/// Internal implementation that mirrors the old `main()` but takes explicit
1549/// args instead of reading `std::env::args()`. Used by `run()` and tested
1550/// directly.
1551fn run_with_args(args: Vec<String>) -> Result<()> {
1552    // Match the dispatcher entrypoint: Unix shells and supervisors may inherit
1553    // SIGPIPE ignored, which turns short pipelines such as `codewhale doctor |
1554    // head` into BrokenPipe panics once this delegated TUI binary prints.
1555    #[cfg(unix)]
1556    unsafe {
1557        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1558    }
1559
1560    startup_trace::mark_process_start();
1561    configure_windows_console_utf8();
1562    install_rustls_crypto_provider();
1563
1564    // ── Process hardening (#2183) ─────────────────────────────────────────
1565    // MUST run before Tokio is booted and before any threads are spawned.
1566    // See crates/tui/src/sandbox/process_hardening.rs for ordering rationale.
1567    crate::sandbox::process_hardening::apply_process_hardening();
1568
1569    // ── Fatal-signal terminal guard (#5424) ───────────────────────────────
1570    // Abort-class deaths (stack overflow, allocation failure, double panic)
1571    // skip the panic hook AND every Drop guard, leaving mouse capture and
1572    // the kitty keyboard stack leaking into the user's shell. A classic
1573    // sigaction handler restores the terminal and stamps a marker before
1574    // re-raising. Also before any threads exist.
1575    crate::tui::ui::fatal_signal_guard::install_fatal_signal_guard();
1576
1577    // Set up process panic hook before anything else — writes crash dumps
1578    // to ~/.deepseek/crashes/ even if the panic happens before tokio is up,
1579    // and restores the terminal so a panicked TUI doesn't leave the user's
1580    // shell stuck in alt-screen mode.
1581    let orig_hook = std::panic::take_hook();
1582    std::panic::set_hook(Box::new(move |panic_info| {
1583        // Restore the terminal first so the panic message itself, plus the
1584        // user's shell after exit, are visible. Best-effort — we may not be
1585        // in raw / alt-screen mode if the panic happens pre-TUI. Shared
1586        // with the signal handler installed below so both exit paths leave
1587        // the terminal in the same well-defined state.
1588        crate::tui::ui::emergency_restore_terminal();
1589
1590        let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1591            s.to_string()
1592        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1593            s.clone()
1594        } else {
1595            format!("{:?}", panic_info.payload())
1596        };
1597        let location = panic_info
1598            .location()
1599            .map(|loc| loc.to_string())
1600            .unwrap_or_else(|| "unknown".to_string());
1601        tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
1602
1603        // Telemetry, if and only if this process was armed. This hook is
1604        // installed before `Cli::parse()` and long before any config is
1605        // resolved, so it cannot consult a resolved value — but it can consult
1606        // a `OnceLock` that is by construction empty until resolution
1607        // completes. A user who never opted in panics without writing a byte
1608        // and without creating a directory.
1609        //
1610        // The site is allowlist-reduced and `msg` is deliberately not read: a
1611        // slicing panic embeds the entire string being sliced, and this tree
1612        // slices user and model text in dozens of places.
1613        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
1614        if let Some(site) = panic_info
1615            .location()
1616            .map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
1617        {
1618            codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
1619        }
1620        // Write crash dump best-effort
1621        if let Some(home) = crate::config::effective_home_dir() {
1622            let crash_dir = home.join(".deepseek").join("crashes");
1623            let _ = std::fs::create_dir_all(&crash_dir);
1624            use chrono::Utc;
1625            let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1626            let path = crash_dir.join(format!("{ts}-process-panic.log"));
1627            let contents =
1628                format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
1629            let _ = std::fs::write(&path, contents);
1630        }
1631        // Invoke the original hook (prints to stderr, etc.)
1632        orig_hook(panic_info);
1633    }));
1634
1635    // Parse and freeze every startup authority before Tokio or any other
1636    // worker thread exists. A workspace `.env` is intentionally a narrow
1637    // credential convenience surface: it must never redirect product state,
1638    // configuration, MCP, trust, sandbox, executable lookup, or plugin
1639    // discovery. Plugin discovery therefore runs first, and the loader below
1640    // admits only built-in provider credential names from a stable file read.
1641    let cli = match Cli::try_parse_from(args) {
1642        Ok(c) => c,
1643        Err(e) => {
1644            e.exit();
1645        }
1646    };
1647    // #5098: project-scope fleet agent profiles (`.codewhale/agents/*.toml`)
1648    // join the dispatch roster under the same trust decision as the rest of
1649    // project-level config — `--no-project-config` opts the layer out for
1650    // every roster read in this process.
1651    crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
1652    let workspace = resolve_workspace(&cli);
1653    let mut plugin_discovery = None;
1654    let mut plugin_registry = None;
1655    let (cli, command) = prepare_cli_startup(
1656        cli,
1657        || {
1658            let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
1659            plugin_registry = Some(discovery.registry_for_workspace(&workspace));
1660            plugin_discovery = Some(discovery);
1661        },
1662        warn_on_workspace_dotenv_result,
1663    );
1664    let plugin_discovery = plugin_discovery
1665        .expect("plugin discovery initialization must precede workspace dotenv loading");
1666    let plugin_registry = plugin_registry
1667        .expect("plugin discovery initialization must precede workspace dotenv loading");
1668
1669    // The interactive runtime intentionally carries a large state machine:
1670    // terminal rendering, modal dispatch, provider setup, and fleet/workflow
1671    // events all share one async owner. Debug builds retain enough stack
1672    // temporaries that nesting a modal event over the TUI loop can exceed the
1673    // platform main-thread default (8 MiB on macOS). Give that owner an
1674    // explicit stack while keeping process hardening and the global panic hook
1675    // above this boundary, before Tokio or any worker thread exists.
1676    let runtime_thread = std::thread::Builder::new()
1677        .name("codewhale-main".to_string())
1678        .stack_size(CODEWHALE_MAIN_STACK_BYTES)
1679        .spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
1680        .context("Failed to start the Codewhale runtime thread")?;
1681    match runtime_thread.join() {
1682        Ok(result) => result,
1683        Err(payload) => {
1684            let message = payload
1685                .downcast_ref::<&str>()
1686                .map(|value| (*value).to_string())
1687                .or_else(|| payload.downcast_ref::<String>().cloned())
1688                .unwrap_or_else(|| "unknown panic payload".to_string());
1689            Err(anyhow!("Codewhale runtime thread panicked: {message}"))
1690        }
1691    }
1692}
1693
1694fn run_async_main(
1695    cli: Cli,
1696    command: Option<Commands>,
1697    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1698    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1699) -> Result<()> {
1700    build_runtime()?.block_on(run_async_main_inner(
1701        cli,
1702        command,
1703        plugin_discovery,
1704        plugin_registry,
1705    ))
1706}
1707
1708/// Build the runtime that owns every async task in this binary.
1709///
1710/// `#[tokio::main]` used to expand here, which left every worker thread on
1711/// tokio's 2 MiB default while only the `codewhale-main` owner thread above
1712/// received `CODEWHALE_MAIN_STACK_BYTES`. The engine does not run on that owner
1713/// thread — `core::engine::spawn_engine` hands `Engine::run` to
1714/// `utils::spawn_supervised`, a bare `tokio::spawn` — so the explicit stack
1715/// never applied where the depth actually is.
1716///
1717/// A debug-build `agent` dispatch (turn_loop -> FuturesUnordered ->
1718/// execute_full_with_context -> AgentTool::execute -> spawn_subagent_from_input)
1719/// measured a stack high-water mark between 2.25 and 2.5 MiB and aborted the
1720/// whole process on the guard page. A Rust stack overflow is not a panic: it
1721/// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the
1722/// process dies with 134 mid-dispatch.
1723///
1724/// This is behavior-identical to the old `#[tokio::main]` expansion apart from
1725/// the stack size, and it makes the knob greppable.
1726pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
1727    tokio::runtime::Builder::new_multi_thread()
1728        .enable_all()
1729        .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
1730        .build()
1731        .context("Failed to build the Codewhale Tokio runtime")
1732}
1733
1734/// Which product surface this process is serving.
1735///
1736/// A function of the parsed subcommand, never of the executable: this one
1737/// binary serves at least five surfaces, so `current_exe()` would label all of
1738/// them the same.
1739fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
1740    use codewhale_telemetry::Surface;
1741    match command {
1742        None | Some(Commands::Resume { .. } | Commands::Fork { .. } | Commands::Pr { .. }) => {
1743            Surface::Tui
1744        }
1745        Some(Commands::Exec(_)) => Surface::Exec,
1746        Some(Commands::Serve(args)) => {
1747            if args.mcp {
1748                Surface::McpServer
1749            } else {
1750                Surface::Serve
1751            }
1752        }
1753        Some(_) => Surface::Cli,
1754    }
1755}
1756
1757/// How this session was started, for `session_start`.
1758fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
1759    use codewhale_telemetry::SessionSource;
1760    match command {
1761        None | Some(Commands::Pr { .. }) => SessionSource::Interactive,
1762        Some(Commands::Resume { .. }) => SessionSource::Resume,
1763        Some(Commands::Fork { .. }) => SessionSource::Fork,
1764        Some(Commands::Serve(_)) => SessionSource::Api,
1765        Some(_) => SessionSource::Unknown,
1766    }
1767}
1768
1769/// Read-only commands must not create telemetry state as a side effect.
1770fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
1771    matches!(
1772        command,
1773        Some(Commands::Doctor(_) | Commands::SessionDiagnostics(_) | Commands::Sessions { .. })
1774    ) || matches!(command, Some(Commands::Setup(args)) if args.status)
1775}
1776
1777/// Resolve the emit predicate and arm, once, before anything can record.
1778///
1779/// This is the read that v1 of the design was missing entirely:
1780/// `resolve_runtime_options` had no non-test caller in this crate, so neither
1781/// `telemetry = false` in the config file nor `CODEWHALE_TELEMETRY=0` was ever
1782/// consulted by a process that would have emitted.
1783///
1784/// `CliRuntimeOverrides::default()` is correct here. The dispatcher has already
1785/// applied the kill-switch floor and forwarded the *resolved* value through
1786/// `CODEWHALE_TELEMETRY`, which `EnvRuntimeOverrides::load()` picks up — and
1787/// re-reading `CODEWHALE_TELEMETRY` inside the telemetry crate would fork
1788/// `parse_bool`, the `DEEPSEEK_TELEMETRY` alias, and the floor into a second
1789/// source of truth.
1790fn arm_telemetry_with_setup(
1791    config_path: Option<PathBuf>,
1792    surface: codewhale_telemetry::Surface,
1793    source: codewhale_telemetry::SessionSource,
1794    setup_override: Option<&codewhale_config::SetupState>,
1795) {
1796    let Ok(store) = codewhale_config::ConfigStore::load(config_path) else {
1797        return;
1798    };
1799    let resolved = store
1800        .config
1801        .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1802    let setup = if let Some(setup) = setup_override {
1803        setup.clone()
1804    } else {
1805        let Some(setup) = codewhale_telemetry::load_setup_state_for_decision() else {
1806            // An existing unreadable privacy record may contain a decline.
1807            // Failing closed is safer than replacing it with default-on.
1808            return;
1809        };
1810        setup
1811    };
1812    let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
1813        codewhale_telemetry::decide(&resolved, &setup, surface)
1814    else {
1815        return;
1816    };
1817    codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
1818    let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
1819    codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
1820}
1821
1822fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
1823    if telemetry_command_is_read_only(command) {
1824        return;
1825    }
1826    arm_telemetry_with_setup(
1827        cli.config.clone(),
1828        telemetry_surface(command),
1829        telemetry_session_source(command),
1830        None,
1831    );
1832}
1833
1834/// Apply the choice made in the native TUI disclosure.
1835///
1836/// The in-memory setup state is authoritative for this process. In particular,
1837/// a Disable choice reaches `decide` as an opt-out even when neither durable
1838/// write landed, so the current launch cannot arm and any existing buffer is
1839/// wiped whenever the telemetry home remains reachable.
1840pub(crate) fn apply_tui_telemetry_decision(
1841    pending: &crate::telemetry_notice::PendingTelemetryNotice,
1842    setup: &codewhale_config::SetupState,
1843) {
1844    arm_telemetry_with_setup(
1845        pending.config_path.clone(),
1846        codewhale_telemetry::Surface::Tui,
1847        pending.session_source,
1848        Some(setup),
1849    );
1850}
1851
1852/// Close the armed session and flush, bounded.
1853async fn finish_telemetry(outcome: &Result<()>) {
1854    if !codewhale_telemetry::is_armed() {
1855        return;
1856    }
1857    // Only escalate: the panic hook and the signal path have already spoken if
1858    // they ran, and a stated class must not be overwritten by an inferred one.
1859    if outcome.is_err()
1860        && codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
1861    {
1862        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
1863    }
1864    codewhale_telemetry::record(telemetry_session_end());
1865    // `shutdown_blocking` parks a thread waiting on the writer, so it goes to
1866    // the blocking pool, and it is bounded there. The persistence actor's
1867    // unbounded `let _ = task.await` next door is not a pattern to copy here: a
1868    // hung TLS handshake would hold the process open past the last frame.
1869    let _ = tokio::time::timeout(
1870        codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
1871        tokio::task::spawn_blocking(|| {
1872            codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
1873        }),
1874    )
1875    .await;
1876}
1877
1878async fn run_async_main_inner(
1879    cli: Cli,
1880    command: Option<Commands>,
1881    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1882    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1883) -> Result<()> {
1884    // Install signal handlers that restore the terminal before the process
1885    // exits. Without this, Ctrl+C delivered while raw mode / kitty keyboard
1886    // enhancement / alt-screen are active (or in the brief windows around
1887    // startup and teardown where they're being toggled) leaves the user's shell
1888    // receiving raw CSI sequences like `^[[>5u` until they run `reset` (#1583).
1889    //
1890    // Once the TUI's raw mode is engaged the terminal driver delivers Ctrl+C as
1891    // the byte 0x03 rather than SIGINT, so the in-TUI key handler — not this
1892    // handler — is what processes user interrupts during normal operation. This
1893    // handler exists for the gaps: pre-TUI subcommands (--version, doctor,
1894    // login, …), the moments around enable_raw_mode / disable_raw_mode, the
1895    // external-editor suspend path, and SIGTERM / SIGHUP from the OS.
1896    //
1897    // It goes up before arming and before the notice: arming is the first
1898    // externally observable thing this process does (it creates the telemetry
1899    // buffer), and the notice is the first thing that can sit waiting on a
1900    // human. A Ctrl-C in either window must still restore the terminal and exit
1901    // 130 rather than kill the process outright. Recording a `session_end` from
1902    // the signal path is a no-op until `arm_telemetry` runs, so installing
1903    // ahead of it collects nothing.
1904    spawn_signal_cleanup_task();
1905
1906    // A due interactive disclosure belongs to the first native TUI frame. In
1907    // that one case arming is deferred until its decision event; every other
1908    // surface keeps the ordinary pre-dispatch predicate. This is what lets an
1909    // immediate Disable choice stop this very session without printing or
1910    // blocking on a shell questionnaire first.
1911    let surface = telemetry_surface(command.as_ref());
1912    let telemetry_notice_plan = if surface == codewhale_telemetry::Surface::Tui {
1913        crate::telemetry_notice::plan_if_due(
1914            cli.config.clone(),
1915            telemetry_session_source(command.as_ref()),
1916        )
1917    } else {
1918        crate::telemetry_notice::TelemetryNoticePlan::NotDue
1919    };
1920    let should_arm_before_dispatch = surface != codewhale_telemetry::Surface::Tui
1921        || telemetry_notice_plan.should_arm_before_tui();
1922    let pending_telemetry_notice = telemetry_notice_plan.into_pending();
1923    if should_arm_before_dispatch {
1924        arm_telemetry(&cli, command.as_ref());
1925    }
1926    let outcome = run_async_main_dispatch(
1927        cli,
1928        command,
1929        plugin_discovery,
1930        plugin_registry,
1931        pending_telemetry_notice,
1932    )
1933    .await;
1934    finish_telemetry(&outcome).await;
1935    outcome
1936}
1937
1938async fn run_async_main_dispatch(
1939    cli: Cli,
1940    command: Option<Commands>,
1941    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1942    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1943    mut pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
1944) -> Result<()> {
1945    logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
1946
1947    // Install any user prompt overrides from the config directory before an
1948    // engine can compose a system prompt. The override cells are
1949    // first-call-wins; doing this once here keeps every downstream turn
1950    // consistent. Missing files are a no-op (bundled defaults). See #3638.
1951    crate::prompts::load_prompt_overrides_from_config_home();
1952
1953    // Plugins own one read-only discovery snapshot per process. Initialize it
1954    // before the subcommand match so plain launch, resume, fork, exec, serve,
1955    // and every other runtime surface feed Skills and MCP from the same trust
1956    // decision (#3916, #4399). Discovery never enables, trusts, executes, or
1957    // persists a bundle.
1958
1959    // Handle subcommands first
1960    if let Some(command) = command {
1961        return match command {
1962            Commands::Doctor(args) => {
1963                let config = match load_doctor_config_from_cli(&cli, &args) {
1964                    Ok(config) => config,
1965                    Err(error) if args.json => return run_doctor_json_config_error(&error),
1966                    Err(_) => {
1967                        bail!(
1968                            "doctor configuration validation failed; details omitted because configuration errors may contain credential material"
1969                        )
1970                    }
1971                };
1972                let workspace = resolve_workspace(&cli);
1973                if args.context_json {
1974                    run_doctor_context_json(&config, &workspace)
1975                } else if args.json {
1976                    run_doctor_json(
1977                        &config,
1978                        &workspace,
1979                        cli.config.as_deref(),
1980                        plugin_registry.as_ref(),
1981                    )
1982                } else {
1983                    let probes = crate::doctor::DoctorProbeRequest {
1984                        check_updates: args.check_updates,
1985                        probe_api: args.probe_api,
1986                        probe_local: args.probe_local,
1987                        probe_mcp: args.probe_mcp,
1988                    };
1989                    run_doctor(
1990                        &config,
1991                        &workspace,
1992                        cli.config.as_deref(),
1993                        probes,
1994                        plugin_registry.as_ref(),
1995                    )
1996                    .await;
1997                    Ok(())
1998                }
1999            }
2000            Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
2001            Commands::Setup(args) => {
2002                let config = load_config_from_cli(&cli)?;
2003                let workspace = resolve_workspace(&cli);
2004                run_setup(&config, &workspace, args, plugin_registry.as_ref())
2005            }
2006            Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args),
2007            Commands::Completions { shell } => {
2008                generate_completions(shell);
2009                Ok(())
2010            }
2011            Commands::Sessions { limit, search } => list_sessions(limit, search),
2012            Commands::Init => init_project(),
2013            Commands::Login { api_key } => run_login(api_key),
2014            Commands::Logout => run_logout(),
2015            Commands::Auth(args) => match args.command {
2016                TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
2017            },
2018            Commands::Models(args) => {
2019                let config = load_config_from_cli(&cli)?;
2020                run_models(&config, args).await
2021            }
2022            Commands::Speech(args) => {
2023                let config = load_config_from_cli(&cli)?;
2024                run_speech(&config, args).await
2025            }
2026            Commands::Exec(args) => {
2027                let config = load_config_from_cli(&cli)?;
2028                let workspace = cli.workspace.clone().unwrap_or_else(|| {
2029                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2030                });
2031                let mut config = config.clone();
2032                // #4641: `--no-project-config` skips the workspace-specific
2033                // `[workspace]`/`[projects]` user-config overlay so a headless
2034                // launch (e.g. a future Verifiers harness) sees a reproducible
2035                // config surface that depends only on the explicit `--config`.
2036                if !cli.no_project_config {
2037                    merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
2038                }
2039                if let Some(sandbox) = args.sandbox.as_deref() {
2040                    let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
2041                    config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
2042                }
2043                // Honour CODEWHALE_BASE_URL / DEEPSEEK_BASE_URL forwarded by
2044                // the CLI dispatcher from --base-url.
2045                if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
2046                    .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
2047                {
2048                    let trimmed = env_url.trim();
2049                    if !trimmed.is_empty() {
2050                        config.base_url = Some(trimmed.to_string());
2051                    }
2052                }
2053                // Honour `--provider` (#4093): a Fleet worker whose profile pins
2054                // a provider launches on that provider even when the parent
2055                // session is on another one. This sets ONLY the non-secret
2056                // provider identity (`config.provider`); credentials/base URL
2057                // still resolve from the worker's own env/config, and for a
2058                // non-DeepSeek provider the legacy root `base_url` above is
2059                // ignored by `deepseek_base_url()`. Must precede model
2060                // resolution so an `auto`/default model resolves to the
2061                // overridden provider's default.
2062                let explicit_provider = args
2063                    .provider
2064                    .as_deref()
2065                    .map(str::trim)
2066                    .filter(|provider| !provider.is_empty());
2067                if let Some(provider_arg) = explicit_provider {
2068                    apply_exec_provider_override(&mut config, provider_arg)?;
2069                }
2070                if let Some(reasoning_arg) = args
2071                    .reasoning_effort
2072                    .as_deref()
2073                    .map(str::trim)
2074                    .filter(|value| !value.is_empty())
2075                {
2076                    config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
2077                    config.reasoning_effort_inferred_from_legacy_alias = false;
2078                }
2079                let prompt = join_prompt_parts(&args.prompt);
2080                let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
2081                validate_exec_tool_authority_resume(
2082                    args.tool_authority_json.as_deref(),
2083                    resume_session_id.is_some(),
2084                )?;
2085                let resume_session = resume_session_id
2086                    .as_deref()
2087                    .map(load_exec_resume_session)
2088                    .transpose()?;
2089                let explicit_model = args
2090                    .model
2091                    .as_deref()
2092                    .map(str::trim)
2093                    .filter(|model| !model.is_empty());
2094                let model = if let Some(saved) = resume_session.as_ref() {
2095                    resolve_exec_resume_route(
2096                        &mut config,
2097                        saved,
2098                        explicit_provider.is_some(),
2099                        explicit_model,
2100                    )?
2101                } else {
2102                    resolve_exec_model(&config, explicit_model)
2103                };
2104                let force_configured_route = should_force_configured_exec_route(
2105                    resume_session.is_some(),
2106                    explicit_provider,
2107                    explicit_model,
2108                );
2109                // The `deepseek` launcher forwards `--yolo` to this binary via
2110                // the DEEPSEEK_YOLO env var (which the config loader folds into
2111                // `config.yolo`), not as a CLI flag. Honour either source.
2112                let yolo = cli.yolo || config.yolo.unwrap_or(false);
2113                let env_tool_surface = exec_tool_surface_from_env();
2114                let needs_engine = args.auto
2115                    || yolo
2116                    || resume_session_id.is_some()
2117                    || args.output_format == ExecOutputFormat::StreamJson
2118                    || args.max_turns.is_some()
2119                    || args.allowed_tools.is_some()
2120                    || args.disallowed_tools.is_some()
2121                    || args.append_system_prompt.is_some()
2122                    || args.tool_authority_json.is_some()
2123                    || args.sandbox.is_some()
2124                    || args.allow_sandbox_elevation
2125                    || env_tool_surface.is_some();
2126                if needs_engine {
2127                    let provider = config.api_provider();
2128                    let max_subagents = cli.max_subagents.map_or_else(
2129                        || config.max_subagents_for_provider(provider),
2130                        |value| value.clamp(1, MAX_SUBAGENTS),
2131                    );
2132                    let auto_mode = args.auto || yolo;
2133                    let max_turns = exec_max_steps(args.max_turns);
2134                    let allowed_tools =
2135                        resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface);
2136                    let disallowed_tools = args
2137                        .disallowed_tools
2138                        .as_deref()
2139                        .map(normalize_exec_tool_names);
2140                    run_exec_agent(
2141                        &config,
2142                        &model,
2143                        &prompt,
2144                        workspace,
2145                        max_subagents,
2146                        auto_mode,
2147                        args.allow_sandbox_elevation,
2148                        args.sandbox.as_deref(),
2149                        auto_mode,
2150                        args.json,
2151                        resume_session,
2152                        force_configured_route,
2153                        args.output_format,
2154                        max_turns,
2155                        allowed_tools,
2156                        disallowed_tools,
2157                        args.append_system_prompt.clone(),
2158                        args.tool_authority_json.clone(),
2159                        std::sync::Arc::clone(&plugin_registry),
2160                    )
2161                    .await
2162                } else if args.json {
2163                    run_one_shot_json(&config, &model, &prompt, force_configured_route).await
2164                } else {
2165                    run_one_shot(&config, &model, &prompt, force_configured_route).await
2166                }
2167            }
2168            Commands::Fleet(args) => {
2169                let config = load_config_from_cli(&cli)?;
2170                let workspace = resolve_workspace(&cli);
2171                run_fleet_command(&workspace, &config, args).await
2172            }
2173            Commands::WorkflowTool(args) => {
2174                run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
2175            }
2176            Commands::Review(args) => {
2177                let config = load_config_from_cli(&cli)?;
2178                run_review(&config, args).await
2179            }
2180            Commands::Pr {
2181                number,
2182                repo,
2183                checkout,
2184            } => {
2185                let config = load_config_from_cli(&cli)?;
2186                run_pr(
2187                    &cli,
2188                    &config,
2189                    number,
2190                    repo.as_deref(),
2191                    checkout,
2192                    pending_telemetry_notice.take(),
2193                    Arc::clone(&plugin_registry),
2194                )
2195                .await
2196            }
2197            Commands::Apply(args) => run_apply(args),
2198            Commands::Eval(args) => run_eval(args),
2199            Commands::Scorecard(args) => run_scorecard(args),
2200            Commands::Mcp { command } => {
2201                let config = load_config_from_cli(&cli)?;
2202                let workspace = resolve_workspace(&cli);
2203                run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
2204            }
2205            Commands::Features(command) => {
2206                let config = load_config_from_cli(&cli)?;
2207                run_features_command(&config, command)
2208            }
2209            Commands::Integrations { command } => {
2210                // Identity derivation is structural: credential-bearing
2211                // environment values never enter this path.
2212                let config = load_structural_config_from_cli(&cli)?;
2213                let workspace = resolve_workspace(&cli);
2214                integrations::cli::run(&config, &workspace, command)
2215            }
2216            Commands::Sandbox(args) => run_sandbox_command(args),
2217            Commands::Serve(args) => {
2218                let workspace = cli.workspace.clone().unwrap_or_else(|| {
2219                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2220                });
2221                let http_selected = validate_serve_mode_selection(
2222                    args.mcp,
2223                    args.http,
2224                    args.mobile,
2225                    args.web,
2226                    args.acp,
2227                )?;
2228                if args.mcp {
2229                    tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
2230                } else if http_selected {
2231                    let (config, config_profile) =
2232                        load_config_from_cli_with_effective_profile(&cli)?;
2233                    let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
2234                    let bind_host = resolve_serve_bind_host(args.mobile, args.host);
2235                    if args.web && bind_host.host != "127.0.0.1" {
2236                        bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2237                    }
2238                    if bind_host.mobile_rebound_to_lan {
2239                        println!(
2240                            "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."
2241                        );
2242                    }
2243                    runtime_api::run_http_server(
2244                        config,
2245                        workspace,
2246                        std::sync::Arc::clone(&plugin_discovery),
2247                        runtime_api::RuntimeApiOptions {
2248                            host: bind_host.host,
2249                            port: args.port,
2250                            workers: args.workers.clamp(1, 8),
2251                            cors_origins,
2252                            auth_token: args.auth_token,
2253                            insecure_no_auth: args.insecure_no_auth,
2254                            mobile: args.mobile,
2255                            web: args.web,
2256                            show_qr: args.qr,
2257                            config_path: cli.config.clone(),
2258                            config_profile,
2259                        },
2260                    )
2261                    .await
2262                } else if args.acp {
2263                    let config = load_config_from_cli(&cli)?;
2264                    let model = config.default_model();
2265                    acp_server::run_acp_server(config, model, workspace).await
2266                } else {
2267                    unreachable!("server mode count checked above")
2268                }
2269            }
2270            Commands::Resume { session_id, last } => {
2271                let config = load_config_from_cli(&cli)?;
2272                let workspace = resolve_workspace(&cli);
2273                let resume_id = resolve_session_id(session_id, last, &workspace)?;
2274                run_interactive(
2275                    &cli,
2276                    &config,
2277                    Some(resume_id),
2278                    None,
2279                    pending_telemetry_notice.take(),
2280                    std::sync::Arc::clone(&plugin_registry),
2281                )
2282                .await
2283            }
2284            Commands::Fork { session_id, last } => {
2285                let config = load_config_from_cli(&cli)?;
2286                let workspace = resolve_workspace(&cli);
2287                let new_session_id = fork_session(&config, session_id, last, &workspace)?;
2288                run_interactive(
2289                    &cli,
2290                    &config,
2291                    Some(new_session_id),
2292                    None,
2293                    pending_telemetry_notice.take(),
2294                    std::sync::Arc::clone(&plugin_registry),
2295                )
2296                .await
2297            }
2298        };
2299    }
2300
2301    // Top-level prompt mode: submit the initial prompt, then keep the TUI alive
2302    // for follow-up messages. Use `codewhale exec` for explicit non-interactive
2303    // one-shot behavior (#2370).
2304    let config = load_config_from_cli(&cli)?;
2305    if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
2306        return run_interactive(
2307            &cli,
2308            &config,
2309            None,
2310            Some(initial_input),
2311            pending_telemetry_notice.take(),
2312            std::sync::Arc::clone(&plugin_registry),
2313        )
2314        .await;
2315    }
2316
2317    // Handle session resume. Plain `codewhale` starts fresh: interrupted
2318    // snapshots are preserved for explicit resume, but never auto-attached.
2319    let mut startup_notice = None;
2320    let resume_session_id = if cli.continue_session {
2321        let workspace = resolve_workspace(&cli);
2322        recover_interrupted_checkpoint_for_resume(&workspace)
2323            .or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
2324    } else if let Some(id) = cli.resume.clone() {
2325        Some(id)
2326    } else if !cli.fresh {
2327        let workspace = resolve_workspace(&cli);
2328        preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
2329        // Opt-in auto-resume (#2934). Off by default, so the historical
2330        // "plain `codewhale` starts fresh" behaviour is unchanged unless the
2331        // user asked for something else. The decision never resumes an
2332        // archived, unreadable, or foreign-workspace session; every fallback
2333        // carries a receipt rather than silently starting blank.
2334        let (session_id, notice) = resolve_auto_resume(&workspace);
2335        startup_notice = notice;
2336        session_id
2337    } else {
2338        None
2339    };
2340
2341    // Default: Interactive TUI
2342    // --yolo starts in YOLO mode (auto-approve; shell enabled)
2343    run_interactive_with_notice(
2344        &cli,
2345        &config,
2346        resume_session_id,
2347        None,
2348        startup_notice,
2349        pending_telemetry_notice.take(),
2350        plugin_registry,
2351    )
2352    .await
2353}
2354
2355/// Resolve the opt-in auto-resume setting into a session id plus a receipt.
2356///
2357/// Deliberately scoped to the plain interactive launch. `codewhale "do X"`
2358/// (top-level prompt) and `codewhale exec` are not covered: silently prefixing
2359/// a one-shot task with a prior conversation would change what is sent to the
2360/// model, which is not a layout preference the user opted into.
2361fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
2362    use crate::session_resume::{ResumeRequest, decide_auto_resume};
2363
2364    let enabled = crate::settings::Settings::load_persisted()
2365        .map(|settings| settings.session_auto_resume)
2366        .unwrap_or(false);
2367    if !enabled {
2368        return (None, None);
2369    }
2370    let Ok(manager) = SessionManager::default_location() else {
2371        return (None, None);
2372    };
2373    let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
2374    (
2375        decision.session_id().map(str::to_string),
2376        decision.status_message(),
2377    )
2378}
2379
2380fn prepare_cli_startup(
2381    cli: Cli,
2382    initialize_plugins: impl FnOnce(),
2383    load_dotenv: impl FnOnce(),
2384) -> (Cli, Option<Commands>) {
2385    initialize_plugins();
2386    let command = cli.command.clone();
2387    let should_load_dotenv = match command.as_ref() {
2388        Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
2389        _ => true,
2390    };
2391    if should_load_dotenv {
2392        load_dotenv();
2393    }
2394    (cli, command)
2395}
2396
2397const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
2398
2399#[derive(Debug, Default)]
2400struct WorkspaceDotenvReport {
2401    path: PathBuf,
2402    loaded: BTreeSet<String>,
2403    ignored: BTreeSet<String>,
2404}
2405
2406/// Load the narrow, data-plane subset of a workspace `.env` before Tokio.
2407///
2408/// Repository content is not product authority. In particular, a committed
2409/// `.env` must not be able to redirect `CODEWHALE_HOME`, config/profile files,
2410/// MCP servers, plugin trust, executable lookup, sandbox/approval posture, or
2411/// network destinations. Shell-exported values and config/CLI arguments remain
2412/// the explicit surfaces for those controls.
2413fn warn_on_workspace_dotenv_result() {
2414    match load_workspace_dotenv_credentials() {
2415        Ok(Some(report)) if !report.ignored.is_empty() => {
2416            eprintln!(
2417                "Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
2418                report.path.display(),
2419                display_env_key_set(&report.ignored)
2420            );
2421        }
2422        Ok(_) => {}
2423        Err(error) => {
2424            // The error intentionally contains no file contents or parsed
2425            // values. A malformed or unsafe workspace file fails closed while
2426            // shell/config credentials remain available.
2427            eprintln!("Codewhale did not load workspace .env: {error}");
2428        }
2429    }
2430}
2431
2432fn display_env_key_set(keys: &BTreeSet<String>) -> String {
2433    const MAX_DISPLAYED: usize = 12;
2434    let mut labels = keys
2435        .iter()
2436        .take(MAX_DISPLAYED)
2437        .map(|key| {
2438            if key
2439                .chars()
2440                .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
2441            {
2442                key.as_str()
2443            } else {
2444                "<invalid-name>"
2445            }
2446        })
2447        .collect::<Vec<_>>();
2448    if keys.len() > MAX_DISPLAYED {
2449        labels.push("...");
2450    }
2451    labels.join(", ")
2452}
2453
2454fn load_workspace_dotenv_credentials() -> Result<Option<WorkspaceDotenvReport>> {
2455    let Some(path) = find_workspace_dotenv()? else {
2456        return Ok(None);
2457    };
2458    load_workspace_dotenv_credentials_from_path(&path).map(Some)
2459}
2460
2461fn find_workspace_dotenv() -> Result<Option<PathBuf>> {
2462    let cwd = std::env::current_dir().context("could not resolve the current workspace")?;
2463    let boundary = cwd
2464        .ancestors()
2465        .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
2466        .unwrap_or(cwd.as_path());
2467
2468    for ancestor in cwd.ancestors() {
2469        let candidate = ancestor.join(".env");
2470        match std::fs::symlink_metadata(&candidate) {
2471            Ok(_) => return Ok(Some(candidate)),
2472            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2473            Err(error) => {
2474                return Err(anyhow!(
2475                    "could not inspect {}: {error}",
2476                    candidate.display()
2477                ));
2478            }
2479        }
2480        if ancestor == boundary {
2481            break;
2482        }
2483    }
2484    Ok(None)
2485}
2486
2487fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
2488    let contents = read_stable_workspace_dotenv(path)?;
2489    let text = std::str::from_utf8(&contents)
2490        .map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
2491    if dotenv_has_variable_expansion(text) {
2492        bail!(
2493            "{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
2494            path.display()
2495        );
2496    }
2497
2498    let mut report = WorkspaceDotenvReport {
2499        path: path.to_path_buf(),
2500        ..WorkspaceDotenvReport::default()
2501    };
2502    let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
2503        .collect::<std::result::Result<Vec<_>, _>>()
2504        .map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
2505    for entry in entries {
2506        let (key, value) = entry;
2507        if !is_workspace_dotenv_credential_key(&key) {
2508            report.ignored.insert(key);
2509            continue;
2510        }
2511        if std::env::var_os(&key).is_some() {
2512            continue;
2513        }
2514
2515        // SAFETY: this loader runs synchronously in `main` before the runtime
2516        // owner or Tokio workers are spawned. No concurrent environment reader
2517        // exists inside Codewhale, and later startup code treats this process
2518        // environment as immutable.
2519        unsafe { std::env::set_var(&key, value) };
2520        report.loaded.insert(key);
2521    }
2522    Ok(report)
2523}
2524
2525fn is_workspace_dotenv_credential_key(key: &str) -> bool {
2526    codewhale_config::provider::providers_sorted_for_display()
2527        .into_iter()
2528        .any(|provider| provider.env_vars().contains(&key))
2529        || matches!(
2530            key,
2531            "DEEPSEEK_SEARCH_API_KEY"
2532                | "SOFYA_API_KEY"
2533                | "METASO_API_KEY"
2534                | "BAIDU_SEARCH_API_KEY"
2535                | "DEEPSEEK_SANDBOX_API_KEY"
2536        )
2537}
2538
2539fn dotenv_has_variable_expansion(contents: &str) -> bool {
2540    let mut escaped = false;
2541    let mut single_quoted = false;
2542    let mut double_quoted = false;
2543    let mut comment = false;
2544
2545    for ch in contents.chars() {
2546        if comment {
2547            // Reject expansion markers even in comments. This is deliberately
2548            // conservative, and ignoring other comment text prevents an
2549            // unmatched quote there from changing how the next line is read.
2550            if ch == '$' {
2551                return true;
2552            }
2553            if ch == '\n' {
2554                comment = false;
2555                escaped = false;
2556            }
2557            continue;
2558        }
2559        if single_quoted {
2560            if ch == '\'' {
2561                single_quoted = false;
2562            }
2563            continue;
2564        }
2565        if escaped {
2566            escaped = false;
2567            continue;
2568        }
2569        if ch == '\\' {
2570            escaped = true;
2571            continue;
2572        }
2573        if ch == '\'' && !double_quoted {
2574            single_quoted = true;
2575            continue;
2576        }
2577        if ch == '"' {
2578            double_quoted = !double_quoted;
2579            continue;
2580        }
2581        if ch == '#' && !double_quoted {
2582            comment = true;
2583            continue;
2584        }
2585        if ch == '$' {
2586            return true;
2587        }
2588    }
2589    false
2590}
2591
2592fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
2593    let mut file = open_workspace_dotenv_without_following_links(path)?;
2594    let metadata = file
2595        .metadata()
2596        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2597    if !metadata.is_file() {
2598        bail!("{} is not a regular file", path.display());
2599    }
2600    if workspace_dotenv_has_multiple_links(&file, &metadata)? {
2601        bail!(
2602            "{} has multiple filesystem links, not a unique workspace-owned file",
2603            path.display()
2604        );
2605    }
2606    if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
2607        bail!(
2608            "{} exceeds the {} byte workspace .env limit",
2609            path.display(),
2610            MAX_WORKSPACE_DOTENV_BYTES
2611        );
2612    }
2613
2614    let mut contents = Vec::with_capacity(metadata.len() as usize);
2615    (&mut file)
2616        .take(MAX_WORKSPACE_DOTENV_BYTES + 1)
2617        .read_to_end(&mut contents)
2618        .map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
2619    if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
2620        bail!(
2621            "{} exceeds the {} byte workspace .env limit",
2622            path.display(),
2623            MAX_WORKSPACE_DOTENV_BYTES
2624        );
2625    }
2626    Ok(contents)
2627}
2628
2629#[cfg(unix)]
2630fn workspace_dotenv_has_multiple_links(
2631    _file: &std::fs::File,
2632    metadata: &std::fs::Metadata,
2633) -> Result<bool> {
2634    use std::os::unix::fs::MetadataExt;
2635
2636    Ok(metadata.nlink() > 1)
2637}
2638
2639#[cfg(windows)]
2640fn workspace_dotenv_has_multiple_links(
2641    file: &std::fs::File,
2642    _metadata: &std::fs::Metadata,
2643) -> Result<bool> {
2644    use std::os::windows::io::AsRawHandle;
2645    use windows::Win32::Foundation::HANDLE;
2646    use windows::Win32::Storage::FileSystem::{
2647        BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
2648    };
2649
2650    let mut information = BY_HANDLE_FILE_INFORMATION::default();
2651    // SAFETY: `file` owns a live kernel handle for the already-open `.env`;
2652    // `information` remains writable for the duration of this synchronous
2653    // call. No path lookup or re-open occurs here.
2654    unsafe {
2655        GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
2656            .map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
2657    }
2658    Ok(information.nNumberOfLinks > 1)
2659}
2660
2661#[cfg(not(any(unix, windows)))]
2662fn workspace_dotenv_has_multiple_links(
2663    _file: &std::fs::File,
2664    _metadata: &std::fs::Metadata,
2665) -> Result<bool> {
2666    Ok(false)
2667}
2668
2669#[cfg(unix)]
2670fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2671    use std::os::unix::fs::OpenOptionsExt;
2672
2673    std::fs::OpenOptions::new()
2674        .read(true)
2675        // `O_NONBLOCK` is inert for regular files but prevents a FIFO named
2676        // `.env` from hanging startup before the metadata check can reject it.
2677        .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
2678        .open(path)
2679        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2680}
2681
2682#[cfg(windows)]
2683fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2684    use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
2685
2686    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
2687    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2688    let file = std::fs::OpenOptions::new()
2689        .read(true)
2690        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
2691        .open(path)
2692        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
2693    let metadata = file
2694        .metadata()
2695        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2696    if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
2697        bail!(
2698            "{} is a reparse point, not a workspace-owned file",
2699            path.display()
2700        );
2701    }
2702    Ok(file)
2703}
2704
2705#[cfg(not(any(unix, windows)))]
2706fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2707    let metadata = std::fs::symlink_metadata(path)
2708        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2709    if metadata.file_type().is_symlink() {
2710        bail!(
2711            "{} is a symbolic link, not a workspace-owned file",
2712            path.display()
2713        );
2714    }
2715    std::fs::File::open(path)
2716        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2717}
2718
2719/// Generate shell completions for the given shell
2720fn generate_completions(shell: Shell) {
2721    let mut cmd = Cli::command();
2722    let name = cmd.get_name().to_string();
2723    generate(shell, &mut cmd, name, &mut io::stdout());
2724}
2725
2726/// Run the offline evaluation harness (no network/LLM calls).
2727fn run_eval(args: EvalArgs) -> Result<()> {
2728    let fail_step = match args.fail_step.as_deref() {
2729        Some(value) => ScenarioStepKind::parse(value)
2730            .map(Some)
2731            .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
2732        None => None,
2733    };
2734
2735    let config = EvalHarnessConfig {
2736        fail_step,
2737        shell_command: args.shell_command,
2738        shell_expect_token: args.shell_expect_token,
2739        max_output_chars: args.max_output_chars,
2740        record_dir: args.record.clone(),
2741        ..EvalHarnessConfig::default()
2742    };
2743
2744    let harness = EvalHarness::new(config);
2745    let run = harness.run().context("evaluation harness failed")?;
2746    let report = run.to_report();
2747
2748    if args.json {
2749        let json = serde_json::to_string_pretty(&report)?;
2750        println!("{json}");
2751    } else {
2752        println!("Offline Eval Harness");
2753        println!("scenario: {}", report.scenario_name);
2754        println!("workspace: {}", report.workspace_root.display());
2755        println!("success: {}", report.metrics.success);
2756        println!("steps: {}", report.metrics.steps);
2757        println!("tool_errors: {}", report.metrics.tool_errors);
2758        println!("duration_ms: {}", report.metrics.duration.as_millis());
2759
2760        if !report.metrics.per_tool.is_empty() {
2761            println!("per_tool:");
2762            for (kind, stats) in &report.metrics.per_tool {
2763                println!(
2764                    "  {} invocations={} errors={} duration_ms={}",
2765                    kind.tool_name(),
2766                    stats.invocations,
2767                    stats.errors,
2768                    stats.total_duration.as_millis()
2769                );
2770            }
2771        }
2772
2773        let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
2774        if !failed_steps.is_empty() {
2775            println!("failed_steps:");
2776            for step in failed_steps {
2777                let error = step.error.as_deref().unwrap_or("unknown error");
2778                println!(
2779                    "  {} tool={} error={}",
2780                    step.kind.tool_name(),
2781                    step.tool_name,
2782                    error
2783                );
2784            }
2785        }
2786    }
2787
2788    if report.metrics.success {
2789        Ok(())
2790    } else {
2791        bail!("offline evaluation harness reported failure")
2792    }
2793}
2794
2795/// Score a run's token/cache/cost from recorded turns and (optionally) flag
2796/// regressions against a committed baseline. Offline: reads recorded usage from
2797/// a JSON file, reuses the pricing layer, never calls a model. Exits non-zero
2798/// when a baseline is supplied and a metric regresses past the threshold, so it
2799/// can be wired as a release gate (#3388).
2800fn run_scorecard(args: ScorecardArgs) -> Result<()> {
2801    use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
2802
2803    let raw = std::fs::read_to_string(&args.input)
2804        .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
2805    let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
2806        .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
2807
2808    let card = Scorecard::from_recorded_turns(&recorded);
2809
2810    let regressions = match &args.baseline {
2811        Some(path) => {
2812            let baseline_raw = std::fs::read_to_string(path)
2813                .with_context(|| format!("failed to read baseline {}", path.display()))?;
2814            let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
2815                .with_context(|| format!("failed to parse baseline {}", path.display()))?;
2816            card.metrics.regressions_against(&baseline, args.threshold)
2817        }
2818        None => Vec::new(),
2819    };
2820
2821    if args.json {
2822        let out = serde_json::json!({
2823            "per_turn": card.per_turn,
2824            "metrics": card.metrics,
2825            "regressions": regressions,
2826        });
2827        println!("{}", serde_json::to_string_pretty(&out)?);
2828    } else {
2829        print!("{}", card.to_summary());
2830        for r in &regressions {
2831            println!(
2832                "REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
2833                r.metric, r.baseline, r.current, r.pct_increase
2834            );
2835        }
2836    }
2837
2838    if regressions.is_empty() {
2839        Ok(())
2840    } else {
2841        bail!(
2842            "{} metric(s) regressed past the {:.1}% threshold",
2843            regressions.len(),
2844            args.threshold
2845        )
2846    }
2847}
2848
2849async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
2850    use crate::fleet::alerts::{
2851        FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
2852        FleetEnvSecretResolver,
2853    };
2854    use crate::fleet::control as fleet_control;
2855    use crate::fleet::executor::FleetExecutor;
2856    use crate::fleet::manager::{FleetManager, FleetStatusSnapshot, FleetWorkerInspection};
2857    use codewhale_lane::{ControlOperation, ControlSurface};
2858    use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
2859
2860    // Every label and every row below comes from the shared Fleet control
2861    // surface, so `codewhale fleet …` and `/fleet …` cannot drift in how they
2862    // describe the same durable ledger (#1888, #4022).
2863    fn print_status(status: &FleetStatusSnapshot) {
2864        println!("{}", fleet_control::render_fleet_status_snapshot(status));
2865    }
2866
2867    fn print_inspection(inspection: &FleetWorkerInspection) {
2868        println!("{}", fleet_control::render_inspection(inspection));
2869    }
2870
2871    fn print_artifacts(inspection: &FleetWorkerInspection) {
2872        println!("{}", fleet_control::render_artifacts(inspection));
2873    }
2874
2875    /// Print one shared control receipt on the CLI surface.
2876    fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
2877        if receipt.is_error() {
2878            eprintln!("{}", receipt.render());
2879            let detail = receipt
2880                .failure
2881                .as_ref()
2882                .map(ToString::to_string)
2883                .unwrap_or_else(|| receipt.outcome.as_str().to_string());
2884            bail!("{}: {detail}", receipt.operation_id);
2885        }
2886        println!("{}", receipt.render());
2887        Ok(())
2888    }
2889
2890    fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
2891        let mut printed = false;
2892        for artifact in inspection
2893            .artifacts
2894            .iter()
2895            .filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
2896        {
2897            let path = workspace.join(&artifact.path);
2898            println!("== {} ==", artifact.path.display());
2899            let contents = std::fs::read_to_string(&path)
2900                .with_context(|| format!("reading fleet log {}", path.display()))?;
2901            let preview: String = contents.chars().take(16 * 1024).collect();
2902            // Worker logs can contain captured terminal bytes (a child TUI's
2903            // mouse-tracking handshake, SGR, OSC). Printing them raw would
2904            // re-arm mouse reporting in the caller's shell and leave it
2905            // executing escape fragments after this command exits.
2906            let mut safe_preview = String::with_capacity(preview.len());
2907            crate::tui::osc8::strip_ansi_into(&preview, &mut safe_preview);
2908            print!("{safe_preview}");
2909            if contents.chars().count() > preview.chars().count() {
2910                println!("\n[truncated]");
2911            } else if !preview.ends_with('\n') {
2912                println!();
2913            }
2914            printed = true;
2915        }
2916        if !printed {
2917            println!("logs: none");
2918        }
2919        Ok(())
2920    }
2921
2922    fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
2923        match arg {
2924            FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
2925            FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
2926            FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
2927            FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
2928            FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
2929            FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
2930        }
2931    }
2932
2933    fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
2934        if let Some(status) = override_status {
2935            return status;
2936        }
2937        match class {
2938            FleetAlertEventClass::Stale => "stale",
2939            FleetAlertEventClass::RestartExhausted => "failed",
2940            FleetAlertEventClass::NeedsHuman => "needs_human",
2941            FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
2942            FleetAlertEventClass::VerifierFailed => "verifier_failed",
2943            FleetAlertEventClass::RunCompleted => "completed",
2944        }
2945        .to_string()
2946    }
2947
2948    fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
2949        match args.adapter {
2950            FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
2951                webhook_env: args.slack_webhook_env.clone(),
2952                channel: None,
2953            },
2954            FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
2955                url_env: args.webhook_url_env.clone(),
2956                secret_env: args.webhook_secret_env.clone(),
2957            },
2958            FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
2959                routing_key_env: args.pagerduty_routing_key_env.clone(),
2960                severity: args.pagerduty_severity.clone(),
2961            },
2962        }
2963    }
2964
2965    let fleet_config = config.fleet_config();
2966    let provider = config.api_provider();
2967    let max_subagents = config.max_subagents_for_provider(provider);
2968    let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout(
2969        workspace.to_path_buf(),
2970        max_subagents,
2971        config
2972            .max_admitted_subagents_for_provider(provider)
2973            .max(max_subagents),
2974        Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
2975        config.launch_concurrency_for_provider(provider),
2976        config.subagent_token_budget_for_provider(provider),
2977    );
2978    // Probe the durable ledger *before* opening the manager: FleetManager::open
2979    // creates `.codewhale/fleet.jsonl` as a side effect, so a later probe would
2980    // always find a ledger and the CLI would report availability differently
2981    // from the slash surface for the same workspace (#4022).
2982    let fleet_context = fleet_control::fleet_control_context(workspace);
2983    // Probing is not enough on its own: `FleetManager::open` *creates* the
2984    // ledger, and it used to run for every subcommand before this match. That
2985    // made `codewhale fleet status` in a ledgerless workspace print
2986    // "no_fleet_ledger" while simultaneously creating the file it said was
2987    // missing — and the next invocation then reported an empty ledger as if a
2988    // Fleet had existed all along. Refuse the control verbs here, before the
2989    // manager exists, so the CLI and `/fleet` agree and neither surface
2990    // conjures the store it is reporting on (#4022).
2991    if let Some(operation) = match &args.command {
2992        FleetCommand::List => Some(ControlOperation::FleetList),
2993        FleetCommand::Status => Some(ControlOperation::FleetStatus),
2994        FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
2995        FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
2996        _ => None,
2997    } {
2998        let descriptor = operation.descriptor();
2999        let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
3000        if !availability.is_available() {
3001            return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
3002                descriptor,
3003                ControlSurface::Cli,
3004                availability,
3005            ));
3006        }
3007    }
3008
3009    // The configured route is the operator: fleet workers without a
3010    // task/profile model pin inherit the session's active model.
3011    let manager = FleetManager::open(workspace)?
3012        .with_exec_config(fleet_config.exec.clone())
3013        .with_fleet_config(fleet_config)
3014        .with_sub_agent_manager(coordination_manager)
3015        .with_session_model(config.default_model())
3016        .with_route_config(config.clone());
3017    match args.command {
3018        FleetCommand::Init => {
3019            println!("fleet ledger: {}", manager.ledger_path().display());
3020            Ok(())
3021        }
3022        FleetCommand::Run(args) => {
3023            let max_workers = args.max_workers.clamp(1, 128);
3024            let manager =
3025                manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
3026            let report = manager.create_run_from_task_spec_path(&args.task_spec, max_workers)?;
3027            println!(
3028                "fleet run: {} tasks={} leased={} queued={}",
3029                report.run_id.0, report.task_count, report.leased, report.queued
3030            );
3031            for warning in &report.warnings {
3032                println!("warning: {warning}");
3033            }
3034            println!("workers:");
3035            for worker_id in &report.worker_ids {
3036                println!("  {worker_id}");
3037            }
3038            if args.once {
3039                print_status(&manager.run_status(&report.run_id)?);
3040                return Ok(());
3041            }
3042            println!(
3043                "manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
3044            );
3045            let mut executor = FleetExecutor::new(workspace);
3046            let codewhale_binary = fleet::executor::configured_codewhale_binary();
3047            let status = manager
3048                .run_to_completion(
3049                    &report.run_id,
3050                    max_workers,
3051                    &mut executor,
3052                    &codewhale_binary,
3053                    None,
3054                    Duration::from_secs(2),
3055                )
3056                .await?;
3057            print_status(&status);
3058            Ok(())
3059        }
3060        FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3061            ControlSurface::Cli,
3062            workspace,
3063            fleet_context,
3064            &manager,
3065            ControlOperation::FleetList,
3066            None,
3067        )),
3068        FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3069            ControlSurface::Cli,
3070            workspace,
3071            fleet_context,
3072            &manager,
3073            ControlOperation::FleetStatus,
3074            None,
3075        )),
3076        FleetCommand::Inspect { worker_id } => {
3077            print_inspection(&manager.inspect_worker(&worker_id)?);
3078            Ok(())
3079        }
3080        FleetCommand::Logs { worker_id } => {
3081            let inspection = manager.inspect_worker(&worker_id)?;
3082            print_logs(workspace, &inspection)
3083        }
3084        FleetCommand::Artifacts { worker_id } => {
3085            let inspection = manager.inspect_worker(&worker_id)?;
3086            print_artifacts(&inspection);
3087            Ok(())
3088        }
3089        FleetCommand::Interrupt { worker_id } => {
3090            emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3091                ControlSurface::Cli,
3092                workspace,
3093                fleet_context,
3094                &manager,
3095                ControlOperation::FleetInterrupt,
3096                Some(&worker_id),
3097            ))
3098        }
3099        FleetCommand::Restart { worker_id } => {
3100            let report = manager.restart_worker(&worker_id)?;
3101            print_inspection(&report.inspection);
3102            println!(
3103                "manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
3104                report.run_id.0
3105            );
3106            let mut executor = FleetExecutor::new(workspace);
3107            let codewhale_binary = fleet::executor::configured_codewhale_binary();
3108            let status = manager
3109                .run_to_completion(
3110                    &report.run_id,
3111                    report.max_workers,
3112                    &mut executor,
3113                    &codewhale_binary,
3114                    None,
3115                    Duration::from_secs(2),
3116                )
3117                .await?;
3118            print_status(&status);
3119            Ok(())
3120        }
3121        FleetCommand::Resume {
3122            run_id,
3123            stale_after_seconds,
3124        } => {
3125            let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
3126            emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3127                ControlSurface::Cli,
3128                workspace,
3129                fleet_context,
3130                &manager,
3131                ControlOperation::FleetResume,
3132                Some(&run_id),
3133            ))
3134        }
3135        FleetCommand::Stop { all } => {
3136            if !all {
3137                bail!("pass --all to stop all fleet work");
3138            }
3139            let stopped = manager.stop_all()?;
3140            println!("stopped: {stopped}");
3141            Ok(())
3142        }
3143        FleetCommand::AlertDryRun(args) => {
3144            let class = alert_event_class(args.event);
3145            let adapter = alert_adapter(&args);
3146            let event = FleetAlertEvent {
3147                class,
3148                run_id: FleetRunId::from(args.run_id.clone()),
3149                worker_id: args.worker_id.clone(),
3150                task_id: args.task_id.clone(),
3151                status: alert_status(class, args.status.clone()),
3152                reason: args.reason.clone(),
3153            };
3154            let dispatcher = FleetAlertDispatcher::new(
3155                FleetAlertConfig::dry_run_for_adapter(adapter),
3156                FleetEnvSecretResolver,
3157            );
3158            let deliveries = dispatcher.dispatch(&event)?;
3159            for delivery in deliveries {
3160                println!(
3161                    "{}",
3162                    serde_json::to_string_pretty(&delivery.redacted_payload)?
3163                );
3164            }
3165            Ok(())
3166        }
3167    }
3168}
3169
3170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3171enum WriteStatus {
3172    Created,
3173    Overwritten,
3174    SkippedExists,
3175}
3176
3177fn ensure_parent_dir(path: &Path) -> Result<()> {
3178    if let Some(parent) = path.parent()
3179        && !parent.as_os_str().is_empty()
3180    {
3181        std::fs::create_dir_all(parent)
3182            .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
3183    }
3184    Ok(())
3185}
3186
3187fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
3188    ensure_parent_dir(path)?;
3189
3190    if path.exists() && !force {
3191        return Ok(WriteStatus::SkippedExists);
3192    }
3193
3194    let status = if path.exists() {
3195        WriteStatus::Overwritten
3196    } else {
3197        WriteStatus::Created
3198    };
3199
3200    std::fs::write(path, contents)
3201        .with_context(|| format!("Failed to write template at {}", path.display()))?;
3202
3203    Ok(status)
3204}
3205
3206fn mcp_template_json() -> Result<String> {
3207    let mut cfg = McpConfig::default();
3208    cfg.servers.insert(
3209        "example".to_string(),
3210        McpServerConfig {
3211            command: Some("node".to_string()),
3212            args: vec!["./path/to/your-mcp-server.js".to_string()],
3213            env: std::collections::HashMap::new(),
3214            cwd: None,
3215            url: None,
3216            transport: None,
3217            connect_timeout: None,
3218            execute_timeout: None,
3219            read_timeout: None,
3220            disabled: true,
3221            enabled: true,
3222            required: false,
3223            enabled_tools: Vec::new(),
3224            disabled_tools: Vec::new(),
3225            headers: std::collections::HashMap::new(),
3226            env_headers: std::collections::HashMap::new(),
3227            bearer_token_env_var: None,
3228            scopes: Vec::new(),
3229            oauth: None,
3230            oauth_resource: None,
3231            reviewed_plugin: None,
3232        },
3233    );
3234    serde_json::to_string_pretty(&cfg)
3235        .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
3236}
3237
3238fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
3239    let template = mcp_template_json()?;
3240    write_template_file(path, &template, force)
3241}
3242
3243fn skills_template(name: &str) -> String {
3244    format!(
3245        "\
3246---\n\
3247name: {name}\n\
3248description: Quick repo diagnostics and setup guidance\n\
3249allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
3250---\n\n\
3251When this skill is active:\n\
32521. Run the diagnostics tool to report workspace and sandbox status.\n\
32532. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
32543. Prefer small, validated changes and summarize what you verified.\n\
3255"
3256    )
3257}
3258
3259fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
3260    std::fs::create_dir_all(skills_dir)
3261        .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
3262
3263    let skill_name = "getting-started";
3264    let skill_path = skills_dir.join(skill_name).join("SKILL.md");
3265    ensure_parent_dir(&skill_path)?;
3266
3267    let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
3268    Ok((skill_path, status))
3269}
3270
3271fn tools_readme_template() -> &'static str {
3272    "# Local tools\n\n\
3273     Drop self-describing scripts here so they can be discovered by\n\
3274     `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
3275     When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
3276     `~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
3277     registered as model-visible tools.\n\n\
3278     Each script should start with a frontmatter-style header so the\n\
3279     description is visible without executing the file and the agent knows\n\
3280     the tool name, description, and input schema:\n\n\
3281     ```\n\
3282     # name: my-tool\n\
3283     # description: One-line summary of what this tool does\n\
3284     # usage: my-tool [args...]\n\
3285     ```\n\n\
3286     The directory is intentionally not auto-loaded into the agent's tool\n\
3287     catalog. Wire individual tools through MCP, hooks, or skills when you\n\
3288     want them available inside a session.\n"
3289}
3290
3291fn tools_example_script() -> &'static str {
3292    "#!/usr/bin/env sh\n\
3293     # name: example\n\
3294     # description: Print a confirmation that local tool discovery works\n\
3295     # usage: example [name]\n\
3296     printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n"
3297}
3298
3299fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
3300    std::fs::create_dir_all(tools_dir)
3301        .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
3302
3303    let readme_path = tools_dir.join("README.md");
3304    let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
3305
3306    let example_path = tools_dir.join("example.sh");
3307    let example_status = write_template_file(&example_path, tools_example_script(), force)?;
3308
3309    Ok((tools_dir.to_path_buf(), readme_status, example_status))
3310}
3311
3312fn plugins_readme_template() -> &'static str {
3313    "# Local plugins\n\n\
3314     Each Codewhale plugin bundle lives in its own subdirectory with a\n\
3315     versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
3316     under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
3317     untrusted, and disabled by default.\n\n\
3318     A v0.9.1 bundle layout looks like:\n\n\
3319     ```\n\
3320     plugins/\n\
3321       my-plugin/\n\
3322         plugin.toml\n\
3323         skills/\n\
3324           my-skill/SKILL.md\n\
3325     ```\n\n\
3326     Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
3327     Enablement opens a content- and capability-bound trust review;\n\
3328     confirm the displayed `/plugin trust` command to create an owner-only,\n\
3329     content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
3330     authentication must name environment sources; never store secret values\n\
3331     in `plugin.toml`.\n\n\
3332     Codewhale activates only declarative Skills and MCP servers through their\n\
3333     existing engines. Commands, agents, hooks, LSP, native extensions,\n\
3334     filesystem grants, and lifecycle mutation stay inventoried and inactive;\n\
3335     a mixed bundle can still activate its supported Skills and MCP.\n\
3336     There is no marketplace, install, update, ambient compatibility scan, or\n\
3337     automatic trust surface in this release.\n"
3338}
3339
3340fn plugin_example_manifest_template() -> &'static str {
3341    "schema_version = 1\n\n\
3342     [plugin]\n\
3343     name = \"example\"\n\
3344     version = \"0.1.0\"\n\
3345     description = \"Starter Codewhale plugin bundle\"\n\n\
3346     [skills]\n\
3347     path = \"skills\"\n"
3348}
3349
3350fn plugin_example_skill_template() -> &'static str {
3351    "---\n\
3352     name: hello\n\
3353     description: Explain that the example plugin bundle is active.\n\
3354     ---\n\n\
3355     Tell the user this instruction came from the namespaced\n\
3356     `example:hello` plugin skill. Do not perform side effects.\n"
3357}
3358
3359fn init_plugins_dir(
3360    plugins_dir: &Path,
3361    force: bool,
3362) -> Result<(
3363    PathBuf,
3364    PathBuf,
3365    PathBuf,
3366    WriteStatus,
3367    WriteStatus,
3368    WriteStatus,
3369)> {
3370    std::fs::create_dir_all(plugins_dir)
3371        .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
3372
3373    let readme_path = plugins_dir.join("README.md");
3374    let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
3375
3376    let manifest_path = plugins_dir.join("example").join("plugin.toml");
3377    ensure_parent_dir(&manifest_path)?;
3378    let manifest_status =
3379        write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
3380
3381    let skill_path = plugins_dir
3382        .join("example")
3383        .join("skills")
3384        .join("hello")
3385        .join("SKILL.md");
3386    ensure_parent_dir(&skill_path)?;
3387    let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
3388
3389    Ok((
3390        readme_path,
3391        manifest_path,
3392        skill_path,
3393        readme_status,
3394        manifest_status,
3395        skill_status,
3396    ))
3397}
3398
3399/// Resolve the user-supplied CORS origins for `codewhale serve --http`.
3400///
3401/// Sources, in priority order (later sources extend earlier ones):
3402/// 1. `--cors-origin URL` flags (repeatable)
3403/// 2. `CODEWHALE_CORS_ORIGINS` env var (comma-separated),
3404///    then `DEEPSEEK_CORS_ORIGINS` as an alias
3405/// 3. `[runtime_api] cors_origins = [...]` in `config.toml`
3406///
3407/// The runtime API always allows the built-in dev defaults
3408/// (localhost:3000, localhost:1420, tauri://localhost). User entries are
3409/// appended on top — empty strings are skipped, and duplicates are deduped
3410/// while preserving first-seen order. Whalescale#255 / #561.
3411fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
3412    let mut out: Vec<String> = Vec::new();
3413    let mut push = |raw: &str| {
3414        let trimmed = raw.trim();
3415        if trimmed.is_empty() {
3416            return;
3417        }
3418        if !out.iter().any(|existing| existing == trimmed) {
3419            out.push(trimmed.to_string());
3420        }
3421    };
3422    for o in flag_origins {
3423        push(o);
3424    }
3425    if let Ok(env_value) =
3426        std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
3427    {
3428        for piece in env_value.split(',') {
3429            push(piece);
3430        }
3431    }
3432    if let Some(rt) = &config.runtime_api
3433        && let Some(list) = &rt.cors_origins
3434    {
3435        for o in list {
3436            push(o);
3437        }
3438    }
3439    out
3440}
3441
3442fn deepseek_home_dir() -> PathBuf {
3443    codewhale_config::codewhale_home().unwrap_or_else(|_| {
3444        crate::config::effective_home_dir()
3445            .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
3446    })
3447}
3448
3449/// Resolve the default tools directory. Mirrors `default_skills_dir` shape.
3450fn default_tools_dir() -> PathBuf {
3451    deepseek_home_dir().join("tools")
3452}
3453
3454/// Resolve the default plugins directory.
3455fn default_plugins_dir() -> PathBuf {
3456    deepseek_home_dir().join("plugins")
3457}
3458
3459/// Default location for crash/offline-queue checkpoints managed by the TUI.
3460fn default_checkpoints_dir() -> PathBuf {
3461    deepseek_home_dir().join("sessions").join("checkpoints")
3462}
3463
3464#[derive(Debug, Clone, PartialEq, Eq)]
3465struct CleanPlan {
3466    targets: Vec<PathBuf>,
3467}
3468
3469fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
3470    // Every `*.json` file in the checkpoints directory is checkpoint state:
3471    // per-session crash checkpoints (`<session_id>.json`), the legacy
3472    // single-slot checkpoint (`latest.json`), and the offline input queue
3473    // (`offline_queue.json`). Non-JSON files and subdirectories are left
3474    // alone.
3475    let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
3476        .map(|entries| {
3477            entries
3478                .filter_map(|entry| entry.ok().map(|e| e.path()))
3479                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
3480                .collect()
3481        })
3482        .unwrap_or_default();
3483    targets.sort();
3484    CleanPlan { targets }
3485}
3486
3487fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
3488    let mut removed = Vec::with_capacity(plan.targets.len());
3489    for path in &plan.targets {
3490        std::fs::remove_file(path)
3491            .with_context(|| format!("Failed to remove {}", path.display()))?;
3492        removed.push(path.clone());
3493    }
3494    Ok(removed)
3495}
3496
3497fn run_setup(
3498    config: &Config,
3499    workspace: &Path,
3500    args: SetupArgs,
3501    plugins: &crate::plugins::PluginRegistry,
3502) -> Result<()> {
3503    if args.status {
3504        return run_setup_status(config, workspace, plugins);
3505    }
3506    if args.clean {
3507        return run_setup_clean(&default_checkpoints_dir(), args.force);
3508    }
3509
3510    use crate::palette;
3511    use colored::Colorize;
3512
3513    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3514    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3515
3516    let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
3517    let run_mcp = args.mcp || args.all || !any_explicit;
3518    let run_skills = args.skills || args.all || !any_explicit;
3519    let run_tools = args.tools || args.all;
3520    let run_plugins = args.plugins || args.all;
3521
3522    println!(
3523        "{}",
3524        "Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
3525    );
3526    println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
3527    println!("Workspace: {}", crate::utils::display_path(workspace));
3528
3529    if run_mcp {
3530        let mcp_path = config.mcp_config_path();
3531        let status = init_mcp_config(&mcp_path, args.force)?;
3532        match status {
3533            WriteStatus::Created => {
3534                println!("  ✓ Created MCP config at {}", mcp_path.display());
3535            }
3536            WriteStatus::Overwritten => {
3537                println!("  ✓ Overwrote MCP config at {}", mcp_path.display());
3538            }
3539            WriteStatus::SkippedExists => {
3540                println!("  · MCP config already exists at {}", mcp_path.display());
3541            }
3542        }
3543        println!(
3544            "    Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
3545        );
3546    }
3547
3548    if run_skills {
3549        let skills_dir = if args.local {
3550            workspace.join("skills")
3551        } else {
3552            config.skills_dir()
3553        };
3554        let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
3555        match status {
3556            WriteStatus::Created => {
3557                println!("  ✓ Created example skill at {}", skill_path.display());
3558            }
3559            WriteStatus::Overwritten => {
3560                println!("  ✓ Overwrote example skill at {}", skill_path.display());
3561            }
3562            WriteStatus::SkippedExists => {
3563                println!(
3564                    "  · Example skill already exists at {}",
3565                    skill_path.display()
3566                );
3567            }
3568        }
3569        if args.local {
3570            println!(
3571                "    Local skills dir enabled for this workspace: {}",
3572                crate::utils::display_path(&skills_dir)
3573            );
3574        } else {
3575            println!(
3576                "    Skills dir: {}",
3577                crate::utils::display_path(&skills_dir)
3578            );
3579        }
3580        println!("    Next: run the TUI and use `/skills` then `/skill getting-started`.");
3581    }
3582
3583    if run_tools {
3584        let tools_dir = default_tools_dir();
3585        let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
3586        report_write_status("Tools README", &dir.join("README.md"), readme_status);
3587        report_write_status("Example tool", &dir.join("example.sh"), example_status);
3588        println!("    Tools dir: {}", crate::utils::display_path(&dir));
3589        println!("    Next: drop scripts here; surface them via skills/MCP when ready.");
3590    }
3591
3592    if run_plugins {
3593        let plugins_dir = default_plugins_dir();
3594        let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
3595            init_plugins_dir(&plugins_dir, args.force)?;
3596        report_write_status("Plugins README", &readme_path, readme_status);
3597        report_write_status("Example plugin manifest", &manifest_path, manifest_status);
3598        report_write_status("Example plugin skill", &skill_path, skill_status);
3599        println!(
3600            "    Plugins dir: {}",
3601            crate::utils::display_path(&plugins_dir)
3602        );
3603        println!("    Next: run `/plugin validate`, review `example`, then trust and enable it.");
3604    }
3605
3606    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3607        config.prefer_bwrap.unwrap_or(false),
3608    );
3609    if let Some(kind) = sandbox {
3610        println!("  ✓ Sandbox available: {kind}");
3611    } else {
3612        println!("  · Sandbox not available on this platform (best-effort only).");
3613    }
3614
3615    Ok(())
3616}
3617
3618fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
3619    match status {
3620        WriteStatus::Created => {
3621            println!("  ✓ Created {label} at {}", path.display());
3622        }
3623        WriteStatus::Overwritten => {
3624            println!("  ✓ Overwrote {label} at {}", path.display());
3625        }
3626        WriteStatus::SkippedExists => {
3627            println!("  · {label} already exists at {}", path.display());
3628        }
3629    }
3630}
3631
3632/// Source of the resolved API key, used only by static doctor/setup reports.
3633///
3634/// These reports must not migrate a legacy secret store or acquire a
3635/// write-capable credential handle just to label a source.
3636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3637enum ApiKeySource {
3638    ConfigDeclared,
3639    EnvDeclared,
3640    ExternalAuthDeclared,
3641    SecretStoreUnprobed,
3642    SecretStoreUnavailable,
3643    OAuth,
3644    ExternalConsent,
3645    NoAuth,
3646    LocalRuntime,
3647    Unknown,
3648}
3649
3650/// What structural diagnostics can truthfully say about credential
3651/// availability without consulting environment values or durable stores.
3652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3653enum CredentialAvailability {
3654    Present,
3655    NotRequired,
3656    Unknown,
3657    NotProbed,
3658    Unavailable,
3659}
3660
3661impl CredentialAvailability {
3662    fn label(self) -> &'static str {
3663        match self {
3664            Self::Present => "present",
3665            Self::NotRequired => "not_required",
3666            Self::Unknown => "unknown",
3667            Self::NotProbed => "not_probed",
3668            Self::Unavailable => "unavailable",
3669        }
3670    }
3671
3672    fn certifies_ready(self) -> bool {
3673        matches!(self, Self::Present | Self::NotRequired)
3674    }
3675}
3676
3677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3678struct CredentialDiagnostic {
3679    source: ApiKeySource,
3680    availability: CredentialAvailability,
3681}
3682
3683impl CredentialDiagnostic {
3684    const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
3685        Self {
3686            source,
3687            availability,
3688        }
3689    }
3690}
3691
3692fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
3693    let provider = config.api_provider();
3694    let base_url = config.deepseek_base_url();
3695    let auth_mode = config.auth_mode_for_provider(provider);
3696    if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
3697        return CredentialDiagnostic::new(
3698            ApiKeySource::NoAuth,
3699            CredentialAvailability::NotRequired,
3700        );
3701    }
3702    if !crate::config::auth_mode_requires_api_key(auth_mode.as_deref())
3703        && (crate::config::provider_route_is_keyless_self_hosted(provider, &base_url)
3704            || crate::config::base_url_uses_local_host(&base_url))
3705    {
3706        return CredentialDiagnostic::new(
3707            ApiKeySource::LocalRuntime,
3708            CredentialAvailability::NotRequired,
3709        );
3710    }
3711    let custom_endpoint = config.provider_uses_custom_endpoint(provider);
3712    if !custom_endpoint && provider == crate::config::ApiProvider::OpenaiCodex {
3713        return config
3714            .external_credential_consent_status(provider)
3715            .filter(|status| status.route_state == "active")
3716            .map_or_else(
3717                || {
3718                    CredentialDiagnostic::new(
3719                        ApiKeySource::OAuth,
3720                        CredentialAvailability::NotProbed,
3721                    )
3722                },
3723                |_| {
3724                    CredentialDiagnostic::new(
3725                        ApiKeySource::ExternalConsent,
3726                        CredentialAvailability::NotProbed,
3727                    )
3728                },
3729            );
3730    }
3731    if !custom_endpoint
3732        && provider == crate::config::ApiProvider::Xai
3733        && auth_mode
3734            .as_deref()
3735            .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3736    {
3737        return config
3738            .external_credential_consent_status(provider)
3739            .filter(|status| status.route_state == "active")
3740            .map_or_else(
3741                || {
3742                    CredentialDiagnostic::new(
3743                        ApiKeySource::OAuth,
3744                        CredentialAvailability::NotProbed,
3745                    )
3746                },
3747                |_| {
3748                    CredentialDiagnostic::new(
3749                        ApiKeySource::ExternalConsent,
3750                        CredentialAvailability::NotProbed,
3751                    )
3752                },
3753            );
3754    }
3755    let provider_config = config.provider_config();
3756    let provider_config_key_kind = provider_config
3757        .and_then(|entry| entry.api_key.as_deref())
3758        .map(crate::config::classify_config_api_key_value);
3759    let root_key_applies = matches!(
3760        provider,
3761        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
3762    ) || (provider == crate::config::ApiProvider::Custom
3763        && config.uses_legacy_literal_custom_route());
3764    let root_key_kind = root_key_applies
3765        .then_some(config.api_key.as_deref())
3766        .flatten()
3767        .map(crate::config::classify_config_api_key_value);
3768
3769    if matches!(
3770        provider_config_key_kind,
3771        Some(crate::config::ConfigApiKeyValueKind::Literal)
3772    ) || matches!(
3773        root_key_kind,
3774        Some(crate::config::ConfigApiKeyValueKind::Literal)
3775    ) {
3776        CredentialDiagnostic::new(
3777            ApiKeySource::ConfigDeclared,
3778            CredentialAvailability::Present,
3779        )
3780    } else if config
3781        .provider_config()
3782        .and_then(|entry| entry.api_key_env.as_deref())
3783        .is_some_and(|name| !name.trim().is_empty())
3784    {
3785        CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
3786    } else if config
3787        .provider_config()
3788        .and_then(|entry| entry.auth.as_ref())
3789        .is_some()
3790    {
3791        CredentialDiagnostic::new(
3792            ApiKeySource::ExternalAuthDeclared,
3793            CredentialAvailability::NotProbed,
3794        )
3795    } else if matches!(
3796        provider_config_key_kind,
3797        Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3798    ) || matches!(
3799        root_key_kind,
3800        Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3801    ) {
3802        if config.should_skip_secret_store_for_provider(provider) {
3803            return CredentialDiagnostic::new(
3804                ApiKeySource::SecretStoreUnavailable,
3805                CredentialAvailability::Unavailable,
3806            );
3807        }
3808        // The sentinel is a declaration that runtime resolution should use
3809        // the secret-store layer, never a literal key. Doctor does not read it.
3810        CredentialDiagnostic::new(
3811            ApiKeySource::SecretStoreUnprobed,
3812            CredentialAvailability::NotProbed,
3813        )
3814    } else if !config.should_skip_secret_store_for_provider(provider) {
3815        // No literal config declaration was found, but this route can continue
3816        // through the durable store and ambient provider environment. Ordinary
3817        // doctor deliberately does not inspect either source.
3818        CredentialDiagnostic::new(
3819            ApiKeySource::SecretStoreUnprobed,
3820            CredentialAvailability::NotProbed,
3821        )
3822    } else {
3823        CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
3824    }
3825}
3826
3827#[cfg(test)]
3828fn resolve_api_key_source(config: &Config) -> ApiKeySource {
3829    resolve_credential_diagnostic(config).source
3830}
3831
3832fn provider_config_table_key(provider: crate::config::ApiProvider) -> &'static str {
3833    provider
3834        .metadata()
3835        .map(|metadata| metadata.provider_config_key())
3836        .unwrap_or("deepseek_cn")
3837}
3838
3839fn count_dir_entries(dir: &Path) -> usize {
3840    std::fs::read_dir(dir)
3841        .map(|entries| entries.filter_map(std::result::Result::ok).count())
3842        .unwrap_or(0)
3843}
3844
3845fn skills_count_for(dir: &Path) -> usize {
3846    if !dir.exists() {
3847        return 0;
3848    }
3849    crate::skills::SkillRegistry::discover(dir).len()
3850}
3851
3852fn run_setup_status(
3853    config: &Config,
3854    workspace: &Path,
3855    plugins: &crate::plugins::PluginRegistry,
3856) -> Result<()> {
3857    use crate::palette;
3858    use colored::Colorize;
3859
3860    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3861    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3862
3863    println!(
3864        "{}",
3865        "Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
3866    );
3867    println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
3868    println!("workspace: {}", workspace.display());
3869
3870    let credential = resolve_credential_diagnostic(config);
3871    match credential.source {
3872        ApiKeySource::ConfigDeclared => println!(
3873            "  {} api_key: literal config value structurally present",
3874            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3875        ),
3876        ApiKeySource::EnvDeclared => println!(
3877            "  {} api_key: environment source declared (value not inspected)",
3878            "·".dimmed()
3879        ),
3880        ApiKeySource::ExternalAuthDeclared => println!(
3881            "  {} api_key: external auth source declared (value not inspected)",
3882            "·".dimmed()
3883        ),
3884        ApiKeySource::SecretStoreUnprobed => println!(
3885            "  {} api_key: secret store eligible (store not probed)",
3886            "·".dimmed()
3887        ),
3888        ApiKeySource::SecretStoreUnavailable => println!(
3889            "  {} api_key: secret-store sentinel declared, but this route cannot use that store",
3890            "!".truecolor(sky_r, sky_g, sky_b)
3891        ),
3892        ApiKeySource::OAuth => println!(
3893            "  {} oauth: Codewhale-owned route selected (token availability not probed)",
3894            "·".dimmed()
3895        ),
3896        ApiKeySource::ExternalConsent => println!(
3897            "  {} oauth: external read-only consent configured (credential file not probed)",
3898            "·".dimmed()
3899        ),
3900        ApiKeySource::NoAuth => println!(
3901            "  {} api_key: disabled for this route",
3902            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3903        ),
3904        ApiKeySource::LocalRuntime => println!(
3905            "  {} api_key: not required for this local runtime",
3906            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3907        ),
3908        ApiKeySource::Unknown => println!(
3909            "  {} api_key: unknown (credential environment and durable stores not inspected)",
3910            "·".dimmed()
3911        ),
3912    }
3913    println!(
3914        "  · credential availability: {}",
3915        credential.availability.label()
3916    );
3917    println!(
3918        "  · base_url: {}",
3919        crate::doctor::structural_url_authority(&config.deepseek_base_url())
3920    );
3921    let model = config
3922        .default_text_model
3923        .clone()
3924        .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
3925    println!("  · default_text_model: {model}");
3926    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
3927    println!("  · default_mode: {default_mode} ({default_mode_source})");
3928
3929    let mcp_path = config.mcp_config_path();
3930    let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
3931    let mcp_count =
3932        match crate::mcp::load_config_with_workspace_and_plugins(&mcp_path, workspace, plugins) {
3933            Ok(cfg) => cfg.servers.len(),
3934            Err(_) => 0,
3935        };
3936    let mcp_present = if mcp_path.exists() { "" } else { "  (missing)" };
3937    let project_mcp_present = if project_mcp_path.exists() {
3938        ""
3939    } else {
3940        "  (missing)"
3941    };
3942    println!(
3943        "  · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
3944        mcp_path.display(),
3945        project_mcp_path.display()
3946    );
3947
3948    let skills_dir = config.skills_dir();
3949    println!(
3950        "  · skills: {} at {}",
3951        skills_count_for(&skills_dir),
3952        crate::utils::display_path(&skills_dir)
3953    );
3954
3955    let tools_dir = default_tools_dir();
3956    let tools_present = if tools_dir.exists() {
3957        ""
3958    } else {
3959        "  (missing — run `setup --tools`)"
3960    };
3961    println!(
3962        "  · tools: {} entries at {}{tools_present}",
3963        if tools_dir.exists() {
3964            count_dir_entries(&tools_dir)
3965        } else {
3966            0
3967        },
3968        crate::utils::display_path(&tools_dir)
3969    );
3970
3971    let plugins_dir = default_plugins_dir();
3972    let plugins_present = if plugins_dir.exists() {
3973        ""
3974    } else {
3975        "  (missing — run `setup --plugins`)"
3976    };
3977    println!(
3978        "  · plugins: {} entries at {}{plugins_present}",
3979        if plugins_dir.exists() {
3980            count_dir_entries(&plugins_dir)
3981        } else {
3982            0
3983        },
3984        crate::utils::display_path(&plugins_dir)
3985    );
3986
3987    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3988        config.prefer_bwrap.unwrap_or(false),
3989    );
3990    match sandbox {
3991        Some(kind) => println!(
3992            "  {} sandbox: {kind}",
3993            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3994        ),
3995        None => println!(
3996            "  {} sandbox: unavailable (commands run best-effort)",
3997            "!".truecolor(sky_r, sky_g, sky_b)
3998        ),
3999    }
4000
4001    println!("  {} {}", "·".dimmed(), dotenv_status_line(workspace));
4002
4003    println!();
4004    println!("Run `codewhale doctor --json` for a machine-readable check.");
4005    Ok(())
4006}
4007
4008fn dotenv_status_line(workspace: &Path) -> String {
4009    let dotenv = workspace.join(".env");
4010    if dotenv.exists() {
4011        return format!(
4012            ".env present at {} (literal provider credentials only)",
4013            dotenv.display()
4014        );
4015    }
4016
4017    if workspace.join(".env.example").exists() {
4018        return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
4019    }
4020
4021    ".env not present in workspace".to_string()
4022}
4023
4024fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
4025    use colored::Colorize;
4026
4027    if !checkpoints_dir.exists() {
4028        println!(
4029            "Nothing to clean — checkpoints dir does not exist: {}",
4030            checkpoints_dir.display()
4031        );
4032        return Ok(());
4033    }
4034
4035    let plan = collect_clean_targets(checkpoints_dir);
4036    if plan.targets.is_empty() {
4037        println!(
4038            "Nothing to clean — no checkpoint files in {}",
4039            checkpoints_dir.display()
4040        );
4041        return Ok(());
4042    }
4043
4044    if !force {
4045        println!(
4046            "Would remove {} checkpoint file(s) (use --force to apply):",
4047            plan.targets.len()
4048        );
4049        for path in &plan.targets {
4050            println!("  · {}", path.display());
4051        }
4052        return Ok(());
4053    }
4054
4055    let removed = execute_clean_plan(&plan)?;
4056    println!("{}", "Cleaned checkpoints:".bold());
4057    for path in &removed {
4058        println!("  ✓ {}", path.display());
4059    }
4060    Ok(())
4061}
4062
4063fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
4064    let contents = std::fs::read_to_string(&args.path).with_context(|| {
4065        format!(
4066            "read session diagnostic JSONL from {}",
4067            crate::utils::display_path(&args.path)
4068        )
4069    })?;
4070    let summary = crate::session_diagnostics::analyze_session_failure_jsonl(&contents);
4071    if args.json {
4072        println!("{}", serde_json::to_string_pretty(&summary)?);
4073    } else {
4074        println!(
4075            "{}",
4076            crate::session_diagnostics::format_redacted_failure_summary(&summary)
4077        );
4078    }
4079    Ok(())
4080}
4081
4082/// Live API checks are explicit. Local endpoints have a separate opt-in because
4083/// an HTTP request can wake a desktop-managed daemon (notably Ollama.app).
4084fn doctor_should_probe_api(
4085    provider: crate::config::ApiProvider,
4086    base_url: &str,
4087    probes: crate::doctor::DoctorProbeRequest,
4088) -> bool {
4089    let local = crate::config::provider_route_is_keyless_self_hosted(provider, base_url)
4090        || crate::config::base_url_uses_local_host(base_url);
4091    probes.should_probe_api(local)
4092}
4093
4094/// Doctor must never turn credential inspection into a refresh/write path.
4095/// OAuth connectivity is exercised by an ordinary user request instead;
4096/// doctor limits itself to non-mutating readiness inspection.
4097fn doctor_should_probe_auth(config: &Config) -> bool {
4098    let provider = config.api_provider();
4099    if provider == crate::config::ApiProvider::OpenaiCodex
4100        && !config.provider_uses_custom_endpoint(provider)
4101    {
4102        return false;
4103    }
4104    let auth_mode = config.auth_mode_for_provider(provider);
4105    if provider == crate::config::ApiProvider::Xai
4106        && auth_mode
4107            .as_deref()
4108            .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
4109    {
4110        return false;
4111    }
4112    !(provider == crate::config::ApiProvider::Moonshot
4113        && auth_mode
4114            .as_deref()
4115            .is_some_and(crate::config::auth_mode_uses_kimi_imported_token))
4116}
4117
4118/// Run system diagnostics
4119async fn run_doctor(
4120    config: &Config,
4121    workspace: &Path,
4122    config_path_override: Option<&Path>,
4123    probes: crate::doctor::DoctorProbeRequest,
4124    plugins: &crate::plugins::PluginRegistry,
4125) {
4126    use crate::palette;
4127    use colored::Colorize;
4128
4129    let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
4130    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
4131    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
4132    let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
4133
4134    println!(
4135        "{}",
4136        "codewhale Doctor"
4137            .truecolor(accent_r, accent_g, accent_b)
4138            .bold()
4139    );
4140    println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
4141    println!();
4142
4143    // Version info
4144    println!("{}", "Version Information:".bold());
4145    println!("  codewhale-tui: {}", env!("DEEPSEEK_BUILD_VERSION"));
4146    println!("  rust: {}", rustc_version());
4147    println!();
4148
4149    println!("{}", "Updates:".bold());
4150    crate::doctor::print_update_report(probes).await;
4151    println!();
4152
4153    // Configuration summary
4154    let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
4155        Ok(paths) => paths,
4156        Err(error) => {
4157            println!("{}", "Resolved User Paths:".bold());
4158            println!(
4159                "  {} unavailable: {error:#}",
4160                "✗".truecolor(red_r, red_g, red_b)
4161            );
4162            return;
4163        }
4164    };
4165    println!("{}", "Configuration:".bold());
4166    let config_path = &doctor_paths.config;
4167
4168    if config_path.exists() {
4169        println!(
4170            "  {} config.toml found at {}",
4171            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4172            crate::utils::display_path(config_path)
4173        );
4174        // Secret hygiene: name the keys, never the values. Plain-text config
4175        // is not a secret store.
4176        if let Ok(raw) = std::fs::read_to_string(config_path) {
4177            let flagged = crate::doctor::config_credential_shaped_keys(&raw);
4178            if !flagged.is_empty() {
4179                println!(
4180                    "  {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
4181                    "!".truecolor(sky_r, sky_g, sky_b),
4182                    flagged.join(", ")
4183                );
4184            }
4185        }
4186    } else {
4187        println!(
4188            "  {} config.toml not found at {} (using defaults/env)",
4189            "!".truecolor(sky_r, sky_g, sky_b),
4190            crate::utils::display_path(config_path)
4191        );
4192    }
4193    println!("  workspace: {}", crate::utils::display_path(workspace));
4194    println!("  {}", doctor_search_provider_line(config));
4195
4196    println!();
4197    println!("{}", "Resolved User Paths (read-only):".bold());
4198    for (label, path) in doctor_paths.entries() {
4199        println!("  · {label}: {}", crate::utils::display_path(path));
4200    }
4201
4202    let secret_backend = codewhale_secrets::diagnose_secret_backend();
4203    println!();
4204    println!("{}", "Secret Backend (structural only):".bold());
4205    for line in crate::doctor::secret_backend_human_lines(&secret_backend) {
4206        println!("  · {line}");
4207    }
4208
4209    // State root (v0.8.44)
4210    println!();
4211    println!("{}", "State Root:".bold());
4212    let (code_home, legacy_home) = doctor_state_roots();
4213    let active_root = if code_home.exists() {
4214        &code_home
4215    } else if legacy_home.exists() {
4216        &legacy_home
4217    } else {
4218        &code_home
4219    };
4220    println!("  active: {}", crate::utils::display_path(active_root));
4221    if active_root != &code_home {
4222        println!(
4223            "  note: legacy {} found; start Codewhale once to trigger safe migration where available.",
4224            crate::utils::display_path(&legacy_home)
4225        );
4226    }
4227    if legacy_home.exists() && code_home.exists() {
4228        println!(
4229            "  dual roots: {} (primary) + {} (legacy)",
4230            crate::utils::display_path(&code_home),
4231            crate::utils::display_path(&legacy_home)
4232        );
4233    }
4234    let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
4235    let session_recovery = doctor_session_recovery_report(
4236        &code_home,
4237        &legacy_home,
4238        codewhale_config::codewhale_home_is_explicit(),
4239    );
4240    print_doctor_legacy_state_report(
4241        &legacy_state_report,
4242        &session_recovery,
4243        (aqua_r, aqua_g, aqua_b),
4244        (sky_r, sky_g, sky_b),
4245    );
4246
4247    let (setup_state, setup_source) = doctor_setup_state(config, workspace);
4248    print_doctor_setup_report(
4249        config,
4250        workspace,
4251        &setup_state,
4252        setup_source,
4253        (aqua_r, aqua_g, aqua_b),
4254        (sky_r, sky_g, sky_b),
4255    );
4256
4257    // Check API keys
4258    println!();
4259    println!("{}", "API Keys:".bold());
4260
4261    // Per-provider state: env + config file only (no values printed).
4262    // Keep doctor/status prompt-free and credential-value-free even for
4263    // unsigned rebuilt binaries.
4264    for provider in crate::config::ApiProvider::all().iter().copied() {
4265        let slot = provider.as_str();
4266        let provider_config = config.provider_config_for(provider);
4267        let config_declared = provider_config.is_some_and(|entry| {
4268            entry.api_key.as_deref().is_some_and(|key| {
4269                crate::config::classify_config_api_key_value(key)
4270                    == crate::config::ConfigApiKeyValueKind::Literal
4271            })
4272        }) || (matches!(provider, crate::config::ApiProvider::Deepseek)
4273            && config.api_key.as_deref().is_some_and(|key| {
4274                crate::config::classify_config_api_key_value(key)
4275                    == crate::config::ConfigApiKeyValueKind::Literal
4276            }));
4277        let env_source_declared = provider_config
4278            .and_then(|entry| entry.api_key_env.as_deref())
4279            .is_some_and(|name| !name.trim().is_empty());
4280        let icon = if config_declared || env_source_declared {
4281            "·".truecolor(aqua_r, aqua_g, aqua_b)
4282        } else {
4283            "·".dimmed()
4284        };
4285        println!(
4286            "  {} {slot}: env_source={}, config_source={}",
4287            icon,
4288            if env_source_declared {
4289                "declared (value not inspected)"
4290            } else {
4291                "not inspected"
4292            },
4293            if config_declared {
4294                "declared (value not inspected)"
4295            } else {
4296                "not declared"
4297            }
4298        );
4299    }
4300    println!("  · credential precedence is unchanged; doctor does not inspect credential values");
4301    println!();
4302    println!(
4303        "{}",
4304        "External credential consent (configuration only):".bold()
4305    );
4306    for line in doctor_external_credential_consent_lines(config) {
4307        println!("  {line}");
4308    }
4309
4310    println!();
4311    println!(
4312        "{}",
4313        "DeepSeek Harness integration (read-only detection):".bold()
4314    );
4315    for line in doctor_dsh_integration_lines(config, workspace) {
4316        println!("  {line}");
4317    }
4318
4319    let credential = resolve_credential_diagnostic(config);
4320    let source_label = match credential.source {
4321        ApiKeySource::ConfigDeclared => "literal config value structurally present",
4322        ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
4323        ApiKeySource::ExternalAuthDeclared => {
4324            "external auth source declared; credential not resolved"
4325        }
4326        ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
4327        ApiKeySource::SecretStoreUnavailable => {
4328            "secret-store sentinel declared, but this route cannot use that store"
4329        }
4330        ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
4331        ApiKeySource::ExternalConsent => "external consent configured; token file not read",
4332        ApiKeySource::NoAuth => "no-auth route",
4333        ApiKeySource::LocalRuntime => "local runtime; credentials not required",
4334        ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
4335    };
4336    println!(
4337        "  {} active provider credential source: {source_label}",
4338        "·".dimmed()
4339    );
4340    println!(
4341        "  · active provider credential availability: {}",
4342        credential.availability.label()
4343    );
4344
4345    // API connectivity test
4346    println!();
4347    println!("{}", "API Connectivity:".bold());
4348    let api_target = doctor_api_target(config);
4349    // Configured-vs-active honesty (DGF-01): doctor describes the route a
4350    // session launched NOW would resolve. It cannot see inside an already
4351    // running session, which keeps the route it resolved at its own launch.
4352    println!(
4353        "  · 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)"
4354    );
4355    println!("  · provider: {}", api_target.provider);
4356    println!(
4357        "  · base_url: {}",
4358        crate::doctor::structural_url_authority(&api_target.base_url)
4359    );
4360    match api_target.resolution {
4361        DoctorModelResolution::Resolved => {
4362            println!("  · model: {} (resolved)", api_target.model);
4363        }
4364        DoctorModelResolution::ConfiguredOnly => {
4365            println!(
4366                "  · model: {} (configured; route resolution unavailable)",
4367                api_target.model
4368            );
4369        }
4370    }
4371    let tls_status = doctor_tls_status(config);
4372    if !tls_status.certificate_verification {
4373        println!("  ! {}", tls_status.message);
4374        println!("    Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
4375    }
4376    let strict_tool_mode = doctor_strict_tool_mode_status(config);
4377    let strict_icon = match strict_tool_mode.status {
4378        "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
4379        "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
4380        _ => "·".dimmed(),
4381    };
4382    println!(
4383        "  {} strict_tool_mode: {}",
4384        strict_icon, strict_tool_mode.message
4385    );
4386    if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
4387        println!(
4388            "    Use the {} endpoint for DeepSeek strict schemas.",
4389            crate::doctor::structural_url_authority(recommended)
4390        );
4391    }
4392    let capability = crate::config::provider_capability(config.api_provider(), &api_target.model);
4393    if let Some(alias) = capability.alias_deprecation.as_ref() {
4394        println!(
4395            "  ! model alias {} retires {}; switch to {}",
4396            alias.alias, alias.retirement_date, alias.replacement
4397        );
4398    }
4399    let live_api_requested =
4400        doctor_should_probe_api(config.api_provider(), &api_target.base_url, probes);
4401    let endpoint_is_local = crate::config::provider_route_is_keyless_self_hosted(
4402        config.api_provider(),
4403        &api_target.base_url,
4404    ) || crate::config::base_url_uses_local_host(&api_target.base_url);
4405    if doctor_should_probe_auth(config) && live_api_requested {
4406        print!("  {} Testing connection...", "·".dimmed());
4407        use std::io::Write;
4408        std::io::stdout().flush().ok();
4409
4410        // Resolve a credential through the diagnostic-only store first, then
4411        // probe with an in-memory clone. Constructing the normal client from
4412        // the original config could otherwise trigger its legacy secret-store
4413        // migration while a user merely asks doctor to test connectivity.
4414        let connectivity_result = match config.with_read_only_api_key_for_diagnostic() {
4415            Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
4416            Err(error) => Err(error),
4417        };
4418        match connectivity_result {
4419            Ok(()) => {
4420                println!(
4421                    "\r  {} API connection successful",
4422                    "✓".truecolor(aqua_r, aqua_g, aqua_b)
4423                );
4424            }
4425            Err(e) => {
4426                let error_msg = e.to_string();
4427                println!(
4428                    "\r  {} API connection failed",
4429                    "✗".truecolor(red_r, red_g, red_b)
4430                );
4431                if error_msg.contains("401") || error_msg.contains("Unauthorized") {
4432                    println!(
4433                        "    Invalid API key. Check `codewhale auth status`, DEEPSEEK_API_KEY, or config.toml"
4434                    );
4435                } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
4436                    println!(
4437                        "    API key lacks permissions. Verify key is active at platform.deepseek.com"
4438                    );
4439                } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
4440                    for line in doctor_timeout_recovery_lines(config) {
4441                        println!("    {line}");
4442                    }
4443                } else if error_msg.contains("dns") || error_msg.contains("resolve") {
4444                    println!("    DNS resolution failed. Check your network connection");
4445                } else if error_msg.contains("connect") {
4446                    println!("    Connection failed. Check firewall settings or try again");
4447                } else if crate::doctor::is_keyless_ds4_route(config) {
4448                    println!("    {error_msg}");
4449                } else {
4450                    println!(
4451                        "    Error details omitted because provider failures can contain credential material."
4452                    );
4453                }
4454            }
4455        }
4456    } else if !doctor_should_probe_auth(config) {
4457        println!(
4458            "  {} Live OAuth connectivity not checked by non-mutating doctor",
4459            "·".dimmed()
4460        );
4461        println!(
4462            "    Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
4463        );
4464    } else {
4465        if endpoint_is_local {
4466            println!(
4467                "  {} Live connectivity not checked for this local endpoint",
4468                "·".dimmed()
4469            );
4470            println!(
4471                "    Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
4472            );
4473        } else {
4474            println!(
4475                "  {} Live hosted connectivity not checked (offline default)",
4476                "·".dimmed()
4477            );
4478            println!("    Run `codewhale doctor --probe-api` to opt in.");
4479        }
4480    }
4481
4482    // MCP configuration
4483    println!();
4484    println!("{}", "MCP Servers (configuration only):".bold());
4485    println!("  · Static check only; no server process was started.");
4486    let features = config.features();
4487    if features.enabled(Feature::Mcp) {
4488        println!(
4489            "  {} MCP feature flag enabled",
4490            "✓".truecolor(aqua_r, aqua_g, aqua_b)
4491        );
4492    } else {
4493        println!(
4494            "  {} MCP feature flag disabled",
4495            "!".truecolor(sky_r, sky_g, sky_b)
4496        );
4497    }
4498
4499    let mcp_config_path = config.mcp_config_path();
4500    let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
4501    if mcp_config_path.exists() {
4502        println!(
4503            "  {} MCP config found at {}",
4504            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4505            crate::utils::display_path(&mcp_config_path)
4506        );
4507    } else {
4508        println!(
4509            "  {} MCP config not found at {}",
4510            "·".dimmed(),
4511            crate::utils::display_path(&mcp_config_path)
4512        );
4513    }
4514    if project_mcp_config_path.exists() {
4515        println!(
4516            "  {} Project MCP config found at {}",
4517            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4518            crate::utils::display_path(&project_mcp_config_path)
4519        );
4520    } else {
4521        println!(
4522            "  {} Project MCP config not found at {}",
4523            "·".dimmed(),
4524            crate::utils::display_path(&project_mcp_config_path)
4525        );
4526    }
4527
4528    match crate::mcp::load_config_with_workspace_and_plugins(&mcp_config_path, workspace, plugins) {
4529        Ok(cfg) if cfg.servers.is_empty() => {
4530            println!("  {} 0 merged server(s) configured", "·".dimmed());
4531            if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
4532                println!("    Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
4533            }
4534        }
4535        Ok(cfg) => {
4536            println!(
4537                "  {} {} merged server(s) configured",
4538                "·".dimmed(),
4539                cfg.servers.len()
4540            );
4541            for (name, server) in &cfg.servers {
4542                let status = doctor_check_mcp_server(server);
4543                let icon = match &status {
4544                    McpServerDoctorStatus::Ok(detail) => {
4545                        format!(
4546                            "  {} {name}: configuration valid; {}",
4547                            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4548                            detail
4549                        )
4550                    }
4551                    McpServerDoctorStatus::Warning(detail) => {
4552                        format!(
4553                            "  {} {name}: configuration warning; {}",
4554                            "!".truecolor(sky_r, sky_g, sky_b),
4555                            detail
4556                        )
4557                    }
4558                    McpServerDoctorStatus::Error(detail) => {
4559                        format!(
4560                            "  {} {name}: configuration invalid; {}",
4561                            "✗".truecolor(red_r, red_g, red_b),
4562                            detail
4563                        )
4564                    }
4565                };
4566                println!("{icon}");
4567                if !server.is_enabled() {
4568                    println!("      disabled; live health not checked");
4569                } else {
4570                    println!(
4571                        "      process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
4572                    );
4573                }
4574            }
4575            if probes.should_probe_mcp() {
4576                println!();
4577                println!(
4578                    "  {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
4579                    "!".truecolor(sky_r, sky_g, sky_b)
4580                );
4581                match crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
4582                    &mcp_config_path,
4583                    workspace,
4584                    std::sync::Arc::new(plugins.clone()),
4585                ) {
4586                    Ok(mut pool) => {
4587                        let errors = pool.connect_all().await;
4588                        let failed = errors
4589                            .iter()
4590                            .map(|(name, _)| name.as_str())
4591                            .collect::<std::collections::BTreeSet<_>>();
4592                        for (name, server) in &cfg.servers {
4593                            if !server.is_enabled() {
4594                                continue;
4595                            }
4596                            if failed.contains(name.as_str()) {
4597                                println!(
4598                                    "      {} {name}: process/protocol unreachable; error details omitted",
4599                                    "✗".truecolor(red_r, red_g, red_b)
4600                                );
4601                            } else {
4602                                println!(
4603                                    "      {} {name}: process reachable and protocol initialized; backend tool health not checked",
4604                                    "✓".truecolor(aqua_r, aqua_g, aqua_b)
4605                                );
4606                            }
4607                        }
4608                    }
4609                    Err(_) => println!(
4610                        "      {} live MCP probe could not load merged configuration; details omitted",
4611                        "✗".truecolor(red_r, red_g, red_b)
4612                    ),
4613                }
4614            } else {
4615                println!(
4616                    "    Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
4617                );
4618            }
4619        }
4620        Err(_) => {
4621            println!(
4622                "  {} MCP configuration could not be loaded; details omitted",
4623                "✗".truecolor(red_r, red_g, red_b)
4624            );
4625        }
4626    }
4627
4628    // Skills configuration
4629    println!();
4630    println!("{}", "Skills:".bold());
4631    let global_skills_dir = config.skills_dir();
4632    let agents_skills_dir = workspace.join(".agents").join("skills");
4633    let local_skills_dir = workspace.join("skills");
4634    let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
4635    // #432: cross-tool skill discovery dirs. Presence is reported here
4636    // even though they sit lower in the precedence chain so users can
4637    // see at a glance whether a `.opencode/skills/`, `.claude/skills/`,
4638    // `.cursor/skills/`, or global agentskills.io directory is contributing
4639    // to the merged catalogue.
4640    let opencode_skills_dir = workspace.join(".opencode").join("skills");
4641    let claude_skills_dir = workspace.join(".claude").join("skills");
4642    let selected_skills_dir = if agents_skills_dir.exists() {
4643        agents_skills_dir.clone()
4644    } else if local_skills_dir.exists() {
4645        local_skills_dir.clone()
4646    } else if config.skills_dir.is_none()
4647        && let Some(global_agents) = agents_global_skills_dir.as_ref()
4648        && global_agents.exists()
4649    {
4650        global_agents.clone()
4651    } else {
4652        global_skills_dir.clone()
4653    };
4654
4655    let describe_dir = |dir: &Path| -> usize {
4656        std::fs::read_dir(dir)
4657            .map(|entries| entries.filter_map(std::result::Result::ok).count())
4658            .unwrap_or(0)
4659    };
4660
4661    if local_skills_dir.exists() {
4662        println!(
4663            "  {} local skills dir found at {} ({} items)",
4664            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4665            crate::utils::display_path(&local_skills_dir),
4666            describe_dir(&local_skills_dir)
4667        );
4668    } else {
4669        println!(
4670            "  {} local skills dir not found at {}",
4671            "·".dimmed(),
4672            crate::utils::display_path(&local_skills_dir)
4673        );
4674    }
4675
4676    if agents_skills_dir.exists() {
4677        println!(
4678            "  {} .agents skills dir found at {} ({} items)",
4679            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4680            crate::utils::display_path(&agents_skills_dir),
4681            describe_dir(&agents_skills_dir)
4682        );
4683    } else {
4684        println!(
4685            "  {} .agents skills dir not found at {}",
4686            "·".dimmed(),
4687            crate::utils::display_path(&agents_skills_dir)
4688        );
4689    }
4690
4691    if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
4692        if agents_global_skills_dir.exists() {
4693            println!(
4694                "  {} global .agents skills dir found at {} ({} items)",
4695                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4696                crate::utils::display_path(agents_global_skills_dir),
4697                describe_dir(agents_global_skills_dir)
4698            );
4699        } else {
4700            println!(
4701                "  {} global .agents skills dir not found at {}",
4702                "·".dimmed(),
4703                crate::utils::display_path(agents_global_skills_dir)
4704            );
4705        }
4706    }
4707
4708    if global_skills_dir.exists() {
4709        println!(
4710            "  {} global skills dir found at {} ({} items)",
4711            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4712            crate::utils::display_path(&global_skills_dir),
4713            describe_dir(&global_skills_dir)
4714        );
4715    } else {
4716        println!(
4717            "  {} global skills dir not found at {}",
4718            "·".dimmed(),
4719            crate::utils::display_path(&global_skills_dir)
4720        );
4721    }
4722
4723    // #432: only print interop dirs when they're populated — empty
4724    // .opencode/.claude folders are common and would just clutter
4725    // the report with false-positive "absent" lines.
4726    if opencode_skills_dir.exists() {
4727        println!(
4728            "  {} .opencode skills dir found at {} ({} items)",
4729            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4730            crate::utils::display_path(&opencode_skills_dir),
4731            describe_dir(&opencode_skills_dir)
4732        );
4733    }
4734    if claude_skills_dir.exists() {
4735        println!(
4736            "  {} .claude skills dir found at {} ({} items)",
4737            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4738            crate::utils::display_path(&claude_skills_dir),
4739            describe_dir(&claude_skills_dir)
4740        );
4741    }
4742
4743    println!(
4744        "  {} selected skills dir: {}",
4745        "·".dimmed(),
4746        crate::utils::display_path(&selected_skills_dir)
4747    );
4748    if !agents_skills_dir.exists()
4749        && !local_skills_dir.exists()
4750        && !agents_global_skills_dir
4751            .as_ref()
4752            .is_some_and(|dir| dir.exists())
4753        && !global_skills_dir.exists()
4754    {
4755        println!("    Run `codewhale setup --skills` (or add --local for ./skills).");
4756    }
4757
4758    // Tools directory
4759    println!();
4760    println!("{}", "Tools:".bold());
4761    let tools_dir = default_tools_dir();
4762    if tools_dir.exists() {
4763        let count = count_dir_entries(&tools_dir);
4764        println!(
4765            "  {} tools dir found at {} ({} items)",
4766            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4767            crate::utils::display_path(&tools_dir),
4768            count
4769        );
4770    } else {
4771        println!(
4772            "  {} tools dir not found at {}",
4773            "·".dimmed(),
4774            crate::utils::display_path(&tools_dir)
4775        );
4776        println!("    Run `codewhale setup --tools` to scaffold a starter dir.");
4777    }
4778
4779    // Plugins directory
4780    println!();
4781    println!("{}", "Plugins:".bold());
4782    let plugins_dir = default_plugins_dir();
4783    if plugins_dir.exists() {
4784        let count = count_dir_entries(&plugins_dir);
4785        println!(
4786            "  {} plugins dir found at {} ({} items)",
4787            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4788            crate::utils::display_path(&plugins_dir),
4789            count
4790        );
4791    } else {
4792        println!(
4793            "  {} plugins dir not found at {}",
4794            "·".dimmed(),
4795            crate::utils::display_path(&plugins_dir)
4796        );
4797        println!("    Run `codewhale setup --plugins` to scaffold a starter dir.");
4798    }
4799
4800    // Storage surfaces (#422 / #440 / #500)
4801    println!();
4802    println!("{}", "Storage:".bold());
4803    if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
4804        let (present, count) = if spillover_root.is_dir() {
4805            (true, count_dir_entries(&spillover_root))
4806        } else {
4807            (false, 0)
4808        };
4809        if present {
4810            println!(
4811                "  {} tool-output spillover at {} ({} file{})",
4812                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4813                crate::utils::display_path(&spillover_root),
4814                count,
4815                if count == 1 { "" } else { "s" }
4816            );
4817        } else {
4818            println!(
4819                "  {} tool-output spillover dir not yet created at {}",
4820                "·".dimmed(),
4821                crate::utils::display_path(&spillover_root)
4822            );
4823        }
4824    }
4825    let stash = crate::composer_stash::diagnostic_stash_report();
4826    if let Some(stash_path) = stash.path.as_ref() {
4827        if let Some(error) = stash.error.as_deref() {
4828            println!(
4829                "  {} composer stash was not inspected at {}: {error}",
4830                "!".truecolor(sky_r, sky_g, sky_b),
4831                crate::utils::display_path(stash_path),
4832            );
4833        } else if stash.present {
4834            println!(
4835                "  {} composer stash at {} ({} parked draft{})",
4836                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4837                crate::utils::display_path(stash_path),
4838                stash.count,
4839                if stash.count == 1 { "" } else { "s" }
4840            );
4841        } else {
4842            println!(
4843                "  {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
4844                "·".dimmed()
4845            );
4846        }
4847    } else if let Some(error) = stash.error.as_deref() {
4848        println!(
4849            "  {} composer stash was not inspected: {error}",
4850            "!".truecolor(sky_r, sky_g, sky_b),
4851        );
4852    }
4853
4854    // Tool dependencies — probe external binaries that individual
4855    // tools rely on (Python for code_execution, pdftotext for PDF
4856    // reading) so users see explicit ✓/✗ rather than the tool failing
4857    // at execution time with "program not found". New in v0.8.31.
4858    println!();
4859    println!("{}", "Tool Dependencies:".bold());
4860
4861    match crate::dependencies::resolve_python_interpreter() {
4862        Some(name) => println!(
4863            "  {} Python: {} → code_execution tool registered",
4864            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4865            name
4866        ),
4867        None => {
4868            println!(
4869                "  {} Python: not found (tried {:?})",
4870                "✗".truecolor(red_r, red_g, red_b),
4871                crate::dependencies::PYTHON_CANDIDATES,
4872            );
4873            println!("    code_execution tool is NOT advertised to the model on this install.");
4874            println!("    Install Python 3 and ensure one of those names is on PATH:");
4875            match std::env::consts::OS {
4876                "macos" => {
4877                    println!("      brew install python@3.12   (or download from python.org)")
4878                }
4879                "linux" => println!(
4880                    "      sudo apt install python3    (Debian/Ubuntu) — or your distro's equivalent"
4881                ),
4882                "windows" => {
4883                    println!("      winget install Python.Python.3   (or download from python.org)")
4884                }
4885                other => println!("      install Python 3 for {other} from python.org"),
4886            }
4887        }
4888    }
4889
4890    match crate::dependencies::resolve_node() {
4891        Some(_) => println!(
4892            "  {} Node.js: present → js_execution tool registered",
4893            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4894        ),
4895        None => {
4896            println!(
4897                "  {} Node.js: not found (tried `node`)",
4898                "✗".truecolor(red_r, red_g, red_b),
4899            );
4900            println!("    js_execution tool is NOT advertised to the model on this install.");
4901            println!("    Install Node 18+ and ensure `node` is on PATH:");
4902            match std::env::consts::OS {
4903                "macos" => println!("      brew install node   (or download from nodejs.org)"),
4904                "linux" => println!(
4905                    "      sudo apt install nodejs    (Debian/Ubuntu) — or your distro's equivalent"
4906                ),
4907                "windows" => {
4908                    println!("      winget install OpenJS.NodeJS   (or download from nodejs.org)")
4909                }
4910                other => println!("      install Node.js for {other} from nodejs.org"),
4911            }
4912        }
4913    }
4914
4915    match crate::dependencies::resolve_pandoc() {
4916        Some(_) => println!(
4917            "  {} pandoc: present → pandoc_convert tool registered",
4918            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4919        ),
4920        None => {
4921            println!("  {} pandoc: not found (optional)", "·".dimmed(),);
4922            println!(
4923                "    pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:"
4924            );
4925            match std::env::consts::OS {
4926                "macos" => println!("      brew install pandoc"),
4927                "linux" => println!(
4928                    "      sudo apt install pandoc    (Debian/Ubuntu) — or your distro's equivalent"
4929                ),
4930                "windows" => {
4931                    println!("      winget install JohnMacFarlane.Pandoc")
4932                }
4933                other => println!("      install pandoc for {other} from pandoc.org"),
4934            }
4935        }
4936    }
4937
4938    match crate::dependencies::resolve_tesseract() {
4939        Some(_) => {
4940            if cfg!(target_os = "macos") {
4941                println!(
4942                    "  {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
4943                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4944                );
4945            } else {
4946                println!(
4947                    "  {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
4948                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4949                );
4950            }
4951        }
4952        None => {
4953            if cfg!(target_os = "macos") {
4954                println!(
4955                    "  {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
4956                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4957                );
4958                println!(
4959                    "    tesseract not found (optional; install only for alternate OCR packs)."
4960                );
4961            } else {
4962                println!("  {} tesseract: not found (optional)", "·".dimmed(),);
4963                println!(
4964                    "    image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
4965                );
4966                match std::env::consts::OS {
4967                    "macos" => println!("      brew install tesseract"),
4968                    "linux" => println!(
4969                        "      sudo apt install tesseract-ocr    (Debian/Ubuntu) — or your distro's equivalent"
4970                    ),
4971                    "windows" => println!("      winget install UB-Mannheim.TesseractOCR"),
4972                    other => {
4973                        println!("      install tesseract for {other} from tesseract-ocr.github.io")
4974                    }
4975                }
4976            }
4977        }
4978    }
4979
4980    // PDF text extraction is an optional integration. Codewhale itself stays
4981    // a single required executable; file and web tools report a typed
4982    // failed `binary_unavailable` result when Poppler is not installed.
4983    match crate::dependencies::resolve_pdftotext() {
4984        Some(_) => println!(
4985            "  {} pdftotext: available → PDF text extraction enabled",
4986            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4987        ),
4988        None => {
4989            println!(
4990                "  {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
4991                "·".dimmed(),
4992            );
4993            match std::env::consts::OS {
4994                "macos" => println!("    Install via: brew install poppler"),
4995                "linux" => {
4996                    println!("    Install via: sudo apt install poppler-utils   (Debian/Ubuntu)")
4997                }
4998                "windows" => println!(
4999                    "    Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
5000                ),
5001                _ => {}
5002            }
5003        }
5004    }
5005
5006    // Terminal-quirk overrides currently active. Mirrors the env
5007    // signals checked by `Settings::apply_env_overrides` so users
5008    // can see at a glance which a11y/compat overrides fired.
5009    println!();
5010    println!("{}", "Terminal Quirks:".bold());
5011    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
5012    let term_program_lc = term_program.to_ascii_lowercase();
5013    let mut any_quirk = false;
5014    if matches!(term_program.as_str(), "vscode" | "ghostty") {
5015        println!(
5016            "  {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
5017            "•".truecolor(sky_r, sky_g, sky_b),
5018            term_program
5019        );
5020        any_quirk = true;
5021    }
5022    if term_program == "Termius"
5023        || std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
5024        || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
5025    {
5026        println!(
5027            "  {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
5028            "•".truecolor(sky_r, sky_g, sky_b)
5029        );
5030        any_quirk = true;
5031    }
5032    if term_program_lc.contains("ptyxis")
5033        || std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
5034    {
5035        println!(
5036            "  {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
5037            "•".truecolor(sky_r, sky_g, sky_b)
5038        );
5039        any_quirk = true;
5040    }
5041    if crate::settings::detected_legacy_windows_console_host() {
5042        println!(
5043            "  {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
5044            "•".truecolor(sky_r, sky_g, sky_b)
5045        );
5046        any_quirk = true;
5047    }
5048    if !any_quirk {
5049        println!(
5050            "  {} no env-driven terminal-quirk overrides active",
5051            "·".dimmed()
5052        );
5053    }
5054
5055    // Platform and sandbox checks
5056    println!();
5057    println!("{}", "Platform:".bold());
5058    println!("  OS: {}", std::env::consts::OS);
5059    println!("  Arch: {}", std::env::consts::ARCH);
5060
5061    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
5062        config.prefer_bwrap.unwrap_or(false),
5063    );
5064    if let Some(kind) = sandbox {
5065        println!(
5066            "  {} sandbox available: {}",
5067            "✓".truecolor(aqua_r, aqua_g, aqua_b),
5068            kind
5069        );
5070    } else {
5071        println!(
5072            "  {} sandbox not available (commands run best-effort)",
5073            "!".truecolor(sky_r, sky_g, sky_b)
5074        );
5075    }
5076
5077    println!();
5078    println!(
5079        "{}",
5080        "All checks complete!"
5081            .truecolor(aqua_r, aqua_g, aqua_b)
5082            .bold()
5083    );
5084}
5085
5086const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
5087    "sessions",
5088    "tasks",
5089    "skills",
5090    "slop_ledger",
5091    "trophies",
5092    "catalog",
5093    "review-receipts",
5094    "config.toml",
5095    "settings.toml",
5096    "mcp.json",
5097];
5098const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
5099const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
5100
5101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5102enum DoctorLegacyStateStatus {
5103    PrimaryOnly,
5104    LegacyOnly,
5105    Both,
5106    Absent,
5107}
5108
5109impl DoctorLegacyStateStatus {
5110    fn as_str(self) -> &'static str {
5111        match self {
5112            Self::PrimaryOnly => "primary_only",
5113            Self::LegacyOnly => "legacy_only",
5114            Self::Both => "both",
5115            Self::Absent => "absent",
5116        }
5117    }
5118}
5119
5120#[derive(Debug, Clone)]
5121struct DoctorLegacyStateEntry {
5122    name: &'static str,
5123    primary_path: PathBuf,
5124    legacy_path: PathBuf,
5125    primary_present: bool,
5126    legacy_present: bool,
5127    status: DoctorLegacyStateStatus,
5128}
5129
5130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5131enum DoctorSessionRecoveryStatus {
5132    Isolated,
5133    NoLegacySessions,
5134    MigrationPending,
5135    MigrationIncomplete,
5136    MigrationComplete,
5137    ScanFailed,
5138}
5139
5140impl DoctorSessionRecoveryStatus {
5141    fn as_str(self) -> &'static str {
5142        match self {
5143            Self::Isolated => "isolated",
5144            Self::NoLegacySessions => "no_legacy_sessions",
5145            Self::MigrationPending => "migration_pending",
5146            Self::MigrationIncomplete => "migration_incomplete",
5147            Self::MigrationComplete => "migration_complete",
5148            Self::ScanFailed => "scan_failed",
5149        }
5150    }
5151}
5152
5153#[derive(Debug, Clone)]
5154struct DoctorRecoverableSessionEntry {
5155    name: PathBuf,
5156    source_path: PathBuf,
5157    destination_path: PathBuf,
5158}
5159
5160#[derive(Debug, Clone)]
5161struct DoctorSessionRecoveryReport {
5162    status: DoctorSessionRecoveryStatus,
5163    primary_sessions_path: PathBuf,
5164    legacy_sessions_path: PathBuf,
5165    codewhale_home_is_explicit: bool,
5166    legacy_session_file_count: usize,
5167    already_present_file_count: usize,
5168    recoverable_file_count: usize,
5169    /// Bounded filename/path sample; the total is `recoverable_file_count`.
5170    recoverable: Vec<DoctorRecoverableSessionEntry>,
5171    error: Option<String>,
5172}
5173
5174impl DoctorSessionRecoveryReport {
5175    fn needs_attention(&self) -> bool {
5176        matches!(
5177            self.status,
5178            DoctorSessionRecoveryStatus::MigrationPending
5179                | DoctorSessionRecoveryStatus::MigrationIncomplete
5180                | DoctorSessionRecoveryStatus::ScanFailed
5181        )
5182    }
5183}
5184
5185fn doctor_legacy_state_status(
5186    primary_present: bool,
5187    legacy_present: bool,
5188) -> DoctorLegacyStateStatus {
5189    match (primary_present, legacy_present) {
5190        (true, false) => DoctorLegacyStateStatus::PrimaryOnly,
5191        (false, true) => DoctorLegacyStateStatus::LegacyOnly,
5192        (true, true) => DoctorLegacyStateStatus::Both,
5193        (false, false) => DoctorLegacyStateStatus::Absent,
5194    }
5195}
5196
5197fn doctor_state_roots() -> (PathBuf, PathBuf) {
5198    let code_home =
5199        codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
5200    let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
5201        code_home.join(codewhale_config::LEGACY_APP_DIR)
5202    } else {
5203        codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
5204    };
5205    (code_home, legacy_home)
5206}
5207
5208fn doctor_legacy_state_report(
5209    primary_root: &Path,
5210    legacy_root: &Path,
5211) -> Vec<DoctorLegacyStateEntry> {
5212    DOCTOR_LEGACY_STATE_ITEMS
5213        .iter()
5214        .copied()
5215        .map(|name| {
5216            let primary_path = primary_root.join(name);
5217            let legacy_path = legacy_root.join(name);
5218            let primary_present = primary_path.exists();
5219            let legacy_present = legacy_path.exists();
5220            let status = doctor_legacy_state_status(primary_present, legacy_present);
5221            DoctorLegacyStateEntry {
5222                name,
5223                primary_path,
5224                legacy_path,
5225                primary_present,
5226                legacy_present,
5227                status,
5228            }
5229        })
5230        .collect()
5231}
5232
5233/// Compare legacy and primary session filenames without opening session files.
5234///
5235/// This is deliberately separate from `SessionManager::default_location()`:
5236/// constructing the manager can trigger the additive legacy migration, while
5237/// doctor must remain a read-only diagnostic. Session history is stored as
5238/// top-level JSON files. Directories (including `checkpoints`) and symlinks
5239/// observed during the scan are ignored, so the diagnostic does not
5240/// intentionally traverse checkpoint internals or link targets. These checks
5241/// are best-effort observations, not a race-free no-follow guarantee.
5242/// A matching filename is only a regular-file counterpart check: doctor does
5243/// not parse or compare session descriptors.
5244fn doctor_session_recovery_report(
5245    primary_root: &Path,
5246    legacy_root: &Path,
5247    codewhale_home_is_explicit: bool,
5248) -> DoctorSessionRecoveryReport {
5249    let primary_sessions_path = primary_root.join("sessions");
5250    let legacy_sessions_path = legacy_root.join("sessions");
5251    let mut report = DoctorSessionRecoveryReport {
5252        status: DoctorSessionRecoveryStatus::NoLegacySessions,
5253        primary_sessions_path,
5254        legacy_sessions_path,
5255        codewhale_home_is_explicit,
5256        legacy_session_file_count: 0,
5257        already_present_file_count: 0,
5258        recoverable_file_count: 0,
5259        recoverable: Vec::new(),
5260        error: None,
5261    };
5262
5263    if codewhale_home_is_explicit {
5264        report.status = DoctorSessionRecoveryStatus::Isolated;
5265        return report;
5266    }
5267
5268    let legacy_root_is_present =
5269        match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
5270            Ok(present) => present,
5271            Err(error) => {
5272                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5273                report.error = Some(error);
5274                return report;
5275            }
5276        };
5277    if !legacy_root_is_present {
5278        return report;
5279    }
5280    if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
5281        report.status = DoctorSessionRecoveryStatus::ScanFailed;
5282        report.error = Some(error);
5283        return report;
5284    }
5285
5286    let legacy_sessions_are_present = match doctor_session_directory_is_safe(
5287        &report.legacy_sessions_path,
5288        "legacy sessions root",
5289    ) {
5290        Ok(present) => present,
5291        Err(error) => {
5292            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5293            report.error = Some(error);
5294            return report;
5295        }
5296    };
5297    if !legacy_sessions_are_present {
5298        return report;
5299    }
5300    let primary_sessions_are_present = match doctor_session_directory_is_safe(
5301        &report.primary_sessions_path,
5302        "primary sessions root",
5303    ) {
5304        Ok(present) => present,
5305        Err(error) => {
5306            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5307            report.error = Some(error);
5308            return report;
5309        }
5310    };
5311
5312    let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
5313        Ok(entries) => entries,
5314        Err(err) => {
5315            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5316            report.error = Some(format!(
5317                "could not inspect legacy session filenames at {}: {err}",
5318                crate::utils::display_path(&report.legacy_sessions_path)
5319            ));
5320            return report;
5321        }
5322    };
5323
5324    for entry in entries {
5325        let entry = match entry {
5326            Ok(entry) => entry,
5327            Err(err) => {
5328                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5329                report.error = Some(format!(
5330                    "could not inspect an entry under {}: {err}",
5331                    crate::utils::display_path(&report.legacy_sessions_path)
5332                ));
5333                return report;
5334            }
5335        };
5336        let file_type = match entry.file_type() {
5337            Ok(file_type) => file_type,
5338            Err(err) => {
5339                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5340                report.error = Some(format!(
5341                    "could not inspect legacy session entry metadata under {}: {err}",
5342                    crate::utils::display_path(&report.legacy_sessions_path)
5343                ));
5344                return report;
5345            }
5346        };
5347        if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
5348            continue;
5349        }
5350
5351        report.legacy_session_file_count += 1;
5352        let name = PathBuf::from(entry.file_name());
5353        let destination_path = report.primary_sessions_path.join(&name);
5354        match std::fs::symlink_metadata(&destination_path) {
5355            Ok(metadata) if metadata.file_type().is_file() => {
5356                report.already_present_file_count += 1;
5357            }
5358            Ok(metadata) => {
5359                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5360                let shape = if metadata.file_type().is_symlink() {
5361                    "destination session entry is a symlink"
5362                } else {
5363                    "destination session entry is not a regular file"
5364                };
5365                report.error = Some(format!(
5366                    "could not inspect destination session metadata at {}: {shape}",
5367                    crate::utils::display_path(&destination_path)
5368                ));
5369                return report;
5370            }
5371            Err(err) if err.kind() == io::ErrorKind::NotFound => {
5372                report.recoverable_file_count += 1;
5373                record_doctor_recoverable_session(
5374                    &mut report.recoverable,
5375                    DoctorRecoverableSessionEntry {
5376                        source_path: entry.path(),
5377                        destination_path,
5378                        name,
5379                    },
5380                );
5381            }
5382            Err(err) => {
5383                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5384                report.error = Some(format!(
5385                    "could not inspect destination metadata at {}: {err}",
5386                    crate::utils::display_path(&destination_path)
5387                ));
5388                return report;
5389            }
5390        }
5391    }
5392
5393    report.status = if report.legacy_session_file_count == 0 {
5394        DoctorSessionRecoveryStatus::NoLegacySessions
5395    } else if report.recoverable_file_count == 0 {
5396        DoctorSessionRecoveryStatus::MigrationComplete
5397    } else if primary_sessions_are_present {
5398        DoctorSessionRecoveryStatus::MigrationIncomplete
5399    } else {
5400        DoctorSessionRecoveryStatus::MigrationPending
5401    };
5402    report
5403}
5404
5405/// Validate a session-state directory from observed metadata.
5406///
5407/// `doctor` only compares top-level filenames. It rejects a state-root or
5408/// sessions-root symlink observed during inspection rather than using it for a
5409/// recovery suggestion. This is a best-effort observation, not a race-free
5410/// no-follow guarantee. Missing paths are normal on a fresh install and are
5411/// reported as `false`.
5412fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
5413    let metadata = match std::fs::symlink_metadata(path) {
5414        Ok(metadata) => metadata,
5415        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
5416        Err(error) => {
5417            return Err(format!(
5418                "could not inspect {label} at {}: {error}",
5419                crate::utils::display_path(path)
5420            ));
5421        }
5422    };
5423    if metadata.file_type().is_symlink() {
5424        return Err(format!(
5425            "could not inspect {label} at {}: path is a symlink",
5426            crate::utils::display_path(path)
5427        ));
5428    }
5429    if !metadata.file_type().is_dir() {
5430        return Err(format!(
5431            "could not inspect {label} at {}: path is not a directory",
5432            crate::utils::display_path(path)
5433        ));
5434    }
5435    Ok(true)
5436}
5437
5438/// Keep the report bounded while preserving a deterministic, lexical sample.
5439/// `read_dir` order is platform- and filesystem-dependent, so retaining the
5440/// first entries encountered would make the JSON and human receipts drift.
5441fn record_doctor_recoverable_session(
5442    recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
5443    entry: DoctorRecoverableSessionEntry,
5444) {
5445    let insert_at = recoverable
5446        .binary_search_by(|existing| existing.name.cmp(&entry.name))
5447        .unwrap_or_else(|index| index);
5448    if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
5449        && insert_at == recoverable.len()
5450    {
5451        return;
5452    }
5453    recoverable.insert(insert_at, entry);
5454    if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
5455        recoverable.pop();
5456    }
5457}
5458
5459fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
5460    entry.name != "sessions"
5461        && matches!(
5462            entry.status,
5463            DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
5464        )
5465}
5466
5467fn print_doctor_legacy_state_report(
5468    report: &[DoctorLegacyStateEntry],
5469    session_recovery: &DoctorSessionRecoveryReport,
5470    ok_rgb: (u8, u8, u8),
5471    warn_rgb: (u8, u8, u8),
5472) {
5473    use colored::Colorize;
5474
5475    let attention: Vec<_> = report
5476        .iter()
5477        .filter(|entry| legacy_state_needs_attention(entry))
5478        .collect();
5479    if attention.is_empty()
5480        && !session_recovery.needs_attention()
5481        && session_recovery.status != DoctorSessionRecoveryStatus::Isolated
5482    {
5483        println!(
5484            "  {} legacy state: no known .deepseek entries need migration",
5485            "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5486        );
5487    } else if !attention.is_empty() {
5488        println!(
5489            "  {} legacy state needs review:",
5490            "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5491        );
5492        for entry in attention {
5493            match entry.status {
5494                DoctorLegacyStateStatus::LegacyOnly => {
5495                    println!(
5496                        "    {} {} exists but {} is missing",
5497                        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5498                        crate::utils::display_path(&entry.legacy_path),
5499                        crate::utils::display_path(&entry.primary_path),
5500                    );
5501                }
5502                DoctorLegacyStateStatus::Both => {
5503                    println!(
5504                        "    {} {} exists alongside primary {}; legacy data may still need review",
5505                        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5506                        crate::utils::display_path(&entry.legacy_path),
5507                        crate::utils::display_path(&entry.primary_path),
5508                    );
5509                }
5510                DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
5511            }
5512        }
5513        println!(
5514            "    Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
5515        );
5516    }
5517
5518    print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
5519}
5520
5521fn print_doctor_session_recovery_report(
5522    report: &DoctorSessionRecoveryReport,
5523    ok_rgb: (u8, u8, u8),
5524    warn_rgb: (u8, u8, u8),
5525) {
5526    use colored::Colorize;
5527
5528    match report.status {
5529        DoctorSessionRecoveryStatus::Isolated => {
5530            println!(
5531                "  {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
5532                "·".dimmed()
5533            );
5534            println!(
5535                "    This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
5536            );
5537        }
5538        DoctorSessionRecoveryStatus::NoLegacySessions => {
5539            println!(
5540                "  {} legacy sessions: no top-level session JSON files found",
5541                "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5542            );
5543        }
5544        DoctorSessionRecoveryStatus::MigrationComplete => {
5545            println!(
5546                "  {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
5547                "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
5548                report.legacy_session_file_count,
5549                crate::utils::display_path(&report.primary_sessions_path),
5550            );
5551        }
5552        DoctorSessionRecoveryStatus::MigrationPending
5553        | DoctorSessionRecoveryStatus::MigrationIncomplete => {
5554            let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
5555                "migration is incomplete"
5556            } else {
5557                "migration has not completed"
5558            };
5559            println!(
5560                "  {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
5561                "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5562                report.recoverable_file_count,
5563                crate::utils::display_path(&report.primary_sessions_path),
5564            );
5565            for entry in report
5566                .recoverable
5567                .iter()
5568                .take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
5569            {
5570                println!(
5571                    "    {} {} -> {}",
5572                    "·".dimmed(),
5573                    crate::utils::display_path(&entry.source_path),
5574                    crate::utils::display_path(&entry.destination_path),
5575                );
5576            }
5577            if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
5578                println!(
5579                    "    · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
5580                    report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
5581                );
5582            }
5583            println!("    Safe recovery:");
5584            println!(
5585                "      1. Back up {} and {} (if present).",
5586                crate::utils::display_path(&report.legacy_sessions_path),
5587                crate::utils::display_path(&report.primary_sessions_path),
5588            );
5589            println!(
5590                "      2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
5591            );
5592            println!(
5593                "      3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
5594            );
5595        }
5596        DoctorSessionRecoveryStatus::ScanFailed => {
5597            println!(
5598                "  {} legacy sessions: recovery diagnostic could not complete",
5599                "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5600            );
5601            if let Some(error) = report.error.as_deref() {
5602                println!("    {error}");
5603            }
5604            println!(
5605                "    Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
5606            );
5607        }
5608    }
5609    if report.status != DoctorSessionRecoveryStatus::Isolated {
5610        println!(
5611            "    Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
5612        );
5613    }
5614}
5615
5616fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
5617    use serde_json::json;
5618
5619    let recoverable: Vec<_> = report
5620        .recoverable
5621        .iter()
5622        .take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
5623        .map(|entry| {
5624            json!({
5625                "name": entry.name.display().to_string(),
5626                "source_path": entry.source_path.display().to_string(),
5627                "destination_path": entry.destination_path.display().to_string(),
5628            })
5629        })
5630        .collect();
5631
5632    json!({
5633        "status": report.status.as_str(),
5634        "needs_attention": report.needs_attention(),
5635        "read_only": true,
5636        "chat_contents_read": false,
5637        "checkpoint_internals_scanned": false,
5638        "session_descriptors_compared": false,
5639        "counterpart_check": "top_level_filename_and_regular_file_only",
5640        "codewhale_home_is_explicit": report.codewhale_home_is_explicit,
5641        "legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
5642        "primary_sessions_path": report.primary_sessions_path.display().to_string(),
5643        "legacy_session_file_count": report.legacy_session_file_count,
5644        "already_present_file_count": report.already_present_file_count,
5645        "recoverable_file_count": report.recoverable_file_count,
5646        "recoverable_files": recoverable,
5647        "recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
5648        "error": report.error,
5649        "recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
5650            Some("codewhale sessions")
5651        } else {
5652            None
5653        },
5654    })
5655}
5656
5657fn doctor_legacy_state_json(
5658    primary_root: &Path,
5659    legacy_root: &Path,
5660    report: &[DoctorLegacyStateEntry],
5661    session_recovery: &DoctorSessionRecoveryReport,
5662) -> serde_json::Value {
5663    use serde_json::json;
5664
5665    let legacy_only = report
5666        .iter()
5667        .filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
5668        .count();
5669    let both = report
5670        .iter()
5671        .filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
5672        .count();
5673    let entries: Vec<_> = report
5674        .iter()
5675        .map(|entry| {
5676            json!({
5677                "name": entry.name,
5678                "primary_path": entry.primary_path.display().to_string(),
5679                "legacy_path": entry.legacy_path.display().to_string(),
5680                "primary_present": entry.primary_present,
5681                "legacy_present": entry.legacy_present,
5682                "status": entry.status.as_str(),
5683            })
5684        })
5685        .collect();
5686
5687    json!({
5688        "primary_root": primary_root.display().to_string(),
5689        "legacy_root": legacy_root.display().to_string(),
5690        "needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
5691        "legacy_only_count": legacy_only,
5692        "dual_present_count": both,
5693        "session_recovery": doctor_session_recovery_json(session_recovery),
5694        "entries": entries,
5695    })
5696}
5697
5698fn doctor_setup_state(
5699    config: &Config,
5700    workspace: &Path,
5701) -> (codewhale_config::SetupState, &'static str) {
5702    if let Ok(Some(state)) = codewhale_config::SetupState::load() {
5703        return (state, "persisted");
5704    }
5705
5706    (
5707        codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
5708            config, workspace,
5709        )),
5710        "derived",
5711    )
5712}
5713
5714fn doctor_inherited_setup_facts(
5715    config: &Config,
5716    workspace: &Path,
5717) -> codewhale_config::InheritedConfigFacts {
5718    let user_constitution = codewhale_config::UserConstitution::load().ok();
5719    let user_constitution_validity = user_constitution.as_ref().map_or(
5720        codewhale_config::ConstitutionValidity::Unknown,
5721        codewhale_config::UserConstitutionLoad::validity,
5722    );
5723    let has_user_constitution = user_constitution
5724        .as_ref()
5725        .is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
5726    let has_expert_override = codewhale_config::codewhale_home()
5727        .ok()
5728        .map(|home| home.join(Path::new(crate::prompts::CONSTITUTION_OVERRIDE_FILE)))
5729        .is_some_and(|path| path.exists());
5730
5731    codewhale_config::InheritedConfigFacts {
5732        language: None,
5733        has_provider_route: !config.default_model().trim().is_empty(),
5734        has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
5735        trust_chosen: !crate::tui::onboarding::needs_trust(workspace),
5736        has_expert_override,
5737        has_user_constitution,
5738        user_constitution_validity,
5739    }
5740}
5741
5742fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
5743    resolve_credential_diagnostic(config)
5744        .availability
5745        .certifies_ready()
5746}
5747
5748fn print_doctor_setup_report(
5749    config: &Config,
5750    workspace: &Path,
5751    state: &codewhale_config::SetupState,
5752    source: &str,
5753    ok_rgb: (u8, u8, u8),
5754    warn_rgb: (u8, u8, u8),
5755) {
5756    use colored::Colorize;
5757
5758    let credential = resolve_credential_diagnostic(config);
5759    // Setup completion is persisted independently from credential probing.
5760    // Ordinary doctor deliberately does not read environment values or the
5761    // durable secret store, so `not_probed` must not erase a completed lane.
5762    let first_run_ready = state.first_run_ready();
5763    let update_ready = state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION);
5764    let operate_ready = state.operate_ready();
5765    let first_run_icon = if first_run_ready {
5766        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5767    } else {
5768        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5769    };
5770    let update_icon = if update_ready {
5771        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5772    } else {
5773        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5774    };
5775    let operate_icon = if operate_ready {
5776        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5777    } else {
5778        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5779    };
5780
5781    println!();
5782    println!("{}", "Setup State:".bold());
5783    println!("  · source: {source}");
5784    println!(
5785        "  · credential: source={}, availability={}",
5786        doctor_api_key_source_label(credential.source),
5787        credential.availability.label()
5788    );
5789    println!(
5790        "  {first_run_icon} first-run: {}",
5791        doctor_ready_label(first_run_ready)
5792    );
5793    println!(
5794        "  {update_icon} update checkpoint {}: {}",
5795        crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
5796        doctor_ready_label(update_ready)
5797    );
5798    println!(
5799        "  {operate_icon} operate/fleet: {}",
5800        doctor_ready_label(operate_ready)
5801    );
5802    println!(
5803        "  · constitution autonomy: {} (guidance only)",
5804        doctor_constitution_autonomy_preference_id()
5805    );
5806    println!(
5807        "  · runtime posture: {}",
5808        doctor_runtime_posture_line(config, workspace)
5809    );
5810    let consistency = doctor_setup_consistency(state, source);
5811    if consistency["status"] == "inconsistent" {
5812        let issues = consistency["issues"]
5813            .as_array()
5814            .map(|issues| {
5815                issues
5816                    .iter()
5817                    .filter_map(serde_json::Value::as_str)
5818                    .collect::<Vec<_>>()
5819                    .join(", ")
5820            })
5821            .unwrap_or_default();
5822        println!(
5823            "  {} consistency: half-applied setup detected ({issues}) — {}",
5824            "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5825            consistency["repair"].as_str().unwrap_or("/setup"),
5826        );
5827    }
5828    println!(
5829        "  · 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)"
5830    );
5831    for step in codewhale_config::SetupStep::ALL {
5832        let entry = state.steps.get(&step);
5833        let required = entry.is_some_and(|entry| entry.required);
5834        let version = entry.and_then(|entry| entry.version.as_deref());
5835        let result = entry.and_then(|entry| entry.result.as_deref());
5836        let required_label = if required { "required" } else { "optional" };
5837        let version_label = version.unwrap_or("unversioned");
5838        let result_label = result.unwrap_or("no result");
5839        println!(
5840            "    · {}: {} ({required_label}, {version_label}, {result_label})",
5841            setup_step_id(step),
5842            setup_status_id(state.status(step))
5843        );
5844    }
5845}
5846
5847fn doctor_ready_label(ready: bool) -> &'static str {
5848    if ready { "ready" } else { "needs action" }
5849}
5850
5851/// Detect half-applied setup persistence (#3410).
5852///
5853/// The setup transaction writes `constitution.json` and `setup_state.json`
5854/// together, so a persisted state that points at a user-global constitution
5855/// which is missing or unusable on disk means a write was interrupted or a
5856/// file was removed out-of-band. Stale `.tmp*` files in `$CODEWHALE_HOME`
5857/// are the other fingerprint of an interrupted atomic write.
5858fn doctor_setup_consistency(
5859    state: &codewhale_config::SetupState,
5860    source: &str,
5861) -> serde_json::Value {
5862    use serde_json::json;
5863
5864    let mut issues: Vec<&'static str> = Vec::new();
5865
5866    if source == "persisted"
5867        && matches!(
5868            state.constitution_source,
5869            codewhale_config::ConstitutionSource::UserGlobal
5870        )
5871    {
5872        match codewhale_config::UserConstitution::load() {
5873            Ok(codewhale_config::UserConstitutionLoad::Missing) => {
5874                issues.push("setup_state_points_at_missing_user_constitution");
5875            }
5876            Ok(codewhale_config::UserConstitutionLoad::Empty) => {
5877                issues.push("user_constitution_empty");
5878            }
5879            Ok(codewhale_config::UserConstitutionLoad::Invalid(_)) => {
5880                issues.push("user_constitution_invalid");
5881            }
5882            Ok(codewhale_config::UserConstitutionLoad::Unreadable(_)) | Err(_) => {
5883                issues.push("user_constitution_unreadable");
5884            }
5885            Ok(codewhale_config::UserConstitutionLoad::Loaded(_)) => {}
5886        }
5887    }
5888
5889    if doctor_home_has_stale_setup_temp_files() {
5890        issues.push("stale_setup_temp_files_in_codewhale_home");
5891    }
5892
5893    json!({
5894        "status": if issues.is_empty() { "consistent" } else { "inconsistent" },
5895        "issues": issues,
5896        "repair": "/constitution to rebuild standing law, /setup to re-run the checkpoint",
5897    })
5898}
5899
5900fn doctor_home_has_stale_setup_temp_files() -> bool {
5901    let Ok(home) = codewhale_config::codewhale_home() else {
5902        return false;
5903    };
5904    let Ok(entries) = std::fs::read_dir(&home) else {
5905        return false;
5906    };
5907    entries.flatten().any(|entry| {
5908        entry.file_name().to_string_lossy().starts_with(".tmp")
5909            && entry.file_type().is_ok_and(|kind| kind.is_file())
5910    })
5911}
5912
5913fn doctor_constitution_autonomy_preference() -> codewhale_config::AutonomyPreference {
5914    codewhale_config::UserConstitution::load()
5915        .ok()
5916        .and_then(|load| {
5917            load.constitution()
5918                .map(|constitution| constitution.autonomy_preference)
5919        })
5920        .unwrap_or(codewhale_config::AutonomyPreference::Unspecified)
5921}
5922
5923fn doctor_constitution_autonomy_preference_id() -> &'static str {
5924    autonomy_preference_id(doctor_constitution_autonomy_preference())
5925}
5926
5927fn autonomy_preference_id(preference: codewhale_config::AutonomyPreference) -> &'static str {
5928    match preference {
5929        codewhale_config::AutonomyPreference::Unspecified => "unspecified",
5930        codewhale_config::AutonomyPreference::Cautious => "cautious",
5931        codewhale_config::AutonomyPreference::Balanced => "balanced",
5932        codewhale_config::AutonomyPreference::Autonomous => "autonomous",
5933    }
5934}
5935
5936fn doctor_runtime_default_mode() -> (String, &'static str) {
5937    match crate::settings::Settings::load_read_only() {
5938        Ok(settings) => (settings.default_mode, "settings"),
5939        Err(_) => (crate::settings::Settings::default().default_mode, "default"),
5940    }
5941}
5942
5943/// TUI settings posture used when `config.approval_policy` is unset.
5944/// Doctor must surface this separately so a saved Full Access baseline is not
5945/// misreported as the config default `approval_policy=on-request`.
5946fn doctor_runtime_permission_posture() -> (String, &'static str) {
5947    match crate::settings::Settings::load_read_only() {
5948        Ok(settings) => match settings.permission_posture {
5949            Some(posture) => (posture, "settings"),
5950            None => ("unset".to_string(), "default"),
5951        },
5952        Err(_) => ("unset".to_string(), "default"),
5953    }
5954}
5955
5956fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
5957    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
5958    let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
5959    let approval = config.approval_policy.as_deref().unwrap_or("on-request");
5960    let approval_source = if config.approval_policy.is_some() {
5961        "config"
5962    } else {
5963        "default"
5964    };
5965    let allow_shell = config.interactive_allow_shell();
5966    let allow_shell_source = if config.allow_shell.is_some() {
5967        "config"
5968    } else {
5969        "interactive default"
5970    };
5971    let sandbox = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
5972    let sandbox_source = if config.sandbox_mode.is_some() {
5973        "config"
5974    } else {
5975        "default"
5976    };
5977    let network = config
5978        .network
5979        .as_ref()
5980        .map_or("prompt", |policy| policy.default.as_str());
5981    let network_source = if config.network.is_some() {
5982        "config"
5983    } else {
5984        "default"
5985    };
5986    let trust = if crate::tui::onboarding::needs_trust(workspace) {
5987        "workspace not elevated"
5988    } else {
5989        "workspace trusted"
5990    };
5991
5992    format!(
5993        "default_mode={default_mode} ({default_mode_source}), permission_posture={permission_posture} ({permission_posture_source}), approval_policy={approval} ({approval_source}), allow_shell={allow_shell} ({allow_shell_source}), sandbox={sandbox} ({sandbox_source}), network.default={network} ({network_source}), trust={trust}"
5994    )
5995}
5996
5997fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
5998    use serde_json::json;
5999
6000    let provider = config.api_provider();
6001    // Doctor reports configured routing posture only. In particular it must
6002    // never consume an external-file grant merely to label Fleet readiness.
6003    let credential = resolve_credential_diagnostic(config);
6004    let has_credentials_or_local = credential.availability.certifies_ready();
6005    let subagents_enabled = config.subagents_enabled_for_provider(provider);
6006    let disabled_reason = if subagents_enabled {
6007        None
6008    } else {
6009        Some(
6010            config
6011                .subagents_disabled_reason()
6012                .unwrap_or("disabled for active provider"),
6013        )
6014    };
6015    let max_subagents = config.max_subagents_for_provider(provider);
6016    let launch_concurrency = config.launch_concurrency_for_provider(provider);
6017    let max_admitted = config.max_admitted_subagents_for_provider(provider);
6018    let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
6019    let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
6020    let mut built_in_members = 0usize;
6021    let mut config_members = 0usize;
6022    let mut personal_members = 0usize;
6023    let mut workspace_members = 0usize;
6024    for member in roster.members() {
6025        match member.origin {
6026            crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
6027            crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
6028            crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
6029            crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
6030        }
6031    }
6032    let roster_members = roster.members().len();
6033    let custom_members = config_members + personal_members + workspace_members;
6034    let roster_ready = roster_members > 0;
6035    let runtime_ready =
6036        subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
6037
6038    json!({
6039        "ready": has_credentials_or_local && runtime_ready && roster_ready,
6040        "provider": {
6041            "id": config.provider_identity_for(provider),
6042            "auth": {
6043                "present_or_local": has_credentials_or_local,
6044                "source": doctor_api_key_source_label(credential.source),
6045                "availability": credential.availability.label(),
6046            },
6047        },
6048        "worker_runtime": {
6049            "ready": runtime_ready,
6050            "enabled": subagents_enabled,
6051            "disabled_reason": disabled_reason,
6052            "max_subagents": max_subagents,
6053            "launch_concurrency": launch_concurrency,
6054            "max_admitted": max_admitted,
6055            "max_spawn_depth": max_spawn_depth,
6056            "host_enforced_workflow_receipts": true,
6057        },
6058        "roster": {
6059            "ready": roster_ready,
6060            "total": roster_members,
6061            "built_in": built_in_members,
6062            "config": config_members,
6063            "personal": personal_members,
6064            "workspace": workspace_members,
6065            "custom": custom_members,
6066            "starter_roster_available": built_in_members > 0,
6067            "readiness_rule": "built-in starter roster or custom roster",
6068        },
6069        "concurrency": {
6070            "launch_concurrency": launch_concurrency,
6071            "max_subagents": max_subagents,
6072            "max_admitted": max_admitted,
6073            "plan_limit_probed": false,
6074        },
6075    })
6076}
6077
6078fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
6079    use serde_json::json;
6080
6081    let provider = config.api_provider();
6082    let credential = resolve_credential_diagnostic(config);
6083    let auth_present_or_local = credential.availability.certifies_ready();
6084    let credential_help = provider.credential_help();
6085    let credential_url = credential_help
6086        .credential_url
6087        .map(crate::doctor::structural_url_authority);
6088    let credential_docs_url = credential_help
6089        .docs_url
6090        .map(crate::doctor::structural_url_authority);
6091
6092    json!({
6093        "provider": {
6094            "id": config.provider_identity_for(provider),
6095            "display": provider.display_name(),
6096        },
6097        "model": {
6098            "resolved": config.default_model(),
6099        },
6100        "auth": {
6101            "present_or_local": auth_present_or_local,
6102            "source": doctor_api_key_source_label(credential.source),
6103            "availability": credential.availability.label(),
6104            "env_vars": provider.env_vars(),
6105            "credential_mode": credential_help.acquisition.as_str(),
6106            "credential_url": credential_url,
6107            "credential_docs_url": credential_docs_url,
6108            "credential_guidance": credential_help.guidance,
6109            "oauth_only": credential_help.acquisition
6110                == codewhale_config::provider::CredentialAcquisition::OAuth,
6111        },
6112        "health": {
6113            "live_validation": false,
6114            "next_action": if auth_present_or_local {
6115                "/model"
6116            } else {
6117                "/setup provider or /provider setup <name>"
6118            },
6119        },
6120    })
6121}
6122
6123fn doctor_dsh_integration_report(
6124    config: &Config,
6125    workspace: &Path,
6126) -> anyhow::Result<crate::integrations::dsh::DshStatusReport> {
6127    use crate::integrations::dsh;
6128    let paths = dsh::DshPaths::from_process()?;
6129    let detection = dsh::detect::detect(&dsh::DetectEnv::from_process(), &dsh::ProcessRunner);
6130    let identity = dsh::codewhale_route_identity(config, workspace);
6131    dsh::compute_status(
6132        &paths,
6133        detection,
6134        identity,
6135        false,
6136        dsh::bundle_availability_now(),
6137    )
6138}
6139
6140fn doctor_dsh_integration_lines(config: &Config, workspace: &Path) -> Vec<String> {
6141    match doctor_dsh_integration_report(config, workspace) {
6142        Ok(report) => {
6143            let mut lines = vec![
6144                format!("state: {}", report.state.label()),
6145                crate::integrations::dsh::status_line(&report),
6146                format!(
6147                    "owned files: {} (overlay {})",
6148                    crate::utils::display_path(&report.paths_root),
6149                    if report.overlay_present {
6150                        "present"
6151                    } else {
6152                        "absent"
6153                    }
6154                ),
6155            ];
6156            if !report.shadowing_namespaces.is_empty() {
6157                lines.push(format!(
6158                    "dsh settings.yaml sections that can shadow the overlay: {}",
6159                    report.shadowing_namespaces.join(", ")
6160                ));
6161            }
6162            lines
6163        }
6164        Err(error) => vec![format!("unavailable: {error}")],
6165    }
6166}
6167
6168fn doctor_dsh_integration_json(config: &Config, workspace: &Path) -> serde_json::Value {
6169    match doctor_dsh_integration_report(config, workspace) {
6170        Ok(report) => serde_json::json!({
6171            "state": report.state.label(),
6172            "summary": crate::integrations::dsh::status_line(&report),
6173            "dsh_version": report.detection.version,
6174            "compatibility": report.detection.compatibility.label(),
6175            "overlay_present": report.overlay_present,
6176            "shadowing_namespaces": report.shadowing_namespaces,
6177        }),
6178        Err(error) => serde_json::json!({ "state": "unavailable", "error": error.to_string() }),
6179    }
6180}
6181
6182fn doctor_external_credential_consent_statuses(
6183    config: &Config,
6184) -> Vec<codewhale_config::ExternalCredentialConsentStatus> {
6185    [
6186        crate::config::ApiProvider::OpenaiCodex,
6187        crate::config::ApiProvider::Xai,
6188        crate::config::ApiProvider::Deepseek,
6189    ]
6190    .into_iter()
6191    .filter_map(|provider| config.external_credential_consent_status(provider))
6192    .collect()
6193}
6194
6195fn doctor_external_credential_consent_lines(config: &Config) -> Vec<String> {
6196    doctor_external_credential_consent_statuses(config)
6197        .into_iter()
6198        .flat_map(|status| {
6199            let mut lines = vec![
6200                format!(
6201                    "{}: access={}, provider={}, source={}, owner={}, path={}, version={}, state={}, ambient_path_changed={}",
6202                    status.provider,
6203                    status.access.as_str(),
6204                    status.provider,
6205                    status.source.as_str(),
6206                    status.owner,
6207                    codewhale_config::quote_os_path(&status.path),
6208                    status.consent_version,
6209                    status.route_state,
6210                    status.ambient_path_changed,
6211                ),
6212                format!("  semantics: {}", status.semantics),
6213                format!("  revoke: {}", status.revoke_command),
6214            ];
6215            if let Some(warning) = status.ambient_path_warning() {
6216                lines.push(format!("  {warning}"));
6217            }
6218            lines
6219        })
6220        .collect()
6221}
6222
6223fn doctor_external_credential_consent_json(config: &Config) -> serde_json::Value {
6224    serde_json::Value::Array(
6225        doctor_external_credential_consent_statuses(config)
6226            .into_iter()
6227            .map(|status| {
6228                serde_json::json!({
6229                    "provider": status.provider,
6230                    "access": status.access.as_str(),
6231                    "source": status.source.as_str(),
6232                    "owner": status.owner,
6233                    "path": codewhale_config::quote_os_path(&status.path),
6234                    "consent_version": status.consent_version,
6235                    "scope_valid": status.scope_valid,
6236                    "ambient_path_changed": status.ambient_path_changed,
6237                    "ambient_path_warning": status.ambient_path_warning(),
6238                    "route_state": status.route_state,
6239                    "semantics": status.semantics,
6240                    "revoke_command": status.revoke_command,
6241                })
6242            })
6243            .collect(),
6244    )
6245}
6246
6247fn doctor_setup_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6248    use serde_json::json;
6249
6250    let (state, source) = doctor_setup_state(config, workspace);
6251    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
6252    let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
6253    let approval_policy = config.approval_policy.as_deref().unwrap_or("on-request");
6254    let approval_policy_source = if config.approval_policy.is_some() {
6255        "config"
6256    } else {
6257        "default"
6258    };
6259    let allow_shell = config.interactive_allow_shell();
6260    let allow_shell_source = if config.allow_shell.is_some() {
6261        "config"
6262    } else {
6263        "interactive_default"
6264    };
6265    let sandbox_mode = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6266    let sandbox_mode_source = if config.sandbox_mode.is_some() {
6267        "config"
6268    } else {
6269        "default"
6270    };
6271    let network_default = config
6272        .network
6273        .as_ref()
6274        .map_or("prompt", |policy| policy.default.as_str());
6275    let network_source = if config.network.is_some() {
6276        "config"
6277    } else {
6278        "default"
6279    };
6280    let workspace_trusted = !crate::tui::onboarding::needs_trust(workspace);
6281    let credential = resolve_credential_diagnostic(config);
6282    let credential_ready = credential.availability.certifies_ready();
6283    let steps: Vec<_> = codewhale_config::SetupStep::ALL
6284        .into_iter()
6285        .map(|step| {
6286            let entry = state.steps.get(&step);
6287            json!({
6288                "step": setup_step_id(step),
6289                "status": setup_status_id(state.status(step)),
6290                "required": entry.is_some_and(|entry| entry.required),
6291                "version": entry.and_then(|entry| entry.version.clone()),
6292                "result": entry.and_then(|entry| entry.result.clone()),
6293            })
6294        })
6295        .collect();
6296
6297    json!({
6298        "source": source,
6299        "schema_version": state.schema_version,
6300        "inherited": state.inherited,
6301        "checkpoint_version": crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
6302        "first_run_ready": state.first_run_ready(),
6303        "update_ready": state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION),
6304        "operate_ready": state.operate_ready(),
6305        "credential": {
6306            "ready": credential_ready,
6307            "source": doctor_api_key_source_label(credential.source),
6308            "availability": credential.availability.label(),
6309        },
6310        "constitution": {
6311            "choice": constitution_choice_id(state.constitution_choice),
6312            "source": constitution_source_id(state.constitution_source),
6313            "validity": constitution_validity_id(state.constitution_validity),
6314            "checkpoint_completed_for": state.constitution_checkpoint_completed_for.clone(),
6315            "language": state.constitution_language.clone(),
6316            "preview_hash_present": state.constitution_preview_hash.is_some(),
6317            "preview_version": state.constitution_preview_version,
6318            "autonomy_preference": doctor_constitution_autonomy_preference_id(),
6319        },
6320        "runtime_posture_source": runtime_posture_source_id(state.runtime_posture_source),
6321        "runtime_posture": {
6322            "source": runtime_posture_source_id(state.runtime_posture_source),
6323            "default_mode": {
6324                "value": default_mode,
6325                "source": default_mode_source,
6326            },
6327            "permission_posture": {
6328                "value": permission_posture,
6329                "source": permission_posture_source,
6330            },
6331            "approval_policy": {
6332                "value": approval_policy,
6333                "source": approval_policy_source,
6334            },
6335            "allow_shell": {
6336                "value": allow_shell,
6337                "source": allow_shell_source,
6338            },
6339            "sandbox_mode": {
6340                "value": sandbox_mode,
6341                "source": sandbox_mode_source,
6342            },
6343            "network_default": {
6344                "value": network_default,
6345                "source": network_source,
6346            },
6347            "workspace_trust": {
6348                "trusted": workspace_trusted,
6349                "source": "workspace",
6350            },
6351        },
6352        "provider_model": doctor_provider_model_report_json(config),
6353        "operate_fleet": doctor_operate_fleet_report_json(config, workspace),
6354        "consistency": doctor_setup_consistency(&state, source),
6355        "next_actions": {
6356            "constitution": "/constitution",
6357            "setup_report": "/setup report",
6358            "provider_model": "/setup provider, /provider setup <name>, or /model",
6359            "runtime_posture": "/config",
6360            "operate_fleet": "/setup fleet (readiness), /fleet setup (explicit profile authoring)",
6361            "hotbar": "/setup hotbar",
6362            "tools_mcp": "/setup tools",
6363            "remote_runtime": "/setup remote",
6364            "persistence": "/setup persistence",
6365        },
6366        "steps": steps,
6367    })
6368}
6369
6370fn setup_step_id(step: codewhale_config::SetupStep) -> &'static str {
6371    match step {
6372        codewhale_config::SetupStep::Language => "language",
6373        codewhale_config::SetupStep::ProviderModel => "provider_model",
6374        codewhale_config::SetupStep::TrustSandbox => "trust_sandbox",
6375        codewhale_config::SetupStep::ToolsMcp => "tools_mcp",
6376        codewhale_config::SetupStep::Hotbar => "hotbar",
6377        codewhale_config::SetupStep::RemoteRuntime => "remote_runtime",
6378        codewhale_config::SetupStep::Persistence => "persistence",
6379        codewhale_config::SetupStep::Constitution => "constitution",
6380        codewhale_config::SetupStep::OperateFleet => "operate_fleet",
6381        codewhale_config::SetupStep::Verification => "verification",
6382    }
6383}
6384
6385fn setup_status_id(status: codewhale_config::StepStatus) -> &'static str {
6386    match status {
6387        codewhale_config::StepStatus::NotStarted => "not_started",
6388        codewhale_config::StepStatus::Recommended => "recommended",
6389        codewhale_config::StepStatus::Optional => "optional",
6390        codewhale_config::StepStatus::Deferred => "deferred",
6391        codewhale_config::StepStatus::InProgress => "in_progress",
6392        codewhale_config::StepStatus::Verified => "verified",
6393        codewhale_config::StepStatus::NeedsAction => "needs_action",
6394        codewhale_config::StepStatus::Failed => "failed",
6395        codewhale_config::StepStatus::Skipped => "skipped",
6396    }
6397}
6398
6399fn constitution_choice_id(choice: codewhale_config::ConstitutionChoice) -> &'static str {
6400    match choice {
6401        codewhale_config::ConstitutionChoice::Unset => "unset",
6402        codewhale_config::ConstitutionChoice::Bundled => "bundled",
6403        codewhale_config::ConstitutionChoice::GuidedCustom => "guided_custom",
6404        codewhale_config::ConstitutionChoice::ExpertOverride => "expert_override",
6405        codewhale_config::ConstitutionChoice::Deferred => "deferred",
6406    }
6407}
6408
6409fn constitution_source_id(source: codewhale_config::ConstitutionSource) -> &'static str {
6410    match source {
6411        codewhale_config::ConstitutionSource::Bundled => "bundled",
6412        codewhale_config::ConstitutionSource::UserGlobal => "user_global",
6413        codewhale_config::ConstitutionSource::ExpertOverride => "expert_override",
6414    }
6415}
6416
6417fn constitution_validity_id(validity: codewhale_config::ConstitutionValidity) -> &'static str {
6418    match validity {
6419        codewhale_config::ConstitutionValidity::Unknown => "unknown",
6420        codewhale_config::ConstitutionValidity::Valid => "valid",
6421        codewhale_config::ConstitutionValidity::Invalid => "invalid",
6422        codewhale_config::ConstitutionValidity::Empty => "empty",
6423        codewhale_config::ConstitutionValidity::Unreadable => "unreadable",
6424    }
6425}
6426
6427fn runtime_posture_source_id(source: codewhale_config::RuntimePostureSource) -> &'static str {
6428    match source {
6429        codewhale_config::RuntimePostureSource::Unset => "unset",
6430        codewhale_config::RuntimePostureSource::Inherited => "inherited",
6431        codewhale_config::RuntimePostureSource::Confirmed => "confirmed",
6432    }
6433}
6434
6435/// Emit a bounded, secret-redacted JSON failure when configuration cannot be
6436/// loaded or validated. Invalid configuration must not be forced through the
6437/// normal doctor report because its route/capability facts would be misleading.
6438fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
6439    let safe_message = error
6440        .downcast_ref::<crate::config::SafeConfigDiagnostic>()
6441        .map(ToString::to_string);
6442    let report = serde_json::json!({
6443        "status": "error",
6444        "error": {
6445            "kind": "config_validation",
6446            "message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
6447        },
6448    });
6449    println!("{}", serde_json::to_string_pretty(&report)?);
6450
6451    // Keep stderr generic: the actionable, redacted error is already on
6452    // stdout, and Rust's Result termination must never redisclose a secret.
6453    bail!("doctor configuration validation failed; see JSON output")
6454}
6455
6456/// Machine-readable counterpart to `run_doctor`. This report is always
6457/// structural and offline; live probe flags conflict with `--json`.
6458fn run_doctor_json(
6459    config: &Config,
6460    workspace: &Path,
6461    config_path_override: Option<&Path>,
6462    plugins: &crate::plugins::PluginRegistry,
6463) -> Result<()> {
6464    use serde_json::json;
6465
6466    let doctor_paths = crate::doctor::DoctorPathReport::resolve(config_path_override)?;
6467    let config_path = &doctor_paths.config;
6468    let secret_backend = codewhale_secrets::diagnose_secret_backend();
6469
6470    let credential = resolve_credential_diagnostic(config);
6471
6472    let mcp_config_path = config.mcp_config_path();
6473    let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
6474    let mcp_present = mcp_config_path.exists();
6475    let project_mcp_present = project_mcp_config_path.exists();
6476    let mcp_summary = match crate::mcp::load_config_with_workspace_and_plugins(
6477        &mcp_config_path,
6478        workspace,
6479        plugins,
6480    ) {
6481        Ok(cfg) => {
6482            let servers: Vec<serde_json::Value> = cfg
6483                .servers
6484                .iter()
6485                .map(|(name, server)| doctor_mcp_server_json(name, server))
6486                .collect();
6487            json!({
6488                "config_path": mcp_config_path.display().to_string(),
6489                "present": mcp_present,
6490                "project_config_path": project_mcp_config_path.display().to_string(),
6491                "project_present": project_mcp_present,
6492                "probe_scope": "configuration",
6493                "live_health_checked": false,
6494                "servers": servers,
6495            })
6496        }
6497        Err(_) => json!({
6498            "config_path": mcp_config_path.display().to_string(),
6499            "present": mcp_present,
6500            "project_config_path": project_mcp_config_path.display().to_string(),
6501            "project_present": project_mcp_present,
6502            "probe_scope": "configuration",
6503            "live_health_checked": false,
6504            "servers": [],
6505            "error": "configuration_unavailable_details_omitted",
6506        }),
6507    };
6508
6509    let global_skills_dir = config.skills_dir();
6510    let agents_skills_dir = workspace.join(".agents").join("skills");
6511    let local_skills_dir = workspace.join("skills");
6512    let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
6513    // #432: cross-tool skill discovery dirs surface in the JSON
6514    // report so external dashboards can see whether any
6515    // `.opencode/skills/`, `.claude/skills/`, `.cursor/skills/`, or
6516    // global agentskills.io content is contributing to the merged catalogue.
6517    let opencode_skills_dir = workspace.join(".opencode").join("skills");
6518    let claude_skills_dir = workspace.join(".claude").join("skills");
6519    let selected_skills_dir = if agents_skills_dir.exists() {
6520        agents_skills_dir.clone()
6521    } else if local_skills_dir.exists() {
6522        local_skills_dir.clone()
6523    } else if config.skills_dir.is_none()
6524        && let Some(global_agents) = agents_global_skills_dir.as_ref()
6525        && global_agents.exists()
6526    {
6527        global_agents.clone()
6528    } else {
6529        global_skills_dir.clone()
6530    };
6531    let agents_global_summary = agents_global_skills_dir
6532        .as_ref()
6533        .map(|path| {
6534            json!({
6535                "path": path.display().to_string(),
6536                "present": path.exists(),
6537                "count": skills_count_for(path),
6538            })
6539        })
6540        .unwrap_or_else(|| {
6541            json!({
6542                "path": null,
6543                "present": false,
6544                "count": 0,
6545            })
6546        });
6547
6548    let tools_dir = default_tools_dir();
6549    let plugins_dir = default_plugins_dir();
6550
6551    // Memory feature state (#489). Operators ask "is memory on?" and
6552    // "where does it live?" — surface both here so the question can be
6553    // answered without booting the TUI. Both inputs are checked: the
6554    // config flag and the env-var override that the runtime would
6555    // honour. (The dedicated `Config::memory_enabled()` accessor lives
6556    // on the memory-MVP branch (#518); this duplicates the same logic
6557    // until the two PRs land and it can be replaced with a single
6558    // method call.)
6559    let memory_path = config.memory_path();
6560    let memory_enabled_env = std::env::var("CODEWHALE_MEMORY")
6561        .or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
6562        .ok()
6563        .map(|raw| {
6564            matches!(
6565                raw.trim().to_ascii_lowercase().as_str(),
6566                "1" | "on" | "true" | "yes" | "y" | "enabled"
6567            )
6568        })
6569        .unwrap_or(false);
6570    let memory_summary = json!({
6571        // The MVP feature is opt-in by default; this defaults to false
6572        // on branches without the [memory] section in `Config`.
6573        "enabled": memory_enabled_env,
6574        "path": memory_path.display().to_string(),
6575        "file_present": memory_path.exists(),
6576    });
6577    let api_target = doctor_api_target(config);
6578    let strict_tool_mode = doctor_strict_tool_mode_status(config);
6579    let tls_status = doctor_tls_status(config);
6580    let (code_home, legacy_home) = doctor_state_roots();
6581    let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
6582    let session_recovery = doctor_session_recovery_report(
6583        &code_home,
6584        &legacy_home,
6585        codewhale_config::codewhale_home_is_explicit(),
6586    );
6587
6588    let stash = crate::composer_stash::diagnostic_stash_report();
6589    let report = json!({
6590        "version": env!("CARGO_PKG_VERSION"),
6591        "config_path": config_path.display().to_string(),
6592        "config_present": config_path.exists(),
6593        "paths": doctor_paths,
6594        "secret_backend": secret_backend,
6595        "workspace": workspace.display().to_string(),
6596        "legacy_state": doctor_legacy_state_json(
6597            &code_home,
6598            &legacy_home,
6599            &legacy_state_report,
6600            &session_recovery,
6601        ),
6602        "setup": doctor_setup_report_json(config, workspace),
6603        "api_key": {
6604            "source": doctor_api_key_source_label(credential.source),
6605            "availability": credential.availability.label(),
6606        },
6607        "external_credentials": doctor_external_credential_consent_json(config),
6608        "dsh_integration": doctor_dsh_integration_json(config, workspace),
6609        "base_url": crate::doctor::structural_url_authority(&api_target.base_url),
6610        "default_text_model": api_target.model,
6611        // DGF-01: this report describes the route a session launched now
6612        // would resolve; a running session keeps its launch-time route.
6613        "route_scope": "configured_at_launch",
6614        "model_resolution": match api_target.resolution {
6615            DoctorModelResolution::Resolved => "resolved",
6616            DoctorModelResolution::ConfiguredOnly => "configured_unresolved",
6617        },
6618        "route": doctor_route_report(config),
6619        "strict_tool_mode": doctor_strict_tool_mode_report_json(&strict_tool_mode),
6620        "tls": {
6621            "certificate_verification": tls_status.certificate_verification,
6622            "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
6623            "provider": tls_status.provider,
6624            "message": tls_status.message,
6625        },
6626        "search_provider": doctor_search_provider_json(config),
6627        "memory": memory_summary,
6628        "mcp": mcp_summary,
6629        "skills": {
6630            "selected": selected_skills_dir.display().to_string(),
6631            "global": {
6632                "path": global_skills_dir.display().to_string(),
6633                "present": global_skills_dir.exists(),
6634                "count": skills_count_for(&global_skills_dir),
6635            },
6636            "agents": {
6637                "path": agents_skills_dir.display().to_string(),
6638                "present": agents_skills_dir.exists(),
6639                "count": skills_count_for(&agents_skills_dir),
6640            },
6641            "agents_global": agents_global_summary,
6642            "local": {
6643                "path": local_skills_dir.display().to_string(),
6644                "present": local_skills_dir.exists(),
6645                "count": skills_count_for(&local_skills_dir),
6646            },
6647            "opencode": {
6648                "path": opencode_skills_dir.display().to_string(),
6649                "present": opencode_skills_dir.exists(),
6650                "count": skills_count_for(&opencode_skills_dir),
6651            },
6652            "claude": {
6653                "path": claude_skills_dir.display().to_string(),
6654                "present": claude_skills_dir.exists(),
6655                "count": skills_count_for(&claude_skills_dir),
6656            },
6657        },
6658        "tools": {
6659            "path": tools_dir.display().to_string(),
6660            "present": tools_dir.exists(),
6661            "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
6662        },
6663        "plugins": {
6664            "path": plugins_dir.display().to_string(),
6665            "present": plugins_dir.exists(),
6666            "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
6667        },
6668        "storage": {
6669            "spillover": {
6670                "path": crate::tools::truncate::spillover_root()
6671                    .map(|p| p.display().to_string())
6672                    .unwrap_or_default(),
6673                "present": crate::tools::truncate::spillover_root()
6674                    .is_some_and(|p| p.is_dir()),
6675                "count": crate::tools::truncate::spillover_root()
6676                    .filter(|p| p.is_dir())
6677                    .map(|p| count_dir_entries(&p))
6678                    .unwrap_or(0),
6679            },
6680            "stash": {
6681                "path": stash
6682                    .path
6683                    .as_ref()
6684                    .map(|path| path.display().to_string())
6685                    .unwrap_or_default(),
6686                "present": stash.present,
6687                "count": stash.count,
6688                "error": stash.error,
6689            },
6690        },
6691        "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
6692            config.prefer_bwrap.unwrap_or(false),
6693        ) {
6694            Some(kind) => json!({"available": true, "kind": kind.to_string()}),
6695            None => json!({"available": false, "kind": null}),
6696        },
6697        "platform": {
6698            "os": std::env::consts::OS,
6699            "arch": std::env::consts::ARCH,
6700        },
6701        "api_connectivity": {
6702            "checked": false,
6703            "status": "not_probed",
6704            "note": "JSON doctor is offline; use `codewhale doctor --probe-api` or `--probe-local` for an explicit live check.",
6705        },
6706        "capability": provider_capability_report(config),
6707    });
6708
6709    println!("{}", serde_json::to_string_pretty(&report)?);
6710    Ok(())
6711}
6712
6713fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
6714    let report = crate::context_report::build_headless_context_report(config, workspace);
6715    println!("{}", crate::context_report::context_report_json(&report));
6716    Ok(())
6717}
6718
6719/// Build the `capability` section for the machine-readable doctor report.
6720///
6721/// Returns a JSON value with the resolved provider, resolved model, context
6722/// window, max output, thinking support, cache telemetry support, and request
6723/// payload mode.
6724fn provider_capability_report(config: &Config) -> serde_json::Value {
6725    use serde_json::json;
6726
6727    let provider = config.api_provider();
6728    let configured_model = config.default_model();
6729    let route_result =
6730        crate::route_runtime::resolve_runtime_route(config, provider, Some(&configured_model));
6731    let route_error = route_result
6732        .is_err()
6733        .then_some("route_resolution_failed_details_omitted");
6734    let route = route_result.ok();
6735    let resolved_model = route
6736        .as_ref()
6737        .map_or(configured_model.as_str(), |route| route.model.as_str());
6738    let cap = crate::config::provider_capability(provider, resolved_model);
6739    let route_profile = route.as_ref().map(|route| {
6740        crate::model_profile::resolved_capability_profile_for_route(
6741            provider,
6742            resolved_model,
6743            route.candidate.capabilities(),
6744            route.candidate.limits(),
6745        )
6746    });
6747    let context_window = route
6748        .as_ref()
6749        .map_or(cap.context_window, |route| route.context_window.tokens);
6750    let context_window_source = route.as_ref().map_or(
6751        crate::route_runtime::ContextWindowSource::Fallback.label(),
6752        |route| route.context_window.source.label(),
6753    );
6754    // `null` when neither the resolved route nor the compatibility matrix
6755    // publishes an output ceiling — doctor must not invent one.
6756    let max_output = route_profile
6757        .as_ref()
6758        .and_then(|profile| profile.max_output)
6759        .or(cap.max_output);
6760    let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
6761        crate::config::is_exact_kimi_code_k3_route(
6762            provider,
6763            &route.candidate.endpoint().base_url,
6764            route.candidate.wire_model_id().as_str(),
6765        )
6766    });
6767    let thinking_supported = is_exact_kimi_code_k3
6768        || route_profile
6769            .as_ref()
6770            .map_or(cap.thinking_supported, |profile| {
6771                profile.supports_reasoning()
6772            });
6773    let cache_telemetry_supported = route_profile
6774        .as_ref()
6775        .map_or(cap.cache_telemetry_supported, |profile| {
6776            profile.prompt_caching.is_supported()
6777        });
6778    let request_payload_mode = route_profile
6779        .as_ref()
6780        .map_or(cap.request_payload_mode, |profile| {
6781            profile.request_payload_mode
6782        });
6783    let alias_deprecation = config.active_deepseek_alias_deprecation();
6784
6785    json!({
6786        "resolved_provider": config.provider_identity_for(provider),
6787        "resolved_model": resolved_model,
6788        "context_window": context_window,
6789        "context_window_source": context_window_source,
6790        "max_output": max_output,
6791        "thinking_supported": thinking_supported,
6792        "cache_telemetry_supported": cache_telemetry_supported,
6793        "request_payload_mode": serde_json::to_value(request_payload_mode).unwrap_or_default(),
6794        "route_error": route_error,
6795        "alias_deprecation": alias_deprecation,
6796    })
6797}
6798
6799fn doctor_route_report(config: &Config) -> serde_json::Value {
6800    use serde_json::json;
6801
6802    let target = doctor_api_target(config);
6803    let provider = config.api_provider();
6804    let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
6805    let route_result =
6806        crate::route_runtime::resolve_runtime_route(config, provider, Some(&target.model));
6807    let route_error = route_result
6808        .is_err()
6809        .then_some("route_resolution_failed_details_omitted");
6810    let context_window = route_result
6811        .ok()
6812        .map(|route| {
6813        json!({
6814            "tokens": route.context_window.tokens,
6815            "source": route.context_window.source.label(),
6816        })
6817    })
6818    .unwrap_or_else(|| {
6819        json!({
6820            "tokens": crate::config::provider_capability(provider, &target.model).context_window,
6821            "source": crate::route_runtime::ContextWindowSource::Fallback.label(),
6822        })
6823    });
6824
6825    let route_identity =
6826        crate::config::moonshot_k3_route_display_name(&target.base_url, &target.model);
6827    let credential = resolve_credential_diagnostic(config);
6828
6829    json!({
6830        "provider": target.provider,
6831        "provider_source": doctor_provider_source(config),
6832        "provider_config_table": doctor_provider_config_table(config, provider),
6833        "model": target.model,
6834        "route_identity": route_identity,
6835        "wire_protocol": doctor_wire_protocol(provider),
6836        "base_url": {
6837            "redacted": redacted_base_url,
6838            "class": doctor_base_url_class(provider, &target.base_url),
6839            "fingerprint": crate::utils::redacted_identifier_for_log(&target.base_url),
6840        },
6841        "auth": {
6842            "scheme": doctor_auth_scheme(config),
6843            "source": doctor_api_key_source_label(credential.source),
6844            "availability": credential.availability.label(),
6845        },
6846        "context_window": context_window,
6847        "route_error": route_error,
6848    })
6849}
6850
6851fn doctor_provider_config_table(config: &Config, provider: crate::config::ApiProvider) -> String {
6852    if provider != crate::config::ApiProvider::Custom {
6853        return provider_config_table_key(provider).to_string();
6854    }
6855    if config.uses_legacy_literal_custom_route() {
6856        "root (legacy literal custom)".to_string()
6857    } else {
6858        format!("providers.{}", config.provider_identity_for(provider))
6859    }
6860}
6861
6862fn doctor_provider_source(config: &Config) -> &'static str {
6863    if config
6864        .provider
6865        .as_ref()
6866        .is_some_and(|provider| !provider.trim().is_empty())
6867    {
6868        "config"
6869    } else {
6870        "default"
6871    }
6872}
6873
6874fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str {
6875    let policy = provider
6876        .metadata()
6877        .map(|metadata| metadata.wire_policy())
6878        .unwrap_or(codewhale_config::provider::WirePolicy::Fixed(
6879            codewhale_config::provider::WireFormat::ChatCompletions,
6880        ));
6881    match policy.fixed() {
6882        Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions",
6883        Some(codewhale_config::provider::WireFormat::Responses) => "responses",
6884        Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages",
6885        None => "model_aware",
6886    }
6887}
6888
6889fn doctor_base_url_class(provider: crate::config::ApiProvider, base_url: &str) -> &'static str {
6890    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
6891    if normalized.starts_with("http://localhost")
6892        || normalized.starts_with("http://127.0.0.1")
6893        || normalized.starts_with("http://[::1]")
6894    {
6895        return "local";
6896    }
6897    if normalized
6898        == provider
6899            .default_base_url()
6900            .trim_end_matches('/')
6901            .to_ascii_lowercase()
6902    {
6903        "default"
6904    } else {
6905        "custom"
6906    }
6907}
6908
6909fn doctor_auth_scheme(config: &Config) -> &'static str {
6910    let provider = config.api_provider();
6911    if crate::config::auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
6912    {
6913        "none"
6914    } else if provider == crate::config::ApiProvider::Anthropic {
6915        "x-api-key"
6916    } else if provider == crate::config::ApiProvider::XiaomiMimo
6917        && doctor_xiaomi_mimo_base_url_uses_token_plan(&config.deepseek_base_url())
6918    {
6919        "api-key"
6920    } else if provider == crate::config::ApiProvider::XiaomiMimo {
6921        // The alternate MiMo scheme depends on a credential prefix. Ordinary
6922        // doctor does not read credentials merely to make this label precise.
6923        "unknown"
6924    } else if matches!(
6925        provider,
6926        crate::config::ApiProvider::Sglang
6927            | crate::config::ApiProvider::Vllm
6928            | crate::config::ApiProvider::Ollama
6929    ) {
6930        "optional_bearer"
6931    } else {
6932        "bearer"
6933    }
6934}
6935
6936fn doctor_xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
6937    let normalized = base_url.trim_end_matches('/');
6938    [
6939        crate::config::XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
6940        crate::config::XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
6941        crate::config::XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
6942    ]
6943    .iter()
6944    .any(|candidate| normalized.eq_ignore_ascii_case(candidate.trim_end_matches('/')))
6945}
6946
6947fn doctor_api_key_source_label(source: ApiKeySource) -> &'static str {
6948    match source {
6949        ApiKeySource::ConfigDeclared => "config_declared",
6950        ApiKeySource::EnvDeclared => "env_declared",
6951        ApiKeySource::ExternalAuthDeclared => "external_auth_declared",
6952        ApiKeySource::SecretStoreUnprobed => "secret_store_unprobed",
6953        ApiKeySource::SecretStoreUnavailable => "secret_store_unavailable",
6954        ApiKeySource::OAuth => "oauth_unprobed",
6955        ApiKeySource::ExternalConsent => "external_consent",
6956        ApiKeySource::NoAuth => "none",
6957        ApiKeySource::LocalRuntime => "local_runtime",
6958        ApiKeySource::Unknown => "unknown",
6959    }
6960}
6961
6962fn doctor_search_provider_line(config: &Config) -> String {
6963    let search_provider = config.search_provider_resolution();
6964    let switch_hint = if matches!(
6965        (search_provider.provider, search_provider.source),
6966        (
6967            crate::config::SearchProvider::Firecrawl,
6968            crate::config::SearchProviderSource::Default
6969        )
6970    ) {
6971        "; set [search] provider = \"baidu\" | \"metaso\" | \"volcengine\" for China"
6972    } else {
6973        ""
6974    };
6975
6976    format!(
6977        "search_provider: {} (source: {}{})",
6978        search_provider.provider.as_str(),
6979        search_provider.source.as_str(),
6980        switch_hint
6981    )
6982}
6983
6984fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
6985    use serde_json::json;
6986
6987    let search_provider = config.search_provider_resolution();
6988    json!({
6989        "provider": search_provider.provider.as_str(),
6990        "source": search_provider.source.as_str(),
6991    })
6992}
6993
6994/// Whether the model in a [`DoctorApiTarget`] is the wire id the engine
6995/// resolver produced, or only the raw configured value because resolution
6996/// failed. Doctor never prints resolution error details — the JSON route
6997/// report already redacts them for the same reason.
6998#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6999enum DoctorModelResolution {
7000    Resolved,
7001    ConfiguredOnly,
7002}
7003
7004#[derive(Debug, Clone, PartialEq, Eq)]
7005struct DoctorApiTarget {
7006    provider: String,
7007    base_url: String,
7008    model: String,
7009    resolution: DoctorModelResolution,
7010}
7011
7012#[derive(Debug, Clone, PartialEq, Eq)]
7013struct DoctorStrictToolModeStatus {
7014    enabled: bool,
7015    status: &'static str,
7016    function_strict_sent: bool,
7017    message: String,
7018    recommended_base_url: Option<String>,
7019}
7020
7021fn doctor_api_target(config: &Config) -> DoctorApiTarget {
7022    let provider = config.api_provider();
7023    // Report the model through the same resolver the live client uses at
7024    // session launch (`client.rs` → `resolve_runtime_route`), so doctor's
7025    // answer matches what a session started now would actually serve —
7026    // saved provider models, alias normalization, and roster preference
7027    // included — instead of re-deriving a config default that can diverge
7028    // from the engine (DGF-01, dogfood 2026-08-02).
7029    let (model, resolution) =
7030        match crate::route_runtime::resolve_runtime_route(config, provider, None) {
7031            Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved),
7032            Err(_) => (
7033                config.default_model(),
7034                DoctorModelResolution::ConfiguredOnly,
7035            ),
7036        };
7037    DoctorApiTarget {
7038        provider: config.provider_identity_for(provider),
7039        base_url: config.deepseek_base_url(),
7040        model,
7041        resolution,
7042    }
7043}
7044
7045fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus {
7046    if !config.strict_tool_mode.unwrap_or(false) {
7047        return DoctorStrictToolModeStatus {
7048            enabled: false,
7049            status: "disabled",
7050            function_strict_sent: false,
7051            message: "disabled".to_string(),
7052            recommended_base_url: None,
7053        };
7054    }
7055
7056    let target = doctor_api_target(config);
7057    match known_deepseek_base_url_kind(&target.base_url) {
7058        Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus {
7059            enabled: true,
7060            status: "ready",
7061            function_strict_sent: true,
7062            message: "enabled; DeepSeek strict schemas use the beta endpoint".to_string(),
7063            recommended_base_url: None,
7064        },
7065        Some(DeepSeekBaseUrlKind::NonBeta) => {
7066            let recommended = recommended_strict_base_url(config, &target.base_url);
7067            DoctorStrictToolModeStatus {
7068                enabled: true,
7069                status: "fallback_non_beta",
7070                function_strict_sent: false,
7071                message:
7072                    "enabled, but function.strict is stripped for this non-beta DeepSeek endpoint"
7073                        .to_string(),
7074                recommended_base_url: Some(recommended.to_string()),
7075            }
7076        }
7077        None => DoctorStrictToolModeStatus {
7078            enabled: true,
7079            status: "custom_endpoint",
7080            function_strict_sent: true,
7081            message: "enabled; function.strict will be sent to this custom endpoint".to_string(),
7082            recommended_base_url: None,
7083        },
7084    }
7085}
7086
7087fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> serde_json::Value {
7088    serde_json::json!({
7089        "enabled": status.enabled,
7090        "status": status.status,
7091        "function_strict_sent": status.function_strict_sent,
7092        "message": status.message,
7093        "recommended_base_url": status
7094            .recommended_base_url
7095            .as_deref()
7096            .map(crate::doctor::structural_url_authority),
7097    })
7098}
7099
7100#[derive(Debug, Clone, PartialEq, Eq)]
7101struct DoctorTlsStatus {
7102    certificate_verification: bool,
7103    insecure_skip_tls_verify: bool,
7104    provider: String,
7105    message: String,
7106}
7107
7108fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
7109    let provider = config.provider_identity_for(config.api_provider());
7110    let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
7111    let message = if insecure_skip_tls_verify {
7112        format!(
7113            "TLS certificate verification cannot be disabled for provider {provider}; use SSL_CERT_FILE with a trusted custom CA bundle"
7114        )
7115    } else {
7116        "TLS certificate verification enabled".to_string()
7117    };
7118    DoctorTlsStatus {
7119        certificate_verification: true,
7120        insecure_skip_tls_verify,
7121        provider,
7122        message,
7123    }
7124}
7125
7126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7127enum DeepSeekBaseUrlKind {
7128    Beta,
7129    NonBeta,
7130}
7131
7132fn known_deepseek_base_url_kind(base_url: &str) -> Option<DeepSeekBaseUrlKind> {
7133    let normalized = base_url.trim_end_matches('/');
7134    if normalized.eq_ignore_ascii_case("https://api.deepseek.com/beta")
7135        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/beta")
7136    {
7137        Some(DeepSeekBaseUrlKind::Beta)
7138    } else if normalized.eq_ignore_ascii_case("https://api.deepseek.com")
7139        || normalized.eq_ignore_ascii_case("https://api.deepseek.com/v1")
7140        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com")
7141        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/v1")
7142    {
7143        Some(DeepSeekBaseUrlKind::NonBeta)
7144    } else {
7145        None
7146    }
7147}
7148
7149fn recommended_strict_base_url(_config: &Config, _base_url: &str) -> &'static str {
7150    crate::config::DEFAULT_DEEPSEEK_BASE_URL
7151}
7152
7153fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
7154    let target = doctor_api_target(config);
7155    let mut lines = vec![format!(
7156        "Connection timed out while reaching {}.",
7157        crate::doctor::structural_url_authority(&target.base_url)
7158    )];
7159
7160    match config.api_provider() {
7161        crate::config::ApiProvider::Deepseek
7162            if target.base_url.contains("api.deepseek.com")
7163                && !target.base_url.contains("api.deepseeki.com") =>
7164        {
7165            lines.push(
7166                "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.codewhale/config.toml and rerun `codewhale doctor`."
7167                    .to_string(),
7168            );
7169        }
7170        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
7171            lines.push(
7172                "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
7173                    .to_string(),
7174            );
7175        }
7176        _ => {
7177            lines.push(
7178                "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
7179                    .to_string(),
7180            );
7181        }
7182    }
7183
7184    lines.push(
7185        "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
7186            .to_string(),
7187    );
7188    lines
7189}
7190
7191fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
7192    match command.command {
7193        FeaturesSubcommand::List => {
7194            print!("{}", render_feature_table(&config.features()));
7195            Ok(())
7196        }
7197    }
7198}
7199
7200async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
7201    use crate::client::DeepSeekClient;
7202
7203    let client = DeepSeekClient::new(config)?;
7204    let mut models = client.list_models().await?;
7205    models.sort_by(|a, b| a.id.cmp(&b.id));
7206
7207    if args.json {
7208        println!("{}", serde_json::to_string_pretty(&models)?);
7209        return Ok(());
7210    }
7211
7212    if models.is_empty() {
7213        println!("No models returned by the API.");
7214        return Ok(());
7215    }
7216
7217    let default_model = config.default_model();
7218
7219    println!("Available models (default: {default_model})");
7220    for model in models {
7221        let marker = if model.id == default_model { "*" } else { " " };
7222        if let Some(owner) = model.owned_by {
7223            println!("{marker} {} ({owner})", model.id);
7224        } else {
7225            println!("{marker} {}", model.id);
7226        }
7227    }
7228
7229    Ok(())
7230}
7231
7232async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
7233    use crate::client::{DeepSeekClient, SpeechSynthesisRequest};
7234    use crate::config::ApiProvider;
7235    use crate::tools::speech::{
7236        DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions,
7237        default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri,
7238        infer_speech_model, normalize_speech_format,
7239    };
7240
7241    let SpeechArgs {
7242        text,
7243        output,
7244        output_dir,
7245        model,
7246        voice,
7247        instruction,
7248        voice_prompt,
7249        clone_voice,
7250        format,
7251        json: json_output,
7252    } = args;
7253
7254    if config.api_provider() != ApiProvider::XiaomiMimo {
7255        bail!(
7256            "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
7257            config.api_provider().as_str()
7258        );
7259    }
7260
7261    if text.trim().is_empty() {
7262        bail!("Speech text cannot be empty");
7263    }
7264    let voice_is_data_uri = voice
7265        .as_deref()
7266        .map(str::trim)
7267        .is_some_and(|value| value.starts_with("data:audio/"));
7268    if clone_voice.is_some() && voice.is_some() {
7269        bail!("Use either --clone-voice or --voice for cloned voice data, not both");
7270    }
7271    let model = infer_speech_model(
7272        model.as_deref(),
7273        clone_voice.is_some() || voice_is_data_uri,
7274        voice_prompt.is_some(),
7275    );
7276    let model_lower = model.to_ascii_lowercase();
7277    if !model_lower.contains("tts") {
7278        bail!(
7279            "speech requires a TTS model (examples: {}); got {model}",
7280            SPEECH_MODEL_EXAMPLES.join(", ")
7281        );
7282    }
7283    let is_voice_design = model_lower.contains("voicedesign");
7284    let is_voice_clone = model_lower.contains("voiceclone");
7285
7286    let instruction = combine_speech_instructions(instruction, voice_prompt);
7287    if is_voice_design
7288        && instruction
7289            .as_deref()
7290            .is_none_or(|value| value.trim().is_empty())
7291    {
7292        bail!(
7293            "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice"
7294        );
7295    }
7296
7297    let voice = if let Some(clone_path) = clone_voice {
7298        Some(encode_voice_clone_sample_data_uri(&clone_path)?)
7299    } else if is_voice_design {
7300        None
7301    } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) {
7302        Some(value)
7303    } else if is_voice_clone {
7304        bail!("mimo-v2.5-tts-voiceclone requires --clone-voice <mp3|wav> or --voice <data-uri>");
7305    } else {
7306        Some(DEFAULT_VOICE.to_string())
7307    };
7308    let format = normalize_speech_format(&format).with_context(|| {
7309        format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)")
7310    })?;
7311    let output = output.unwrap_or_else(|| {
7312        output_dir
7313            .or_else(|| config.speech_output_dir())
7314            .unwrap_or_default()
7315            .join(default_speech_output_name(&format))
7316    });
7317
7318    let client = DeepSeekClient::new(config)?;
7319    let response = client
7320        .synthesize_speech(SpeechSynthesisRequest {
7321            model: model.clone(),
7322            text,
7323            instruction,
7324            audio_format: format.clone(),
7325            voice,
7326        })
7327        .await?;
7328
7329    if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
7330        std::fs::create_dir_all(parent)
7331            .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
7332    }
7333    std::fs::write(&output, &response.audio_bytes)
7334        .with_context(|| format!("Failed to write audio file {}", output.display()))?;
7335
7336    if json_output {
7337        println!(
7338            "{}",
7339            serde_json::to_string_pretty(&serde_json::json!({
7340                "mode": "speech",
7341                "success": true,
7342                "model": response.model,
7343                "format": response.audio_format,
7344                "output": output.display().to_string(),
7345                "bytes": response.audio_bytes.len(),
7346                "voice": response.voice.as_deref().map(describe_speech_voice),
7347                "transcript": response.transcript,
7348            }))?
7349        );
7350    } else {
7351        println!(
7352            "Generated speech: {} ({} bytes, model: {}, format: {})",
7353            output.display(),
7354            response.audio_bytes.len(),
7355            response.model,
7356            response.audio_format
7357        );
7358    }
7359
7360    Ok(())
7361}
7362
7363#[cfg(test)]
7364mod speech_cli_tests {
7365    use super::*;
7366    use crate::tools::speech::{
7367        default_speech_output_name, infer_speech_model, normalize_speech_format,
7368    };
7369
7370    #[test]
7371    fn normalizes_documented_speech_formats() {
7372        assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav"));
7373        assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16"));
7374        assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16"));
7375        assert_eq!(normalize_speech_format("flac"), None);
7376    }
7377
7378    #[test]
7379    fn default_speech_output_tracks_requested_format() {
7380        assert_eq!(
7381            PathBuf::from(default_speech_output_name("mp3")),
7382            PathBuf::from("speech.mp3")
7383        );
7384        assert_eq!(
7385            PathBuf::from("audio").join(default_speech_output_name("pcm")),
7386            PathBuf::from("audio").join("speech.pcm16")
7387        );
7388    }
7389
7390    #[test]
7391    fn speech_command_parses_cli_passthrough_smoke() {
7392        let cli = Cli::try_parse_from([
7393            "codewhale-tui",
7394            "speech",
7395            "hello",
7396            "--model",
7397            "tts",
7398            "--format",
7399            "pcm",
7400            "--output-dir",
7401            "audio",
7402            "--voice",
7403            "Mia",
7404        ])
7405        .expect("speech command parses");
7406
7407        let Some(Commands::Speech(args)) = cli.command else {
7408            panic!("expected speech command");
7409        };
7410        assert_eq!(args.text, "hello");
7411        assert_eq!(
7412            infer_speech_model(args.model.as_deref(), false, false),
7413            "mimo-v2.5-tts"
7414        );
7415        assert_eq!(
7416            normalize_speech_format(&args.format).as_deref(),
7417            Some("pcm16")
7418        );
7419        assert_eq!(args.output_dir, Some(PathBuf::from("audio")));
7420        assert_eq!(args.voice.as_deref(), Some("Mia"));
7421    }
7422}
7423
7424/// Test API connectivity by making a minimal request
7425async fn test_api_connectivity(config: &Config) -> Result<()> {
7426    use crate::client::DeepSeekClient;
7427    use crate::models::{ContentBlock, Message, MessageRequest};
7428
7429    let client = DeepSeekClient::new(config)?;
7430    let model = client.model().to_string();
7431
7432    if crate::doctor::is_keyless_ds4_route(config) {
7433        return crate::doctor::probe_ds4_models(config).await;
7434    }
7435
7436    // Minimal request: single word prompt, 1 max token
7437    let request = MessageRequest {
7438        model: model.clone(),
7439        messages: vec![Message {
7440            role: "user".to_string(),
7441            content: vec![ContentBlock::Text {
7442                text: "hi".to_string(),
7443                cache_control: None,
7444            }],
7445        }],
7446        max_tokens: 1,
7447        system: None,
7448        tools: None,
7449        tool_choice: None,
7450        metadata: None,
7451        thinking: None,
7452        // This is a one-token transport probe, not a reasoning task.
7453        reasoning_effort: Some("off".to_string()),
7454        stream: Some(false),
7455        temperature: None,
7456        top_p: None,
7457    };
7458
7459    // Use tokio timeout to catch hanging requests
7460    let timeout_duration = std::time::Duration::from_secs(15);
7461    match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
7462        Ok(Ok(_response)) => Ok(()),
7463        Ok(Err(e)) => Err(e),
7464        Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
7465    }
7466}
7467
7468fn rustc_version() -> String {
7469    let Some(mut cmd) = crate::dependencies::RustC::command() else {
7470        return "unknown".to_string();
7471    };
7472    let Ok(output) = cmd.arg("--version").output() else {
7473        return "unknown".to_string();
7474    };
7475    String::from_utf8(output.stdout)
7476        .map(|s| s.trim().to_string())
7477        .unwrap_or_else(|_| "unknown".to_string())
7478}
7479
7480/// List saved sessions
7481fn sessions_resume_command() -> &'static str {
7482    "codewhale resume"
7483}
7484
7485fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
7486    use crate::palette;
7487    use colored::Colorize;
7488    use session_manager::{SessionManager, format_session_line};
7489
7490    let (action_r, action_g, action_b) = palette::WHALE_ACTION_RGB;
7491    let (human_r, human_g, human_b) = palette::WHALE_HUMAN_RGB;
7492    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7493    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7494
7495    let manager = SessionManager::default_location()?;
7496
7497    let sessions = if let Some(query) = search {
7498        manager.search_sessions(&query)?
7499    } else {
7500        manager.list_sessions()?
7501    };
7502
7503    if sessions.is_empty() {
7504        println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
7505        println!(
7506            "Start a new session with: {}",
7507            "codewhale".truecolor(human_r, human_g, human_b)
7508        );
7509        return Ok(());
7510    }
7511
7512    println!(
7513        "{}",
7514        "Saved Sessions"
7515            .truecolor(action_r, action_g, action_b)
7516            .bold()
7517    );
7518    println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
7519    println!();
7520
7521    for (i, session) in sessions.iter().take(limit).enumerate() {
7522        let line = format_session_line(session);
7523        if i == 0 {
7524            println!("  {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
7525        } else {
7526            println!("    {line}");
7527        }
7528    }
7529
7530    let total = sessions.len();
7531    if total > limit {
7532        println!();
7533        println!(
7534            "  {} more session(s). Use --limit to show more.",
7535            total - limit
7536        );
7537    }
7538
7539    println!();
7540    println!(
7541        "Resume with: {} {}",
7542        sessions_resume_command().truecolor(action_r, action_g, action_b),
7543        "<session-id>".dimmed()
7544    );
7545    println!(
7546        "Continue latest in this workspace: {}",
7547        "codewhale --continue".truecolor(action_r, action_g, action_b)
7548    );
7549
7550    Ok(())
7551}
7552
7553/// Initialize a new project with AGENTS.md
7554fn init_project() -> Result<()> {
7555    use crate::palette;
7556    use colored::Colorize;
7557    use project_context::create_default_agents_md;
7558
7559    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7560    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7561    let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
7562
7563    let workspace = std::env::current_dir()?;
7564    let agents_path = workspace.join("AGENTS.md");
7565
7566    if agents_path.exists() {
7567        println!(
7568            "{} AGENTS.md already exists at {}",
7569            "!".truecolor(sky_r, sky_g, sky_b),
7570            agents_path.display()
7571        );
7572        return Ok(());
7573    }
7574
7575    match create_default_agents_md(&workspace) {
7576        Ok(path) => {
7577            println!(
7578                "{} Created {}",
7579                "✓".truecolor(aqua_r, aqua_g, aqua_b),
7580                path.display()
7581            );
7582            println!();
7583            println!("Edit this file to customize how the AI agent works with your project.");
7584            println!("The instructions will be loaded automatically when you run codewhale.");
7585        }
7586        Err(e) => {
7587            println!(
7588                "{} Failed to create AGENTS.md: {}",
7589                "✗".truecolor(red_r, red_g, red_b),
7590                e
7591            );
7592        }
7593    }
7594
7595    Ok(())
7596}
7597
7598fn resolve_workspace(cli: &Cli) -> PathBuf {
7599    cli.workspace
7600        .clone()
7601        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
7602}
7603
7604fn load_config_from_cli(cli: &Cli) -> Result<Config> {
7605    load_config_from_cli_with_effective_profile(cli).map(|(config, _)| config)
7606}
7607
7608/// Doctor is a structural report unless the user explicitly asks it to probe
7609/// a provider endpoint. Keep credential-bearing environment values out of the
7610/// regular diagnostic configuration so an unrelated renderer or error path
7611/// cannot disclose them.
7612fn load_doctor_config_from_cli(cli: &Cli, args: &DoctorArgs) -> Result<Config> {
7613    if args.probe_api || args.probe_local {
7614        return load_config_from_cli(cli);
7615    }
7616    load_structural_config_from_cli(cli)
7617}
7618
7619fn load_structural_config_from_cli(cli: &Cli) -> Result<Config> {
7620    let profile = effective_config_profile(cli);
7621    let mut config = Config::load_structural(cli.config.clone(), profile.as_deref())?;
7622    if let Ok(settings) = crate::settings::Settings::load_read_only() {
7623        apply_saved_reasoning_preference(&mut config, &settings);
7624    }
7625    cli.feature_toggles.apply(&mut config)?;
7626    Ok(config)
7627}
7628
7629fn effective_config_profile(cli: &Cli) -> Option<String> {
7630    cli.profile
7631        .clone()
7632        .or_else(|| std::env::var("CODEWHALE_PROFILE").ok())
7633        .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok())
7634}
7635
7636fn load_config_from_cli_with_effective_profile(cli: &Cli) -> Result<(Config, Option<String>)> {
7637    let profile = effective_config_profile(cli);
7638    let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
7639    // Config loading is shared by diagnostics and mutating runtimes. Read the
7640    // saved preference without migrating or creating state here; interactive
7641    // startup performs any permitted migration later through `Settings::load`.
7642    if let Ok(settings) = crate::settings::Settings::load_read_only() {
7643        apply_saved_reasoning_preference(&mut config, &settings);
7644    }
7645    cli.feature_toggles.apply(&mut config)?;
7646    Ok((config, profile))
7647}
7648
7649/// Apply the same reasoning-preference precedence as interactive `App`
7650/// construction to non-TUI runtimes.
7651///
7652/// `/model` and the config editor persist this preference in `settings.toml`.
7653/// Exec, review, workflow, ACP, and runtime-thread launches all begin with a
7654/// `Config`, so copying the saved value here keeps those entry points from
7655/// silently falling back to a route classifier or an older config.toml value.
7656fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::settings::Settings) {
7657    let Some(reasoning_effort) = settings.reasoning_effort.as_ref() else {
7658        return;
7659    };
7660    config.reasoning_effort = Some(reasoning_effort.clone());
7661    config.reasoning_effort_inferred_from_legacy_alias = false;
7662}
7663
7664fn read_api_key_from_stdin() -> Result<String> {
7665    let mut stdin = io::stdin();
7666    if stdin.is_terminal() {
7667        bail!("No API key provided. Pass --api-key or pipe one via stdin.");
7668    }
7669    let mut buffer = String::new();
7670    stdin.read_to_string(&mut buffer)?;
7671    let api_key = buffer.trim().to_string();
7672    if api_key.is_empty() {
7673        bail!("No API key provided via stdin.");
7674    }
7675    Ok(api_key)
7676}
7677
7678fn run_login(api_key: Option<String>) -> Result<()> {
7679    let api_key = match api_key {
7680        Some(key) => key,
7681        None => read_api_key_from_stdin()?,
7682    };
7683    let saved = config::save_api_key(&api_key)?;
7684    println!("Saved API key to {}", saved.describe());
7685    Ok(())
7686}
7687
7688fn run_logout() -> Result<()> {
7689    config::clear_api_key()?;
7690    println!("Cleared saved API key.");
7691    Ok(())
7692}
7693
7694async fn run_xai_device_auth(config_path: Option<&Path>) -> Result<()> {
7695    let pending = xai_oauth::device_code_login().await?;
7696    let activation = xai_oauth::activate_device_login(pending, config_path, None)?;
7697    println!(
7698        "xAI OAuth is ready; activated {} via {}",
7699        codewhale_config::quote_os_path(&activation.auth_path),
7700        codewhale_config::quote_os_path(&activation.config_path)
7701    );
7702    Ok(())
7703}
7704
7705fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
7706    if last {
7707        return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
7708            anyhow!(
7709                "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume <SESSION_ID>` to resume one explicitly.",
7710                workspace.display()
7711            )
7712        });
7713    }
7714    if let Some(id) = session_id {
7715        return Ok(id);
7716    }
7717    pick_session_id()
7718}
7719
7720fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
7721    let manager = SessionManager::default_location()?;
7722    Ok(manager
7723        .get_latest_session_for_workspace(workspace)?
7724        .map(|session| session.id))
7725}
7726
7727fn fork_session(
7728    config: &Config,
7729    session_id: Option<String>,
7730    last: bool,
7731    workspace: &Path,
7732) -> Result<String> {
7733    let manager = SessionManager::default_location()?;
7734    let saved = if last {
7735        let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
7736            bail!(
7737                "No saved sessions found for workspace {}.",
7738                workspace.display()
7739            );
7740        };
7741        manager.load_session(&meta.id)?
7742    } else {
7743        let id = resolve_session_id(session_id, false, workspace)?;
7744        manager.load_session_by_prefix(&id)?
7745    };
7746    let saved_provider_identity = saved
7747        .metadata
7748        .model_provider_id
7749        .as_deref()
7750        .filter(|identity| !identity.trim().is_empty())
7751        .unwrap_or(&saved.metadata.model_provider);
7752    let provider_identity = config
7753        .resolve_persisted_provider_identity(
7754            Some(&saved.metadata.model_provider),
7755            saved.metadata.model_provider_id.as_deref(),
7756        )
7757        .map_err(anyhow::Error::msg)
7758        .with_context(|| {
7759            format!(
7760                "saved session provider '{}' is unavailable; fork will not fall back",
7761                saved_provider_identity
7762            )
7763        })?;
7764
7765    let system_prompt = saved
7766        .system_prompt
7767        .as_ref()
7768        .map(|text| SystemPrompt::Text(text.clone()));
7769    let mut forked = create_saved_session(
7770        &saved.messages,
7771        &saved.metadata.model,
7772        &saved.metadata.workspace,
7773        saved.metadata.total_tokens,
7774        system_prompt.as_ref(),
7775    );
7776    forked.metadata.set_model_provider_route(
7777        provider_identity.provider.as_str(),
7778        provider_identity.persisted_id(),
7779    );
7780    forked.metadata.copy_cost_from(&saved.metadata);
7781    forked.metadata.mark_forked_from(&saved.metadata);
7782    manager.save_session(&forked)?;
7783
7784    let source_title = saved.metadata.title.trim();
7785    let source_label = if source_title.is_empty() {
7786        "session".to_string()
7787    } else {
7788        format!("\"{source_title}\"")
7789    };
7790    println!(
7791        "Forked {source_label} ({source_id}) → new session {new_id}",
7792        source_id = truncate_id(&saved.metadata.id),
7793        new_id = truncate_id(&forked.metadata.id),
7794    );
7795
7796    Ok(forked.metadata.id)
7797}
7798
7799fn pick_session_id() -> Result<String> {
7800    let manager = SessionManager::default_location()?;
7801    let sessions = manager.list_sessions()?;
7802    if sessions.is_empty() {
7803        bail!("No saved sessions found.");
7804    }
7805
7806    println!("Select a session to resume:");
7807    for (idx, session) in sessions.iter().enumerate() {
7808        println!("  {:>2}. {} ({})", idx + 1, session.title, session.id);
7809    }
7810    print!("Enter a number (or press Enter to cancel): ");
7811    io::stdout().flush()?;
7812
7813    let mut input = String::new();
7814    io::stdin().read_line(&mut input)?;
7815    let input = input.trim();
7816    if input.is_empty() {
7817        bail!("No session selected.");
7818    }
7819    let idx: usize = input
7820        .parse()
7821        .map_err(|_| anyhow::anyhow!("Invalid input"))?;
7822    let session = sessions
7823        .get(idx.saturating_sub(1))
7824        .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
7825    Ok(session.id.clone())
7826}
7827
7828async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
7829    use crate::client::DeepSeekClient;
7830
7831    let diff = collect_diff(&args)?;
7832    if diff.trim().is_empty() {
7833        bail!("No diff to review.");
7834    }
7835    validate_review_receipt_args(&args)?;
7836    if args.check_receipt {
7837        return run_review_receipt_check(&diff, &args);
7838    }
7839
7840    let model = resolve_review_model(config, args.model.as_deref());
7841    let route = resolve_cli_exec_route(config, &model, &diff, args.model.is_none()).await?;
7842    let execution_config = config_for_cli_route(config, &route);
7843    let route_provider = execution_config.provider_identity_for(route.provider);
7844    let model = route.model.clone();
7845    let user_prompt =
7846        format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
7847    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
7848        cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
7849    });
7850
7851    let system = SystemPrompt::Text(
7852        "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
7853Provide findings ordered by severity with file references, then open questions, then a brief summary."
7854            .to_string(),
7855    );
7856    let client = DeepSeekClient::new(&execution_config)?;
7857    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
7858    let request = MessageRequest {
7859        model: model.clone(),
7860        messages: vec![Message {
7861            role: "user".to_string(),
7862            content: vec![ContentBlock::Text {
7863                text: user_prompt,
7864                cache_control: None,
7865            }],
7866        }],
7867        max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
7868            request_route.provider,
7869            &request_route.model,
7870            None,
7871        ),
7872        system: Some(system),
7873        tools: None,
7874        tool_choice: None,
7875        metadata: None,
7876        thinking: None,
7877        reasoning_effort,
7878        stream: Some(false),
7879        temperature: None,
7880        top_p: None,
7881    };
7882
7883    let response = client.create_message(request).await?;
7884    let review_stop_reason = response.stop_reason.clone();
7885    let review_incomplete = crate::models::is_incomplete_stop_reason(review_stop_reason.as_deref());
7886    let mut output = String::new();
7887    for block in response.content {
7888        if let ContentBlock::Text { text, .. } = block {
7889            output.push_str(&text);
7890        }
7891    }
7892    // A truncated review must not become a receipt or a success. The partial
7893    // text is still printed for diagnostics below.
7894    let receipt = if args.write_receipt && !review_incomplete {
7895        let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
7896        let receipt = crate::tools::review::build_review_receipt(
7897            review_target_label(&args),
7898            &diff,
7899            &route_provider,
7900            &model,
7901            &parsed_output,
7902            &output,
7903            Vec::new(),
7904        );
7905        let path =
7906            crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref())?;
7907        Some((path, receipt))
7908    } else {
7909        None
7910    };
7911    let review_error = review_incomplete.then(|| {
7912        format!(
7913            "Model response incomplete: provider stop reason `{}`; the partial review was not accepted.",
7914            crate::models::stop_reason_detail(review_stop_reason.as_deref())
7915        )
7916    });
7917    if args.json {
7918        println!(
7919            "{}",
7920            serde_json::to_string_pretty(&serde_json::json!({
7921                "mode": "review",
7922                "provider": route_provider,
7923                "model": model,
7924                "success": !review_incomplete,
7925                "content": output,
7926                "stop_reason": review_stop_reason,
7927                "error": review_error,
7928                "receipt_path": receipt
7929                    .as_ref()
7930                    .map(|(path, _)| path.display().to_string()),
7931                "receipt": receipt.as_ref().map(|(_, receipt)| receipt),
7932            }))?
7933        );
7934        if let Some(error) = review_error {
7935            anyhow::bail!(error);
7936        }
7937    } else {
7938        println!("{output}");
7939        if let Some((path, _)) = receipt {
7940            eprintln!("Review receipt written: {}", path.display());
7941        }
7942        if let Some(error) = review_error {
7943            anyhow::bail!(error);
7944        }
7945    }
7946    Ok(())
7947}
7948
7949fn resolve_review_model(config: &Config, explicit_model: Option<&str>) -> String {
7950    explicit_model
7951        .map(str::trim)
7952        .filter(|model| !model.is_empty())
7953        .map(str::to_string)
7954        .unwrap_or_else(|| config.default_model())
7955}
7956
7957fn validate_review_receipt_args(args: &ReviewArgs) -> Result<()> {
7958    if args.receipt_path.is_some() && !args.write_receipt && !args.check_receipt {
7959        bail!("--receipt-path requires --write-receipt or --check-receipt");
7960    }
7961    if args.write_receipt && args.check_receipt {
7962        bail!("--write-receipt and --check-receipt are mutually exclusive");
7963    }
7964    Ok(())
7965}
7966
7967fn run_review_receipt_check(diff: &str, args: &ReviewArgs) -> Result<()> {
7968    let (path, receipt) = if let Some(path) = args.receipt_path.as_ref() {
7969        (
7970            path.clone(),
7971            crate::tools::review::read_review_receipt(path)
7972                .with_context(|| format!("failed to read review receipt {}", path.display()))?,
7973        )
7974    } else {
7975        crate::tools::review::latest_review_receipt_for_diff(diff)?.ok_or_else(|| {
7976            anyhow!(
7977                "No review receipt found for the current diff. Run `codewhale review --write-receipt` first, or pass --receipt-path."
7978            )
7979        })?
7980    };
7981    let validation =
7982        crate::tools::review::validate_review_receipt_for_diff(diff, &receipt, Some(path.clone()));
7983
7984    if args.json {
7985        println!(
7986            "{}",
7987            serde_json::to_string_pretty(&serde_json::json!({
7988                "mode": "review_receipt_check",
7989                "success": validation.passed,
7990                "validation": review_receipt_validation_public_json(&validation),
7991            }))?
7992        );
7993    } else if validation.passed {
7994        println!("Review receipt valid: {}", path.display());
7995    }
7996
7997    if !validation.passed {
7998        bail!("Review receipt check failed: {}", validation.reason);
7999    }
8000    Ok(())
8001}
8002
8003fn review_receipt_validation_public_json(
8004    validation: &crate::tools::review::ReviewReceiptValidation,
8005) -> serde_json::Value {
8006    let unresolved_risk = validation.unresolved_risk.as_ref();
8007    serde_json::json!({
8008        "passed": validation.passed,
8009        "status": review_receipt_validation_status(validation),
8010        "diff_fingerprint": validation.diff_fingerprint.as_str(),
8011        "receipt_fingerprint": validation.receipt_fingerprint.as_deref(),
8012        "unresolved": unresolved_risk.is_some_and(|risk| risk.unresolved),
8013        "risk_level": unresolved_risk.map(|risk| risk.level.as_str()),
8014    })
8015}
8016
8017fn review_receipt_validation_status(
8018    validation: &crate::tools::review::ReviewReceiptValidation,
8019) -> &'static str {
8020    if validation.passed {
8021        "valid"
8022    } else if validation
8023        .receipt_fingerprint
8024        .as_deref()
8025        .is_some_and(|fingerprint| fingerprint != validation.diff_fingerprint.as_str())
8026    {
8027        "diff_mismatch"
8028    } else if validation
8029        .unresolved_risk
8030        .as_ref()
8031        .is_some_and(|risk| risk.unresolved)
8032    {
8033        "unresolved_risk"
8034    } else if validation
8035        .reason
8036        .starts_with("unsupported review receipt schema version")
8037    {
8038        "unsupported_schema"
8039    } else if validation.reason.starts_with("review receipt check ") {
8040        "check_failed"
8041    } else {
8042        "invalid"
8043    }
8044}
8045
8046/// `codewhale pr <N>` (#451) — fetch a GitHub PR via `gh`, format
8047/// title + body + diff as the composer's first message, and launch
8048/// the interactive TUI. Falls back gracefully if `gh` is missing.
8049async fn run_pr(
8050    cli: &Cli,
8051    config: &Config,
8052    number: u32,
8053    repo: Option<&str>,
8054    checkout: bool,
8055    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
8056    plugin_registry: Arc<crate::plugins::PluginRegistry>,
8057) -> Result<()> {
8058    if !is_command_available("gh") {
8059        bail!(
8060            "`gh` CLI not found on PATH. Install GitHub CLI \
8061             (https://cli.github.com) and authenticate (`gh auth login`) \
8062             so `codewhale pr <N>` can fetch PR metadata and the diff."
8063        );
8064    }
8065
8066    let view = run_gh_pr_view(number, repo)?;
8067    let diff = run_gh_pr_diff(number, repo)?;
8068
8069    if checkout {
8070        match run_gh_pr_checkout(number, repo) {
8071            Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
8072            Err(err) => eprintln!(
8073                "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
8074            ),
8075        }
8076    }
8077
8078    let prompt = format_pr_prompt(number, &view, &diff);
8079    let resume_session_id = if cli.continue_session {
8080        let workspace = resolve_workspace(cli);
8081        latest_session_id_for_workspace(&workspace).ok().flatten()
8082    } else {
8083        cli.resume.clone()
8084    };
8085    run_interactive(
8086        cli,
8087        config,
8088        resume_session_id,
8089        Some(tui::InitialInput::Prefill(prompt)),
8090        pending_telemetry_notice,
8091        plugin_registry,
8092    )
8093    .await
8094}
8095
8096/// Return true if `name` resolves to an executable on the current `PATH`.
8097///
8098/// Walks `$PATH` directly instead of probing with `--version`. The
8099/// previous implementation invoked `Command::new(name).arg("--version")`,
8100/// which fails on the Ubuntu CI runner because `/bin/sh` is `dash` —
8101/// `dash --version` exits with status 2 ("invalid option") even though
8102/// `sh` is plainly on PATH. macOS happens to ship bash as `sh`, which
8103/// does honor `--version`, so the bug was invisible locally and only
8104/// surfaced in CI logs.
8105///
8106/// Windows: also checks the `.exe` extension when `name` doesn't have
8107/// one, matching the platform's PATHEXT lookup behavior for the common
8108/// case.
8109fn is_command_available(name: &str) -> bool {
8110    let Some(path) = std::env::var_os("PATH") else {
8111        return false;
8112    };
8113    for dir in std::env::split_paths(&path) {
8114        let candidate = dir.join(name);
8115        if candidate.is_file() {
8116            return true;
8117        }
8118        #[cfg(windows)]
8119        {
8120            // PATHEXT gives `.exe`/`.cmd`/`.bat` etc. priority — we only
8121            // probe `.exe` because that's the case that actually trips
8122            // up the negative case (`gh` resolves as `gh.exe`).
8123            if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
8124                return true;
8125            }
8126        }
8127    }
8128    false
8129}
8130
8131#[derive(Debug, Clone, Default)]
8132struct GhPullRequest {
8133    title: String,
8134    body: String,
8135    base: String,
8136    head: String,
8137    url: String,
8138}
8139
8140fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
8141    let mut cmd = crate::dependencies::Gh::command()
8142        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8143    cmd.arg("pr").arg("view").arg(number.to_string());
8144    if let Some(r) = repo {
8145        cmd.arg("--repo").arg(r);
8146    }
8147    cmd.arg("--json")
8148        .arg("title,body,baseRefName,headRefName,url");
8149    let output = cmd
8150        .output()
8151        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
8152    if !output.status.success() {
8153        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8154        bail!("gh pr view #{number} failed: {stderr}");
8155    }
8156    let raw = String::from_utf8_lossy(&output.stdout).to_string();
8157    let value: serde_json::Value = serde_json::from_str(&raw)
8158        .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
8159    let pick = |key: &str| {
8160        value
8161            .get(key)
8162            .and_then(serde_json::Value::as_str)
8163            .unwrap_or_default()
8164            .to_string()
8165    };
8166    Ok(GhPullRequest {
8167        title: pick("title"),
8168        body: pick("body"),
8169        base: pick("baseRefName"),
8170        head: pick("headRefName"),
8171        url: pick("url"),
8172    })
8173}
8174
8175fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
8176    let mut cmd = crate::dependencies::Gh::command()
8177        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8178    cmd.arg("pr").arg("diff").arg(number.to_string());
8179    if let Some(r) = repo {
8180        cmd.arg("--repo").arg(r);
8181    }
8182    let output = cmd
8183        .output()
8184        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
8185    if !output.status.success() {
8186        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8187        bail!("gh pr diff #{number} failed: {stderr}");
8188    }
8189    Ok(String::from_utf8_lossy(&output.stdout).to_string())
8190}
8191
8192fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
8193    let mut cmd = crate::dependencies::Gh::command()
8194        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8195    cmd.arg("pr").arg("checkout").arg(number.to_string());
8196    if let Some(r) = repo {
8197        cmd.arg("--repo").arg(r);
8198    }
8199    let output = cmd
8200        .output()
8201        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
8202    if !output.status.success() {
8203        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8204        bail!("gh pr checkout #{number} failed: {stderr}");
8205    }
8206    Ok(())
8207}
8208
8209/// Format the PR review prompt that lands in the composer. Caps the
8210/// diff at 200 KiB so a massive PR doesn't blow the model's context
8211/// window before the user even hits Enter — they can always ask the
8212/// model to fetch more via `gh pr diff #N` from inside the session.
8213fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
8214    const MAX_DIFF_BYTES: usize = 200 * 1024;
8215    let diff_section = if diff.len() > MAX_DIFF_BYTES {
8216        let cut = (0..=MAX_DIFF_BYTES)
8217            .rev()
8218            .find(|&i| diff.is_char_boundary(i))
8219            .unwrap_or(0);
8220        format!(
8221            "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
8222            &diff[..cut],
8223            MAX_DIFF_BYTES / 1024
8224        )
8225    } else {
8226        diff.to_string()
8227    };
8228    let body = if view.body.trim().is_empty() {
8229        "(no description)".to_string()
8230    } else {
8231        view.body.trim().to_string()
8232    };
8233    let title = if view.title.trim().is_empty() {
8234        format!("(PR #{number})")
8235    } else {
8236        view.title.trim().to_string()
8237    };
8238    let branches = match (view.base.is_empty(), view.head.is_empty()) {
8239        (false, false) => format!("{} ← {}", view.base, view.head),
8240        (false, true) => view.base.clone(),
8241        (true, false) => view.head.clone(),
8242        _ => "(unknown)".to_string(),
8243    };
8244    format!(
8245        "Review PR #{number} — {title}\n\
8246         \n\
8247         URL: {url}\n\
8248         Branches: {branches}\n\
8249         \n\
8250         ## Description\n\
8251         \n\
8252         {body}\n\
8253         \n\
8254         ## Diff\n\
8255         \n\
8256         ```diff\n\
8257         {diff_section}\n\
8258         ```\n",
8259        url = if view.url.is_empty() {
8260            "(unavailable)"
8261        } else {
8262            view.url.as_str()
8263        },
8264    )
8265}
8266
8267fn collect_diff(args: &ReviewArgs) -> Result<String> {
8268    let mut cmd = crate::dependencies::Git::command()
8269        .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
8270    cmd.arg("diff");
8271    if args.staged {
8272        cmd.arg("--cached");
8273    }
8274    if let Some(base) = &args.base {
8275        cmd.arg(format!("{base}...HEAD"));
8276    }
8277    if let Some(path) = &args.path {
8278        cmd.arg("--").arg(path);
8279    }
8280
8281    let output = cmd
8282        .output()
8283        .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
8284    if !output.status.success() {
8285        let stderr = String::from_utf8_lossy(&output.stderr);
8286        bail!("git diff failed: {}", stderr.trim());
8287    }
8288    let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
8289    if diff.len() > args.max_chars {
8290        diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
8291    }
8292    Ok(diff)
8293}
8294
8295fn review_target_label(args: &ReviewArgs) -> String {
8296    let mut label = if args.staged {
8297        "staged".to_string()
8298    } else if let Some(base) = args
8299        .base
8300        .as_deref()
8301        .map(str::trim)
8302        .filter(|base| !base.is_empty())
8303    {
8304        format!("base:{base}")
8305    } else {
8306        "working-tree".to_string()
8307    };
8308    if let Some(path) = &args.path {
8309        label.push(' ');
8310        label.push_str(path.to_string_lossy().as_ref());
8311    }
8312    label
8313}
8314
8315fn run_apply(args: ApplyArgs) -> Result<()> {
8316    let patch = if let Some(path) = args.patch_file {
8317        std::fs::read_to_string(&path)
8318            .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
8319    } else {
8320        read_patch_from_stdin()?
8321    };
8322    if patch.trim().is_empty() {
8323        bail!("Patch is empty.");
8324    }
8325
8326    let mut tmp = NamedTempFile::new()?;
8327    tmp.write_all(patch.as_bytes())?;
8328    let tmp_path = tmp.path().to_path_buf();
8329
8330    let output = crate::dependencies::Git::command()
8331        .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?
8332        .arg("apply")
8333        .arg("--whitespace=nowarn")
8334        .arg(&tmp_path)
8335        .output()
8336        .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?;
8337
8338    if !output.status.success() {
8339        let stderr = String::from_utf8_lossy(&output.stderr);
8340        bail!("git apply failed: {}", stderr.trim());
8341    }
8342    println!("Applied patch successfully.");
8343    Ok(())
8344}
8345
8346fn read_patch_from_stdin() -> Result<String> {
8347    let mut stdin = io::stdin();
8348    if stdin.is_terminal() {
8349        bail!("No patch file provided and stdin is empty.");
8350    }
8351    let mut buffer = String::new();
8352    stdin.read_to_string(&mut buffer)?;
8353    Ok(buffer)
8354}
8355
8356async fn run_mcp_command(
8357    config: &Config,
8358    workspace: &Path,
8359    command: McpCommand,
8360    plugins: &crate::plugins::PluginRegistry,
8361) -> Result<()> {
8362    let config_path = config.mcp_config_path();
8363    match command {
8364        McpCommand::Init { force } => {
8365            let status = init_mcp_config(&config_path, force)?;
8366            match status {
8367                WriteStatus::Created => {
8368                    println!("Created MCP config at {}", config_path.display());
8369                }
8370                WriteStatus::Overwritten => {
8371                    println!("Overwrote MCP config at {}", config_path.display());
8372                }
8373                WriteStatus::SkippedExists => {
8374                    println!(
8375                        "MCP config already exists at {} (use --force to overwrite)",
8376                        config_path.display()
8377                    );
8378                }
8379            }
8380            println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`.");
8381            Ok(())
8382        }
8383        McpCommand::List => {
8384            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8385                &config_path,
8386                workspace,
8387                plugins,
8388            )?;
8389            if cfg.servers.is_empty() {
8390                println!(
8391                    "No MCP servers configured in {} or {}",
8392                    config_path.display(),
8393                    crate::mcp::workspace_mcp_config_path(workspace).display()
8394                );
8395                return Ok(());
8396            }
8397            println!("MCP servers ({}):", cfg.servers.len());
8398            for (name, server) in cfg.servers {
8399                let status = if server.enabled && !server.disabled {
8400                    "enabled"
8401                } else {
8402                    "disabled"
8403                };
8404                let auth_status = crate::mcp::oauth::auth_status_for_server(&name, &server).await;
8405                let auth = if auth_status == crate::mcp::oauth::McpAuthStatus::Unsupported {
8406                    String::new()
8407                } else {
8408                    format!(
8409                        " auth={}",
8410                        auth_status
8411                            .to_string()
8412                            .to_ascii_lowercase()
8413                            .replace(' ', "-")
8414                    )
8415                };
8416                let args = if server.args.is_empty() {
8417                    "".to_string()
8418                } else {
8419                    format!(" {}", server.args.join(" "))
8420                };
8421                let cmd_str = if let Some(cmd) = server.command {
8422                    format!("{cmd}{args}")
8423                } else if let Some(url) = server.url {
8424                    url
8425                } else {
8426                    "unknown".to_string()
8427                };
8428                let required = if server.required { " required" } else { "" };
8429                println!("  - {name} [{status}{required}{auth}] {cmd_str}");
8430            }
8431            Ok(())
8432        }
8433        McpCommand::Connect { server } => {
8434            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8435                &config_path,
8436                workspace,
8437                std::sync::Arc::new(plugins.clone()),
8438            )?;
8439            if let Some(name) = server {
8440                if let Err(err) = pool.get_or_connect(&name).await {
8441                    if crate::mcp::oauth::error_looks_auth_required(&err) {
8442                        let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8443                        return Err(err).context(hint);
8444                    }
8445                    return Err(err);
8446                }
8447                println!("Connected to MCP server: {name}");
8448            } else {
8449                let errors = pool.connect_all().await;
8450                if errors.is_empty() {
8451                    println!("Connected to all configured MCP servers.");
8452                } else {
8453                    for (name, err) in errors {
8454                        eprintln!("Failed to connect {name}: {err:#}");
8455                        if crate::mcp::oauth::error_looks_auth_required(&err) {
8456                            eprintln!("  {}", crate::mcp::oauth::auth_required_login_hint(&name));
8457                        }
8458                    }
8459                }
8460            }
8461            Ok(())
8462        }
8463        McpCommand::Tools { server } => {
8464            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8465                &config_path,
8466                workspace,
8467                std::sync::Arc::new(plugins.clone()),
8468            )?;
8469            if let Some(name) = server {
8470                let conn = match pool.get_or_connect(&name).await {
8471                    Ok(conn) => conn,
8472                    Err(err) => {
8473                        if crate::mcp::oauth::error_looks_auth_required(&err) {
8474                            let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8475                            return Err(err).context(hint);
8476                        }
8477                        return Err(err);
8478                    }
8479                };
8480                if conn.tools().is_empty() {
8481                    println!("No tools found for MCP server: {name}");
8482                } else {
8483                    println!("Tools for {name}:");
8484                    for tool in conn.tools() {
8485                        println!(
8486                            "  - {}{}",
8487                            tool.name,
8488                            crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8489                        );
8490                    }
8491                }
8492            } else {
8493                let errors = pool.connect_all().await;
8494                for (name, err) in errors {
8495                    eprintln!("Failed to connect {name}: {err:#}");
8496                    if crate::mcp::oauth::error_looks_auth_required(&err) {
8497                        eprintln!("  {}", crate::mcp::oauth::auth_required_login_hint(&name));
8498                    }
8499                }
8500                let tools = pool.all_tools();
8501                if tools.is_empty() {
8502                    println!("No MCP tools discovered.");
8503                } else {
8504                    println!("MCP tools:");
8505                    for (name, tool) in tools {
8506                        println!(
8507                            "  - {}{}",
8508                            name,
8509                            crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8510                        );
8511                    }
8512                }
8513            }
8514            Ok(())
8515        }
8516        McpCommand::Add {
8517            name,
8518            command,
8519            url,
8520            transport,
8521            bearer_token_env_var,
8522            oauth_client_id,
8523            oauth_resource,
8524            scopes,
8525            args,
8526        } => {
8527            if command.is_none() && url.is_none() {
8528                bail!("Provide either --command or --url for `mcp add`.");
8529            }
8530            if let Some(transport) = transport.as_deref()
8531                && !transport.trim().eq_ignore_ascii_case("sse")
8532            {
8533                bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
8534            }
8535            let added_server = McpServerConfig {
8536                command,
8537                args,
8538                env: std::collections::HashMap::new(),
8539                cwd: None,
8540                url,
8541                transport,
8542                connect_timeout: None,
8543                execute_timeout: None,
8544                read_timeout: None,
8545                disabled: false,
8546                enabled: true,
8547                required: false,
8548                enabled_tools: Vec::new(),
8549                disabled_tools: Vec::new(),
8550                headers: std::collections::HashMap::new(),
8551                env_headers: std::collections::HashMap::new(),
8552                bearer_token_env_var,
8553                scopes,
8554                oauth: oauth_client_id.map(|client_id| McpServerOAuthConfig {
8555                    client_id: Some(client_id),
8556                }),
8557                oauth_resource,
8558                reviewed_plugin: None,
8559            };
8560            let can_suggest_oauth = added_server.url.is_some()
8561                && added_server.bearer_token_env_var.is_none()
8562                && added_server
8563                    .headers
8564                    .keys()
8565                    .all(|key| !key.trim().eq_ignore_ascii_case("authorization"))
8566                && added_server
8567                    .env_headers
8568                    .keys()
8569                    .all(|key| !key.trim().eq_ignore_ascii_case("authorization"));
8570            let mut cfg = load_mcp_config(&config_path)?;
8571            cfg.servers.insert(name.clone(), added_server.clone());
8572            save_mcp_config(&config_path, &cfg)?;
8573            println!("Added MCP server '{name}' in {}", config_path.display());
8574            if can_suggest_oauth
8575                && crate::mcp::oauth::oauth_login_support(&added_server)
8576                    .await
8577                    .is_ok_and(|support| support.is_some())
8578            {
8579                println!(
8580                    "OAuth is available for '{name}'. Run `codewhale mcp login {name}` to authenticate."
8581                );
8582            }
8583            Ok(())
8584        }
8585        McpCommand::Login { name, scopes } => {
8586            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8587                &config_path,
8588                workspace,
8589                plugins,
8590            )?;
8591            let server = cfg
8592                .servers
8593                .get(&name)
8594                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8595            let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
8596            crate::mcp::oauth::perform_oauth_login_for_server(
8597                &name,
8598                server,
8599                explicit_scopes,
8600                config.mcp_oauth_callback_port,
8601                config.mcp_oauth_callback_url.as_deref(),
8602            )
8603            .await?;
8604            println!("Stored OAuth credentials for MCP server '{name}'.");
8605            Ok(())
8606        }
8607        McpCommand::Logout { name } => {
8608            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8609                &config_path,
8610                workspace,
8611                plugins,
8612            )?;
8613            let server = cfg
8614                .servers
8615                .get(&name)
8616                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8617            if crate::mcp::oauth::delete_oauth_tokens_for_server(&name, server)? {
8618                println!("Deleted stored OAuth credentials for MCP server '{name}'.");
8619            } else {
8620                println!("No stored OAuth credentials found for MCP server '{name}'.");
8621            }
8622            Ok(())
8623        }
8624        McpCommand::Remove { name } => {
8625            let mut cfg = load_mcp_config(&config_path)?;
8626            if cfg.servers.remove(&name).is_none() {
8627                bail!("MCP server '{name}' not found");
8628            }
8629            save_mcp_config(&config_path, &cfg)?;
8630            println!("Removed MCP server '{name}'");
8631            Ok(())
8632        }
8633        McpCommand::Enable { name } => {
8634            let mut cfg = load_mcp_config(&config_path)?;
8635            let server = cfg
8636                .servers
8637                .get_mut(&name)
8638                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8639            server.enabled = true;
8640            server.disabled = false;
8641            save_mcp_config(&config_path, &cfg)?;
8642            println!("Enabled MCP server '{name}'");
8643            Ok(())
8644        }
8645        McpCommand::Disable { name } => {
8646            let mut cfg = load_mcp_config(&config_path)?;
8647            let server = cfg
8648                .servers
8649                .get_mut(&name)
8650                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8651            server.enabled = false;
8652            server.disabled = true;
8653            save_mcp_config(&config_path, &cfg)?;
8654            println!("Disabled MCP server '{name}'");
8655            Ok(())
8656        }
8657        McpCommand::Validate => {
8658            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8659                &config_path,
8660                workspace,
8661                std::sync::Arc::new(plugins.clone()),
8662            )?;
8663            let errors = pool.connect_all().await;
8664            if errors.is_empty() {
8665                println!("MCP config is valid. All enabled servers connected.");
8666                return Ok(());
8667            }
8668            eprintln!("MCP validation failed:");
8669            for (name, err) in errors {
8670                eprintln!("  - {name}: {err:#}");
8671            }
8672            bail!("one or more MCP servers failed validation");
8673        }
8674        McpCommand::AddSelf { name, workspace } => {
8675            let exe_path = std::env::current_exe()
8676                .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
8677            let exe_str = exe_path.to_string_lossy().to_string();
8678
8679            let mut args = vec!["serve".to_string(), "--mcp".to_string()];
8680            if let Some(ref ws) = workspace {
8681                args.push("--workspace".to_string());
8682                args.push(ws.clone());
8683            }
8684
8685            let mut cfg = load_mcp_config(&config_path)?;
8686            if cfg.servers.contains_key(&name) {
8687                bail!(
8688                    "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.",
8689                    config_path.display()
8690                );
8691            }
8692            cfg.servers.insert(
8693                name.clone(),
8694                McpServerConfig {
8695                    command: Some(exe_str.clone()),
8696                    args,
8697                    env: std::collections::HashMap::new(),
8698                    cwd: None,
8699                    url: None,
8700                    transport: None,
8701                    connect_timeout: None,
8702                    execute_timeout: None,
8703                    read_timeout: None,
8704                    disabled: false,
8705                    enabled: true,
8706                    required: false,
8707                    enabled_tools: Vec::new(),
8708                    disabled_tools: Vec::new(),
8709                    headers: std::collections::HashMap::new(),
8710                    env_headers: std::collections::HashMap::new(),
8711                    bearer_token_env_var: None,
8712                    scopes: Vec::new(),
8713                    oauth: None,
8714                    oauth_resource: None,
8715                    reviewed_plugin: None,
8716                },
8717            );
8718            save_mcp_config(&config_path, &cfg)?;
8719            println!(
8720                "Registered Codewhale as MCP server '{name}' in {}",
8721                config_path.display()
8722            );
8723            println!("  command: {exe_str}");
8724            println!(
8725                "  args:    serve --mcp{}",
8726                workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
8727            );
8728            println!();
8729            println!("Tip: Use `codewhale mcp validate` to test the connection.");
8730            println!("     Use `codewhale serve --http` for the HTTP/SSE runtime API instead.");
8731            Ok(())
8732        }
8733    }
8734}
8735
8736fn load_mcp_config(path: &Path) -> Result<McpConfig> {
8737    if !path.exists() {
8738        return Ok(McpConfig::default());
8739    }
8740    let contents = std::fs::read_to_string(path)
8741        .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
8742    let cfg: McpConfig = serde_json::from_str(&contents).map_err(|_| {
8743        anyhow::anyhow!(
8744            "Failed to parse MCP config {}; file contents were omitted",
8745            codewhale_config::quote_os_path(path)
8746        )
8747    })?;
8748    Ok(cfg)
8749}
8750
8751/// Diagnostic status for an MCP server entry.
8752#[derive(Debug)]
8753enum McpServerDoctorStatus {
8754    Ok(String),
8755    Warning(String),
8756    Error(String),
8757}
8758
8759impl McpServerDoctorStatus {
8760    fn legacy_status(&self) -> &'static str {
8761        match self {
8762            Self::Ok(_) => "ok",
8763            Self::Warning(_) => "warning",
8764            Self::Error(_) => "error",
8765        }
8766    }
8767
8768    fn configuration_status(&self) -> &'static str {
8769        match self {
8770            Self::Ok(_) => "valid",
8771            Self::Warning(_) => "warning",
8772            Self::Error(_) => "invalid",
8773        }
8774    }
8775
8776    fn detail(&self) -> &str {
8777        match self {
8778            Self::Ok(detail) | Self::Warning(detail) | Self::Error(detail) => detail,
8779        }
8780    }
8781}
8782
8783/// Inspect command availability without starting the configured MCP server.
8784fn doctor_mcp_command_status(server: &McpServerConfig) -> McpCommandAvailability {
8785    if server.url.is_some() {
8786        return McpCommandAvailability::NotApplicable;
8787    }
8788    match server.command.as_deref() {
8789        Some("") => McpCommandAvailability::Missing,
8790        Some(_) | None => McpCommandAvailability::NotChecked,
8791    }
8792}
8793
8794fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::Value {
8795    use serde_json::json;
8796
8797    let status = doctor_check_mcp_server(server);
8798    json!({
8799        "name": name,
8800        "enabled": server.enabled && !server.disabled,
8801        // Compatibility field retained for existing doctor JSON consumers.
8802        // Its scope is now explicit in `checks.configuration` below.
8803        "status": status.legacy_status(),
8804        "detail": status.detail(),
8805        "transport": if server.url.is_some() { "http" } else { "stdio" },
8806        "endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
8807        "command_configured": server.command.is_some(),
8808        "args_count": server.args.len(),
8809        "env_count": server.env.len(),
8810        "headers_count": server.headers.len(),
8811        "env_headers_count": server.env_headers.len(),
8812        "check_scope": "configuration",
8813        "checks": {
8814            "configuration": {
8815                "status": status.configuration_status(),
8816                "detail": status.detail(),
8817            },
8818            "command": {
8819                "status": doctor_mcp_command_status(server).as_str(),
8820            },
8821            "process_reachable": {
8822                "status": "not_checked",
8823            },
8824            "protocol_initialized": {
8825                "status": "not_checked",
8826            },
8827            "backend_tool_health": {
8828                "status": "not_checked",
8829            },
8830        },
8831    })
8832}
8833
8834/// Check an MCP server config entry for common issues.
8835fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
8836    // No command or URL — incomplete entry.
8837    if server.command.is_none() && server.url.is_none() {
8838        return McpServerDoctorStatus::Error("no command or url configured".to_string());
8839    }
8840
8841    // URL-based server: omit userinfo, query, and fragment entirely.
8842    if let Some(ref url) = server.url {
8843        let authority = crate::doctor::structural_url_authority(url);
8844        return if authority.starts_with("unparseable") {
8845            McpServerDoctorStatus::Warning(
8846                "HTTP/SSE server URL is invalid; configured value omitted".to_string(),
8847            )
8848        } else {
8849            McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {authority}"))
8850        };
8851    }
8852
8853    // Command-based: validate command path exists.
8854    let cmd = server.command.as_deref().unwrap_or("");
8855    if cmd.is_empty() {
8856        return McpServerDoctorStatus::Error("empty command".to_string());
8857    }
8858
8859    if server.cwd.is_none() {
8860        if is_relative_stdio_path_arg(cmd) {
8861            return McpServerDoctorStatus::Warning(
8862                "stdio server uses a relative command without cwd; command value omitted"
8863                    .to_string(),
8864            );
8865        }
8866        if server
8867            .args
8868            .iter()
8869            .any(|arg| is_relative_stdio_path_arg(arg))
8870        {
8871            return McpServerDoctorStatus::Warning(
8872                "stdio server uses a relative path argument without cwd; argument values omitted"
8873                    .to_string(),
8874            );
8875        }
8876    }
8877
8878    McpServerDoctorStatus::Ok(format!(
8879        "stdio server configured (command omitted; {} argument(s), {} environment binding(s))",
8880        server.args.len(),
8881        server.env.len()
8882    ))
8883}
8884
8885fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
8886    if let Some(parent) = path.parent() {
8887        std::fs::create_dir_all(parent).with_context(|| {
8888            format!("Failed to create MCP config directory {}", parent.display())
8889        })?;
8890    }
8891    let rendered = serde_json::to_string_pretty(cfg)
8892        .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
8893    crate::utils::write_atomic(path, rendered.as_bytes())
8894        .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
8895    Ok(())
8896}
8897
8898fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
8899    use crate::sandbox::{CommandSpec, SandboxManager};
8900
8901    let SandboxCommand::Run {
8902        policy,
8903        network,
8904        writable_root,
8905        exclude_tmpdir,
8906        exclude_slash_tmp,
8907        cwd,
8908        timeout_ms,
8909        command,
8910    } = args.command;
8911
8912    let policy = parse_sandbox_policy(
8913        &policy,
8914        network,
8915        writable_root,
8916        exclude_tmpdir,
8917        exclude_slash_tmp,
8918    )?;
8919    let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
8920    let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
8921
8922    let (program, args) = command
8923        .split_first()
8924        .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
8925    let spec =
8926        CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
8927    let manager = SandboxManager::new();
8928    let exec_env = manager.prepare(&spec);
8929
8930    let mut cmd = Command::new(exec_env.program());
8931    cmd.args(exec_env.args())
8932        .current_dir(&exec_env.cwd)
8933        .stdout(Stdio::piped())
8934        .stderr(Stdio::piped());
8935    child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
8936
8937    let mut child = cmd
8938        .spawn()
8939        .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
8940    let stdout_handle = child
8941        .stdout
8942        .take()
8943        .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
8944    let stderr_handle = child
8945        .stderr
8946        .take()
8947        .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
8948
8949    let timeout = exec_env.timeout;
8950    let stdout_thread = std::thread::spawn(move || {
8951        let mut reader = stdout_handle;
8952        let mut buf = Vec::new();
8953        let _ = reader.read_to_end(&mut buf);
8954        buf
8955    });
8956    let stderr_thread = std::thread::spawn(move || {
8957        let mut reader = stderr_handle;
8958        let mut buf = Vec::new();
8959        let _ = reader.read_to_end(&mut buf);
8960        buf
8961    });
8962
8963    if let Some(status) = child.wait_timeout(timeout)? {
8964        let stdout = stdout_thread.join().unwrap_or_default();
8965        let stderr = stderr_thread.join().unwrap_or_default();
8966        let stderr_str = String::from_utf8_lossy(&stderr);
8967        let exit_code = status.code().unwrap_or(-1);
8968        let sandbox_type = exec_env.sandbox_type;
8969        let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
8970
8971        if !stdout.is_empty() {
8972            print!("{}", String::from_utf8_lossy(&stdout));
8973        }
8974        if !stderr.is_empty() {
8975            eprint!("{stderr_str}");
8976        }
8977        if sandbox_denied {
8978            eprintln!(
8979                "{}",
8980                SandboxManager::denial_message(sandbox_type, &stderr_str)
8981            );
8982        }
8983
8984        if !status.success() {
8985            bail!("Command failed with exit code {exit_code}");
8986        }
8987    } else {
8988        let _ = child.kill();
8989        let _ = child.wait();
8990        bail!("Command timed out after {}ms", timeout.as_millis());
8991    }
8992    Ok(())
8993}
8994
8995fn parse_sandbox_policy(
8996    policy: &str,
8997    network: bool,
8998    writable_root: Vec<PathBuf>,
8999    exclude_tmpdir: bool,
9000    exclude_slash_tmp: bool,
9001) -> Result<crate::sandbox::SandboxPolicy> {
9002    use crate::sandbox::SandboxPolicy;
9003
9004    match policy {
9005        "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
9006        "read-only" => Ok(SandboxPolicy::ReadOnly),
9007        "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
9008            network_access: network,
9009        }),
9010        "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
9011            writable_roots: writable_root,
9012            network_access: network,
9013            exclude_tmpdir,
9014            exclude_slash_tmp,
9015        }),
9016        other => bail!("Unknown sandbox policy: {other}"),
9017    }
9018}
9019
9020fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
9021    true
9022}
9023
9024fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
9025    let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
9026    let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
9027    let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
9028    should_use_mouse_capture_with(
9029        cli,
9030        config,
9031        use_alt_screen,
9032        terminal_emulator.as_deref(),
9033        wt_session.as_deref(),
9034        conemu_pid.as_deref(),
9035    )
9036}
9037
9038fn should_use_mouse_capture_with(
9039    cli: &Cli,
9040    config: &Config,
9041    use_alt_screen: bool,
9042    terminal_emulator: Option<&str>,
9043    wt_session: Option<&str>,
9044    conemu_pid: Option<&str>,
9045) -> bool {
9046    if !use_alt_screen || cli.no_mouse_capture {
9047        return false;
9048    }
9049    if cli.mouse_capture {
9050        return true;
9051    }
9052    config
9053        .tui
9054        .as_ref()
9055        .and_then(|tui| tui.mouse_capture)
9056        .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
9057}
9058
9059/// Whether to enable terminal mouse capture by default for this platform/host.
9060///
9061/// On Windows the default depends on the host: Windows Terminal (which sets
9062/// `WT_SESSION`) and ConEmu/Cmder (which set `ConEmuPID`) handle mouse-mode
9063/// reporting cleanly, so default-on there gives users in-app text selection
9064/// and keeps the application's selection clamped to the transcript area
9065/// (#1169). Legacy conhost (CMD without either env var) stays default-off
9066/// because its mouse-mode reporting can leak SGR escape sequences as raw
9067/// text into the composer (#878 / #898).
9068///
9069/// Off elsewhere only for JetBrains' JediTerm, which advertises mouse
9070/// support but forwards the same SGR escape sequences as raw input. The
9071/// user can still opt back in with `[tui] mouse_capture = true` in
9072/// `~/.codewhale/config.toml` or `--mouse-capture`.
9073fn default_mouse_capture_enabled(
9074    terminal_emulator: Option<&str>,
9075    wt_session: Option<&str>,
9076    conemu_pid: Option<&str>,
9077) -> bool {
9078    if cfg!(windows) {
9079        return wt_session.is_some() || conemu_pid.is_some();
9080    }
9081    if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
9082        return false;
9083    }
9084    true
9085}
9086
9087/// A loadable crash-recovery checkpoint candidate: session content, file
9088/// age, and which slot it came from (per-session file or the legacy single
9089/// slot).
9090struct RecentCheckpoint {
9091    session: session_manager::SavedSession,
9092    age: std::time::Duration,
9093    source: session_manager::CheckpointSource,
9094}
9095
9096const CHECKPOINT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
9097
9098/// Load all recent crash-recovery checkpoints, pruning stale ones first.
9099///
9100/// Candidates are the per-session checkpoint files plus the legacy
9101/// single-slot `checkpoints/latest.json` (compatibility read). Files older
9102/// than 24 hours are removed; unreadable files are skipped. The result is
9103/// sorted most recent first.
9104fn load_recent_checkpoints(manager: &session_manager::SessionManager) -> Vec<RecentCheckpoint> {
9105    let refs = manager.list_checkpoints().unwrap_or_default();
9106    let mut recent = Vec::new();
9107    for checkpoint_ref in refs {
9108        let Ok(age) = std::time::SystemTime::now().duration_since(checkpoint_ref.modified) else {
9109            continue;
9110        };
9111        if age > CHECKPOINT_MAX_AGE {
9112            let _ = match &checkpoint_ref.source {
9113                session_manager::CheckpointSource::Session(id) => {
9114                    manager.clear_session_checkpoint(id)
9115                }
9116                session_manager::CheckpointSource::Legacy => manager.clear_legacy_checkpoint(),
9117            };
9118            continue;
9119        }
9120        let loaded = match &checkpoint_ref.source {
9121            session_manager::CheckpointSource::Session(id) => manager.load_session_checkpoint(id),
9122            session_manager::CheckpointSource::Legacy => manager.load_legacy_checkpoint(),
9123        };
9124        let Ok(Some(session)) = loaded else {
9125            continue;
9126        };
9127        recent.push(RecentCheckpoint {
9128            session,
9129            age,
9130            source: checkpoint_ref.source,
9131        });
9132    }
9133    // `list_checkpoints` sorts newest-first already; keep it explicit here so
9134    // selection does not silently depend on the manager's ordering.
9135    recent.sort_by_key(|c| c.age);
9136    recent
9137}
9138
9139fn checkpoint_age_label(age: std::time::Duration) -> String {
9140    if age.as_secs() < 60 {
9141        format!("{}s ago", age.as_secs())
9142    } else if age.as_secs() < 3600 {
9143        format!("{}m ago", age.as_secs() / 60)
9144    } else {
9145        format!("{}h ago", age.as_secs() / 3600)
9146    }
9147}
9148
9149/// Check for a crash-recovery checkpoint and return the session ID if explicit
9150/// recovery was requested *and* the checkpoint belongs to the current
9151/// workspace.
9152///
9153/// Candidates are all per-session checkpoint files plus the legacy
9154/// single-slot `checkpoints/latest.json`; each must be younger than 24 hours
9155/// **and its workspace must match the resolved launch workspace after
9156/// canonicalisation** — the newest matching candidate wins. If no candidate
9157/// matches, a one-line notice points at `codewhale sessions`, and nothing is
9158/// auto-loaded: another workspace's checkpoint file is never touched (it may
9159/// belong to a live session there).
9160fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
9161    let manager = session_manager::SessionManager::default_location().ok()?;
9162    let candidates = load_recent_checkpoints(&manager);
9163    if candidates.is_empty() {
9164        return None;
9165    }
9166
9167    // Refuse to silently restore a session from another workspace. Compare
9168    // against the resolved launch workspace, not the shell cwd, so callers
9169    // using `--workspace` cannot accidentally recover a checkpoint from the
9170    // directory their shell happened to be in.
9171    let (matching, mismatched): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|candidate| {
9172        session_manager::workspace_scope_matches(
9173            &candidate.session.metadata.workspace,
9174            launch_workspace,
9175        )
9176    });
9177
9178    let Some(best) = matching.into_iter().next() else {
9179        if let Some(newest) = mismatched.first() {
9180            eprintln!(
9181                "Note: an interrupted session from another workspace ({}) is \
9182                 available. Run `codewhale sessions` to list saved sessions. Starting \
9183                 fresh in {}.",
9184                newest.session.metadata.workspace.display(),
9185                launch_workspace.display(),
9186            );
9187        }
9188        return None;
9189    };
9190
9191    let session_id = best.session.metadata.id.clone();
9192
9193    // Persist the checkpoint as a regular session so the TUI can load it by
9194    // id — unless a newer regular session file for the same id already
9195    // exists (e.g. `--continue` ran before and the session advanced since).
9196    // A stale checkpoint must never overwrite newer durable session state.
9197    if !saved_session_is_newer(&manager, &best.session)
9198        && manager.save_session(&best.session).is_err()
9199    {
9200        return None;
9201    }
9202
9203    match &best.source {
9204        session_manager::CheckpointSource::Session(id) => {
9205            // Consume the per-session checkpoint now that it is recovered.
9206            let _ = manager.clear_session_checkpoint(id);
9207        }
9208        session_manager::CheckpointSource::Legacy => {
9209            // Migrate the legacy slot to a per-session file (never
9210            // overwriting an existing one) and leave `latest.json` in place
9211            // so an older binary can still find it; its writer is already
9212            // gone and the file ages out within 24 hours.
9213            let _ = manager.write_session_checkpoint_if_absent(&best.session);
9214        }
9215    }
9216
9217    let age_str = checkpoint_age_label(best.age);
9218    eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
9219
9220    Some(session_id)
9221}
9222
9223/// Whether a regular session file for the checkpoint's id already exists and
9224/// is at least as recent as the checkpoint. When it is, persisting the
9225/// checkpoint over it would replace newer durable state with older in-flight
9226/// state.
9227fn saved_session_is_newer(
9228    manager: &session_manager::SessionManager,
9229    checkpoint: &session_manager::SavedSession,
9230) -> bool {
9231    manager
9232        .load_session(&checkpoint.metadata.id)
9233        .is_ok_and(|existing| existing.metadata.updated_at >= checkpoint.metadata.updated_at)
9234}
9235
9236/// Preserve an interrupted checkpoint on a normal fresh launch without
9237/// attaching it to the new TUI instance. This keeps "open another codewhale in
9238/// the same folder" from re-entering the previous in-flight session while still
9239/// leaving an explicit resume path.
9240///
9241/// Only the newest recent checkpoint drives the notice. The legacy
9242/// single-slot file is persisted as a regular session and consumed (today's
9243/// behavior for that slot); per-session checkpoint files are persisted but
9244/// left in place — they may belong to a live session in another terminal,
9245/// and `--continue` reads them directly.
9246fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
9247    let Some(manager) = session_manager::SessionManager::default_location().ok() else {
9248        return;
9249    };
9250    let Some(newest) = load_recent_checkpoints(&manager).into_iter().next() else {
9251        return;
9252    };
9253
9254    let session_workspace = newest.session.metadata.workspace.clone();
9255    // #4479: removed save_session call — checkpoint should not be auto-promoted to session
9256    if newest.source == session_manager::CheckpointSource::Legacy {
9257        // Migrate legacy single-slot checkpoint to per-session format
9258        // before clearing the legacy file, or the data is unrecoverable.
9259        let _ = manager.save_checkpoint(&newest.session);
9260        let _ = manager.clear_legacy_checkpoint();
9261    }
9262
9263    let age_str = checkpoint_age_label(newest.age);
9264    if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
9265        eprintln!(
9266            "Found an in-flight session snapshot ({age_str}). Starting a new \
9267             session. Run `codewhale --continue` to resume it."
9268        );
9269    } else {
9270        eprintln!(
9271            "Note: an interrupted session from another workspace ({}) is \
9272             available. Run `codewhale sessions` to list saved sessions. Starting \
9273             fresh in {}.",
9274            session_workspace.display(),
9275            launch_workspace.display(),
9276        );
9277    }
9278}
9279
9280/// Load project-level config from `$WORKSPACE/.codewhale/config.toml`, with
9281/// legacy `$WORKSPACE/.deepseek/config.toml` fallback, then apply its fields as
9282/// overrides on top of the global config (#485).
9283/// Only explicitly set fields in the project file are applied; everything
9284/// else falls back to the global value.
9285#[cfg(test)]
9286fn merge_project_config(config: &mut Config, workspace: &Path) {
9287    merge_project_config_with_approval_baseline(config, workspace, None);
9288}
9289
9290/// Apply project config while evaluating approval tightening against the
9291/// user's effective interactive baseline. `Config::approval_policy` remains
9292/// authoritative when present; the saved TUI posture is used only when the
9293/// root config leaves approval unset.
9294fn merge_project_config_with_approval_baseline(
9295    config: &mut Config,
9296    workspace: &Path,
9297    saved_permission_posture: Option<&str>,
9298) {
9299    // When the workspace is the user's home directory, the project-scope
9300    // config file is also the global config file. Skip the merge to avoid
9301    // redundant processing and a misleading "project-scope config key
9302    // ignored" warning on every launch from ~.
9303    if let Some(home) = effective_home_dir()
9304        && let (Ok(w), Ok(h)) = (
9305            std::fs::canonicalize(workspace),
9306            std::fs::canonicalize(&home),
9307        )
9308        && w == h
9309    {
9310        return;
9311    }
9312
9313    // v0.8.44: prefer .codewhale/config.toml, fall back to .deepseek/
9314    let path = workspace
9315        .join(codewhale_config::CODEWHALE_APP_DIR)
9316        .join("config.toml");
9317    let raw = match read_project_config_file(&path) {
9318        Ok(Some(r)) => r,
9319        Ok(None) => {
9320            let legacy = workspace
9321                .join(codewhale_config::LEGACY_APP_DIR)
9322                .join("config.toml");
9323            match read_project_config_file(&legacy) {
9324                Ok(Some(r)) => r,
9325                Ok(None) => return,
9326                Err(err) => {
9327                    eprintln!(
9328                        "warning: failed to read project-scope config {}: {err}",
9329                        legacy.display()
9330                    );
9331                    return;
9332                }
9333            }
9334        }
9335        Err(err) => {
9336            eprintln!(
9337                "warning: failed to read project-scope config {}: {err}",
9338                path.display()
9339            );
9340            return;
9341        }
9342    };
9343    let project: toml::Value = match toml::from_str(&raw) {
9344        Ok(v) => v,
9345        Err(_) => return,
9346    };
9347    let table = match project.as_table() {
9348        Some(t) => t,
9349        None => return,
9350    };
9351
9352    // #417: dangerous keys are denied at project scope. A malicious
9353    // `<workspace>/.deepseek/config.toml` could otherwise:
9354    // * `api_key` / `base_url` / `provider` — exfiltrate prompts to a
9355    //   look-alike endpoint by swapping the user's credentials and
9356    //   target host with project-controlled values.
9357    // * `mcp_config_path` — point the loader at an MCP config that
9358    //   spawns arbitrary stdio servers under the user's identity.
9359    // * `mcp_oauth_callback_*` — choose local OAuth redirect listener
9360    //   behavior for user-owned MCP credentials.
9361    //
9362    // The overlay path is non-interactive; users can't visually
9363    // confirm a rogue project config is hijacking these. We surface
9364    // a stderr warning on first encounter so a user who *did* expect
9365    // the override has a chance to notice the deny instead of silent
9366    // discard.
9367    const DENY_AT_PROJECT_SCOPE: &[&str] = &[
9368        "api_key",
9369        "base_url",
9370        "provider",
9371        "mcp_config_path",
9372        "mcp_oauth_callback_port",
9373        "mcp_oauth_callback_url",
9374    ];
9375    for key in DENY_AT_PROJECT_SCOPE {
9376        if table.contains_key(*key) {
9377            eprintln!(
9378                "warning: project-scope config key `{key}` is ignored — \
9379                 set it in `~/.codewhale/config.toml` instead. \
9380                 (See #417 for the deny-list rationale.)"
9381            );
9382        }
9383    }
9384
9385    // String fields a project may legitimately override (model,
9386    // approval/sandbox tightening, notes path, reasoning effort).
9387    for (key, field) in [
9388        ("model", &mut config.default_text_model),
9389        ("reasoning_effort", &mut config.reasoning_effort),
9390        ("notes_path", &mut config.notes_path),
9391    ] {
9392        if let Some(v) = table.get(key).and_then(toml::Value::as_str)
9393            && !v.is_empty()
9394        {
9395            *field = Some(v.to_string());
9396        }
9397    }
9398
9399    if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
9400        && !v.is_empty()
9401    {
9402        let saved_approval_baseline =
9403            crate::config::approval_policy_baseline_from_permission_posture(
9404                saved_permission_posture,
9405            );
9406        let approval_baseline = config
9407            .approval_policy
9408            .as_deref()
9409            .or(saved_approval_baseline);
9410        if codewhale_config::project_approval_policy_is_allowed(approval_baseline, v) {
9411            config.approval_policy = Some(v.to_string());
9412        } else {
9413            eprintln!(
9414                "warning: project-scope `approval_policy = \"{v}\"` is ignored — \
9415                 project config can only tighten the user's approval policy. \
9416                 (See #417.)"
9417            );
9418        }
9419    }
9420
9421    if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
9422        && !v.is_empty()
9423    {
9424        if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
9425            config.sandbox_mode = Some(v.to_string());
9426        } else {
9427            eprintln!(
9428                "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
9429                 project config can only tighten the user's sandbox mode. \
9430                 (See #417.)"
9431            );
9432        }
9433    }
9434
9435    // Numeric / bool fields that benefit from per-project overrides.
9436    if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
9437        && v > 0
9438    {
9439        config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
9440    }
9441    if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
9442        if v {
9443            eprintln!(
9444                "warning: project-scope `allow_shell = true` is ignored — \
9445                 enable shell from user config for this workspace instead. \
9446                 (See #417.)"
9447            );
9448        } else {
9449            config.allow_shell = Some(false);
9450        }
9451    }
9452
9453    if table.contains_key("instructions") {
9454        eprintln!(
9455            "warning: project-scope `instructions` is ignored — \
9456             configure instruction files from user config instead. \
9457             (See #417.)"
9458        );
9459    }
9460}
9461
9462fn read_project_config_file(path: &Path) -> io::Result<Option<String>> {
9463    let metadata = match std::fs::symlink_metadata(path) {
9464        Ok(metadata) => metadata,
9465        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
9466        Err(err) => return Err(err),
9467    };
9468    let file_type = metadata.file_type();
9469    if file_type.is_symlink() {
9470        return Err(io::Error::new(
9471            io::ErrorKind::InvalidInput,
9472            "project-scope config must not be a symlink",
9473        ));
9474    }
9475    if !file_type.is_file() {
9476        return Ok(None);
9477    }
9478
9479    let mut file = open_project_config_file(path)?;
9480    let mut raw = String::new();
9481    file.read_to_string(&mut raw)?;
9482    Ok(Some(raw))
9483}
9484
9485#[cfg(unix)]
9486fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9487    use std::os::unix::fs::OpenOptionsExt;
9488
9489    std::fs::OpenOptions::new()
9490        .read(true)
9491        .custom_flags(libc::O_NOFOLLOW)
9492        .open(path)
9493}
9494
9495#[cfg(not(unix))]
9496fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9497    std::fs::File::open(path)
9498}
9499
9500fn merge_user_workspace_config(
9501    config: &mut Config,
9502    config_path: Option<PathBuf>,
9503    workspace: &Path,
9504) {
9505    if config.managed_config_path.is_some() || config.requirements_path.is_some() {
9506        return;
9507    }
9508    let allow_shell_before = config.allow_shell;
9509    let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
9510        || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
9511    let path = match crate::config::resolve_load_config_path(config_path) {
9512        Ok(Some(path)) => path,
9513        Ok(None) => return,
9514        Err(error) => {
9515            tracing::error!(
9516                error = %error,
9517                "failed to resolve workspace config overlay; refusing to substitute another file"
9518            );
9519            return;
9520        }
9521    };
9522    let raw = match std::fs::read_to_string(&path) {
9523        Ok(raw) => raw,
9524        Err(error) => {
9525            eprintln!(
9526                "warning: could not read user config at {}: {error}. \
9527                 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9528                 revert to defaults for this session. Fix or remove the file to \
9529                 restore them.",
9530                path.display()
9531            );
9532            return;
9533        }
9534    };
9535    let doc = match toml::from_str::<toml::Value>(&raw) {
9536        Ok(doc) => doc,
9537        Err(error) => {
9538            eprintln!(
9539                "warning: could not parse user config at {}: {error}. \
9540                 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9541                 revert to defaults for this session. Fix the TOML syntax to \
9542                 restore them.",
9543                path.display()
9544            );
9545            return;
9546        }
9547    };
9548    merge_user_workspace_config_from_doc(config, &doc, workspace);
9549    if allow_shell_from_env {
9550        config.allow_shell = allow_shell_before;
9551    }
9552}
9553
9554fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
9555    for table_name in ["workspace", "projects"] {
9556        let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
9557            continue;
9558        };
9559        for (raw_path, entry) in entries {
9560            if !workspace_config_path_matches(raw_path, workspace) {
9561                continue;
9562            }
9563            if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
9564                config.allow_shell = Some(allow_shell);
9565            }
9566        }
9567    }
9568}
9569
9570fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
9571    let configured = crate::config::expand_path(raw_path);
9572    let configured = configured.canonicalize().unwrap_or(configured);
9573    let workspace = workspace
9574        .canonicalize()
9575        .unwrap_or_else(|_| workspace.to_path_buf());
9576    paths_equal_for_config(&configured, &workspace)
9577}
9578
9579#[cfg(windows)]
9580fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9581    normalize_windows_config_path_for_compare(left)
9582        == normalize_windows_config_path_for_compare(right)
9583}
9584
9585#[cfg(not(windows))]
9586fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9587    left == right
9588}
9589
9590#[cfg(windows)]
9591fn normalize_windows_config_path_for_compare(path: &Path) -> String {
9592    normalize_windows_config_path_str(&path.to_string_lossy())
9593}
9594
9595#[cfg(any(windows, test))]
9596fn normalize_windows_config_path_str(path: &str) -> String {
9597    let mut normalized = path.replace('/', "\\");
9598    if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") {
9599        normalized = format!("\\\\{rest}");
9600    } else if let Some(rest) = normalized.strip_prefix(r"\\?\") {
9601        normalized = rest.to_string();
9602    }
9603    while normalized.len() > 3 && normalized.ends_with('\\') {
9604        normalized.pop();
9605    }
9606    normalized.to_ascii_lowercase()
9607}
9608
9609fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool {
9610    yolo || config.interactive_allow_shell()
9611}
9612
9613async fn run_interactive(
9614    cli: &Cli,
9615    config: &Config,
9616    resume_session_id: Option<String>,
9617    initial_input: Option<tui::InitialInput>,
9618    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9619    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9620) -> Result<()> {
9621    run_interactive_with_notice(
9622        cli,
9623        config,
9624        resume_session_id,
9625        initial_input,
9626        None,
9627        pending_telemetry_notice,
9628        plugin_registry,
9629    )
9630    .await
9631}
9632
9633/// As [`run_interactive`], but carrying a one-line startup receipt to show in
9634/// the transcript — used by auto-resume to explain why it did or did not
9635/// reattach to a previous session (#2934).
9636async fn run_interactive_with_notice(
9637    cli: &Cli,
9638    config: &Config,
9639    resume_session_id: Option<String>,
9640    initial_input: Option<tui::InitialInput>,
9641    startup_notice: Option<String>,
9642    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9643    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9644) -> Result<()> {
9645    let initial_input = if cli.remote_control {
9646        Some(tui::InitialInput::RemoteControl)
9647    } else {
9648        initial_input
9649    };
9650    let workspace = cli
9651        .workspace
9652        .clone()
9653        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9654
9655    // Merge project-level config from $WORKSPACE/.codewhale/config.toml
9656    // or legacy $WORKSPACE/.deepseek/config.toml
9657    // unless --no-project-config was passed (#485).
9658    let mut merged_config = config.clone();
9659    merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
9660    if !cli.no_project_config {
9661        let saved_permission_posture = crate::settings::Settings::load_persisted()
9662            .ok()
9663            .and_then(|settings| settings.permission_posture);
9664        merge_project_config_with_approval_baseline(
9665            &mut merged_config,
9666            &workspace,
9667            saved_permission_posture.as_deref(),
9668        );
9669    }
9670    let config = &merged_config;
9671
9672    if !cli.skip_onboarding {
9673        match crate::config::ensure_config_file_exists(cli.config.clone()) {
9674            Ok(Some(path)) => logging::info(format!(
9675                "Created first-run config file at {}",
9676                path.display()
9677            )),
9678            Ok(None) => {}
9679            Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
9680        }
9681    }
9682
9683    // v0.8.44: migrate config from ~/.deepseek/ to ~/.codewhale/ on first
9684    // launch. Non-fatal — existing installs keep working either way.
9685    match codewhale_config::migrate_config_if_needed() {
9686        Ok(Some(migration)) => {
9687            eprintln!("{}", migration.user_notice());
9688        }
9689        Ok(None) => {}
9690        Err(err) => logging::warn(format!("Config migration skipped: {err}")),
9691    }
9692
9693    let model = config.default_model();
9694    let provider = config.api_provider();
9695    let max_subagents = cli.max_subagents.map_or_else(
9696        || config.max_subagents_for_provider(provider),
9697        |value| value.clamp(1, MAX_SUBAGENTS),
9698    );
9699    let use_alt_screen = should_use_alt_screen(cli, config);
9700    let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
9701    let use_bracketed_paste = crate::settings::Settings::load()
9702        .map(|s| s.effective_bracketed_paste())
9703        .unwrap_or_else(|_| !crate::settings::detected_legacy_windows_console_host());
9704
9705    // Auto-install bundled system skills (e.g. skill-creator) on first launch.
9706    // Errors are non-fatal: log a warning and continue.
9707    let skills_dir = config.skills_dir();
9708    if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
9709        logging::warn(format!("Failed to install system skills: {e}"));
9710    }
9711
9712    startup_trace::mark("interactive_config");
9713
9714    // Seed ProviderLake from the secret-free Models.dev disk cache before any
9715    // picker/inventory read, then kick a best-effort background refresh (#4187).
9716    // Failures are quiet: bundled catalog rows always remain available.
9717    crate::models_dev_live::maybe_load_persisted_cache();
9718    crate::models_dev_live::spawn_background_refresh();
9719    // Best-effort per-provider catalog refresh: fetches the active provider's
9720    // own /v1/models endpoint and merges live rows into the provider lake
9721    // alongside the Models.dev snapshot. Currently active for TelecomJS, whose
9722    // model list is not covered by the Models.dev catalog.
9723    crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
9724
9725    // Boot janitors — snapshot prune (7-day default), spillover prune
9726    // (#422), and managed-session cleanup (v0.8.44) — are best-effort disk
9727    // hygiene. On a large ~/.codewhale they were the dominant startup cost
9728    // (a git object walk plus thousands of stat/read calls), so they run on
9729    // a blocking worker while the TUI brings up its first frame (#3757).
9730    // All three were already documented as non-fatal.
9731    let snapshots = config.snapshots_config();
9732    let janitor_snapshots_enabled = snapshots.enabled;
9733    let janitor_max_age = snapshots.max_age();
9734    let janitor_workspace = workspace.clone();
9735    // Session cleanup races session restore: skip it entirely when a session
9736    // is being resumed/continued this launch (the just-resumed session could
9737    // be pruned before its first save bumps `updated_at`). It runs next
9738    // clean launch. When we do run it, exclude the explicit resume id too.
9739    let janitor_resume_id = resume_session_id.clone();
9740    let janitor_skip_session_cleanup = resume_session_id.is_some() || cli.continue_session;
9741    tokio::task::spawn_blocking(move || {
9742        if janitor_snapshots_enabled {
9743            session_manager::prune_workspace_snapshots(&janitor_workspace, janitor_max_age);
9744        }
9745
9746        match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
9747            Ok(0) => {}
9748            Ok(n) => tracing::debug!(
9749                target: "spillover",
9750                "boot prune removed {n} spillover file(s)"
9751            ),
9752            Err(err) => tracing::warn!(
9753                target: "spillover",
9754                ?err,
9755                "spillover prune skipped on boot"
9756            ),
9757        }
9758
9759        if !janitor_skip_session_cleanup
9760            && let Ok(manager) = session_manager::SessionManager::default_location()
9761        {
9762            let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
9763        }
9764    });
9765
9766    // The `deepseek` launcher forwards `--yolo` to this binary via the
9767    // DEEPSEEK_YOLO env var (config.yolo), not as a CLI flag. Honour either.
9768    let yolo = cli.yolo || config.yolo.unwrap_or(false);
9769
9770    tui::run_tui(
9771        config,
9772        tui::TuiOptions {
9773            model,
9774            workspace,
9775            config_path: cli.config.clone(),
9776            config_profile: effective_config_profile(cli),
9777            allow_shell: interactive_tui_allow_shell(yolo, config),
9778            use_alt_screen,
9779            use_mouse_capture,
9780            use_bracketed_paste,
9781            skills_dir,
9782            memory_path: config.memory_path(),
9783            notes_path: config.notes_path(),
9784            mcp_config_path: config.mcp_config_path(),
9785            use_memory: config.memory_enabled(),
9786            start_in_agent_mode: yolo,
9787            skip_onboarding: cli.skip_onboarding,
9788            yolo, // YOLO mode auto-approves all tool executions
9789            resume_session_id,
9790            initial_input,
9791            startup_notice,
9792            max_subagents,
9793        },
9794        plugin_registry,
9795        pending_telemetry_notice,
9796    )
9797    .await
9798}
9799
9800#[derive(Debug)]
9801struct CliAutoRoute {
9802    provider: crate::config::ApiProvider,
9803    model: String,
9804    reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
9805    /// Whether the runtime should continue resolving reasoning per prompt.
9806    ///
9807    /// This is independent from `auto_model`: an Auto model can carry a fixed
9808    /// saved effort, while a fixed Fleet model can still request Auto effort.
9809    auto_controls_reasoning: bool,
9810    auto_model: bool,
9811}
9812
9813fn cli_reasoning_effort_value(
9814    config: &Config,
9815    model: &str,
9816    effort: crate::tui::app::ReasoningEffort,
9817) -> Option<String> {
9818    effort
9819        .api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
9820        .map(str::to_string)
9821}
9822
9823fn cli_reasoning_effort_value_for_prompt(
9824    config: &Config,
9825    model: &str,
9826    effort: crate::tui::app::ReasoningEffort,
9827    prompt: &str,
9828) -> Option<String> {
9829    let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
9830        crate::auto_reasoning::select(false, prompt)
9831    } else {
9832        effort
9833    };
9834    cli_reasoning_effort_value(config, model, resolved)
9835}
9836
9837fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
9838    let trimmed = value.trim();
9839    if trimmed.is_empty() {
9840        return Ok(None);
9841    }
9842    if matches!(
9843        trimmed.to_ascii_lowercase().as_str(),
9844        "inherit" | "parent" | "same" | "current" | "default" | "unset"
9845    ) {
9846        return Ok(None);
9847    }
9848    crate::tui::app::ReasoningEffort::parse_strict(trimmed)
9849        .map(|effort| Some(effort.as_setting().to_string()))
9850        .map_err(anyhow::Error::msg)
9851}
9852
9853fn config_for_cli_route(config: &Config, route: &CliAutoRoute) -> Config {
9854    let mut execution_config = config.clone();
9855    execution_config.provider = Some(config.provider_identity_for(route.provider));
9856    execution_config.set_provider_model_override(route.provider, Some(route.model.clone()));
9857    if matches!(
9858        route.provider,
9859        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
9860    ) {
9861        execution_config.default_text_model = Some(route.model.clone());
9862    }
9863    execution_config
9864}
9865
9866async fn resolve_cli_auto_route(
9867    config: &Config,
9868    model: &str,
9869    prompt: &str,
9870) -> Result<CliAutoRoute> {
9871    if model.trim().eq_ignore_ascii_case("auto") {
9872        let selection =
9873            model_routing::resolve_auto_route_with_inventory(config, prompt, "", "auto", "auto")
9874                .await?;
9875        let preference = config
9876            .reasoning_effort()
9877            .filter(|_| config.reasoning_effort_is_explicit())
9878            .map(crate::tui::app::ReasoningEffort::from_setting);
9879        let (reasoning_effort, auto_controls_reasoning) =
9880            model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
9881        Ok(CliAutoRoute {
9882            provider: selection.provider,
9883            model: selection.model,
9884            reasoning_effort,
9885            auto_controls_reasoning,
9886            auto_model: true,
9887        })
9888    } else {
9889        if let Some(selection) = model_routing::resolve_explicit_route_with_inventory(config, model)
9890        {
9891            let auto_controls_reasoning = matches!(
9892                selection.reasoning_effort,
9893                Some(crate::tui::app::ReasoningEffort::Auto)
9894            );
9895            return Ok(CliAutoRoute {
9896                provider: selection.provider,
9897                model: selection.model,
9898                reasoning_effort: selection.reasoning_effort,
9899                auto_controls_reasoning,
9900                auto_model: false,
9901            });
9902        }
9903
9904        let candidate_providers = model_routing::explicit_route_candidate_providers(config, model);
9905        if !candidate_providers.is_empty() && !candidate_providers.contains(&config.api_provider())
9906        {
9907            let providers = candidate_providers
9908                .iter()
9909                .map(|provider| provider.as_str())
9910                .collect::<Vec<_>>()
9911                .join(", ");
9912            bail!(
9913                "model `{model}` is available from configured provider route(s): {providers}. \
9914                 Pass `--provider <provider>` with `--model {model}` to choose one explicitly. \
9915                 In the TUI, use `/provider`, `/model`, or `/setup` to resolve the route before sending."
9916            );
9917        }
9918
9919        // When --model is not `auto`, fall back to the reasoning_effort
9920        // declared in the user's config.toml. The previous hard-coded `None`
9921        // silently dropped the user's setting on every non-auto-route exec
9922        // call, which (for example) prevented vllm + Qwen3 users from
9923        // disabling thinking via `reasoning_effort = "off"` and caused
9924        // 30+ second SSE idle timeouts on trivial prompts.
9925        let reasoning_effort = config
9926            .reasoning_effort()
9927            .map(crate::tui::app::ReasoningEffort::from_setting);
9928        Ok(CliAutoRoute {
9929            provider: config.api_provider(),
9930            model: model.to_string(),
9931            auto_controls_reasoning: matches!(
9932                reasoning_effort,
9933                Some(crate::tui::app::ReasoningEffort::Auto)
9934            ),
9935            reasoning_effort,
9936            auto_model: false,
9937        })
9938    }
9939}
9940
9941async fn resolve_cli_exec_route(
9942    config: &Config,
9943    model: &str,
9944    prompt: &str,
9945    force_configured_route: bool,
9946) -> Result<CliAutoRoute> {
9947    if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
9948        let reasoning_effort = config
9949            .reasoning_effort()
9950            .map(crate::tui::app::ReasoningEffort::from_setting);
9951        return Ok(CliAutoRoute {
9952            provider: config.api_provider(),
9953            model: model.to_string(),
9954            auto_controls_reasoning: matches!(
9955                reasoning_effort,
9956                Some(crate::tui::app::ReasoningEffort::Auto)
9957            ),
9958            reasoning_effort,
9959            auto_model: false,
9960        });
9961    }
9962    resolve_cli_auto_route(config, model, prompt).await
9963}
9964
9965fn should_force_configured_exec_route(
9966    resuming: bool,
9967    explicit_provider: Option<&str>,
9968    explicit_model: Option<&str>,
9969) -> bool {
9970    // A configured/default model belongs to the configured provider route.
9971    // Cross-provider inventory inference is reserved for an explicit model
9972    // override without an explicit provider. Resume remains route-authoritative
9973    // even when its model is overridden because it restores the saved provider.
9974    resuming || explicit_provider.is_some() || explicit_model.is_none()
9975}
9976
9977async fn run_one_shot(
9978    config: &Config,
9979    model: &str,
9980    prompt: &str,
9981    force_configured_route: bool,
9982) -> Result<()> {
9983    use crate::client::DeepSeekClient;
9984    use crate::models::{
9985        ContentBlock, Message, MessageRequest, is_incomplete_stop_reason, stop_reason_detail,
9986    };
9987
9988    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
9989    let execution_config = config_for_cli_route(config, &route);
9990    let client = DeepSeekClient::new(&execution_config)?;
9991    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
9992        cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt)
9993    });
9994    let model = route.model;
9995    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
9996
9997    let request = MessageRequest {
9998        model,
9999        messages: vec![Message {
10000            role: "user".to_string(),
10001            content: vec![ContentBlock::Text {
10002                text: prompt.to_string(),
10003                cache_control: None,
10004            }],
10005        }],
10006        max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
10007            request_route.provider,
10008            &request_route.model,
10009            None,
10010        ),
10011        system: None,
10012        tools: None,
10013        tool_choice: None,
10014        metadata: None,
10015        thinking: None,
10016        reasoning_effort,
10017        stream: Some(false),
10018        temperature: None,
10019        top_p: None,
10020    };
10021
10022    let response = client.create_message(request).await?;
10023    let stop_reason = response.stop_reason.clone();
10024
10025    for block in response.content {
10026        if let ContentBlock::Text { text, .. } = block {
10027            println!("{text}");
10028        }
10029    }
10030
10031    if is_incomplete_stop_reason(stop_reason.as_deref()) {
10032        anyhow::bail!(
10033            "Model response incomplete: provider stop reason `{}`; the partial response was printed but the command did not succeed.",
10034            stop_reason_detail(stop_reason.as_deref())
10035        );
10036    }
10037
10038    Ok(())
10039}
10040
10041async fn run_one_shot_json(
10042    config: &Config,
10043    model: &str,
10044    prompt: &str,
10045    force_configured_route: bool,
10046) -> Result<()> {
10047    use crate::client::DeepSeekClient;
10048    use crate::models::{
10049        ContentBlock, Message, MessageRequest, SystemPrompt, is_incomplete_stop_reason,
10050        stop_reason_detail,
10051    };
10052
10053    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10054    let execution_config = config_for_cli_route(config, &route);
10055    let provider = execution_config.provider_identity_for(route.provider);
10056    let client = DeepSeekClient::new(&execution_config)?;
10057    let model = route.model.clone();
10058    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
10059        cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt)
10060    });
10061    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
10062    let request = MessageRequest {
10063        model: model.clone(),
10064        messages: vec![Message {
10065            role: "user".to_string(),
10066            content: vec![ContentBlock::Text {
10067                text: prompt.to_string(),
10068                cache_control: None,
10069            }],
10070        }],
10071        max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
10072            request_route.provider,
10073            &request_route.model,
10074            None,
10075        ),
10076        system: Some(SystemPrompt::Text(
10077            "You are a coding assistant. Give concise, actionable responses.".to_string(),
10078        )),
10079        tools: None,
10080        tool_choice: None,
10081        metadata: None,
10082        thinking: None,
10083        reasoning_effort,
10084        stream: Some(false),
10085        temperature: None,
10086        top_p: None,
10087    };
10088
10089    let response = client.create_message(request).await?;
10090    let stop_reason = response.stop_reason.clone();
10091    let usage = response.usage.clone();
10092    let mut output = String::new();
10093    for block in response.content {
10094        if let ContentBlock::Text { text, .. } = block {
10095            output.push_str(&text);
10096        }
10097    }
10098    println!(
10099        "{}",
10100        serde_json::to_string_pretty(&one_shot_exec_json_receipt(
10101            provider,
10102            model,
10103            output,
10104            stop_reason.clone(),
10105            usage,
10106        ))?
10107    );
10108    if is_incomplete_stop_reason(stop_reason.as_deref()) {
10109        anyhow::bail!(
10110            "Model response incomplete: provider stop reason `{}`; the JSON receipt records success=false.",
10111            stop_reason_detail(stop_reason.as_deref())
10112        );
10113    }
10114    Ok(())
10115}
10116
10117fn one_shot_exec_json_receipt(
10118    provider: String,
10119    model: String,
10120    output: String,
10121    stop_reason: Option<String>,
10122    usage: crate::models::Usage,
10123) -> serde_json::Value {
10124    let incomplete = crate::models::is_incomplete_stop_reason(stop_reason.as_deref());
10125    let error = incomplete.then(|| {
10126        format!(
10127            "Model response incomplete: provider stop reason `{}`.",
10128            crate::models::stop_reason_detail(stop_reason.as_deref())
10129        )
10130    });
10131    serde_json::json!({
10132        "mode": "one-shot",
10133        "provider": provider,
10134        "model": model,
10135        "success": !incomplete,
10136        "output": output,
10137        "stop_reason": stop_reason,
10138        "usage": usage,
10139        "error": error,
10140    })
10141}
10142
10143fn exec_stream_provider_route(
10144    identity: &crate::config::ProviderIdentity,
10145) -> (String, Option<String>) {
10146    let provider = identity.provider.as_str().to_string();
10147    let provider_id = if identity.provider == crate::config::ApiProvider::Custom {
10148        identity.exact_id.clone()
10149    } else {
10150        None
10151    };
10152    (provider, provider_id)
10153}
10154
10155#[derive(serde::Serialize)]
10156struct ExecStreamMeta {
10157    receipt_kind: &'static str,
10158    provider: String,
10159    /// Exact configured provider-table id, when one selected the route.
10160    /// `None` deliberately distinguishes the legacy idless root custom route
10161    /// from literal `[providers.custom]`, whose exact id is `"custom"`.
10162    #[serde(skip_serializing_if = "Option::is_none")]
10163    provider_id: Option<String>,
10164    model: String,
10165    route_source: String,
10166    #[serde(skip_serializing_if = "Option::is_none")]
10167    input_tokens: Option<u32>,
10168    #[serde(skip_serializing_if = "Option::is_none")]
10169    output_tokens: Option<u32>,
10170    #[serde(skip_serializing_if = "Option::is_none")]
10171    prompt_cache_hit_tokens: Option<u32>,
10172    #[serde(skip_serializing_if = "Option::is_none")]
10173    prompt_cache_miss_tokens: Option<u32>,
10174    #[serde(skip_serializing_if = "Option::is_none")]
10175    prompt_cache_write_tokens: Option<u32>,
10176    #[serde(skip_serializing_if = "Option::is_none")]
10177    reasoning_tokens: Option<u32>,
10178    /// Resolved output ceiling the route actually requested (post-catalogue).
10179    #[serde(skip_serializing_if = "Option::is_none")]
10180    codewhale_max_output_tokens: Option<u32>,
10181    /// Provenance of that ceiling: `documented`, `uncatalogued`, or
10182    /// `route-declared`.
10183    #[serde(skip_serializing_if = "Option::is_none")]
10184    codewhale_max_output_tokens_source: Option<&'static str>,
10185    duration_ms: u64,
10186    #[serde(skip_serializing_if = "Option::is_none")]
10187    retry_count: Option<u32>,
10188    approval_posture: String,
10189    sandbox_posture: String,
10190    #[serde(skip_serializing_if = "Option::is_none")]
10191    binary_sha256: Option<String>,
10192    #[serde(skip_serializing_if = "Option::is_none")]
10193    config_sha256: Option<String>,
10194    prompt_sha256: String,
10195    #[serde(skip_serializing_if = "Option::is_none")]
10196    tool_catalog_sha256: Option<String>,
10197    input_analysis: ExecStreamInputAnalysis,
10198    visible_final_answer_chars: usize,
10199    session_id: String,
10200    resume_command: String,
10201    workspace: String,
10202    message_count: usize,
10203    #[serde(skip_serializing_if = "Option::is_none")]
10204    status: Option<String>,
10205    #[serde(skip_serializing_if = "Option::is_none")]
10206    termination_reason: Option<String>,
10207    #[serde(skip_serializing_if = "Option::is_none")]
10208    error_category: Option<String>,
10209    #[serde(skip_serializing_if = "Option::is_none")]
10210    error: Option<String>,
10211}
10212
10213#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)]
10214struct ExecStreamInputAnalysis {
10215    estimated_request_tokens: usize,
10216    estimated_message_content_tokens: usize,
10217    estimated_system_tokens: usize,
10218    estimated_framing_tokens: usize,
10219    user_message_count: usize,
10220    assistant_message_count: usize,
10221    tool_message_count: usize,
10222    tool_use_count: usize,
10223    tool_result_count: usize,
10224    text_chars: usize,
10225    thinking_chars: usize,
10226    tool_use_input_chars: usize,
10227    tool_result_chars: usize,
10228    text_estimated_tokens: usize,
10229    thinking_estimated_tokens: usize,
10230    tool_use_input_estimated_tokens: usize,
10231    tool_result_estimated_tokens: usize,
10232}
10233
10234#[derive(serde::Serialize)]
10235#[serde(tag = "type")]
10236// Keep receipts flat for stable JSONL consumers. Boxing the whole tool_result
10237// payload would introduce a nested object and break the stream schema.
10238#[allow(clippy::large_enum_variant)]
10239enum ExecStreamEvent {
10240    #[serde(rename = "content")]
10241    Content { content: String },
10242    #[serde(rename = "tool_use")]
10243    ToolUse {
10244        name: String,
10245        id: String,
10246        input: serde_json::Value,
10247        started_at: String,
10248    },
10249    #[serde(rename = "tool_result")]
10250    ToolResult {
10251        id: String,
10252        name: String,
10253        output: String,
10254        status: String,
10255        started_at: String,
10256        completed_at: String,
10257        duration_ms: u64,
10258        side_effect_status: String,
10259        #[serde(skip_serializing_if = "Option::is_none")]
10260        error_category: Option<String>,
10261        #[serde(skip_serializing_if = "Option::is_none")]
10262        truncated: Option<bool>,
10263        #[serde(skip_serializing_if = "Option::is_none")]
10264        artifact: Option<serde_json::Value>,
10265        #[serde(skip_serializing_if = "Option::is_none")]
10266        result_metadata: Option<serde_json::Value>,
10267    },
10268    /// A sub-agent was launched, and the model it was launched on.
10269    ///
10270    /// Without this, a delegated child is invisible to anything reading the
10271    /// stream: a parent turn on one route could spawn children billed on
10272    /// another and the only place it surfaced was the invoice. That is not
10273    /// hypothetical — the `Fast` loadout re-priced scout children onto a
10274    /// cheaper sibling until it was fixed, and nothing in the output said so.
10275    #[serde(rename = "agent_spawned")]
10276    AgentSpawned {
10277        id: String,
10278        model: String,
10279        spawn_depth: u32,
10280        #[serde(skip_serializing_if = "Option::is_none")]
10281        parent_run_id: Option<String>,
10282        /// Why the child got this route, when the spawn path resolved one.
10283        #[serde(skip_serializing_if = "Option::is_none")]
10284        route_source: Option<String>,
10285    },
10286    #[serde(rename = "sandbox_denied")]
10287    SandboxDenied {
10288        tool_id: String,
10289        tool_name: String,
10290        reason: String,
10291        outcome: String,
10292    },
10293    #[serde(rename = "workflow_event")]
10294    WorkflowEvent {
10295        run_id: String,
10296        event: serde_json::Value,
10297    },
10298    #[serde(rename = "session_capture")]
10299    SessionCapture { content: String },
10300    #[serde(rename = "service_released")]
10301    #[cfg(unix)]
10302    ServiceReleased {
10303        task_id: String,
10304        pid: u32,
10305        process_group_id: u32,
10306        ownership: String,
10307    },
10308    /// Per-model-call usage receipt. Field names mirror the terminal
10309    /// `metadata` receipt (`prompt_cache_hit_tokens` is the provider's
10310    /// cache-read count, `prompt_cache_write_tokens` the cache-creation
10311    /// count). Optional fields are omitted — never emitted as null or zero —
10312    /// when the provider does not report them; the whole event is skipped
10313    /// for model calls whose provider reported no usage at all.
10314    #[serde(rename = "turn_usage")]
10315    TurnUsage {
10316        /// 1-based index of the model call within this exec run.
10317        turn: u32,
10318        input_tokens: u32,
10319        output_tokens: u32,
10320        #[serde(skip_serializing_if = "Option::is_none")]
10321        reasoning_tokens: Option<u32>,
10322        #[serde(skip_serializing_if = "Option::is_none")]
10323        prompt_cache_hit_tokens: Option<u32>,
10324        #[serde(skip_serializing_if = "Option::is_none")]
10325        prompt_cache_miss_tokens: Option<u32>,
10326        #[serde(skip_serializing_if = "Option::is_none")]
10327        prompt_cache_write_tokens: Option<u32>,
10328        #[serde(skip_serializing_if = "Option::is_none")]
10329        reasoning_replay_tokens: Option<u32>,
10330        duration_ms: u64,
10331    },
10332    #[serde(rename = "metadata")]
10333    Metadata { meta: Box<ExecStreamMeta> },
10334    #[serde(rename = "done")]
10335    Done,
10336    #[serde(rename = "error")]
10337    Error { error: String },
10338}
10339
10340fn exec_sandbox_elevation_authorized(
10341    allow_sandbox_elevation: bool,
10342    explicit_sandbox: Option<&str>,
10343) -> bool {
10344    allow_sandbox_elevation
10345        || explicit_sandbox.is_some_and(|policy| policy.eq_ignore_ascii_case("danger-full-access"))
10346}
10347
10348fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> {
10349    println!("{}", serde_json::to_string(&exec_stream_value(event)?)?);
10350    Ok(())
10351}
10352
10353/// Process exit code `codewhale exec` uses when a turn ends on a retryable
10354/// infrastructure failure (provider/transport) rather than a genuine task
10355/// failure. 75 is `EX_TEMPFAIL` from sysexits.h — "temporary failure; the
10356/// invocation is expected to succeed on retry" — so bench harnesses and
10357/// supervisors can distinguish retryable infra exits from genuine task
10358/// failures (exit 1) without parsing the stream-json metadata.
10359const EXEC_EXIT_RETRYABLE_INFRA: i32 = 75; // EX_TEMPFAIL
10360
10361/// Map a terminal exec error category to the process exit code.
10362///
10363/// `network` / `timeout` mean the provider connection dropped or stalled
10364/// after every in-session retry budget was exhausted: the task itself
10365/// neither passed nor failed, and re-running the same command is safe.
10366/// `rate_limit` is deliberately NOT mapped to the retryable code — the same
10367/// category also covers quota exhaustion, which a blind retry would hammer.
10368fn exec_failure_exit_code(error_category: Option<&str>) -> i32 {
10369    match error_category {
10370        Some("network" | "timeout") => EXEC_EXIT_RETRYABLE_INFRA,
10371        _ => 1,
10372    }
10373}
10374
10375/// Should a mid-turn engine error event force the final exec summary into
10376/// failure? Only non-recoverable envelopes do. Recoverable warnings (stream
10377/// stall notices, transient retry noise) are emitted on the stream for
10378/// visibility, but the terminal `TurnComplete` event carries the
10379/// authoritative turn outcome — a warning must never fail a run whose turn
10380/// later completes.
10381fn exec_error_event_is_fatal(envelope: &crate::error_taxonomy::ErrorEnvelope) -> bool {
10382    !envelope.recoverable
10383}
10384
10385fn exec_stream_value(event: &ExecStreamEvent) -> Result<serde_json::Value> {
10386    let mut value = serde_json::to_value(event)?;
10387    if let Some(object) = value.as_object_mut() {
10388        object.insert("schema_version".to_string(), serde_json::json!(1));
10389        object.insert(
10390            "schema".to_string(),
10391            serde_json::json!("codewhale.exec-stream"),
10392        );
10393    }
10394    Ok(value)
10395}
10396
10397fn tool_error_receipt_category(error: &crate::tools::spec::ToolError) -> &'static str {
10398    use crate::tools::spec::ToolError;
10399    match error {
10400        ToolError::InvalidInput { .. } => "invalid_input",
10401        ToolError::MissingField { .. } => "missing_field",
10402        ToolError::PathEscape { .. } => "path_escape",
10403        ToolError::ExecutionFailed { .. } => "execution_failed",
10404        ToolError::Timeout { .. } => "timeout",
10405        ToolError::Cancelled { .. } => "cancelled",
10406        ToolError::NotAvailable { .. } => "not_available",
10407        ToolError::PermissionDenied { .. } => "permission_denied",
10408    }
10409}
10410
10411fn tool_artifact_receipt(metadata: Option<&serde_json::Value>) -> Option<serde_json::Value> {
10412    let object = metadata?.as_object()?;
10413    let mut artifact = serde_json::Map::new();
10414    for key in [
10415        "artifact_id",
10416        "artifact_path",
10417        "artifact_relative_path",
10418        "artifact_byte_size",
10419        "spillover_path",
10420        "content_digest",
10421        "original_byte_count",
10422        "retained_head_bytes",
10423        "retained_tail_bytes",
10424    ] {
10425        if let Some(value) = object.get(key) {
10426            artifact.insert(key.to_string(), value.clone());
10427        }
10428    }
10429    (!artifact.is_empty()).then_some(serde_json::Value::Object(artifact))
10430}
10431
10432fn current_binary_sha256() -> Option<String> {
10433    let bytes = std::fs::read(std::env::current_exe().ok()?).ok()?;
10434    Some(format!("sha256:{}", crate::hashing::sha256_hex(&bytes)))
10435}
10436
10437async fn run_workflow_tool_command(
10438    cli: &Cli,
10439    args: WorkflowToolArgs,
10440    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10441) -> Result<()> {
10442    match run_workflow_tool_command_inner(cli, args, plugin_registry).await {
10443        Ok(()) => Ok(()),
10444        Err(error) => {
10445            let _ = emit_exec_stream_event(&ExecStreamEvent::Error {
10446                error: format!("{error:#}"),
10447            });
10448            exit_workflow_tool_failure();
10449        }
10450    }
10451}
10452
10453async fn run_workflow_tool_command_inner(
10454    cli: &Cli,
10455    args: WorkflowToolArgs,
10456    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10457) -> Result<()> {
10458    use crate::tools::spec::ToolSpec;
10459
10460    if args.approval_source != "explicit-workflow-command" {
10461        bail!("workflow-tool requires --approval-source explicit-workflow-command");
10462    }
10463    let input: serde_json::Value = serde_json::from_str(&args.input_json)
10464        .context("--input-json must be a valid Workflow tool input object")?;
10465    if !input.is_object() {
10466        bail!("--input-json must be a JSON object");
10467    }
10468    if !input
10469        .get("action")
10470        .and_then(serde_json::Value::as_str)
10471        .is_some_and(|action| action.eq_ignore_ascii_case("run"))
10472    {
10473        bail!("workflow-tool accepts only action=run");
10474    }
10475
10476    let workspace = resolve_workspace(cli);
10477    let mut config = load_config_from_cli(cli)?;
10478    merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
10479    if let Ok(env_url) =
10480        std::env::var("CODEWHALE_BASE_URL").or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
10481    {
10482        let trimmed = env_url.trim();
10483        if !trimmed.is_empty() {
10484            config.base_url = Some(trimmed.to_string());
10485        }
10486    }
10487
10488    let model = resolve_exec_model(&config, None);
10489    let route = resolve_cli_exec_route(
10490        &config,
10491        &model,
10492        "Run a checked-in Workflow through the host runtime",
10493        true,
10494    )
10495    .await?;
10496    let execution_config = config_for_cli_route(&config, &route);
10497    let route_identity = execution_config
10498        .active_provider_identity(route.provider)
10499        .map_err(anyhow::Error::msg)
10500        .context("workflow terminal route lost its exact provider identity")?;
10501    let (route_provider, route_provider_id) = exec_stream_provider_route(&route_identity);
10502    let workflow_input_sha256 = format!(
10503        "sha256:{}",
10504        crate::hashing::sha256_hex(&serde_json::to_vec(&input)?)
10505    );
10506    let tool_id = format!("workflow_host_{}", &uuid::Uuid::new_v4().to_string()[..8]);
10507    let tool_started = Instant::now();
10508    let tool_started_at = chrono::Utc::now().to_rfc3339();
10509
10510    emit_exec_stream_event(&ExecStreamEvent::ToolUse {
10511        name: "workflow".to_string(),
10512        id: tool_id.clone(),
10513        input: input.clone(),
10514        started_at: tool_started_at.clone(),
10515    })?;
10516
10517    let (event_tx, event_rx) = tokio::sync::mpsc::channel(1024);
10518    let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
10519    let event_forwarder = tokio::spawn(forward_direct_workflow_events(event_rx, stop_rx));
10520    let (tool, context) = match build_direct_workflow_tool(
10521        &execution_config,
10522        &route,
10523        &workspace,
10524        event_tx,
10525        plugin_registry,
10526    )
10527    .await
10528    {
10529        Ok(built) => built,
10530        Err(err) => {
10531            let _ = stop_tx.send(());
10532            let _ = event_forwarder.await;
10533            exit_workflow_tool_error(&tool_id, err.to_string());
10534        }
10535    };
10536
10537    let result = tool.execute(input, &context).await;
10538    drop(tool);
10539    let _ = stop_tx.send(());
10540    event_forwarder
10541        .await
10542        .context("workflow event forwarder task failed")??;
10543
10544    let result = match result {
10545        Ok(result) => result,
10546        Err(err) => {
10547            let error = err.to_string();
10548            exit_workflow_tool_error(&tool_id, error);
10549        }
10550    };
10551
10552    let workflow_status =
10553        direct_workflow_status(&result.content).unwrap_or_else(|| "unknown".to_string());
10554    let completed = result.success && workflow_status == "completed";
10555    emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10556        id: tool_id,
10557        name: "workflow".to_string(),
10558        output: result.content.clone(),
10559        status: if completed { "success" } else { "error" }.to_string(),
10560        started_at: tool_started_at,
10561        completed_at: chrono::Utc::now().to_rfc3339(),
10562        duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10563        side_effect_status: result
10564            .metadata
10565            .as_ref()
10566            .and_then(|metadata| metadata.get("side_effect_status"))
10567            .and_then(serde_json::Value::as_str)
10568            .unwrap_or("unknown")
10569            .to_string(),
10570        error_category: (!completed).then(|| "tool_error".to_string()),
10571        truncated: result
10572            .metadata
10573            .as_ref()
10574            .and_then(|metadata| metadata.get("truncated"))
10575            .and_then(serde_json::Value::as_bool),
10576        artifact: tool_artifact_receipt(result.metadata.as_ref()),
10577        result_metadata: result.metadata.clone(),
10578    })?;
10579    emit_exec_stream_event(&ExecStreamEvent::Metadata {
10580        meta: Box::new(ExecStreamMeta {
10581            receipt_kind: "terminal",
10582            provider: route_provider,
10583            provider_id: route_provider_id,
10584            // No parent/operator model call occurs on this host-owned path;
10585            // child model/provider usage remains attributable in typed task
10586            // receipts rather than being misreported as one root model.
10587            model: "host-workflow".to_string(),
10588            route_source: "host_workflow".to_string(),
10589            input_tokens: None,
10590            output_tokens: None,
10591            prompt_cache_hit_tokens: None,
10592            prompt_cache_miss_tokens: None,
10593            prompt_cache_write_tokens: None,
10594            reasoning_tokens: None,
10595            codewhale_max_output_tokens: None,
10596            codewhale_max_output_tokens_source: None,
10597            duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10598            retry_count: None,
10599            approval_posture: "explicit_workflow_command".to_string(),
10600            sandbox_posture: "configured".to_string(),
10601            binary_sha256: current_binary_sha256(),
10602            config_sha256: None,
10603            prompt_sha256: workflow_input_sha256,
10604            tool_catalog_sha256: None,
10605            input_analysis: ExecStreamInputAnalysis::default(),
10606            visible_final_answer_chars: result.content.chars().count(),
10607            session_id: String::new(),
10608            resume_command: String::new(),
10609            workspace: workspace.display().to_string(),
10610            message_count: 0,
10611            status: Some(workflow_status.clone()),
10612            termination_reason: Some(if completed { "resolved" } else { "tool_error" }.to_string()),
10613            error_category: (!completed).then(|| "tool".to_string()),
10614            error: (!completed)
10615                .then(|| format!("workflow run ended with terminal status {workflow_status}")),
10616        }),
10617    })?;
10618    if !completed {
10619        let error = format!("workflow run ended with terminal status {workflow_status}");
10620        emit_exec_stream_event(&ExecStreamEvent::Error {
10621            error: error.clone(),
10622        })?;
10623        exit_workflow_tool_failure();
10624    }
10625    emit_exec_stream_event(&ExecStreamEvent::Done)?;
10626    Ok(())
10627}
10628
10629fn exit_workflow_tool_failure() -> ! {
10630    let _ = io::stdout().flush();
10631    std::process::exit(1)
10632}
10633
10634fn exit_workflow_tool_error(tool_id: &str, error: String) -> ! {
10635    let now = chrono::Utc::now().to_rfc3339();
10636    let _ = emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10637        id: tool_id.to_string(),
10638        name: "workflow".to_string(),
10639        output: error.clone(),
10640        status: "error".to_string(),
10641        started_at: now.clone(),
10642        completed_at: now,
10643        duration_ms: 0,
10644        side_effect_status: "unknown".to_string(),
10645        error_category: Some("execution_failed".to_string()),
10646        truncated: None,
10647        artifact: None,
10648        result_metadata: None,
10649    });
10650    let _ = emit_exec_stream_event(&ExecStreamEvent::Error { error });
10651    exit_workflow_tool_failure()
10652}
10653
10654async fn initialize_direct_workflow_mcp_pool(
10655    config: &Config,
10656    workspace: &Path,
10657    network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
10658    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10659) -> Option<(
10660    std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
10661    Vec<(String, String)>,
10662)> {
10663    if !config.features().enabled(Feature::Mcp) {
10664        return None;
10665    }
10666    let mut pool = crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
10667        &config.mcp_config_path(),
10668        workspace,
10669        plugin_registry,
10670    )
10671    .unwrap_or_else(|error| {
10672        tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
10673        crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
10674    });
10675    if let Some(policy) = network_policy {
10676        pool = pool.with_network_policy(policy);
10677    }
10678    let failures = pool
10679        .connect_all()
10680        .await
10681        .into_iter()
10682        .map(|(server, error)| (server, format!("{error:#}")))
10683        .collect();
10684    Some((std::sync::Arc::new(tokio::sync::Mutex::new(pool)), failures))
10685}
10686
10687async fn build_direct_workflow_tool(
10688    config: &Config,
10689    route: &CliAutoRoute,
10690    workspace: &Path,
10691    event_tx: tokio::sync::mpsc::Sender<crate::core::events::Event>,
10692    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10693) -> Result<(
10694    crate::tools::workflow::WorkflowTool,
10695    crate::tools::ToolContext,
10696)> {
10697    use std::sync::Arc;
10698
10699    use crate::client::DeepSeekClient;
10700    use crate::core::authority::shell_policy_for_mode;
10701    use crate::fleet::roster::FleetRoster;
10702    use crate::tools::AgentToolSurfaceOptions;
10703    use crate::tools::goal::new_shared_goal_state;
10704    use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager_with_timeout};
10705    use crate::tools::todo::new_shared_todo_list;
10706    use crate::tui::app::AppMode;
10707
10708    let provider = config.api_provider();
10709    if !config.subagents_enabled_for_provider(provider) {
10710        bail!(
10711            "Workflow dispatch requires sub-agents for provider {} ({})",
10712            provider.as_str(),
10713            config
10714                .subagents_disabled_reason()
10715                .unwrap_or("provider-specific sub-agent configuration disabled it")
10716        );
10717    }
10718
10719    let yolo = config.yolo.unwrap_or(false);
10720    let mode = if yolo {
10721        AppMode::Yolo
10722    } else {
10723        AppMode::Operate
10724    };
10725    let allow_shell = yolo || config.allow_shell();
10726    let shell_policy = shell_policy_for_mode(mode, allow_shell);
10727    let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
10728    let mut context = crate::tools::ToolContext::with_auto_approve(
10729        workspace.to_path_buf(),
10730        yolo,
10731        config.notes_path(),
10732        config.mcp_config_path(),
10733        yolo,
10734    )
10735    .with_features(config.features())
10736    .with_skills_config(
10737        config.skills_dir(),
10738        config.skills_config().scan_codewhale_only(),
10739    )
10740    .with_plugin_registry(std::sync::Arc::clone(&plugin_registry))
10741    .with_shell_policy(shell_policy)
10742    .with_trusted_external_paths(trusted.paths().to_vec())
10743    .with_elevated_sandbox_policy(crate::core::authority::sandbox_policy_for_turn(
10744        mode,
10745        if yolo {
10746            crate::tui::approval::ApprovalMode::Bypass
10747        } else {
10748            crate::tui::approval::ApprovalMode::Suggest
10749        },
10750        config.sandbox_mode.as_deref(),
10751        workspace,
10752    ));
10753    let network_policy = config.network.clone().map(|network| {
10754        crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
10755    });
10756    if let Some(policy) = network_policy.as_ref() {
10757        context = context.with_network_policy(policy.clone());
10758    }
10759    if config.memory_enabled() {
10760        context.memory_path = Some(config.memory_path());
10761    }
10762    context.search_provider = config.search_provider();
10763    context.search_api_key = config
10764        .search
10765        .as_ref()
10766        .and_then(|search| search.api_key.clone());
10767    context.search_base_url = config
10768        .search
10769        .as_ref()
10770        .and_then(|search| search.base_url.clone());
10771    if let Some(backend) = crate::sandbox::backend::create_backend(config)? {
10772        context = context.with_sandbox_backend(Arc::from(backend));
10773    }
10774
10775    let max_subagents = config.max_subagents_for_provider(provider);
10776    let manager = new_shared_subagent_manager_with_timeout(
10777        workspace.to_path_buf(),
10778        max_subagents,
10779        config
10780            .max_admitted_subagents_for_provider(provider)
10781            .max(max_subagents),
10782        Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
10783        config.launch_concurrency_for_provider(provider),
10784        config.subagent_token_budget_for_provider(provider),
10785    );
10786    let roster = Arc::new(FleetRoster::load(&config.fleet_config(), workspace));
10787    let mut role_models = roster.model_overrides();
10788    role_models.extend(config.subagent_model_overrides());
10789
10790    let features = config.features();
10791    let mut surface = AgentToolSurfaceOptions::new(shell_policy);
10792    surface.apply_patch_enabled = features.enabled(Feature::ApplyPatch);
10793    surface.web_search_enabled = features.enabled(Feature::WebSearch);
10794    surface.memory_tool_enabled = config.memory_enabled();
10795    surface.vision_config = features
10796        .enabled(Feature::VisionModel)
10797        .then(|| config.vision_model_config())
10798        .flatten();
10799    surface.speech_output_dir = config.speech_output_dir();
10800    surface.goal_state = Some(new_shared_goal_state());
10801
10802    let client = DeepSeekClient::new(config)?;
10803    // A FIXED model with `reasoning_effort = auto` (the shape a Fleet worker
10804    // subprocess launches with: `--model <exact> --reasoning-effort auto`) is
10805    // still Auto. Deriving the auto flag from `route.auto_model` alone left it
10806    // raw AND non-auto: the runtime carried the literal string `"auto"` while
10807    // nothing was allowed to resolve it. Auto is a reasoning decision, not a
10808    // model decision — it does not require `--model auto`.
10809    let reasoning_effort_auto = route.auto_controls_reasoning;
10810    let reasoning_effort = route
10811        .reasoning_effort
10812        .and_then(|effort| cli_reasoning_effort_value(config, &route.model, effort));
10813    let mcp_pool = if let Some((pool, failures)) =
10814        initialize_direct_workflow_mcp_pool(config, workspace, network_policy, plugin_registry)
10815            .await
10816    {
10817        for (server, error) in failures {
10818            tracing::warn!(
10819                server = %server,
10820                error = %error,
10821                "direct Workflow runtime could not connect MCP server"
10822            );
10823        }
10824        Some(pool)
10825    } else {
10826        None
10827    };
10828    let runtime = SubAgentRuntime::new(
10829        client,
10830        route.model.clone(),
10831        context.clone(),
10832        allow_shell,
10833        Some(event_tx),
10834        manager.clone(),
10835    )
10836    .with_locale_tag(
10837        crate::localization::resolve_locale(
10838            &crate::settings::Settings::load_persisted()
10839                .unwrap_or_default()
10840                .locale,
10841        )
10842        .tag(),
10843    )
10844    .with_role_models(role_models)
10845    .with_api_config(config.clone())
10846    .with_fleet_roster(roster)
10847    .with_auto_model(route.auto_model)
10848    .with_reasoning_effort(reasoning_effort, reasoning_effort_auto)
10849    .with_agent_tool_surface_options(surface)
10850    .with_max_spawn_depth(config.subagent_max_spawn_depth_for_provider(provider))
10851    .with_step_api_timeout(Duration::from_secs(
10852        config.subagent_api_timeout_secs_for_provider(provider),
10853    ))
10854    .with_speech_output_dir(config.speech_output_dir())
10855    .with_mcp_pool(mcp_pool)
10856    .with_todos(new_shared_todo_list())
10857    .with_parent_mode(mode);
10858
10859    Ok((
10860        crate::tools::workflow::WorkflowTool::new(manager, runtime).with_explicit_cli_approval(),
10861        context,
10862    ))
10863}
10864
10865async fn forward_direct_workflow_events(
10866    mut event_rx: tokio::sync::mpsc::Receiver<crate::core::events::Event>,
10867    mut stop_rx: tokio::sync::oneshot::Receiver<()>,
10868) -> Result<()> {
10869    loop {
10870        tokio::select! {
10871            biased;
10872            event = event_rx.recv() => match event {
10873                Some(event) => emit_direct_workflow_event(event)?,
10874                None => return Ok(()),
10875            },
10876            _ = &mut stop_rx => {
10877                while let Ok(event) = event_rx.try_recv() {
10878                    emit_direct_workflow_event(event)?;
10879                }
10880                return Ok(());
10881            }
10882        }
10883    }
10884}
10885
10886fn emit_direct_workflow_event(event: crate::core::events::Event) -> Result<()> {
10887    if let crate::core::events::Event::WorkflowUi { run_id, event } = event {
10888        emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
10889    }
10890    Ok(())
10891}
10892
10893fn direct_workflow_status(content: &str) -> Option<String> {
10894    serde_json::from_str::<serde_json::Value>(content)
10895        .ok()?
10896        .get("status")?
10897        .as_str()
10898        .map(str::to_ascii_lowercase)
10899}
10900
10901fn exec_stream_input_analysis(
10902    messages: &[Message],
10903    system: Option<&SystemPrompt>,
10904) -> ExecStreamInputAnalysis {
10905    let mut analysis = ExecStreamInputAnalysis {
10906        estimated_request_tokens: crate::compaction::estimate_input_tokens_conservative(
10907            messages, system,
10908        ),
10909        estimated_message_content_tokens: crate::compaction::estimate_tokens(messages),
10910        estimated_system_tokens: exec_stream_estimate_system_tokens(system),
10911        estimated_framing_tokens: messages.len().saturating_mul(12).saturating_add(48),
10912        ..ExecStreamInputAnalysis::default()
10913    };
10914
10915    for message in messages {
10916        match message.role.as_str() {
10917            "user" => analysis.user_message_count += 1,
10918            "assistant" => analysis.assistant_message_count += 1,
10919            "tool" => analysis.tool_message_count += 1,
10920            _ => {}
10921        }
10922
10923        for block in &message.content {
10924            match block {
10925                ContentBlock::Text { text, .. } => {
10926                    exec_stream_add_text_estimate(
10927                        text,
10928                        &mut analysis.text_chars,
10929                        &mut analysis.text_estimated_tokens,
10930                    );
10931                }
10932                ContentBlock::Thinking { thinking, .. } => {
10933                    exec_stream_add_text_estimate(
10934                        thinking,
10935                        &mut analysis.thinking_chars,
10936                        &mut analysis.thinking_estimated_tokens,
10937                    );
10938                }
10939                ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
10940                    analysis.tool_use_count += 1;
10941                    exec_stream_add_json_estimate(
10942                        input,
10943                        &mut analysis.tool_use_input_chars,
10944                        &mut analysis.tool_use_input_estimated_tokens,
10945                    );
10946                }
10947                ContentBlock::ToolResult {
10948                    content,
10949                    content_blocks,
10950                    ..
10951                } => {
10952                    analysis.tool_result_count += 1;
10953                    exec_stream_add_text_estimate(
10954                        content,
10955                        &mut analysis.tool_result_chars,
10956                        &mut analysis.tool_result_estimated_tokens,
10957                    );
10958                    if let Some(blocks) = content_blocks {
10959                        exec_stream_add_json_estimate(
10960                            blocks,
10961                            &mut analysis.tool_result_chars,
10962                            &mut analysis.tool_result_estimated_tokens,
10963                        );
10964                    }
10965                }
10966                ContentBlock::ToolSearchToolResult { content, .. }
10967                | ContentBlock::CodeExecutionToolResult { content, .. } => {
10968                    analysis.tool_result_count += 1;
10969                    exec_stream_add_json_estimate(
10970                        content,
10971                        &mut analysis.tool_result_chars,
10972                        &mut analysis.tool_result_estimated_tokens,
10973                    );
10974                }
10975                ContentBlock::ImageUrl { .. } => {}
10976            }
10977        }
10978    }
10979
10980    analysis
10981}
10982
10983fn exec_stream_add_text_estimate(text: &str, chars: &mut usize, tokens: &mut usize) {
10984    *chars = chars.saturating_add(text.chars().count());
10985    *tokens = tokens.saturating_add(crate::compaction::estimate_text_tokens_conservative(text));
10986}
10987
10988fn exec_stream_add_json_estimate<T: serde::Serialize>(
10989    value: &T,
10990    chars: &mut usize,
10991    tokens: &mut usize,
10992) {
10993    let text = serde_json::to_string(value).unwrap_or_default();
10994    exec_stream_add_text_estimate(&text, chars, tokens);
10995}
10996
10997fn exec_stream_estimate_system_tokens(system: Option<&SystemPrompt>) -> usize {
10998    match system {
10999        Some(SystemPrompt::Text(text)) => {
11000            crate::compaction::estimate_text_tokens_conservative(text)
11001        }
11002        Some(SystemPrompt::Blocks(blocks)) => blocks
11003            .iter()
11004            .map(|block| crate::compaction::estimate_text_tokens_conservative(&block.text))
11005            .sum(),
11006        None => 0,
11007    }
11008}
11009
11010fn exec_saved_session_line(session_id: &str) -> String {
11011    format!("session: {}", truncate_id(session_id))
11012}
11013
11014fn exec_resumed_session_line(session_id: &str) -> String {
11015    format!("resumed session: {}", truncate_id(session_id))
11016}
11017
11018fn exec_stream_session_ref(session_id: &str) -> String {
11019    crate::utils::redacted_identifier_for_log(session_id)
11020}
11021
11022fn exec_stream_resume_hint(session_id: &str) -> String {
11023    if session_id.trim().is_empty() {
11024        String::new()
11025    } else {
11026        "codewhale exec --resume <redacted-session-id>".to_string()
11027    }
11028}
11029
11030#[derive(Clone, Copy)]
11031struct PersistedProviderRoute<'a> {
11032    kind: &'a str,
11033    id: Option<&'a str>,
11034}
11035
11036fn persist_exec_session(
11037    messages: &[Message],
11038    model: &str,
11039    provider_route: PersistedProviderRoute<'_>,
11040    workspace: &Path,
11041    system_prompt: &Option<SystemPrompt>,
11042    session_id: Option<&str>,
11043    total_tokens: u64,
11044) -> Result<String> {
11045    let manager =
11046        SessionManager::default_location().context("could not open session manager for save")?;
11047    let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) {
11048        match manager.load_session(id) {
11049            Ok(existing) => session_manager::update_session(
11050                existing,
11051                messages,
11052                total_tokens,
11053                system_prompt.as_ref(),
11054            ),
11055            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
11056                session_manager::create_saved_session_with_id_and_mode(
11057                    id.to_string(),
11058                    messages,
11059                    model,
11060                    workspace,
11061                    total_tokens,
11062                    system_prompt.as_ref(),
11063                    Some("exec"),
11064                )
11065            }
11066            Err(err) => return Err(err).context("could not load existing exec session"),
11067        }
11068    } else {
11069        session_manager::create_saved_session_with_mode(
11070            messages,
11071            model,
11072            workspace,
11073            total_tokens,
11074            system_prompt.as_ref(),
11075            Some("exec"),
11076        )
11077    };
11078    stamp_exec_session_metadata(
11079        &mut saved,
11080        model,
11081        provider_route.kind,
11082        provider_route.id,
11083        workspace,
11084    );
11085    let id = saved.metadata.id.clone();
11086    manager
11087        .save_session(&saved)
11088        .context("could not save exec session")?;
11089    Ok(id)
11090}
11091
11092fn stamp_exec_session_metadata(
11093    saved: &mut session_manager::SavedSession,
11094    model: &str,
11095    model_provider_kind: &str,
11096    model_provider_id: Option<&str>,
11097    workspace: &Path,
11098) {
11099    saved.metadata.model = model.to_string();
11100    saved
11101        .metadata
11102        .set_model_provider_route(model_provider_kind, model_provider_id);
11103    saved.metadata.workspace = workspace.to_path_buf();
11104    saved.metadata.mode = Some("exec".to_string());
11105}
11106
11107#[derive(serde::Serialize)]
11108struct ExecToolEntry {
11109    name: String,
11110    success: bool,
11111    output: String,
11112}
11113
11114#[derive(serde::Serialize)]
11115struct ExecOutcome {
11116    kind: String,
11117    outcome: String,
11118    tool_name: String,
11119    reason: String,
11120}
11121
11122#[derive(serde::Serialize, Default)]
11123struct ExecSummary {
11124    mode: String,
11125    provider: String,
11126    model: String,
11127    prompt: String,
11128    output: String,
11129    tools: Vec<ExecToolEntry>,
11130    outcomes: Vec<ExecOutcome>,
11131    status: Option<String>,
11132    termination_reason: Option<String>,
11133    error_category: Option<String>,
11134    error: Option<String>,
11135    #[serde(skip_serializing_if = "Vec::is_empty")]
11136    released_services: Vec<crate::tools::shell::PersistentServiceReceipt>,
11137}
11138
11139fn validate_exec_tool_authority_resume(
11140    tool_authority_json: Option<&str>,
11141    resuming: bool,
11142) -> Result<()> {
11143    if tool_authority_json.is_some() && resuming {
11144        bail!(
11145            "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue"
11146        );
11147    }
11148    Ok(())
11149}
11150
11151fn exec_network_policy(
11152    config: &Config,
11153    outer_network_access: Option<bool>,
11154) -> Option<crate::network_policy::NetworkPolicyDecider> {
11155    // Fleet caps are an outer authority boundary: user configuration may
11156    // narrow them further, but it may never widen an explicit network denial.
11157    if outer_network_access == Some(false) {
11158        return Some(crate::network_policy::NetworkPolicyDecider::new(
11159            crate::network_policy::NetworkPolicy {
11160                default: crate::network_policy::DecisionToml::Deny,
11161                ..crate::network_policy::NetworkPolicy::default()
11162            },
11163            None,
11164        ));
11165    }
11166    config.network.clone().map(|toml_cfg| {
11167        crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
11168    })
11169}
11170
11171fn apply_fleet_engine_feature_caps(
11172    features: &mut crate::features::Features,
11173    fleet_authority_active: bool,
11174    outer_network_access: Option<bool>,
11175    shell_authority: crate::tools::spec::ToolShellAuthority,
11176) {
11177    if fleet_authority_active {
11178        features.disable(crate::features::Feature::Subagents);
11179        features.disable(crate::features::Feature::Mcp);
11180        if shell_authority != crate::tools::spec::ToolShellAuthority::ReadOnly {
11181            features.disable(crate::features::Feature::ShellTool);
11182        }
11183    }
11184    if outer_network_access == Some(false) {
11185        features.disable(crate::features::Feature::WebSearch);
11186    }
11187}
11188
11189/// Resolve the optional headless safety budget without imposing a hidden
11190/// default. Benchmarks and other long-running exec callers continue until the
11191/// model finishes unless they opt into a finite `--max-turns` value.
11192fn exec_max_steps(max_turns: Option<u32>) -> u32 {
11193    max_turns.unwrap_or(u32::MAX)
11194}
11195
11196#[allow(clippy::too_many_arguments)]
11197async fn run_exec_agent(
11198    config: &Config,
11199    model: &str,
11200    prompt: &str,
11201    workspace: PathBuf,
11202    max_subagents: usize,
11203    auto_approve: bool,
11204    allow_sandbox_elevation: bool,
11205    explicit_sandbox: Option<&str>,
11206    trust_mode: bool,
11207    json_output: bool,
11208    resume_session: Option<session_manager::SavedSession>,
11209    force_configured_route: bool,
11210    output_format: ExecOutputFormat,
11211    max_turns: u32,
11212    allowed_tools: Option<Vec<String>>,
11213    disallowed_tools: Option<Vec<String>>,
11214    append_system_prompt: Option<String>,
11215    tool_authority_json: Option<String>,
11216    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
11217) -> Result<()> {
11218    use crate::compaction::CompactionConfig;
11219    use crate::core::engine::{EngineConfig, spawn_engine};
11220    use crate::core::events::Event;
11221    use crate::core::ops::Op;
11222    use crate::tools::plan::new_shared_plan_state;
11223    use crate::tools::todo::new_shared_todo_list;
11224    use crate::tui::app::AppMode;
11225
11226    validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
11227    let fleet_authority = tool_authority_json
11228        .as_deref()
11229        .map(crate::tools::spec::ToolAuthorityEnvelope::from_json)
11230        .transpose()
11231        .map_err(anyhow::Error::msg)?;
11232    let fleet_authority_active = fleet_authority.is_some();
11233    let outer_network_access = fleet_authority
11234        .as_ref()
11235        .and_then(|authority| authority.network_access);
11236    let outer_shell_authority = fleet_authority
11237        .as_ref()
11238        .map(|authority| authority.shell)
11239        .unwrap_or_default();
11240    if let Some(envelope) = fleet_authority {
11241        crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?;
11242    }
11243
11244    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
11245    let execution_config = config_for_cli_route(config, &route);
11246    let auto_model = route.auto_model;
11247    let effective_provider = route.provider;
11248    let effective_model = route.model;
11249    let validated_route = crate::route_runtime::resolve_runtime_route(
11250        &execution_config,
11251        effective_provider,
11252        Some(&effective_model),
11253    )
11254    .map_err(anyhow::Error::msg)?
11255    .validate()
11256    .map_err(anyhow::Error::msg)?;
11257    let effective_provider_name = validated_route.identity.key.clone();
11258    let effective_provider_id = validated_route.identity.exact_id.clone();
11259    let (effective_provider_kind, effective_stream_provider_id) =
11260        exec_stream_provider_route(&validated_route.identity);
11261    let route_source = if auto_model {
11262        "auto_resolver"
11263    } else {
11264        "explicit_or_configured"
11265    }
11266    .to_string();
11267    let exec_started = Instant::now();
11268    let prompt_sha256 = format!("sha256:{}", crate::hashing::sha256_hex(prompt.as_bytes()));
11269    let binary_sha256 = current_binary_sha256();
11270    let approval_posture = if auto_approve { "auto_tools" } else { "ask" }.to_string();
11271    let sandbox_posture = explicit_sandbox.unwrap_or("configured_default").to_string();
11272    let active_route_limits =
11273        crate::route_budget::known_route_limits(validated_route.candidate.limits());
11274    let max_subagents = if max_subagents == config.max_subagents_for_provider(config.api_provider())
11275    {
11276        execution_config
11277            .max_subagents_for_provider(effective_provider)
11278            .clamp(1, MAX_SUBAGENTS)
11279    } else {
11280        max_subagents
11281    };
11282    // A FIXED model with `--reasoning-effort auto` (the exact shape a Fleet
11283    // worker subprocess launches with: `--model <exact> --reasoning-effort
11284    // auto`) is still Auto. `auto_model` is a *model* decision and is false
11285    // here, so deriving the auto flag from it left this path both raw and
11286    // non-auto: the literal string `"auto"` travelled to the engine while the
11287    // receipt claimed no Auto was in play.
11288    let reasoning_effort_auto = route.auto_controls_reasoning;
11289    // Resolve Auto against this run's prompt at the CLI boundary, exactly like
11290    // `run_one_shot`/`run_one_shot_json` and the interactive launch path do,
11291    // so the tier the engine (and the receipt below) sees is concrete.
11292    let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| {
11293        cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt)
11294    });
11295
11296    let settings = crate::settings::Settings::load().unwrap_or_default();
11297    let auto_compact_enabled = if crate::settings::Settings::auto_compact_explicitly_configured() {
11298        settings.auto_compact
11299    } else {
11300        crate::route_budget::auto_compact_default_for_route(
11301            effective_provider,
11302            &effective_model,
11303            active_route_limits,
11304        )
11305    };
11306    let compaction = CompactionConfig {
11307        enabled: auto_compact_enabled,
11308        model: effective_model.clone(),
11309        effective_context_window: Some(crate::route_budget::route_context_window_tokens(
11310            effective_provider,
11311            &effective_model,
11312            active_route_limits,
11313        )),
11314        token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
11315            effective_provider,
11316            &effective_model,
11317            active_route_limits,
11318            settings.auto_compact_threshold_percent,
11319        ),
11320        ..Default::default()
11321    };
11322
11323    let network_policy = exec_network_policy(&execution_config, outer_network_access);
11324
11325    let lsp_config = (!fleet_authority_active)
11326        .then(|| {
11327            execution_config
11328                .lsp
11329                .clone()
11330                .map(crate::config::LspConfigToml::into_runtime)
11331        })
11332        .flatten();
11333    let mut engine_features = execution_config.features();
11334    apply_fleet_engine_feature_caps(
11335        &mut engine_features,
11336        fleet_authority_active,
11337        outer_network_access,
11338        outer_shell_authority,
11339    );
11340    if crate::core::allowlist_is_native_file_and_shell_only(allowed_tools.as_deref()) {
11341        engine_features.disable(crate::features::Feature::Mcp);
11342    }
11343    let engine_plugin_registry = if fleet_authority_active {
11344        std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
11345    } else {
11346        plugin_registry
11347    };
11348    let exec_allow_shell = crate::tools::spec::fleet_exec_shell_enabled(
11349        fleet_authority_active,
11350        outer_shell_authority,
11351        disallowed_tools.as_deref(),
11352    ) || (!fleet_authority_active
11353        && (auto_approve || execution_config.allow_shell()));
11354    let persist_services_enabled = cfg!(unix)
11355        && !fleet_authority_active
11356        && exec_allow_shell
11357        && explicit_sandbox
11358            .is_some_and(|sandbox| sandbox.eq_ignore_ascii_case("danger-full-access"));
11359    let exec_shell_manager = crate::tools::shell::new_shared_shell_manager(workspace.clone());
11360    let runtime_services = crate::tools::spec::RuntimeToolServices {
11361        shell_manager: Some(exec_shell_manager.clone()),
11362        persist_services_enabled,
11363        ..crate::tools::spec::RuntimeToolServices::default()
11364    };
11365
11366    let engine_config = EngineConfig {
11367        model: effective_model.clone(),
11368        active_route_limits,
11369        workspace: workspace.clone(),
11370        subagent_state_root: None,
11371        plugin_registry: Some(engine_plugin_registry),
11372        allow_shell: exec_allow_shell,
11373        trust_mode,
11374        notes_path: execution_config.notes_path(),
11375        mcp_config_path: execution_config.mcp_config_path(),
11376        skills_dir: execution_config.skills_dir(),
11377        skills_scan_codewhale_only: execution_config.skills_config().scan_codewhale_only(),
11378        instructions: {
11379            let mut instrs: Vec<crate::prompts::InstructionSource> = execution_config
11380                .instructions_paths()
11381                .into_iter()
11382                .map(Into::into)
11383                .collect();
11384            if let Some(ref extra) = append_system_prompt {
11385                instrs.push(crate::prompts::InstructionSource::Inline {
11386                    name: "cli:append-system-prompt".into(),
11387                    content: extra.clone(),
11388                });
11389            }
11390            instrs
11391        },
11392        project_context_pack_enabled: execution_config.project_context_pack_enabled(),
11393        translation_enabled: false,
11394        max_steps: max_turns,
11395        max_subagents,
11396        max_admitted_subagents: execution_config
11397            .max_admitted_subagents_for_provider(effective_provider)
11398            .max(max_subagents),
11399        launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider),
11400        subagents_enabled: !fleet_authority_active
11401            && execution_config.subagents_enabled_for_provider(effective_provider),
11402        features: engine_features,
11403        auto_review_policy: execution_config.auto_review_policy(),
11404        compaction: compaction.clone(),
11405        todos: new_shared_todo_list(),
11406        plan_state: new_shared_plan_state(),
11407        goal_state: crate::tools::goal::new_shared_goal_state(),
11408        max_spawn_depth: if fleet_authority_active {
11409            0
11410        } else {
11411            execution_config.subagent_max_spawn_depth_for_provider(effective_provider)
11412        },
11413        subagent_token_budget: execution_config
11414            .subagent_token_budget_for_provider(effective_provider),
11415        network_policy,
11416        snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled,
11417        snapshots_max_workspace_bytes: execution_config
11418            .snapshots_config()
11419            .max_workspace_gb
11420            .saturating_mul(1024 * 1024 * 1024),
11421        lsp_config,
11422        runtime_services,
11423        subagent_model_overrides: execution_config.subagent_model_overrides(),
11424        fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
11425            &execution_config.fleet_config(),
11426            &workspace,
11427        )),
11428        subagent_api_timeout: std::time::Duration::from_secs(
11429            execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
11430        ),
11431        stream_chunk_timeout: std::time::Duration::from_secs(
11432            execution_config.stream_chunk_timeout_secs(),
11433        ),
11434        subagent_heartbeat_timeout: std::time::Duration::from_secs(
11435            execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
11436        ),
11437        prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
11438        memory_enabled: execution_config.memory_enabled(),
11439        memory_path: execution_config.memory_path(),
11440        speech_output_dir: execution_config.speech_output_dir(),
11441        vision_config: execution_config.vision_model_config(),
11442        strict_tool_mode: execution_config.strict_tool_mode.unwrap_or(false),
11443        goal_objective: None,
11444        goal_token_budget: None,
11445        goal_status: crate::tools::goal::GoalStatus::Active,
11446        goal_max_continuations: execution_config.goal_max_continuations(),
11447        allowed_tools: allowed_tools.clone(),
11448        disallowed_tools: disallowed_tools.clone(),
11449        max_tool_calls: None,
11450        hook_executor: None,
11451        locale_tag: crate::localization::resolve_locale(&settings.locale)
11452            .tag()
11453            .to_string(),
11454        workshop: {
11455            crate::tools::large_output_router::WorkshopConfig::install_active(
11456                config.workshop.as_ref(),
11457            );
11458            config.workshop.clone()
11459        },
11460        search_provider: execution_config.search_provider(),
11461        search_api_key: execution_config
11462            .search
11463            .as_ref()
11464            .and_then(|s| s.api_key.clone()),
11465        search_base_url: execution_config
11466            .search
11467            .as_ref()
11468            .and_then(|s| s.base_url.clone()),
11469        tools_always_load: if fleet_authority_active {
11470            std::collections::HashSet::new()
11471        } else {
11472            execution_config.tools_always_load()
11473        },
11474        tools: if fleet_authority_active {
11475            None
11476        } else {
11477            execution_config.tools.clone()
11478        },
11479        verbosity: execution_config.verbosity.clone(),
11480        workspace_follow_symlinks: settings.workspace_follow_symlinks,
11481        exec_policy_engine: execution_config.exec_policy_engine.clone(),
11482        terminal_chrome_enabled: false,
11483        advisor_config: execution_config
11484            .advisor
11485            .as_ref()
11486            .map(crate::tools::subagent::AdvisorConfig::from_toml)
11487            .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
11488    };
11489
11490    let engine_handle = spawn_engine(engine_config, &execution_config);
11491    let mode = if auto_approve {
11492        AppMode::Yolo
11493    } else {
11494        AppMode::Agent
11495    };
11496
11497    let resuming_session = resume_session.is_some();
11498    let mut loaded_session_id = None;
11499    if let Some(saved) = resume_session {
11500        let saved_id = saved.metadata.id.clone();
11501        if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text {
11502            eprintln!(
11503                "Warning: session {} was created in a different workspace ({}). Resuming anyway.",
11504                truncate_id(&saved_id),
11505                saved.metadata.workspace.display(),
11506            );
11507        }
11508
11509        engine_handle
11510            .send(Op::SyncSession {
11511                session_id: Some(saved_id.clone()),
11512                messages: saved.messages,
11513                system_prompt: saved.system_prompt.map(SystemPrompt::Text),
11514                system_prompt_override: false,
11515                model: saved.metadata.model,
11516                workspace: saved.metadata.workspace,
11517                mode,
11518            })
11519            .await?;
11520        loaded_session_id = Some(saved_id.clone());
11521        if output_format == ExecOutputFormat::Text && !json_output {
11522            eprintln!("{}", exec_resumed_session_line(&saved_id));
11523        }
11524    }
11525
11526    engine_handle
11527        .send(Op::SendMessage {
11528            content: prompt.to_string(),
11529            mode,
11530            route: Box::new(validated_route.into_resolved()),
11531            compaction: Box::new(compaction.clone()),
11532            goal_objective: None,
11533            goal_token_budget: None,
11534            goal_status: crate::tools::goal::GoalStatus::Active,
11535            allowed_tools: allowed_tools.clone(),
11536            dynamic_tools: Vec::new(),
11537            hook_executor: None,
11538            reasoning_effort: effective_reasoning_effort,
11539            reasoning_effort_auto,
11540            auto_model,
11541            allow_shell: auto_approve || execution_config.allow_shell(),
11542            trust_mode,
11543            auto_approve,
11544            translation_enabled: false,
11545            approval_mode: if auto_approve {
11546                crate::tui::approval::ApprovalMode::Bypass
11547            } else {
11548                execution_config
11549                    .approval_policy
11550                    .as_deref()
11551                    .and_then(crate::tui::approval::ApprovalMode::from_config_value)
11552                    .unwrap_or_default()
11553            },
11554            verbosity: execution_config.verbosity.clone(),
11555            provenance: crate::core::ops::UserInputProvenance::ExternalUser,
11556        })
11557        .await?;
11558
11559    let mut summary = ExecSummary {
11560        mode: "agent".to_string(),
11561        provider: effective_provider_name.clone(),
11562        model: effective_model.clone(),
11563        prompt: prompt.to_string(),
11564        ..ExecSummary::default()
11565    };
11566    let can_elevate_sandbox =
11567        exec_sandbox_elevation_authorized(allow_sandbox_elevation, explicit_sandbox);
11568    let mut sandbox_denied = false;
11569    let mut approval_required = false;
11570    let mut tool_error_seen = false;
11571    let mut last_error_category = None;
11572    let mut reported_sandbox_contract = false;
11573
11574    let should_persist_session = resuming_session || output_format == ExecOutputFormat::StreamJson;
11575    let mut latest_session_id = loaded_session_id;
11576    let mut latest_messages: Vec<Message> = Vec::new();
11577    let mut latest_system_prompt: Option<SystemPrompt> = None;
11578    let mut latest_model = effective_model;
11579    let mut latest_workspace = workspace.clone();
11580    let mut tool_starts: HashMap<String, (Instant, String)> = HashMap::new();
11581    let mut turn_usage_seq: u32 = 0;
11582
11583    let mut stdout = io::stdout();
11584    let mut ends_with_newline = false;
11585    loop {
11586        let event = {
11587            let mut rx = engine_handle.rx_event.write().await;
11588            rx.recv().await
11589        };
11590
11591        let Some(event) = event else {
11592            break;
11593        };
11594
11595        match event {
11596            Event::MessageDelta { content, .. } => {
11597                summary.output.push_str(&content);
11598                if output_format == ExecOutputFormat::StreamJson {
11599                    emit_exec_stream_event(&ExecStreamEvent::Content { content })?;
11600                } else if !json_output {
11601                    print!("{content}");
11602                    stdout.flush()?;
11603                }
11604                ends_with_newline = summary.output.ends_with('\n');
11605            }
11606            Event::MessageComplete { .. }
11607                if output_format == ExecOutputFormat::Text
11608                    && !json_output
11609                    && !ends_with_newline =>
11610            {
11611                println!();
11612            }
11613            Event::ThinkingDelta { .. } => {
11614                // Exec stream-json intentionally omits reasoning deltas; the
11615                // TUI transcript retains its existing Activity Detail surface.
11616            }
11617            Event::ToolCallStarted { id, name, input } => {
11618                let started_at = chrono::Utc::now().to_rfc3339();
11619                tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
11620                if output_format == ExecOutputFormat::StreamJson {
11621                    emit_exec_stream_event(&ExecStreamEvent::ToolUse {
11622                        name,
11623                        id,
11624                        input,
11625                        started_at,
11626                    })?;
11627                } else if !json_output {
11628                    let summary = summarize_tool_args(&input);
11629                    if let Some(summary) = summary {
11630                        eprintln!("tool: {name} ({summary})");
11631                    } else {
11632                        eprintln!("tool: {name}");
11633                    }
11634                }
11635            }
11636            Event::ToolCallComplete {
11637                id, name, result, ..
11638            } => {
11639                let (duration_ms, started_at) = tool_starts
11640                    .remove(&id)
11641                    .map(|(started, timestamp)| {
11642                        (
11643                            u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
11644                            timestamp,
11645                        )
11646                    })
11647                    .unwrap_or_else(|| (0, chrono::Utc::now().to_rfc3339()));
11648                let receipt_name = name.clone();
11649                match result {
11650                    Ok(output) => {
11651                        tool_error_seen |= !output.success;
11652                        summary.tools.push(ExecToolEntry {
11653                            name: name.clone(),
11654                            success: output.success,
11655                            output: output.content.clone(),
11656                        });
11657                        if output_format == ExecOutputFormat::StreamJson {
11658                            emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11659                                id,
11660                                name: receipt_name,
11661                                output: output.content,
11662                                status: if output.success {
11663                                    "success".to_string()
11664                                } else {
11665                                    "error".to_string()
11666                                },
11667                                started_at,
11668                                completed_at: chrono::Utc::now().to_rfc3339(),
11669                                duration_ms,
11670                                side_effect_status: output
11671                                    .metadata
11672                                    .as_ref()
11673                                    .and_then(|metadata| metadata.get("side_effect_status"))
11674                                    .and_then(serde_json::Value::as_str)
11675                                    .unwrap_or("unknown")
11676                                    .to_string(),
11677                                error_category: (!output.success).then(|| {
11678                                    output
11679                                        .metadata
11680                                        .as_ref()
11681                                        .and_then(|metadata| metadata.get("error_category"))
11682                                        .and_then(serde_json::Value::as_str)
11683                                        .unwrap_or("tool_reported_failure")
11684                                        .to_string()
11685                                }),
11686                                truncated: output
11687                                    .metadata
11688                                    .as_ref()
11689                                    .and_then(|metadata| metadata.get("truncated"))
11690                                    .and_then(serde_json::Value::as_bool),
11691                                artifact: tool_artifact_receipt(output.metadata.as_ref()),
11692                                result_metadata: output.metadata,
11693                            })?;
11694                        } else if !json_output {
11695                            if name == "exec_shell" && !output.content.trim().is_empty() {
11696                                eprintln!("tool {name} completed");
11697                                eprintln!(
11698                                    "--- stdout/stderr ---\n{}\n---------------------",
11699                                    output.content
11700                                );
11701                            } else {
11702                                eprintln!(
11703                                    "tool {name} completed: {}",
11704                                    summarize_tool_output(&output.content)
11705                                );
11706                            }
11707                        }
11708                    }
11709                    Err(err) => {
11710                        tool_error_seen = true;
11711                        let error_text = err.to_string();
11712                        summary.tools.push(ExecToolEntry {
11713                            name: name.clone(),
11714                            success: false,
11715                            output: error_text.clone(),
11716                        });
11717                        if output_format == ExecOutputFormat::StreamJson {
11718                            emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11719                                id,
11720                                name: receipt_name,
11721                                output: error_text,
11722                                status: "error".to_string(),
11723                                started_at,
11724                                completed_at: chrono::Utc::now().to_rfc3339(),
11725                                duration_ms,
11726                                side_effect_status: "not_started_or_unknown".to_string(),
11727                                error_category: Some(tool_error_receipt_category(&err).to_string()),
11728                                truncated: None,
11729                                artifact: None,
11730                                result_metadata: None,
11731                            })?;
11732                        } else if !json_output {
11733                            eprintln!("tool {name} failed: {err}");
11734                        }
11735                    }
11736                }
11737            }
11738            Event::AgentSpawned { id, prompt, .. }
11739                if output_format == ExecOutputFormat::Text && !json_output =>
11740            {
11741                eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
11742            }
11743            Event::AgentProgress { id, status, .. }
11744                if output_format == ExecOutputFormat::Text && !json_output =>
11745            {
11746                eprintln!("sub-agent {id}: {status}");
11747            }
11748            Event::AgentComplete { id, result }
11749                if output_format == ExecOutputFormat::Text && !json_output =>
11750            {
11751                eprintln!(
11752                    "sub-agent {id} completed: {}",
11753                    summarize_tool_output(&result)
11754                );
11755            }
11756            Event::AgentSpawned {
11757                id,
11758                parent_run_id,
11759                spawn_depth,
11760                model,
11761                route_source,
11762                ..
11763            } if output_format == ExecOutputFormat::StreamJson => {
11764                emit_exec_stream_event(&ExecStreamEvent::AgentSpawned {
11765                    id,
11766                    model,
11767                    spawn_depth,
11768                    parent_run_id,
11769                    route_source,
11770                })?;
11771            }
11772            Event::AgentSpawned { .. }
11773            | Event::AgentProgress { .. }
11774            | Event::AgentComplete { .. } => {}
11775            Event::WorkflowUi { run_id, event }
11776                if output_format == ExecOutputFormat::StreamJson =>
11777            {
11778                emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11779            }
11780            Event::ApprovalRequired { id, .. } => {
11781                if auto_approve {
11782                    let _ = engine_handle.approve_tool_call(id).await;
11783                } else {
11784                    approval_required = true;
11785                    let _ = engine_handle.deny_tool_call(id).await;
11786                }
11787            }
11788            Event::ElevationRequired {
11789                tool_id,
11790                tool_name,
11791                denial_reason,
11792                ..
11793            } => {
11794                if can_elevate_sandbox {
11795                    let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
11796                    let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
11797                } else {
11798                    sandbox_denied = true;
11799                    approval_required = true;
11800                    summary.outcomes.push(ExecOutcome {
11801                        kind: "sandbox_denied".to_string(),
11802                        outcome: "approval_required".to_string(),
11803                        tool_name: tool_name.clone(),
11804                        reason: denial_reason.clone(),
11805                    });
11806                    if !reported_sandbox_contract {
11807                        eprintln!(
11808                            "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"
11809                        );
11810                        reported_sandbox_contract = true;
11811                    }
11812                    if output_format == ExecOutputFormat::StreamJson {
11813                        emit_exec_stream_event(&ExecStreamEvent::SandboxDenied {
11814                            tool_id: tool_id.clone(),
11815                            tool_name,
11816                            reason: denial_reason,
11817                            outcome: "approval_required".to_string(),
11818                        })?;
11819                    }
11820                    let _ = engine_handle.deny_tool_call(tool_id).await;
11821                }
11822            }
11823            Event::Error {
11824                envelope,
11825                recoverable: _,
11826            } => {
11827                // Only a non-recoverable envelope may force the run summary
11828                // into failure. Recoverable warnings (stream-stall notices,
11829                // transient retry noise) are still streamed for visibility,
11830                // but the terminal TurnComplete event carries the
11831                // authoritative turn outcome — letting a warning set
11832                // `summary.error` here would exit an otherwise-successful
11833                // `exec` run non-zero.
11834                if exec_error_event_is_fatal(&envelope) {
11835                    last_error_category = Some(envelope.category);
11836                    summary.error_category = Some(envelope.category.to_string());
11837                    summary.error = Some(envelope.message.clone());
11838                }
11839                if output_format == ExecOutputFormat::StreamJson {
11840                    emit_exec_stream_event(&ExecStreamEvent::Error {
11841                        error: envelope.message,
11842                    })?;
11843                } else if !json_output {
11844                    eprintln!("error: {}", envelope.message);
11845                }
11846            }
11847            Event::TurnUsage {
11848                usage, duration_ms, ..
11849            } => {
11850                if output_format == ExecOutputFormat::StreamJson {
11851                    turn_usage_seq = turn_usage_seq.saturating_add(1);
11852                    emit_exec_stream_event(&ExecStreamEvent::TurnUsage {
11853                        turn: turn_usage_seq,
11854                        input_tokens: usage.input_tokens,
11855                        output_tokens: usage.output_tokens,
11856                        reasoning_tokens: usage.reasoning_tokens,
11857                        prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11858                        prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11859                        prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11860                        reasoning_replay_tokens: usage.reasoning_replay_tokens,
11861                        duration_ms,
11862                    })?;
11863                }
11864            }
11865            Event::TurnComplete {
11866                status,
11867                error,
11868                usage,
11869                tool_catalog,
11870                ..
11871            } => {
11872                let (terminal_status, terminal_error) = (status, error);
11873                #[cfg(unix)]
11874                let (mut terminal_status, mut terminal_error) = (terminal_status, terminal_error);
11875                if matches!(
11876                    terminal_status,
11877                    crate::core::events::TurnOutcomeStatus::Completed
11878                ) && terminal_error.is_none()
11879                {
11880                    #[cfg(unix)]
11881                    match exec_shell_manager.lock() {
11882                        Ok(mut manager) => match manager.commit_persistent_services() {
11883                            Ok(receipts) => {
11884                                for receipt in &receipts {
11885                                    if output_format == ExecOutputFormat::StreamJson {
11886                                        emit_exec_stream_event(
11887                                            &ExecStreamEvent::ServiceReleased {
11888                                                task_id: receipt.task_id.clone(),
11889                                                pid: receipt.pid,
11890                                                process_group_id: receipt.process_group_id,
11891                                                ownership: receipt.ownership.clone(),
11892                                            },
11893                                        )?;
11894                                    } else if !json_output {
11895                                        eprintln!(
11896                                            "persistent service released: {} pid={} pgid={} ownership={}",
11897                                            receipt.task_id,
11898                                            receipt.pid,
11899                                            receipt.process_group_id,
11900                                            receipt.ownership
11901                                        );
11902                                    }
11903                                }
11904                                summary.released_services.extend(receipts);
11905                            }
11906                            Err(error) => {
11907                                manager.abort_persistent_services();
11908                                terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11909                                terminal_error = Some(format!(
11910                                    "Persistent service ownership transfer failed: {error}"
11911                                ));
11912                            }
11913                        },
11914                        Err(_) => {
11915                            terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11916                            terminal_error = Some(
11917                                "Persistent service ownership transfer failed: shell manager lock poisoned"
11918                                    .to_string(),
11919                            );
11920                        }
11921                    }
11922                } else if let Ok(mut manager) = exec_shell_manager.lock() {
11923                    manager.abort_persistent_services();
11924                }
11925                summary.status = Some(format!("{terminal_status:?}").to_lowercase());
11926                if terminal_error.is_some() {
11927                    summary.error = terminal_error;
11928                }
11929                if sandbox_denied
11930                    && summary.error.is_none()
11931                    && matches!(
11932                        terminal_status,
11933                        crate::core::events::TurnOutcomeStatus::Failed
11934                    )
11935                {
11936                    summary.error = Some(
11937                        "exec turn failed after sandbox denial; explicit sandbox elevation was not authorized"
11938                            .to_string(),
11939                    );
11940                }
11941                if last_error_category.is_none() {
11942                    last_error_category = summary
11943                        .error
11944                        .as_deref()
11945                        .map(crate::error_taxonomy::classify_error_message);
11946                    summary.error_category =
11947                        last_error_category.map(|category| category.to_string());
11948                }
11949                let termination_reason = crate::core::termination::classify_turn_termination(
11950                    terminal_status,
11951                    last_error_category,
11952                    tool_error_seen,
11953                    approval_required,
11954                );
11955                summary.termination_reason = Some(termination_reason.as_str().to_string());
11956                // State the exit class here rather than inferring it later
11957                // from the process exit code: `Canceled` exits 130, the same
11958                // value the SIGINT path uses, so a code-based derivation would
11959                // report every Esc-cancelled turn as a signal. A no-op unless
11960                // this process was armed.
11961                if !termination_reason.is_success() {
11962                    codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
11963                }
11964                let saved_session_id = if should_persist_session && !latest_messages.is_empty() {
11965                    match persist_exec_session(
11966                        &latest_messages,
11967                        &latest_model,
11968                        PersistedProviderRoute {
11969                            kind: effective_provider.as_str(),
11970                            id: effective_provider_id.as_deref(),
11971                        },
11972                        &latest_workspace,
11973                        &latest_system_prompt,
11974                        latest_session_id.as_deref(),
11975                        u64::from(usage.input_tokens) + u64::from(usage.output_tokens),
11976                    ) {
11977                        Ok(id) => {
11978                            if output_format == ExecOutputFormat::Text && !json_output {
11979                                eprintln!("{}", exec_saved_session_line(&id));
11980                            }
11981                            Some(id)
11982                        }
11983                        Err(err) => {
11984                            if output_format == ExecOutputFormat::Text && !json_output {
11985                                eprintln!("warning: failed to save exec session: {err}");
11986                            }
11987                            latest_session_id.clone()
11988                        }
11989                    }
11990                } else {
11991                    latest_session_id.clone()
11992                };
11993                if output_format == ExecOutputFormat::StreamJson {
11994                    if let Some(id) = saved_session_id.as_ref() {
11995                        emit_exec_stream_event(&ExecStreamEvent::SessionCapture {
11996                            content: exec_stream_session_ref(id),
11997                        })?;
11998                    }
11999                    // Resolved output ceiling and its provenance, surfaced so a
12000                    // wrong ceiling is visible in the receipt rather than
12001                    // requiring packet capture.
12002                    let codewhale_max_output_tokens =
12003                        crate::route_budget::effective_max_output_tokens_for_route(
12004                            effective_provider,
12005                            &latest_model,
12006                            active_route_limits,
12007                        );
12008                    let codewhale_max_output_tokens_source =
12009                        crate::route_budget::output_ceiling_source(
12010                            effective_provider,
12011                            &latest_model,
12012                        )
12013                        .as_str();
12014                    emit_exec_stream_event(&ExecStreamEvent::Metadata {
12015                        meta: Box::new(ExecStreamMeta {
12016                            receipt_kind: "terminal",
12017                            provider: effective_provider_kind.clone(),
12018                            provider_id: effective_stream_provider_id.clone(),
12019                            model: latest_model.clone(),
12020                            route_source: route_source.clone(),
12021                            input_tokens: Some(usage.input_tokens),
12022                            output_tokens: Some(usage.output_tokens),
12023                            prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
12024                            prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
12025                            prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
12026                            reasoning_tokens: usage.reasoning_tokens,
12027                            codewhale_max_output_tokens: Some(codewhale_max_output_tokens),
12028                            codewhale_max_output_tokens_source: Some(
12029                                codewhale_max_output_tokens_source,
12030                            ),
12031                            duration_ms: u64::try_from(exec_started.elapsed().as_millis())
12032                                .unwrap_or(u64::MAX),
12033                            retry_count: None,
12034                            approval_posture: approval_posture.clone(),
12035                            sandbox_posture: sandbox_posture.clone(),
12036                            binary_sha256: binary_sha256.clone(),
12037                            config_sha256: None,
12038                            prompt_sha256: prompt_sha256.clone(),
12039                            tool_catalog_sha256: tool_catalog.as_ref().and_then(|catalog| {
12040                                serde_json::to_vec(catalog).ok().map(|bytes| {
12041                                    format!("sha256:{}", crate::hashing::sha256_hex(&bytes))
12042                                })
12043                            }),
12044                            input_analysis: exec_stream_input_analysis(
12045                                &latest_messages,
12046                                latest_system_prompt.as_ref(),
12047                            ),
12048                            visible_final_answer_chars: summary.output.chars().count(),
12049                            resume_command: saved_session_id
12050                                .as_deref()
12051                                .map(exec_stream_resume_hint)
12052                                .unwrap_or_default(),
12053                            session_id: saved_session_id
12054                                .as_deref()
12055                                .map(exec_stream_session_ref)
12056                                .unwrap_or_default(),
12057                            workspace: latest_workspace.display().to_string(),
12058                            message_count: latest_messages.len(),
12059                            status: summary.status.clone(),
12060                            termination_reason: summary.termination_reason.clone(),
12061                            error_category: summary.error_category.clone(),
12062                            error: summary.error.clone(),
12063                        }),
12064                    })?;
12065                    emit_exec_stream_event(&ExecStreamEvent::Done)?;
12066                }
12067                let _ = engine_handle.send(Op::Shutdown).await;
12068                break;
12069            }
12070            Event::SessionUpdated {
12071                session_id,
12072                messages,
12073                system_prompt,
12074                model,
12075                workspace,
12076            } => {
12077                latest_session_id = Some(session_id);
12078                latest_messages = messages;
12079                latest_system_prompt = system_prompt;
12080                latest_model = model;
12081                latest_workspace = workspace;
12082            }
12083            // #3027: surface the engine's max-steps notice in text mode so a
12084            // --max-turns run that stops early says why instead of going quiet.
12085            Event::Status { message }
12086                if output_format == ExecOutputFormat::Text
12087                    && !json_output
12088                    && message.contains("Maximum model steps") =>
12089            {
12090                eprintln!("{message}");
12091            }
12092            _ => {}
12093        }
12094    }
12095
12096    if summary.status.is_none() {
12097        if let Ok(mut manager) = exec_shell_manager.lock() {
12098            manager.abort_persistent_services();
12099        }
12100        let error = summary.error.clone().unwrap_or_else(|| {
12101            "Engine event channel closed before a terminal turn receipt".to_string()
12102        });
12103        let category = last_error_category
12104            .unwrap_or_else(|| crate::error_taxonomy::classify_error_message(&error));
12105        let termination_reason = crate::core::termination::classify_turn_termination(
12106            crate::core::events::TurnOutcomeStatus::Failed,
12107            Some(category),
12108            tool_error_seen,
12109            approval_required,
12110        );
12111        summary.status = Some("failed".to_string());
12112        summary.error_category = Some(category.to_string());
12113        summary.termination_reason = Some(termination_reason.as_str().to_string());
12114        summary.error = Some(error.clone());
12115        if output_format == ExecOutputFormat::StreamJson {
12116            emit_exec_stream_event(&ExecStreamEvent::Error { error })?;
12117        }
12118    }
12119
12120    if json_output {
12121        println!("{}", serde_json::to_string_pretty(&summary)?);
12122    }
12123
12124    if let Some(error) = summary.error.as_ref()
12125        && !error.trim().is_empty()
12126    {
12127        // Distinguish retryable infrastructure failures (provider/transport,
12128        // after all in-session retries are exhausted) from genuine task
12129        // failures so supervisors and bench harnesses can tell them apart at
12130        // the process level without parsing the stream. Genuine failures
12131        // keep the historical `bail!` → exit 1 path.
12132        let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
12133        if exit_code != 1 {
12134            eprintln!("Error: exec turn failed: {error}");
12135            let _ = io::stdout().flush();
12136            std::process::exit(exit_code);
12137        }
12138        bail!("exec turn failed: {error}");
12139    }
12140
12141    if matches!(
12142        summary.status.as_deref(),
12143        Some("failed" | "canceled" | "interrupted")
12144    ) {
12145        let status = summary.status.as_deref().unwrap_or("unknown");
12146        bail!("exec turn ended with status {status}");
12147    }
12148
12149    Ok(())
12150}
12151
12152#[cfg(test)]
12153mod serve_bind_host_tests {
12154    use super::*;
12155
12156    #[test]
12157    fn http_defaults_to_loopback() {
12158        assert_eq!(
12159            resolve_serve_bind_host(false, None),
12160            ServeBindHost {
12161                host: "127.0.0.1".to_string(),
12162                mobile_rebound_to_lan: false,
12163            }
12164        );
12165    }
12166
12167    #[test]
12168    fn mobile_default_rebinds_to_lan_with_warning_flag() {
12169        assert_eq!(
12170            resolve_serve_bind_host(true, None),
12171            ServeBindHost {
12172                host: "0.0.0.0".to_string(),
12173                mobile_rebound_to_lan: true,
12174            }
12175        );
12176    }
12177
12178    #[test]
12179    fn mobile_respects_explicit_loopback_host() {
12180        assert_eq!(
12181            resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
12182            ServeBindHost {
12183                host: "127.0.0.1".to_string(),
12184                mobile_rebound_to_lan: false,
12185            }
12186        );
12187    }
12188
12189    #[test]
12190    fn http_and_mobile_are_mutually_exclusive() {
12191        let err = validate_serve_mode_selection(false, true, true, false, false).unwrap_err();
12192        assert!(
12193            err.to_string()
12194                .contains("--http and --mobile are mutually exclusive")
12195        );
12196    }
12197
12198    #[test]
12199    fn web_is_a_distinct_loopback_runtime_mode() {
12200        assert!(validate_serve_mode_selection(false, false, false, true, false).unwrap());
12201        let err = validate_serve_mode_selection(false, true, false, true, false).unwrap_err();
12202        assert!(err.to_string().contains("--web is mutually exclusive"));
12203        assert_eq!(
12204            resolve_serve_bind_host(false, None),
12205            ServeBindHost {
12206                host: "127.0.0.1".to_string(),
12207                mobile_rebound_to_lan: false,
12208            }
12209        );
12210    }
12211}
12212
12213#[cfg(test)]
12214#[path = "tests/exec_exit_semantics.rs"]
12215mod exec_exit_semantics_tests;
12216#[cfg(test)]
12217mod doctor_legacy_state_tests {
12218    use super::*;
12219    use std::env;
12220    use std::ffi::OsString;
12221    use std::fs;
12222    use tempfile::TempDir;
12223
12224    struct EnvVarRestore {
12225        key: &'static str,
12226        previous: Option<OsString>,
12227    }
12228
12229    impl EnvVarRestore {
12230        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
12231            let previous = env::var_os(key);
12232            unsafe {
12233                env::set_var(key, value);
12234            }
12235            Self { key, previous }
12236        }
12237    }
12238
12239    impl Drop for EnvVarRestore {
12240        fn drop(&mut self) {
12241            unsafe {
12242                match &self.previous {
12243                    Some(value) => env::set_var(self.key, value),
12244                    None => env::remove_var(self.key),
12245                }
12246            }
12247        }
12248    }
12249
12250    fn roots(tmp: &TempDir) -> (PathBuf, PathBuf) {
12251        (tmp.path().join(".codewhale"), tmp.path().join(".deepseek"))
12252    }
12253
12254    fn entry<'a>(report: &'a [DoctorLegacyStateEntry], name: &str) -> &'a DoctorLegacyStateEntry {
12255        report
12256            .iter()
12257            .find(|entry| entry.name == name)
12258            .expect("legacy state entry should exist")
12259    }
12260
12261    #[test]
12262    fn doctor_legacy_state_report_marks_unmigrated_legacy_entries() {
12263        let tmp = TempDir::new().expect("tempdir");
12264        let (primary_root, legacy_root) = roots(&tmp);
12265        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12266        fs::create_dir_all(legacy_root.join("tasks")).expect("legacy tasks");
12267        fs::create_dir_all(&primary_root).expect("primary root");
12268        fs::write(legacy_root.join("config.toml"), "api_key = 'old'").expect("legacy config");
12269
12270        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12271        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12272
12273        assert_eq!(
12274            entry(&report, "sessions").status,
12275            DoctorLegacyStateStatus::LegacyOnly
12276        );
12277        assert_eq!(
12278            entry(&report, "config.toml").status,
12279            DoctorLegacyStateStatus::LegacyOnly
12280        );
12281        assert_eq!(
12282            entry(&report, "skills").status,
12283            DoctorLegacyStateStatus::Absent
12284        );
12285
12286        let json =
12287            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12288        assert_eq!(json["needs_attention"], true);
12289        assert_eq!(json["legacy_only_count"], 3);
12290        assert_eq!(json["dual_present_count"], 0);
12291    }
12292
12293    #[test]
12294    fn doctor_legacy_state_report_marks_dual_present_entries() {
12295        let tmp = TempDir::new().expect("tempdir");
12296        let (primary_root, legacy_root) = roots(&tmp);
12297        fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12298        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12299        fs::write(primary_root.join("mcp.json"), "{}").expect("primary mcp");
12300        fs::write(legacy_root.join("mcp.json"), "{}").expect("legacy mcp");
12301
12302        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12303        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12304
12305        assert_eq!(
12306            entry(&report, "sessions").status,
12307            DoctorLegacyStateStatus::Both
12308        );
12309        assert_eq!(
12310            entry(&report, "mcp.json").status,
12311            DoctorLegacyStateStatus::Both
12312        );
12313
12314        let json =
12315            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12316        assert_eq!(json["needs_attention"], true);
12317        assert_eq!(json["legacy_only_count"], 0);
12318        assert_eq!(json["dual_present_count"], 2);
12319    }
12320
12321    #[test]
12322    fn doctor_legacy_state_report_is_clear_when_only_primary_exists() {
12323        let tmp = TempDir::new().expect("tempdir");
12324        let (primary_root, legacy_root) = roots(&tmp);
12325        fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12326        fs::write(primary_root.join("settings.toml"), "default_mode = 'ask'")
12327            .expect("primary settings");
12328
12329        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12330        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12331
12332        assert_eq!(
12333            entry(&report, "sessions").status,
12334            DoctorLegacyStateStatus::PrimaryOnly
12335        );
12336        assert!(!report.iter().any(legacy_state_needs_attention));
12337
12338        let json =
12339            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12340        assert_eq!(json["needs_attention"], false);
12341        assert_eq!(json["legacy_only_count"], 0);
12342        assert_eq!(json["dual_present_count"], 0);
12343    }
12344
12345    #[test]
12346    fn doctor_legacy_state_report_is_clear_when_neither_root_exists() {
12347        let tmp = TempDir::new().expect("tempdir");
12348        let (primary_root, legacy_root) = roots(&tmp);
12349
12350        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12351        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12352
12353        assert!(
12354            report
12355                .iter()
12356                .all(|entry| entry.status == DoctorLegacyStateStatus::Absent)
12357        );
12358        assert!(!report.iter().any(legacy_state_needs_attention));
12359
12360        let json =
12361            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12362        assert_eq!(json["needs_attention"], false);
12363        assert_eq!(json["legacy_only_count"], 0);
12364        assert_eq!(json["dual_present_count"], 0);
12365    }
12366
12367    #[test]
12368    fn doctor_reports_incomplete_session_migration_without_mutating_files() {
12369        let tmp = TempDir::new().expect("tempdir");
12370        let (primary_root, legacy_root) = roots(&tmp);
12371        let primary_sessions = primary_root.join("sessions");
12372        let legacy_sessions = legacy_root.join("sessions");
12373        fs::create_dir_all(&primary_sessions).expect("primary sessions");
12374        fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
12375        fs::write(primary_sessions.join("already-there.json"), b"primary")
12376            .expect("primary session");
12377        fs::write(legacy_sessions.join("already-there.json"), b"legacy")
12378            .expect("legacy matching session");
12379        fs::write(
12380            legacy_sessions.join("recover-me.json"),
12381            b"not parsed by doctor",
12382        )
12383        .expect("legacy recoverable session");
12384        fs::write(
12385            legacy_sessions.join("checkpoints").join("latest.json"),
12386            b"checkpoint not inspected",
12387        )
12388        .expect("legacy checkpoint");
12389
12390        let legacy_before = fs::read(legacy_sessions.join("recover-me.json"))
12391            .expect("read legacy fixture before diagnostic");
12392        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12393
12394        assert_eq!(
12395            report.status,
12396            DoctorSessionRecoveryStatus::MigrationIncomplete
12397        );
12398        assert_eq!(report.legacy_session_file_count, 2);
12399        assert_eq!(report.already_present_file_count, 1);
12400        assert_eq!(report.recoverable_file_count, 1);
12401        assert_eq!(report.recoverable.len(), 1);
12402        assert_eq!(report.recoverable[0].name, PathBuf::from("recover-me.json"));
12403        assert!(
12404            !primary_sessions.join("recover-me.json").exists(),
12405            "doctor must not copy a recoverable session"
12406        );
12407        assert_eq!(
12408            fs::read(legacy_sessions.join("recover-me.json"))
12409                .expect("legacy file remains after diagnostic"),
12410            legacy_before,
12411            "doctor must not rewrite or delete the legacy source"
12412        );
12413
12414        let json = doctor_session_recovery_json(&report);
12415        assert_eq!(json["needs_attention"], true);
12416        assert_eq!(json["read_only"], true);
12417        assert_eq!(json["chat_contents_read"], false);
12418        assert_eq!(json["checkpoint_internals_scanned"], false);
12419        assert_eq!(json["recoverable_file_count"], 1);
12420        assert_eq!(json["recovery_command"], "codewhale sessions");
12421        assert_eq!(json["recoverable_files"][0]["name"], "recover-me.json");
12422        let serialized = json.to_string();
12423        assert!(
12424            !serialized.contains("not parsed by doctor"),
12425            "the report must not expose session contents"
12426        );
12427        assert!(
12428            !serialized.contains("checkpoint not inspected"),
12429            "the report must not expose checkpoint contents"
12430        );
12431    }
12432
12433    #[test]
12434    fn doctor_treats_preserved_legacy_sessions_as_complete_by_filename() {
12435        let tmp = TempDir::new().expect("tempdir");
12436        let (primary_root, legacy_root) = roots(&tmp);
12437        let primary_sessions = primary_root.join("sessions");
12438        let legacy_sessions = legacy_root.join("sessions");
12439        fs::create_dir_all(&primary_sessions).expect("primary sessions");
12440        fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12441        fs::write(primary_sessions.join("same-name.json"), b"primary").expect("primary session");
12442        fs::write(legacy_sessions.join("same-name.json"), b"legacy").expect("legacy session");
12443
12444        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12445
12446        assert_eq!(
12447            report.status,
12448            DoctorSessionRecoveryStatus::MigrationComplete
12449        );
12450        assert!(!report.needs_attention());
12451        assert_eq!(report.recoverable_file_count, 0);
12452        assert!(report.recoverable.is_empty());
12453        assert_eq!(report.already_present_file_count, 1);
12454        let json = doctor_session_recovery_json(&report);
12455        assert_eq!(json["session_descriptors_compared"], false);
12456        assert_eq!(
12457            json["counterpart_check"],
12458            "top_level_filename_and_regular_file_only"
12459        );
12460    }
12461
12462    #[test]
12463    fn doctor_bounds_recoverable_session_filename_samples() {
12464        let tmp = TempDir::new().expect("tempdir");
12465        let (primary_root, legacy_root) = roots(&tmp);
12466        let legacy_sessions = legacy_root.join("sessions");
12467        fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12468        for index in 0..DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
12469            fs::write(
12470                legacy_sessions.join(format!("late-{index:03}.json")),
12471                b"fixture",
12472            )
12473            .expect("legacy session fixture");
12474        }
12475        fs::write(legacy_sessions.join("early-000.json"), b"fixture")
12476            .expect("earliest legacy session fixture");
12477        fs::write(legacy_sessions.join("early-001.json"), b"fixture")
12478            .expect("second earliest legacy session fixture");
12479        let total = DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT + 2;
12480
12481        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12482        let json = doctor_session_recovery_json(&report);
12483
12484        assert_eq!(report.recoverable_file_count, total);
12485        assert_eq!(
12486            report.recoverable.len(),
12487            DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
12488        );
12489        assert_eq!(
12490            json["recoverable_files"].as_array().map(Vec::len),
12491            Some(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
12492        );
12493        assert_eq!(
12494            report.recoverable.first().map(|entry| entry.name.as_path()),
12495            Some(Path::new("early-000.json")),
12496            "the bounded sample must not depend on read_dir order"
12497        );
12498        assert_eq!(
12499            report.recoverable.last().map(|entry| entry.name.as_path()),
12500            Some(Path::new("late-097.json")),
12501            "the bounded sample must retain the lexical prefix"
12502        );
12503        assert_eq!(json["recoverable_files_truncated"], true);
12504    }
12505
12506    #[test]
12507    fn doctor_session_recovery_fails_closed_on_an_unreadable_path_shape() {
12508        let tmp = TempDir::new().expect("tempdir");
12509        let (primary_root, legacy_root) = roots(&tmp);
12510        fs::create_dir_all(&legacy_root).expect("legacy root");
12511        fs::write(legacy_root.join("sessions"), b"not a directory")
12512            .expect("invalid legacy sessions path");
12513
12514        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12515
12516        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12517        assert!(report.needs_attention());
12518        assert!(report.error.as_deref().is_some_and(|error| {
12519            error.contains("legacy sessions root") && error.contains("not a directory")
12520        }));
12521    }
12522
12523    #[test]
12524    fn doctor_session_recovery_rejects_a_non_directory_legacy_state_root() {
12525        let tmp = TempDir::new().expect("tempdir");
12526        let (primary_root, legacy_root) = roots(&tmp);
12527        fs::write(&legacy_root, b"not a state directory").expect("invalid legacy root");
12528
12529        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12530
12531        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12532        assert!(report.error.as_deref().is_some_and(|error| {
12533            error.contains("legacy state root") && error.contains("not a directory")
12534        }));
12535    }
12536
12537    #[test]
12538    fn doctor_session_recovery_rejects_a_non_directory_primary_state_root() {
12539        let tmp = TempDir::new().expect("tempdir");
12540        let (primary_root, legacy_root) = roots(&tmp);
12541        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12542        fs::write(&primary_root, b"not a state directory").expect("invalid primary root");
12543
12544        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12545
12546        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12547        assert!(report.error.as_deref().is_some_and(|error| {
12548            error.contains("primary state root") && error.contains("not a directory")
12549        }));
12550    }
12551
12552    #[test]
12553    fn doctor_session_recovery_rejects_a_non_directory_primary_sessions_root() {
12554        let tmp = TempDir::new().expect("tempdir");
12555        let (primary_root, legacy_root) = roots(&tmp);
12556        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12557        fs::create_dir_all(&primary_root).expect("primary root");
12558        fs::write(primary_root.join("sessions"), b"not a sessions directory")
12559            .expect("invalid primary sessions path");
12560
12561        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12562
12563        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12564        assert!(report.error.as_deref().is_some_and(|error| {
12565            error.contains("primary sessions root") && error.contains("not a directory")
12566        }));
12567    }
12568
12569    #[cfg(unix)]
12570    #[test]
12571    fn doctor_session_recovery_rejects_a_symlinked_legacy_sessions_root() {
12572        use std::os::unix::fs::symlink;
12573
12574        let tmp = TempDir::new().expect("tempdir");
12575        let (primary_root, legacy_root) = roots(&tmp);
12576        let external_sessions = tmp.path().join("external-sessions");
12577        fs::create_dir_all(&external_sessions).expect("external sessions");
12578        fs::write(
12579            external_sessions.join("must-not-be-enumerated.json"),
12580            b"session contents must stay unread",
12581        )
12582        .expect("external session fixture");
12583        fs::create_dir_all(&legacy_root).expect("legacy root");
12584        symlink(&external_sessions, legacy_root.join("sessions"))
12585            .expect("symlinked legacy sessions root");
12586
12587        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12588
12589        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12590        assert!(report.needs_attention());
12591        assert_eq!(report.legacy_session_file_count, 0);
12592        assert!(report.recoverable.is_empty());
12593        assert!(
12594            report
12595                .error
12596                .as_deref()
12597                .is_some_and(|error| error.contains("legacy sessions root")
12598                    && error.contains("path is a symlink"))
12599        );
12600    }
12601
12602    #[cfg(unix)]
12603    #[test]
12604    fn doctor_session_recovery_rejects_symlinked_primary_root_and_sessions_root() {
12605        use std::os::unix::fs::symlink;
12606
12607        let tmp = TempDir::new().expect("tempdir");
12608        let (primary_root, legacy_root) = roots(&tmp);
12609        let external_primary = tmp.path().join("external-primary");
12610        fs::create_dir_all(external_primary.join("sessions")).expect("external primary");
12611        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12612        symlink(&external_primary, &primary_root).expect("symlinked primary root");
12613
12614        let root_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12615        assert_eq!(root_report.status, DoctorSessionRecoveryStatus::ScanFailed);
12616        assert!(root_report.error.as_deref().is_some_and(|error| {
12617            error.contains("primary state root") && error.contains("path is a symlink")
12618        }));
12619
12620        fs::remove_file(&primary_root).expect("remove primary root symlink");
12621        fs::create_dir_all(&primary_root).expect("primary root");
12622        symlink(&external_primary, primary_root.join("sessions"))
12623            .expect("symlinked primary sessions root");
12624
12625        let sessions_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12626        assert_eq!(
12627            sessions_report.status,
12628            DoctorSessionRecoveryStatus::ScanFailed
12629        );
12630        assert!(sessions_report.error.as_deref().is_some_and(|error| {
12631            error.contains("primary sessions root") && error.contains("path is a symlink")
12632        }));
12633    }
12634
12635    #[test]
12636    fn explicit_codewhale_home_skips_session_recovery_scan() {
12637        let tmp = TempDir::new().expect("tempdir");
12638        let (primary_root, legacy_root) = roots(&tmp);
12639        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12640        fs::write(legacy_root.join("sessions").join("ambient.json"), b"legacy")
12641            .expect("legacy session");
12642
12643        let report = doctor_session_recovery_report(&primary_root, &legacy_root, true);
12644
12645        assert_eq!(report.status, DoctorSessionRecoveryStatus::Isolated);
12646        assert!(report.codewhale_home_is_explicit);
12647        assert_eq!(report.legacy_session_file_count, 0);
12648        assert_eq!(report.recoverable_file_count, 0);
12649        assert!(report.recoverable.is_empty());
12650        assert!(!report.needs_attention());
12651    }
12652
12653    #[test]
12654    fn doctor_state_roots_ignore_ambient_legacy_home_when_codewhale_home_is_explicit() {
12655        let _env_lock = crate::test_support::lock_test_env();
12656        let tmp = TempDir::new().expect("tempdir");
12657        let explicit_home = tmp.path().join("isolated-codewhale");
12658        let ambient_legacy = tmp.path().join(".deepseek");
12659        fs::create_dir_all(&ambient_legacy).expect("ambient legacy root");
12660        fs::write(
12661            ambient_legacy.join("config.toml"),
12662            "provider = 'deepseek'\n",
12663        )
12664        .expect("ambient legacy config");
12665        let _home = EnvVarRestore::set("HOME", tmp.path());
12666        let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
12667
12668        let (primary_root, legacy_root) = doctor_state_roots();
12669        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12670        let session_recovery = doctor_session_recovery_report(
12671            &primary_root,
12672            &legacy_root,
12673            codewhale_config::codewhale_home_is_explicit(),
12674        );
12675
12676        assert_eq!(primary_root, explicit_home);
12677        assert_eq!(
12678            legacy_root,
12679            primary_root.join(codewhale_config::LEGACY_APP_DIR)
12680        );
12681        assert!(
12682            report
12683                .iter()
12684                .all(|entry| entry.status == DoctorLegacyStateStatus::Absent),
12685            "doctor must not report ambient legacy state when CODEWHALE_HOME is explicit"
12686        );
12687        assert!(!report.iter().any(legacy_state_needs_attention));
12688        assert_eq!(
12689            session_recovery.status,
12690            DoctorSessionRecoveryStatus::Isolated
12691        );
12692        assert!(session_recovery.recoverable.is_empty());
12693    }
12694}
12695
12696#[cfg(test)]
12697mod doctor_setup_state_tests {
12698    use super::*;
12699    use std::fs;
12700    use tempfile::TempDir;
12701
12702    fn prepare_env(tmp: &TempDir) -> (crate::test_support::EnvVarGuard, PathBuf) {
12703        let codewhale_home = tmp.path().join(".codewhale");
12704        fs::create_dir_all(&codewhale_home).expect("codewhale home");
12705        (
12706            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str()),
12707            codewhale_home,
12708        )
12709    }
12710
12711    fn provider_step(report: &serde_json::Value) -> &serde_json::Value {
12712        report["steps"]
12713            .as_array()
12714            .expect("steps array")
12715            .iter()
12716            .find(|step| step["step"] == "provider_model")
12717            .expect("provider/model step")
12718    }
12719
12720    #[test]
12721    fn doctor_setup_consistency_flags_missing_user_constitution() {
12722        let _guard = crate::test_support::lock_test_env();
12723        let tmp = TempDir::new().expect("tempdir");
12724        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12725        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12726        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12727        let workspace = tmp.path().join("workspace");
12728        fs::create_dir_all(&workspace).expect("workspace");
12729
12730        let state = codewhale_config::SetupState {
12731            constitution_source: codewhale_config::ConstitutionSource::UserGlobal,
12732            ..Default::default()
12733        };
12734        state.save().expect("persist setup state");
12735
12736        let report = doctor_setup_report_json(&Config::default(), &workspace);
12737
12738        assert_eq!(report["source"], "persisted");
12739        assert_eq!(report["consistency"]["status"], "inconsistent");
12740        let issues = report["consistency"]["issues"].to_string();
12741        assert!(
12742            issues.contains("setup_state_points_at_missing_user_constitution"),
12743            "{issues}"
12744        );
12745    }
12746
12747    #[test]
12748    fn doctor_setup_consistency_flags_stale_temp_files() {
12749        let _guard = crate::test_support::lock_test_env();
12750        let tmp = TempDir::new().expect("tempdir");
12751        let (_home_guard, codewhale_home) = prepare_env(&tmp);
12752        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12753        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12754        let workspace = tmp.path().join("workspace");
12755        fs::create_dir_all(&workspace).expect("workspace");
12756        fs::write(codewhale_home.join(".tmpAbC123"), b"orphaned atomic write")
12757            .expect("stale temp file");
12758
12759        let report = doctor_setup_report_json(&Config::default(), &workspace);
12760
12761        assert_eq!(report["consistency"]["status"], "inconsistent");
12762        let issues = report["consistency"]["issues"].to_string();
12763        assert!(
12764            issues.contains("stale_setup_temp_files_in_codewhale_home"),
12765            "{issues}"
12766        );
12767    }
12768
12769    #[test]
12770    fn doctor_setup_consistency_reports_consistent_for_clean_home() {
12771        let _guard = crate::test_support::lock_test_env();
12772        let tmp = TempDir::new().expect("tempdir");
12773        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12774        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12775        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12776        let workspace = tmp.path().join("workspace");
12777        fs::create_dir_all(&workspace).expect("workspace");
12778
12779        let report = doctor_setup_report_json(&Config::default(), &workspace);
12780
12781        assert_eq!(report["consistency"]["status"], "consistent");
12782        assert_eq!(
12783            report["consistency"]["issues"]
12784                .as_array()
12785                .map(Vec::len)
12786                .unwrap_or_default(),
12787            0
12788        );
12789    }
12790
12791    #[test]
12792    fn doctor_setup_report_json_derives_state_without_sidecar() {
12793        let _guard = crate::test_support::lock_test_env();
12794        let tmp = TempDir::new().expect("tempdir");
12795        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12796        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12797        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12798        let workspace = tmp.path().join("workspace");
12799        fs::create_dir_all(&workspace).expect("workspace");
12800
12801        let report = doctor_setup_report_json(&Config::default(), &workspace);
12802
12803        assert_eq!(report["source"], "derived");
12804        assert_eq!(report["inherited"], true);
12805        assert_eq!(report["next_actions"]["constitution"], "/constitution");
12806        assert_eq!(report["next_actions"]["setup_report"], "/setup report");
12807        assert_eq!(
12808            report["next_actions"]["provider_model"],
12809            "/setup provider, /provider setup <name>, or /model"
12810        );
12811        assert_eq!(report["next_actions"]["runtime_posture"], "/config");
12812        assert_eq!(
12813            report["next_actions"]["operate_fleet"],
12814            "/setup fleet (readiness), /fleet setup (explicit profile authoring)"
12815        );
12816        assert_eq!(report["next_actions"]["hotbar"], "/setup hotbar");
12817        assert_eq!(report["next_actions"]["tools_mcp"], "/setup tools");
12818        assert_eq!(report["next_actions"]["remote_runtime"], "/setup remote");
12819        assert_eq!(report["next_actions"]["persistence"], "/setup persistence");
12820        assert_eq!(
12821            report["checkpoint_version"],
12822            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12823        );
12824        assert_eq!(report["update_ready"], false);
12825        assert_eq!(report["operate_ready"], false);
12826        assert_eq!(
12827            report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12828            false
12829        );
12830        assert_eq!(
12831            report["operate_fleet"]["roster"]["readiness_rule"],
12832            "built-in starter roster or custom roster"
12833        );
12834        assert_eq!(report["provider_model"]["provider"]["id"], "deepseek");
12835        assert_eq!(report["provider_model"]["provider"]["display"], "DeepSeek");
12836        assert_eq!(
12837            report["provider_model"]["model"]["resolved"],
12838            crate::config::DEFAULT_TEXT_MODEL
12839        );
12840        assert_eq!(
12841            report["provider_model"]["auth"]["source"],
12842            "secret_store_unprobed"
12843        );
12844        assert_eq!(
12845            report["provider_model"]["auth"]["availability"],
12846            "not_probed"
12847        );
12848        assert_eq!(
12849            report["provider_model"]["auth"]["credential_url"],
12850            "https://platform.deepseek.com"
12851        );
12852        assert_eq!(
12853            report["provider_model"]["auth"]["credential_mode"],
12854            "api_key"
12855        );
12856        assert_eq!(
12857            report["provider_model"]["auth"]["env_vars"][0],
12858            "DEEPSEEK_API_KEY"
12859        );
12860        assert_eq!(report["provider_model"]["health"]["live_validation"], false);
12861        assert_eq!(report["constitution"]["source"], "bundled");
12862        assert_eq!(report["constitution"]["autonomy_preference"], "unspecified");
12863        assert_eq!(report["runtime_posture"]["source"], "unset");
12864        assert_eq!(report["runtime_posture"]["default_mode"]["value"], "agent");
12865        assert_eq!(
12866            report["runtime_posture"]["approval_policy"]["value"],
12867            "on-request"
12868        );
12869        assert_eq!(report["runtime_posture"]["allow_shell"]["value"], true);
12870        assert_eq!(
12871            report["runtime_posture"]["sandbox_mode"]["value"],
12872            "mode-derived"
12873        );
12874        assert_eq!(
12875            report["runtime_posture"]["network_default"]["value"],
12876            "prompt"
12877        );
12878        assert_eq!(provider_step(&report)["status"], "needs_action");
12879    }
12880
12881    #[test]
12882    fn doctor_setup_provider_model_json_covers_cn_codex_and_local_matrix() {
12883        let _guard = crate::test_support::lock_test_env();
12884        let tmp = TempDir::new().expect("tempdir");
12885        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12886        let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
12887        let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
12888        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12889        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12890        let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
12891        let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
12892        let codex_auth_path = tmp.path().join("external-codex-auth.json");
12893        let codex_auth_raw = serde_json::json!({
12894            "tokens": {
12895                "access_token": crate::test_support::future_test_jwt("doctor"),
12896                "account_id": "acct-doctor-read-only",
12897                "refresh_token": "must-never-be-used",
12898                "unknown": {"preserve": true}
12899            }
12900        })
12901        .to_string();
12902        fs::write(&codex_auth_path, &codex_auth_raw).expect("Codex auth trap fixture");
12903        let _codex_auth =
12904            crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_auth_path);
12905        let workspace = tmp.path().join("workspace");
12906        fs::create_dir_all(&workspace).expect("workspace");
12907
12908        let cn_config = Config {
12909            provider: Some("deepseek-cn".to_string()),
12910            ..Config::default()
12911        };
12912        let cn_report = doctor_setup_report_json(&cn_config, &workspace);
12913        assert_eq!(cn_report["provider_model"]["provider"]["id"], "deepseek-cn");
12914        assert_eq!(
12915            cn_report["provider_model"]["provider"]["display"],
12916            "DeepSeek (legacy alias)"
12917        );
12918        assert_eq!(
12919            cn_report["provider_model"]["auth"]["env_vars"][0],
12920            "DEEPSEEK_API_KEY"
12921        );
12922        assert_eq!(
12923            cn_report["provider_model"]["auth"]["credential_url"],
12924            "https://platform.deepseek.com"
12925        );
12926        assert_eq!(cn_report["provider_model"]["auth"]["oauth_only"], false);
12927        assert_eq!(
12928            cn_report["provider_model"]["health"]["live_validation"],
12929            false
12930        );
12931
12932        let codex_config = Config {
12933            provider: Some("openai-codex".to_string()),
12934            ..Config::default()
12935        };
12936        crate::external_credentials::reset_side_effect_trap();
12937        let codex_report = doctor_setup_report_json(&codex_config, &workspace);
12938        assert_eq!(
12939            codex_report["provider_model"]["provider"]["id"],
12940            crate::config::ApiProvider::OpenaiCodex.as_str()
12941        );
12942        assert!(codex_report["provider_model"]["auth"]["credential_url"].is_null());
12943        assert_eq!(
12944            codex_report["provider_model"]["auth"]["credential_mode"],
12945            "oauth"
12946        );
12947        assert_eq!(codex_report["provider_model"]["auth"]["oauth_only"], true);
12948        assert_eq!(
12949            codex_report["provider_model"]["health"]["next_action"],
12950            "/setup provider or /provider setup <name>"
12951        );
12952        assert_eq!(
12953            crate::external_credentials::side_effect_trap_counts(),
12954            (0, 0),
12955            "doctor must not stat or read external credentials without consent"
12956        );
12957
12958        let mut consent = codewhale_config::ExternalCredentialConsentToml::read_only(
12959            codewhale_config::ProviderKind::OpenaiCodex,
12960            codewhale_config::ExternalCredentialSource::CodexCli,
12961            codex_auth_path.clone(),
12962        );
12963        let codex_read_only = Config {
12964            provider: Some("openai-codex".to_string()),
12965            providers: Some(crate::config::ProvidersConfig {
12966                openai_codex: crate::config::ProviderConfig {
12967                    auth_mode: Some("oauth".to_string()),
12968                    external_credentials: Some(consent.clone()),
12969                    ..Default::default()
12970                },
12971                ..Default::default()
12972            }),
12973            ..Config::default()
12974        };
12975        let changed_ambient_path = tmp.path().join("new-ambient-codex-auth.json");
12976        let _changed_codex_auth =
12977            crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &changed_ambient_path);
12978        crate::external_credentials::reset_side_effect_trap();
12979        let codex_read_only_report = doctor_setup_report_json(&codex_read_only, &workspace);
12980        assert_eq!(
12981            codex_read_only_report["provider_model"]["auth"]["present_or_local"],
12982            false
12983        );
12984        assert_eq!(
12985            codex_read_only_report["provider_model"]["auth"]["source"],
12986            "external_consent"
12987        );
12988        let status_json = doctor_external_credential_consent_json(&codex_read_only);
12989        let codex_status = status_json
12990            .as_array()
12991            .and_then(|rows| rows.first())
12992            .expect("Codex structural status");
12993        assert_eq!(codex_status["access"], "read_only");
12994        assert_eq!(codex_status["provider"], "openai-codex");
12995        assert_eq!(codex_status["source"], "codex_cli");
12996        assert_eq!(codex_status["route_state"], "active");
12997        assert_eq!(codex_status["ambient_path_changed"], true);
12998        assert!(
12999            codex_status["ambient_path_warning"]
13000                .as_str()
13001                .is_some_and(|warning| warning.contains("remains pinned"))
13002        );
13003        assert_eq!(
13004            codex_status["revoke_command"],
13005            "codewhale auth external-revoke --provider openai-codex"
13006        );
13007        let human = doctor_external_credential_consent_lines(&codex_read_only).join("\n");
13008        assert!(human.contains("path="), "{human}");
13009        assert!(human.contains("version=1"), "{human}");
13010        assert!(human.contains("no refresh, identity-provider or discovery requests"));
13011        assert!(human.contains("normal requests to the explicitly selected provider"));
13012        assert!(human.contains("consent remains pinned"), "{human}");
13013        assert!(
13014            human.contains(&codewhale_config::quote_os_path(&codex_auth_path)),
13015            "{human}"
13016        );
13017        assert!(!human.contains(&changed_ambient_path.display().to_string()));
13018        assert_eq!(
13019            crate::external_credentials::complete_side_effect_trap_counts(),
13020            (0, 0, 0, 0, 0),
13021            "doctor consent status is structural and must not inspect the file"
13022        );
13023        assert_eq!(
13024            fs::read_to_string(&codex_auth_path).expect("unchanged Codex auth fixture"),
13025            codex_auth_raw
13026        );
13027
13028        consent.access = codewhale_config::ExternalCredentialAccess::Managed;
13029        let codex_managed = Config {
13030            provider: Some("openai-codex".to_string()),
13031            providers: Some(crate::config::ProvidersConfig {
13032                openai_codex: crate::config::ProviderConfig {
13033                    auth_mode: Some("oauth".to_string()),
13034                    external_credentials: Some(consent),
13035                    ..Default::default()
13036                },
13037                ..Default::default()
13038            }),
13039            ..Config::default()
13040        };
13041        crate::external_credentials::reset_side_effect_trap();
13042        let codex_managed_report = doctor_setup_report_json(&codex_managed, &workspace);
13043        assert_eq!(
13044            codex_managed_report["provider_model"]["auth"]["present_or_local"],
13045            false
13046        );
13047        assert_eq!(
13048            crate::external_credentials::side_effect_trap_counts(),
13049            (0, 0),
13050            "unsupported managed mode must fail before external I/O"
13051        );
13052        assert_eq!(
13053            fs::read_to_string(&codex_auth_path).expect("unchanged managed auth fixture"),
13054            codex_auth_raw
13055        );
13056
13057        let local_config = Config {
13058            provider: Some("ollama".to_string()),
13059            ..Config::default()
13060        };
13061        let local_report = doctor_setup_report_json(&local_config, &workspace);
13062        assert_eq!(local_report["provider_model"]["provider"]["id"], "ollama");
13063        assert_eq!(
13064            local_report["provider_model"]["auth"]["present_or_local"],
13065            true
13066        );
13067        assert!(local_report["provider_model"]["auth"]["credential_url"].is_null());
13068        assert_eq!(
13069            local_report["provider_model"]["auth"]["credential_mode"],
13070            "local_optional"
13071        );
13072        assert_eq!(local_report["provider_model"]["auth"]["oauth_only"], false);
13073        assert_eq!(
13074            local_report["provider_model"]["health"]["next_action"],
13075            "/model"
13076        );
13077
13078        let kimi_config = Config {
13079            provider: Some("moonshot".to_string()),
13080            ..Config::default()
13081        };
13082        let kimi_report = doctor_setup_report_json(&kimi_config, &workspace);
13083        assert_eq!(
13084            kimi_report["provider_model"]["auth"]["credential_url"],
13085            "https://platform.kimi.ai"
13086        );
13087        assert_eq!(
13088            kimi_report["provider_model"]["auth"]["credential_docs_url"],
13089            "https://platform.kimi.ai"
13090        );
13091        assert_eq!(
13092            kimi_report["provider_model"]["auth"]["credential_mode"],
13093            "api_key"
13094        );
13095        assert!(
13096            kimi_report["provider_model"]["auth"]["credential_guidance"]
13097                .as_str()
13098                .is_some_and(|guidance| guidance.contains("OAuth is not available"))
13099        );
13100    }
13101
13102    #[test]
13103    fn doctor_setup_report_json_uses_persisted_state() {
13104        let _guard = crate::test_support::lock_test_env();
13105        let tmp = TempDir::new().expect("tempdir");
13106        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13107        let workspace = tmp.path().join("workspace");
13108        fs::create_dir_all(&workspace).expect("workspace");
13109        let mut state = codewhale_config::SetupState::default();
13110        state.set_step(
13111            codewhale_config::SetupStep::Language,
13112            codewhale_config::StepEntry::new(
13113                codewhale_config::StepStatus::Verified,
13114                true,
13115                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13116            ),
13117        );
13118        state.set_step(
13119            codewhale_config::SetupStep::ProviderModel,
13120            codewhale_config::StepEntry::new(
13121                codewhale_config::StepStatus::Verified,
13122                true,
13123                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13124            )
13125            .with_result("deepseek/deepseek-chat"),
13126        );
13127        state.set_step(
13128            codewhale_config::SetupStep::TrustSandbox,
13129            codewhale_config::StepEntry::new(
13130                codewhale_config::StepStatus::Verified,
13131                true,
13132                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13133            ),
13134        );
13135        state
13136            .complete_constitution_checkpoint(
13137                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13138                codewhale_config::ConstitutionChoice::Bundled,
13139            )
13140            .set_step(
13141                codewhale_config::SetupStep::Constitution,
13142                codewhale_config::StepEntry::new(
13143                    codewhale_config::StepStatus::Verified,
13144                    true,
13145                    crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13146                ),
13147            );
13148        state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13149        state.save().expect("persist setup state");
13150        codewhale_config::UserConstitution {
13151            autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
13152            ..Default::default()
13153        }
13154        .save()
13155        .expect("persist user constitution");
13156        let config = Config {
13157            api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13158            approval_policy: Some("never".to_string()),
13159            allow_shell: Some(false),
13160            sandbox_mode: Some("read-only".to_string()),
13161            network: Some(crate::config::NetworkPolicyToml {
13162                default: "deny".to_string(),
13163                ..Default::default()
13164            }),
13165            ..Config::default()
13166        };
13167
13168        let report = doctor_setup_report_json(&config, &workspace);
13169
13170        assert_eq!(report["source"], "persisted");
13171        assert_eq!(report["first_run_ready"], true);
13172        assert_eq!(report["update_ready"], true);
13173        assert_eq!(report["operate_ready"], false);
13174        assert_eq!(report["constitution"]["choice"], "bundled");
13175        assert_eq!(
13176            report["constitution"]["checkpoint_completed_for"],
13177            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
13178        );
13179        assert_eq!(report["constitution"]["autonomy_preference"], "balanced");
13180        assert_eq!(report["runtime_posture_source"], "confirmed");
13181        assert_eq!(report["runtime_posture"]["source"], "confirmed");
13182        assert_eq!(
13183            report["runtime_posture"]["approval_policy"]["value"],
13184            "never"
13185        );
13186        assert_eq!(
13187            report["runtime_posture"]["approval_policy"]["source"],
13188            "config"
13189        );
13190        assert_eq!(report["runtime_posture"]["allow_shell"]["value"], false);
13191        assert_eq!(report["runtime_posture"]["allow_shell"]["source"], "config");
13192        assert_eq!(
13193            report["runtime_posture"]["sandbox_mode"]["value"],
13194            "read-only"
13195        );
13196        assert_eq!(
13197            report["runtime_posture"]["sandbox_mode"]["source"],
13198            "config"
13199        );
13200        assert_eq!(
13201            report["runtime_posture"]["network_default"]["value"],
13202            "deny"
13203        );
13204        assert_eq!(
13205            report["runtime_posture"]["network_default"]["source"],
13206            "config"
13207        );
13208        assert_eq!(provider_step(&report)["result"], "deepseek/deepseek-chat");
13209
13210        let unprobed_config = Config {
13211            api_key: Some(crate::config::API_KEYRING_SENTINEL.to_string()),
13212            ..config.clone()
13213        };
13214        let unprobed_report = doctor_setup_report_json(&unprobed_config, &workspace);
13215        assert_eq!(unprobed_report["credential"]["ready"], false);
13216        assert_eq!(unprobed_report["credential"]["availability"], "not_probed");
13217        assert_eq!(unprobed_report["first_run_ready"], true);
13218        assert_eq!(unprobed_report["update_ready"], true);
13219    }
13220
13221    #[test]
13222    fn doctor_reports_settings_permission_posture_when_approval_policy_unset() {
13223        let _guard = crate::test_support::lock_test_env();
13224        let tmp = TempDir::new().expect("tempdir");
13225        let (_home_guard, codewhale_home) = prepare_env(&tmp);
13226        let workspace = tmp.path().join("workspace");
13227        fs::create_dir_all(&workspace).expect("workspace");
13228        fs::write(
13229            codewhale_home.join("settings.toml"),
13230            "permission_posture = \"full-access\"\n",
13231        )
13232        .expect("write settings.toml");
13233
13234        let config = Config::default();
13235        assert!(config.approval_policy.is_none());
13236
13237        let line = doctor_runtime_posture_line(&config, &workspace);
13238        assert!(
13239            line.contains("permission_posture=full-access (settings)"),
13240            "text doctor should report saved settings posture: {line}"
13241        );
13242        assert!(
13243            line.contains("approval_policy=on-request (default)"),
13244            "text doctor should keep unset config approval_policy default: {line}"
13245        );
13246
13247        let report = doctor_setup_report_json(&config, &workspace);
13248        assert_eq!(
13249            report["runtime_posture"]["permission_posture"]["value"],
13250            "full-access"
13251        );
13252        assert_eq!(
13253            report["runtime_posture"]["permission_posture"]["source"],
13254            "settings"
13255        );
13256        assert_eq!(
13257            report["runtime_posture"]["approval_policy"]["value"],
13258            "on-request"
13259        );
13260        assert_eq!(
13261            report["runtime_posture"]["approval_policy"]["source"],
13262            "default"
13263        );
13264    }
13265
13266    #[test]
13267    fn doctor_setup_report_json_fails_closed_without_operate_receipts() {
13268        let _guard = crate::test_support::lock_test_env();
13269        let tmp = TempDir::new().expect("tempdir");
13270        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13271        let workspace = tmp.path().join("workspace");
13272        fs::create_dir_all(&workspace).expect("workspace");
13273        let mut state = codewhale_config::SetupState::default();
13274        state.set_step(
13275            codewhale_config::SetupStep::Language,
13276            codewhale_config::StepEntry::new(
13277                codewhale_config::StepStatus::Verified,
13278                true,
13279                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13280            ),
13281        );
13282        state.set_step(
13283            codewhale_config::SetupStep::ProviderModel,
13284            codewhale_config::StepEntry::new(
13285                codewhale_config::StepStatus::Verified,
13286                true,
13287                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13288            ),
13289        );
13290        state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13291        state.complete_constitution_checkpoint(
13292            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13293            codewhale_config::ConstitutionChoice::Bundled,
13294        );
13295        state.set_step(
13296            codewhale_config::SetupStep::OperateFleet,
13297            codewhale_config::StepEntry::new(
13298                codewhale_config::StepStatus::Verified,
13299                false,
13300                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13301            )
13302            .with_result(
13303                "provider=ready, runtime=ready, roster=ready, concurrency=plan limit not probed",
13304            ),
13305        );
13306        state.save().expect("persist setup state");
13307
13308        let config = Config {
13309            api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13310            ..Config::default()
13311        };
13312        let report = doctor_setup_report_json(&config, &workspace);
13313
13314        assert_eq!(report["first_run_ready"], true);
13315        assert_eq!(report["operate_ready"], false);
13316        assert_eq!(
13317            report["operate_fleet"]["concurrency"]["plan_limit_probed"],
13318            false
13319        );
13320        assert!(
13321            report["operate_fleet"]["roster"]["built_in"]
13322                .as_u64()
13323                .is_some_and(|count| count > 0)
13324        );
13325        let operate_step = report["steps"]
13326            .as_array()
13327            .expect("steps array")
13328            .iter()
13329            .find(|step| step["step"] == "operate_fleet")
13330            .expect("operate/fleet step");
13331        assert_eq!(operate_step["status"], "verified");
13332        assert!(
13333            operate_step["result"]
13334                .as_str()
13335                .is_some_and(|result| result.contains("plan limit not probed"))
13336        );
13337    }
13338}
13339
13340#[cfg(test)]
13341mod doctor_endpoint_tests {
13342    use super::*;
13343
13344    #[test]
13345    fn doctor_api_target_reports_default_endpoint() {
13346        let config = Config::default();
13347
13348        let target = doctor_api_target(&config);
13349
13350        assert_eq!(target.provider, "deepseek");
13351        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13352        assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13353        assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13354    }
13355
13356    #[test]
13357    fn doctor_api_target_falls_back_to_configured_model_when_resolution_fails() {
13358        // `custom` with no custom provider table cannot resolve an identity;
13359        // doctor must fall back to the raw configured model and say so
13360        // instead of presenting an unresolved value as the engine's route.
13361        let config = Config {
13362            provider: Some("custom".to_string()),
13363            ..Default::default()
13364        };
13365
13366        let target = doctor_api_target(&config);
13367
13368        assert_eq!(target.resolution, DoctorModelResolution::ConfiguredOnly);
13369        assert_eq!(target.model, config.default_model());
13370    }
13371
13372    #[test]
13373    fn doctor_api_target_routes_deepseek_cn_alias_to_beta_endpoint() {
13374        let config = Config {
13375            provider: Some("deepseek-cn".to_string()),
13376            ..Default::default()
13377        };
13378
13379        let target = doctor_api_target(&config);
13380
13381        assert_eq!(target.provider, "deepseek-cn");
13382        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
13383        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13384        assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13385        assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13386    }
13387
13388    #[test]
13389    fn strict_tool_mode_doctor_reports_disabled_by_default() {
13390        let config = Config::default();
13391
13392        let status = doctor_strict_tool_mode_status(&config);
13393
13394        assert!(!status.enabled);
13395        assert_eq!(status.status, "disabled");
13396        assert!(!status.function_strict_sent);
13397        assert!(status.recommended_base_url.is_none());
13398    }
13399
13400    #[test]
13401    fn doctor_known_base_urls_are_ascii_case_insensitive() {
13402        assert!(doctor_xiaomi_mimo_base_url_uses_token_plan(
13403            "HTTPS://TOKEN-PLAN-CN.XIAOMIMIMO.COM/V1/"
13404        ));
13405        assert_eq!(
13406            known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/BETA/"),
13407            Some(DeepSeekBaseUrlKind::Beta)
13408        );
13409        assert_eq!(
13410            known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/V1/"),
13411            Some(DeepSeekBaseUrlKind::NonBeta)
13412        );
13413    }
13414
13415    #[test]
13416    fn strict_tool_mode_doctor_accepts_default_beta_endpoint() {
13417        let config = Config {
13418            strict_tool_mode: Some(true),
13419            ..Default::default()
13420        };
13421
13422        let status = doctor_strict_tool_mode_status(&config);
13423
13424        assert!(status.enabled);
13425        assert_eq!(status.status, "ready");
13426        assert!(status.function_strict_sent);
13427        assert!(status.message.contains("beta endpoint"));
13428        assert!(status.recommended_base_url.is_none());
13429    }
13430
13431    #[test]
13432    fn strict_tool_mode_doctor_warns_for_non_beta_deepseek_endpoint() {
13433        let config = Config {
13434            strict_tool_mode: Some(true),
13435            base_url: Some("https://api.deepseek.com".to_string()),
13436            ..Default::default()
13437        };
13438
13439        let status = doctor_strict_tool_mode_status(&config);
13440
13441        assert_eq!(status.status, "fallback_non_beta");
13442        assert!(!status.function_strict_sent);
13443        assert_eq!(
13444            status.recommended_base_url.as_deref(),
13445            Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL)
13446        );
13447        assert_eq!(
13448            doctor_strict_tool_mode_report_json(&status)["recommended_base_url"],
13449            "https://api.deepseek.com"
13450        );
13451    }
13452
13453    #[test]
13454    fn strict_tool_mode_doctor_accepts_deepseek_cn_alias_default_endpoint() {
13455        let config = Config {
13456            provider: Some("deepseek-cn".to_string()),
13457            strict_tool_mode: Some(true),
13458            ..Default::default()
13459        };
13460
13461        let status = doctor_strict_tool_mode_status(&config);
13462
13463        assert_eq!(status.status, "ready");
13464        assert!(status.function_strict_sent);
13465        assert!(status.message.contains("beta endpoint"));
13466        assert!(status.recommended_base_url.is_none());
13467    }
13468
13469    #[test]
13470    fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() {
13471        let config = Config {
13472            provider: Some("vllm".to_string()),
13473            strict_tool_mode: Some(true),
13474            ..Default::default()
13475        };
13476
13477        let status = doctor_strict_tool_mode_status(&config);
13478
13479        assert_eq!(status.status, "custom_endpoint");
13480        assert!(status.function_strict_sent);
13481        assert!(status.message.contains("custom endpoint"));
13482    }
13483
13484    #[test]
13485    fn doctor_tls_status_reports_verification_enabled_by_default() {
13486        let status = doctor_tls_status(&Config::default());
13487
13488        assert!(status.certificate_verification);
13489        assert!(!status.insecure_skip_tls_verify);
13490        assert_eq!(status.provider, "deepseek");
13491        assert!(status.message.contains("enabled"));
13492    }
13493
13494    #[test]
13495    fn doctor_tls_status_warns_when_active_provider_skips_verification() {
13496        let mut providers = crate::config::ProvidersConfig::default();
13497        providers.openai.insecure_skip_tls_verify = Some(true);
13498        let config = Config {
13499            provider: Some("openai".to_string()),
13500            providers: Some(providers),
13501            ..Default::default()
13502        };
13503
13504        let status = doctor_tls_status(&config);
13505
13506        assert!(status.certificate_verification);
13507        assert!(status.insecure_skip_tls_verify);
13508        assert_eq!(status.provider, "openai");
13509        assert!(status.message.contains("cannot be disabled"));
13510        assert!(status.message.contains("SSL_CERT_FILE"));
13511    }
13512
13513    #[test]
13514    fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
13515        let mut config = Config {
13516            default_text_model: Some("deepseek-chat".to_string()),
13517            ..Default::default()
13518        };
13519        crate::config::normalize_model_config_for_test(&mut config);
13520
13521        let report = provider_capability_report(&config);
13522
13523        assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13524        assert_eq!(report["context_window"], 1_000_000);
13525        assert_eq!(report["thinking_supported"], true);
13526        assert_eq!(report["alias_deprecation"]["alias"], "deepseek-chat");
13527        assert_eq!(
13528            report["alias_deprecation"]["replacement"],
13529            "deepseek-v4-flash"
13530        );
13531        assert_eq!(
13532            report["alias_deprecation"]["retirement_utc"],
13533            "2026-07-24T15:59:00Z"
13534        );
13535    }
13536
13537    #[test]
13538    fn provider_capability_report_preserves_custom_deepseek_alias_namespace() {
13539        let mut config = Config {
13540            base_url: Some("https://models.example/v1".to_string()),
13541            default_text_model: Some("deepseek-chat".to_string()),
13542            ..Default::default()
13543        };
13544        crate::config::normalize_model_config_for_test(&mut config);
13545
13546        let report = provider_capability_report(&config);
13547
13548        assert_eq!(report["resolved_model"], "deepseek-chat");
13549        assert!(report["alias_deprecation"].is_null());
13550    }
13551
13552    #[test]
13553    fn provider_capability_report_leaves_canonical_flash_alias_metadata_null() {
13554        let config = Config {
13555            default_text_model: Some("deepseek-v4-flash".to_string()),
13556            ..Default::default()
13557        };
13558
13559        let report = provider_capability_report(&config);
13560
13561        assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13562        assert!(report["alias_deprecation"].is_null());
13563    }
13564
13565    #[test]
13566    fn doctor_route_report_exposes_tokenhub_openai_compatible_route_without_secret() {
13567        let mut providers = crate::config::ProvidersConfig::default();
13568        providers.openai.api_key = Some("tokenhub-secret-value".to_string());
13569        providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
13570        providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
13571        let config = Config {
13572            provider: Some("openai".to_string()),
13573            providers: Some(providers),
13574            ..Default::default()
13575        };
13576
13577        let report = doctor_route_report(&config);
13578        let serialized = report.to_string();
13579
13580        assert_eq!(report["provider"], "openai");
13581        assert_eq!(report["provider_source"], "config");
13582        assert_eq!(report["provider_config_table"], "openai");
13583        assert_eq!(report["model"], "deepseek-ai/DeepSeek-V4-Pro");
13584        assert_eq!(report["wire_protocol"], "chat_completions");
13585        assert_eq!(
13586            report["base_url"]["redacted"],
13587            "https://tokenhub.tencentmaas.com"
13588        );
13589        assert_eq!(report["base_url"]["class"], "custom");
13590        assert_eq!(report["auth"]["scheme"], "bearer");
13591        assert_eq!(report["auth"]["source"], "config_declared");
13592        assert!(
13593            report["base_url"]["fingerprint"]
13594                .as_str()
13595                .is_some_and(|value| value.starts_with("<redacted:"))
13596        );
13597        assert!(!serialized.contains("tokenhub-secret-value"));
13598    }
13599
13600    #[test]
13601    fn doctor_route_report_exposes_siliconflow_cn_provider_route() {
13602        let mut providers = crate::config::ProvidersConfig::default();
13603        providers.siliconflow_cn.api_key = Some("sf-cn-secret-value".to_string());
13604        providers.siliconflow_cn.base_url =
13605            Some(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
13606        providers.siliconflow_cn.model = Some(crate::config::DEFAULT_SILICONFLOW_MODEL.to_string());
13607        let config = Config {
13608            provider: Some("siliconflow-CN".to_string()),
13609            providers: Some(providers),
13610            ..Default::default()
13611        };
13612
13613        let report = doctor_route_report(&config);
13614        let serialized = report.to_string();
13615
13616        assert_eq!(report["provider"], "siliconflow-CN");
13617        assert_eq!(report["provider_config_table"], "siliconflow_cn");
13618        assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
13619        assert_eq!(
13620            report["base_url"]["redacted"],
13621            crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
13622        );
13623        assert_eq!(report["base_url"]["class"], "default");
13624        assert_eq!(report["auth"]["scheme"], "bearer");
13625        assert_eq!(report["auth"]["source"], "config_declared");
13626        assert!(!serialized.contains("sf-cn-secret-value"));
13627    }
13628
13629    #[test]
13630    fn doctor_route_report_names_kimi_code_context_provenance() {
13631        let config = Config {
13632            provider: Some("moonshot".to_string()),
13633            providers: Some(crate::config::ProvidersConfig {
13634                moonshot: crate::config::ProviderConfig {
13635                    api_key: Some("kimi-plan-secret".to_string()),
13636                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13637                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13638                    ..Default::default()
13639                },
13640                ..Default::default()
13641            }),
13642            ..Default::default()
13643        };
13644
13645        let report = doctor_route_report(&config);
13646        let serialized = report.to_string();
13647
13648        assert_eq!(report["context_window"]["tokens"], 262_144);
13649        assert_eq!(
13650            report["context_window"]["source"],
13651            "static Kimi Code safe floor"
13652        );
13653        assert!(!serialized.contains("kimi-plan-secret"));
13654    }
13655
13656    #[test]
13657    fn provider_capability_report_uses_exact_kimi_code_route_facts() {
13658        let config = Config {
13659            provider: Some("moonshot".to_string()),
13660            providers: Some(crate::config::ProvidersConfig {
13661                moonshot: crate::config::ProviderConfig {
13662                    api_key: Some("kimi-plan-secret".to_string()),
13663                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13664                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13665                    ..Default::default()
13666                },
13667                ..Default::default()
13668            }),
13669            ..Default::default()
13670        };
13671
13672        let report = provider_capability_report(&config);
13673
13674        assert_eq!(report["resolved_model"], crate::config::KIMI_CODE_K3_MODEL);
13675        assert_eq!(report["context_window"], 262_144);
13676        assert_eq!(
13677            report["context_window_source"],
13678            "static Kimi Code safe floor"
13679        );
13680        assert_eq!(report["thinking_supported"], true);
13681    }
13682
13683    #[test]
13684    fn provider_capability_report_honors_kimi_code_context_override() {
13685        let config = Config {
13686            provider: Some("moonshot".to_string()),
13687            providers: Some(crate::config::ProvidersConfig {
13688                moonshot: crate::config::ProviderConfig {
13689                    api_key: Some("kimi-plan-secret".to_string()),
13690                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13691                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13692                    context_window: Some(1_048_576),
13693                    ..Default::default()
13694                },
13695                ..Default::default()
13696            }),
13697            ..Default::default()
13698        };
13699
13700        let report = provider_capability_report(&config);
13701
13702        assert_eq!(
13703            report["resolved_model"],
13704            crate::config::KIMI_CODE_K3_MODEL,
13705            "the configured window must preserve Kimi Code's bare wire id"
13706        );
13707        assert_eq!(report["context_window"], 1_048_576);
13708        assert_eq!(report["context_window_source"], "configured");
13709        assert_eq!(report["thinking_supported"], true);
13710    }
13711
13712    #[test]
13713    fn provider_capability_report_uses_direct_moonshot_k3_route_facts() {
13714        let config = Config {
13715            provider: Some("moonshot".to_string()),
13716            providers: Some(crate::config::ProvidersConfig {
13717                moonshot: crate::config::ProviderConfig {
13718                    api_key: Some("moonshot-secret".to_string()),
13719                    base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
13720                    model: Some("kimi-k3".to_string()),
13721                    ..Default::default()
13722                },
13723                ..Default::default()
13724            }),
13725            ..Default::default()
13726        };
13727
13728        let report = provider_capability_report(&config);
13729
13730        assert_eq!(report["resolved_model"], "kimi-k3");
13731        assert_eq!(report["context_window"], 1_048_576);
13732        assert_eq!(report["context_window_source"], "catalog");
13733        assert_eq!(report["max_output"], 1_048_576);
13734        assert_eq!(report["thinking_supported"], true);
13735    }
13736
13737    #[test]
13738    fn doctor_search_provider_line_includes_firecrawl_default_source_and_switch_hint() {
13739        let _guard = crate::test_support::lock_test_env();
13740        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13741        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13742
13743        let line = doctor_search_provider_line(&Config::default());
13744
13745        match prev {
13746            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13747            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13748        }
13749        assert!(line.contains("search_provider: firecrawl"));
13750        assert!(line.contains("source: default"));
13751        assert!(line.contains("[search] provider"));
13752        assert!(line.contains("provider = \"baidu\""));
13753    }
13754
13755    #[test]
13756    fn doctor_search_provider_json_reports_config_source() {
13757        let _guard = crate::test_support::lock_test_env();
13758        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13759        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13760        let config = Config {
13761            search: Some(crate::config::SearchConfig {
13762                provider: Some(crate::config::SearchProvider::DuckDuckGo),
13763                base_url: None,
13764                api_key: None,
13765            }),
13766            ..Default::default()
13767        };
13768
13769        let report = doctor_search_provider_json(&config);
13770
13771        match prev {
13772            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13773            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13774        }
13775        assert_eq!(report["provider"], "duckduckgo");
13776        assert_eq!(report["source"], "config");
13777    }
13778
13779    #[test]
13780    fn doctor_search_provider_json_reports_env_override_source() {
13781        let _guard = crate::test_support::lock_test_env();
13782        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13783        unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", "tavily") };
13784
13785        let report = doctor_search_provider_json(&Config::default());
13786
13787        match prev {
13788            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13789            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13790        }
13791        assert_eq!(report["provider"], "tavily");
13792        assert_eq!(report["source"], "env override");
13793    }
13794
13795    #[test]
13796    fn doctor_search_provider_line_omits_switch_hint_when_bing_is_configured() {
13797        let _guard = crate::test_support::lock_test_env();
13798        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13799        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13800        let config = Config {
13801            search: Some(crate::config::SearchConfig {
13802                provider: Some(crate::config::SearchProvider::Bing),
13803                base_url: None,
13804                api_key: None,
13805            }),
13806            ..Default::default()
13807        };
13808
13809        let line = doctor_search_provider_line(&config);
13810
13811        match prev {
13812            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13813            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13814        }
13815        assert!(line.contains("search_provider: bing"));
13816        assert!(line.contains("source: config"));
13817        assert!(!line.contains("[search] provider"));
13818    }
13819
13820    #[test]
13821    fn timeout_recovery_keeps_default_deepseek_users_on_default_endpoint() {
13822        let config = Config::default();
13823
13824        let text = doctor_timeout_recovery_lines(&config).join("\n");
13825
13826        assert!(text.contains("api.deepseek.com"));
13827        assert!(text.contains("custom DeepSeek-compatible endpoint"));
13828        assert!(!text.contains("provider = \"deepseek-cn\""));
13829        assert!(text.contains("codewhale doctor --json"));
13830    }
13831
13832    #[test]
13833    fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
13834        let config = Config {
13835            provider: Some("vllm".to_string()),
13836            ..Default::default()
13837        };
13838
13839        let text = doctor_timeout_recovery_lines(&config).join("\n");
13840
13841        assert!(text.contains("/v1/models"));
13842        assert!(text.contains("/v1/chat/completions"));
13843        assert!(!text.contains("api.deepseeki.com"));
13844    }
13845}
13846
13847#[cfg(test)]
13848mod terminal_mode_tests {
13849    use super::*;
13850    use clap::Parser;
13851
13852    fn parse_cli(args: &[&str]) -> Cli {
13853        Cli::try_parse_from(args).expect("CLI args should parse")
13854    }
13855
13856    #[test]
13857    fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
13858        let config = Config {
13859            network: Some(crate::config::NetworkPolicyToml {
13860                default: "allow".to_string(),
13861                audit: false,
13862                ..crate::config::NetworkPolicyToml::default()
13863            }),
13864            ..Config::default()
13865        };
13866        let authority = crate::tools::spec::ToolAuthorityEnvelope {
13867            schema_version: 1,
13868            owner: "consultant-1".to_string(),
13869            authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
13870            network_access: Some(false),
13871            shell: crate::tools::spec::ToolShellAuthority::None,
13872            verification: crate::tools::spec::ToolVerificationAuthority::None,
13873            writable_roots: Vec::new(),
13874            writable_files: Vec::new(),
13875            coordination_contracts: Vec::new(),
13876        }
13877        .normalized()
13878        .expect("Consultant authority");
13879
13880        let policy = exec_network_policy(&config, authority.network_access)
13881            .expect("explicit network=false always installs a policy");
13882        assert_eq!(
13883            policy.evaluate("example.com", "web_search"),
13884            crate::network_policy::Decision::Deny,
13885            "the permissive user config must not widen Consultant network authority"
13886        );
13887        let mut features = crate::features::Features::default();
13888        features.enable(crate::features::Feature::ShellTool);
13889        features.enable(crate::features::Feature::WebSearch);
13890        apply_fleet_engine_feature_caps(
13891            &mut features,
13892            true,
13893            authority.network_access,
13894            authority.shell,
13895        );
13896        assert!(!features.enabled(crate::features::Feature::WebSearch));
13897        assert!(!features.enabled(crate::features::Feature::ShellTool));
13898
13899        let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
13900        assert_eq!(
13901            worker_policy.evaluate("example.com", "web_search"),
13902            crate::network_policy::Decision::Allow,
13903            "a network-capable role keeps the configured policy"
13904        );
13905    }
13906    #[test]
13907    fn hidden_remote_control_flag_starts_the_interactive_handoff() {
13908        let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
13909        assert!(cli.remote_control);
13910    }
13911
13912    #[test]
13913    fn plugin_registry_discovery_is_route_independent_and_read_only() {
13914        let _env_lock = crate::test_support::lock_test_env();
13915        let temp = tempfile::tempdir().unwrap();
13916        let workspace = temp.path().join("workspace");
13917        let codewhale_home = temp.path().join("home");
13918        std::fs::create_dir_all(&workspace).unwrap();
13919        let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
13920        let workspace_arg = workspace.to_string_lossy().into_owned();
13921
13922        for route in [
13923            Vec::<&str>::new(),
13924            vec!["resume", "--last"],
13925            vec!["fork", "--last"],
13926            vec!["exec", "hello"],
13927            vec!["serve", "--mcp"],
13928        ] {
13929            let mut args = vec![
13930                "codewhale-tui".to_string(),
13931                "--workspace".to_string(),
13932                workspace_arg.clone(),
13933            ];
13934            args.extend(route.into_iter().map(str::to_string));
13935            let cli = Cli::try_parse_from(args).expect("route should parse");
13936            let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
13937            let registry = discovery
13938                .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
13939            assert_eq!(registry.workspace(), workspace.as_path());
13940            assert!(
13941                !codewhale_home.join("plugins/state.json").exists(),
13942                "startup discovery must remain read-only"
13943            );
13944        }
13945    }
13946
13947    fn custom_exec_config(active: &str) -> Config {
13948        let mut custom = std::collections::HashMap::new();
13949        for (name, base_url, model) in [
13950            (
13951                "custom-a",
13952                "http://127.0.0.1:18181/v1",
13953                crate::config::ZAI_GLM_5_2_MODEL,
13954            ),
13955            ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
13956        ] {
13957            custom.insert(
13958                name.to_string(),
13959                crate::config::ProviderConfig {
13960                    kind: Some("openai-compatible".to_string()),
13961                    base_url: Some(base_url.to_string()),
13962                    model: Some(model.to_string()),
13963                    api_key: Some("local-test-key".to_string()),
13964                    ..Default::default()
13965                },
13966            );
13967        }
13968        Config {
13969            provider: Some(active.to_string()),
13970            providers: Some(crate::config::ProvidersConfig {
13971                custom,
13972                ..Default::default()
13973            }),
13974            ..Default::default()
13975        }
13976    }
13977
13978    #[test]
13979    fn doctor_json_surfaces_keep_exact_named_custom_provider() {
13980        let config = custom_exec_config("custom-a");
13981        let workspace = tempfile::tempdir().expect("doctor workspace");
13982
13983        let operate = doctor_operate_fleet_report_json(&config, workspace.path());
13984        let provider_model = doctor_provider_model_report_json(&config);
13985        let capability = provider_capability_report(&config);
13986        let route = doctor_route_report(&config);
13987
13988        assert_eq!(operate["provider"]["id"], "custom-a");
13989        assert_eq!(provider_model["provider"]["id"], "custom-a");
13990        assert_eq!(capability["resolved_provider"], "custom-a");
13991        assert_eq!(route["provider"], "custom-a");
13992        assert_eq!(route["provider_config_table"], "providers.custom-a");
13993        let serialized = serde_json::to_string(&serde_json::json!({
13994            "operate": operate,
13995            "provider_model": provider_model,
13996            "capability": capability,
13997            "route": route,
13998        }))
13999        .expect("doctor JSON");
14000        assert!(!serialized.contains("local-test-key"));
14001    }
14002
14003    fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
14004        let mut saved = session_manager::create_saved_session_with_mode(
14005            &[],
14006            model,
14007            Path::new("/tmp/exec-resume"),
14008            0,
14009            None,
14010            Some("exec"),
14011        );
14012        let kind = crate::config::ApiProvider::parse(provider)
14013            .unwrap_or(crate::config::ApiProvider::Custom)
14014            .as_str();
14015        let exact_id = (!provider
14016            .eq_ignore_ascii_case(crate::config::ApiProvider::Custom.as_str()))
14017        .then_some(provider);
14018        saved.metadata.set_model_provider_route(kind, exact_id);
14019        saved
14020    }
14021
14022    #[test]
14023    fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
14024        let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
14025
14026        assert_eq!(cli.prompt, vec!["hello", "world"]);
14027    }
14028
14029    #[test]
14030    fn prompt_flag_starts_interactive_submit_input() {
14031        let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
14032
14033        assert_eq!(
14034            top_level_prompt_initial_input(&cli.prompt),
14035            Some(tui::InitialInput::Submit("read the project".to_string()))
14036        );
14037    }
14038
14039    #[test]
14040    fn companion_binary_reports_its_own_name() {
14041        assert_eq!(Cli::command().get_name(), "codewhale-tui");
14042    }
14043
14044    #[test]
14045    fn xai_device_auth_subcommand_parses() {
14046        let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
14047        assert!(matches!(
14048            cli.command,
14049            Some(Commands::Auth(TuiAuthArgs {
14050                command: TuiAuthCommand::XaiDevice
14051            }))
14052        ));
14053    }
14054
14055    #[test]
14056    fn workflow_tool_internal_subcommand_parses_exact_json() {
14057        let cli = parse_cli(&[
14058            "codewhale-tui",
14059            "workflow-tool",
14060            "--approval-source",
14061            "explicit-workflow-command",
14062            "--input-json",
14063            r#"{"action":"run","source_path":"workflows/demo.js"}"#,
14064        ]);
14065        let Some(Commands::WorkflowTool(args)) = cli.command else {
14066            panic!("expected workflow-tool command");
14067        };
14068        assert!(args.input_json.contains("\"action\":\"run\""));
14069    }
14070
14071    #[tokio::test]
14072    async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
14073        use crate::tools::spec::ToolSpec;
14074
14075        let workspace = tempfile::tempdir().expect("workspace");
14076        let config = Config {
14077            provider: Some("vllm".to_string()),
14078            mcp_config_path: Some(
14079                workspace
14080                    .path()
14081                    .join("missing-mcp.json")
14082                    .display()
14083                    .to_string(),
14084            ),
14085            providers: Some(crate::config::ProvidersConfig {
14086                vllm: crate::config::ProviderConfig {
14087                    base_url: Some("http://127.0.0.1:9/v1".to_string()),
14088                    model: Some("offline-test-model".to_string()),
14089                    ..Default::default()
14090                },
14091                ..Default::default()
14092            }),
14093            ..Default::default()
14094        };
14095        let route = CliAutoRoute {
14096            provider: crate::config::ApiProvider::Vllm,
14097            model: "offline-test-model".to_string(),
14098            reasoning_effort: None,
14099            auto_controls_reasoning: false,
14100            auto_model: false,
14101        };
14102        let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
14103        let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14104        let (tool, context) =
14105            build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
14106                .await
14107                .expect("build direct workflow runtime");
14108
14109        let result = tool
14110            .execute(
14111                serde_json::json!({
14112                    "action": "run",
14113                    "script": "phase('offline'); return { ok: true };",
14114                    "token_budget": 1_000_000
14115                }),
14116                &context,
14117            )
14118            .await
14119            .expect("model-free workflow run");
14120        let payload: serde_json::Value =
14121            serde_json::from_str(&result.content).expect("workflow JSON");
14122
14123        assert_eq!(payload["status"], "completed");
14124        assert_eq!(payload["result"]["ok"], true);
14125        assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
14126        assert_eq!(
14127            payload["plan_approval"]["decision"],
14128            "approved_explicit_cli_command"
14129        );
14130        assert!(!context.auto_approve);
14131        assert!(!context.trust_mode);
14132        assert_eq!(
14133            context.shell_policy,
14134            crate::worker_profile::ShellPolicy::None
14135        );
14136        assert!(matches!(
14137            context.elevated_sandbox_policy,
14138            Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
14139        ));
14140        let mut event_types = Vec::new();
14141        while let Ok(event) = event_rx.try_recv() {
14142            if let crate::core::events::Event::WorkflowUi { event, .. } = event
14143                && let Some(kind) = event["type"].as_str()
14144            {
14145                event_types.push(kind.to_string());
14146            }
14147        }
14148        assert!(event_types.iter().any(|kind| kind == "run_started"));
14149        assert!(event_types.iter().any(|kind| kind == "run_completed"));
14150    }
14151
14152    #[tokio::test]
14153    async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
14154        let workspace = tempfile::tempdir().expect("workspace");
14155        let mcp_path = workspace.path().join("mcp.json");
14156        std::fs::write(
14157            &mcp_path,
14158            r#"{
14159                "mcpServers": {
14160                    "blocked": { "url": "https://blocked.invalid/mcp" }
14161                }
14162            }"#,
14163        )
14164        .expect("write MCP config");
14165        let config = Config {
14166            mcp_config_path: Some(mcp_path.display().to_string()),
14167            ..Default::default()
14168        };
14169        let policy = crate::network_policy::NetworkPolicyDecider::new(
14170            crate::network_policy::NetworkPolicy {
14171                default: crate::network_policy::DecisionToml::Deny,
14172                allow: Vec::new(),
14173                deny: Vec::new(),
14174                proxy: Vec::new(),
14175                proxy_fake_ip_cidrs: Vec::new(),
14176                audit: false,
14177            },
14178            None,
14179        );
14180
14181        let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14182        let (_pool, failures) =
14183            initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
14184                .await
14185                .expect("MCP feature enabled");
14186        assert_eq!(failures.len(), 1, "failures={failures:?}");
14187        assert_eq!(failures[0].0, "blocked");
14188        assert!(failures[0].1.contains("blocked by network policy"));
14189    }
14190
14191    #[test]
14192    fn exec_model_resolution_uses_provider_scoped_default() {
14193        let _env_lock = crate::test_support::lock_test_env();
14194        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14195        let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14196        let config = Config {
14197            provider: Some("openrouter".to_string()),
14198            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14199            providers: Some(crate::config::ProvidersConfig {
14200                openrouter: crate::config::ProviderConfig {
14201                    model: Some("arcee-ai/trinity-large-thinking".to_string()),
14202                    ..Default::default()
14203                },
14204                ..Default::default()
14205            }),
14206            ..Default::default()
14207        };
14208
14209        assert_eq!(
14210            resolve_exec_model(&config, None),
14211            "arcee-ai/trinity-large-thinking"
14212        );
14213        assert_eq!(
14214            resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
14215            "arcee-ai/trinity-large-thinking"
14216        );
14217    }
14218
14219    #[test]
14220    fn exec_model_resolution_prefers_codewhale_model_env_override() {
14221        let _env_lock = crate::test_support::lock_test_env();
14222        let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
14223        let _deepseek_model =
14224            crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
14225        let config = Config {
14226            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14227            ..Default::default()
14228        };
14229
14230        assert_eq!(resolve_exec_model(&config, None), "auto");
14231    }
14232
14233    #[test]
14234    fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
14235        let _env_lock = crate::test_support::lock_test_env();
14236        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14237        let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
14238        let config = Config {
14239            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14240            ..Default::default()
14241        };
14242
14243        assert_eq!(resolve_exec_model(&config, None), "auto");
14244    }
14245
14246    #[test]
14247    fn exec_model_resolution_uses_provider_safe_default_for_zai() {
14248        let _env_lock = crate::test_support::lock_test_env();
14249        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14250        let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14251        let config = Config {
14252            provider: Some("zai".to_string()),
14253            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14254            ..Default::default()
14255        };
14256
14257        assert_eq!(
14258            resolve_exec_model(&config, None),
14259            crate::config::DEFAULT_ZAI_MODEL
14260        );
14261    }
14262
14263    #[tokio::test]
14264    #[allow(clippy::await_holding_lock)]
14265    async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
14266        let _env_lock = crate::test_support::lock_test_env();
14267        let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14268        let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
14269        let config = Config {
14270            provider: Some("deepseek".to_string()),
14271            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14272            ..Default::default()
14273        };
14274
14275        let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14276            .await
14277            .expect("explicit GLM should route to the configured Z.ai provider");
14278
14279        assert_eq!(route.provider, crate::config::ApiProvider::Zai);
14280        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14281        assert!(!route.auto_model);
14282    }
14283
14284    #[tokio::test]
14285    #[allow(clippy::await_holding_lock)]
14286    async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
14287        let _env_lock = crate::test_support::lock_test_env();
14288        let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14289        let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
14290        let config = Config {
14291            provider: Some("deepseek".to_string()),
14292            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14293            ..Default::default()
14294        };
14295
14296        let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14297            .await
14298            .expect_err("ambiguous GLM route should ask for an explicit provider");
14299        let message = err.to_string();
14300
14301        assert!(message.contains("model `GLM-5.2` is available"));
14302        assert!(message.contains("openrouter"));
14303        assert!(message.contains("zai"));
14304        assert!(message.contains("--provider"));
14305        assert!(message.contains("/provider"));
14306        assert!(message.contains("/model"));
14307        assert!(message.contains("/setup"));
14308    }
14309
14310    #[tokio::test]
14311    async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
14312        let config = Config {
14313            provider: Some("vllm".to_string()),
14314            reasoning_effort: Some("low".to_string()),
14315            providers: Some(crate::config::ProvidersConfig {
14316                vllm: crate::config::ProviderConfig {
14317                    base_url: Some("http://127.0.0.1:18190/v1".to_string()),
14318                    model: Some("local-auto-model".to_string()),
14319                    ..Default::default()
14320                },
14321                ..Default::default()
14322            }),
14323            ..Default::default()
14324        };
14325
14326        let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
14327            .await
14328            .expect("Auto model route");
14329
14330        assert!(route.auto_model);
14331        assert_eq!(
14332            route.reasoning_effort,
14333            Some(crate::tui::app::ReasoningEffort::Low)
14334        );
14335        assert!(
14336            !route.auto_controls_reasoning,
14337            "a fixed saved tier must not be replaced per prompt"
14338        );
14339    }
14340
14341    #[test]
14342    fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
14343        let mut providers = crate::config::ProvidersConfig::default();
14344        providers.deepseek.model = Some("deepseek-v4-pro".to_string());
14345        let config = Config {
14346            provider: Some("deepseek".to_string()),
14347            providers: Some(providers),
14348            ..Default::default()
14349        };
14350        let route = CliAutoRoute {
14351            provider: crate::config::ApiProvider::Deepseek,
14352            model: "deepseek-v4-flash".to_string(),
14353            reasoning_effort: None,
14354            auto_controls_reasoning: true,
14355            auto_model: true,
14356        };
14357
14358        let execution_config = config_for_cli_route(&config, &route);
14359
14360        assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
14361        assert_eq!(
14362            execution_config
14363                .provider_config_for(crate::config::ApiProvider::Deepseek)
14364                .and_then(|entry| entry.model.as_deref()),
14365            Some("deepseek-v4-flash")
14366        );
14367    }
14368
14369    #[test]
14370    fn cli_route_execution_config_preserves_legacy_literal_custom_root_route() {
14371        let _lock = crate::test_support::lock_test_env();
14372        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
14373        let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
14374        let config = Config {
14375            provider: Some("custom".to_string()),
14376            api_key: Some("legacy-root-key".to_string()),
14377            base_url: Some("http://127.0.0.1:18183/v1".to_string()),
14378            default_text_model: Some("legacy-model".to_string()),
14379            ..Default::default()
14380        };
14381        let route = CliAutoRoute {
14382            provider: crate::config::ApiProvider::Custom,
14383            model: "routed-legacy-model".to_string(),
14384            reasoning_effort: None,
14385            auto_controls_reasoning: false,
14386            auto_model: false,
14387        };
14388
14389        let execution = config_for_cli_route(&config, &route);
14390
14391        assert!(execution.uses_legacy_literal_custom_route());
14392        assert!(
14393            execution
14394                .providers
14395                .as_ref()
14396                .is_none_or(|providers| !providers.custom.contains_key("custom"))
14397        );
14398        assert_eq!(execution.provider.as_deref(), Some("custom"));
14399        assert_eq!(execution.default_model(), "routed-legacy-model");
14400        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18183/v1");
14401        assert_eq!(execution.deepseek_api_key().unwrap(), "legacy-root-key");
14402        for _ in 0..2 {
14403            let identity = execution
14404                .resolve_provider_identity("custom")
14405                .expect("legacy identity remains repeatedly resolvable");
14406            assert_eq!(identity.key, "custom");
14407        }
14408        let client =
14409            crate::client::DeepSeekClient::new(&execution).expect("legacy execution client");
14410        assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
14411    }
14412
14413    #[test]
14414    fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
14415        let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
14416        let Some(Commands::Exec(args)) = cli.command else {
14417            panic!("expected exec command");
14418        };
14419
14420        assert_eq!(args.prompt, vec!["hello", "world"]);
14421    }
14422
14423    #[test]
14424    fn exec_keeps_model_flag_before_split_prompt_words() {
14425        let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
14426        let Some(Commands::Exec(args)) = cli.command else {
14427            panic!("expected exec command");
14428        };
14429
14430        assert_eq!(args.model.as_deref(), Some("auto"));
14431        assert_eq!(args.prompt, vec!["hello", "world"]);
14432    }
14433
14434    #[test]
14435    fn exec_keeps_flags_before_split_prompt_words() {
14436        let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
14437        let Some(Commands::Exec(args)) = cli.command else {
14438            panic!("expected exec command");
14439        };
14440
14441        assert!(args.json);
14442        assert_eq!(args.prompt, vec!["hello", "world"]);
14443    }
14444
14445    #[test]
14446    fn exec_parses_provider_flag_alongside_model() {
14447        // #4093: Fleet threads `--provider <id>` so a worker launches on its
14448        // profile-pinned provider even when the parent session is elsewhere.
14449        let cli = parse_cli(&[
14450            "codewhale",
14451            "exec",
14452            "--provider",
14453            "openrouter",
14454            "--model",
14455            "glm-5.2",
14456            "audit",
14457        ]);
14458        let Some(Commands::Exec(args)) = cli.command else {
14459            panic!("expected exec command");
14460        };
14461
14462        assert_eq!(args.provider.as_deref(), Some("openrouter"));
14463        assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14464        assert_eq!(args.prompt, vec!["audit"]);
14465        // The threaded id round-trips through the provider vocabulary the exec
14466        // handler validates against — never a model-id sniff (EPIC #2608).
14467        assert_eq!(
14468            crate::config::ApiProvider::parse(args.provider.as_deref().unwrap()),
14469            Some(crate::config::ApiProvider::Openrouter)
14470        );
14471    }
14472
14473    #[test]
14474    fn exec_provider_override_accepts_configured_custom_provider() {
14475        let mut custom = std::collections::HashMap::new();
14476        custom.insert(
14477            "lm-studio".to_string(),
14478            crate::config::ProviderConfig {
14479                kind: Some("openai-compatible".to_string()),
14480                base_url: Some("http://127.0.0.1:1234/v1".to_string()),
14481                model: Some("qwen-2.5-7b".to_string()),
14482                api_key: Some("lm-studio".to_string()),
14483                ..Default::default()
14484            },
14485        );
14486        let mut config = Config {
14487            provider: Some("deepseek".to_string()),
14488            providers: Some(crate::config::ProvidersConfig {
14489                custom,
14490                ..Default::default()
14491            }),
14492            ..Default::default()
14493        };
14494
14495        apply_exec_provider_override(&mut config, "lm-studio")
14496            .expect("configured custom provider should be accepted");
14497
14498        assert_eq!(config.provider.as_deref(), Some("lm-studio"));
14499        assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14500    }
14501
14502    #[test]
14503    fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
14504        let mut config = Config {
14505            provider: Some("deepseek".to_string()),
14506            providers: Some(crate::config::ProvidersConfig {
14507                custom: std::collections::HashMap::from([(
14508                    "CUSTOM".to_string(),
14509                    crate::config::ProviderConfig {
14510                        kind: Some("openai-compatible".to_string()),
14511                        base_url: Some("http://127.0.0.1:5678/v1".to_string()),
14512                        model: Some("case-model".to_string()),
14513                        api_key: Some("case-key".to_string()),
14514                        ..Default::default()
14515                    },
14516                )]),
14517                ..Default::default()
14518            }),
14519            ..Default::default()
14520        };
14521
14522        apply_exec_provider_override(&mut config, "CUSTOM")
14523            .expect("exact case-colliding custom provider");
14524        assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
14525        assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14526        assert_eq!(
14527            config.provider_identity_for(crate::config::ApiProvider::Custom),
14528            "CUSTOM"
14529        );
14530        let route = crate::route_runtime::resolve_runtime_route(
14531            &config,
14532            crate::config::ApiProvider::Custom,
14533            Some("case-model"),
14534        )
14535        .expect("resolve exact case-colliding route")
14536        .validate()
14537        .expect("preflight exact case-colliding route");
14538        assert_eq!(route.identity.key, "CUSTOM");
14539        assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
14540    }
14541
14542    #[test]
14543    fn exec_provider_override_rejects_unknown_provider() {
14544        let mut config = Config {
14545            provider: Some("deepseek".to_string()),
14546            ..Default::default()
14547        };
14548
14549        let err = apply_exec_provider_override(&mut config, "lm-studio")
14550            .expect_err("unconfigured custom provider should fail closed");
14551        let message = err.to_string();
14552
14553        assert!(message.contains("Unrecognized --provider"));
14554        assert!(message.contains("[providers.<name>] custom provider"));
14555        assert_eq!(config.provider.as_deref(), Some("deepseek"));
14556    }
14557
14558    #[test]
14559    fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
14560        let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14561
14562        let mut restored = custom_exec_config("custom-b");
14563        let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
14564            .expect("plain resume restores saved route");
14565        assert_eq!(restored.provider.as_deref(), Some("custom-a"));
14566        assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
14567
14568        let mut explicit_provider = custom_exec_config("custom-a");
14569        apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
14570        let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
14571            .expect("explicit provider wins");
14572        assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
14573        assert_eq!(model, "model-b");
14574
14575        let mut explicit_model = custom_exec_config("custom-b");
14576        let model =
14577            resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
14578                .expect("explicit model keeps saved provider");
14579        assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
14580        assert_eq!(model, "override-model");
14581
14582        let mut missing = custom_exec_config("custom-b");
14583        missing
14584            .providers
14585            .as_mut()
14586            .expect("providers")
14587            .custom
14588            .remove("custom-a");
14589        let before = missing.provider.clone();
14590        let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
14591            .expect_err("removed saved provider must fail closed");
14592        assert!(err.to_string().contains("will not fall back"), "{err}");
14593        assert_eq!(missing.provider, before);
14594    }
14595
14596    #[test]
14597    fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
14598        let (started_tx, started_rx) = std::sync::mpsc::channel();
14599        let (tx, rx) = std::sync::mpsc::channel();
14600
14601        let (reader, expected_after_restore) = {
14602            let lock = crate::test_support::lock_test_env();
14603            let expected_after_restore = exec_model_env_override();
14604            let temporary =
14605                crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
14606            let reader = std::thread::spawn(move || {
14607                started_tx.send(()).expect("signal model read start");
14608                tx.send(exec_model_env_override())
14609                    .expect("send resolved model override");
14610            });
14611
14612            started_rx
14613                .recv_timeout(std::time::Duration::from_secs(2))
14614                .expect("reader reached model read");
14615            assert!(
14616                rx.recv_timeout(std::time::Duration::from_millis(50))
14617                    .is_err(),
14618                "a foreign reader observed another test's temporary model override"
14619            );
14620            drop(temporary);
14621            drop(lock);
14622            (reader, expected_after_restore)
14623        };
14624
14625        let observed = rx
14626            .recv_timeout(std::time::Duration::from_secs(2))
14627            .expect("reader resumed after model override was restored");
14628        reader.join().expect("reader thread");
14629        assert_eq!(observed, expected_after_restore);
14630    }
14631
14632    #[tokio::test]
14633    async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
14634        let config = custom_exec_config("custom-a");
14635
14636        let route =
14637            resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
14638                .await
14639                .expect("forced route");
14640        let execution = config_for_cli_route(&config, &route);
14641
14642        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14643        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14644        assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14645    }
14646
14647    #[tokio::test]
14648    async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
14649        let mut config = custom_exec_config("custom-a");
14650        config
14651            .providers
14652            .as_mut()
14653            .expect("providers")
14654            .custom
14655            .get_mut("custom-a")
14656            .expect("custom A")
14657            .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
14658        let model = resolve_exec_model(&config, None);
14659        let force = should_force_configured_exec_route(false, None, None);
14660
14661        assert!(force, "configured/default exec route must be authoritative");
14662        assert!(!should_force_configured_exec_route(
14663            false,
14664            None,
14665            Some(crate::config::ZAI_GLM_5_2_MODEL)
14666        ));
14667        assert!(should_force_configured_exec_route(
14668            false,
14669            Some("custom-a"),
14670            Some(crate::config::ZAI_GLM_5_2_MODEL)
14671        ));
14672        assert!(should_force_configured_exec_route(
14673            true,
14674            None,
14675            Some("override-model")
14676        ));
14677
14678        let route = resolve_cli_exec_route(&config, &model, "audit", force)
14679            .await
14680            .expect("no-flag configured route");
14681        let execution = config_for_cli_route(&config, &route);
14682        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14683        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14684        assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14685    }
14686
14687    #[tokio::test]
14688    async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
14689        let mut config = custom_exec_config("custom-a");
14690        config
14691            .providers
14692            .as_mut()
14693            .expect("providers")
14694            .custom
14695            .get_mut("custom-a")
14696            .expect("custom A")
14697            .model = Some("model-a".to_string());
14698        config.default_text_model = Some("stale-root-deepseek-model".to_string());
14699        let model = resolve_review_model(&config, None);
14700        assert_eq!(model, "model-a");
14701        assert_eq!(
14702            resolve_review_model(&config, Some("explicit-review-model")),
14703            "explicit-review-model"
14704        );
14705
14706        let route = resolve_cli_exec_route(&config, &model, "review diff", true)
14707            .await
14708            .expect("configured review route");
14709        let execution = config_for_cli_route(&config, &route);
14710        let provider = execution.provider_identity_for(route.provider);
14711
14712        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14713        assert_eq!(provider, "custom-a");
14714        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14715        let output = crate::tools::review::ReviewOutput::from_str("{}");
14716        let receipt = crate::tools::review::build_review_receipt(
14717            "working tree",
14718            "diff --git a/a b/a",
14719            provider,
14720            &route.model,
14721            &output,
14722            "{}",
14723            Vec::new(),
14724        );
14725        assert_eq!(receipt.provider, "custom-a");
14726        let serialized = serde_json::to_string(&receipt).expect("review receipt");
14727        assert!(!serialized.contains("127.0.0.1"));
14728        assert!(!serialized.contains("local-test-key"));
14729    }
14730
14731    #[tokio::test]
14732    async fn configured_workflow_default_keeps_named_custom_route() {
14733        let config = custom_exec_config("custom-a");
14734        let model = config.default_model();
14735
14736        let route = resolve_cli_exec_route(
14737            &config,
14738            &model,
14739            "Run a checked-in Workflow through the host runtime",
14740            true,
14741        )
14742        .await
14743        .expect("configured workflow route");
14744        let execution = config_for_cli_route(&config, &route);
14745
14746        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14747        assert_eq!(execution.provider_identity_for(route.provider), "custom-a");
14748        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14749        let client = crate::client::DeepSeekClient::new(&execution).expect("workflow client");
14750        assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
14751    }
14752
14753    #[test]
14754    fn exec_json_receipts_keep_exact_named_custom_provider() {
14755        let config = custom_exec_config("custom-a");
14756        let provider = config.provider_identity_for(crate::config::ApiProvider::Custom);
14757        let one_shot = one_shot_exec_json_receipt(
14758            provider.clone(),
14759            "model-a".to_string(),
14760            "done".to_string(),
14761            Some("end_turn".to_string()),
14762            crate::models::Usage {
14763                input_tokens: 12,
14764                output_tokens: 3,
14765                ..Default::default()
14766            },
14767        );
14768        assert_eq!(one_shot["provider"], "custom-a");
14769        assert_eq!(one_shot["success"], true);
14770
14771        let truncated = one_shot_exec_json_receipt(
14772            provider.clone(),
14773            "model-a".to_string(),
14774            "partial".to_string(),
14775            Some("max_output_tokens".to_string()),
14776            crate::models::Usage {
14777                input_tokens: 20,
14778                output_tokens: 9,
14779                ..Default::default()
14780            },
14781        );
14782        assert_eq!(truncated["success"], false);
14783        assert_eq!(truncated["stop_reason"], "max_output_tokens");
14784        assert_eq!(truncated["usage"]["input_tokens"], 20);
14785        assert_eq!(truncated["usage"]["output_tokens"], 9);
14786        assert!(truncated["error"].as_str().is_some_and(|error| {
14787            error.contains("Model response incomplete") && error.contains("max_output_tokens")
14788        }));
14789
14790        let agent = serde_json::to_value(ExecSummary {
14791            mode: "agent".to_string(),
14792            provider,
14793            model: "model-a".to_string(),
14794            ..ExecSummary::default()
14795        })
14796        .expect("agent exec JSON receipt");
14797        assert_eq!(agent["provider"], "custom-a");
14798        let serialized = serde_json::to_string(&agent).expect("serialize receipt");
14799        assert!(!serialized.contains("127.0.0.1"));
14800        assert!(!serialized.contains("local-test-key"));
14801    }
14802
14803    #[test]
14804    fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
14805        let named = crate::config::ProviderIdentity {
14806            provider: crate::config::ApiProvider::Custom,
14807            key: "lm-studio".to_string(),
14808            exact_id: Some("lm-studio".to_string()),
14809            migrated_legacy_ollama_cloud_route: false,
14810        };
14811        let literal = crate::config::ProviderIdentity {
14812            provider: crate::config::ApiProvider::Custom,
14813            key: "custom".to_string(),
14814            exact_id: Some("custom".to_string()),
14815            migrated_legacy_ollama_cloud_route: false,
14816        };
14817        let root = crate::config::ProviderIdentity {
14818            provider: crate::config::ApiProvider::Custom,
14819            key: "custom".to_string(),
14820            exact_id: None,
14821            migrated_legacy_ollama_cloud_route: false,
14822        };
14823        let built_in = crate::config::ProviderIdentity {
14824            provider: crate::config::ApiProvider::Deepseek,
14825            key: "deepseek".to_string(),
14826            exact_id: Some("deepseek".to_string()),
14827            migrated_legacy_ollama_cloud_route: false,
14828        };
14829
14830        assert_eq!(
14831            exec_stream_provider_route(&named),
14832            ("custom".to_string(), Some("lm-studio".to_string()))
14833        );
14834        assert_eq!(
14835            exec_stream_provider_route(&literal),
14836            ("custom".to_string(), Some("custom".to_string()))
14837        );
14838        assert_eq!(
14839            exec_stream_provider_route(&root),
14840            ("custom".to_string(), None)
14841        );
14842        assert_eq!(
14843            exec_stream_provider_route(&built_in),
14844            ("deepseek".to_string(), None)
14845        );
14846    }
14847
14848    #[test]
14849    fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
14850        let saved_a = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14851        let mut config = custom_exec_config("custom-a");
14852        apply_exec_provider_override(&mut config, "custom-b").expect("custom B");
14853        let model = resolve_exec_resume_route(&mut config, &saved_a, true, None)
14854            .expect("explicit provider route");
14855        let mut persisted = saved_a;
14856        stamp_exec_session_metadata(
14857            &mut persisted,
14858            &model,
14859            crate::config::ApiProvider::Custom.as_str(),
14860            Some("custom-b"),
14861            Path::new("/tmp/exec-resume"),
14862        );
14863
14864        let mut next_config = custom_exec_config("custom-a");
14865        let resumed_model = resolve_exec_resume_route(&mut next_config, &persisted, false, None)
14866            .expect("next plain resume");
14867
14868        assert_eq!(persisted.metadata.model_provider, "custom");
14869        assert_eq!(
14870            persisted.metadata.model_provider_id.as_deref(),
14871            Some("custom-b")
14872        );
14873        assert_eq!(persisted.metadata.model, "model-b");
14874        assert_eq!(next_config.provider.as_deref(), Some("custom-b"));
14875        assert_eq!(resumed_model, "model-b");
14876    }
14877
14878    #[test]
14879    fn exec_persistence_omits_id_for_legacy_root_custom_route() {
14880        let mut saved = session_manager::create_saved_session_with_mode(
14881            &[],
14882            "legacy-root-model",
14883            Path::new("/tmp/exec-root"),
14884            0,
14885            None,
14886            Some("exec"),
14887        );
14888        stamp_exec_session_metadata(
14889            &mut saved,
14890            "legacy-root-model",
14891            crate::config::ApiProvider::Custom.as_str(),
14892            None,
14893            Path::new("/tmp/exec-root"),
14894        );
14895
14896        assert_eq!(saved.metadata.model_provider, "custom");
14897        assert_eq!(saved.metadata.model_provider_id, None);
14898        assert!(
14899            !serde_json::to_string(&saved)
14900                .expect("serialize exec session")
14901                .contains("model_provider_id")
14902        );
14903    }
14904
14905    #[test]
14906    fn exec_parses_reasoning_effort_flag_alongside_provider() {
14907        let cli = parse_cli(&[
14908            "codewhale",
14909            "exec",
14910            "--provider",
14911            "openrouter",
14912            "--model",
14913            "glm-5.2",
14914            "--reasoning-effort",
14915            "max",
14916            "audit",
14917        ]);
14918        let Some(Commands::Exec(args)) = cli.command else {
14919            panic!("expected exec command");
14920        };
14921
14922        assert_eq!(args.provider.as_deref(), Some("openrouter"));
14923        assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14924        assert_eq!(args.reasoning_effort.as_deref(), Some("max"));
14925        assert_eq!(args.prompt, vec!["audit"]);
14926    }
14927
14928    #[test]
14929    fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
14930        // The thinking ladder split these: `xhigh` is a tier the CLI can now
14931        // name, `ultracode` is still an alias and resolves to `ultra`.
14932        assert_eq!(
14933            normalize_cli_reasoning_effort("xhigh").unwrap().as_deref(),
14934            Some("xhigh")
14935        );
14936        assert_eq!(
14937            normalize_cli_reasoning_effort("ultracode")
14938                .unwrap()
14939                .as_deref(),
14940            Some("ultra")
14941        );
14942        assert_eq!(normalize_cli_reasoning_effort("default").unwrap(), None);
14943        assert!(normalize_cli_reasoning_effort("expensive").is_err());
14944    }
14945
14946    #[test]
14947    fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() {
14948        let config = Config {
14949            provider: Some("moonshot".to_string()),
14950            providers: Some(crate::config::ProvidersConfig {
14951                moonshot: crate::config::ProviderConfig {
14952                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
14953                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
14954                    ..Default::default()
14955                },
14956                ..Default::default()
14957            }),
14958            ..Default::default()
14959        };
14960
14961        for (prompt, expected) in [
14962            ("lookup the public docs", "low"),
14963            ("debug this error", "max"),
14964            ("review this ordinary change", "high"),
14965        ] {
14966            assert_eq!(
14967                cli_reasoning_effort_value_for_prompt(
14968                    &config,
14969                    crate::config::KIMI_CODE_K3_MODEL,
14970                    crate::tui::app::ReasoningEffort::Auto,
14971                    prompt,
14972                )
14973                .as_deref(),
14974                Some(expected),
14975                "prompt selector must resolve Auto for `{prompt}`"
14976            );
14977        }
14978
14979        assert_eq!(
14980            cli_reasoning_effort_value_for_prompt(
14981                &config,
14982                crate::config::KIMI_CODE_K3_MODEL,
14983                crate::tui::app::ReasoningEffort::Off,
14984                "debug must not override an explicit effort",
14985            )
14986            .as_deref(),
14987            Some("low"),
14988            "membership K3 still applies its exact-route always-thinking floor"
14989        );
14990    }
14991
14992    #[test]
14993    fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
14994        use crate::tui::app::ReasoningEffort;
14995
14996        let fixed_model_auto_reasoning = CliAutoRoute {
14997            provider: crate::config::ApiProvider::Deepseek,
14998            model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
14999            reasoning_effort: Some(ReasoningEffort::Auto),
15000            auto_controls_reasoning: true,
15001            auto_model: false,
15002        };
15003        let auto_model_fixed_reasoning = CliAutoRoute {
15004            provider: crate::config::ApiProvider::OpenaiCodex,
15005            model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
15006            reasoning_effort: Some(ReasoningEffort::High),
15007            auto_controls_reasoning: false,
15008            auto_model: true,
15009        };
15010
15011        assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
15012        assert!(!fixed_model_auto_reasoning.auto_model);
15013        assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
15014        assert!(auto_model_fixed_reasoning.auto_model);
15015    }
15016
15017    #[test]
15018    fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
15019        let mut config = Config {
15020            reasoning_effort: Some("max".to_string()),
15021            reasoning_effort_inferred_from_legacy_alias: true,
15022            ..Default::default()
15023        };
15024        let settings = crate::settings::Settings {
15025            reasoning_effort: Some("low".to_string()),
15026            ..Default::default()
15027        };
15028
15029        apply_saved_reasoning_preference(&mut config, &settings);
15030
15031        assert_eq!(config.reasoning_effort(), Some("low"));
15032        assert!(config.reasoning_effort_is_explicit());
15033    }
15034
15035    /// `run_exec_agent` must hand the engine a concrete tier, never the literal
15036    /// `"auto"` sentinel, for a fixed-model Auto launch.
15037    #[test]
15038    fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
15039        let config = Config {
15040            provider: Some("zai".to_string()),
15041            ..Default::default()
15042        };
15043
15044        let resolved = cli_reasoning_effort_value_for_prompt(
15045            &config,
15046            crate::config::ZAI_GLM_5_2_MODEL,
15047            crate::tui::app::ReasoningEffort::Auto,
15048            "debug this failing integration test",
15049        )
15050        .expect("Auto must resolve to a concrete tier");
15051
15052        assert_ne!(
15053            resolved, "auto",
15054            "the literal auto sentinel must never reach a provider"
15055        );
15056        assert!(
15057            matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
15058            "unexpected resolved tier: {resolved}"
15059        );
15060    }
15061
15062    #[test]
15063    fn exec_accepts_resume_session_flags_for_harnesses() {
15064        let cli = parse_cli(&[
15065            "codewhale",
15066            "exec",
15067            "--resume",
15068            "abc123",
15069            "--output-format",
15070            "stream-json",
15071            "follow up",
15072        ]);
15073        let Some(Commands::Exec(args)) = cli.command else {
15074            panic!("expected exec command");
15075        };
15076
15077        assert_eq!(args.resume.as_deref(), Some("abc123"));
15078        assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
15079        assert_eq!(args.prompt, vec!["follow up"]);
15080    }
15081
15082    #[test]
15083    fn exec_accepts_session_id_alias() {
15084        let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]);
15085        let Some(Commands::Exec(args)) = cli.command else {
15086            panic!("expected exec command");
15087        };
15088
15089        assert_eq!(args.session_id.as_deref(), Some("abc123"));
15090        assert_eq!(args.output_format, ExecOutputFormat::Text);
15091    }
15092
15093    #[test]
15094    fn exec_parses_tool_gate_and_hardening_flags() {
15095        let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#;
15096        let cli = parse_cli(&[
15097            "codewhale",
15098            "exec",
15099            "--allowed-tools",
15100            "File,Git",
15101            "--disallowed-tools",
15102            "Bash",
15103            "--max-turns",
15104            "7",
15105            "--append-system-prompt",
15106            "extra rules",
15107            "--tool-authority-json",
15108            envelope,
15109            "do the thing",
15110        ]);
15111        let Some(Commands::Exec(args)) = cli.command else {
15112            panic!("expected exec command");
15113        };
15114
15115        assert_eq!(
15116            args.allowed_tools.as_deref(),
15117            Some(&["File".to_string(), "Git".to_string()][..])
15118        );
15119        assert_eq!(
15120            args.disallowed_tools.as_deref(),
15121            Some(&["Bash".to_string()][..])
15122        );
15123        assert_eq!(args.max_turns, Some(7));
15124        assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
15125        assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
15126        assert_eq!(args.prompt, vec!["do the thing"]);
15127    }
15128
15129    #[test]
15130    fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
15131        assert!(validate_exec_tool_authority_resume(None, true).is_ok());
15132        assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
15133        let error = validate_exec_tool_authority_resume(Some("{}"), true)
15134            .expect_err("authority must remain bound to its fresh Fleet launch")
15135            .to_string();
15136        assert!(error.contains("cannot be combined with exec --resume"));
15137    }
15138
15139    #[test]
15140    fn exec_auto_does_not_authorize_sandbox_elevation() {
15141        let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
15142        let Some(Commands::Exec(args)) = cli.command else {
15143            panic!("expected exec command");
15144        };
15145
15146        assert!(!exec_sandbox_elevation_authorized(
15147            args.allow_sandbox_elevation,
15148            args.sandbox.as_deref()
15149        ));
15150    }
15151
15152    #[test]
15153    fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
15154        let danger = parse_cli(&[
15155            "codewhale",
15156            "exec",
15157            "--auto",
15158            "--sandbox",
15159            "danger-full-access",
15160            "run it",
15161        ]);
15162        let Some(Commands::Exec(args)) = danger.command else {
15163            panic!("expected exec command");
15164        };
15165        assert!(exec_sandbox_elevation_authorized(
15166            args.allow_sandbox_elevation,
15167            args.sandbox.as_deref()
15168        ));
15169
15170        let flag = parse_cli(&[
15171            "codewhale",
15172            "exec",
15173            "--auto",
15174            "--allow-sandbox-elevation",
15175            "run it",
15176        ]);
15177        let Some(Commands::Exec(args)) = flag.command else {
15178            panic!("expected exec command");
15179        };
15180        assert!(exec_sandbox_elevation_authorized(
15181            args.allow_sandbox_elevation,
15182            args.sandbox.as_deref()
15183        ));
15184    }
15185
15186    #[test]
15187    fn exec_sandbox_denial_stream_event_is_typed() {
15188        let event = ExecStreamEvent::SandboxDenied {
15189            tool_id: "call_1".to_string(),
15190            tool_name: "exec_shell".to_string(),
15191            reason: "write blocked".to_string(),
15192            outcome: "approval_required".to_string(),
15193        };
15194        let value: serde_json::Value =
15195            serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
15196                .expect("valid json");
15197        assert_eq!(value["type"], "sandbox_denied");
15198        assert_eq!(value["outcome"], "approval_required");
15199    }
15200
15201    #[test]
15202    fn exec_help_separates_agent_mode_from_sandbox_elevation() {
15203        let mut cli = Cli::command();
15204        let help = cli
15205            .find_subcommand_mut("exec")
15206            .expect("exec command")
15207            .render_help()
15208            .to_string();
15209        assert!(help.contains("--auto"));
15210        assert!(help.contains("--sandbox"));
15211        assert!(help.contains("--allow-sandbox-elevation"));
15212        assert!(help.contains("does not change the"));
15213        assert!(help.contains("explicitly authorize sandbox elevation"));
15214    }
15215
15216    #[test]
15217    fn exec_shell_only_tool_surface_env_sets_shell_allowlist() {
15218        let _env_lock = crate::test_support::lock_test_env();
15219        let _surface =
15220            crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, " shell-only ");
15221
15222        let allowed_tools = resolve_exec_allowed_tools(None, exec_tool_surface_from_env())
15223            .expect("shell-only surface should set an allowlist");
15224
15225        assert_eq!(allowed_tools, vec!["bash".to_string()]);
15226    }
15227
15228    #[test]
15229    fn exec_explicit_allowed_tools_override_shell_only_env() {
15230        let _env_lock = crate::test_support::lock_test_env();
15231        let _surface =
15232            crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "shell-only");
15233        let explicit = vec![" File ".to_string(), "GIT".to_string()];
15234
15235        let allowed_tools =
15236            resolve_exec_allowed_tools(Some(&explicit), exec_tool_surface_from_env())
15237                .expect("explicit allowlist should be preserved");
15238
15239        assert_eq!(allowed_tools, vec!["file".to_string(), "git".to_string()]);
15240    }
15241
15242    #[test]
15243    fn exec_full_tool_surface_env_leaves_allowlist_unset() {
15244        let _env_lock = crate::test_support::lock_test_env();
15245        let _surface = crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "full");
15246
15247        assert_eq!(
15248            resolve_exec_allowed_tools(None, exec_tool_surface_from_env()),
15249            None
15250        );
15251    }
15252
15253    #[test]
15254    fn exec_unknown_tool_surface_env_warns_without_allowlist() {
15255        assert!(should_warn_unknown_exec_tool_surface("shell_onyl"));
15256        assert!(!should_warn_unknown_exec_tool_surface("shell-only"));
15257        assert!(!should_warn_unknown_exec_tool_surface("native-tools"));
15258        assert!(!should_warn_unknown_exec_tool_surface("full"));
15259        assert!(!should_warn_unknown_exec_tool_surface(" "));
15260        assert_eq!(parse_exec_tool_surface("shell_onyl"), None);
15261    }
15262
15263    #[test]
15264    fn exec_rejects_zero_max_turns() {
15265        let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
15266            .expect_err("max-turns must be >= 1");
15267        assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
15268    }
15269
15270    #[test]
15271    fn exec_omits_the_headless_turn_cap_by_default() {
15272        let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
15273        let Some(Commands::Exec(args)) = cli.command else {
15274            panic!("expected exec command");
15275        };
15276
15277        assert_eq!(args.max_turns, None);
15278        assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
15279        assert_eq!(exec_max_steps(Some(7)), 7);
15280    }
15281
15282    #[test]
15283    fn exec_accepts_continue_for_latest_workspace_session() {
15284        let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);
15285        let Some(Commands::Exec(args)) = cli.command else {
15286            panic!("expected exec command");
15287        };
15288
15289        assert!(args.continue_session);
15290    }
15291
15292    #[test]
15293    fn sessions_footer_points_to_resume_subcommand() {
15294        let cli = parse_cli(&["codewhale", "resume", "abc123"]);
15295        let Some(Commands::Resume { session_id, last }) = cli.command else {
15296            panic!("expected resume command");
15297        };
15298
15299        assert_eq!(session_id.as_deref(), Some("abc123"));
15300        assert!(!last);
15301        assert_eq!(sessions_resume_command(), "codewhale resume");
15302        assert!(!sessions_resume_command().contains("--resume"));
15303    }
15304
15305    #[test]
15306    fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() {
15307        use std::cell::Cell;
15308
15309        #[derive(Clone, Copy)]
15310        enum Expected {
15311            Plain,
15312            Resume,
15313            Fork,
15314            Exec,
15315            Serve,
15316        }
15317
15318        let cases: &[(&[&str], Expected)] = &[
15319            (&["codewhale"], Expected::Plain),
15320            (&["codewhale", "resume", "--last"], Expected::Resume),
15321            (&["codewhale", "fork", "--last"], Expected::Fork),
15322            (&["codewhale", "exec", "probe"], Expected::Exec),
15323            (&["codewhale", "serve", "--mcp"], Expected::Serve),
15324        ];
15325
15326        for (args, expected) in cases {
15327            let phase = Cell::new(0);
15328            let (_cli, command) = prepare_cli_startup(
15329                parse_cli(args),
15330                || {
15331                    assert_eq!(phase.get(), 0, "plugin init order for {args:?}");
15332                    phase.set(1);
15333                },
15334                || {
15335                    assert_eq!(phase.get(), 1, "dotenv load order for {args:?}");
15336                    phase.set(2);
15337                },
15338            );
15339
15340            assert_eq!(phase.get(), 2, "startup phases for {args:?}");
15341            let correct_variant = matches!(
15342                (expected, command.as_ref()),
15343                (Expected::Plain, None)
15344                    | (Expected::Resume, Some(Commands::Resume { .. }))
15345                    | (Expected::Fork, Some(Commands::Fork { .. }))
15346                    | (Expected::Exec, Some(Commands::Exec(_)))
15347                    | (Expected::Serve, Some(Commands::Serve(_)))
15348            );
15349            assert!(correct_variant, "unexpected command for {args:?}");
15350        }
15351    }
15352
15353    #[test]
15354    fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() {
15355        let _lock = crate::test_support::lock_test_env();
15356        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15357        let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key");
15358        let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
15359        let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
15360        let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL");
15361        let tmp = tempfile::TempDir::new().expect("temp workspace");
15362        let dotenv = tmp.path().join(".env");
15363        std::fs::write(
15364            &dotenv,
15365            "DEEPSEEK_API_KEY=workspace-key\n\
15366             NVIDIA_API_KEY=repo-must-not-override-shell\n\
15367             CODEWHALE_HOME=./attacker-home\n\
15368             CODEWHALE_CONFIG_PATH=./attacker.toml\n\
15369             DEEPSEEK_ALLOW_SHELL=true\n",
15370        )
15371        .expect("write dotenv");
15372
15373        let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load");
15374
15375        assert_eq!(
15376            std::env::var("DEEPSEEK_API_KEY").as_deref(),
15377            Ok("workspace-key")
15378        );
15379        assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key"));
15380        assert!(std::env::var_os("CODEWHALE_HOME").is_none());
15381        assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none());
15382        assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none());
15383        assert_eq!(
15384            report.loaded,
15385            BTreeSet::from(["DEEPSEEK_API_KEY".to_string()])
15386        );
15387        assert_eq!(
15388            report.ignored,
15389            BTreeSet::from([
15390                "CODEWHALE_CONFIG_PATH".to_string(),
15391                "CODEWHALE_HOME".to_string(),
15392                "DEEPSEEK_ALLOW_SHELL".to_string(),
15393            ])
15394        );
15395    }
15396
15397    #[test]
15398    fn workspace_dotenv_rejects_ambient_variable_substitution() {
15399        let _lock = crate::test_support::lock_test_env();
15400        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15401        let _ambient = crate::test_support::EnvVarGuard::set(
15402            "CODEWHALE_JS_SECRET_LEAK_TEST",
15403            "ambient-secret-must-not-expand",
15404        );
15405        let tmp = tempfile::TempDir::new().expect("temp workspace");
15406        let dotenv = tmp.path().join(".env");
15407        std::fs::write(
15408            &dotenv,
15409            "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n",
15410        )
15411        .expect("write dotenv");
15412
15413        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15414            .expect_err("expansion must fail closed")
15415            .to_string();
15416
15417        assert!(error.contains("variable expansion"));
15418        assert!(!error.contains("ambient-secret-must-not-expand"));
15419        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15420    }
15421
15422    #[test]
15423    fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() {
15424        let _lock = crate::test_support::lock_test_env();
15425        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15426        let _ambient = crate::test_support::EnvVarGuard::set(
15427            "CODEWHALE_JS_SECRET_LEAK_TEST",
15428            "ambient-secret-must-not-expand",
15429        );
15430        let tmp = tempfile::TempDir::new().expect("temp workspace");
15431        let dotenv = tmp.path().join(".env");
15432        std::fs::write(
15433            &dotenv,
15434            "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n",
15435        )
15436        .expect("write dotenv");
15437
15438        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15439            .expect_err("multiline expansion must fail closed")
15440            .to_string();
15441
15442        assert!(error.contains("variable expansion"));
15443        assert!(!error.contains("ambient-secret-must-not-expand"));
15444        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15445    }
15446
15447    #[test]
15448    fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() {
15449        let _lock = crate::test_support::lock_test_env();
15450        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15451        let _ambient = crate::test_support::EnvVarGuard::set(
15452            "CODEWHALE_JS_SECRET_LEAK_TEST",
15453            "ambient-secret-must-not-expand",
15454        );
15455        let tmp = tempfile::TempDir::new().expect("temp workspace");
15456        let dotenv = tmp.path().join(".env");
15457        std::fs::write(
15458            &dotenv,
15459            "# unmatched quote in ignored comment: '\n\
15460             DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n",
15461        )
15462        .expect("write dotenv");
15463
15464        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15465            .expect_err("comment quote must not hide expansion")
15466            .to_string();
15467
15468        assert!(error.contains("variable expansion"));
15469        assert!(!error.contains("ambient-secret-must-not-expand"));
15470        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15471    }
15472
15473    #[test]
15474    fn workspace_dotenv_allows_single_quoted_literal_dollar() {
15475        let _lock = crate::test_support::lock_test_env();
15476        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15477        let tmp = tempfile::TempDir::new().expect("temp workspace");
15478        let dotenv = tmp.path().join(".env");
15479        std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv");
15480
15481        load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load");
15482
15483        assert_eq!(
15484            std::env::var("DEEPSEEK_API_KEY").as_deref(),
15485            Ok("$literal-value")
15486        );
15487    }
15488
15489    #[test]
15490    fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() {
15491        let _lock = crate::test_support::lock_test_env();
15492        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15493        let tmp = tempfile::TempDir::new().expect("temp workspace");
15494        let dotenv = tmp.path().join(".env");
15495        std::fs::write(
15496            &dotenv,
15497            "DEEPSEEK_API_KEY=must-not-survive\nBROKEN=\"unterminated\n",
15498        )
15499        .expect("write dotenv");
15500
15501        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15502            .expect_err("parse failure must be transactional")
15503            .to_string();
15504
15505        assert!(error.contains("could not be parsed safely"), "{error}");
15506        assert!(!error.contains("must-not-survive"));
15507        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15508    }
15509
15510    #[test]
15511    fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() {
15512        for provider in codewhale_config::provider::providers_sorted_for_display() {
15513            for key in provider.env_vars() {
15514                assert!(
15515                    is_workspace_dotenv_credential_key(key),
15516                    "provider credential {key} must remain supported"
15517                );
15518            }
15519        }
15520        for key in [
15521            "CODEWHALE_HOME",
15522            "CODEWHALE_CONFIG_PATH",
15523            "DEEPSEEK_CONFIG_PATH",
15524            "DEEPSEEK_PROFILE",
15525            "DEEPSEEK_MANAGED_CONFIG_PATH",
15526            "DEEPSEEK_REQUIREMENTS_PATH",
15527            "DEEPSEEK_PROVIDER",
15528            "DEEPSEEK_BASE_URL",
15529            "DEEPSEEK_MODEL",
15530            "DEEPSEEK_APPROVAL_POLICY",
15531            "DEEPSEEK_SANDBOX_MODE",
15532            "DEEPSEEK_ALLOW_SHELL",
15533            "DEEPSEEK_YOLO",
15534            "DEEPSEEK_MCP_CONFIG",
15535            "CODEWHALE_RUNTIME_TOKEN",
15536            "PATH",
15537            "NODE_OPTIONS",
15538            "PYTHONPATH",
15539            "LD_PRELOAD",
15540            "DYLD_INSERT_LIBRARIES",
15541        ] {
15542            assert!(
15543                !is_workspace_dotenv_credential_key(key),
15544                "control-plane variable {key} must not load from a workspace"
15545            );
15546        }
15547    }
15548
15549    #[cfg(unix)]
15550    #[test]
15551    fn workspace_dotenv_does_not_follow_symbolic_links() {
15552        use std::os::unix::fs::symlink;
15553
15554        let tmp = tempfile::TempDir::new().expect("temp workspace");
15555        let external = tmp.path().join("external-credentials");
15556        let dotenv = tmp.path().join(".env");
15557        std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15558            .expect("write external fixture");
15559        symlink(&external, &dotenv).expect("create dotenv symlink");
15560
15561        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15562            .expect_err("symlink must fail closed")
15563            .to_string();
15564
15565        assert!(error.contains("securely open"), "{error}");
15566        assert!(!error.contains("external-secret"));
15567    }
15568
15569    #[cfg(unix)]
15570    #[test]
15571    fn workspace_dotenv_rejects_hard_links_to_external_files() {
15572        let tmp = tempfile::TempDir::new().expect("temp workspace");
15573        let external = tmp.path().join("external-credentials");
15574        let dotenv = tmp.path().join(".env");
15575        std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15576            .expect("write external fixture");
15577        std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link");
15578
15579        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15580            .expect_err("hard link must fail closed")
15581            .to_string();
15582
15583        assert!(error.contains("multiple filesystem links"), "{error}");
15584        assert!(!error.contains("external-secret"));
15585    }
15586
15587    #[cfg(unix)]
15588    #[test]
15589    fn workspace_dotenv_rejects_fifo_without_blocking_startup() {
15590        use std::ffi::CString;
15591        use std::os::unix::ffi::OsStrExt;
15592        use std::sync::mpsc;
15593        use std::time::Duration;
15594
15595        let tmp = tempfile::TempDir::new().expect("temp workspace");
15596        let dotenv = tmp.path().join(".env");
15597        let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path");
15598        // SAFETY: `c_path` is a live, NUL-terminated path and the requested
15599        // mode grants access only to the current user.
15600        let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) };
15601        assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
15602
15603        let (tx, rx) = mpsc::channel();
15604        let worker_path = dotenv.clone();
15605        let worker = std::thread::spawn(move || {
15606            let result = load_workspace_dotenv_credentials_from_path(&worker_path)
15607                .map(|_| "unexpected success".to_string())
15608                .unwrap_or_else(|error| error.to_string());
15609            tx.send(result).expect("send loader result");
15610        });
15611
15612        let error = match rx.recv_timeout(Duration::from_secs(1)) {
15613            Ok(error) => error,
15614            Err(timeout) => {
15615                // Release a regressed blocking reader so the test can fail
15616                // promptly instead of leaving a stuck process behind.
15617                let _writer = std::fs::OpenOptions::new()
15618                    .write(true)
15619                    .open(&dotenv)
15620                    .expect("open fifo writer to release blocked reader");
15621                let _ = rx.recv_timeout(Duration::from_secs(1));
15622                worker.join().expect("join released loader");
15623                panic!("workspace .env FIFO blocked startup: {timeout}");
15624            }
15625        };
15626        worker.join().expect("join loader");
15627
15628        assert!(error.contains("not a regular file"), "{error}");
15629    }
15630
15631    #[test]
15632    fn exec_json_conflicts_with_stream_json_output() {
15633        let err = Cli::try_parse_from([
15634            "codewhale",
15635            "exec",
15636            "--json",
15637            "--output-format",
15638            "stream-json",
15639            "hello",
15640        ])
15641        .expect_err("json summary and stream-json must not mix");
15642
15643        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
15644    }
15645
15646    #[test]
15647    fn exec_stream_turn_usage_event_serializes_reported_fields() {
15648        let event = ExecStreamEvent::TurnUsage {
15649            turn: 2,
15650            input_tokens: 1200,
15651            output_tokens: 180,
15652            reasoning_tokens: Some(90),
15653            prompt_cache_hit_tokens: Some(900),
15654            prompt_cache_miss_tokens: Some(300),
15655            prompt_cache_write_tokens: Some(0),
15656            reasoning_replay_tokens: Some(40),
15657            duration_ms: 1834,
15658        };
15659
15660        let value = exec_stream_value(&event).expect("serializes");
15661        let json = serde_json::to_string(&value).expect("serializes");
15662        assert!(!json.contains('\n'));
15663        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15664        assert_eq!(parsed["type"], "turn_usage");
15665        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15666        assert_eq!(parsed["schema_version"], 1);
15667        assert_eq!(parsed["turn"], 2);
15668        assert_eq!(parsed["input_tokens"], 1200);
15669        assert_eq!(parsed["output_tokens"], 180);
15670        assert_eq!(parsed["reasoning_tokens"], 90);
15671        assert_eq!(parsed["prompt_cache_hit_tokens"], 900);
15672        assert_eq!(parsed["prompt_cache_miss_tokens"], 300);
15673        assert_eq!(parsed["prompt_cache_write_tokens"], 0);
15674        assert_eq!(parsed["reasoning_replay_tokens"], 40);
15675        assert_eq!(parsed["duration_ms"], 1834);
15676    }
15677
15678    #[test]
15679    fn exec_stream_turn_usage_event_omits_unreported_fields() {
15680        // Honest absence: optional token fields the provider did not report
15681        // are dropped from the object entirely — never emitted as null and
15682        // never backfilled with fabricated zeros.
15683        let event = ExecStreamEvent::TurnUsage {
15684            turn: 1,
15685            input_tokens: 11,
15686            output_tokens: 3,
15687            reasoning_tokens: None,
15688            prompt_cache_hit_tokens: None,
15689            prompt_cache_miss_tokens: None,
15690            prompt_cache_write_tokens: None,
15691            reasoning_replay_tokens: None,
15692            duration_ms: 250,
15693        };
15694
15695        let value = exec_stream_value(&event).expect("serializes");
15696        let parsed = value;
15697        assert_eq!(parsed["type"], "turn_usage");
15698        assert_eq!(parsed["input_tokens"], 11);
15699        assert_eq!(parsed["output_tokens"], 3);
15700        assert_eq!(parsed["duration_ms"], 250);
15701        let object = parsed.as_object().expect("event object");
15702        for absent in [
15703            "reasoning_tokens",
15704            "prompt_cache_hit_tokens",
15705            "prompt_cache_miss_tokens",
15706            "prompt_cache_write_tokens",
15707            "reasoning_replay_tokens",
15708        ] {
15709            assert!(!object.contains_key(absent), "{absent} leaked: {parsed}");
15710        }
15711    }
15712
15713    #[test]
15714    fn exec_stream_pre_existing_event_type_tags_are_unchanged() {
15715        // Contract guard for existing stream consumers (bench harness, fleet
15716        // ledger): the pre-turn_usage event vocabulary keeps its exact tags.
15717        let cases: Vec<(ExecStreamEvent, &str)> = vec![
15718            (
15719                ExecStreamEvent::Content {
15720                    content: "hi".to_string(),
15721                },
15722                "content",
15723            ),
15724            (
15725                ExecStreamEvent::ToolUse {
15726                    name: "read_file".to_string(),
15727                    id: "call_1".to_string(),
15728                    input: serde_json::json!({}),
15729                    started_at: "2026-08-03T00:00:00Z".to_string(),
15730                },
15731                "tool_use",
15732            ),
15733            (
15734                ExecStreamEvent::ToolResult {
15735                    id: "call_1".to_string(),
15736                    name: "read_file".to_string(),
15737                    output: "ok".to_string(),
15738                    status: "success".to_string(),
15739                    started_at: "2026-08-03T00:00:00Z".to_string(),
15740                    completed_at: "2026-08-03T00:00:01Z".to_string(),
15741                    duration_ms: 1,
15742                    side_effect_status: "unknown".to_string(),
15743                    error_category: None,
15744                    truncated: None,
15745                    artifact: None,
15746                    result_metadata: None,
15747                },
15748                "tool_result",
15749            ),
15750            (
15751                ExecStreamEvent::SandboxDenied {
15752                    tool_id: "call_1".to_string(),
15753                    tool_name: "exec_shell".to_string(),
15754                    reason: "denied".to_string(),
15755                    outcome: "approval_required".to_string(),
15756                },
15757                "sandbox_denied",
15758            ),
15759            (
15760                ExecStreamEvent::WorkflowEvent {
15761                    run_id: "workflow_1".to_string(),
15762                    event: serde_json::json!({"type": "task_completed"}),
15763                },
15764                "workflow_event",
15765            ),
15766            (
15767                ExecStreamEvent::SessionCapture {
15768                    content: "x".to_string(),
15769                },
15770                "session_capture",
15771            ),
15772            (
15773                ExecStreamEvent::Error {
15774                    error: "boom".to_string(),
15775                },
15776                "error",
15777            ),
15778            (ExecStreamEvent::Done, "done"),
15779        ];
15780
15781        for (event, expected_type) in cases {
15782            let value = exec_stream_value(&event).expect("serializes");
15783            assert_eq!(value["type"], expected_type, "event tag drifted");
15784            assert_eq!(value["schema"], "codewhale.exec-stream");
15785            assert_eq!(value["schema_version"], 1);
15786        }
15787    }
15788
15789    #[test]
15790    fn exec_stream_events_are_json_lines() {
15791        let event = ExecStreamEvent::ToolResult {
15792            id: "call_1".to_string(),
15793            name: "read_file".to_string(),
15794            output: "line 1\nline 2".to_string(),
15795            status: "success".to_string(),
15796            started_at: "2026-07-13T00:00:00Z".to_string(),
15797            completed_at: "2026-07-13T00:00:01Z".to_string(),
15798            duration_ms: 1000,
15799            side_effect_status: "not_started".to_string(),
15800            error_category: None,
15801            truncated: Some(false),
15802            artifact: None,
15803            result_metadata: None,
15804        };
15805
15806        let value = exec_stream_value(&event).expect("serializes");
15807        let json = serde_json::to_string(&value).expect("serializes");
15808        assert!(!json.contains('\n'));
15809        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15810        assert_eq!(parsed["type"], "tool_result");
15811        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15812        assert_eq!(parsed["schema_version"], 1);
15813        assert_eq!(parsed["duration_ms"], 1000);
15814        assert_eq!(parsed["side_effect_status"], "not_started");
15815    }
15816
15817    #[test]
15818    fn workflow_receipt_stream_event_is_one_json_line() {
15819        let event = ExecStreamEvent::WorkflowEvent {
15820            run_id: "workflow_1234".to_string(),
15821            event: serde_json::json!({
15822                "type": "handoff_promoted",
15823                "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15824                "gate_id": "review-gate",
15825                "kind": "review_report",
15826                "from_role": "reviewer",
15827                "to_role": "verifier",
15828                "producer_task_id": "agent_1"
15829            }),
15830        };
15831
15832        let value = exec_stream_value(&event).expect("serializes");
15833        let json = serde_json::to_string(&value).expect("serializes");
15834        assert!(!json.contains('\n'));
15835        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15836        assert_eq!(parsed["type"], "workflow_event");
15837        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15838        assert_eq!(parsed["schema_version"], 1);
15839        assert_eq!(parsed["run_id"], "workflow_1234");
15840        assert_eq!(parsed["event"]["type"], "handoff_promoted");
15841        assert_eq!(
15842            parsed["event"]["artifact_id"],
15843            "workflow_1234:agent_1:review-gate:review_report"
15844        );
15845        assert_eq!(parsed["event"]["gate_id"], "review-gate");
15846        assert_eq!(parsed["event"]["kind"], "review_report");
15847        assert_eq!(parsed["event"]["from_role"], "reviewer");
15848        assert_eq!(parsed["event"]["to_role"], "verifier");
15849        assert_eq!(parsed["event"]["producer_task_id"], "agent_1");
15850        assert!(parsed["event"].get("payload").is_none(), "{parsed}");
15851
15852        let consumed = ExecStreamEvent::WorkflowEvent {
15853            run_id: "workflow_1234".to_string(),
15854            event: serde_json::json!({
15855                "type": "handoff_consumed",
15856                "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15857                "kind": "review_report",
15858                "from_role": "reviewer",
15859                "to_role": "verifier",
15860                "consumer_task_id": "agent_2"
15861            }),
15862        };
15863        let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt");
15864        assert_eq!(consumed["type"], "workflow_event");
15865        assert_eq!(consumed["schema"], "codewhale.exec-stream");
15866        assert_eq!(consumed["schema_version"], 1);
15867        assert_eq!(consumed["event"]["type"], "handoff_consumed");
15868        assert_eq!(
15869            consumed["event"]["artifact_id"],
15870            "workflow_1234:agent_1:review-gate:review_report"
15871        );
15872        assert_eq!(consumed["event"]["consumer_task_id"], "agent_2");
15873        assert!(consumed["event"].get("payload").is_none(), "{consumed}");
15874    }
15875
15876    #[test]
15877    fn exec_stream_metadata_redacts_resume_breadcrumbs() {
15878        let raw_session_id = "abc123fullsecret";
15879        let event = ExecStreamEvent::Metadata {
15880            meta: Box::new(ExecStreamMeta {
15881                receipt_kind: "terminal",
15882                provider: "deepseek".to_string(),
15883                provider_id: None,
15884                model: "deepseek-v4-flash".to_string(),
15885                route_source: "explicit_or_configured".to_string(),
15886                input_tokens: Some(123),
15887                output_tokens: Some(45),
15888                prompt_cache_hit_tokens: Some(10),
15889                prompt_cache_miss_tokens: None,
15890                prompt_cache_write_tokens: None,
15891                reasoning_tokens: Some(3),
15892                codewhale_max_output_tokens: Some(384_000),
15893                codewhale_max_output_tokens_source: Some("documented"),
15894                duration_ms: 2500,
15895                retry_count: None,
15896                approval_posture: "ask".to_string(),
15897                sandbox_posture: "configured_default".to_string(),
15898                binary_sha256: Some("sha256:binary".to_string()),
15899                config_sha256: None,
15900                prompt_sha256: "sha256:prompt".to_string(),
15901                tool_catalog_sha256: Some("sha256:tools".to_string()),
15902                input_analysis: ExecStreamInputAnalysis::default(),
15903                visible_final_answer_chars: 17,
15904                session_id: exec_stream_session_ref(raw_session_id),
15905                resume_command: exec_stream_resume_hint(raw_session_id),
15906                workspace: "/tmp/work".to_string(),
15907                message_count: 4,
15908                status: Some("completed".to_string()),
15909                termination_reason: Some("resolved".to_string()),
15910                error_category: None,
15911                error: None,
15912            }),
15913        };
15914
15915        let json = serde_json::to_string(&event).expect("serializes");
15916        assert!(!json.contains('\n'));
15917        assert!(!json.contains(raw_session_id));
15918        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15919        assert_eq!(parsed["type"], "metadata");
15920        assert_ne!(parsed["meta"]["session_id"], raw_session_id);
15921        assert!(
15922            parsed["meta"]["session_id"]
15923                .as_str()
15924                .unwrap()
15925                .starts_with("<redacted:")
15926        );
15927        assert_eq!(
15928            parsed["meta"]["resume_command"],
15929            "codewhale exec --resume <redacted-session-id>"
15930        );
15931        assert_eq!(parsed["meta"]["workspace"], "/tmp/work");
15932        assert_eq!(parsed["meta"]["message_count"], 4);
15933        assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17);
15934
15935        let capture = ExecStreamEvent::SessionCapture {
15936            content: exec_stream_session_ref(raw_session_id),
15937        };
15938        let capture_json = serde_json::to_string(&capture).expect("serializes");
15939        assert!(!capture_json.contains(raw_session_id));
15940        let parsed_capture: serde_json::Value =
15941            serde_json::from_str(&capture_json).expect("valid json");
15942        assert_eq!(parsed_capture["type"], "session_capture");
15943        assert_ne!(parsed_capture["content"], raw_session_id);
15944    }
15945
15946    #[test]
15947    fn exec_stream_input_analysis_reports_prompt_composition() {
15948        let system = SystemPrompt::Text("system rules".to_string());
15949        let messages = vec![
15950            Message {
15951                role: "user".to_string(),
15952                content: vec![ContentBlock::Text {
15953                    text: "run tests".to_string(),
15954                    cache_control: None,
15955                }],
15956            },
15957            Message {
15958                role: "assistant".to_string(),
15959                content: vec![
15960                    ContentBlock::thinking("checking context"),
15961                    ContentBlock::Text {
15962                        text: "working".to_string(),
15963                        cache_control: None,
15964                    },
15965                    ContentBlock::ToolUse {
15966                        id: "call-1".to_string(),
15967                        name: "exec_shell".to_string(),
15968                        input: serde_json::json!({"command": "cargo test"}),
15969                        caller: None,
15970                        thought_signature: None,
15971                    },
15972                ],
15973            },
15974            Message {
15975                role: "user".to_string(),
15976                content: vec![ContentBlock::ToolResult {
15977                    tool_use_id: "call-1".to_string(),
15978                    content: "stdout line\nstderr line".to_string(),
15979                    is_error: Some(false),
15980                    content_blocks: Some(vec![serde_json::json!({
15981                        "type": "text",
15982                        "text": "structured output"
15983                    })]),
15984                }],
15985            },
15986        ];
15987
15988        let analysis = exec_stream_input_analysis(&messages, Some(&system));
15989
15990        assert_eq!(analysis.user_message_count, 2);
15991        assert_eq!(analysis.assistant_message_count, 1);
15992        assert_eq!(analysis.tool_message_count, 0);
15993        assert_eq!(analysis.tool_use_count, 1);
15994        assert_eq!(analysis.tool_result_count, 1);
15995        assert_eq!(analysis.thinking_chars, "checking context".chars().count());
15996        assert!(analysis.text_chars >= "run testsworking".chars().count());
15997        assert!(analysis.tool_use_input_chars > 0);
15998        assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count());
15999        assert!(analysis.estimated_system_tokens > 0);
16000        assert!(analysis.estimated_message_content_tokens > 0);
16001        assert!(
16002            analysis.estimated_request_tokens
16003                >= analysis.estimated_system_tokens
16004                    + analysis.estimated_message_content_tokens
16005                    + analysis.estimated_framing_tokens
16006        );
16007    }
16008
16009    #[test]
16010    fn review_receipt_check_public_json_omits_private_details() {
16011        let validation = crate::tools::review::ReviewReceiptValidation {
16012            passed: false,
16013            reason: "secret reason with /tmp/private/receipt.json".to_string(),
16014            diff_fingerprint: "sha256:current".to_string(),
16015            receipt_fingerprint: Some("sha256:current".to_string()),
16016            receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")),
16017            unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk {
16018                unresolved: true,
16019                level: "error".to_string(),
16020                summary: "secret summary".to_string(),
16021            }),
16022        };
16023
16024        let public = review_receipt_validation_public_json(&validation);
16025        let encoded = serde_json::to_string(&public).expect("public json");
16026
16027        assert_eq!(public["passed"], false);
16028        assert_eq!(public["status"], "unresolved_risk");
16029        assert_eq!(public["risk_level"], "error");
16030        assert!(!encoded.contains("secret"));
16031        assert!(!encoded.contains("/tmp/private"));
16032    }
16033
16034    #[test]
16035    fn exec_text_session_breadcrumbs_use_compact_ids() {
16036        let session_id = "1234567890abcdef";
16037
16038        assert_eq!(exec_saved_session_line(session_id), "session: 12345678");
16039        assert_eq!(
16040            exec_resumed_session_line(session_id),
16041            "resumed session: 12345678"
16042        );
16043        assert!(!exec_saved_session_line(session_id).contains(session_id));
16044        assert!(!exec_resumed_session_line(session_id).contains(session_id));
16045    }
16046
16047    #[test]
16048    fn alternate_screen_defaults_on_in_auto_mode() {
16049        let cli = parse_cli(&["codewhale"]);
16050        let config = Config::default();
16051
16052        assert!(should_use_alt_screen(&cli, &config));
16053    }
16054
16055    #[test]
16056    fn removed_no_alt_screen_flag_is_rejected() {
16057        // Negative test: the retired compatibility flag must not be silently
16058        // accepted and must not reach the alternate-screen decision at all.
16059        let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"])
16060            .expect_err("--no-alt-screen must no longer parse");
16061        assert_eq!(
16062            error.kind(),
16063            clap::error::ErrorKind::UnknownArgument,
16064            "retired flag should fail as an unknown argument, not be absorbed"
16065        );
16066    }
16067
16068    #[test]
16069    fn config_never_is_accepted_but_keeps_alternate_screen() {
16070        let cli = parse_cli(&["codewhale"]);
16071        let config = Config {
16072            tui: Some(crate::config::TuiConfig {
16073                alternate_screen: Some("never".to_string()),
16074                mouse_capture: None,
16075                terminal_probe_timeout_ms: None,
16076                stream_chunk_timeout_secs: None,
16077                status_items: None,
16078                osc8_links: None,
16079                composer_arrows_scroll: None,
16080                notification_condition: None,
16081                header_items: None,
16082            }),
16083            ..Config::default()
16084        };
16085
16086        assert!(should_use_alt_screen(&cli, &config));
16087    }
16088
16089    #[test]
16090    #[cfg(not(windows))]
16091    fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
16092        let cli = parse_cli(&["codewhale"]);
16093        let config = Config::default();
16094
16095        assert!(should_use_mouse_capture_with(
16096            &cli, &config, true, None, None, None
16097        ));
16098    }
16099
16100    #[test]
16101    #[cfg(windows)]
16102    fn mouse_capture_defaults_off_on_legacy_windows_console() {
16103        // Legacy conhost (no `WT_SESSION` and no `ConEmuPID`) keeps the
16104        // v0.8.x default-off behavior: mouse-mode reporting on legacy console
16105        // can leak SGR escapes into the composer.
16106        let cli = parse_cli(&["codewhale"]);
16107        let config = Config::default();
16108
16109        assert!(!should_use_mouse_capture_with(
16110            &cli, &config, true, None, None, None
16111        ));
16112    }
16113
16114    // #1169: Windows Terminal sets `WT_SESSION` and handles mouse-mode
16115    // reporting cleanly, so default-on there gives users in-app text
16116    // selection (and the side-effect of clamping selection to the
16117    // transcript region instead of the terminal painting across the
16118    // sidebar via native selection).
16119    #[test]
16120    #[cfg(windows)]
16121    fn mouse_capture_defaults_on_in_windows_terminal() {
16122        let cli = parse_cli(&["codewhale"]);
16123        let config = Config::default();
16124
16125        assert!(should_use_mouse_capture_with(
16126            &cli,
16127            &config,
16128            true,
16129            None,
16130            Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
16131            None,
16132        ));
16133    }
16134
16135    // ConEmu/Cmder sets `ConEmuPID` and handles VT mouse-mode reporting
16136    // cleanly; default mouse capture on there so users get in-app scrolling.
16137    #[test]
16138    #[cfg(windows)]
16139    fn mouse_capture_defaults_on_in_conemu() {
16140        let cli = parse_cli(&["codewhale"]);
16141        let config = Config::default();
16142
16143        assert!(should_use_mouse_capture_with(
16144            &cli,
16145            &config,
16146            true,
16147            None,
16148            None,
16149            Some("12345"),
16150        ));
16151    }
16152
16153    #[test]
16154    fn no_mouse_capture_flag_disables_mouse_capture() {
16155        let cli = parse_cli(&["codewhale", "--no-mouse-capture"]);
16156        let config = Config::default();
16157
16158        assert!(!should_use_mouse_capture_with(
16159            &cli, &config, true, None, None, None
16160        ));
16161    }
16162
16163    #[test]
16164    fn config_can_disable_default_mouse_capture() {
16165        let cli = parse_cli(&["codewhale"]);
16166        let config = Config {
16167            tui: Some(crate::config::TuiConfig {
16168                alternate_screen: None,
16169                mouse_capture: Some(false),
16170                terminal_probe_timeout_ms: None,
16171                stream_chunk_timeout_secs: None,
16172                status_items: None,
16173                osc8_links: None,
16174                composer_arrows_scroll: None,
16175                notification_condition: None,
16176                header_items: None,
16177            }),
16178            ..Config::default()
16179        };
16180
16181        assert!(!should_use_mouse_capture_with(
16182            &cli, &config, true, None, None, None
16183        ));
16184    }
16185
16186    #[test]
16187    fn mouse_capture_flag_enables_mouse_capture() {
16188        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16189        let config = Config::default();
16190
16191        assert!(should_use_mouse_capture_with(
16192            &cli, &config, true, None, None, None
16193        ));
16194    }
16195
16196    #[test]
16197    fn config_can_enable_mouse_capture() {
16198        let cli = parse_cli(&["codewhale"]);
16199        let config = Config {
16200            tui: Some(crate::config::TuiConfig {
16201                alternate_screen: None,
16202                mouse_capture: Some(true),
16203                terminal_probe_timeout_ms: None,
16204                stream_chunk_timeout_secs: None,
16205                status_items: None,
16206                osc8_links: None,
16207                composer_arrows_scroll: None,
16208                notification_condition: None,
16209                header_items: None,
16210            }),
16211            ..Config::default()
16212        };
16213
16214        assert!(should_use_mouse_capture_with(
16215            &cli, &config, true, None, None, None
16216        ));
16217    }
16218
16219    #[test]
16220    fn mouse_capture_is_off_without_alternate_screen() {
16221        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16222        let config = Config::default();
16223
16224        assert!(!should_use_mouse_capture_with(
16225            &cli, &config, false, None, None, None
16226        ));
16227    }
16228
16229    // Issue #878 / #898: JetBrains JediTerm advertises mouse support but
16230    // forwards SGR mouse-event escapes as raw input characters, producing
16231    // the "input box auto-fills with garbled characters when I move the
16232    // mouse" failure mode in PyCharm/IDEA terminals. Default the capture
16233    // off when we see TERMINAL_EMULATOR=JetBrains-JediTerm; explicit
16234    // config / --mouse-capture still wins.
16235
16236    #[test]
16237    fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
16238        let cli = parse_cli(&["codewhale"]);
16239        let config = Config::default();
16240
16241        assert!(!should_use_mouse_capture_with(
16242            &cli,
16243            &config,
16244            true,
16245            Some("JetBrains-JediTerm"),
16246            None,
16247            None,
16248        ));
16249    }
16250
16251    #[test]
16252    fn jetbrains_default_off_is_case_insensitive() {
16253        let cli = parse_cli(&["codewhale"]);
16254        let config = Config::default();
16255
16256        // JetBrains has occasionally varied the casing across releases;
16257        // a case-insensitive match keeps the protection in place.
16258        assert!(!should_use_mouse_capture_with(
16259            &cli,
16260            &config,
16261            true,
16262            Some("jetbrains-jediterm"),
16263            None,
16264            None,
16265        ));
16266    }
16267
16268    #[test]
16269    fn mouse_capture_flag_overrides_jetbrains_default() {
16270        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16271        let config = Config::default();
16272
16273        assert!(should_use_mouse_capture_with(
16274            &cli,
16275            &config,
16276            true,
16277            Some("JetBrains-JediTerm"),
16278            None,
16279            None,
16280        ));
16281    }
16282
16283    #[test]
16284    fn config_mouse_capture_true_overrides_jetbrains_default() {
16285        let cli = parse_cli(&["codewhale"]);
16286        let config = Config {
16287            tui: Some(crate::config::TuiConfig {
16288                alternate_screen: None,
16289                mouse_capture: Some(true),
16290                terminal_probe_timeout_ms: None,
16291                stream_chunk_timeout_secs: None,
16292                status_items: None,
16293                osc8_links: None,
16294                composer_arrows_scroll: None,
16295                notification_condition: None,
16296                header_items: None,
16297            }),
16298            ..Config::default()
16299        };
16300
16301        assert!(should_use_mouse_capture_with(
16302            &cli,
16303            &config,
16304            true,
16305            Some("JetBrains-JediTerm"),
16306            None,
16307            None,
16308        ));
16309    }
16310}
16311
16312#[cfg(test)]
16313mod interactive_startup_tests {
16314    use super::*;
16315
16316    #[test]
16317    fn interactive_tui_defaults_agent_shell_to_approval_gated_on() {
16318        let default_config = Config::default();
16319        assert!(
16320            interactive_tui_allow_shell(false, &default_config),
16321            "interactive Agent mode should expose shell tools by default so approvals can gate commands"
16322        );
16323
16324        let disabled = Config {
16325            allow_shell: Some(false),
16326            ..Config::default()
16327        };
16328        assert!(
16329            !interactive_tui_allow_shell(false, &disabled),
16330            "explicit allow_shell=false still hides shell tools"
16331        );
16332
16333        assert!(
16334            interactive_tui_allow_shell(true, &disabled),
16335            "YOLO forces shell access for its no-guardrails contract"
16336        );
16337    }
16338}
16339
16340#[cfg(test)]
16341mod project_config_tests {
16342    use super::*;
16343    use std::fs;
16344    use tempfile::tempdir;
16345
16346    /// Write a `<workspace>/.deepseek/config.toml` and return the workspace
16347    /// root so the merge function can find it.
16348    fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
16349        let tmp = tempdir().expect("tempdir");
16350        let project_dir = tmp.path().join(".deepseek");
16351        fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
16352        fs::write(project_dir.join("config.toml"), body).expect("write project config");
16353        tmp
16354    }
16355
16356    #[cfg(unix)]
16357    #[test]
16358    fn project_overlay_rejects_symlinked_primary_config() {
16359        let workspace = tempdir().expect("workspace tempdir");
16360        let outside = tempdir().expect("outside tempdir");
16361        let primary_dir = workspace.path().join(codewhale_config::CODEWHALE_APP_DIR);
16362        let legacy_dir = workspace.path().join(codewhale_config::LEGACY_APP_DIR);
16363        fs::create_dir_all(&primary_dir).expect("mkdir primary");
16364        fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
16365        let outside_config = outside.path().join("config.toml");
16366        fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
16367        fs::write(legacy_dir.join("config.toml"), "model = \"legacy-model\"\n")
16368            .expect("write legacy config");
16369        std::os::unix::fs::symlink(&outside_config, primary_dir.join("config.toml"))
16370            .expect("symlink project config");
16371        let mut config = Config {
16372            default_text_model: Some("base-model".to_string()),
16373            ..Config::default()
16374        };
16375
16376        merge_project_config(&mut config, workspace.path());
16377
16378        assert_eq!(
16379            config.default_text_model.as_deref(),
16380            Some("base-model"),
16381            "symlinked primary project config should stop the project overlay"
16382        );
16383    }
16384
16385    fn with_home_dir<T>(home: &Path, f: impl FnOnce() -> T) -> T {
16386        let prev_home = std::env::var_os("HOME");
16387        let prev_userprofile = std::env::var_os("USERPROFILE");
16388        unsafe {
16389            std::env::set_var("HOME", home);
16390            std::env::set_var("USERPROFILE", home);
16391        }
16392        let result = f();
16393        unsafe {
16394            match prev_home {
16395                Some(value) => std::env::set_var("HOME", value),
16396                None => std::env::remove_var("HOME"),
16397            }
16398            match prev_userprofile {
16399                Some(value) => std::env::set_var("USERPROFILE", value),
16400                None => std::env::remove_var("USERPROFILE"),
16401            }
16402        }
16403        result
16404    }
16405
16406    #[test]
16407    fn project_overlay_skips_when_workspace_is_home_directory() {
16408        let _guard = crate::test_support::lock_test_env();
16409        let tmp = tempdir().expect("tempdir");
16410        let project_dir = tmp.path().join(codewhale_config::CODEWHALE_APP_DIR);
16411        fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
16412        fs::write(
16413            project_dir.join("config.toml"),
16414            r#"model = "project-override-model""#,
16415        )
16416        .expect("write project config");
16417
16418        with_home_dir(tmp.path(), || {
16419            let mut config = Config {
16420                default_text_model: Some("deepseek-v4-flash".to_string()),
16421                ..Config::default()
16422            };
16423
16424            merge_project_config(&mut config, tmp.path());
16425
16426            assert_eq!(
16427                config.default_text_model.as_deref(),
16428                Some("deepseek-v4-flash")
16429            );
16430        });
16431    }
16432
16433    #[test]
16434    fn project_overlay_overrides_model_but_denies_provider() {
16435        // #417: `provider` is on the deny-list; only the `model`
16436        // override applies. The denied key emits a stderr warning
16437        // (verified by integration runs; here we assert the post-
16438        // merge state).
16439        let tmp = workspace_with_project_config(
16440            r#"
16441provider = "nvidia-nim"
16442model = "deepseek-ai/deepseek-v4-pro"
16443"#,
16444        );
16445        let mut config = Config::default();
16446        merge_project_config(&mut config, tmp.path());
16447        assert_eq!(
16448            config.provider, None,
16449            "#417: project-scope `provider` must be denied"
16450        );
16451        assert_eq!(
16452            config.default_text_model.as_deref(),
16453            Some("deepseek-ai/deepseek-v4-pro"),
16454            "model is allowed at project scope"
16455        );
16456    }
16457
16458    #[test]
16459    fn project_overlay_denies_dangerous_credentials_and_redirects() {
16460        // #417: `api_key` / `base_url` / `provider` / `mcp_config_path`
16461        // and MCP OAuth callback settings are all on the deny-list. A
16462        // malicious project must not be able to redirect prompts, hijack MCP
16463        // servers, or influence OAuth callback behavior via these.
16464        let tmp = workspace_with_project_config(
16465            r#"
16466api_key = "ATTACKER_KEY"
16467base_url = "https://evil.example.com"
16468provider = "nvidia-nim"
16469mcp_config_path = "/tmp/attacker-mcp.json"
16470mcp_oauth_callback_port = 9999
16471mcp_oauth_callback_url = "http://evil.example.com/callback"
16472"#,
16473        );
16474        let mut config = Config {
16475            api_key: Some("USER_KEY".to_string()),
16476            base_url: Some("https://api.deepseek.com".to_string()),
16477            mcp_oauth_callback_port: Some(1455),
16478            mcp_oauth_callback_url: Some("http://127.0.0.1:1455/callback".to_string()),
16479            ..Config::default()
16480        };
16481        merge_project_config(&mut config, tmp.path());
16482        assert_eq!(
16483            config.api_key.as_deref(),
16484            Some("USER_KEY"),
16485            "user api_key must survive project-config attack"
16486        );
16487        assert_eq!(
16488            config.base_url.as_deref(),
16489            Some("https://api.deepseek.com"),
16490            "user base_url must survive project-config attack"
16491        );
16492        assert_eq!(
16493            config.provider, None,
16494            "project-scope provider must be denied"
16495        );
16496        assert_eq!(
16497            config.mcp_config_path, None,
16498            "project-scope mcp_config_path must be denied"
16499        );
16500        assert_eq!(
16501            config.mcp_oauth_callback_port,
16502            Some(1455),
16503            "project-scope mcp_oauth_callback_port must be denied"
16504        );
16505        assert_eq!(
16506            config.mcp_oauth_callback_url.as_deref(),
16507            Some("http://127.0.0.1:1455/callback"),
16508            "project-scope mcp_oauth_callback_url must be denied"
16509        );
16510    }
16511
16512    #[test]
16513    fn project_overlay_overrides_approval_and_sandbox() {
16514        let tmp = workspace_with_project_config(
16515            r#"
16516approval_policy = "never"
16517sandbox_mode = "read-only"
16518"#,
16519        );
16520        let mut config = Config::default();
16521        merge_project_config(&mut config, tmp.path());
16522        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16523        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16524    }
16525
16526    #[test]
16527    fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
16528        // #417 value-deny: the loosest values (`approval_policy = "auto"`,
16529        // `sandbox_mode = "danger-full-access"`) are pure escalation.
16530        // Even when the user hasn't set these fields, the project
16531        // can't push the session to the loosest posture.
16532        let tmp = workspace_with_project_config(
16533            r#"
16534approval_policy = "auto"
16535sandbox_mode = "danger-full-access"
16536model = "deepseek-v4-pro"
16537"#,
16538        );
16539        let mut config = Config::default();
16540        merge_project_config(&mut config, tmp.path());
16541        assert_eq!(
16542            config.approval_policy, None,
16543            "project-scope `approval_policy = \"auto\"` must be denied"
16544        );
16545        assert_eq!(
16546            config.sandbox_mode, None,
16547            "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
16548        );
16549        // Non-escalation overrides on the same merge succeed —
16550        // the deny is per-key, not per-file.
16551        assert_eq!(
16552            config.default_text_model.as_deref(),
16553            Some("deepseek-v4-pro"),
16554            "non-escalation overrides should still apply"
16555        );
16556    }
16557
16558    #[test]
16559    fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
16560        // Belt-and-suspenders: if the user has `approval_policy = "never"`
16561        // and the project tries `approval_policy = "auto"`, the deny
16562        // keeps the user's strict value rather than falling through to
16563        // None.
16564        let tmp = workspace_with_project_config(
16565            r#"
16566approval_policy = "auto"
16567"#,
16568        );
16569        let mut config = Config {
16570            approval_policy: Some("never".to_string()),
16571            ..Config::default()
16572        };
16573        merge_project_config(&mut config, tmp.path());
16574        assert_eq!(
16575            config.approval_policy.as_deref(),
16576            Some("never"),
16577            "user's strict approval_policy must survive a project escalation attempt"
16578        );
16579    }
16580
16581    #[test]
16582    fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
16583        let tmp = workspace_with_project_config(
16584            r#"
16585approval_policy = "on-request"
16586sandbox_mode = "workspace-write"
16587"#,
16588        );
16589        let mut config = Config {
16590            approval_policy: Some("never".to_string()),
16591            sandbox_mode: Some("read-only".to_string()),
16592            ..Config::default()
16593        };
16594        merge_project_config(&mut config, tmp.path());
16595        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16596        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16597    }
16598
16599    #[test]
16600    fn project_overlay_can_tighten_user_policy() {
16601        let tmp = workspace_with_project_config(
16602            r#"
16603approval_policy = "never"
16604sandbox_mode = "read-only"
16605"#,
16606        );
16607        let mut config = Config {
16608            approval_policy: Some("on-request".to_string()),
16609            sandbox_mode: Some("workspace-write".to_string()),
16610            ..Config::default()
16611        };
16612        merge_project_config(&mut config, tmp.path());
16613        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16614        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16615    }
16616
16617    #[test]
16618    fn project_overlay_can_tighten_saved_full_access_posture() {
16619        let tmp = workspace_with_project_config(
16620            r#"
16621approval_policy = "on-request"
16622"#,
16623        );
16624        let mut config = Config::default();
16625
16626        merge_project_config_with_approval_baseline(&mut config, tmp.path(), Some("full-access"));
16627
16628        assert_eq!(
16629            config.approval_policy.as_deref(),
16630            Some("on-request"),
16631            "a project may tighten the saved Full Access baseline to Ask"
16632        );
16633    }
16634
16635    #[test]
16636    fn project_overlay_overrides_max_subagents_and_can_disable_shell() {
16637        let tmp = workspace_with_project_config(
16638            r#"
16639max_subagents = 4
16640allow_shell = false
16641"#,
16642        );
16643        let mut config = Config::default();
16644        merge_project_config(&mut config, tmp.path());
16645        assert_eq!(config.max_subagents, Some(4));
16646        assert_eq!(config.allow_shell, Some(false));
16647    }
16648
16649    #[test]
16650    fn project_overlay_cannot_enable_shell() {
16651        let tmp = workspace_with_project_config(
16652            r#"
16653allow_shell = true
16654"#,
16655        );
16656        let mut config = Config {
16657            allow_shell: Some(false),
16658            ..Config::default()
16659        };
16660        merge_project_config(&mut config, tmp.path());
16661        assert_eq!(
16662            config.allow_shell,
16663            Some(false),
16664            "project overlay must not loosen shell access"
16665        );
16666    }
16667
16668    #[test]
16669    fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
16670        let tmp = tempdir().expect("tempdir");
16671        let workspace = tmp.path().join("project");
16672        fs::create_dir_all(&workspace).expect("mkdir workspace");
16673        let raw = format!(
16674            "[workspace.'{}']\nallow_shell = true\n",
16675            workspace.display()
16676        );
16677        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16678
16679        let mut config = Config::default();
16680        merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16681
16682        assert_eq!(config.allow_shell, Some(true));
16683    }
16684
16685    #[test]
16686    fn exec_no_project_config_skips_user_workspace_overlay() {
16687        // #4641: `codewhale --no-project-config exec` must skip the
16688        // workspace-specific `[workspace]`/`[projects]` overlay so a headless
16689        // launch sees a reproducible config surface. This documents the overlay
16690        // the `Commands::Exec` gate skips; the end-to-end wiring is proven by
16691        // `tests/verifiers_harness_contract.rs`.
16692        let tmp = tempdir().expect("tempdir");
16693        let workspace = tmp.path().join("project");
16694        fs::create_dir_all(&workspace).expect("mkdir workspace");
16695        let raw = format!(
16696            "[workspace.'{}']\nallow_shell = true\n",
16697            workspace.display()
16698        );
16699        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16700
16701        // Default (flag off): the overlay applies.
16702        let mut applied = Config::default();
16703        let no_project_config = false;
16704        if !no_project_config {
16705            merge_user_workspace_config_from_doc(&mut applied, &doc, &workspace);
16706        }
16707        assert_eq!(applied.allow_shell, Some(true));
16708
16709        // `--no-project-config`: Exec skips the overlay, leaving config untouched.
16710        let mut skipped = Config::default();
16711        let no_project_config = true;
16712        if !no_project_config {
16713            merge_user_workspace_config_from_doc(&mut skipped, &doc, &workspace);
16714        }
16715        assert_eq!(skipped.allow_shell, None);
16716    }
16717
16718    #[test]
16719    fn user_workspace_overlay_accepts_legacy_projects_table() {
16720        let tmp = tempdir().expect("tempdir");
16721        let workspace = tmp.path().join("project");
16722        fs::create_dir_all(&workspace).expect("mkdir workspace");
16723        let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display());
16724        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16725
16726        let mut config = Config::default();
16727        merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16728
16729        assert_eq!(config.allow_shell, Some(true));
16730    }
16731
16732    #[test]
16733    fn user_workspace_overlay_ignores_non_matching_workspace() {
16734        let tmp = tempdir().expect("tempdir");
16735        let configured_workspace = tmp.path().join("configured");
16736        let active_workspace = tmp.path().join("active");
16737        fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
16738        fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
16739        let raw = format!(
16740            "[workspace.'{}']\nallow_shell = true\n",
16741            configured_workspace.display()
16742        );
16743        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16744
16745        let mut config = Config::default();
16746        merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
16747
16748        assert_eq!(config.allow_shell, None);
16749    }
16750
16751    #[test]
16752    fn user_workspace_overlay_preserves_allow_shell_env_override() {
16753        let _guard = crate::test_support::lock_test_env();
16754        let tmp = tempdir().expect("tempdir");
16755        let workspace = tmp.path().join("project");
16756        fs::create_dir_all(&workspace).expect("mkdir workspace");
16757        let config_path = tmp.path().join("config.toml");
16758        fs::write(
16759            &config_path,
16760            format!(
16761                "[workspace.'{}']\nallow_shell = true\n",
16762                workspace.display()
16763            ),
16764        )
16765        .expect("write config");
16766
16767        unsafe {
16768            std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
16769        }
16770        let mut config = Config {
16771            allow_shell: Some(false),
16772            ..Config::default()
16773        };
16774        merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16775        unsafe {
16776            std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
16777        }
16778
16779        assert_eq!(config.allow_shell, Some(false));
16780    }
16781
16782    #[test]
16783    fn user_workspace_overlay_does_not_override_managed_config() {
16784        let tmp = tempdir().expect("tempdir");
16785        let workspace = tmp.path().join("project");
16786        fs::create_dir_all(&workspace).expect("mkdir workspace");
16787        let config_path = tmp.path().join("config.toml");
16788        fs::write(
16789            &config_path,
16790            format!(
16791                "[workspace.'{}']\nallow_shell = true\n",
16792                workspace.display()
16793            ),
16794        )
16795        .expect("write config");
16796
16797        let mut config = Config {
16798            allow_shell: Some(false),
16799            managed_config_path: Some("managed.toml".to_string()),
16800            ..Config::default()
16801        };
16802        merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16803
16804        assert_eq!(config.allow_shell, Some(false));
16805    }
16806
16807    #[test]
16808    fn windows_config_path_compare_normalizes_mixed_separators() {
16809        assert_eq!(
16810            normalize_windows_config_path_str(r"C:\Users\me\repo"),
16811            normalize_windows_config_path_str(r"C:/Users/me/repo/")
16812        );
16813    }
16814
16815    #[test]
16816    fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() {
16817        assert_eq!(
16818            normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"),
16819            normalize_windows_config_path_str(r"C:/Users/me/repo")
16820        );
16821        assert_eq!(
16822            normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"),
16823            normalize_windows_config_path_str(r"\\server/share/repo/")
16824        );
16825    }
16826
16827    #[test]
16828    fn project_overlay_clamps_max_subagents_to_safe_range() {
16829        let tmp = workspace_with_project_config(
16830            r#"
16831max_subagents = 500
16832"#,
16833        );
16834        let mut config = Config::default();
16835        merge_project_config(&mut config, tmp.path());
16836        assert_eq!(
16837            config.max_subagents,
16838            Some(crate::config::MAX_SUBAGENTS),
16839            "should clamp to MAX_SUBAGENTS"
16840        );
16841    }
16842
16843    #[test]
16844    fn project_overlay_ignores_negative_max_subagents() {
16845        let tmp = workspace_with_project_config(
16846            r#"
16847max_subagents = -3
16848"#,
16849        );
16850        let mut config = Config::default();
16851        merge_project_config(&mut config, tmp.path());
16852        assert_eq!(config.max_subagents, None, "negative should be ignored");
16853    }
16854
16855    #[test]
16856    fn project_overlay_skips_missing_config_file() {
16857        let tmp = tempdir().expect("tempdir");
16858        let mut config = Config {
16859            provider: Some("codewhale".to_string()),
16860            ..Config::default()
16861        };
16862        merge_project_config(&mut config, tmp.path());
16863        // Untouched.
16864        assert_eq!(config.provider.as_deref(), Some("codewhale"));
16865    }
16866
16867    #[test]
16868    fn project_overlay_skips_malformed_toml() {
16869        let tmp = workspace_with_project_config("this is not valid TOML !!");
16870        let mut config = Config {
16871            provider: Some("codewhale".to_string()),
16872            ..Config::default()
16873        };
16874        merge_project_config(&mut config, tmp.path());
16875        // Untouched on parse error — better to fall back to global than crash.
16876        assert_eq!(config.provider.as_deref(), Some("codewhale"));
16877    }
16878
16879    #[test]
16880    fn project_overlay_ignores_empty_string_values() {
16881        let tmp = workspace_with_project_config(
16882            r#"
16883provider = ""
16884model = ""
16885"#,
16886        );
16887        let mut config = Config {
16888            provider: Some("codewhale".to_string()),
16889            default_text_model: Some("deepseek-v4-pro".to_string()),
16890            ..Config::default()
16891        };
16892        merge_project_config(&mut config, tmp.path());
16893        // Empty strings are ignored — they're rarely a deliberate override.
16894        assert_eq!(config.provider.as_deref(), Some("codewhale"));
16895        assert_eq!(
16896            config.default_text_model.as_deref(),
16897            Some("deepseek-v4-pro")
16898        );
16899    }
16900
16901    #[test]
16902    fn project_overlay_ignores_project_instructions_array() {
16903        let tmp = workspace_with_project_config(
16904            r#"
16905instructions = ["./AGENTS.md", "./extra.md"]
16906"#,
16907        );
16908        let user = vec!["~/global.md".to_string()];
16909        let mut config = Config {
16910            instructions: Some(user.clone()),
16911            ..Config::default()
16912        };
16913        merge_project_config(&mut config, tmp.path());
16914        assert_eq!(
16915            config.instructions.as_deref(),
16916            Some(user.as_slice()),
16917            "project overlay must not replace user-owned instructions"
16918        );
16919    }
16920
16921    #[test]
16922    fn project_overlay_empty_instructions_array_preserves_user_list() {
16923        let tmp = workspace_with_project_config(
16924            r#"
16925instructions = []
16926"#,
16927        );
16928        let user = vec!["~/global.md".to_string(), "~/team-prefs.md".to_string()];
16929        let mut config = Config {
16930            instructions: Some(user.clone()),
16931            ..Config::default()
16932        };
16933        merge_project_config(&mut config, tmp.path());
16934        assert_eq!(
16935            config.instructions.as_deref(),
16936            Some(user.as_slice()),
16937            "project overlay must not clear user-owned instructions"
16938        );
16939    }
16940
16941    #[test]
16942    fn project_overlay_preserves_user_instructions_when_field_absent() {
16943        let tmp = workspace_with_project_config(
16944            r#"
16945provider = "deepseek"
16946"#,
16947        );
16948        let user = vec!["~/global.md".to_string()];
16949        let mut config = Config {
16950            instructions: Some(user.clone()),
16951            ..Config::default()
16952        };
16953        merge_project_config(&mut config, tmp.path());
16954        // No `instructions` key in the project file → user list intact.
16955        assert_eq!(
16956            config.instructions.as_deref(),
16957            Some(user.as_slice()),
16958            "absent project field must not clobber the user list"
16959        );
16960    }
16961
16962    #[test]
16963    fn project_overlay_ignores_new_instructions_when_user_has_none() {
16964        let tmp = workspace_with_project_config(
16965            r#"
16966instructions = ["./AGENTS.md", "", "  ", "./extra.md"]
16967"#,
16968        );
16969        let mut config = Config::default();
16970        merge_project_config(&mut config, tmp.path());
16971        assert_eq!(
16972            config.instructions.as_deref(),
16973            None,
16974            "project overlay must not introduce instruction paths"
16975        );
16976    }
16977}
16978
16979#[cfg(test)]
16980mod doctor_mcp_tests {
16981    use super::*;
16982
16983    fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
16984        McpServerConfig {
16985            command: command.map(String::from),
16986            args: args.iter().map(|s| s.to_string()).collect(),
16987            env: std::collections::HashMap::new(),
16988            cwd: None,
16989            url: url.map(String::from),
16990            transport: None,
16991            connect_timeout: None,
16992            execute_timeout: None,
16993            read_timeout: None,
16994            disabled: false,
16995            enabled: true,
16996            required: false,
16997            enabled_tools: Vec::new(),
16998            disabled_tools: Vec::new(),
16999            headers: std::collections::HashMap::new(),
17000            env_headers: std::collections::HashMap::new(),
17001            bearer_token_env_var: None,
17002            scopes: Vec::new(),
17003            oauth: None,
17004            oauth_resource: None,
17005            reviewed_plugin: None,
17006        }
17007    }
17008
17009    #[test]
17010    fn test_no_command_or_url_is_error() {
17011        let server = make_server(None, &[], None);
17012        assert!(matches!(
17013            doctor_check_mcp_server(&server),
17014            McpServerDoctorStatus::Error(_)
17015        ));
17016    }
17017
17018    #[test]
17019    fn test_url_server_is_ok() {
17020        let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
17021        match doctor_check_mcp_server(&server) {
17022            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
17023            other => panic!("Expected Ok, got {other:?}"),
17024        }
17025    }
17026
17027    #[test]
17028    fn test_command_server_is_ok() {
17029        let executable = std::env::current_exe().expect("current test executable");
17030        let executable = executable.to_string_lossy();
17031        let server = make_server(Some(&executable), &["server.js"], None);
17032        match doctor_check_mcp_server(&server) {
17033            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17034            other => panic!("Expected Ok, got {other:?}"),
17035        }
17036    }
17037
17038    #[test]
17039    fn test_relative_stdio_path_arg_without_cwd_warns() {
17040        let executable = std::env::current_exe().expect("current test executable");
17041        let executable = executable.to_string_lossy();
17042        let server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17043        match doctor_check_mcp_server(&server) {
17044            McpServerDoctorStatus::Warning(detail) => {
17045                assert!(detail.contains("relative path argument"));
17046                assert!(detail.contains("cwd"));
17047            }
17048            other => panic!("Expected Warning for relative path argument, got {other:?}"),
17049        }
17050    }
17051
17052    #[test]
17053    fn test_relative_stdio_path_arg_with_cwd_is_ok() {
17054        let executable = std::env::current_exe().expect("current test executable");
17055        let executable = executable.to_string_lossy();
17056        let mut server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17057        server.cwd = Some(PathBuf::from("/tmp/codewhale-project"));
17058        match doctor_check_mcp_server(&server) {
17059            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17060            other => panic!("Expected Ok when cwd anchors relative path, got {other:?}"),
17061        }
17062    }
17063
17064    #[test]
17065    fn test_self_hosted_absolute_is_ok() {
17066        let executable = std::env::current_exe().expect("current test executable");
17067        let executable = executable.to_string_lossy();
17068        let server = make_server(Some(&executable), &["serve", "--mcp"], None);
17069        match doctor_check_mcp_server(&server) {
17070            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio server")),
17071            McpServerDoctorStatus::Warning(detail) => {
17072                panic!("Absolute path should not warn: {detail}")
17073            }
17074            McpServerDoctorStatus::Error(detail) => panic!("unexpected error: {detail}"),
17075        }
17076    }
17077
17078    #[cfg(test)]
17079    mod mcp_auth_guidance_tests {
17080        #[test]
17081        fn mcp_auth_hint_is_actionable_for_connect_failures() {
17082            let hint = crate::mcp::oauth::auth_required_login_hint("nordic-mcp");
17083            assert_eq!(
17084                hint,
17085                "MCP server 'nordic-mcp' requires OAuth authentication. Run `codewhale mcp login nordic-mcp` to authenticate."
17086            );
17087        }
17088    }
17089
17090    #[test]
17091    fn test_empty_command_is_error() {
17092        let server = make_server(Some(""), &[], None);
17093        assert!(matches!(
17094            doctor_check_mcp_server(&server),
17095            McpServerDoctorStatus::Error(_)
17096        ));
17097    }
17098
17099    #[test]
17100    fn doctor_json_separates_configuration_from_live_health() {
17101        let server = make_server(None, &[], Some("http://127.0.0.1:3000/mcp"));
17102        let report = doctor_mcp_server_json("tools-only", &server);
17103
17104        assert_eq!(report["check_scope"], "configuration");
17105        assert_eq!(report["checks"]["configuration"]["status"], "valid");
17106        assert_eq!(report["checks"]["command"]["status"], "not_applicable");
17107        assert_eq!(
17108            report["checks"]["process_reachable"]["status"],
17109            "not_checked"
17110        );
17111        assert_eq!(
17112            report["checks"]["protocol_initialized"]["status"],
17113            "not_checked"
17114        );
17115        assert_eq!(
17116            report["checks"]["backend_tool_health"]["status"],
17117            "not_checked"
17118        );
17119        assert!(!report.to_string().contains("healthy"));
17120    }
17121
17122    #[cfg(unix)]
17123    #[test]
17124    fn static_mcp_check_never_starts_the_configured_command() {
17125        use std::os::unix::fs::PermissionsExt;
17126
17127        let temp = tempfile::tempdir().expect("tempdir");
17128        let marker = temp.path().join("started");
17129        let script = temp.path().join("mcp-server");
17130        std::fs::write(
17131            &script,
17132            format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
17133        )
17134        .expect("write test server");
17135        let mut permissions = std::fs::metadata(&script)
17136            .expect("script metadata")
17137            .permissions();
17138        permissions.set_mode(0o755);
17139        std::fs::set_permissions(&script, permissions).expect("make script executable");
17140
17141        let script = script.to_string_lossy();
17142        let server = make_server(Some(&script), &[], None);
17143        assert!(matches!(
17144            doctor_check_mcp_server(&server),
17145            McpServerDoctorStatus::Ok(_)
17146        ));
17147        assert!(!marker.exists(), "static doctor check started MCP server");
17148    }
17149}
17150
17151#[cfg(test)]
17152mod doctor_live_probe_tests {
17153    use super::*;
17154
17155    #[test]
17156    fn local_provider_probe_requires_explicit_opt_in() {
17157        assert!(!doctor_should_probe_api(
17158            crate::config::ApiProvider::Ollama,
17159            "http://127.0.0.1:11434/v1",
17160            crate::doctor::DoctorProbeRequest::default(),
17161        ));
17162        assert!(doctor_should_probe_api(
17163            crate::config::ApiProvider::Ollama,
17164            "http://127.0.0.1:11434/v1",
17165            crate::doctor::DoctorProbeRequest {
17166                probe_local: true,
17167                ..crate::doctor::DoctorProbeRequest::default()
17168            },
17169        ));
17170    }
17171
17172    #[test]
17173    fn ollama_cloud_probe_uses_hosted_opt_in_not_local_opt_in() {
17174        let cloud = codewhale_config::provider::OLLAMA_CLOUD_BASE_URL;
17175        assert!(!doctor_should_probe_api(
17176            crate::config::ApiProvider::OllamaCloud,
17177            cloud,
17178            crate::doctor::DoctorProbeRequest::default(),
17179        ));
17180        assert!(doctor_should_probe_api(
17181            crate::config::ApiProvider::OllamaCloud,
17182            cloud,
17183            crate::doctor::DoctorProbeRequest {
17184                probe_api: true,
17185                ..crate::doctor::DoctorProbeRequest::default()
17186            },
17187        ));
17188        assert!(!doctor_should_probe_api(
17189            crate::config::ApiProvider::OllamaCloud,
17190            cloud,
17191            crate::doctor::DoctorProbeRequest {
17192                probe_local: true,
17193                ..crate::doctor::DoctorProbeRequest::default()
17194            },
17195        ));
17196    }
17197
17198    #[test]
17199    fn custom_loopback_probe_also_requires_explicit_opt_in() {
17200        assert!(!doctor_should_probe_api(
17201            crate::config::ApiProvider::Custom,
17202            "http://localhost:8000/v1",
17203            crate::doctor::DoctorProbeRequest::default(),
17204        ));
17205    }
17206
17207    #[test]
17208    fn oauth_routes_skip_live_probe_to_keep_doctor_non_mutating() {
17209        let codex = Config {
17210            provider: Some("openai-codex".to_string()),
17211            ..Config::default()
17212        };
17213        assert!(!doctor_should_probe_auth(&codex));
17214
17215        let xai = Config {
17216            provider: Some("xai".to_string()),
17217            providers: Some(crate::config::ProvidersConfig {
17218                xai: crate::config::ProviderConfig {
17219                    auth_mode: Some("oauth".to_string()),
17220                    ..Default::default()
17221                },
17222                ..Default::default()
17223            }),
17224            ..Config::default()
17225        };
17226        assert!(!doctor_should_probe_auth(&xai));
17227        assert!(doctor_should_probe_auth(&Config::default()));
17228    }
17229}
17230
17231#[cfg(test)]
17232mod setup_helper_tests {
17233    use super::*;
17234    use std::collections::BTreeSet;
17235    use tempfile::TempDir;
17236
17237    #[test]
17238    fn init_tools_dir_creates_readme_and_example() {
17239        let tmp = TempDir::new().unwrap();
17240        let dir = tmp.path().join("tools");
17241        let (returned_dir, readme_status, example_status) =
17242            init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
17243
17244        assert_eq!(returned_dir, dir);
17245        assert!(matches!(readme_status, WriteStatus::Created));
17246        assert!(matches!(example_status, WriteStatus::Created));
17247        assert!(dir.join("README.md").exists());
17248        assert!(dir.join("example.sh").exists());
17249
17250        let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
17251        assert!(
17252            readme.contains("# name:"),
17253            "README must show frontmatter convention"
17254        );
17255
17256        let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17257        assert!(example.starts_with("#!/usr/bin/env sh"));
17258        assert!(example.contains("# name: example"));
17259        assert!(example.contains("# description:"));
17260    }
17261
17262    #[test]
17263    fn init_tools_dir_skips_existing_without_force() {
17264        let tmp = TempDir::new().unwrap();
17265        let dir = tmp.path().join("tools");
17266        let _ = init_tools_dir(&dir, false).unwrap();
17267        let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
17268        assert!(matches!(readme_status, WriteStatus::SkippedExists));
17269        assert!(matches!(example_status, WriteStatus::SkippedExists));
17270    }
17271
17272    #[test]
17273    fn init_tools_dir_force_overwrites() {
17274        let tmp = TempDir::new().unwrap();
17275        let dir = tmp.path().join("tools");
17276        let _ = init_tools_dir(&dir, false).unwrap();
17277        std::fs::write(dir.join("example.sh"), "stale").unwrap();
17278        let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
17279        assert!(matches!(example_status, WriteStatus::Overwritten));
17280        let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17281        assert_ne!(example, "stale");
17282    }
17283
17284    #[test]
17285    fn init_plugins_dir_creates_readme_and_example_layout() {
17286        let tmp = TempDir::new().unwrap();
17287        let dir = tmp.path().join("plugins");
17288        let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
17289            init_plugins_dir(&dir, false).unwrap();
17290
17291        assert_eq!(readme_path, dir.join("README.md"));
17292        assert_eq!(manifest_path, dir.join("example").join("plugin.toml"));
17293        assert_eq!(
17294            skill_path,
17295            dir.join("example/skills/hello").join("SKILL.md")
17296        );
17297        assert!(matches!(readme_status, WriteStatus::Created));
17298        assert!(matches!(manifest_status, WriteStatus::Created));
17299        assert!(matches!(skill_status, WriteStatus::Created));
17300        assert!(readme_path.exists());
17301        assert!(manifest_path.exists());
17302        assert!(skill_path.exists());
17303
17304        let manifest = std::fs::read_to_string(&manifest_path).unwrap();
17305        assert!(manifest.contains("schema_version = 1"));
17306        assert!(manifest.contains("name = \"example\""));
17307        let validated =
17308            crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
17309                .expect("scaffolded plugin should validate");
17310        assert_eq!(validated.inventory.skills, 1);
17311    }
17312
17313    #[test]
17314    fn collect_clean_targets_finds_all_checkpoint_json_files() {
17315        let tmp = TempDir::new().unwrap();
17316        let dir = tmp.path();
17317        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17318        std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17319        // Per-session crash checkpoint files are clean targets too.
17320        std::fs::write(dir.join("some-session-id.json"), "{}").unwrap();
17321        // Non-JSON files and subdirectories are left alone.
17322        std::fs::write(dir.join("notes.txt"), "keep").unwrap();
17323        std::fs::create_dir_all(dir.join("subdir")).unwrap();
17324
17325        let plan = collect_clean_targets(dir);
17326        assert_eq!(plan.targets.len(), 3);
17327        assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
17328        assert!(
17329            plan.targets
17330                .iter()
17331                .any(|p| p.ends_with("offline_queue.json"))
17332        );
17333        assert!(
17334            plan.targets
17335                .iter()
17336                .any(|p| p.ends_with("some-session-id.json"))
17337        );
17338        assert!(!plan.targets.iter().any(|p| p.ends_with("notes.txt")));
17339    }
17340
17341    #[test]
17342    fn execute_clean_plan_removes_files_and_returns_them() {
17343        let tmp = TempDir::new().unwrap();
17344        let dir = tmp.path();
17345        let latest = dir.join("latest.json");
17346        let queue = dir.join("offline_queue.json");
17347        std::fs::write(&latest, "{}").unwrap();
17348        std::fs::write(&queue, "[]").unwrap();
17349
17350        let plan = collect_clean_targets(dir);
17351        let removed = execute_clean_plan(&plan).unwrap();
17352        assert_eq!(removed.len(), 2);
17353        assert!(!latest.exists());
17354        assert!(!queue.exists());
17355    }
17356
17357    #[test]
17358    fn run_setup_clean_dry_run_lists_targets_without_force() {
17359        let tmp = TempDir::new().unwrap();
17360        let dir = tmp.path();
17361        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17362        run_setup_clean(dir, false).unwrap();
17363        // Without --force, files must remain on disk.
17364        assert!(dir.join("latest.json").exists());
17365    }
17366
17367    #[test]
17368    fn run_setup_clean_force_removes_files() {
17369        let tmp = TempDir::new().unwrap();
17370        let dir = tmp.path();
17371        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17372        std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17373        run_setup_clean(dir, true).unwrap();
17374        assert!(!dir.join("latest.json").exists());
17375        assert!(!dir.join("offline_queue.json").exists());
17376    }
17377
17378    #[test]
17379    fn run_setup_clean_handles_missing_dir() {
17380        let tmp = TempDir::new().unwrap();
17381        let dir = tmp.path().join("does-not-exist");
17382        // Should print and return Ok without error.
17383        run_setup_clean(&dir, true).unwrap();
17384        assert!(!dir.exists());
17385    }
17386
17387    fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
17388        let prev_home = std::env::var_os("HOME");
17389        let prev_userprofile = std::env::var_os("USERPROFILE");
17390        unsafe {
17391            std::env::set_var("HOME", home);
17392            std::env::set_var("USERPROFILE", home);
17393        }
17394        let result = f();
17395        unsafe {
17396            match prev_home {
17397                Some(value) => std::env::set_var("HOME", value),
17398                None => std::env::remove_var("HOME"),
17399            }
17400            match prev_userprofile {
17401                Some(value) => std::env::set_var("USERPROFILE", value),
17402                None => std::env::remove_var("USERPROFILE"),
17403            }
17404        }
17405        result
17406    }
17407
17408    #[test]
17409    fn plain_launch_preserves_checkpoint_but_starts_fresh() {
17410        let _guard = crate::test_support::lock_test_env();
17411        let tmp = TempDir::new().unwrap();
17412        let workspace = tmp.path().join("workspace");
17413        std::fs::create_dir_all(&workspace).unwrap();
17414
17415        with_home(tmp.path(), || {
17416            let manager = SessionManager::default_location().expect("manager");
17417            let messages = vec![Message {
17418                role: "user".to_string(),
17419                content: vec![ContentBlock::Text {
17420                    text: "in flight".to_string(),
17421                    cache_control: None,
17422                }],
17423            }];
17424            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17425            let session_id = session.metadata.id.clone();
17426            manager.save_checkpoint(&session).expect("save checkpoint");
17427
17428            preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17429
17430            assert!(
17431                manager
17432                    .load_session_checkpoint(&session_id)
17433                    .expect("load checkpoint")
17434                    .is_some(),
17435                "normal launch must leave the per-session checkpoint in place \
17436                 (it may belong to a live session; `--continue` consumes it)"
17437            );
17438            // #4479: checkpoint is no longer promoted to session file.
17439            assert!(
17440                manager
17441                    .load_session_checkpoint(&session_id)
17442                    .expect("load checkpoint")
17443                    .is_some(),
17444                "checkpoint stays in checkpoints/ for --continue"
17445            );
17446        });
17447    }
17448
17449    #[test]
17450    fn plain_launch_consumes_legacy_checkpoint_after_preserving_it() {
17451        let _guard = crate::test_support::lock_test_env();
17452        let tmp = TempDir::new().unwrap();
17453        let workspace = tmp.path().join("workspace");
17454        std::fs::create_dir_all(&workspace).unwrap();
17455
17456        with_home(tmp.path(), || {
17457            let manager = SessionManager::default_location().expect("manager");
17458            let session = create_saved_session(
17459                &[Message {
17460                    role: "user".to_string(),
17461                    content: vec![ContentBlock::Text {
17462                        text: "legacy in flight".to_string(),
17463                        cache_control: None,
17464                    }],
17465                }],
17466                "test-model",
17467                &workspace,
17468                0,
17469                None,
17470            );
17471            let session_id = session.metadata.id.clone();
17472            write_legacy_checkpoint(&manager, &session);
17473
17474            preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17475
17476            assert!(
17477                manager
17478                    .load_legacy_checkpoint()
17479                    .expect("load legacy checkpoint")
17480                    .is_none(),
17481                "normal launch should consume the legacy single-slot checkpoint"
17482            );
17483            // #4479: checkpoint is no longer promoted to session file.
17484            assert!(
17485                manager
17486                    .load_session_checkpoint(&session_id)
17487                    .expect("load checkpoint")
17488                    .is_some(),
17489                "checkpoint stays in checkpoints/ for --continue"
17490            );
17491        });
17492    }
17493
17494    #[test]
17495    fn continue_recovers_same_workspace_checkpoint() {
17496        let _guard = crate::test_support::lock_test_env();
17497        let tmp = TempDir::new().unwrap();
17498        let workspace = tmp.path().join("workspace");
17499        std::fs::create_dir_all(&workspace).unwrap();
17500
17501        with_home(tmp.path(), || {
17502            let manager = SessionManager::default_location().expect("manager");
17503            let messages = vec![Message {
17504                role: "user".to_string(),
17505                content: vec![ContentBlock::Text {
17506                    text: "continue me".to_string(),
17507                    cache_control: None,
17508                }],
17509            }];
17510            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17511            let session_id = session.metadata.id.clone();
17512            manager.save_checkpoint(&session).expect("save checkpoint");
17513
17514            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17515
17516            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17517            assert!(
17518                manager
17519                    .load_session_checkpoint(&session_id)
17520                    .expect("load checkpoint")
17521                    .is_none(),
17522                "--continue should consume the per-session checkpoint"
17523            );
17524            assert!(manager.load_session(&session_id).is_ok());
17525        });
17526    }
17527
17528    /// Write a legacy single-slot checkpoint file the way pre-cutover
17529    /// binaries did. The current binary only reads this slot.
17530    fn write_legacy_checkpoint(manager: &SessionManager, session: &session_manager::SavedSession) {
17531        let checkpoints = manager.sessions_dir().join("checkpoints");
17532        std::fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
17533        let content = serde_json::to_string_pretty(session).expect("serialize checkpoint");
17534        std::fs::write(checkpoints.join("latest.json"), content).expect("write legacy checkpoint");
17535    }
17536
17537    #[test]
17538    fn continue_recovers_legacy_checkpoint_and_migrates_it() {
17539        let _guard = crate::test_support::lock_test_env();
17540        let tmp = TempDir::new().unwrap();
17541        let workspace = tmp.path().join("workspace");
17542        std::fs::create_dir_all(&workspace).unwrap();
17543
17544        with_home(tmp.path(), || {
17545            let manager = SessionManager::default_location().expect("manager");
17546            let messages = vec![Message {
17547                role: "user".to_string(),
17548                content: vec![ContentBlock::Text {
17549                    text: "legacy continue".to_string(),
17550                    cache_control: None,
17551                }],
17552            }];
17553            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17554            let session_id = session.metadata.id.clone();
17555            write_legacy_checkpoint(&manager, &session);
17556
17557            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17558
17559            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17560            assert!(
17561                manager.load_session(&session_id).is_ok(),
17562                "recovered legacy checkpoint must be loadable as a session"
17563            );
17564            assert!(
17565                manager
17566                    .load_session_checkpoint(&session_id)
17567                    .expect("load per-session checkpoint")
17568                    .is_some(),
17569                "legacy recovery must migrate to a per-session checkpoint file"
17570            );
17571            assert!(
17572                manager
17573                    .load_legacy_checkpoint()
17574                    .expect("load legacy checkpoint")
17575                    .is_some(),
17576                "legacy latest.json stays in place for one more release"
17577            );
17578        });
17579    }
17580
17581    #[test]
17582    fn continue_refuses_checkpoint_from_other_workspace() {
17583        let _guard = crate::test_support::lock_test_env();
17584        let tmp = TempDir::new().unwrap();
17585        let launch_workspace = tmp.path().join("launch-workspace");
17586        let other_workspace = tmp.path().join("other-workspace");
17587        std::fs::create_dir_all(&launch_workspace).unwrap();
17588        std::fs::create_dir_all(&other_workspace).unwrap();
17589
17590        with_home(tmp.path(), || {
17591            let manager = SessionManager::default_location().expect("manager");
17592            let messages = vec![Message {
17593                role: "user".to_string(),
17594                content: vec![ContentBlock::Text {
17595                    text: "belongs elsewhere".to_string(),
17596                    cache_control: None,
17597                }],
17598            }];
17599            let session = create_saved_session(&messages, "test-model", &other_workspace, 0, None);
17600            let session_id = session.metadata.id.clone();
17601            manager.save_checkpoint(&session).expect("save checkpoint");
17602
17603            let recovered = recover_interrupted_checkpoint_for_resume(&launch_workspace);
17604
17605            assert_eq!(recovered, None, "workspace mismatch must refuse recovery");
17606            assert!(
17607                manager
17608                    .load_session_checkpoint(&session_id)
17609                    .expect("load checkpoint")
17610                    .is_some(),
17611                "another workspace's checkpoint file must be left untouched"
17612            );
17613        });
17614    }
17615
17616    #[test]
17617    fn continue_twice_does_not_clobber_newer_session_with_stale_legacy_checkpoint() {
17618        let _guard = crate::test_support::lock_test_env();
17619        let tmp = TempDir::new().unwrap();
17620        let workspace = tmp.path().join("workspace");
17621        std::fs::create_dir_all(&workspace).unwrap();
17622
17623        with_home(tmp.path(), || {
17624            let manager = SessionManager::default_location().expect("manager");
17625            let stale = create_saved_session(
17626                &[Message {
17627                    role: "user".to_string(),
17628                    content: vec![ContentBlock::Text {
17629                        text: "crash-time state".to_string(),
17630                        cache_control: None,
17631                    }],
17632                }],
17633                "test-model",
17634                &workspace,
17635                0,
17636                None,
17637            );
17638            let session_id = stale.metadata.id.clone();
17639            write_legacy_checkpoint(&manager, &stale);
17640
17641            // The session advanced after the checkpoint was taken: a newer
17642            // regular session file exists for the same id.
17643            let mut advanced = stale.clone();
17644            advanced.messages.push(Message {
17645                role: "assistant".to_string(),
17646                content: vec![ContentBlock::Text {
17647                    text: "post-recovery progress".to_string(),
17648                    cache_control: None,
17649                }],
17650            });
17651            advanced.metadata.message_count = advanced.messages.len();
17652            advanced.metadata.updated_at = stale.metadata.updated_at + chrono::Duration::hours(1);
17653            manager.save_session(&advanced).expect("save newer session");
17654
17655            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17656
17657            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17658            let persisted = manager.load_session(&session_id).expect("load session");
17659            assert_eq!(
17660                persisted.messages.len(),
17661                advanced.messages.len(),
17662                "stale checkpoint content must not overwrite the newer session"
17663            );
17664        });
17665    }
17666
17667    #[test]
17668    fn dotenv_status_points_to_example_when_present() {
17669        let tmp = TempDir::new().unwrap();
17670        std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
17671
17672        assert_eq!(
17673            dotenv_status_line(tmp.path()),
17674            ".env not present in workspace (run `cp .env.example .env` and edit)"
17675        );
17676
17677        std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
17678        assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
17679    }
17680
17681    #[test]
17682    fn env_example_is_trackable_and_every_key_is_wired() {
17683        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
17684        let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
17685        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
17686
17687        assert!(gitignore.contains("!.env.example"));
17688
17689        let keys = documented_env_keys(&env_example);
17690        for required in [
17691            "DEEPSEEK_API_KEY",
17692            "NVIDIA_API_KEY",
17693            "NVIDIA_NIM_API_KEY",
17694            "ATLASCLOUD_API_KEY",
17695        ] {
17696            assert!(
17697                keys.contains(required),
17698                ".env.example is missing {required}"
17699            );
17700        }
17701
17702        for key in &keys {
17703            assert!(
17704                is_workspace_dotenv_credential_key(key),
17705                ".env.example documents non-credential control setting {key}"
17706            );
17707        }
17708
17709        let sources = [
17710            include_str!("config.rs"),
17711            include_str!("logging.rs"),
17712            include_str!("../../config/src/lib.rs"),
17713            include_str!("../../config/src/provider.rs"),
17714            include_str!("../../cli/src/main.rs"),
17715        ]
17716        .join("\n");
17717
17718        for key in keys {
17719            assert!(
17720                sources.contains(&key),
17721                ".env.example documents {key}, but no source file references it"
17722            );
17723        }
17724    }
17725
17726    fn documented_env_keys(content: &str) -> BTreeSet<String> {
17727        content
17728            .lines()
17729            .filter_map(|line| {
17730                let trimmed = line.trim();
17731                let uncommented = trimmed
17732                    .strip_prefix('#')
17733                    .map(str::trim_start)
17734                    .unwrap_or(trimmed);
17735                let (key, _) = uncommented.split_once('=')?;
17736                let key = key.trim();
17737                let is_env_key = key
17738                    .chars()
17739                    .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
17740                    && key.chars().any(|ch| ch == '_');
17741                is_env_key.then(|| key.to_string())
17742            })
17743            .collect()
17744    }
17745
17746    #[test]
17747    fn custom_provider_env_source_precedes_saved_secret_store() {
17748        let _lock = crate::test_support::lock_test_env();
17749        let temp = TempDir::new().expect("temp home");
17750        let codewhale_home = temp.path().join("codewhale-home");
17751        std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17752        let _home =
17753            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17754        let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17755        let _declared_env =
17756            crate::test_support::EnvVarGuard::set("QA_CUSTOM_API_KEY", "declared-env-key");
17757        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17758        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17759        codewhale_secrets::Secrets::auto_detect()
17760            .set("custom", "saved-custom-secret")
17761            .expect("save secret");
17762
17763        let mut custom = std::collections::HashMap::new();
17764        custom.insert(
17765            "qa-gateway".to_string(),
17766            crate::config::ProviderConfig {
17767                kind: Some("openai-compatible".to_string()),
17768                base_url: Some("https://gateway.example.test/v1".to_string()),
17769                model: Some("qa-model".to_string()),
17770                api_key_env: Some("QA_CUSTOM_API_KEY".to_string()),
17771                ..Default::default()
17772            },
17773        );
17774        let config = Config {
17775            provider: Some("qa-gateway".to_string()),
17776            providers: Some(crate::config::ProvidersConfig {
17777                custom,
17778                ..Default::default()
17779            }),
17780            ..Config::default()
17781        };
17782
17783        assert_eq!(resolve_api_key_source(&config), ApiKeySource::EnvDeclared);
17784        assert_eq!(
17785            config.deepseek_api_key().expect("custom key"),
17786            "declared-env-key"
17787        );
17788    }
17789
17790    #[test]
17791    fn named_custom_provider_does_not_report_generic_secret_store() {
17792        let _lock = crate::test_support::lock_test_env();
17793        let temp = TempDir::new().expect("temp home");
17794        let codewhale_home = temp.path().join("codewhale-home");
17795        std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17796        let _home =
17797            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17798        let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17799        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17800        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17801        codewhale_secrets::Secrets::auto_detect()
17802            .set("custom", "unrelated-custom-secret")
17803            .expect("save secret");
17804
17805        let mut custom = std::collections::HashMap::new();
17806        custom.insert(
17807            "qa-gateway".to_string(),
17808            crate::config::ProviderConfig {
17809                kind: Some("openai-compatible".to_string()),
17810                base_url: Some("https://gateway.example.test/v1".to_string()),
17811                model: Some("qa-model".to_string()),
17812                auth_mode: Some("api_key".to_string()),
17813                ..Default::default()
17814            },
17815        );
17816        let config = Config {
17817            provider: Some("qa-gateway".to_string()),
17818            providers: Some(crate::config::ProvidersConfig {
17819                custom,
17820                ..Default::default()
17821            }),
17822            ..Config::default()
17823        };
17824
17825        assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17826        assert!(config.deepseek_api_key().is_err());
17827    }
17828
17829    #[test]
17830    fn custom_built_in_endpoint_does_not_report_ambient_provider_key() {
17831        let _lock = crate::test_support::lock_test_env();
17832        let _openrouter =
17833            crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17834        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17835        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17836        let mut providers = crate::config::ProvidersConfig::default();
17837        providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
17838        let config = Config {
17839            provider: Some("openrouter".to_string()),
17840            providers: Some(providers),
17841            ..Config::default()
17842        };
17843
17844        assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
17845        assert!(config.deepseek_api_key().is_err());
17846    }
17847
17848    #[test]
17849    fn ollama_doctor_credential_source_is_route_aware() {
17850        let local = Config {
17851            provider: Some("ollama".to_string()),
17852            ..Config::default()
17853        };
17854        assert_eq!(resolve_api_key_source(&local), ApiKeySource::LocalRuntime);
17855        assert_eq!(
17856            resolve_credential_diagnostic(&local).availability,
17857            CredentialAvailability::NotRequired
17858        );
17859
17860        let ollama_config = |base_url: &str| Config {
17861            provider: Some("ollama".to_string()),
17862            providers: Some(crate::config::ProvidersConfig {
17863                ollama: crate::config::ProviderConfig {
17864                    base_url: Some(base_url.to_string()),
17865                    ..Default::default()
17866                },
17867                ..Default::default()
17868            }),
17869            ..Config::default()
17870        };
17871        let cloud = ollama_config(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL);
17872        assert_eq!(
17873            cloud.api_provider(),
17874            crate::config::ApiProvider::OllamaCloud
17875        );
17876        assert_eq!(
17877            resolve_api_key_source(&cloud),
17878            ApiKeySource::SecretStoreUnprobed
17879        );
17880        assert_eq!(
17881            resolve_credential_diagnostic(&cloud).availability,
17882            CredentialAvailability::NotProbed
17883        );
17884        assert_eq!(doctor_auth_scheme(&cloud), "bearer");
17885        let report = doctor_route_report(&cloud);
17886        assert_eq!(report["provider"], "ollama-cloud");
17887        assert_eq!(report["provider_config_table"], "ollama_cloud");
17888
17889        let custom_remote = ollama_config("https://ollama-gateway.example.test/v1");
17890        assert_eq!(
17891            resolve_api_key_source(&custom_remote),
17892            ApiKeySource::Unknown
17893        );
17894        assert_eq!(
17895            resolve_credential_diagnostic(&custom_remote).availability,
17896            CredentialAvailability::Unknown
17897        );
17898    }
17899
17900    #[test]
17901    fn auth_mode_none_reports_distinct_no_auth_source_and_scheme() {
17902        let _lock = crate::test_support::lock_test_env();
17903        let _openrouter =
17904            crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
17905        let mut providers = crate::config::ProvidersConfig::default();
17906        providers.openrouter.auth_mode = Some("none".to_string());
17907        providers.openrouter.api_key = Some("configured-key".to_string());
17908        let config = Config {
17909            provider: Some("openrouter".to_string()),
17910            providers: Some(providers),
17911            ..Config::default()
17912        };
17913
17914        assert_eq!(resolve_api_key_source(&config), ApiKeySource::NoAuth);
17915        assert_eq!(doctor_api_key_source_label(ApiKeySource::NoAuth), "none");
17916        assert_eq!(doctor_auth_scheme(&config), "none");
17917        assert_eq!(config.deepseek_api_key().expect("no-auth route"), "");
17918    }
17919
17920    #[test]
17921    fn resolve_api_key_source_prefers_config_over_env() {
17922        let _guard = crate::test_support::lock_test_env();
17923        let prev = std::env::var("DEEPSEEK_API_KEY").ok();
17924        let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
17925        unsafe {
17926            std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
17927            std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
17928        }
17929        let cfg = Config {
17930            api_key: Some("fresh-config-key".to_string()),
17931            ..Config::default()
17932        };
17933        let source = resolve_api_key_source(&cfg);
17934        match prev {
17935            Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
17936            None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
17937        }
17938        match prev_source {
17939            Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
17940            None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
17941        }
17942        assert_eq!(source, ApiKeySource::ConfigDeclared);
17943    }
17944
17945    #[test]
17946    fn resolve_api_key_source_reports_active_provider_env_from_metadata() {
17947        let _guard = crate::test_support::lock_test_env();
17948        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17949        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17950        let _anthropic_key =
17951            crate::test_support::EnvVarGuard::set("ANTHROPIC_API_KEY", "test-anthropic-key");
17952        let cfg = Config {
17953            provider: Some("anthropic".to_string()),
17954            ..Config::default()
17955        };
17956
17957        let source = resolve_api_key_source(&cfg);
17958
17959        assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
17960    }
17961
17962    #[test]
17963    fn resolve_api_key_source_ignores_unresolved_provider_command_metadata() {
17964        let _guard = crate::test_support::lock_test_env();
17965        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17966        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17967        let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17968        let mut providers = crate::config::ProvidersConfig::default();
17969        providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17970            source: codewhale_config::AuthSourceKind::Command,
17971            command: vec!["secret-tool".to_string(), "lookup".to_string()],
17972            timeout_ms: Some(2000),
17973            secret_id: None,
17974        });
17975        let cfg = Config {
17976            provider: Some("openai".to_string()),
17977            providers: Some(providers),
17978            ..Config::default()
17979        };
17980
17981        let source = resolve_api_key_source(&cfg);
17982
17983        assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
17984        assert!(cfg.deepseek_api_key().is_err());
17985    }
17986
17987    #[test]
17988    fn resolve_api_key_source_ignores_unresolved_provider_secret_metadata() {
17989        let _guard = crate::test_support::lock_test_env();
17990        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17991        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17992        let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
17993        let mut providers = crate::config::ProvidersConfig::default();
17994        providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
17995            source: codewhale_config::AuthSourceKind::Secret,
17996            command: Vec::new(),
17997            timeout_ms: None,
17998            secret_id: Some("codewhale/openai".to_string()),
17999        });
18000        let cfg = Config {
18001            provider: Some("openai".to_string()),
18002            providers: Some(providers),
18003            ..Config::default()
18004        };
18005
18006        let source = resolve_api_key_source(&cfg);
18007
18008        assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
18009        assert!(cfg.deepseek_api_key().is_err());
18010    }
18011
18012    #[test]
18013    fn resolve_api_key_source_ignores_root_deepseek_key_for_other_provider() {
18014        let _guard = crate::test_support::lock_test_env();
18015        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18016        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18017        let _openrouter_key = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
18018        let cfg = Config {
18019            provider: Some("openrouter".to_string()),
18020            api_key: Some("legacy-deepseek-root-key".to_string()),
18021            ..Config::default()
18022        };
18023
18024        let source = resolve_api_key_source(&cfg);
18025
18026        assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
18027    }
18028
18029    #[test]
18030    fn provider_status_helpers_use_provider_metadata() {
18031        assert_eq!(
18032            provider_config_table_key(crate::config::ApiProvider::Anthropic),
18033            "anthropic"
18034        );
18035        assert_eq!(
18036            provider_config_table_key(crate::config::ApiProvider::SiliconflowCn),
18037            "siliconflow_cn"
18038        );
18039    }
18040
18041    #[test]
18042    fn skills_count_for_returns_zero_for_missing_dir() {
18043        let tmp = TempDir::new().unwrap();
18044        let dir = tmp.path().join("nope");
18045        assert_eq!(skills_count_for(&dir), 0);
18046    }
18047
18048    #[test]
18049    fn skills_count_for_counts_valid_skill_dirs() {
18050        let tmp = TempDir::new().unwrap();
18051        let dir = tmp.path().join("skills");
18052        let skill_dir = dir.join("getting-started");
18053        std::fs::create_dir_all(&skill_dir).unwrap();
18054        std::fs::write(
18055            skill_dir.join("SKILL.md"),
18056            "---\nname: getting-started\ndescription: hi\n---\nbody",
18057        )
18058        .unwrap();
18059        assert_eq!(skills_count_for(&dir), 1);
18060    }
18061}
18062
18063#[cfg(test)]
18064#[path = "tests/pr_prompt.rs"]
18065mod pr_prompt_tests;
18066
18067#[cfg(test)]
18068#[path = "tests/telemetry_surface.rs"]
18069mod telemetry_surface_tests;
18070
18071#[cfg(test)]
18072#[path = "tests/telemetry_counters.rs"]
18073mod telemetry_counter_tests;