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        /// Record the Codewhale palette (skin) decision for the bundle profile; applied via DSH's `overrideTokens`, never through the overlay
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        /// Turn the bundle-profile skin on/off (`--skin false`; defaults to the previous choice)
1441        #[arg(long)]
1442        skin: Option<bool>,
1443        /// Turn the ambient ocean scene behind the DSH web UI on/off (`--ocean false`; defaults to the previous choice, initially on; needs the skin)
1444        #[arg(long)]
1445        ocean: Option<bool>,
1446        #[arg(long, default_value_t = false)]
1447        yes: bool,
1448    },
1449    /// Run dsh with the Codewhale overlay; extra args go to the dsh app
1450    Launch {
1451        /// Override the recorded profile (`web` or `headless`)
1452        #[arg(long)]
1453        profile: Option<String>,
1454        /// Print the exact command instead of running it
1455        #[arg(long, default_value_t = false)]
1456        dry_run: bool,
1457        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
1458        args: Vec<String>,
1459    },
1460    /// Keep the overlay but refuse launches
1461    Disable,
1462    /// Allow launches again
1463    Enable,
1464    /// Delete Codewhale-owned files only; $DSH_HOME is never touched
1465    Remove {
1466        #[arg(long, default_value_t = false)]
1467        yes: bool,
1468    },
1469    /// Documented DSH plugin path: install the Codewhale bundle into a dedicated `codewhale` DSH profile via `dsh plugin add` (pnpm required)
1470    InstallBundle {
1471        /// Which shipped DSH app the dedicated profile boots (`web` or `headless`)
1472        #[arg(long, default_value = "web")]
1473        app: String,
1474        #[arg(long, default_value_t = false)]
1475        yes: bool,
1476    },
1477    /// `dsh plugin --profile codewhale remove codewhale-dsh-bundle`, then delete only Codewhale-owned bundle files
1478    RemoveBundle {
1479        #[arg(long, default_value_t = false)]
1480        yes: bool,
1481    },
1482}
1483
1484#[derive(Args, Debug, Clone)]
1485struct FeaturesCli {
1486    #[command(subcommand)]
1487    command: FeaturesSubcommand,
1488}
1489
1490#[derive(Subcommand, Debug, Clone)]
1491enum FeaturesSubcommand {
1492    /// List known feature flags and their state
1493    List,
1494}
1495
1496#[derive(Args, Debug, Clone)]
1497struct SandboxArgs {
1498    #[command(subcommand)]
1499    command: SandboxCommand,
1500}
1501
1502#[derive(Subcommand, Debug, Clone)]
1503enum SandboxCommand {
1504    /// Run a command with sandboxing
1505    Run {
1506        /// Sandbox policy (danger-full-access, read-only, external-sandbox, workspace-write)
1507        #[arg(long, default_value = "workspace-write")]
1508        policy: String,
1509        /// Allow outbound network access
1510        #[arg(long)]
1511        network: bool,
1512        /// Additional writable roots (repeatable)
1513        #[arg(long, value_name = "PATH")]
1514        writable_root: Vec<PathBuf>,
1515        /// Exclude TMPDIR from writable paths
1516        #[arg(long)]
1517        exclude_tmpdir: bool,
1518        /// Exclude /tmp from writable paths
1519        #[arg(long)]
1520        exclude_slash_tmp: bool,
1521        /// Command working directory
1522        #[arg(long)]
1523        cwd: Option<PathBuf>,
1524        /// Timeout in milliseconds
1525        #[arg(long, default_value_t = 60_000)]
1526        timeout_ms: u64,
1527        /// Command and arguments to run
1528        #[arg(required = true, trailing_var_arg = true)]
1529        command: Vec<String>,
1530    },
1531}
1532
1533const CODEWHALE_MAIN_STACK_BYTES: usize = 16 * 1024 * 1024;
1534
1535/// Entry point for the single binary. Takes argv including binary name at 0,
1536/// parses with clap, and runs the TUI/runtime dispatch. Returns process exit
1537/// code for the caller to exit with.
1538pub fn run(args: Vec<String>) -> std::process::ExitCode {
1539    match run_with_args(args) {
1540        Ok(()) => std::process::ExitCode::SUCCESS,
1541        Err(err) => {
1542            eprintln!("error: {err}");
1543            for cause in err.chain().skip(1) {
1544                eprintln!("  caused by: {cause}");
1545            }
1546            std::process::ExitCode::FAILURE
1547        }
1548    }
1549}
1550
1551/// Internal implementation that mirrors the old `main()` but takes explicit
1552/// args instead of reading `std::env::args()`. Used by `run()` and tested
1553/// directly.
1554fn run_with_args(args: Vec<String>) -> Result<()> {
1555    // Match the dispatcher entrypoint: Unix shells and supervisors may inherit
1556    // SIGPIPE ignored, which turns short pipelines such as `codewhale doctor |
1557    // head` into BrokenPipe panics once this delegated TUI binary prints.
1558    #[cfg(unix)]
1559    unsafe {
1560        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1561    }
1562
1563    startup_trace::mark_process_start();
1564    configure_windows_console_utf8();
1565    install_rustls_crypto_provider();
1566
1567    // ── Process hardening (#2183) ─────────────────────────────────────────
1568    // MUST run before Tokio is booted and before any threads are spawned.
1569    // See crates/tui/src/sandbox/process_hardening.rs for ordering rationale.
1570    crate::sandbox::process_hardening::apply_process_hardening();
1571
1572    // ── Fatal-signal terminal guard (#5424) ───────────────────────────────
1573    // Abort-class deaths (stack overflow, allocation failure, double panic)
1574    // skip the panic hook AND every Drop guard, leaving mouse capture and
1575    // the kitty keyboard stack leaking into the user's shell. A classic
1576    // sigaction handler restores the terminal and stamps a marker before
1577    // re-raising. Also before any threads exist.
1578    crate::tui::ui::fatal_signal_guard::install_fatal_signal_guard();
1579
1580    // Set up process panic hook before anything else — writes crash dumps
1581    // to ~/.deepseek/crashes/ even if the panic happens before tokio is up,
1582    // and restores the terminal so a panicked TUI doesn't leave the user's
1583    // shell stuck in alt-screen mode.
1584    let orig_hook = std::panic::take_hook();
1585    std::panic::set_hook(Box::new(move |panic_info| {
1586        // Restore the terminal first so the panic message itself, plus the
1587        // user's shell after exit, are visible. Best-effort — we may not be
1588        // in raw / alt-screen mode if the panic happens pre-TUI. Shared
1589        // with the signal handler installed below so both exit paths leave
1590        // the terminal in the same well-defined state.
1591        crate::tui::ui::emergency_restore_terminal();
1592
1593        let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
1594            s.to_string()
1595        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
1596            s.clone()
1597        } else {
1598            format!("{:?}", panic_info.payload())
1599        };
1600        let location = panic_info
1601            .location()
1602            .map(|loc| loc.to_string())
1603            .unwrap_or_else(|| "unknown".to_string());
1604        tracing::error!(target: "panic", "Process panicked at {location}: {msg}");
1605
1606        // Telemetry, if and only if this process was armed. This hook is
1607        // installed before `Cli::parse()` and long before any config is
1608        // resolved, so it cannot consult a resolved value — but it can consult
1609        // a `OnceLock` that is by construction empty until resolution
1610        // completes. A user who never opted in panics without writing a byte
1611        // and without creating a directory.
1612        //
1613        // The site is allowlist-reduced and `msg` is deliberately not read: a
1614        // slicing panic embeds the entire string being sliced, and this tree
1615        // slices user and model text in dozens of places.
1616        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Panic);
1617        if let Some(site) = panic_info
1618            .location()
1619            .map(|loc| codewhale_telemetry::reduce_panic_site(loc.file(), loc.line(), loc.column()))
1620        {
1621            codewhale_telemetry::record_blocking(codewhale_telemetry::Event::Panic { site });
1622        }
1623        // Write crash dump best-effort
1624        if let Some(home) = crate::config::effective_home_dir() {
1625            let crash_dir = home.join(".deepseek").join("crashes");
1626            let _ = std::fs::create_dir_all(&crash_dir);
1627            use chrono::Utc;
1628            let ts = Utc::now().format("%Y%m%dT%H%M%S%.3fZ");
1629            let path = crash_dir.join(format!("{ts}-process-panic.log"));
1630            let contents =
1631                format!("Process panicked\nLocation: {location}\nTimestamp: {ts}\nPanic: {msg}\n",);
1632            let _ = std::fs::write(&path, contents);
1633        }
1634        // Invoke the original hook (prints to stderr, etc.)
1635        orig_hook(panic_info);
1636    }));
1637
1638    // Parse and freeze every startup authority before Tokio or any other
1639    // worker thread exists. A workspace `.env` is intentionally a narrow
1640    // credential convenience surface: it must never redirect product state,
1641    // configuration, MCP, trust, sandbox, executable lookup, or plugin
1642    // discovery. Plugin discovery therefore runs first, and the loader below
1643    // admits only built-in provider credential names from a stable file read.
1644    let cli = match Cli::try_parse_from(args) {
1645        Ok(c) => c,
1646        Err(e) => {
1647            e.exit();
1648        }
1649    };
1650    // #5098: project-scope fleet agent profiles (`.codewhale/agents/*.toml`)
1651    // join the dispatch roster under the same trust decision as the rest of
1652    // project-level config — `--no-project-config` opts the layer out for
1653    // every roster read in this process.
1654    crate::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
1655    let workspace = resolve_workspace(&cli);
1656    let mut plugin_discovery = None;
1657    let mut plugin_registry = None;
1658    let (cli, command) = prepare_cli_startup(
1659        cli,
1660        || {
1661            let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
1662            plugin_registry = Some(discovery.registry_for_workspace(&workspace));
1663            plugin_discovery = Some(discovery);
1664        },
1665        warn_on_workspace_dotenv_result,
1666    );
1667    let plugin_discovery = plugin_discovery
1668        .expect("plugin discovery initialization must precede workspace dotenv loading");
1669    let plugin_registry = plugin_registry
1670        .expect("plugin discovery initialization must precede workspace dotenv loading");
1671
1672    // The interactive runtime intentionally carries a large state machine:
1673    // terminal rendering, modal dispatch, provider setup, and fleet/workflow
1674    // events all share one async owner. Debug builds retain enough stack
1675    // temporaries that nesting a modal event over the TUI loop can exceed the
1676    // platform main-thread default (8 MiB on macOS). Give that owner an
1677    // explicit stack while keeping process hardening and the global panic hook
1678    // above this boundary, before Tokio or any worker thread exists.
1679    let runtime_thread = std::thread::Builder::new()
1680        .name("codewhale-main".to_string())
1681        .stack_size(CODEWHALE_MAIN_STACK_BYTES)
1682        .spawn(move || run_async_main(cli, command, plugin_discovery, plugin_registry))
1683        .context("Failed to start the Codewhale runtime thread")?;
1684    match runtime_thread.join() {
1685        Ok(result) => result,
1686        Err(payload) => {
1687            let message = payload
1688                .downcast_ref::<&str>()
1689                .map(|value| (*value).to_string())
1690                .or_else(|| payload.downcast_ref::<String>().cloned())
1691                .unwrap_or_else(|| "unknown panic payload".to_string());
1692            Err(anyhow!("Codewhale runtime thread panicked: {message}"))
1693        }
1694    }
1695}
1696
1697fn run_async_main(
1698    cli: Cli,
1699    command: Option<Commands>,
1700    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1701    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1702) -> Result<()> {
1703    build_runtime()?.block_on(run_async_main_inner(
1704        cli,
1705        command,
1706        plugin_discovery,
1707        plugin_registry,
1708    ))
1709}
1710
1711/// Build the runtime that owns every async task in this binary.
1712///
1713/// `#[tokio::main]` used to expand here, which left every worker thread on
1714/// tokio's 2 MiB default while only the `codewhale-main` owner thread above
1715/// received `CODEWHALE_MAIN_STACK_BYTES`. The engine does not run on that owner
1716/// thread — `core::engine::spawn_engine` hands `Engine::run` to
1717/// `utils::spawn_supervised`, a bare `tokio::spawn` — so the explicit stack
1718/// never applied where the depth actually is.
1719///
1720/// A debug-build `agent` dispatch (turn_loop -> FuturesUnordered ->
1721/// execute_full_with_context -> AgentTool::execute -> spawn_subagent_from_input)
1722/// measured a stack high-water mark between 2.25 and 2.5 MiB and aborted the
1723/// whole process on the guard page. A Rust stack overflow is not a panic: it
1724/// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the
1725/// process dies with 134 mid-dispatch.
1726///
1727/// This is behavior-identical to the old `#[tokio::main]` expansion apart from
1728/// the stack size, and it makes the knob greppable.
1729pub(crate) fn build_runtime() -> Result<tokio::runtime::Runtime> {
1730    tokio::runtime::Builder::new_multi_thread()
1731        .enable_all()
1732        .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES)
1733        .build()
1734        .context("Failed to build the Codewhale Tokio runtime")
1735}
1736
1737/// Which product surface this process is serving.
1738///
1739/// A function of the parsed subcommand, never of the executable: this one
1740/// binary serves at least five surfaces, so `current_exe()` would label all of
1741/// them the same.
1742fn telemetry_surface(command: Option<&Commands>) -> codewhale_telemetry::Surface {
1743    use codewhale_telemetry::Surface;
1744    match command {
1745        None | Some(Commands::Resume { .. } | Commands::Fork { .. } | Commands::Pr { .. }) => {
1746            Surface::Tui
1747        }
1748        Some(Commands::Exec(_)) => Surface::Exec,
1749        Some(Commands::Serve(args)) => {
1750            if args.mcp {
1751                Surface::McpServer
1752            } else {
1753                Surface::Serve
1754            }
1755        }
1756        Some(_) => Surface::Cli,
1757    }
1758}
1759
1760/// How this session was started, for `session_start`.
1761fn telemetry_session_source(command: Option<&Commands>) -> codewhale_telemetry::SessionSource {
1762    use codewhale_telemetry::SessionSource;
1763    match command {
1764        None | Some(Commands::Pr { .. }) => SessionSource::Interactive,
1765        Some(Commands::Resume { .. }) => SessionSource::Resume,
1766        Some(Commands::Fork { .. }) => SessionSource::Fork,
1767        Some(Commands::Serve(_)) => SessionSource::Api,
1768        Some(_) => SessionSource::Unknown,
1769    }
1770}
1771
1772/// Read-only commands must not create telemetry state as a side effect.
1773fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
1774    matches!(
1775        command,
1776        Some(Commands::Doctor(_) | Commands::SessionDiagnostics(_) | Commands::Sessions { .. })
1777    ) || matches!(command, Some(Commands::Setup(args)) if args.status)
1778}
1779
1780/// Resolve the emit predicate and arm, once, before anything can record.
1781///
1782/// This is the read that v1 of the design was missing entirely:
1783/// `resolve_runtime_options` had no non-test caller in this crate, so neither
1784/// `telemetry = false` in the config file nor `CODEWHALE_TELEMETRY=0` was ever
1785/// consulted by a process that would have emitted.
1786///
1787/// `CliRuntimeOverrides::default()` is correct here. The dispatcher has already
1788/// applied the kill-switch floor and forwarded the *resolved* value through
1789/// `CODEWHALE_TELEMETRY`, which `EnvRuntimeOverrides::load()` picks up — and
1790/// re-reading `CODEWHALE_TELEMETRY` inside the telemetry crate would fork
1791/// `parse_bool`, the `DEEPSEEK_TELEMETRY` alias, and the floor into a second
1792/// source of truth.
1793fn arm_telemetry_with_setup(
1794    config_path: Option<PathBuf>,
1795    surface: codewhale_telemetry::Surface,
1796    source: codewhale_telemetry::SessionSource,
1797    setup_override: Option<&codewhale_config::SetupState>,
1798) {
1799    let Ok(store) = codewhale_config::ConfigStore::load(config_path) else {
1800        return;
1801    };
1802    let resolved = store
1803        .config
1804        .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default());
1805    let setup = if let Some(setup) = setup_override {
1806        setup.clone()
1807    } else {
1808        let Some(setup) = codewhale_telemetry::load_setup_state_for_decision() else {
1809            // An existing unreadable privacy record may contain a decline.
1810            // Failing closed is safer than replacing it with default-on.
1811            return;
1812        };
1813        setup
1814    };
1815    let codewhale_telemetry::TelemetryDecision::Enabled(consent) =
1816        codewhale_telemetry::decide(&resolved, &setup, surface)
1817    else {
1818        return;
1819    };
1820    codewhale_telemetry::init(consent.with_config_path(Some(store.path().to_path_buf())));
1821    let _ = TELEMETRY_SESSION_START.set(std::time::Instant::now());
1822    codewhale_telemetry::record(codewhale_telemetry::Event::SessionStart { source });
1823}
1824
1825fn arm_telemetry(cli: &Cli, command: Option<&Commands>) {
1826    if telemetry_command_is_read_only(command) {
1827        return;
1828    }
1829    arm_telemetry_with_setup(
1830        cli.config.clone(),
1831        telemetry_surface(command),
1832        telemetry_session_source(command),
1833        None,
1834    );
1835}
1836
1837/// Apply the choice made in the native TUI disclosure.
1838///
1839/// The in-memory setup state is authoritative for this process. In particular,
1840/// a Disable choice reaches `decide` as an opt-out even when neither durable
1841/// write landed, so the current launch cannot arm and any existing buffer is
1842/// wiped whenever the telemetry home remains reachable.
1843pub(crate) fn apply_tui_telemetry_decision(
1844    pending: &crate::telemetry_notice::PendingTelemetryNotice,
1845    setup: &codewhale_config::SetupState,
1846) {
1847    arm_telemetry_with_setup(
1848        pending.config_path.clone(),
1849        codewhale_telemetry::Surface::Tui,
1850        pending.session_source,
1851        Some(setup),
1852    );
1853}
1854
1855/// Close the armed session and flush, bounded.
1856async fn finish_telemetry(outcome: &Result<()>) {
1857    if !codewhale_telemetry::is_armed() {
1858        return;
1859    }
1860    // Only escalate: the panic hook and the signal path have already spoken if
1861    // they ran, and a stated class must not be overwritten by an inferred one.
1862    if outcome.is_err()
1863        && codewhale_telemetry::exit_class() == codewhale_telemetry::ExitClass::Clean
1864    {
1865        codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
1866    }
1867    codewhale_telemetry::record(telemetry_session_end());
1868    // `shutdown_blocking` parks a thread waiting on the writer, so it goes to
1869    // the blocking pool, and it is bounded there. The persistence actor's
1870    // unbounded `let _ = task.await` next door is not a pattern to copy here: a
1871    // hung TLS handshake would hold the process open past the last frame.
1872    let _ = tokio::time::timeout(
1873        codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT,
1874        tokio::task::spawn_blocking(|| {
1875            codewhale_telemetry::shutdown_blocking(codewhale_telemetry::SHUTDOWN_FLUSH_TIMEOUT)
1876        }),
1877    )
1878    .await;
1879}
1880
1881async fn run_async_main_inner(
1882    cli: Cli,
1883    command: Option<Commands>,
1884    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1885    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1886) -> Result<()> {
1887    // Install signal handlers that restore the terminal before the process
1888    // exits. Without this, Ctrl+C delivered while raw mode / kitty keyboard
1889    // enhancement / alt-screen are active (or in the brief windows around
1890    // startup and teardown where they're being toggled) leaves the user's shell
1891    // receiving raw CSI sequences like `^[[>5u` until they run `reset` (#1583).
1892    //
1893    // Once the TUI's raw mode is engaged the terminal driver delivers Ctrl+C as
1894    // the byte 0x03 rather than SIGINT, so the in-TUI key handler — not this
1895    // handler — is what processes user interrupts during normal operation. This
1896    // handler exists for the gaps: pre-TUI subcommands (--version, doctor,
1897    // login, …), the moments around enable_raw_mode / disable_raw_mode, the
1898    // external-editor suspend path, and SIGTERM / SIGHUP from the OS.
1899    //
1900    // It goes up before arming and before the notice: arming is the first
1901    // externally observable thing this process does (it creates the telemetry
1902    // buffer), and the notice is the first thing that can sit waiting on a
1903    // human. A Ctrl-C in either window must still restore the terminal and exit
1904    // 130 rather than kill the process outright. Recording a `session_end` from
1905    // the signal path is a no-op until `arm_telemetry` runs, so installing
1906    // ahead of it collects nothing.
1907    spawn_signal_cleanup_task();
1908
1909    // A due interactive disclosure belongs to the first native TUI frame. In
1910    // that one case arming is deferred until its decision event; every other
1911    // surface keeps the ordinary pre-dispatch predicate. This is what lets an
1912    // immediate Disable choice stop this very session without printing or
1913    // blocking on a shell questionnaire first.
1914    let surface = telemetry_surface(command.as_ref());
1915    let telemetry_notice_plan = if surface == codewhale_telemetry::Surface::Tui {
1916        crate::telemetry_notice::plan_if_due(
1917            cli.config.clone(),
1918            telemetry_session_source(command.as_ref()),
1919        )
1920    } else {
1921        crate::telemetry_notice::TelemetryNoticePlan::NotDue
1922    };
1923    let should_arm_before_dispatch = surface != codewhale_telemetry::Surface::Tui
1924        || telemetry_notice_plan.should_arm_before_tui();
1925    let pending_telemetry_notice = telemetry_notice_plan.into_pending();
1926    if should_arm_before_dispatch {
1927        arm_telemetry(&cli, command.as_ref());
1928    }
1929    let outcome = run_async_main_dispatch(
1930        cli,
1931        command,
1932        plugin_discovery,
1933        plugin_registry,
1934        pending_telemetry_notice,
1935    )
1936    .await;
1937    finish_telemetry(&outcome).await;
1938    outcome
1939}
1940
1941async fn run_async_main_dispatch(
1942    cli: Cli,
1943    command: Option<Commands>,
1944    plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1945    plugin_registry: Arc<crate::plugins::PluginRegistry>,
1946    mut pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
1947) -> Result<()> {
1948    logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
1949
1950    // Install any user prompt overrides from the config directory before an
1951    // engine can compose a system prompt. The override cells are
1952    // first-call-wins; doing this once here keeps every downstream turn
1953    // consistent. Missing files are a no-op (bundled defaults). See #3638.
1954    crate::prompts::load_prompt_overrides_from_config_home();
1955
1956    // Plugins own one read-only discovery snapshot per process. Initialize it
1957    // before the subcommand match so plain launch, resume, fork, exec, serve,
1958    // and every other runtime surface feed Skills and MCP from the same trust
1959    // decision (#3916, #4399). Discovery never enables, trusts, executes, or
1960    // persists a bundle.
1961
1962    // Handle subcommands first
1963    if let Some(command) = command {
1964        return match command {
1965            Commands::Doctor(args) => {
1966                let config = match load_doctor_config_from_cli(&cli, &args) {
1967                    Ok(config) => config,
1968                    Err(error) if args.json => return run_doctor_json_config_error(&error),
1969                    Err(_) => {
1970                        bail!(
1971                            "doctor configuration validation failed; details omitted because configuration errors may contain credential material"
1972                        )
1973                    }
1974                };
1975                let workspace = resolve_workspace(&cli);
1976                if args.context_json {
1977                    run_doctor_context_json(&config, &workspace)
1978                } else if args.json {
1979                    run_doctor_json(
1980                        &config,
1981                        &workspace,
1982                        cli.config.as_deref(),
1983                        plugin_registry.as_ref(),
1984                    )
1985                } else {
1986                    let probes = crate::doctor::DoctorProbeRequest {
1987                        check_updates: args.check_updates,
1988                        probe_api: args.probe_api,
1989                        probe_local: args.probe_local,
1990                        probe_mcp: args.probe_mcp,
1991                    };
1992                    run_doctor(
1993                        &config,
1994                        &workspace,
1995                        cli.config.as_deref(),
1996                        probes,
1997                        plugin_registry.as_ref(),
1998                    )
1999                    .await;
2000                    Ok(())
2001                }
2002            }
2003            Commands::SessionDiagnostics(args) => run_session_diagnostics(args),
2004            Commands::Setup(args) => {
2005                let config = load_config_from_cli(&cli)?;
2006                let workspace = resolve_workspace(&cli);
2007                run_setup(&config, &workspace, args, plugin_registry.as_ref())
2008            }
2009            Commands::RemoteSetup(args) => remote_setup::run_remote_setup(args),
2010            Commands::Completions { shell } => {
2011                generate_completions(shell);
2012                Ok(())
2013            }
2014            Commands::Sessions { limit, search } => list_sessions(limit, search),
2015            Commands::Init => init_project(),
2016            Commands::Login { api_key } => run_login(api_key),
2017            Commands::Logout => run_logout(),
2018            Commands::Auth(args) => match args.command {
2019                TuiAuthCommand::XaiDevice => run_xai_device_auth(cli.config.as_deref()).await,
2020            },
2021            Commands::Models(args) => {
2022                let config = load_config_from_cli(&cli)?;
2023                run_models(&config, args).await
2024            }
2025            Commands::Speech(args) => {
2026                let config = load_config_from_cli(&cli)?;
2027                run_speech(&config, args).await
2028            }
2029            Commands::Exec(args) => {
2030                let config = load_config_from_cli(&cli)?;
2031                let workspace = cli.workspace.clone().unwrap_or_else(|| {
2032                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2033                });
2034                let mut config = config.clone();
2035                // #4641: `--no-project-config` skips the workspace-specific
2036                // `[workspace]`/`[projects]` user-config overlay so a headless
2037                // launch (e.g. a future Verifiers harness) sees a reproducible
2038                // config surface that depends only on the explicit `--config`.
2039                if !cli.no_project_config {
2040                    merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
2041                }
2042                if let Some(sandbox) = args.sandbox.as_deref() {
2043                    let _ = parse_sandbox_policy(sandbox, true, Vec::new(), false, false)?;
2044                    config.sandbox_mode = Some(sandbox.to_ascii_lowercase());
2045                }
2046                // Honour CODEWHALE_BASE_URL / DEEPSEEK_BASE_URL forwarded by
2047                // the CLI dispatcher from --base-url.
2048                if let Ok(env_url) = std::env::var("CODEWHALE_BASE_URL")
2049                    .or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
2050                {
2051                    let trimmed = env_url.trim();
2052                    if !trimmed.is_empty() {
2053                        config.base_url = Some(trimmed.to_string());
2054                    }
2055                }
2056                // Honour `--provider` (#4093): a Fleet worker whose profile pins
2057                // a provider launches on that provider even when the parent
2058                // session is on another one. This sets ONLY the non-secret
2059                // provider identity (`config.provider`); credentials/base URL
2060                // still resolve from the worker's own env/config, and for a
2061                // non-DeepSeek provider the legacy root `base_url` above is
2062                // ignored by `deepseek_base_url()`. Must precede model
2063                // resolution so an `auto`/default model resolves to the
2064                // overridden provider's default.
2065                let explicit_provider = args
2066                    .provider
2067                    .as_deref()
2068                    .map(str::trim)
2069                    .filter(|provider| !provider.is_empty());
2070                if let Some(provider_arg) = explicit_provider {
2071                    apply_exec_provider_override(&mut config, provider_arg)?;
2072                }
2073                if let Some(reasoning_arg) = args
2074                    .reasoning_effort
2075                    .as_deref()
2076                    .map(str::trim)
2077                    .filter(|value| !value.is_empty())
2078                {
2079                    config.reasoning_effort = normalize_cli_reasoning_effort(reasoning_arg)?;
2080                    config.reasoning_effort_inferred_from_legacy_alias = false;
2081                }
2082                let prompt = join_prompt_parts(&args.prompt);
2083                let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
2084                validate_exec_tool_authority_resume(
2085                    args.tool_authority_json.as_deref(),
2086                    resume_session_id.is_some(),
2087                )?;
2088                let resume_session = resume_session_id
2089                    .as_deref()
2090                    .map(load_exec_resume_session)
2091                    .transpose()?;
2092                let explicit_model = args
2093                    .model
2094                    .as_deref()
2095                    .map(str::trim)
2096                    .filter(|model| !model.is_empty());
2097                let model = if let Some(saved) = resume_session.as_ref() {
2098                    resolve_exec_resume_route(
2099                        &mut config,
2100                        saved,
2101                        explicit_provider.is_some(),
2102                        explicit_model,
2103                    )?
2104                } else {
2105                    resolve_exec_model(&config, explicit_model)
2106                };
2107                let force_configured_route = should_force_configured_exec_route(
2108                    resume_session.is_some(),
2109                    explicit_provider,
2110                    explicit_model,
2111                );
2112                // The `deepseek` launcher forwards `--yolo` to this binary via
2113                // the DEEPSEEK_YOLO env var (which the config loader folds into
2114                // `config.yolo`), not as a CLI flag. Honour either source.
2115                let yolo = cli.yolo || config.yolo.unwrap_or(false);
2116                let env_tool_surface = exec_tool_surface_from_env();
2117                let needs_engine = args.auto
2118                    || yolo
2119                    || resume_session_id.is_some()
2120                    || args.output_format == ExecOutputFormat::StreamJson
2121                    || args.max_turns.is_some()
2122                    || args.allowed_tools.is_some()
2123                    || args.disallowed_tools.is_some()
2124                    || args.append_system_prompt.is_some()
2125                    || args.tool_authority_json.is_some()
2126                    || args.sandbox.is_some()
2127                    || args.allow_sandbox_elevation
2128                    || env_tool_surface.is_some();
2129                if needs_engine {
2130                    let provider = config.api_provider();
2131                    let max_subagents = cli.max_subagents.map_or_else(
2132                        || config.max_subagents_for_provider(provider),
2133                        |value| value.clamp(1, MAX_SUBAGENTS),
2134                    );
2135                    let auto_mode = args.auto || yolo;
2136                    let max_turns = exec_max_steps(args.max_turns);
2137                    let allowed_tools =
2138                        resolve_exec_allowed_tools(args.allowed_tools.as_deref(), env_tool_surface);
2139                    let disallowed_tools = args
2140                        .disallowed_tools
2141                        .as_deref()
2142                        .map(normalize_exec_tool_names);
2143                    run_exec_agent(
2144                        &config,
2145                        &model,
2146                        &prompt,
2147                        workspace,
2148                        max_subagents,
2149                        auto_mode,
2150                        args.allow_sandbox_elevation,
2151                        args.sandbox.as_deref(),
2152                        auto_mode,
2153                        args.json,
2154                        resume_session,
2155                        force_configured_route,
2156                        args.output_format,
2157                        max_turns,
2158                        allowed_tools,
2159                        disallowed_tools,
2160                        args.append_system_prompt.clone(),
2161                        args.tool_authority_json.clone(),
2162                        std::sync::Arc::clone(&plugin_registry),
2163                    )
2164                    .await
2165                } else if args.json {
2166                    run_one_shot_json(&config, &model, &prompt, force_configured_route).await
2167                } else {
2168                    run_one_shot(&config, &model, &prompt, force_configured_route).await
2169                }
2170            }
2171            Commands::Fleet(args) => {
2172                let config = load_config_from_cli(&cli)?;
2173                let workspace = resolve_workspace(&cli);
2174                run_fleet_command(&workspace, &config, args).await
2175            }
2176            Commands::WorkflowTool(args) => {
2177                run_workflow_tool_command(&cli, args, std::sync::Arc::clone(&plugin_registry)).await
2178            }
2179            Commands::Review(args) => {
2180                let config = load_config_from_cli(&cli)?;
2181                run_review(&config, args).await
2182            }
2183            Commands::Pr {
2184                number,
2185                repo,
2186                checkout,
2187            } => {
2188                let config = load_config_from_cli(&cli)?;
2189                run_pr(
2190                    &cli,
2191                    &config,
2192                    number,
2193                    repo.as_deref(),
2194                    checkout,
2195                    pending_telemetry_notice.take(),
2196                    Arc::clone(&plugin_registry),
2197                )
2198                .await
2199            }
2200            Commands::Apply(args) => run_apply(args),
2201            Commands::Eval(args) => run_eval(args),
2202            Commands::Scorecard(args) => run_scorecard(args),
2203            Commands::Mcp { command } => {
2204                let config = load_config_from_cli(&cli)?;
2205                let workspace = resolve_workspace(&cli);
2206                run_mcp_command(&config, &workspace, command, plugin_registry.as_ref()).await
2207            }
2208            Commands::Features(command) => {
2209                let config = load_config_from_cli(&cli)?;
2210                run_features_command(&config, command)
2211            }
2212            Commands::Integrations { command } => {
2213                // Identity derivation is structural: credential-bearing
2214                // environment values never enter this path.
2215                let config = load_structural_config_from_cli(&cli)?;
2216                let workspace = resolve_workspace(&cli);
2217                integrations::cli::run(&config, &workspace, command)
2218            }
2219            Commands::Sandbox(args) => run_sandbox_command(args),
2220            Commands::Serve(args) => {
2221                let workspace = cli.workspace.clone().unwrap_or_else(|| {
2222                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2223                });
2224                let http_selected = validate_serve_mode_selection(
2225                    args.mcp,
2226                    args.http,
2227                    args.mobile,
2228                    args.web,
2229                    args.acp,
2230                )?;
2231                if args.mcp {
2232                    tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace))
2233                } else if http_selected {
2234                    let (config, config_profile) =
2235                        load_config_from_cli_with_effective_profile(&cli)?;
2236                    let cors_origins = resolve_cors_origins(&config, &args.cors_origin);
2237                    let bind_host = resolve_serve_bind_host(args.mobile, args.host);
2238                    if args.web && bind_host.host != "127.0.0.1" {
2239                        bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2240                    }
2241                    if bind_host.mobile_rebound_to_lan {
2242                        println!(
2243                            "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."
2244                        );
2245                    }
2246                    runtime_api::run_http_server(
2247                        config,
2248                        workspace,
2249                        std::sync::Arc::clone(&plugin_discovery),
2250                        runtime_api::RuntimeApiOptions {
2251                            host: bind_host.host,
2252                            port: args.port,
2253                            workers: args.workers.clamp(1, 8),
2254                            cors_origins,
2255                            auth_token: args.auth_token,
2256                            insecure_no_auth: args.insecure_no_auth,
2257                            mobile: args.mobile,
2258                            web: args.web,
2259                            show_qr: args.qr,
2260                            config_path: cli.config.clone(),
2261                            config_profile,
2262                        },
2263                    )
2264                    .await
2265                } else if args.acp {
2266                    let config = load_config_from_cli(&cli)?;
2267                    let model = config.default_model();
2268                    acp_server::run_acp_server(config, model, workspace).await
2269                } else {
2270                    unreachable!("server mode count checked above")
2271                }
2272            }
2273            Commands::Resume { session_id, last } => {
2274                let config = load_config_from_cli(&cli)?;
2275                let workspace = resolve_workspace(&cli);
2276                let resume_id = resolve_session_id(session_id, last, &workspace)?;
2277                run_interactive(
2278                    &cli,
2279                    &config,
2280                    Some(resume_id),
2281                    None,
2282                    pending_telemetry_notice.take(),
2283                    std::sync::Arc::clone(&plugin_registry),
2284                )
2285                .await
2286            }
2287            Commands::Fork { session_id, last } => {
2288                let config = load_config_from_cli(&cli)?;
2289                let workspace = resolve_workspace(&cli);
2290                let new_session_id = fork_session(&config, session_id, last, &workspace)?;
2291                run_interactive(
2292                    &cli,
2293                    &config,
2294                    Some(new_session_id),
2295                    None,
2296                    pending_telemetry_notice.take(),
2297                    std::sync::Arc::clone(&plugin_registry),
2298                )
2299                .await
2300            }
2301        };
2302    }
2303
2304    // Top-level prompt mode: submit the initial prompt, then keep the TUI alive
2305    // for follow-up messages. Use `codewhale exec` for explicit non-interactive
2306    // one-shot behavior (#2370).
2307    let config = load_config_from_cli(&cli)?;
2308    if let Some(initial_input) = top_level_prompt_initial_input(&cli.prompt) {
2309        return run_interactive(
2310            &cli,
2311            &config,
2312            None,
2313            Some(initial_input),
2314            pending_telemetry_notice.take(),
2315            std::sync::Arc::clone(&plugin_registry),
2316        )
2317        .await;
2318    }
2319
2320    // Handle session resume. Plain `codewhale` starts fresh: interrupted
2321    // snapshots are preserved for explicit resume, but never auto-attached.
2322    let mut startup_notice = None;
2323    let resume_session_id = if cli.continue_session {
2324        let workspace = resolve_workspace(&cli);
2325        recover_interrupted_checkpoint_for_resume(&workspace)
2326            .or_else(|| latest_session_id_for_workspace(&workspace).ok().flatten())
2327    } else if let Some(id) = cli.resume.clone() {
2328        Some(id)
2329    } else if !cli.fresh {
2330        let workspace = resolve_workspace(&cli);
2331        preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
2332        // Opt-in auto-resume (#2934). Off by default, so the historical
2333        // "plain `codewhale` starts fresh" behaviour is unchanged unless the
2334        // user asked for something else. The decision never resumes an
2335        // archived, unreadable, or foreign-workspace session; every fallback
2336        // carries a receipt rather than silently starting blank.
2337        let (session_id, notice) = resolve_auto_resume(&workspace);
2338        startup_notice = notice;
2339        session_id
2340    } else {
2341        None
2342    };
2343
2344    // Default: Interactive TUI
2345    // --yolo starts in YOLO mode (auto-approve; shell enabled)
2346    run_interactive_with_notice(
2347        &cli,
2348        &config,
2349        resume_session_id,
2350        None,
2351        startup_notice,
2352        pending_telemetry_notice.take(),
2353        plugin_registry,
2354    )
2355    .await
2356}
2357
2358/// Resolve the opt-in auto-resume setting into a session id plus a receipt.
2359///
2360/// Deliberately scoped to the plain interactive launch. `codewhale "do X"`
2361/// (top-level prompt) and `codewhale exec` are not covered: silently prefixing
2362/// a one-shot task with a prior conversation would change what is sent to the
2363/// model, which is not a layout preference the user opted into.
2364fn resolve_auto_resume(workspace: &Path) -> (Option<String>, Option<String>) {
2365    use crate::session_resume::{ResumeRequest, decide_auto_resume};
2366
2367    let enabled = crate::settings::Settings::load_persisted()
2368        .map(|settings| settings.session_auto_resume)
2369        .unwrap_or(false);
2370    if !enabled {
2371        return (None, None);
2372    }
2373    let Ok(manager) = SessionManager::default_location() else {
2374        return (None, None);
2375    };
2376    let decision = decide_auto_resume(true, &ResumeRequest::default(), workspace, &manager);
2377    (
2378        decision.session_id().map(str::to_string),
2379        decision.status_message(),
2380    )
2381}
2382
2383fn prepare_cli_startup(
2384    cli: Cli,
2385    initialize_plugins: impl FnOnce(),
2386    load_dotenv: impl FnOnce(),
2387) -> (Cli, Option<Commands>) {
2388    initialize_plugins();
2389    let command = cli.command.clone();
2390    let should_load_dotenv = match command.as_ref() {
2391        Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
2392        _ => true,
2393    };
2394    if should_load_dotenv {
2395        load_dotenv();
2396    }
2397    (cli, command)
2398}
2399
2400const MAX_WORKSPACE_DOTENV_BYTES: u64 = 1024 * 1024;
2401
2402#[derive(Debug, Default)]
2403struct WorkspaceDotenvReport {
2404    path: PathBuf,
2405    loaded: BTreeSet<String>,
2406    ignored: BTreeSet<String>,
2407}
2408
2409/// Load the narrow, data-plane subset of a workspace `.env` before Tokio.
2410///
2411/// Repository content is not product authority. In particular, a committed
2412/// `.env` must not be able to redirect `CODEWHALE_HOME`, config/profile files,
2413/// MCP servers, plugin trust, executable lookup, sandbox/approval posture, or
2414/// network destinations. Shell-exported values and config/CLI arguments remain
2415/// the explicit surfaces for those controls.
2416fn warn_on_workspace_dotenv_result() {
2417    match load_workspace_dotenv_credentials() {
2418        Ok(Some(report)) if !report.ignored.is_empty() => {
2419            eprintln!(
2420                "Codewhale ignored non-credential settings in {}: {}. Use config.toml, CLI flags, or the launching shell for control settings.",
2421                report.path.display(),
2422                display_env_key_set(&report.ignored)
2423            );
2424        }
2425        Ok(_) => {}
2426        Err(error) => {
2427            // The error intentionally contains no file contents or parsed
2428            // values. A malformed or unsafe workspace file fails closed while
2429            // shell/config credentials remain available.
2430            eprintln!("Codewhale did not load workspace .env: {error}");
2431        }
2432    }
2433}
2434
2435fn display_env_key_set(keys: &BTreeSet<String>) -> String {
2436    const MAX_DISPLAYED: usize = 12;
2437    let mut labels = keys
2438        .iter()
2439        .take(MAX_DISPLAYED)
2440        .map(|key| {
2441            if key
2442                .chars()
2443                .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
2444            {
2445                key.as_str()
2446            } else {
2447                "<invalid-name>"
2448            }
2449        })
2450        .collect::<Vec<_>>();
2451    if keys.len() > MAX_DISPLAYED {
2452        labels.push("...");
2453    }
2454    labels.join(", ")
2455}
2456
2457fn load_workspace_dotenv_credentials() -> Result<Option<WorkspaceDotenvReport>> {
2458    let Some(path) = find_workspace_dotenv()? else {
2459        return Ok(None);
2460    };
2461    load_workspace_dotenv_credentials_from_path(&path).map(Some)
2462}
2463
2464fn find_workspace_dotenv() -> Result<Option<PathBuf>> {
2465    let cwd = std::env::current_dir().context("could not resolve the current workspace")?;
2466    let boundary = cwd
2467        .ancestors()
2468        .find(|ancestor| std::fs::symlink_metadata(ancestor.join(".git")).is_ok())
2469        .unwrap_or(cwd.as_path());
2470
2471    for ancestor in cwd.ancestors() {
2472        let candidate = ancestor.join(".env");
2473        match std::fs::symlink_metadata(&candidate) {
2474            Ok(_) => return Ok(Some(candidate)),
2475            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
2476            Err(error) => {
2477                return Err(anyhow!(
2478                    "could not inspect {}: {error}",
2479                    candidate.display()
2480                ));
2481            }
2482        }
2483        if ancestor == boundary {
2484            break;
2485        }
2486    }
2487    Ok(None)
2488}
2489
2490fn load_workspace_dotenv_credentials_from_path(path: &Path) -> Result<WorkspaceDotenvReport> {
2491    let contents = read_stable_workspace_dotenv(path)?;
2492    let text = std::str::from_utf8(&contents)
2493        .map_err(|_| anyhow!("{} is not valid UTF-8", path.display()))?;
2494    if dotenv_has_variable_expansion(text) {
2495        bail!(
2496            "{} uses variable expansion; workspace .env values must be literal to prevent ambient-secret substitution",
2497            path.display()
2498        );
2499    }
2500
2501    let mut report = WorkspaceDotenvReport {
2502        path: path.to_path_buf(),
2503        ..WorkspaceDotenvReport::default()
2504    };
2505    let entries = dotenvy::from_read_iter(std::io::Cursor::new(contents))
2506        .collect::<std::result::Result<Vec<_>, _>>()
2507        .map_err(|_| anyhow!("{} could not be parsed safely", path.display()))?;
2508    for entry in entries {
2509        let (key, value) = entry;
2510        if !is_workspace_dotenv_credential_key(&key) {
2511            report.ignored.insert(key);
2512            continue;
2513        }
2514        if std::env::var_os(&key).is_some() {
2515            continue;
2516        }
2517
2518        // SAFETY: this loader runs synchronously in `main` before the runtime
2519        // owner or Tokio workers are spawned. No concurrent environment reader
2520        // exists inside Codewhale, and later startup code treats this process
2521        // environment as immutable.
2522        unsafe { std::env::set_var(&key, value) };
2523        report.loaded.insert(key);
2524    }
2525    Ok(report)
2526}
2527
2528fn is_workspace_dotenv_credential_key(key: &str) -> bool {
2529    codewhale_config::provider::providers_sorted_for_display()
2530        .into_iter()
2531        .any(|provider| provider.env_vars().contains(&key))
2532        || matches!(
2533            key,
2534            "DEEPSEEK_SEARCH_API_KEY"
2535                | "SOFYA_API_KEY"
2536                | "METASO_API_KEY"
2537                | "BAIDU_SEARCH_API_KEY"
2538                | "DEEPSEEK_SANDBOX_API_KEY"
2539        )
2540}
2541
2542fn dotenv_has_variable_expansion(contents: &str) -> bool {
2543    let mut escaped = false;
2544    let mut single_quoted = false;
2545    let mut double_quoted = false;
2546    let mut comment = false;
2547
2548    for ch in contents.chars() {
2549        if comment {
2550            // Reject expansion markers even in comments. This is deliberately
2551            // conservative, and ignoring other comment text prevents an
2552            // unmatched quote there from changing how the next line is read.
2553            if ch == '$' {
2554                return true;
2555            }
2556            if ch == '\n' {
2557                comment = false;
2558                escaped = false;
2559            }
2560            continue;
2561        }
2562        if single_quoted {
2563            if ch == '\'' {
2564                single_quoted = false;
2565            }
2566            continue;
2567        }
2568        if escaped {
2569            escaped = false;
2570            continue;
2571        }
2572        if ch == '\\' {
2573            escaped = true;
2574            continue;
2575        }
2576        if ch == '\'' && !double_quoted {
2577            single_quoted = true;
2578            continue;
2579        }
2580        if ch == '"' {
2581            double_quoted = !double_quoted;
2582            continue;
2583        }
2584        if ch == '#' && !double_quoted {
2585            comment = true;
2586            continue;
2587        }
2588        if ch == '$' {
2589            return true;
2590        }
2591    }
2592    false
2593}
2594
2595fn read_stable_workspace_dotenv(path: &Path) -> Result<Vec<u8>> {
2596    let mut file = open_workspace_dotenv_without_following_links(path)?;
2597    let metadata = file
2598        .metadata()
2599        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2600    if !metadata.is_file() {
2601        bail!("{} is not a regular file", path.display());
2602    }
2603    if workspace_dotenv_has_multiple_links(&file, &metadata)? {
2604        bail!(
2605            "{} has multiple filesystem links, not a unique workspace-owned file",
2606            path.display()
2607        );
2608    }
2609    if metadata.len() > MAX_WORKSPACE_DOTENV_BYTES {
2610        bail!(
2611            "{} exceeds the {} byte workspace .env limit",
2612            path.display(),
2613            MAX_WORKSPACE_DOTENV_BYTES
2614        );
2615    }
2616
2617    let mut contents = Vec::with_capacity(metadata.len() as usize);
2618    (&mut file)
2619        .take(MAX_WORKSPACE_DOTENV_BYTES + 1)
2620        .read_to_end(&mut contents)
2621        .map_err(|error| anyhow!("could not read {}: {error}", path.display()))?;
2622    if contents.len() as u64 > MAX_WORKSPACE_DOTENV_BYTES {
2623        bail!(
2624            "{} exceeds the {} byte workspace .env limit",
2625            path.display(),
2626            MAX_WORKSPACE_DOTENV_BYTES
2627        );
2628    }
2629    Ok(contents)
2630}
2631
2632#[cfg(unix)]
2633fn workspace_dotenv_has_multiple_links(
2634    _file: &std::fs::File,
2635    metadata: &std::fs::Metadata,
2636) -> Result<bool> {
2637    use std::os::unix::fs::MetadataExt;
2638
2639    Ok(metadata.nlink() > 1)
2640}
2641
2642#[cfg(windows)]
2643fn workspace_dotenv_has_multiple_links(
2644    file: &std::fs::File,
2645    _metadata: &std::fs::Metadata,
2646) -> Result<bool> {
2647    use std::os::windows::io::AsRawHandle;
2648    use windows::Win32::Foundation::HANDLE;
2649    use windows::Win32::Storage::FileSystem::{
2650        BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
2651    };
2652
2653    let mut information = BY_HANDLE_FILE_INFORMATION::default();
2654    // SAFETY: `file` owns a live kernel handle for the already-open `.env`;
2655    // `information` remains writable for the duration of this synchronous
2656    // call. No path lookup or re-open occurs here.
2657    unsafe {
2658        GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut information)
2659            .map_err(|error| anyhow!("could not inspect workspace .env link count: {error}"))?;
2660    }
2661    Ok(information.nNumberOfLinks > 1)
2662}
2663
2664#[cfg(not(any(unix, windows)))]
2665fn workspace_dotenv_has_multiple_links(
2666    _file: &std::fs::File,
2667    _metadata: &std::fs::Metadata,
2668) -> Result<bool> {
2669    Ok(false)
2670}
2671
2672#[cfg(unix)]
2673fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2674    use std::os::unix::fs::OpenOptionsExt;
2675
2676    std::fs::OpenOptions::new()
2677        .read(true)
2678        // `O_NONBLOCK` is inert for regular files but prevents a FIFO named
2679        // `.env` from hanging startup before the metadata check can reject it.
2680        .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
2681        .open(path)
2682        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2683}
2684
2685#[cfg(windows)]
2686fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2687    use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
2688
2689    const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
2690    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
2691    let file = std::fs::OpenOptions::new()
2692        .read(true)
2693        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
2694        .open(path)
2695        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))?;
2696    let metadata = file
2697        .metadata()
2698        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2699    if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
2700        bail!(
2701            "{} is a reparse point, not a workspace-owned file",
2702            path.display()
2703        );
2704    }
2705    Ok(file)
2706}
2707
2708#[cfg(not(any(unix, windows)))]
2709fn open_workspace_dotenv_without_following_links(path: &Path) -> Result<std::fs::File> {
2710    let metadata = std::fs::symlink_metadata(path)
2711        .map_err(|error| anyhow!("could not inspect {}: {error}", path.display()))?;
2712    if metadata.file_type().is_symlink() {
2713        bail!(
2714            "{} is a symbolic link, not a workspace-owned file",
2715            path.display()
2716        );
2717    }
2718    std::fs::File::open(path)
2719        .map_err(|error| anyhow!("could not securely open {}: {error}", path.display()))
2720}
2721
2722/// Generate shell completions for the given shell
2723fn generate_completions(shell: Shell) {
2724    let mut cmd = Cli::command();
2725    let name = cmd.get_name().to_string();
2726    generate(shell, &mut cmd, name, &mut io::stdout());
2727}
2728
2729/// Run the offline evaluation harness (no network/LLM calls).
2730fn run_eval(args: EvalArgs) -> Result<()> {
2731    let fail_step = match args.fail_step.as_deref() {
2732        Some(value) => ScenarioStepKind::parse(value)
2733            .map(Some)
2734            .ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
2735        None => None,
2736    };
2737
2738    let config = EvalHarnessConfig {
2739        fail_step,
2740        shell_command: args.shell_command,
2741        shell_expect_token: args.shell_expect_token,
2742        max_output_chars: args.max_output_chars,
2743        record_dir: args.record.clone(),
2744        ..EvalHarnessConfig::default()
2745    };
2746
2747    let harness = EvalHarness::new(config);
2748    let run = harness.run().context("evaluation harness failed")?;
2749    let report = run.to_report();
2750
2751    if args.json {
2752        let json = serde_json::to_string_pretty(&report)?;
2753        println!("{json}");
2754    } else {
2755        println!("Offline Eval Harness");
2756        println!("scenario: {}", report.scenario_name);
2757        println!("workspace: {}", report.workspace_root.display());
2758        println!("success: {}", report.metrics.success);
2759        println!("steps: {}", report.metrics.steps);
2760        println!("tool_errors: {}", report.metrics.tool_errors);
2761        println!("duration_ms: {}", report.metrics.duration.as_millis());
2762
2763        if !report.metrics.per_tool.is_empty() {
2764            println!("per_tool:");
2765            for (kind, stats) in &report.metrics.per_tool {
2766                println!(
2767                    "  {} invocations={} errors={} duration_ms={}",
2768                    kind.tool_name(),
2769                    stats.invocations,
2770                    stats.errors,
2771                    stats.total_duration.as_millis()
2772                );
2773            }
2774        }
2775
2776        let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
2777        if !failed_steps.is_empty() {
2778            println!("failed_steps:");
2779            for step in failed_steps {
2780                let error = step.error.as_deref().unwrap_or("unknown error");
2781                println!(
2782                    "  {} tool={} error={}",
2783                    step.kind.tool_name(),
2784                    step.tool_name,
2785                    error
2786                );
2787            }
2788        }
2789    }
2790
2791    if report.metrics.success {
2792        Ok(())
2793    } else {
2794        bail!("offline evaluation harness reported failure")
2795    }
2796}
2797
2798/// Score a run's token/cache/cost from recorded turns and (optionally) flag
2799/// regressions against a committed baseline. Offline: reads recorded usage from
2800/// a JSON file, reuses the pricing layer, never calls a model. Exits non-zero
2801/// when a baseline is supplied and a metric regresses past the threshold, so it
2802/// can be wired as a release gate (#3388).
2803fn run_scorecard(args: ScorecardArgs) -> Result<()> {
2804    use crate::scorecard::{RecordedTurn, Scorecard, ScorecardMetrics};
2805
2806    let raw = std::fs::read_to_string(&args.input)
2807        .with_context(|| format!("failed to read scorecard input {}", args.input.display()))?;
2808    let recorded: Vec<RecordedTurn> = serde_json::from_str(&raw)
2809        .with_context(|| format!("failed to parse scorecard input {}", args.input.display()))?;
2810
2811    let card = Scorecard::from_recorded_turns(&recorded);
2812
2813    let regressions = match &args.baseline {
2814        Some(path) => {
2815            let baseline_raw = std::fs::read_to_string(path)
2816                .with_context(|| format!("failed to read baseline {}", path.display()))?;
2817            let baseline: ScorecardMetrics = serde_json::from_str(&baseline_raw)
2818                .with_context(|| format!("failed to parse baseline {}", path.display()))?;
2819            card.metrics.regressions_against(&baseline, args.threshold)
2820        }
2821        None => Vec::new(),
2822    };
2823
2824    if args.json {
2825        let out = serde_json::json!({
2826            "per_turn": card.per_turn,
2827            "metrics": card.metrics,
2828            "regressions": regressions,
2829        });
2830        println!("{}", serde_json::to_string_pretty(&out)?);
2831    } else {
2832        print!("{}", card.to_summary());
2833        for r in &regressions {
2834            println!(
2835                "REGRESSION {}: baseline {:.4} -> current {:.4} (+{:.1}%)",
2836                r.metric, r.baseline, r.current, r.pct_increase
2837            );
2838        }
2839    }
2840
2841    if regressions.is_empty() {
2842        Ok(())
2843    } else {
2844        bail!(
2845            "{} metric(s) regressed past the {:.1}% threshold",
2846            regressions.len(),
2847            args.threshold
2848        )
2849    }
2850}
2851
2852async fn run_fleet_command(workspace: &Path, config: &Config, args: FleetArgs) -> Result<()> {
2853    use crate::fleet::alerts::{
2854        FleetAlertAdapterConfig, FleetAlertConfig, FleetAlertDispatcher, FleetAlertEvent,
2855        FleetEnvSecretResolver,
2856    };
2857    use crate::fleet::control as fleet_control;
2858    use crate::fleet::executor::FleetExecutor;
2859    use crate::fleet::manager::{FleetManager, FleetStatusSnapshot, FleetWorkerInspection};
2860    use codewhale_lane::{ControlOperation, ControlSurface};
2861    use codewhale_protocol::fleet::{FleetAlertEventClass, FleetArtifactKind, FleetRunId};
2862
2863    // Every label and every row below comes from the shared Fleet control
2864    // surface, so `codewhale fleet …` and `/fleet …` cannot drift in how they
2865    // describe the same durable ledger (#1888, #4022).
2866    fn print_status(status: &FleetStatusSnapshot) {
2867        println!("{}", fleet_control::render_fleet_status_snapshot(status));
2868    }
2869
2870    fn print_inspection(inspection: &FleetWorkerInspection) {
2871        println!("{}", fleet_control::render_inspection(inspection));
2872    }
2873
2874    fn print_artifacts(inspection: &FleetWorkerInspection) {
2875        println!("{}", fleet_control::render_artifacts(inspection));
2876    }
2877
2878    /// Print one shared control receipt on the CLI surface.
2879    fn emit_fleet_receipt(receipt: &codewhale_lane::ControlReceipt) -> Result<()> {
2880        if receipt.is_error() {
2881            eprintln!("{}", receipt.render());
2882            let detail = receipt
2883                .failure
2884                .as_ref()
2885                .map(ToString::to_string)
2886                .unwrap_or_else(|| receipt.outcome.as_str().to_string());
2887            bail!("{}: {detail}", receipt.operation_id);
2888        }
2889        println!("{}", receipt.render());
2890        Ok(())
2891    }
2892
2893    fn print_logs(workspace: &Path, inspection: &FleetWorkerInspection) -> Result<()> {
2894        let mut printed = false;
2895        for artifact in inspection
2896            .artifacts
2897            .iter()
2898            .filter(|artifact| matches!(artifact.kind, FleetArtifactKind::Log))
2899        {
2900            let path = workspace.join(&artifact.path);
2901            println!("== {} ==", artifact.path.display());
2902            let contents = std::fs::read_to_string(&path)
2903                .with_context(|| format!("reading fleet log {}", path.display()))?;
2904            let preview: String = contents.chars().take(16 * 1024).collect();
2905            // Worker logs can contain captured terminal bytes (a child TUI's
2906            // mouse-tracking handshake, SGR, OSC). Printing them raw would
2907            // re-arm mouse reporting in the caller's shell and leave it
2908            // executing escape fragments after this command exits.
2909            let mut safe_preview = String::with_capacity(preview.len());
2910            crate::tui::osc8::strip_ansi_into(&preview, &mut safe_preview);
2911            print!("{safe_preview}");
2912            if contents.chars().count() > preview.chars().count() {
2913                println!("\n[truncated]");
2914            } else if !preview.ends_with('\n') {
2915                println!();
2916            }
2917            printed = true;
2918        }
2919        if !printed {
2920            println!("logs: none");
2921        }
2922        Ok(())
2923    }
2924
2925    fn alert_event_class(arg: FleetAlertEventArg) -> FleetAlertEventClass {
2926        match arg {
2927            FleetAlertEventArg::Stale => FleetAlertEventClass::Stale,
2928            FleetAlertEventArg::RestartExhausted => FleetAlertEventClass::RestartExhausted,
2929            FleetAlertEventArg::NeedsHuman => FleetAlertEventClass::NeedsHuman,
2930            FleetAlertEventArg::BudgetExceeded => FleetAlertEventClass::BudgetExceeded,
2931            FleetAlertEventArg::VerifierFailed => FleetAlertEventClass::VerifierFailed,
2932            FleetAlertEventArg::RunCompleted => FleetAlertEventClass::RunCompleted,
2933        }
2934    }
2935
2936    fn alert_status(class: FleetAlertEventClass, override_status: Option<String>) -> String {
2937        if let Some(status) = override_status {
2938            return status;
2939        }
2940        match class {
2941            FleetAlertEventClass::Stale => "stale",
2942            FleetAlertEventClass::RestartExhausted => "failed",
2943            FleetAlertEventClass::NeedsHuman => "needs_human",
2944            FleetAlertEventClass::BudgetExceeded => "budget_exceeded",
2945            FleetAlertEventClass::VerifierFailed => "verifier_failed",
2946            FleetAlertEventClass::RunCompleted => "completed",
2947        }
2948        .to_string()
2949    }
2950
2951    fn alert_adapter(args: &FleetAlertDryRunArgs) -> FleetAlertAdapterConfig {
2952        match args.adapter {
2953            FleetAlertAdapterArg::Slack => FleetAlertAdapterConfig::Slack {
2954                webhook_env: args.slack_webhook_env.clone(),
2955                channel: None,
2956            },
2957            FleetAlertAdapterArg::Webhook => FleetAlertAdapterConfig::Webhook {
2958                url_env: args.webhook_url_env.clone(),
2959                secret_env: args.webhook_secret_env.clone(),
2960            },
2961            FleetAlertAdapterArg::PagerDuty => FleetAlertAdapterConfig::PagerDuty {
2962                routing_key_env: args.pagerduty_routing_key_env.clone(),
2963                severity: args.pagerduty_severity.clone(),
2964            },
2965        }
2966    }
2967
2968    let fleet_config = config.fleet_config();
2969    let provider = config.api_provider();
2970    let max_subagents = config.max_subagents_for_provider(provider);
2971    let coordination_manager = crate::tools::subagent::new_shared_subagent_manager_with_timeout(
2972        workspace.to_path_buf(),
2973        max_subagents,
2974        config
2975            .max_admitted_subagents_for_provider(provider)
2976            .max(max_subagents),
2977        Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
2978        config.launch_concurrency_for_provider(provider),
2979        config.subagent_token_budget_for_provider(provider),
2980    );
2981    // Probe the durable ledger *before* opening the manager: FleetManager::open
2982    // creates `.codewhale/fleet.jsonl` as a side effect, so a later probe would
2983    // always find a ledger and the CLI would report availability differently
2984    // from the slash surface for the same workspace (#4022).
2985    let fleet_context = fleet_control::fleet_control_context(workspace);
2986    // Probing is not enough on its own: `FleetManager::open` *creates* the
2987    // ledger, and it used to run for every subcommand before this match. That
2988    // made `codewhale fleet status` in a ledgerless workspace print
2989    // "no_fleet_ledger" while simultaneously creating the file it said was
2990    // missing — and the next invocation then reported an empty ledger as if a
2991    // Fleet had existed all along. Refuse the control verbs here, before the
2992    // manager exists, so the CLI and `/fleet` agree and neither surface
2993    // conjures the store it is reporting on (#4022).
2994    if let Some(operation) = match &args.command {
2995        FleetCommand::List => Some(ControlOperation::FleetList),
2996        FleetCommand::Status => Some(ControlOperation::FleetStatus),
2997        FleetCommand::Interrupt { .. } => Some(ControlOperation::FleetInterrupt),
2998        FleetCommand::Resume { .. } => Some(ControlOperation::FleetResume),
2999        _ => None,
3000    } {
3001        let descriptor = operation.descriptor();
3002        let availability = descriptor.availability(ControlSurface::Cli, fleet_context);
3003        if !availability.is_available() {
3004            return emit_fleet_receipt(&codewhale_lane::ControlReceipt::unavailable(
3005                descriptor,
3006                ControlSurface::Cli,
3007                availability,
3008            ));
3009        }
3010    }
3011
3012    // The configured route is the operator: fleet workers without a
3013    // task/profile model pin inherit the session's active model.
3014    let manager = FleetManager::open(workspace)?
3015        .with_exec_config(fleet_config.exec.clone())
3016        .with_fleet_config(fleet_config)
3017        .with_sub_agent_manager(coordination_manager)
3018        .with_session_model(config.default_model())
3019        .with_route_config(config.clone());
3020    match args.command {
3021        FleetCommand::Init => {
3022            println!("fleet ledger: {}", manager.ledger_path().display());
3023            Ok(())
3024        }
3025        FleetCommand::Run(args) => {
3026            let max_workers = args.max_workers.clamp(1, 128);
3027            let manager =
3028                manager.with_stale_after(Duration::from_secs(args.stale_after_seconds.max(1)));
3029            let report = manager.create_run_from_task_spec_path(&args.task_spec, max_workers)?;
3030            println!(
3031                "fleet run: {} tasks={} leased={} queued={}",
3032                report.run_id.0, report.task_count, report.leased, report.queued
3033            );
3034            for warning in &report.warnings {
3035                println!("warning: {warning}");
3036            }
3037            println!("workers:");
3038            for worker_id in &report.worker_ids {
3039                println!("  {worker_id}");
3040            }
3041            if args.once {
3042                print_status(&manager.run_status(&report.run_id)?);
3043                return Ok(());
3044            }
3045            println!(
3046                "manager loop running; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal."
3047            );
3048            let mut executor = FleetExecutor::new(workspace);
3049            let codewhale_binary = fleet::executor::configured_codewhale_binary();
3050            let status = manager
3051                .run_to_completion(
3052                    &report.run_id,
3053                    max_workers,
3054                    &mut executor,
3055                    &codewhale_binary,
3056                    None,
3057                    Duration::from_secs(2),
3058                )
3059                .await?;
3060            print_status(&status);
3061            Ok(())
3062        }
3063        FleetCommand::List => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3064            ControlSurface::Cli,
3065            workspace,
3066            fleet_context,
3067            &manager,
3068            ControlOperation::FleetList,
3069            None,
3070        )),
3071        FleetCommand::Status => emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3072            ControlSurface::Cli,
3073            workspace,
3074            fleet_context,
3075            &manager,
3076            ControlOperation::FleetStatus,
3077            None,
3078        )),
3079        FleetCommand::Inspect { worker_id } => {
3080            print_inspection(&manager.inspect_worker(&worker_id)?);
3081            Ok(())
3082        }
3083        FleetCommand::Logs { worker_id } => {
3084            let inspection = manager.inspect_worker(&worker_id)?;
3085            print_logs(workspace, &inspection)
3086        }
3087        FleetCommand::Artifacts { worker_id } => {
3088            let inspection = manager.inspect_worker(&worker_id)?;
3089            print_artifacts(&inspection);
3090            Ok(())
3091        }
3092        FleetCommand::Interrupt { worker_id } => {
3093            emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3094                ControlSurface::Cli,
3095                workspace,
3096                fleet_context,
3097                &manager,
3098                ControlOperation::FleetInterrupt,
3099                Some(&worker_id),
3100            ))
3101        }
3102        FleetCommand::Restart { worker_id } => {
3103            let report = manager.restart_worker(&worker_id)?;
3104            print_inspection(&report.inspection);
3105            println!(
3106                "manager loop running for restarted run {}; use `codewhale fleet status`, `inspect`, `interrupt`, or `stop --all` from another terminal.",
3107                report.run_id.0
3108            );
3109            let mut executor = FleetExecutor::new(workspace);
3110            let codewhale_binary = fleet::executor::configured_codewhale_binary();
3111            let status = manager
3112                .run_to_completion(
3113                    &report.run_id,
3114                    report.max_workers,
3115                    &mut executor,
3116                    &codewhale_binary,
3117                    None,
3118                    Duration::from_secs(2),
3119                )
3120                .await?;
3121            print_status(&status);
3122            Ok(())
3123        }
3124        FleetCommand::Resume {
3125            run_id,
3126            stale_after_seconds,
3127        } => {
3128            let manager = manager.with_stale_after(Duration::from_secs(stale_after_seconds.max(1)));
3129            emit_fleet_receipt(&fleet_control::execute_fleet_control_with(
3130                ControlSurface::Cli,
3131                workspace,
3132                fleet_context,
3133                &manager,
3134                ControlOperation::FleetResume,
3135                Some(&run_id),
3136            ))
3137        }
3138        FleetCommand::Stop { all } => {
3139            if !all {
3140                bail!("pass --all to stop all fleet work");
3141            }
3142            let stopped = manager.stop_all()?;
3143            println!("stopped: {stopped}");
3144            Ok(())
3145        }
3146        FleetCommand::AlertDryRun(args) => {
3147            let class = alert_event_class(args.event);
3148            let adapter = alert_adapter(&args);
3149            let event = FleetAlertEvent {
3150                class,
3151                run_id: FleetRunId::from(args.run_id.clone()),
3152                worker_id: args.worker_id.clone(),
3153                task_id: args.task_id.clone(),
3154                status: alert_status(class, args.status.clone()),
3155                reason: args.reason.clone(),
3156            };
3157            let dispatcher = FleetAlertDispatcher::new(
3158                FleetAlertConfig::dry_run_for_adapter(adapter),
3159                FleetEnvSecretResolver,
3160            );
3161            let deliveries = dispatcher.dispatch(&event)?;
3162            for delivery in deliveries {
3163                println!(
3164                    "{}",
3165                    serde_json::to_string_pretty(&delivery.redacted_payload)?
3166                );
3167            }
3168            Ok(())
3169        }
3170    }
3171}
3172
3173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3174enum WriteStatus {
3175    Created,
3176    Overwritten,
3177    SkippedExists,
3178}
3179
3180fn ensure_parent_dir(path: &Path) -> Result<()> {
3181    if let Some(parent) = path.parent()
3182        && !parent.as_os_str().is_empty()
3183    {
3184        std::fs::create_dir_all(parent)
3185            .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
3186    }
3187    Ok(())
3188}
3189
3190fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
3191    ensure_parent_dir(path)?;
3192
3193    if path.exists() && !force {
3194        return Ok(WriteStatus::SkippedExists);
3195    }
3196
3197    let status = if path.exists() {
3198        WriteStatus::Overwritten
3199    } else {
3200        WriteStatus::Created
3201    };
3202
3203    std::fs::write(path, contents)
3204        .with_context(|| format!("Failed to write template at {}", path.display()))?;
3205
3206    Ok(status)
3207}
3208
3209fn mcp_template_json() -> Result<String> {
3210    let mut cfg = McpConfig::default();
3211    cfg.servers.insert(
3212        "example".to_string(),
3213        McpServerConfig {
3214            command: Some("node".to_string()),
3215            args: vec!["./path/to/your-mcp-server.js".to_string()],
3216            env: std::collections::HashMap::new(),
3217            cwd: None,
3218            url: None,
3219            transport: None,
3220            connect_timeout: None,
3221            execute_timeout: None,
3222            read_timeout: None,
3223            disabled: true,
3224            enabled: true,
3225            required: false,
3226            enabled_tools: Vec::new(),
3227            disabled_tools: Vec::new(),
3228            headers: std::collections::HashMap::new(),
3229            env_headers: std::collections::HashMap::new(),
3230            bearer_token_env_var: None,
3231            scopes: Vec::new(),
3232            oauth: None,
3233            oauth_resource: None,
3234            reviewed_plugin: None,
3235        },
3236    );
3237    serde_json::to_string_pretty(&cfg)
3238        .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
3239}
3240
3241fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
3242    let template = mcp_template_json()?;
3243    write_template_file(path, &template, force)
3244}
3245
3246fn skills_template(name: &str) -> String {
3247    format!(
3248        "\
3249---\n\
3250name: {name}\n\
3251description: Quick repo diagnostics and setup guidance\n\
3252allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
3253---\n\n\
3254When this skill is active:\n\
32551. Run the diagnostics tool to report workspace and sandbox status.\n\
32562. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
32573. Prefer small, validated changes and summarize what you verified.\n\
3258"
3259    )
3260}
3261
3262fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
3263    std::fs::create_dir_all(skills_dir)
3264        .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
3265
3266    let skill_name = "getting-started";
3267    let skill_path = skills_dir.join(skill_name).join("SKILL.md");
3268    ensure_parent_dir(&skill_path)?;
3269
3270    let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
3271    Ok((skill_path, status))
3272}
3273
3274fn tools_readme_template() -> &'static str {
3275    "# Local tools\n\n\
3276     Drop self-describing scripts here so they can be discovered by\n\
3277     `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\
3278     When `[tools.plugin_dir]` is set in config.toml (or when the default\n\
3279     `~/.codewhale/tools/` directory exists), they are auto-discovered and\n\
3280     registered as model-visible tools.\n\n\
3281     Each script should start with a frontmatter-style header so the\n\
3282     description is visible without executing the file and the agent knows\n\
3283     the tool name, description, and input schema:\n\n\
3284     ```\n\
3285     # name: my-tool\n\
3286     # description: One-line summary of what this tool does\n\
3287     # usage: my-tool [args...]\n\
3288     ```\n\n\
3289     The directory is intentionally not auto-loaded into the agent's tool\n\
3290     catalog. Wire individual tools through MCP, hooks, or skills when you\n\
3291     want them available inside a session.\n"
3292}
3293
3294fn tools_example_script() -> &'static str {
3295    "#!/usr/bin/env sh\n\
3296     # name: example\n\
3297     # description: Print a confirmation that local tool discovery works\n\
3298     # usage: example [name]\n\
3299     printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n"
3300}
3301
3302fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
3303    std::fs::create_dir_all(tools_dir)
3304        .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
3305
3306    let readme_path = tools_dir.join("README.md");
3307    let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
3308
3309    let example_path = tools_dir.join("example.sh");
3310    let example_status = write_template_file(&example_path, tools_example_script(), force)?;
3311
3312    Ok((tools_dir.to_path_buf(), readme_status, example_status))
3313}
3314
3315fn plugins_readme_template() -> &'static str {
3316    "# Local plugins\n\n\
3317     Each Codewhale plugin bundle lives in its own subdirectory with a\n\
3318     versioned `plugin.toml`. User bundles live here; workspace bundles live\n\
3319     under `<workspace>/.codewhale/plugins/`. Both are discovered read-only,\n\
3320     untrusted, and disabled by default.\n\n\
3321     A v0.9.1 bundle layout looks like:\n\n\
3322     ```\n\
3323     plugins/\n\
3324       my-plugin/\n\
3325         plugin.toml\n\
3326         skills/\n\
3327           my-skill/SKILL.md\n\
3328     ```\n\n\
3329     Run `/plugin validate`, `/plugin show <name>`, then `/plugin enable <name>`.\n\
3330     Enablement opens a content- and capability-bound trust review;\n\
3331     confirm the displayed `/plugin trust` command to create an owner-only,\n\
3332     content-addressed runtime snapshot, then enable the bundle. Remote MCP\n\
3333     authentication must name environment sources; never store secret values\n\
3334     in `plugin.toml`.\n\n\
3335     Codewhale activates only declarative Skills and MCP servers through their\n\
3336     existing engines. Commands, agents, hooks, LSP, native extensions,\n\
3337     filesystem grants, and lifecycle mutation stay inventoried and inactive;\n\
3338     a mixed bundle can still activate its supported Skills and MCP.\n\
3339     There is no marketplace, install, update, ambient compatibility scan, or\n\
3340     automatic trust surface in this release.\n"
3341}
3342
3343fn plugin_example_manifest_template() -> &'static str {
3344    "schema_version = 1\n\n\
3345     [plugin]\n\
3346     name = \"example\"\n\
3347     version = \"0.1.0\"\n\
3348     description = \"Starter Codewhale plugin bundle\"\n\n\
3349     [skills]\n\
3350     path = \"skills\"\n"
3351}
3352
3353fn plugin_example_skill_template() -> &'static str {
3354    "---\n\
3355     name: hello\n\
3356     description: Explain that the example plugin bundle is active.\n\
3357     ---\n\n\
3358     Tell the user this instruction came from the namespaced\n\
3359     `example:hello` plugin skill. Do not perform side effects.\n"
3360}
3361
3362fn init_plugins_dir(
3363    plugins_dir: &Path,
3364    force: bool,
3365) -> Result<(
3366    PathBuf,
3367    PathBuf,
3368    PathBuf,
3369    WriteStatus,
3370    WriteStatus,
3371    WriteStatus,
3372)> {
3373    std::fs::create_dir_all(plugins_dir)
3374        .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
3375
3376    let readme_path = plugins_dir.join("README.md");
3377    let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
3378
3379    let manifest_path = plugins_dir.join("example").join("plugin.toml");
3380    ensure_parent_dir(&manifest_path)?;
3381    let manifest_status =
3382        write_template_file(&manifest_path, plugin_example_manifest_template(), force)?;
3383
3384    let skill_path = plugins_dir
3385        .join("example")
3386        .join("skills")
3387        .join("hello")
3388        .join("SKILL.md");
3389    ensure_parent_dir(&skill_path)?;
3390    let skill_status = write_template_file(&skill_path, plugin_example_skill_template(), force)?;
3391
3392    Ok((
3393        readme_path,
3394        manifest_path,
3395        skill_path,
3396        readme_status,
3397        manifest_status,
3398        skill_status,
3399    ))
3400}
3401
3402/// Resolve the user-supplied CORS origins for `codewhale serve --http`.
3403///
3404/// Sources, in priority order (later sources extend earlier ones):
3405/// 1. `--cors-origin URL` flags (repeatable)
3406/// 2. `CODEWHALE_CORS_ORIGINS` env var (comma-separated),
3407///    then `DEEPSEEK_CORS_ORIGINS` as an alias
3408/// 3. `[runtime_api] cors_origins = [...]` in `config.toml`
3409///
3410/// The runtime API always allows the built-in dev defaults
3411/// (localhost:3000, localhost:1420, tauri://localhost). User entries are
3412/// appended on top — empty strings are skipped, and duplicates are deduped
3413/// while preserving first-seen order. Whalescale#255 / #561.
3414fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec<String> {
3415    let mut out: Vec<String> = Vec::new();
3416    let mut push = |raw: &str| {
3417        let trimmed = raw.trim();
3418        if trimmed.is_empty() {
3419            return;
3420        }
3421        if !out.iter().any(|existing| existing == trimmed) {
3422            out.push(trimmed.to_string());
3423        }
3424    };
3425    for o in flag_origins {
3426        push(o);
3427    }
3428    if let Ok(env_value) =
3429        std::env::var("CODEWHALE_CORS_ORIGINS").or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS"))
3430    {
3431        for piece in env_value.split(',') {
3432            push(piece);
3433        }
3434    }
3435    if let Some(rt) = &config.runtime_api
3436        && let Some(list) = &rt.cors_origins
3437    {
3438        for o in list {
3439            push(o);
3440        }
3441    }
3442    out
3443}
3444
3445fn deepseek_home_dir() -> PathBuf {
3446    codewhale_config::codewhale_home().unwrap_or_else(|_| {
3447        crate::config::effective_home_dir()
3448            .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale"))
3449    })
3450}
3451
3452/// Resolve the default tools directory. Mirrors `default_skills_dir` shape.
3453fn default_tools_dir() -> PathBuf {
3454    deepseek_home_dir().join("tools")
3455}
3456
3457/// Resolve the default plugins directory.
3458fn default_plugins_dir() -> PathBuf {
3459    deepseek_home_dir().join("plugins")
3460}
3461
3462/// Default location for crash/offline-queue checkpoints managed by the TUI.
3463fn default_checkpoints_dir() -> PathBuf {
3464    deepseek_home_dir().join("sessions").join("checkpoints")
3465}
3466
3467#[derive(Debug, Clone, PartialEq, Eq)]
3468struct CleanPlan {
3469    targets: Vec<PathBuf>,
3470}
3471
3472fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
3473    // Every `*.json` file in the checkpoints directory is checkpoint state:
3474    // per-session crash checkpoints (`<session_id>.json`), the legacy
3475    // single-slot checkpoint (`latest.json`), and the offline input queue
3476    // (`offline_queue.json`). Non-JSON files and subdirectories are left
3477    // alone.
3478    let mut targets: Vec<PathBuf> = std::fs::read_dir(checkpoints_dir)
3479        .map(|entries| {
3480            entries
3481                .filter_map(|entry| entry.ok().map(|e| e.path()))
3482                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
3483                .collect()
3484        })
3485        .unwrap_or_default();
3486    targets.sort();
3487    CleanPlan { targets }
3488}
3489
3490fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
3491    let mut removed = Vec::with_capacity(plan.targets.len());
3492    for path in &plan.targets {
3493        std::fs::remove_file(path)
3494            .with_context(|| format!("Failed to remove {}", path.display()))?;
3495        removed.push(path.clone());
3496    }
3497    Ok(removed)
3498}
3499
3500fn run_setup(
3501    config: &Config,
3502    workspace: &Path,
3503    args: SetupArgs,
3504    plugins: &crate::plugins::PluginRegistry,
3505) -> Result<()> {
3506    if args.status {
3507        return run_setup_status(config, workspace, plugins);
3508    }
3509    if args.clean {
3510        return run_setup_clean(&default_checkpoints_dir(), args.force);
3511    }
3512
3513    use crate::palette;
3514    use colored::Colorize;
3515
3516    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3517    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3518
3519    let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
3520    let run_mcp = args.mcp || args.all || !any_explicit;
3521    let run_skills = args.skills || args.all || !any_explicit;
3522    let run_tools = args.tools || args.all;
3523    let run_plugins = args.plugins || args.all;
3524
3525    println!(
3526        "{}",
3527        "Codewhale Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
3528    );
3529    println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
3530    println!("Workspace: {}", crate::utils::display_path(workspace));
3531
3532    if run_mcp {
3533        let mcp_path = config.mcp_config_path();
3534        let status = init_mcp_config(&mcp_path, args.force)?;
3535        match status {
3536            WriteStatus::Created => {
3537                println!("  ✓ Created MCP config at {}", mcp_path.display());
3538            }
3539            WriteStatus::Overwritten => {
3540                println!("  ✓ Overwrote MCP config at {}", mcp_path.display());
3541            }
3542            WriteStatus::SkippedExists => {
3543                println!("  · MCP config already exists at {}", mcp_path.display());
3544            }
3545        }
3546        println!(
3547            "    Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."
3548        );
3549    }
3550
3551    if run_skills {
3552        let skills_dir = if args.local {
3553            workspace.join("skills")
3554        } else {
3555            config.skills_dir()
3556        };
3557        let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
3558        match status {
3559            WriteStatus::Created => {
3560                println!("  ✓ Created example skill at {}", skill_path.display());
3561            }
3562            WriteStatus::Overwritten => {
3563                println!("  ✓ Overwrote example skill at {}", skill_path.display());
3564            }
3565            WriteStatus::SkippedExists => {
3566                println!(
3567                    "  · Example skill already exists at {}",
3568                    skill_path.display()
3569                );
3570            }
3571        }
3572        if args.local {
3573            println!(
3574                "    Local skills dir enabled for this workspace: {}",
3575                crate::utils::display_path(&skills_dir)
3576            );
3577        } else {
3578            println!(
3579                "    Skills dir: {}",
3580                crate::utils::display_path(&skills_dir)
3581            );
3582        }
3583        println!("    Next: run the TUI and use `/skills` then `/skill getting-started`.");
3584    }
3585
3586    if run_tools {
3587        let tools_dir = default_tools_dir();
3588        let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
3589        report_write_status("Tools README", &dir.join("README.md"), readme_status);
3590        report_write_status("Example tool", &dir.join("example.sh"), example_status);
3591        println!("    Tools dir: {}", crate::utils::display_path(&dir));
3592        println!("    Next: drop scripts here; surface them via skills/MCP when ready.");
3593    }
3594
3595    if run_plugins {
3596        let plugins_dir = default_plugins_dir();
3597        let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
3598            init_plugins_dir(&plugins_dir, args.force)?;
3599        report_write_status("Plugins README", &readme_path, readme_status);
3600        report_write_status("Example plugin manifest", &manifest_path, manifest_status);
3601        report_write_status("Example plugin skill", &skill_path, skill_status);
3602        println!(
3603            "    Plugins dir: {}",
3604            crate::utils::display_path(&plugins_dir)
3605        );
3606        println!("    Next: run `/plugin validate`, review `example`, then trust and enable it.");
3607    }
3608
3609    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3610        config.prefer_bwrap.unwrap_or(false),
3611    );
3612    if let Some(kind) = sandbox {
3613        println!("  ✓ Sandbox available: {kind}");
3614    } else {
3615        println!("  · Sandbox not available on this platform (best-effort only).");
3616    }
3617
3618    Ok(())
3619}
3620
3621fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
3622    match status {
3623        WriteStatus::Created => {
3624            println!("  ✓ Created {label} at {}", path.display());
3625        }
3626        WriteStatus::Overwritten => {
3627            println!("  ✓ Overwrote {label} at {}", path.display());
3628        }
3629        WriteStatus::SkippedExists => {
3630            println!("  · {label} already exists at {}", path.display());
3631        }
3632    }
3633}
3634
3635/// Source of the resolved API key, used only by static doctor/setup reports.
3636///
3637/// These reports must not migrate a legacy secret store or acquire a
3638/// write-capable credential handle just to label a source.
3639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3640enum ApiKeySource {
3641    ConfigDeclared,
3642    EnvDeclared,
3643    ExternalAuthDeclared,
3644    SecretStoreUnprobed,
3645    SecretStoreUnavailable,
3646    OAuth,
3647    ExternalConsent,
3648    NoAuth,
3649    LocalRuntime,
3650    Unknown,
3651}
3652
3653/// What structural diagnostics can truthfully say about credential
3654/// availability without consulting environment values or durable stores.
3655#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3656enum CredentialAvailability {
3657    Present,
3658    NotRequired,
3659    Unknown,
3660    NotProbed,
3661    Unavailable,
3662}
3663
3664impl CredentialAvailability {
3665    fn label(self) -> &'static str {
3666        match self {
3667            Self::Present => "present",
3668            Self::NotRequired => "not_required",
3669            Self::Unknown => "unknown",
3670            Self::NotProbed => "not_probed",
3671            Self::Unavailable => "unavailable",
3672        }
3673    }
3674
3675    fn certifies_ready(self) -> bool {
3676        matches!(self, Self::Present | Self::NotRequired)
3677    }
3678}
3679
3680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3681struct CredentialDiagnostic {
3682    source: ApiKeySource,
3683    availability: CredentialAvailability,
3684}
3685
3686impl CredentialDiagnostic {
3687    const fn new(source: ApiKeySource, availability: CredentialAvailability) -> Self {
3688        Self {
3689            source,
3690            availability,
3691        }
3692    }
3693}
3694
3695fn resolve_credential_diagnostic(config: &Config) -> CredentialDiagnostic {
3696    let provider = config.api_provider();
3697    let base_url = config.deepseek_base_url();
3698    let auth_mode = config.auth_mode_for_provider(provider);
3699    if crate::config::auth_mode_disables_api_key(auth_mode.as_deref()) {
3700        return CredentialDiagnostic::new(
3701            ApiKeySource::NoAuth,
3702            CredentialAvailability::NotRequired,
3703        );
3704    }
3705    if !crate::config::auth_mode_requires_api_key(auth_mode.as_deref())
3706        && (crate::config::provider_route_is_keyless_self_hosted(provider, &base_url)
3707            || crate::config::base_url_uses_local_host(&base_url))
3708    {
3709        return CredentialDiagnostic::new(
3710            ApiKeySource::LocalRuntime,
3711            CredentialAvailability::NotRequired,
3712        );
3713    }
3714    let custom_endpoint = config.provider_uses_custom_endpoint(provider);
3715    if !custom_endpoint && provider == crate::config::ApiProvider::OpenaiCodex {
3716        return config
3717            .external_credential_consent_status(provider)
3718            .filter(|status| status.route_state == "active")
3719            .map_or_else(
3720                || {
3721                    CredentialDiagnostic::new(
3722                        ApiKeySource::OAuth,
3723                        CredentialAvailability::NotProbed,
3724                    )
3725                },
3726                |_| {
3727                    CredentialDiagnostic::new(
3728                        ApiKeySource::ExternalConsent,
3729                        CredentialAvailability::NotProbed,
3730                    )
3731                },
3732            );
3733    }
3734    if !custom_endpoint
3735        && provider == crate::config::ApiProvider::Xai
3736        && auth_mode
3737            .as_deref()
3738            .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
3739    {
3740        return config
3741            .external_credential_consent_status(provider)
3742            .filter(|status| status.route_state == "active")
3743            .map_or_else(
3744                || {
3745                    CredentialDiagnostic::new(
3746                        ApiKeySource::OAuth,
3747                        CredentialAvailability::NotProbed,
3748                    )
3749                },
3750                |_| {
3751                    CredentialDiagnostic::new(
3752                        ApiKeySource::ExternalConsent,
3753                        CredentialAvailability::NotProbed,
3754                    )
3755                },
3756            );
3757    }
3758    let provider_config = config.provider_config();
3759    let provider_config_key_kind = provider_config
3760        .and_then(|entry| entry.api_key.as_deref())
3761        .map(crate::config::classify_config_api_key_value);
3762    let root_key_applies = matches!(
3763        provider,
3764        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
3765    ) || (provider == crate::config::ApiProvider::Custom
3766        && config.uses_legacy_literal_custom_route());
3767    let root_key_kind = root_key_applies
3768        .then_some(config.api_key.as_deref())
3769        .flatten()
3770        .map(crate::config::classify_config_api_key_value);
3771
3772    if matches!(
3773        provider_config_key_kind,
3774        Some(crate::config::ConfigApiKeyValueKind::Literal)
3775    ) || matches!(
3776        root_key_kind,
3777        Some(crate::config::ConfigApiKeyValueKind::Literal)
3778    ) {
3779        CredentialDiagnostic::new(
3780            ApiKeySource::ConfigDeclared,
3781            CredentialAvailability::Present,
3782        )
3783    } else if config
3784        .provider_config()
3785        .and_then(|entry| entry.api_key_env.as_deref())
3786        .is_some_and(|name| !name.trim().is_empty())
3787    {
3788        CredentialDiagnostic::new(ApiKeySource::EnvDeclared, CredentialAvailability::NotProbed)
3789    } else if config
3790        .provider_config()
3791        .and_then(|entry| entry.auth.as_ref())
3792        .is_some()
3793    {
3794        CredentialDiagnostic::new(
3795            ApiKeySource::ExternalAuthDeclared,
3796            CredentialAvailability::NotProbed,
3797        )
3798    } else if matches!(
3799        provider_config_key_kind,
3800        Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3801    ) || matches!(
3802        root_key_kind,
3803        Some(crate::config::ConfigApiKeyValueKind::SecretStoreSentinel)
3804    ) {
3805        if config.should_skip_secret_store_for_provider(provider) {
3806            return CredentialDiagnostic::new(
3807                ApiKeySource::SecretStoreUnavailable,
3808                CredentialAvailability::Unavailable,
3809            );
3810        }
3811        // The sentinel is a declaration that runtime resolution should use
3812        // the secret-store layer, never a literal key. Doctor does not read it.
3813        CredentialDiagnostic::new(
3814            ApiKeySource::SecretStoreUnprobed,
3815            CredentialAvailability::NotProbed,
3816        )
3817    } else if !config.should_skip_secret_store_for_provider(provider) {
3818        // No literal config declaration was found, but this route can continue
3819        // through the durable store and ambient provider environment. Ordinary
3820        // doctor deliberately does not inspect either source.
3821        CredentialDiagnostic::new(
3822            ApiKeySource::SecretStoreUnprobed,
3823            CredentialAvailability::NotProbed,
3824        )
3825    } else {
3826        CredentialDiagnostic::new(ApiKeySource::Unknown, CredentialAvailability::Unknown)
3827    }
3828}
3829
3830#[cfg(test)]
3831fn resolve_api_key_source(config: &Config) -> ApiKeySource {
3832    resolve_credential_diagnostic(config).source
3833}
3834
3835fn provider_config_table_key(provider: crate::config::ApiProvider) -> &'static str {
3836    provider
3837        .metadata()
3838        .map(|metadata| metadata.provider_config_key())
3839        .unwrap_or("deepseek_cn")
3840}
3841
3842fn count_dir_entries(dir: &Path) -> usize {
3843    std::fs::read_dir(dir)
3844        .map(|entries| entries.filter_map(std::result::Result::ok).count())
3845        .unwrap_or(0)
3846}
3847
3848fn skills_count_for(dir: &Path) -> usize {
3849    if !dir.exists() {
3850        return 0;
3851    }
3852    crate::skills::SkillRegistry::discover(dir).len()
3853}
3854
3855fn run_setup_status(
3856    config: &Config,
3857    workspace: &Path,
3858    plugins: &crate::plugins::PluginRegistry,
3859) -> Result<()> {
3860    use crate::palette;
3861    use colored::Colorize;
3862
3863    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
3864    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
3865
3866    println!(
3867        "{}",
3868        "Codewhale Status".truecolor(aqua_r, aqua_g, aqua_b).bold()
3869    );
3870    println!("{}", "===============".truecolor(sky_r, sky_g, sky_b));
3871    println!("workspace: {}", workspace.display());
3872
3873    let credential = resolve_credential_diagnostic(config);
3874    match credential.source {
3875        ApiKeySource::ConfigDeclared => println!(
3876            "  {} api_key: literal config value structurally present",
3877            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3878        ),
3879        ApiKeySource::EnvDeclared => println!(
3880            "  {} api_key: environment source declared (value not inspected)",
3881            "·".dimmed()
3882        ),
3883        ApiKeySource::ExternalAuthDeclared => println!(
3884            "  {} api_key: external auth source declared (value not inspected)",
3885            "·".dimmed()
3886        ),
3887        ApiKeySource::SecretStoreUnprobed => println!(
3888            "  {} api_key: secret store eligible (store not probed)",
3889            "·".dimmed()
3890        ),
3891        ApiKeySource::SecretStoreUnavailable => println!(
3892            "  {} api_key: secret-store sentinel declared, but this route cannot use that store",
3893            "!".truecolor(sky_r, sky_g, sky_b)
3894        ),
3895        ApiKeySource::OAuth => println!(
3896            "  {} oauth: Codewhale-owned route selected (token availability not probed)",
3897            "·".dimmed()
3898        ),
3899        ApiKeySource::ExternalConsent => println!(
3900            "  {} oauth: external read-only consent configured (credential file not probed)",
3901            "·".dimmed()
3902        ),
3903        ApiKeySource::NoAuth => println!(
3904            "  {} api_key: disabled for this route",
3905            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3906        ),
3907        ApiKeySource::LocalRuntime => println!(
3908            "  {} api_key: not required for this local runtime",
3909            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3910        ),
3911        ApiKeySource::Unknown => println!(
3912            "  {} api_key: unknown (credential environment and durable stores not inspected)",
3913            "·".dimmed()
3914        ),
3915    }
3916    println!(
3917        "  · credential availability: {}",
3918        credential.availability.label()
3919    );
3920    println!(
3921        "  · base_url: {}",
3922        crate::doctor::structural_url_authority(&config.deepseek_base_url())
3923    );
3924    let model = config
3925        .default_text_model
3926        .clone()
3927        .unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
3928    println!("  · default_text_model: {model}");
3929    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
3930    println!("  · default_mode: {default_mode} ({default_mode_source})");
3931
3932    let mcp_path = config.mcp_config_path();
3933    let project_mcp_path = crate::mcp::workspace_mcp_config_path(workspace);
3934    let mcp_count =
3935        match crate::mcp::load_config_with_workspace_and_plugins(&mcp_path, workspace, plugins) {
3936            Ok(cfg) => cfg.servers.len(),
3937            Err(_) => 0,
3938        };
3939    let mcp_present = if mcp_path.exists() { "" } else { "  (missing)" };
3940    let project_mcp_present = if project_mcp_path.exists() {
3941        ""
3942    } else {
3943        "  (missing)"
3944    };
3945    println!(
3946        "  · mcp servers: {mcp_count} from {}{mcp_present} + {}{project_mcp_present}",
3947        mcp_path.display(),
3948        project_mcp_path.display()
3949    );
3950
3951    let skills_dir = config.skills_dir();
3952    println!(
3953        "  · skills: {} at {}",
3954        skills_count_for(&skills_dir),
3955        crate::utils::display_path(&skills_dir)
3956    );
3957
3958    let tools_dir = default_tools_dir();
3959    let tools_present = if tools_dir.exists() {
3960        ""
3961    } else {
3962        "  (missing — run `setup --tools`)"
3963    };
3964    println!(
3965        "  · tools: {} entries at {}{tools_present}",
3966        if tools_dir.exists() {
3967            count_dir_entries(&tools_dir)
3968        } else {
3969            0
3970        },
3971        crate::utils::display_path(&tools_dir)
3972    );
3973
3974    let plugins_dir = default_plugins_dir();
3975    let plugins_present = if plugins_dir.exists() {
3976        ""
3977    } else {
3978        "  (missing — run `setup --plugins`)"
3979    };
3980    println!(
3981        "  · plugins: {} entries at {}{plugins_present}",
3982        if plugins_dir.exists() {
3983            count_dir_entries(&plugins_dir)
3984        } else {
3985            0
3986        },
3987        crate::utils::display_path(&plugins_dir)
3988    );
3989
3990    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
3991        config.prefer_bwrap.unwrap_or(false),
3992    );
3993    match sandbox {
3994        Some(kind) => println!(
3995            "  {} sandbox: {kind}",
3996            "✓".truecolor(aqua_r, aqua_g, aqua_b)
3997        ),
3998        None => println!(
3999            "  {} sandbox: unavailable (commands run best-effort)",
4000            "!".truecolor(sky_r, sky_g, sky_b)
4001        ),
4002    }
4003
4004    println!("  {} {}", "·".dimmed(), dotenv_status_line(workspace));
4005
4006    println!();
4007    println!("Run `codewhale doctor --json` for a machine-readable check.");
4008    Ok(())
4009}
4010
4011fn dotenv_status_line(workspace: &Path) -> String {
4012    let dotenv = workspace.join(".env");
4013    if dotenv.exists() {
4014        return format!(
4015            ".env present at {} (literal provider credentials only)",
4016            dotenv.display()
4017        );
4018    }
4019
4020    if workspace.join(".env.example").exists() {
4021        return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
4022    }
4023
4024    ".env not present in workspace".to_string()
4025}
4026
4027fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
4028    use colored::Colorize;
4029
4030    if !checkpoints_dir.exists() {
4031        println!(
4032            "Nothing to clean — checkpoints dir does not exist: {}",
4033            checkpoints_dir.display()
4034        );
4035        return Ok(());
4036    }
4037
4038    let plan = collect_clean_targets(checkpoints_dir);
4039    if plan.targets.is_empty() {
4040        println!(
4041            "Nothing to clean — no checkpoint files in {}",
4042            checkpoints_dir.display()
4043        );
4044        return Ok(());
4045    }
4046
4047    if !force {
4048        println!(
4049            "Would remove {} checkpoint file(s) (use --force to apply):",
4050            plan.targets.len()
4051        );
4052        for path in &plan.targets {
4053            println!("  · {}", path.display());
4054        }
4055        return Ok(());
4056    }
4057
4058    let removed = execute_clean_plan(&plan)?;
4059    println!("{}", "Cleaned checkpoints:".bold());
4060    for path in &removed {
4061        println!("  ✓ {}", path.display());
4062    }
4063    Ok(())
4064}
4065
4066fn run_session_diagnostics(args: SessionDiagnosticsArgs) -> Result<()> {
4067    let contents = std::fs::read_to_string(&args.path).with_context(|| {
4068        format!(
4069            "read session diagnostic JSONL from {}",
4070            crate::utils::display_path(&args.path)
4071        )
4072    })?;
4073    let summary = crate::session_diagnostics::analyze_session_failure_jsonl(&contents);
4074    if args.json {
4075        println!("{}", serde_json::to_string_pretty(&summary)?);
4076    } else {
4077        println!(
4078            "{}",
4079            crate::session_diagnostics::format_redacted_failure_summary(&summary)
4080        );
4081    }
4082    Ok(())
4083}
4084
4085/// Live API checks are explicit. Local endpoints have a separate opt-in because
4086/// an HTTP request can wake a desktop-managed daemon (notably Ollama.app).
4087fn doctor_should_probe_api(
4088    provider: crate::config::ApiProvider,
4089    base_url: &str,
4090    probes: crate::doctor::DoctorProbeRequest,
4091) -> bool {
4092    let local = crate::config::provider_route_is_keyless_self_hosted(provider, base_url)
4093        || crate::config::base_url_uses_local_host(base_url);
4094    probes.should_probe_api(local)
4095}
4096
4097/// Doctor must never turn credential inspection into a refresh/write path.
4098/// OAuth connectivity is exercised by an ordinary user request instead;
4099/// doctor limits itself to non-mutating readiness inspection.
4100fn doctor_should_probe_auth(config: &Config) -> bool {
4101    let provider = config.api_provider();
4102    if provider == crate::config::ApiProvider::OpenaiCodex
4103        && !config.provider_uses_custom_endpoint(provider)
4104    {
4105        return false;
4106    }
4107    let auth_mode = config.auth_mode_for_provider(provider);
4108    if provider == crate::config::ApiProvider::Xai
4109        && auth_mode
4110            .as_deref()
4111            .is_some_and(crate::xai_oauth::auth_mode_uses_xai_oauth)
4112    {
4113        return false;
4114    }
4115    !(provider == crate::config::ApiProvider::Moonshot
4116        && auth_mode
4117            .as_deref()
4118            .is_some_and(crate::config::auth_mode_uses_kimi_imported_token))
4119}
4120
4121/// Run system diagnostics
4122async fn run_doctor(
4123    config: &Config,
4124    workspace: &Path,
4125    config_path_override: Option<&Path>,
4126    probes: crate::doctor::DoctorProbeRequest,
4127    plugins: &crate::plugins::PluginRegistry,
4128) {
4129    use crate::palette;
4130    use colored::Colorize;
4131
4132    let (accent_r, accent_g, accent_b) = palette::WHALE_HUMAN_RGB;
4133    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
4134    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
4135    let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
4136
4137    println!(
4138        "{}",
4139        "codewhale Doctor"
4140            .truecolor(accent_r, accent_g, accent_b)
4141            .bold()
4142    );
4143    println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
4144    println!();
4145
4146    // Version info
4147    println!("{}", "Version Information:".bold());
4148    println!("  codewhale-tui: {}", env!("DEEPSEEK_BUILD_VERSION"));
4149    println!("  rust: {}", rustc_version());
4150    println!();
4151
4152    println!("{}", "Updates:".bold());
4153    crate::doctor::print_update_report(probes).await;
4154    println!();
4155
4156    // Configuration summary
4157    let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
4158        Ok(paths) => paths,
4159        Err(error) => {
4160            println!("{}", "Resolved User Paths:".bold());
4161            println!(
4162                "  {} unavailable: {error:#}",
4163                "✗".truecolor(red_r, red_g, red_b)
4164            );
4165            return;
4166        }
4167    };
4168    println!("{}", "Configuration:".bold());
4169    let config_path = &doctor_paths.config;
4170
4171    if config_path.exists() {
4172        println!(
4173            "  {} config.toml found at {}",
4174            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4175            crate::utils::display_path(config_path)
4176        );
4177        // Secret hygiene: name the keys, never the values. Plain-text config
4178        // is not a secret store.
4179        if let Ok(raw) = std::fs::read_to_string(config_path) {
4180            let flagged = crate::doctor::config_credential_shaped_keys(&raw);
4181            if !flagged.is_empty() {
4182                println!(
4183                    "  {} credential-shaped value(s) in config.toml ({}): move them to the secret backend, then scrub the file — config.toml is plain text",
4184                    "!".truecolor(sky_r, sky_g, sky_b),
4185                    flagged.join(", ")
4186                );
4187            }
4188        }
4189    } else {
4190        println!(
4191            "  {} config.toml not found at {} (using defaults/env)",
4192            "!".truecolor(sky_r, sky_g, sky_b),
4193            crate::utils::display_path(config_path)
4194        );
4195    }
4196    println!("  workspace: {}", crate::utils::display_path(workspace));
4197    println!("  {}", doctor_search_provider_line(config));
4198
4199    println!();
4200    println!("{}", "Resolved User Paths (read-only):".bold());
4201    for (label, path) in doctor_paths.entries() {
4202        println!("  · {label}: {}", crate::utils::display_path(path));
4203    }
4204
4205    let secret_backend = codewhale_secrets::diagnose_secret_backend();
4206    println!();
4207    println!("{}", "Secret Backend (structural only):".bold());
4208    for line in crate::doctor::secret_backend_human_lines(&secret_backend) {
4209        println!("  · {line}");
4210    }
4211
4212    // State root (v0.8.44)
4213    println!();
4214    println!("{}", "State Root:".bold());
4215    let (code_home, legacy_home) = doctor_state_roots();
4216    let active_root = if code_home.exists() {
4217        &code_home
4218    } else if legacy_home.exists() {
4219        &legacy_home
4220    } else {
4221        &code_home
4222    };
4223    println!("  active: {}", crate::utils::display_path(active_root));
4224    if active_root != &code_home {
4225        println!(
4226            "  note: legacy {} found; start Codewhale once to trigger safe migration where available.",
4227            crate::utils::display_path(&legacy_home)
4228        );
4229    }
4230    if legacy_home.exists() && code_home.exists() {
4231        println!(
4232            "  dual roots: {} (primary) + {} (legacy)",
4233            crate::utils::display_path(&code_home),
4234            crate::utils::display_path(&legacy_home)
4235        );
4236    }
4237    let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
4238    let session_recovery = doctor_session_recovery_report(
4239        &code_home,
4240        &legacy_home,
4241        codewhale_config::codewhale_home_is_explicit(),
4242    );
4243    print_doctor_legacy_state_report(
4244        &legacy_state_report,
4245        &session_recovery,
4246        (aqua_r, aqua_g, aqua_b),
4247        (sky_r, sky_g, sky_b),
4248    );
4249
4250    let (setup_state, setup_source) = doctor_setup_state(config, workspace);
4251    print_doctor_setup_report(
4252        config,
4253        workspace,
4254        &setup_state,
4255        setup_source,
4256        (aqua_r, aqua_g, aqua_b),
4257        (sky_r, sky_g, sky_b),
4258    );
4259    print_doctor_fleet_roster_layers(config, workspace);
4260
4261    // Check API keys
4262    println!();
4263    println!("{}", "API Keys:".bold());
4264
4265    // Per-provider state: env + config file only (no values printed).
4266    // Keep doctor/status prompt-free and credential-value-free even for
4267    // unsigned rebuilt binaries.
4268    for provider in crate::config::ApiProvider::all().iter().copied() {
4269        let slot = provider.as_str();
4270        let provider_config = config.provider_config_for(provider);
4271        let config_declared = provider_config.is_some_and(|entry| {
4272            entry.api_key.as_deref().is_some_and(|key| {
4273                crate::config::classify_config_api_key_value(key)
4274                    == crate::config::ConfigApiKeyValueKind::Literal
4275            })
4276        }) || (matches!(provider, crate::config::ApiProvider::Deepseek)
4277            && config.api_key.as_deref().is_some_and(|key| {
4278                crate::config::classify_config_api_key_value(key)
4279                    == crate::config::ConfigApiKeyValueKind::Literal
4280            }));
4281        let env_source_declared = provider_config
4282            .and_then(|entry| entry.api_key_env.as_deref())
4283            .is_some_and(|name| !name.trim().is_empty());
4284        let icon = if config_declared || env_source_declared {
4285            "·".truecolor(aqua_r, aqua_g, aqua_b)
4286        } else {
4287            "·".dimmed()
4288        };
4289        println!(
4290            "  {} {slot}: env_source={}, config_source={}",
4291            icon,
4292            if env_source_declared {
4293                "declared (value not inspected)"
4294            } else {
4295                "not inspected"
4296            },
4297            if config_declared {
4298                "declared (value not inspected)"
4299            } else {
4300                "not declared"
4301            }
4302        );
4303    }
4304    println!("  · credential precedence is unchanged; doctor does not inspect credential values");
4305    println!();
4306    println!(
4307        "{}",
4308        "External credential consent (configuration only):".bold()
4309    );
4310    for line in doctor_external_credential_consent_lines(config) {
4311        println!("  {line}");
4312    }
4313
4314    println!();
4315    println!(
4316        "{}",
4317        "DeepSeek Harness integration (read-only detection):".bold()
4318    );
4319    for line in doctor_dsh_integration_lines(config, workspace) {
4320        println!("  {line}");
4321    }
4322
4323    let credential = resolve_credential_diagnostic(config);
4324    let source_label = match credential.source {
4325        ApiKeySource::ConfigDeclared => "literal config value structurally present",
4326        ApiKeySource::EnvDeclared => "environment source declared; value not inspected",
4327        ApiKeySource::ExternalAuthDeclared => {
4328            "external auth source declared; credential not resolved"
4329        }
4330        ApiKeySource::SecretStoreUnprobed => "secret store eligible; store not probed",
4331        ApiKeySource::SecretStoreUnavailable => {
4332            "secret-store sentinel declared, but this route cannot use that store"
4333        }
4334        ApiKeySource::OAuth => "OAuth route configured; token availability not probed",
4335        ApiKeySource::ExternalConsent => "external consent configured; token file not read",
4336        ApiKeySource::NoAuth => "no-auth route",
4337        ApiKeySource::LocalRuntime => "local runtime; credentials not required",
4338        ApiKeySource::Unknown => "unknown; credential environment and stores not inspected",
4339    };
4340    println!(
4341        "  {} active provider credential source: {source_label}",
4342        "·".dimmed()
4343    );
4344    println!(
4345        "  · active provider credential availability: {}",
4346        credential.availability.label()
4347    );
4348
4349    // API connectivity test
4350    println!();
4351    println!("{}", "API Connectivity:".bold());
4352    let api_target = doctor_api_target(config);
4353    // Configured-vs-active honesty (DGF-01): doctor describes the route a
4354    // session launched NOW would resolve. It cannot see inside an already
4355    // running session, which keeps the route it resolved at its own launch.
4356    println!(
4357        "  · 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)"
4358    );
4359    println!("  · provider: {}", api_target.provider);
4360    println!(
4361        "  · base_url: {}",
4362        crate::doctor::structural_url_authority(&api_target.base_url)
4363    );
4364    match api_target.resolution {
4365        DoctorModelResolution::Resolved => {
4366            println!("  · model: {} (resolved)", api_target.model);
4367        }
4368        DoctorModelResolution::ConfiguredOnly => {
4369            println!(
4370                "  · model: {} (configured; route resolution unavailable)",
4371                api_target.model
4372            );
4373        }
4374    }
4375    let tls_status = doctor_tls_status(config);
4376    if !tls_status.certificate_verification {
4377        println!("  ! {}", tls_status.message);
4378        println!("    Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
4379    }
4380    let strict_tool_mode = doctor_strict_tool_mode_status(config);
4381    let strict_icon = match strict_tool_mode.status {
4382        "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
4383        "fallback_non_beta" | "custom_endpoint" => "!".truecolor(sky_r, sky_g, sky_b),
4384        _ => "·".dimmed(),
4385    };
4386    println!(
4387        "  {} strict_tool_mode: {}",
4388        strict_icon, strict_tool_mode.message
4389    );
4390    if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
4391        println!(
4392            "    Use the {} endpoint for DeepSeek strict schemas.",
4393            crate::doctor::structural_url_authority(recommended)
4394        );
4395    }
4396    let capability = crate::config::provider_capability(config.api_provider(), &api_target.model);
4397    if let Some(alias) = capability.alias_deprecation.as_ref() {
4398        println!(
4399            "  ! model alias {} retires {}; switch to {}",
4400            alias.alias, alias.retirement_date, alias.replacement
4401        );
4402    }
4403    let live_api_requested =
4404        doctor_should_probe_api(config.api_provider(), &api_target.base_url, probes);
4405    let endpoint_is_local = crate::config::provider_route_is_keyless_self_hosted(
4406        config.api_provider(),
4407        &api_target.base_url,
4408    ) || crate::config::base_url_uses_local_host(&api_target.base_url);
4409    if doctor_should_probe_auth(config) && live_api_requested {
4410        print!("  {} Testing connection...", "·".dimmed());
4411        use std::io::Write;
4412        std::io::stdout().flush().ok();
4413
4414        // Resolve a credential through the diagnostic-only store first, then
4415        // probe with an in-memory clone. Constructing the normal client from
4416        // the original config could otherwise trigger its legacy secret-store
4417        // migration while a user merely asks doctor to test connectivity.
4418        let connectivity_result = match config.with_read_only_api_key_for_diagnostic() {
4419            Ok(diagnostic_config) => test_api_connectivity(&diagnostic_config).await,
4420            Err(error) => Err(error),
4421        };
4422        match connectivity_result {
4423            Ok(()) => {
4424                println!(
4425                    "\r  {} API connection successful",
4426                    "✓".truecolor(aqua_r, aqua_g, aqua_b)
4427                );
4428            }
4429            Err(e) => {
4430                let error_msg = e.to_string();
4431                println!(
4432                    "\r  {} API connection failed",
4433                    "✗".truecolor(red_r, red_g, red_b)
4434                );
4435                if error_msg.contains("401") || error_msg.contains("Unauthorized") {
4436                    println!(
4437                        "    Invalid API key. Check `codewhale auth status`, DEEPSEEK_API_KEY, or config.toml"
4438                    );
4439                } else if error_msg.contains("403") || error_msg.contains("Forbidden") {
4440                    println!(
4441                        "    API key lacks permissions. Verify key is active at platform.deepseek.com"
4442                    );
4443                } else if error_msg.contains("timeout") || error_msg.contains("Timeout") {
4444                    for line in doctor_timeout_recovery_lines(config) {
4445                        println!("    {line}");
4446                    }
4447                } else if error_msg.contains("dns") || error_msg.contains("resolve") {
4448                    println!("    DNS resolution failed. Check your network connection");
4449                } else if error_msg.contains("connect") {
4450                    println!("    Connection failed. Check firewall settings or try again");
4451                } else if crate::doctor::is_keyless_ds4_route(config) {
4452                    println!("    {error_msg}");
4453                } else {
4454                    println!(
4455                        "    Error details omitted because provider failures can contain credential material."
4456                    );
4457                }
4458            }
4459        }
4460    } else if !doctor_should_probe_auth(config) {
4461        println!(
4462            "  {} Live OAuth connectivity not checked by non-mutating doctor",
4463            "·".dimmed()
4464        );
4465        println!(
4466            "    Doctor never refreshes or rewrites credentials; exercise the route with a normal request."
4467        );
4468    } else {
4469        if endpoint_is_local {
4470            println!(
4471                "  {} Live connectivity not checked for this local endpoint",
4472                "·".dimmed()
4473            );
4474            println!(
4475                "    Run `codewhale doctor --probe-local` to opt in; the request may start a local service."
4476            );
4477        } else {
4478            println!(
4479                "  {} Live hosted connectivity not checked (offline default)",
4480                "·".dimmed()
4481            );
4482            println!("    Run `codewhale doctor --probe-api` to opt in.");
4483        }
4484    }
4485
4486    // MCP configuration
4487    println!();
4488    println!("{}", "MCP Servers (configuration only):".bold());
4489    println!("  · Static check only; no server process was started.");
4490    let features = config.features();
4491    if features.enabled(Feature::Mcp) {
4492        println!(
4493            "  {} MCP feature flag enabled",
4494            "✓".truecolor(aqua_r, aqua_g, aqua_b)
4495        );
4496    } else {
4497        println!(
4498            "  {} MCP feature flag disabled",
4499            "!".truecolor(sky_r, sky_g, sky_b)
4500        );
4501    }
4502
4503    let mcp_config_path = config.mcp_config_path();
4504    let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
4505    if mcp_config_path.exists() {
4506        println!(
4507            "  {} MCP config found at {}",
4508            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4509            crate::utils::display_path(&mcp_config_path)
4510        );
4511    } else {
4512        println!(
4513            "  {} MCP config not found at {}",
4514            "·".dimmed(),
4515            crate::utils::display_path(&mcp_config_path)
4516        );
4517    }
4518    if project_mcp_config_path.exists() {
4519        println!(
4520            "  {} Project MCP config found at {}",
4521            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4522            crate::utils::display_path(&project_mcp_config_path)
4523        );
4524    } else {
4525        println!(
4526            "  {} Project MCP config not found at {}",
4527            "·".dimmed(),
4528            crate::utils::display_path(&project_mcp_config_path)
4529        );
4530    }
4531
4532    match crate::mcp::load_config_with_workspace_and_plugins(&mcp_config_path, workspace, plugins) {
4533        Ok(cfg) if cfg.servers.is_empty() => {
4534            println!("  {} 0 merged server(s) configured", "·".dimmed());
4535            if !mcp_config_path.exists() && !project_mcp_config_path.exists() {
4536                println!("    Run `codewhale mcp init` or add `.codewhale/mcp.json`.");
4537            }
4538        }
4539        Ok(cfg) => {
4540            println!(
4541                "  {} {} merged server(s) configured",
4542                "·".dimmed(),
4543                cfg.servers.len()
4544            );
4545            for (name, server) in &cfg.servers {
4546                let status = doctor_check_mcp_server(server);
4547                let icon = match &status {
4548                    McpServerDoctorStatus::Ok(detail) => {
4549                        format!(
4550                            "  {} {name}: configuration valid; {}",
4551                            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4552                            detail
4553                        )
4554                    }
4555                    McpServerDoctorStatus::Warning(detail) => {
4556                        format!(
4557                            "  {} {name}: configuration warning; {}",
4558                            "!".truecolor(sky_r, sky_g, sky_b),
4559                            detail
4560                        )
4561                    }
4562                    McpServerDoctorStatus::Error(detail) => {
4563                        format!(
4564                            "  {} {name}: configuration invalid; {}",
4565                            "✗".truecolor(red_r, red_g, red_b),
4566                            detail
4567                        )
4568                    }
4569                };
4570                println!("{icon}");
4571                if !server.is_enabled() {
4572                    println!("      disabled; live health not checked");
4573                } else {
4574                    println!(
4575                        "      process/protocol/backend: not checked; `codewhale mcp validate` explicitly starts and initializes configured servers"
4576                    );
4577                }
4578            }
4579            if probes.should_probe_mcp() {
4580                println!();
4581                println!(
4582                    "  {} Live MCP probe enabled: starting enabled servers; backend tool health remains untested.",
4583                    "!".truecolor(sky_r, sky_g, sky_b)
4584                );
4585                match crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
4586                    &mcp_config_path,
4587                    workspace,
4588                    std::sync::Arc::new(plugins.clone()),
4589                ) {
4590                    Ok(mut pool) => {
4591                        let errors = pool.connect_all().await;
4592                        let failed = errors
4593                            .iter()
4594                            .map(|(name, _)| name.as_str())
4595                            .collect::<std::collections::BTreeSet<_>>();
4596                        for (name, server) in &cfg.servers {
4597                            if !server.is_enabled() {
4598                                continue;
4599                            }
4600                            if failed.contains(name.as_str()) {
4601                                println!(
4602                                    "      {} {name}: process/protocol unreachable; error details omitted",
4603                                    "✗".truecolor(red_r, red_g, red_b)
4604                                );
4605                            } else {
4606                                println!(
4607                                    "      {} {name}: process reachable and protocol initialized; backend tool health not checked",
4608                                    "✓".truecolor(aqua_r, aqua_g, aqua_b)
4609                                );
4610                            }
4611                        }
4612                    }
4613                    Err(_) => println!(
4614                        "      {} live MCP probe could not load merged configuration; details omitted",
4615                        "✗".truecolor(red_r, red_g, red_b)
4616                    ),
4617                }
4618            } else {
4619                println!(
4620                    "    Use codewhale doctor --probe-mcp to opt in to live process/protocol checks; it may start configured servers."
4621                );
4622            }
4623        }
4624        Err(_) => {
4625            println!(
4626                "  {} MCP configuration could not be loaded; details omitted",
4627                "✗".truecolor(red_r, red_g, red_b)
4628            );
4629        }
4630    }
4631
4632    // Skills configuration
4633    println!();
4634    println!("{}", "Skills:".bold());
4635    let global_skills_dir = config.skills_dir();
4636    let agents_skills_dir = workspace.join(".agents").join("skills");
4637    let local_skills_dir = workspace.join("skills");
4638    let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
4639    // #432: cross-tool skill discovery dirs. Presence is reported here
4640    // even though they sit lower in the precedence chain so users can
4641    // see at a glance whether a `.opencode/skills/`, `.claude/skills/`,
4642    // `.cursor/skills/`, or global agentskills.io directory is contributing
4643    // to the merged catalogue.
4644    let opencode_skills_dir = workspace.join(".opencode").join("skills");
4645    let claude_skills_dir = workspace.join(".claude").join("skills");
4646    let selected_skills_dir = if agents_skills_dir.exists() {
4647        agents_skills_dir.clone()
4648    } else if local_skills_dir.exists() {
4649        local_skills_dir.clone()
4650    } else if config.skills_dir.is_none()
4651        && let Some(global_agents) = agents_global_skills_dir.as_ref()
4652        && global_agents.exists()
4653    {
4654        global_agents.clone()
4655    } else {
4656        global_skills_dir.clone()
4657    };
4658
4659    let describe_dir = |dir: &Path| -> usize {
4660        std::fs::read_dir(dir)
4661            .map(|entries| entries.filter_map(std::result::Result::ok).count())
4662            .unwrap_or(0)
4663    };
4664
4665    if local_skills_dir.exists() {
4666        println!(
4667            "  {} local skills dir found at {} ({} items)",
4668            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4669            crate::utils::display_path(&local_skills_dir),
4670            describe_dir(&local_skills_dir)
4671        );
4672    } else {
4673        println!(
4674            "  {} local skills dir not found at {}",
4675            "·".dimmed(),
4676            crate::utils::display_path(&local_skills_dir)
4677        );
4678    }
4679
4680    if agents_skills_dir.exists() {
4681        println!(
4682            "  {} .agents skills dir found at {} ({} items)",
4683            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4684            crate::utils::display_path(&agents_skills_dir),
4685            describe_dir(&agents_skills_dir)
4686        );
4687    } else {
4688        println!(
4689            "  {} .agents skills dir not found at {}",
4690            "·".dimmed(),
4691            crate::utils::display_path(&agents_skills_dir)
4692        );
4693    }
4694
4695    if let Some(agents_global_skills_dir) = agents_global_skills_dir.as_ref() {
4696        if agents_global_skills_dir.exists() {
4697            println!(
4698                "  {} global .agents skills dir found at {} ({} items)",
4699                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4700                crate::utils::display_path(agents_global_skills_dir),
4701                describe_dir(agents_global_skills_dir)
4702            );
4703        } else {
4704            println!(
4705                "  {} global .agents skills dir not found at {}",
4706                "·".dimmed(),
4707                crate::utils::display_path(agents_global_skills_dir)
4708            );
4709        }
4710    }
4711
4712    if global_skills_dir.exists() {
4713        println!(
4714            "  {} global skills dir found at {} ({} items)",
4715            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4716            crate::utils::display_path(&global_skills_dir),
4717            describe_dir(&global_skills_dir)
4718        );
4719    } else {
4720        println!(
4721            "  {} global skills dir not found at {}",
4722            "·".dimmed(),
4723            crate::utils::display_path(&global_skills_dir)
4724        );
4725    }
4726
4727    // #432: only print interop dirs when they're populated — empty
4728    // .opencode/.claude folders are common and would just clutter
4729    // the report with false-positive "absent" lines.
4730    if opencode_skills_dir.exists() {
4731        println!(
4732            "  {} .opencode skills dir found at {} ({} items)",
4733            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4734            crate::utils::display_path(&opencode_skills_dir),
4735            describe_dir(&opencode_skills_dir)
4736        );
4737    }
4738    if claude_skills_dir.exists() {
4739        println!(
4740            "  {} .claude skills dir found at {} ({} items)",
4741            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4742            crate::utils::display_path(&claude_skills_dir),
4743            describe_dir(&claude_skills_dir)
4744        );
4745    }
4746
4747    println!(
4748        "  {} selected skills dir: {}",
4749        "·".dimmed(),
4750        crate::utils::display_path(&selected_skills_dir)
4751    );
4752    if !agents_skills_dir.exists()
4753        && !local_skills_dir.exists()
4754        && !agents_global_skills_dir
4755            .as_ref()
4756            .is_some_and(|dir| dir.exists())
4757        && !global_skills_dir.exists()
4758    {
4759        println!("    Run `codewhale setup --skills` (or add --local for ./skills).");
4760    }
4761
4762    // Tools directory
4763    println!();
4764    println!("{}", "Tools:".bold());
4765    let tools_dir = default_tools_dir();
4766    if tools_dir.exists() {
4767        let count = count_dir_entries(&tools_dir);
4768        println!(
4769            "  {} tools dir found at {} ({} items)",
4770            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4771            crate::utils::display_path(&tools_dir),
4772            count
4773        );
4774    } else {
4775        println!(
4776            "  {} tools dir not found at {}",
4777            "·".dimmed(),
4778            crate::utils::display_path(&tools_dir)
4779        );
4780        println!("    Run `codewhale setup --tools` to scaffold a starter dir.");
4781    }
4782
4783    // Plugins directory
4784    println!();
4785    println!("{}", "Plugins:".bold());
4786    let plugins_dir = default_plugins_dir();
4787    if plugins_dir.exists() {
4788        let count = count_dir_entries(&plugins_dir);
4789        println!(
4790            "  {} plugins dir found at {} ({} items)",
4791            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4792            crate::utils::display_path(&plugins_dir),
4793            count
4794        );
4795    } else {
4796        println!(
4797            "  {} plugins dir not found at {}",
4798            "·".dimmed(),
4799            crate::utils::display_path(&plugins_dir)
4800        );
4801        println!("    Run `codewhale setup --plugins` to scaffold a starter dir.");
4802    }
4803
4804    // Storage surfaces (#422 / #440 / #500)
4805    println!();
4806    println!("{}", "Storage:".bold());
4807    if let Some(spillover_root) = crate::tools::truncate::spillover_root() {
4808        let (present, count) = if spillover_root.is_dir() {
4809            (true, count_dir_entries(&spillover_root))
4810        } else {
4811            (false, 0)
4812        };
4813        if present {
4814            println!(
4815                "  {} tool-output spillover at {} ({} file{})",
4816                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4817                crate::utils::display_path(&spillover_root),
4818                count,
4819                if count == 1 { "" } else { "s" }
4820            );
4821        } else {
4822            println!(
4823                "  {} tool-output spillover dir not yet created at {}",
4824                "·".dimmed(),
4825                crate::utils::display_path(&spillover_root)
4826            );
4827        }
4828    }
4829    let stash = crate::composer_stash::diagnostic_stash_report();
4830    if let Some(stash_path) = stash.path.as_ref() {
4831        if let Some(error) = stash.error.as_deref() {
4832            println!(
4833                "  {} composer stash was not inspected at {}: {error}",
4834                "!".truecolor(sky_r, sky_g, sky_b),
4835                crate::utils::display_path(stash_path),
4836            );
4837        } else if stash.present {
4838            println!(
4839                "  {} composer stash at {} ({} parked draft{})",
4840                "✓".truecolor(aqua_r, aqua_g, aqua_b),
4841                crate::utils::display_path(stash_path),
4842                stash.count,
4843                if stash.count == 1 { "" } else { "s" }
4844            );
4845        } else {
4846            println!(
4847                "  {} composer stash empty (Ctrl+G or Ctrl+S in the composer to park a draft)",
4848                "·".dimmed()
4849            );
4850        }
4851    } else if let Some(error) = stash.error.as_deref() {
4852        println!(
4853            "  {} composer stash was not inspected: {error}",
4854            "!".truecolor(sky_r, sky_g, sky_b),
4855        );
4856    }
4857
4858    // Tool dependencies — probe external binaries that individual
4859    // tools rely on (Python for code_execution, pdftotext for PDF
4860    // reading) so users see explicit ✓/✗ rather than the tool failing
4861    // at execution time with "program not found". New in v0.8.31.
4862    println!();
4863    println!("{}", "Tool Dependencies:".bold());
4864
4865    match crate::dependencies::resolve_python_interpreter() {
4866        Some(name) => println!(
4867            "  {} Python: {} → code_execution tool registered",
4868            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4869            name
4870        ),
4871        None => {
4872            println!(
4873                "  {} Python: not found (tried {:?})",
4874                "✗".truecolor(red_r, red_g, red_b),
4875                crate::dependencies::PYTHON_CANDIDATES,
4876            );
4877            println!("    code_execution tool is NOT advertised to the model on this install.");
4878            println!("    Install Python 3 and ensure one of those names is on PATH:");
4879            match std::env::consts::OS {
4880                "macos" => {
4881                    println!("      brew install python@3.12   (or download from python.org)")
4882                }
4883                "linux" => println!(
4884                    "      sudo apt install python3    (Debian/Ubuntu) — or your distro's equivalent"
4885                ),
4886                "windows" => {
4887                    println!("      winget install Python.Python.3   (or download from python.org)")
4888                }
4889                other => println!("      install Python 3 for {other} from python.org"),
4890            }
4891        }
4892    }
4893
4894    match crate::dependencies::resolve_node() {
4895        Some(_) => println!(
4896            "  {} Node.js: present → js_execution tool registered",
4897            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4898        ),
4899        None => {
4900            println!(
4901                "  {} Node.js: not found (tried `node`)",
4902                "✗".truecolor(red_r, red_g, red_b),
4903            );
4904            println!("    js_execution tool is NOT advertised to the model on this install.");
4905            println!("    Install Node 18+ and ensure `node` is on PATH:");
4906            match std::env::consts::OS {
4907                "macos" => println!("      brew install node   (or download from nodejs.org)"),
4908                "linux" => println!(
4909                    "      sudo apt install nodejs    (Debian/Ubuntu) — or your distro's equivalent"
4910                ),
4911                "windows" => {
4912                    println!("      winget install OpenJS.NodeJS   (or download from nodejs.org)")
4913                }
4914                other => println!("      install Node.js for {other} from nodejs.org"),
4915            }
4916        }
4917    }
4918
4919    match crate::dependencies::resolve_pandoc() {
4920        Some(_) => println!(
4921            "  {} pandoc: present → pandoc_convert tool registered",
4922            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4923        ),
4924        None => {
4925            println!("  {} pandoc: not found (optional)", "·".dimmed(),);
4926            println!(
4927                "    pandoc_convert tool is NOT advertised to the model. Install pandoc to enable:"
4928            );
4929            match std::env::consts::OS {
4930                "macos" => println!("      brew install pandoc"),
4931                "linux" => println!(
4932                    "      sudo apt install pandoc    (Debian/Ubuntu) — or your distro's equivalent"
4933                ),
4934                "windows" => {
4935                    println!("      winget install JohnMacFarlane.Pandoc")
4936                }
4937                other => println!("      install pandoc for {other} from pandoc.org"),
4938            }
4939        }
4940    }
4941
4942    match crate::dependencies::resolve_tesseract() {
4943        Some(_) => {
4944            if cfg!(target_os = "macos") {
4945                println!(
4946                    "  {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled",
4947                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4948                );
4949            } else {
4950                println!(
4951                    "  {} tesseract: present → image_ocr/read_file screenshot OCR enabled",
4952                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4953                );
4954            }
4955        }
4956        None => {
4957            if cfg!(target_os = "macos") {
4958                println!(
4959                    "  {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled",
4960                    "✓".truecolor(aqua_r, aqua_g, aqua_b),
4961                );
4962                println!(
4963                    "    tesseract not found (optional; install only for alternate OCR packs)."
4964                );
4965            } else {
4966                println!("  {} tesseract: not found (optional)", "·".dimmed(),);
4967                println!(
4968                    "    image_ocr tool is NOT advertised to the model. Install tesseract to enable:"
4969                );
4970                match std::env::consts::OS {
4971                    "macos" => println!("      brew install tesseract"),
4972                    "linux" => println!(
4973                        "      sudo apt install tesseract-ocr    (Debian/Ubuntu) — or your distro's equivalent"
4974                    ),
4975                    "windows" => println!("      winget install UB-Mannheim.TesseractOCR"),
4976                    other => {
4977                        println!("      install tesseract for {other} from tesseract-ocr.github.io")
4978                    }
4979                }
4980            }
4981        }
4982    }
4983
4984    // PDF text extraction is an optional integration. Codewhale itself stays
4985    // a single required executable; file and web tools report a typed
4986    // failed `binary_unavailable` result when Poppler is not installed.
4987    match crate::dependencies::resolve_pdftotext() {
4988        Some(_) => println!(
4989            "  {} pdftotext: available → PDF text extraction enabled",
4990            "✓".truecolor(aqua_r, aqua_g, aqua_b),
4991        ),
4992        None => {
4993            println!(
4994                "  {} pdftotext: not found (optional; PDF text reads fail as `binary_unavailable`)",
4995                "·".dimmed(),
4996            );
4997            match std::env::consts::OS {
4998                "macos" => println!("    Install via: brew install poppler"),
4999                "linux" => {
5000                    println!("    Install via: sudo apt install poppler-utils   (Debian/Ubuntu)")
5001                }
5002                "windows" => println!(
5003                    "    Install Poppler for Windows from https://blog.alivate.com.au/poppler-windows/"
5004                ),
5005                _ => {}
5006            }
5007        }
5008    }
5009
5010    // Terminal-quirk overrides currently active. Mirrors the env
5011    // signals checked by `Settings::apply_env_overrides` so users
5012    // can see at a glance which a11y/compat overrides fired.
5013    println!();
5014    println!("{}", "Terminal Quirks:".bold());
5015    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
5016    let term_program_lc = term_program.to_ascii_lowercase();
5017    let mut any_quirk = false;
5018    if matches!(term_program.as_str(), "vscode" | "ghostty") {
5019        println!(
5020            "  {} TERM_PROGRAM={} → low_motion + fancy_animations=false (auto)",
5021            "•".truecolor(sky_r, sky_g, sky_b),
5022            term_program
5023        );
5024        any_quirk = true;
5025    }
5026    if term_program == "Termius"
5027        || std::env::var_os("SSH_CLIENT").is_some_and(|v| !v.is_empty())
5028        || std::env::var_os("SSH_TTY").is_some_and(|v| !v.is_empty())
5029    {
5030        println!(
5031            "  {} SSH/Termius session → low_motion + fancy_animations=false (auto, #1433)",
5032            "•".truecolor(sky_r, sky_g, sky_b)
5033        );
5034        any_quirk = true;
5035    }
5036    if term_program_lc.contains("ptyxis")
5037        || std::env::var_os("PTYXIS_VERSION").is_some_and(|v| !v.is_empty())
5038    {
5039        println!(
5040            "  {} Ptyxis detected → synchronized_output=off (auto, v0.8.31)",
5041            "•".truecolor(sky_r, sky_g, sky_b)
5042        );
5043        any_quirk = true;
5044    }
5045    if crate::settings::detected_legacy_windows_console_host() {
5046        println!(
5047            "  {} legacy Windows console host → low_motion + fancy_animations=false + bracketed_paste=false + synchronized_output=off (auto)",
5048            "•".truecolor(sky_r, sky_g, sky_b)
5049        );
5050        any_quirk = true;
5051    }
5052    if !any_quirk {
5053        println!(
5054            "  {} no env-driven terminal-quirk overrides active",
5055            "·".dimmed()
5056        );
5057    }
5058
5059    // Platform and sandbox checks
5060    println!();
5061    println!("{}", "Platform:".bold());
5062    println!("  OS: {}", std::env::consts::OS);
5063    println!("  Arch: {}", std::env::consts::ARCH);
5064
5065    let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
5066        config.prefer_bwrap.unwrap_or(false),
5067    );
5068    if let Some(kind) = sandbox {
5069        println!(
5070            "  {} sandbox available: {}",
5071            "✓".truecolor(aqua_r, aqua_g, aqua_b),
5072            kind
5073        );
5074    } else {
5075        println!(
5076            "  {} sandbox not available (commands run best-effort)",
5077            "!".truecolor(sky_r, sky_g, sky_b)
5078        );
5079    }
5080
5081    println!();
5082    println!(
5083        "{}",
5084        "All checks complete!"
5085            .truecolor(aqua_r, aqua_g, aqua_b)
5086            .bold()
5087    );
5088}
5089
5090const DOCTOR_LEGACY_STATE_ITEMS: &[&str] = &[
5091    "sessions",
5092    "tasks",
5093    "skills",
5094    "slop_ledger",
5095    "trophies",
5096    "catalog",
5097    "review-receipts",
5098    "config.toml",
5099    "settings.toml",
5100    "mcp.json",
5101];
5102const DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT: usize = 20;
5103const DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT: usize = 100;
5104
5105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5106enum DoctorLegacyStateStatus {
5107    PrimaryOnly,
5108    LegacyOnly,
5109    Both,
5110    Absent,
5111}
5112
5113impl DoctorLegacyStateStatus {
5114    fn as_str(self) -> &'static str {
5115        match self {
5116            Self::PrimaryOnly => "primary_only",
5117            Self::LegacyOnly => "legacy_only",
5118            Self::Both => "both",
5119            Self::Absent => "absent",
5120        }
5121    }
5122}
5123
5124#[derive(Debug, Clone)]
5125struct DoctorLegacyStateEntry {
5126    name: &'static str,
5127    primary_path: PathBuf,
5128    legacy_path: PathBuf,
5129    primary_present: bool,
5130    legacy_present: bool,
5131    status: DoctorLegacyStateStatus,
5132}
5133
5134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5135enum DoctorSessionRecoveryStatus {
5136    Isolated,
5137    NoLegacySessions,
5138    MigrationPending,
5139    MigrationIncomplete,
5140    MigrationComplete,
5141    ScanFailed,
5142}
5143
5144impl DoctorSessionRecoveryStatus {
5145    fn as_str(self) -> &'static str {
5146        match self {
5147            Self::Isolated => "isolated",
5148            Self::NoLegacySessions => "no_legacy_sessions",
5149            Self::MigrationPending => "migration_pending",
5150            Self::MigrationIncomplete => "migration_incomplete",
5151            Self::MigrationComplete => "migration_complete",
5152            Self::ScanFailed => "scan_failed",
5153        }
5154    }
5155}
5156
5157#[derive(Debug, Clone)]
5158struct DoctorRecoverableSessionEntry {
5159    name: PathBuf,
5160    source_path: PathBuf,
5161    destination_path: PathBuf,
5162}
5163
5164#[derive(Debug, Clone)]
5165struct DoctorSessionRecoveryReport {
5166    status: DoctorSessionRecoveryStatus,
5167    primary_sessions_path: PathBuf,
5168    legacy_sessions_path: PathBuf,
5169    codewhale_home_is_explicit: bool,
5170    legacy_session_file_count: usize,
5171    already_present_file_count: usize,
5172    recoverable_file_count: usize,
5173    /// Bounded filename/path sample; the total is `recoverable_file_count`.
5174    recoverable: Vec<DoctorRecoverableSessionEntry>,
5175    error: Option<String>,
5176}
5177
5178impl DoctorSessionRecoveryReport {
5179    fn needs_attention(&self) -> bool {
5180        matches!(
5181            self.status,
5182            DoctorSessionRecoveryStatus::MigrationPending
5183                | DoctorSessionRecoveryStatus::MigrationIncomplete
5184                | DoctorSessionRecoveryStatus::ScanFailed
5185        )
5186    }
5187}
5188
5189fn doctor_legacy_state_status(
5190    primary_present: bool,
5191    legacy_present: bool,
5192) -> DoctorLegacyStateStatus {
5193    match (primary_present, legacy_present) {
5194        (true, false) => DoctorLegacyStateStatus::PrimaryOnly,
5195        (false, true) => DoctorLegacyStateStatus::LegacyOnly,
5196        (true, true) => DoctorLegacyStateStatus::Both,
5197        (false, false) => DoctorLegacyStateStatus::Absent,
5198    }
5199}
5200
5201fn doctor_state_roots() -> (PathBuf, PathBuf) {
5202    let code_home =
5203        codewhale_config::codewhale_home().unwrap_or_else(|_| PathBuf::from("~/.codewhale"));
5204    let legacy_home = if codewhale_config::codewhale_home_is_explicit() {
5205        code_home.join(codewhale_config::LEGACY_APP_DIR)
5206    } else {
5207        codewhale_config::legacy_deepseek_home().unwrap_or_else(|_| PathBuf::from("~/.deepseek"))
5208    };
5209    (code_home, legacy_home)
5210}
5211
5212fn doctor_legacy_state_report(
5213    primary_root: &Path,
5214    legacy_root: &Path,
5215) -> Vec<DoctorLegacyStateEntry> {
5216    DOCTOR_LEGACY_STATE_ITEMS
5217        .iter()
5218        .copied()
5219        .map(|name| {
5220            let primary_path = primary_root.join(name);
5221            let legacy_path = legacy_root.join(name);
5222            let primary_present = primary_path.exists();
5223            let legacy_present = legacy_path.exists();
5224            let status = doctor_legacy_state_status(primary_present, legacy_present);
5225            DoctorLegacyStateEntry {
5226                name,
5227                primary_path,
5228                legacy_path,
5229                primary_present,
5230                legacy_present,
5231                status,
5232            }
5233        })
5234        .collect()
5235}
5236
5237/// Compare legacy and primary session filenames without opening session files.
5238///
5239/// This is deliberately separate from `SessionManager::default_location()`:
5240/// constructing the manager can trigger the additive legacy migration, while
5241/// doctor must remain a read-only diagnostic. Session history is stored as
5242/// top-level JSON files. Directories (including `checkpoints`) and symlinks
5243/// observed during the scan are ignored, so the diagnostic does not
5244/// intentionally traverse checkpoint internals or link targets. These checks
5245/// are best-effort observations, not a race-free no-follow guarantee.
5246/// A matching filename is only a regular-file counterpart check: doctor does
5247/// not parse or compare session descriptors.
5248fn doctor_session_recovery_report(
5249    primary_root: &Path,
5250    legacy_root: &Path,
5251    codewhale_home_is_explicit: bool,
5252) -> DoctorSessionRecoveryReport {
5253    let primary_sessions_path = primary_root.join("sessions");
5254    let legacy_sessions_path = legacy_root.join("sessions");
5255    let mut report = DoctorSessionRecoveryReport {
5256        status: DoctorSessionRecoveryStatus::NoLegacySessions,
5257        primary_sessions_path,
5258        legacy_sessions_path,
5259        codewhale_home_is_explicit,
5260        legacy_session_file_count: 0,
5261        already_present_file_count: 0,
5262        recoverable_file_count: 0,
5263        recoverable: Vec::new(),
5264        error: None,
5265    };
5266
5267    if codewhale_home_is_explicit {
5268        report.status = DoctorSessionRecoveryStatus::Isolated;
5269        return report;
5270    }
5271
5272    let legacy_root_is_present =
5273        match doctor_session_directory_is_safe(legacy_root, "legacy state root") {
5274            Ok(present) => present,
5275            Err(error) => {
5276                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5277                report.error = Some(error);
5278                return report;
5279            }
5280        };
5281    if !legacy_root_is_present {
5282        return report;
5283    }
5284    if let Err(error) = doctor_session_directory_is_safe(primary_root, "primary state root") {
5285        report.status = DoctorSessionRecoveryStatus::ScanFailed;
5286        report.error = Some(error);
5287        return report;
5288    }
5289
5290    let legacy_sessions_are_present = match doctor_session_directory_is_safe(
5291        &report.legacy_sessions_path,
5292        "legacy sessions root",
5293    ) {
5294        Ok(present) => present,
5295        Err(error) => {
5296            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5297            report.error = Some(error);
5298            return report;
5299        }
5300    };
5301    if !legacy_sessions_are_present {
5302        return report;
5303    }
5304    let primary_sessions_are_present = match doctor_session_directory_is_safe(
5305        &report.primary_sessions_path,
5306        "primary sessions root",
5307    ) {
5308        Ok(present) => present,
5309        Err(error) => {
5310            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5311            report.error = Some(error);
5312            return report;
5313        }
5314    };
5315
5316    let entries = match std::fs::read_dir(&report.legacy_sessions_path) {
5317        Ok(entries) => entries,
5318        Err(err) => {
5319            report.status = DoctorSessionRecoveryStatus::ScanFailed;
5320            report.error = Some(format!(
5321                "could not inspect legacy session filenames at {}: {err}",
5322                crate::utils::display_path(&report.legacy_sessions_path)
5323            ));
5324            return report;
5325        }
5326    };
5327
5328    for entry in entries {
5329        let entry = match entry {
5330            Ok(entry) => entry,
5331            Err(err) => {
5332                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5333                report.error = Some(format!(
5334                    "could not inspect an entry under {}: {err}",
5335                    crate::utils::display_path(&report.legacy_sessions_path)
5336                ));
5337                return report;
5338            }
5339        };
5340        let file_type = match entry.file_type() {
5341            Ok(file_type) => file_type,
5342            Err(err) => {
5343                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5344                report.error = Some(format!(
5345                    "could not inspect legacy session entry metadata under {}: {err}",
5346                    crate::utils::display_path(&report.legacy_sessions_path)
5347                ));
5348                return report;
5349            }
5350        };
5351        if !file_type.is_file() || entry.path().extension().is_none_or(|ext| ext != "json") {
5352            continue;
5353        }
5354
5355        report.legacy_session_file_count += 1;
5356        let name = PathBuf::from(entry.file_name());
5357        let destination_path = report.primary_sessions_path.join(&name);
5358        match std::fs::symlink_metadata(&destination_path) {
5359            Ok(metadata) if metadata.file_type().is_file() => {
5360                report.already_present_file_count += 1;
5361            }
5362            Ok(metadata) => {
5363                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5364                let shape = if metadata.file_type().is_symlink() {
5365                    "destination session entry is a symlink"
5366                } else {
5367                    "destination session entry is not a regular file"
5368                };
5369                report.error = Some(format!(
5370                    "could not inspect destination session metadata at {}: {shape}",
5371                    crate::utils::display_path(&destination_path)
5372                ));
5373                return report;
5374            }
5375            Err(err) if err.kind() == io::ErrorKind::NotFound => {
5376                report.recoverable_file_count += 1;
5377                record_doctor_recoverable_session(
5378                    &mut report.recoverable,
5379                    DoctorRecoverableSessionEntry {
5380                        source_path: entry.path(),
5381                        destination_path,
5382                        name,
5383                    },
5384                );
5385            }
5386            Err(err) => {
5387                report.status = DoctorSessionRecoveryStatus::ScanFailed;
5388                report.error = Some(format!(
5389                    "could not inspect destination metadata at {}: {err}",
5390                    crate::utils::display_path(&destination_path)
5391                ));
5392                return report;
5393            }
5394        }
5395    }
5396
5397    report.status = if report.legacy_session_file_count == 0 {
5398        DoctorSessionRecoveryStatus::NoLegacySessions
5399    } else if report.recoverable_file_count == 0 {
5400        DoctorSessionRecoveryStatus::MigrationComplete
5401    } else if primary_sessions_are_present {
5402        DoctorSessionRecoveryStatus::MigrationIncomplete
5403    } else {
5404        DoctorSessionRecoveryStatus::MigrationPending
5405    };
5406    report
5407}
5408
5409/// Validate a session-state directory from observed metadata.
5410///
5411/// `doctor` only compares top-level filenames. It rejects a state-root or
5412/// sessions-root symlink observed during inspection rather than using it for a
5413/// recovery suggestion. This is a best-effort observation, not a race-free
5414/// no-follow guarantee. Missing paths are normal on a fresh install and are
5415/// reported as `false`.
5416fn doctor_session_directory_is_safe(path: &Path, label: &str) -> std::result::Result<bool, String> {
5417    let metadata = match std::fs::symlink_metadata(path) {
5418        Ok(metadata) => metadata,
5419        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
5420        Err(error) => {
5421            return Err(format!(
5422                "could not inspect {label} at {}: {error}",
5423                crate::utils::display_path(path)
5424            ));
5425        }
5426    };
5427    if metadata.file_type().is_symlink() {
5428        return Err(format!(
5429            "could not inspect {label} at {}: path is a symlink",
5430            crate::utils::display_path(path)
5431        ));
5432    }
5433    if !metadata.file_type().is_dir() {
5434        return Err(format!(
5435            "could not inspect {label} at {}: path is not a directory",
5436            crate::utils::display_path(path)
5437        ));
5438    }
5439    Ok(true)
5440}
5441
5442/// Keep the report bounded while preserving a deterministic, lexical sample.
5443/// `read_dir` order is platform- and filesystem-dependent, so retaining the
5444/// first entries encountered would make the JSON and human receipts drift.
5445fn record_doctor_recoverable_session(
5446    recoverable: &mut Vec<DoctorRecoverableSessionEntry>,
5447    entry: DoctorRecoverableSessionEntry,
5448) {
5449    let insert_at = recoverable
5450        .binary_search_by(|existing| existing.name.cmp(&entry.name))
5451        .unwrap_or_else(|index| index);
5452    if recoverable.len() == DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
5453        && insert_at == recoverable.len()
5454    {
5455        return;
5456    }
5457    recoverable.insert(insert_at, entry);
5458    if recoverable.len() > DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
5459        recoverable.pop();
5460    }
5461}
5462
5463fn legacy_state_needs_attention(entry: &DoctorLegacyStateEntry) -> bool {
5464    entry.name != "sessions"
5465        && matches!(
5466            entry.status,
5467            DoctorLegacyStateStatus::LegacyOnly | DoctorLegacyStateStatus::Both
5468        )
5469}
5470
5471fn print_doctor_legacy_state_report(
5472    report: &[DoctorLegacyStateEntry],
5473    session_recovery: &DoctorSessionRecoveryReport,
5474    ok_rgb: (u8, u8, u8),
5475    warn_rgb: (u8, u8, u8),
5476) {
5477    use colored::Colorize;
5478
5479    let attention: Vec<_> = report
5480        .iter()
5481        .filter(|entry| legacy_state_needs_attention(entry))
5482        .collect();
5483    if attention.is_empty()
5484        && !session_recovery.needs_attention()
5485        && session_recovery.status != DoctorSessionRecoveryStatus::Isolated
5486    {
5487        println!(
5488            "  {} legacy state: no known .deepseek entries need migration",
5489            "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5490        );
5491    } else if !attention.is_empty() {
5492        println!(
5493            "  {} legacy state needs review:",
5494            "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5495        );
5496        for entry in attention {
5497            match entry.status {
5498                DoctorLegacyStateStatus::LegacyOnly => {
5499                    println!(
5500                        "    {} {} exists but {} is missing",
5501                        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5502                        crate::utils::display_path(&entry.legacy_path),
5503                        crate::utils::display_path(&entry.primary_path),
5504                    );
5505                }
5506                DoctorLegacyStateStatus::Both => {
5507                    println!(
5508                        "    {} {} exists alongside primary {}; legacy data may still need review",
5509                        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5510                        crate::utils::display_path(&entry.legacy_path),
5511                        crate::utils::display_path(&entry.primary_path),
5512                    );
5513                }
5514                DoctorLegacyStateStatus::PrimaryOnly | DoctorLegacyStateStatus::Absent => {}
5515            }
5516        }
5517        println!(
5518            "    Start Codewhale once to trigger safe migration where available, then rerun `codewhale doctor`."
5519        );
5520    }
5521
5522    print_doctor_session_recovery_report(session_recovery, ok_rgb, warn_rgb);
5523}
5524
5525fn print_doctor_session_recovery_report(
5526    report: &DoctorSessionRecoveryReport,
5527    ok_rgb: (u8, u8, u8),
5528    warn_rgb: (u8, u8, u8),
5529) {
5530    use colored::Colorize;
5531
5532    match report.status {
5533        DoctorSessionRecoveryStatus::Isolated => {
5534            println!(
5535                "  {} legacy sessions: ambient ~/.deepseek/sessions was not inspected because CODEWHALE_HOME is set",
5536                "·".dimmed()
5537            );
5538            println!(
5539                "    This preserves the explicit home boundary. To inspect the default home, use a separate shell with CODEWHALE_HOME unset and rerun `codewhale doctor`."
5540            );
5541        }
5542        DoctorSessionRecoveryStatus::NoLegacySessions => {
5543            println!(
5544                "  {} legacy sessions: no top-level session JSON files found",
5545                "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5546            );
5547        }
5548        DoctorSessionRecoveryStatus::MigrationComplete => {
5549            println!(
5550                "  {} legacy sessions: all {} filename(s) have regular-file counterparts under {}; descriptor contents were not compared and legacy originals remain preserved",
5551                "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2),
5552                report.legacy_session_file_count,
5553                crate::utils::display_path(&report.primary_sessions_path),
5554            );
5555        }
5556        DoctorSessionRecoveryStatus::MigrationPending
5557        | DoctorSessionRecoveryStatus::MigrationIncomplete => {
5558            let label = if report.status == DoctorSessionRecoveryStatus::MigrationIncomplete {
5559                "migration is incomplete"
5560            } else {
5561                "migration has not completed"
5562            };
5563            println!(
5564                "  {} legacy sessions: {label}; {} recoverable file(s) are absent from {}",
5565                "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5566                report.recoverable_file_count,
5567                crate::utils::display_path(&report.primary_sessions_path),
5568            );
5569            for entry in report
5570                .recoverable
5571                .iter()
5572                .take(DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT)
5573            {
5574                println!(
5575                    "    {} {} -> {}",
5576                    "·".dimmed(),
5577                    crate::utils::display_path(&entry.source_path),
5578                    crate::utils::display_path(&entry.destination_path),
5579                );
5580            }
5581            if report.recoverable_file_count > DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT {
5582                println!(
5583                    "    · {} more filename(s); `codewhale doctor --json` includes a bounded metadata-only sample",
5584                    report.recoverable_file_count - DOCTOR_SESSION_RECOVERY_HUMAN_SAMPLE_LIMIT
5585                );
5586            }
5587            println!("    Safe recovery:");
5588            println!(
5589                "      1. Back up {} and {} (if present).",
5590                crate::utils::display_path(&report.legacy_sessions_path),
5591                crate::utils::display_path(&report.primary_sessions_path),
5592            );
5593            println!(
5594                "      2. Close other Codewhale processes, then run `codewhale sessions`; migration adds only missing files, never overwrites primary files, and leaves legacy originals in place."
5595            );
5596            println!(
5597                "      3. Rerun `codewhale doctor`. If filenames remain, keep both backups and report only the listed source/destination names."
5598            );
5599        }
5600        DoctorSessionRecoveryStatus::ScanFailed => {
5601            println!(
5602                "  {} legacy sessions: recovery diagnostic could not complete",
5603                "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5604            );
5605            if let Some(error) = report.error.as_deref() {
5606                println!("    {error}");
5607            }
5608            println!(
5609                "    Keep both session directories unchanged, back them up, fix path permissions or shape, and rerun `codewhale doctor` before attempting migration."
5610            );
5611        }
5612    }
5613    if report.status != DoctorSessionRecoveryStatus::Isolated {
5614        println!(
5615            "    Doctor inspected filenames and filesystem metadata only; it did not read chat contents, traverse checkpoints, or modify session files."
5616        );
5617    }
5618}
5619
5620fn doctor_session_recovery_json(report: &DoctorSessionRecoveryReport) -> serde_json::Value {
5621    use serde_json::json;
5622
5623    let recoverable: Vec<_> = report
5624        .recoverable
5625        .iter()
5626        .take(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
5627        .map(|entry| {
5628            json!({
5629                "name": entry.name.display().to_string(),
5630                "source_path": entry.source_path.display().to_string(),
5631                "destination_path": entry.destination_path.display().to_string(),
5632            })
5633        })
5634        .collect();
5635
5636    json!({
5637        "status": report.status.as_str(),
5638        "needs_attention": report.needs_attention(),
5639        "read_only": true,
5640        "chat_contents_read": false,
5641        "checkpoint_internals_scanned": false,
5642        "session_descriptors_compared": false,
5643        "counterpart_check": "top_level_filename_and_regular_file_only",
5644        "codewhale_home_is_explicit": report.codewhale_home_is_explicit,
5645        "legacy_sessions_path": report.legacy_sessions_path.display().to_string(),
5646        "primary_sessions_path": report.primary_sessions_path.display().to_string(),
5647        "legacy_session_file_count": report.legacy_session_file_count,
5648        "already_present_file_count": report.already_present_file_count,
5649        "recoverable_file_count": report.recoverable_file_count,
5650        "recoverable_files": recoverable,
5651        "recoverable_files_truncated": report.recoverable_file_count > report.recoverable.len(),
5652        "error": report.error,
5653        "recovery_command": if report.needs_attention() && report.status != DoctorSessionRecoveryStatus::ScanFailed {
5654            Some("codewhale sessions")
5655        } else {
5656            None
5657        },
5658    })
5659}
5660
5661fn doctor_legacy_state_json(
5662    primary_root: &Path,
5663    legacy_root: &Path,
5664    report: &[DoctorLegacyStateEntry],
5665    session_recovery: &DoctorSessionRecoveryReport,
5666) -> serde_json::Value {
5667    use serde_json::json;
5668
5669    let legacy_only = report
5670        .iter()
5671        .filter(|entry| entry.status == DoctorLegacyStateStatus::LegacyOnly)
5672        .count();
5673    let both = report
5674        .iter()
5675        .filter(|entry| entry.status == DoctorLegacyStateStatus::Both)
5676        .count();
5677    let entries: Vec<_> = report
5678        .iter()
5679        .map(|entry| {
5680            json!({
5681                "name": entry.name,
5682                "primary_path": entry.primary_path.display().to_string(),
5683                "legacy_path": entry.legacy_path.display().to_string(),
5684                "primary_present": entry.primary_present,
5685                "legacy_present": entry.legacy_present,
5686                "status": entry.status.as_str(),
5687            })
5688        })
5689        .collect();
5690
5691    json!({
5692        "primary_root": primary_root.display().to_string(),
5693        "legacy_root": legacy_root.display().to_string(),
5694        "needs_attention": report.iter().any(legacy_state_needs_attention) || session_recovery.needs_attention(),
5695        "legacy_only_count": legacy_only,
5696        "dual_present_count": both,
5697        "session_recovery": doctor_session_recovery_json(session_recovery),
5698        "entries": entries,
5699    })
5700}
5701
5702fn doctor_setup_state(
5703    config: &Config,
5704    workspace: &Path,
5705) -> (codewhale_config::SetupState, &'static str) {
5706    if let Ok(Some(state)) = codewhale_config::SetupState::load() {
5707        return (state, "persisted");
5708    }
5709
5710    (
5711        codewhale_config::SetupState::derive_inherited(&doctor_inherited_setup_facts(
5712            config, workspace,
5713        )),
5714        "derived",
5715    )
5716}
5717
5718fn doctor_inherited_setup_facts(
5719    config: &Config,
5720    workspace: &Path,
5721) -> codewhale_config::InheritedConfigFacts {
5722    let user_constitution = codewhale_config::UserConstitution::load().ok();
5723    let user_constitution_validity = user_constitution.as_ref().map_or(
5724        codewhale_config::ConstitutionValidity::Unknown,
5725        codewhale_config::UserConstitutionLoad::validity,
5726    );
5727    let has_user_constitution = user_constitution
5728        .as_ref()
5729        .is_some_and(|loaded| !matches!(loaded, codewhale_config::UserConstitutionLoad::Missing));
5730    let has_expert_override = codewhale_config::codewhale_home()
5731        .ok()
5732        .map(|home| home.join(Path::new(crate::prompts::CONSTITUTION_OVERRIDE_FILE)))
5733        .is_some_and(|path| path.exists());
5734
5735    codewhale_config::InheritedConfigFacts {
5736        language: None,
5737        has_provider_route: !config.default_model().trim().is_empty(),
5738        has_credentials_or_local_runtime: doctor_has_credentials_or_local_runtime(config),
5739        trust_chosen: !crate::tui::onboarding::needs_trust(workspace),
5740        has_expert_override,
5741        has_user_constitution,
5742        user_constitution_validity,
5743    }
5744}
5745
5746fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
5747    resolve_credential_diagnostic(config)
5748        .availability
5749        .certifies_ready()
5750}
5751
5752fn print_doctor_setup_report(
5753    config: &Config,
5754    workspace: &Path,
5755    state: &codewhale_config::SetupState,
5756    source: &str,
5757    ok_rgb: (u8, u8, u8),
5758    warn_rgb: (u8, u8, u8),
5759) {
5760    use colored::Colorize;
5761
5762    let credential = resolve_credential_diagnostic(config);
5763    // Setup completion is persisted independently from credential probing.
5764    // Ordinary doctor deliberately does not read environment values or the
5765    // durable secret store, so `not_probed` must not erase a completed lane.
5766    let first_run_ready = state.first_run_ready();
5767    let update_ready = state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION);
5768    let operate_ready = state.operate_ready();
5769    let first_run_icon = if first_run_ready {
5770        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5771    } else {
5772        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5773    };
5774    let update_icon = if update_ready {
5775        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5776    } else {
5777        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5778    };
5779    let operate_icon = if operate_ready {
5780        "✓".truecolor(ok_rgb.0, ok_rgb.1, ok_rgb.2)
5781    } else {
5782        "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2)
5783    };
5784
5785    println!();
5786    println!("{}", "Setup State:".bold());
5787    println!("  · source: {source}");
5788    println!(
5789        "  · credential: source={}, availability={}",
5790        doctor_api_key_source_label(credential.source),
5791        credential.availability.label()
5792    );
5793    println!(
5794        "  {first_run_icon} first-run: {}",
5795        doctor_ready_label(first_run_ready)
5796    );
5797    println!(
5798        "  {update_icon} update checkpoint {}: {}",
5799        crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
5800        doctor_ready_label(update_ready)
5801    );
5802    println!(
5803        "  {operate_icon} operate/fleet: {}",
5804        doctor_ready_label(operate_ready)
5805    );
5806    println!(
5807        "  · constitution autonomy: {} (guidance only)",
5808        doctor_constitution_autonomy_preference_id()
5809    );
5810    println!(
5811        "  · runtime posture: {}",
5812        doctor_runtime_posture_line(config, workspace)
5813    );
5814    let consistency = doctor_setup_consistency(state, source);
5815    if consistency["status"] == "inconsistent" {
5816        let issues = consistency["issues"]
5817            .as_array()
5818            .map(|issues| {
5819                issues
5820                    .iter()
5821                    .filter_map(serde_json::Value::as_str)
5822                    .collect::<Vec<_>>()
5823                    .join(", ")
5824            })
5825            .unwrap_or_default();
5826        println!(
5827            "  {} consistency: half-applied setup detected ({issues}) — {}",
5828            "!".truecolor(warn_rgb.0, warn_rgb.1, warn_rgb.2),
5829            consistency["repair"].as_str().unwrap_or("/setup"),
5830        );
5831    }
5832    println!(
5833        "  · 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)"
5834    );
5835    for step in codewhale_config::SetupStep::ALL {
5836        let entry = state.steps.get(&step);
5837        let required = entry.is_some_and(|entry| entry.required);
5838        let version = entry.and_then(|entry| entry.version.as_deref());
5839        let result = entry.and_then(|entry| entry.result.as_deref());
5840        let required_label = if required { "required" } else { "optional" };
5841        let version_label = version.unwrap_or("unversioned");
5842        let result_label = result.unwrap_or("no result");
5843        println!(
5844            "    · {}: {} ({required_label}, {version_label}, {result_label})",
5845            setup_step_id(step),
5846            setup_status_id(state.status(step))
5847        );
5848    }
5849}
5850
5851/// #5098: print every profile id that exists in more than one roster layer
5852/// so a personal/config edit that loses to project is visible without
5853/// opening `/fleet`.
5854fn print_doctor_fleet_roster_layers(config: &Config, workspace: &Path) {
5855    use colored::Colorize;
5856
5857    let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
5858    println!();
5859    println!("{}", "Fleet roster layers:".bold());
5860    let lines = roster.doctor_layer_lines();
5861    if lines.is_empty() {
5862        println!("  · no profile id is defined in more than one layer");
5863        return;
5864    }
5865    for line in lines {
5866        if let Some(layer) = line.strip_prefix("  ") {
5867            println!("      {layer}");
5868        } else {
5869            println!("  · {line}");
5870        }
5871    }
5872}
5873
5874fn doctor_ready_label(ready: bool) -> &'static str {
5875    if ready { "ready" } else { "needs action" }
5876}
5877
5878/// Detect half-applied setup persistence (#3410).
5879///
5880/// The setup transaction writes `constitution.json` and `setup_state.json`
5881/// together, so a persisted state that points at a user-global constitution
5882/// which is missing or unusable on disk means a write was interrupted or a
5883/// file was removed out-of-band. Stale `.tmp*` files in `$CODEWHALE_HOME`
5884/// are the other fingerprint of an interrupted atomic write.
5885fn doctor_setup_consistency(
5886    state: &codewhale_config::SetupState,
5887    source: &str,
5888) -> serde_json::Value {
5889    use serde_json::json;
5890
5891    let mut issues: Vec<&'static str> = Vec::new();
5892
5893    if source == "persisted"
5894        && matches!(
5895            state.constitution_source,
5896            codewhale_config::ConstitutionSource::UserGlobal
5897        )
5898    {
5899        match codewhale_config::UserConstitution::load() {
5900            Ok(codewhale_config::UserConstitutionLoad::Missing) => {
5901                issues.push("setup_state_points_at_missing_user_constitution");
5902            }
5903            Ok(codewhale_config::UserConstitutionLoad::Empty) => {
5904                issues.push("user_constitution_empty");
5905            }
5906            Ok(codewhale_config::UserConstitutionLoad::Invalid(_)) => {
5907                issues.push("user_constitution_invalid");
5908            }
5909            Ok(codewhale_config::UserConstitutionLoad::Unreadable(_)) | Err(_) => {
5910                issues.push("user_constitution_unreadable");
5911            }
5912            Ok(codewhale_config::UserConstitutionLoad::Loaded(_)) => {}
5913        }
5914    }
5915
5916    if doctor_home_has_stale_setup_temp_files() {
5917        issues.push("stale_setup_temp_files_in_codewhale_home");
5918    }
5919
5920    json!({
5921        "status": if issues.is_empty() { "consistent" } else { "inconsistent" },
5922        "issues": issues,
5923        "repair": "/constitution to rebuild standing law, /setup to re-run the checkpoint",
5924    })
5925}
5926
5927fn doctor_home_has_stale_setup_temp_files() -> bool {
5928    let Ok(home) = codewhale_config::codewhale_home() else {
5929        return false;
5930    };
5931    let Ok(entries) = std::fs::read_dir(&home) else {
5932        return false;
5933    };
5934    entries.flatten().any(|entry| {
5935        entry.file_name().to_string_lossy().starts_with(".tmp")
5936            && entry.file_type().is_ok_and(|kind| kind.is_file())
5937    })
5938}
5939
5940fn doctor_constitution_autonomy_preference() -> codewhale_config::AutonomyPreference {
5941    codewhale_config::UserConstitution::load()
5942        .ok()
5943        .and_then(|load| {
5944            load.constitution()
5945                .map(|constitution| constitution.autonomy_preference)
5946        })
5947        .unwrap_or(codewhale_config::AutonomyPreference::Unspecified)
5948}
5949
5950fn doctor_constitution_autonomy_preference_id() -> &'static str {
5951    autonomy_preference_id(doctor_constitution_autonomy_preference())
5952}
5953
5954fn autonomy_preference_id(preference: codewhale_config::AutonomyPreference) -> &'static str {
5955    match preference {
5956        codewhale_config::AutonomyPreference::Unspecified => "unspecified",
5957        codewhale_config::AutonomyPreference::Cautious => "cautious",
5958        codewhale_config::AutonomyPreference::Balanced => "balanced",
5959        codewhale_config::AutonomyPreference::Autonomous => "autonomous",
5960    }
5961}
5962
5963fn doctor_runtime_default_mode() -> (String, &'static str) {
5964    match crate::settings::Settings::load_read_only() {
5965        Ok(settings) => (settings.default_mode, "settings"),
5966        Err(_) => (crate::settings::Settings::default().default_mode, "default"),
5967    }
5968}
5969
5970/// TUI settings posture used when `config.approval_policy` is unset.
5971/// Doctor must surface this separately so a saved Full Access baseline is not
5972/// misreported as the config default `approval_policy=on-request`.
5973fn doctor_runtime_permission_posture() -> (String, &'static str) {
5974    match crate::settings::Settings::load_read_only() {
5975        Ok(settings) => match settings.permission_posture {
5976            Some(posture) => (posture, "settings"),
5977            None => ("unset".to_string(), "default"),
5978        },
5979        Err(_) => ("unset".to_string(), "default"),
5980    }
5981}
5982
5983fn doctor_runtime_posture_line(config: &Config, workspace: &Path) -> String {
5984    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
5985    let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
5986    let approval = config.approval_policy.as_deref().unwrap_or("on-request");
5987    let approval_source = if config.approval_policy.is_some() {
5988        "config"
5989    } else {
5990        "default"
5991    };
5992    let allow_shell = config.interactive_allow_shell();
5993    let allow_shell_source = if config.allow_shell.is_some() {
5994        "config"
5995    } else {
5996        "interactive default"
5997    };
5998    let sandbox = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
5999    let sandbox_source = if config.sandbox_mode.is_some() {
6000        "config"
6001    } else {
6002        "default"
6003    };
6004    let network = config
6005        .network
6006        .as_ref()
6007        .map_or("prompt", |policy| policy.default.as_str());
6008    let network_source = if config.network.is_some() {
6009        "config"
6010    } else {
6011        "default"
6012    };
6013    let trust = if crate::tui::onboarding::needs_trust(workspace) {
6014        "workspace not elevated"
6015    } else {
6016        "workspace trusted"
6017    };
6018    let (telemetry_on, telemetry_source) = doctor_runtime_telemetry(config);
6019    let telemetry = if telemetry_on { "on" } else { "off" };
6020
6021    format!(
6022        "default_mode={default_mode} ({default_mode_source}), permission_posture={permission_posture} ({permission_posture_source}), approval_policy={approval} ({approval_source}), allow_shell={allow_shell} ({allow_shell_source}), sandbox={sandbox} ({sandbox_source}), network.default={network} ({network_source}), telemetry={telemetry} ({telemetry_source}), trust={trust}"
6023    )
6024}
6025
6026/// Resolved telemetry consent and where it came from (#5441).
6027///
6028/// Telemetry ships ON by default, and no posture surface reported that — a
6029/// user who never opted in saw nothing saying "telemetry: on (default)".
6030/// Truth change only: the resolution itself is [`codewhale_config`]'s.
6031fn doctor_runtime_telemetry(config: &Config) -> (bool, &'static str) {
6032    let (on, source) = codewhale_config::resolved_telemetry_consent(config.telemetry);
6033    (on, source.as_str())
6034}
6035
6036fn doctor_operate_fleet_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6037    use serde_json::json;
6038
6039    let provider = config.api_provider();
6040    // Doctor reports configured routing posture only. In particular it must
6041    // never consume an external-file grant merely to label Fleet readiness.
6042    let credential = resolve_credential_diagnostic(config);
6043    let has_credentials_or_local = credential.availability.certifies_ready();
6044    let subagents_enabled = config.subagents_enabled_for_provider(provider);
6045    let disabled_reason = if subagents_enabled {
6046        None
6047    } else {
6048        Some(
6049            config
6050                .subagents_disabled_reason()
6051                .unwrap_or("disabled for active provider"),
6052        )
6053    };
6054    let max_subagents = config.max_subagents_for_provider(provider);
6055    let launch_concurrency = config.launch_concurrency_for_provider(provider);
6056    let max_admitted = config.max_admitted_subagents_for_provider(provider);
6057    let max_spawn_depth = config.subagent_max_spawn_depth_for_provider(provider);
6058    let roster = crate::fleet::roster::FleetRoster::load(&config.fleet_config(), workspace);
6059    let mut built_in_members = 0usize;
6060    let mut config_members = 0usize;
6061    let mut personal_members = 0usize;
6062    let mut workspace_members = 0usize;
6063    for member in roster.members() {
6064        match member.origin {
6065            crate::fleet::roster::ProfileOrigin::BuiltIn => built_in_members += 1,
6066            crate::fleet::roster::ProfileOrigin::Config => config_members += 1,
6067            crate::fleet::roster::ProfileOrigin::Personal => personal_members += 1,
6068            crate::fleet::roster::ProfileOrigin::Workspace => workspace_members += 1,
6069        }
6070    }
6071    let roster_members = roster.members().len();
6072    let custom_members = config_members + personal_members + workspace_members;
6073    let roster_ready = roster_members > 0;
6074    let runtime_ready =
6075        subagents_enabled && max_subagents > 0 && launch_concurrency > 0 && max_spawn_depth > 0;
6076    let multi_layer: Vec<serde_json::Value> = roster
6077        .multi_layer_report()
6078        .into_iter()
6079        .map(|entry| {
6080            json!({
6081                "id": entry.id,
6082                "effective": entry.effective.to_string(),
6083                "effective_path": entry.effective_path.display().to_string(),
6084                "layers": entry
6085                    .layers
6086                    .iter()
6087                    .map(|layer| {
6088                        json!({
6089                            "origin": layer.origin.to_string(),
6090                            "path": layer.source.display().to_string(),
6091                            "wins": layer.wins,
6092                        })
6093                    })
6094                    .collect::<Vec<_>>(),
6095            })
6096        })
6097        .collect();
6098
6099    json!({
6100        "ready": has_credentials_or_local && runtime_ready && roster_ready,
6101        "provider": {
6102            "id": config.provider_identity_for(provider),
6103            "auth": {
6104                "present_or_local": has_credentials_or_local,
6105                "source": doctor_api_key_source_label(credential.source),
6106                "availability": credential.availability.label(),
6107            },
6108        },
6109        "worker_runtime": {
6110            "ready": runtime_ready,
6111            "enabled": subagents_enabled,
6112            "disabled_reason": disabled_reason,
6113            "max_subagents": max_subagents,
6114            "launch_concurrency": launch_concurrency,
6115            "max_admitted": max_admitted,
6116            "max_spawn_depth": max_spawn_depth,
6117            "host_enforced_workflow_receipts": true,
6118        },
6119        "roster": {
6120            "ready": roster_ready,
6121            "total": roster_members,
6122            "built_in": built_in_members,
6123            "config": config_members,
6124            "personal": personal_members,
6125            "workspace": workspace_members,
6126            "custom": custom_members,
6127            "starter_roster_available": built_in_members > 0,
6128            "readiness_rule": "built-in starter roster or custom roster",
6129            "multi_layer": multi_layer,
6130        },
6131        "concurrency": {
6132            "launch_concurrency": launch_concurrency,
6133            "max_subagents": max_subagents,
6134            "max_admitted": max_admitted,
6135            "plan_limit_probed": false,
6136        },
6137    })
6138}
6139
6140fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
6141    use serde_json::json;
6142
6143    let provider = config.api_provider();
6144    let credential = resolve_credential_diagnostic(config);
6145    let auth_present_or_local = credential.availability.certifies_ready();
6146    let credential_help = provider.credential_help();
6147    let credential_url = credential_help
6148        .credential_url
6149        .map(crate::doctor::structural_url_authority);
6150    let credential_docs_url = credential_help
6151        .docs_url
6152        .map(crate::doctor::structural_url_authority);
6153
6154    json!({
6155        "provider": {
6156            "id": config.provider_identity_for(provider),
6157            "display": provider.display_name(),
6158        },
6159        "model": {
6160            "resolved": config.default_model(),
6161        },
6162        "auth": {
6163            "present_or_local": auth_present_or_local,
6164            "source": doctor_api_key_source_label(credential.source),
6165            "availability": credential.availability.label(),
6166            "env_vars": provider.env_vars(),
6167            "credential_mode": credential_help.acquisition.as_str(),
6168            "credential_url": credential_url,
6169            "credential_docs_url": credential_docs_url,
6170            "credential_guidance": credential_help.guidance,
6171            "oauth_only": credential_help.acquisition
6172                == codewhale_config::provider::CredentialAcquisition::OAuth,
6173        },
6174        "health": {
6175            "live_validation": false,
6176            "next_action": if auth_present_or_local {
6177                "/model"
6178            } else {
6179                "/setup provider or /provider setup <name>"
6180            },
6181        },
6182    })
6183}
6184
6185fn doctor_dsh_integration_report(
6186    config: &Config,
6187    workspace: &Path,
6188) -> anyhow::Result<crate::integrations::dsh::DshStatusReport> {
6189    use crate::integrations::dsh;
6190    let paths = dsh::DshPaths::from_process()?;
6191    let detection = dsh::detect::detect(&dsh::DetectEnv::from_process(), &dsh::ProcessRunner);
6192    let identity = dsh::codewhale_route_identity(config, workspace);
6193    dsh::compute_status(
6194        &paths,
6195        detection,
6196        identity,
6197        false,
6198        dsh::bundle_availability_now(),
6199    )
6200}
6201
6202fn doctor_dsh_integration_lines(config: &Config, workspace: &Path) -> Vec<String> {
6203    match doctor_dsh_integration_report(config, workspace) {
6204        Ok(report) => {
6205            let mut lines = vec![
6206                format!("state: {}", report.state.label()),
6207                crate::integrations::dsh::status_line(&report),
6208                format!(
6209                    "owned files: {} (overlay {})",
6210                    crate::utils::display_path(&report.paths_root),
6211                    if report.overlay_present {
6212                        "present"
6213                    } else {
6214                        "absent"
6215                    }
6216                ),
6217            ];
6218            if !report.shadowing_namespaces.is_empty() {
6219                lines.push(format!(
6220                    "dsh settings.yaml sections that can shadow the overlay: {}",
6221                    report.shadowing_namespaces.join(", ")
6222                ));
6223            }
6224            lines
6225        }
6226        Err(error) => vec![format!("unavailable: {error}")],
6227    }
6228}
6229
6230fn doctor_dsh_integration_json(config: &Config, workspace: &Path) -> serde_json::Value {
6231    match doctor_dsh_integration_report(config, workspace) {
6232        Ok(report) => serde_json::json!({
6233            "state": report.state.label(),
6234            "summary": crate::integrations::dsh::status_line(&report),
6235            "dsh_version": report.detection.version,
6236            "compatibility": report.detection.compatibility.label(),
6237            "overlay_present": report.overlay_present,
6238            "shadowing_namespaces": report.shadowing_namespaces,
6239        }),
6240        Err(error) => serde_json::json!({ "state": "unavailable", "error": error.to_string() }),
6241    }
6242}
6243
6244fn doctor_external_credential_consent_statuses(
6245    config: &Config,
6246) -> Vec<codewhale_config::ExternalCredentialConsentStatus> {
6247    [
6248        crate::config::ApiProvider::OpenaiCodex,
6249        crate::config::ApiProvider::Xai,
6250        crate::config::ApiProvider::Deepseek,
6251    ]
6252    .into_iter()
6253    .filter_map(|provider| config.external_credential_consent_status(provider))
6254    .collect()
6255}
6256
6257fn doctor_external_credential_consent_lines(config: &Config) -> Vec<String> {
6258    doctor_external_credential_consent_statuses(config)
6259        .into_iter()
6260        .flat_map(|status| {
6261            let mut lines = vec![
6262                format!(
6263                    "{}: access={}, provider={}, source={}, owner={}, path={}, version={}, state={}, ambient_path_changed={}",
6264                    status.provider,
6265                    status.access.as_str(),
6266                    status.provider,
6267                    status.source.as_str(),
6268                    status.owner,
6269                    codewhale_config::quote_os_path(&status.path),
6270                    status.consent_version,
6271                    status.route_state,
6272                    status.ambient_path_changed,
6273                ),
6274                format!("  semantics: {}", status.semantics),
6275                format!("  revoke: {}", status.revoke_command),
6276            ];
6277            if let Some(warning) = status.ambient_path_warning() {
6278                lines.push(format!("  {warning}"));
6279            }
6280            lines
6281        })
6282        .collect()
6283}
6284
6285fn doctor_external_credential_consent_json(config: &Config) -> serde_json::Value {
6286    serde_json::Value::Array(
6287        doctor_external_credential_consent_statuses(config)
6288            .into_iter()
6289            .map(|status| {
6290                serde_json::json!({
6291                    "provider": status.provider,
6292                    "access": status.access.as_str(),
6293                    "source": status.source.as_str(),
6294                    "owner": status.owner,
6295                    "path": codewhale_config::quote_os_path(&status.path),
6296                    "consent_version": status.consent_version,
6297                    "scope_valid": status.scope_valid,
6298                    "ambient_path_changed": status.ambient_path_changed,
6299                    "ambient_path_warning": status.ambient_path_warning(),
6300                    "route_state": status.route_state,
6301                    "semantics": status.semantics,
6302                    "revoke_command": status.revoke_command,
6303                })
6304            })
6305            .collect(),
6306    )
6307}
6308
6309fn doctor_setup_report_json(config: &Config, workspace: &Path) -> serde_json::Value {
6310    use serde_json::json;
6311
6312    let (state, source) = doctor_setup_state(config, workspace);
6313    let (default_mode, default_mode_source) = doctor_runtime_default_mode();
6314    let (permission_posture, permission_posture_source) = doctor_runtime_permission_posture();
6315    let approval_policy = config.approval_policy.as_deref().unwrap_or("on-request");
6316    let approval_policy_source = if config.approval_policy.is_some() {
6317        "config"
6318    } else {
6319        "default"
6320    };
6321    let allow_shell = config.interactive_allow_shell();
6322    let allow_shell_source = if config.allow_shell.is_some() {
6323        "config"
6324    } else {
6325        "interactive_default"
6326    };
6327    let sandbox_mode = config.sandbox_mode.as_deref().unwrap_or("mode-derived");
6328    let sandbox_mode_source = if config.sandbox_mode.is_some() {
6329        "config"
6330    } else {
6331        "default"
6332    };
6333    let network_default = config
6334        .network
6335        .as_ref()
6336        .map_or("prompt", |policy| policy.default.as_str());
6337    let network_source = if config.network.is_some() {
6338        "config"
6339    } else {
6340        "default"
6341    };
6342    let (telemetry_value, telemetry_source) = doctor_runtime_telemetry(config);
6343    let workspace_trusted = !crate::tui::onboarding::needs_trust(workspace);
6344    let credential = resolve_credential_diagnostic(config);
6345    let credential_ready = credential.availability.certifies_ready();
6346    let steps: Vec<_> = codewhale_config::SetupStep::ALL
6347        .into_iter()
6348        .map(|step| {
6349            let entry = state.steps.get(&step);
6350            json!({
6351                "step": setup_step_id(step),
6352                "status": setup_status_id(state.status(step)),
6353                "required": entry.is_some_and(|entry| entry.required),
6354                "version": entry.and_then(|entry| entry.version.clone()),
6355                "result": entry.and_then(|entry| entry.result.clone()),
6356            })
6357        })
6358        .collect();
6359
6360    json!({
6361        "source": source,
6362        "schema_version": state.schema_version,
6363        "inherited": state.inherited,
6364        "checkpoint_version": crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
6365        "first_run_ready": state.first_run_ready(),
6366        "update_ready": state.update_ready(crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION),
6367        "operate_ready": state.operate_ready(),
6368        "credential": {
6369            "ready": credential_ready,
6370            "source": doctor_api_key_source_label(credential.source),
6371            "availability": credential.availability.label(),
6372        },
6373        "constitution": {
6374            "choice": constitution_choice_id(state.constitution_choice),
6375            "source": constitution_source_id(state.constitution_source),
6376            "validity": constitution_validity_id(state.constitution_validity),
6377            "checkpoint_completed_for": state.constitution_checkpoint_completed_for.clone(),
6378            "language": state.constitution_language.clone(),
6379            "preview_hash_present": state.constitution_preview_hash.is_some(),
6380            "preview_version": state.constitution_preview_version,
6381            "autonomy_preference": doctor_constitution_autonomy_preference_id(),
6382        },
6383        "runtime_posture_source": runtime_posture_source_id(state.runtime_posture_source),
6384        "runtime_posture": {
6385            "source": runtime_posture_source_id(state.runtime_posture_source),
6386            "default_mode": {
6387                "value": default_mode,
6388                "source": default_mode_source,
6389            },
6390            "permission_posture": {
6391                "value": permission_posture,
6392                "source": permission_posture_source,
6393            },
6394            "approval_policy": {
6395                "value": approval_policy,
6396                "source": approval_policy_source,
6397            },
6398            "allow_shell": {
6399                "value": allow_shell,
6400                "source": allow_shell_source,
6401            },
6402            "sandbox_mode": {
6403                "value": sandbox_mode,
6404                "source": sandbox_mode_source,
6405            },
6406            "network_default": {
6407                "value": network_default,
6408                "source": network_source,
6409            },
6410            "telemetry": {
6411                "value": telemetry_value,
6412                "source": telemetry_source,
6413            },
6414            "workspace_trust": {
6415                "trusted": workspace_trusted,
6416                "source": "workspace",
6417            },
6418        },
6419        "provider_model": doctor_provider_model_report_json(config),
6420        "operate_fleet": doctor_operate_fleet_report_json(config, workspace),
6421        "consistency": doctor_setup_consistency(&state, source),
6422        "next_actions": {
6423            "constitution": "/constitution",
6424            "setup_report": "/setup report",
6425            "provider_model": "/setup provider, /provider setup <name>, or /model",
6426            "runtime_posture": "/config",
6427            "operate_fleet": "/setup fleet (readiness), /fleet setup (explicit profile authoring)",
6428            "hotbar": "/setup hotbar",
6429            "tools_mcp": "/setup tools",
6430            "remote_runtime": "/setup remote",
6431            "persistence": "/setup persistence",
6432        },
6433        "steps": steps,
6434    })
6435}
6436
6437fn setup_step_id(step: codewhale_config::SetupStep) -> &'static str {
6438    match step {
6439        codewhale_config::SetupStep::Language => "language",
6440        codewhale_config::SetupStep::ProviderModel => "provider_model",
6441        codewhale_config::SetupStep::TrustSandbox => "trust_sandbox",
6442        codewhale_config::SetupStep::ToolsMcp => "tools_mcp",
6443        codewhale_config::SetupStep::Hotbar => "hotbar",
6444        codewhale_config::SetupStep::RemoteRuntime => "remote_runtime",
6445        codewhale_config::SetupStep::Persistence => "persistence",
6446        codewhale_config::SetupStep::Constitution => "constitution",
6447        codewhale_config::SetupStep::OperateFleet => "operate_fleet",
6448        codewhale_config::SetupStep::Verification => "verification",
6449    }
6450}
6451
6452fn setup_status_id(status: codewhale_config::StepStatus) -> &'static str {
6453    match status {
6454        codewhale_config::StepStatus::NotStarted => "not_started",
6455        codewhale_config::StepStatus::Recommended => "recommended",
6456        codewhale_config::StepStatus::Optional => "optional",
6457        codewhale_config::StepStatus::Deferred => "deferred",
6458        codewhale_config::StepStatus::InProgress => "in_progress",
6459        codewhale_config::StepStatus::Verified => "verified",
6460        codewhale_config::StepStatus::NeedsAction => "needs_action",
6461        codewhale_config::StepStatus::Failed => "failed",
6462        codewhale_config::StepStatus::Skipped => "skipped",
6463    }
6464}
6465
6466fn constitution_choice_id(choice: codewhale_config::ConstitutionChoice) -> &'static str {
6467    match choice {
6468        codewhale_config::ConstitutionChoice::Unset => "unset",
6469        codewhale_config::ConstitutionChoice::Bundled => "bundled",
6470        codewhale_config::ConstitutionChoice::GuidedCustom => "guided_custom",
6471        codewhale_config::ConstitutionChoice::ExpertOverride => "expert_override",
6472        codewhale_config::ConstitutionChoice::Deferred => "deferred",
6473    }
6474}
6475
6476fn constitution_source_id(source: codewhale_config::ConstitutionSource) -> &'static str {
6477    match source {
6478        codewhale_config::ConstitutionSource::Bundled => "bundled",
6479        codewhale_config::ConstitutionSource::UserGlobal => "user_global",
6480        codewhale_config::ConstitutionSource::ExpertOverride => "expert_override",
6481    }
6482}
6483
6484fn constitution_validity_id(validity: codewhale_config::ConstitutionValidity) -> &'static str {
6485    match validity {
6486        codewhale_config::ConstitutionValidity::Unknown => "unknown",
6487        codewhale_config::ConstitutionValidity::Valid => "valid",
6488        codewhale_config::ConstitutionValidity::Invalid => "invalid",
6489        codewhale_config::ConstitutionValidity::Empty => "empty",
6490        codewhale_config::ConstitutionValidity::Unreadable => "unreadable",
6491    }
6492}
6493
6494fn runtime_posture_source_id(source: codewhale_config::RuntimePostureSource) -> &'static str {
6495    match source {
6496        codewhale_config::RuntimePostureSource::Unset => "unset",
6497        codewhale_config::RuntimePostureSource::Inherited => "inherited",
6498        codewhale_config::RuntimePostureSource::Confirmed => "confirmed",
6499    }
6500}
6501
6502/// Emit a bounded, secret-redacted JSON failure when configuration cannot be
6503/// loaded or validated. Invalid configuration must not be forced through the
6504/// normal doctor report because its route/capability facts would be misleading.
6505fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
6506    let safe_message = error
6507        .downcast_ref::<crate::config::SafeConfigDiagnostic>()
6508        .map(ToString::to_string);
6509    let report = serde_json::json!({
6510        "status": "error",
6511        "error": {
6512            "kind": "config_validation",
6513            "message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
6514        },
6515    });
6516    println!("{}", serde_json::to_string_pretty(&report)?);
6517
6518    // Keep stderr generic: the actionable, redacted error is already on
6519    // stdout, and Rust's Result termination must never redisclose a secret.
6520    bail!("doctor configuration validation failed; see JSON output")
6521}
6522
6523/// Machine-readable counterpart to `run_doctor`. This report is always
6524/// structural and offline; live probe flags conflict with `--json`.
6525fn run_doctor_json(
6526    config: &Config,
6527    workspace: &Path,
6528    config_path_override: Option<&Path>,
6529    plugins: &crate::plugins::PluginRegistry,
6530) -> Result<()> {
6531    use serde_json::json;
6532
6533    let doctor_paths = crate::doctor::DoctorPathReport::resolve(config_path_override)?;
6534    let config_path = &doctor_paths.config;
6535    let secret_backend = codewhale_secrets::diagnose_secret_backend();
6536
6537    let credential = resolve_credential_diagnostic(config);
6538
6539    let mcp_config_path = config.mcp_config_path();
6540    let project_mcp_config_path = crate::mcp::workspace_mcp_config_path(workspace);
6541    let mcp_present = mcp_config_path.exists();
6542    let project_mcp_present = project_mcp_config_path.exists();
6543    let mcp_summary = match crate::mcp::load_config_with_workspace_and_plugins(
6544        &mcp_config_path,
6545        workspace,
6546        plugins,
6547    ) {
6548        Ok(cfg) => {
6549            let servers: Vec<serde_json::Value> = cfg
6550                .servers
6551                .iter()
6552                .map(|(name, server)| doctor_mcp_server_json(name, server))
6553                .collect();
6554            json!({
6555                "config_path": mcp_config_path.display().to_string(),
6556                "present": mcp_present,
6557                "project_config_path": project_mcp_config_path.display().to_string(),
6558                "project_present": project_mcp_present,
6559                "probe_scope": "configuration",
6560                "live_health_checked": false,
6561                "servers": servers,
6562            })
6563        }
6564        Err(_) => json!({
6565            "config_path": mcp_config_path.display().to_string(),
6566            "present": mcp_present,
6567            "project_config_path": project_mcp_config_path.display().to_string(),
6568            "project_present": project_mcp_present,
6569            "probe_scope": "configuration",
6570            "live_health_checked": false,
6571            "servers": [],
6572            "error": "configuration_unavailable_details_omitted",
6573        }),
6574    };
6575
6576    let global_skills_dir = config.skills_dir();
6577    let agents_skills_dir = workspace.join(".agents").join("skills");
6578    let local_skills_dir = workspace.join("skills");
6579    let agents_global_skills_dir = crate::skills::agents_global_skills_dir();
6580    // #432: cross-tool skill discovery dirs surface in the JSON
6581    // report so external dashboards can see whether any
6582    // `.opencode/skills/`, `.claude/skills/`, `.cursor/skills/`, or
6583    // global agentskills.io content is contributing to the merged catalogue.
6584    let opencode_skills_dir = workspace.join(".opencode").join("skills");
6585    let claude_skills_dir = workspace.join(".claude").join("skills");
6586    let selected_skills_dir = if agents_skills_dir.exists() {
6587        agents_skills_dir.clone()
6588    } else if local_skills_dir.exists() {
6589        local_skills_dir.clone()
6590    } else if config.skills_dir.is_none()
6591        && let Some(global_agents) = agents_global_skills_dir.as_ref()
6592        && global_agents.exists()
6593    {
6594        global_agents.clone()
6595    } else {
6596        global_skills_dir.clone()
6597    };
6598    let agents_global_summary = agents_global_skills_dir
6599        .as_ref()
6600        .map(|path| {
6601            json!({
6602                "path": path.display().to_string(),
6603                "present": path.exists(),
6604                "count": skills_count_for(path),
6605            })
6606        })
6607        .unwrap_or_else(|| {
6608            json!({
6609                "path": null,
6610                "present": false,
6611                "count": 0,
6612            })
6613        });
6614
6615    let tools_dir = default_tools_dir();
6616    let plugins_dir = default_plugins_dir();
6617
6618    // Memory feature state (#489). Operators ask "is memory on?" and
6619    // "where does it live?" — surface both here so the question can be
6620    // answered without booting the TUI. Both inputs are checked: the
6621    // config flag and the env-var override that the runtime would
6622    // honour. (The dedicated `Config::memory_enabled()` accessor lives
6623    // on the memory-MVP branch (#518); this duplicates the same logic
6624    // until the two PRs land and it can be replaced with a single
6625    // method call.)
6626    let memory_path = config.memory_path();
6627    let memory_enabled_env = std::env::var("CODEWHALE_MEMORY")
6628        .or_else(|_| std::env::var("DEEPSEEK_MEMORY"))
6629        .ok()
6630        .map(|raw| {
6631            matches!(
6632                raw.trim().to_ascii_lowercase().as_str(),
6633                "1" | "on" | "true" | "yes" | "y" | "enabled"
6634            )
6635        })
6636        .unwrap_or(false);
6637    let memory_summary = json!({
6638        // The MVP feature is opt-in by default; this defaults to false
6639        // on branches without the [memory] section in `Config`.
6640        "enabled": memory_enabled_env,
6641        "path": memory_path.display().to_string(),
6642        "file_present": memory_path.exists(),
6643    });
6644    let api_target = doctor_api_target(config);
6645    let strict_tool_mode = doctor_strict_tool_mode_status(config);
6646    let tls_status = doctor_tls_status(config);
6647    let (code_home, legacy_home) = doctor_state_roots();
6648    let legacy_state_report = doctor_legacy_state_report(&code_home, &legacy_home);
6649    let session_recovery = doctor_session_recovery_report(
6650        &code_home,
6651        &legacy_home,
6652        codewhale_config::codewhale_home_is_explicit(),
6653    );
6654
6655    let stash = crate::composer_stash::diagnostic_stash_report();
6656    let report = json!({
6657        "version": env!("CARGO_PKG_VERSION"),
6658        "config_path": config_path.display().to_string(),
6659        "config_present": config_path.exists(),
6660        "paths": doctor_paths,
6661        "secret_backend": secret_backend,
6662        "workspace": workspace.display().to_string(),
6663        "legacy_state": doctor_legacy_state_json(
6664            &code_home,
6665            &legacy_home,
6666            &legacy_state_report,
6667            &session_recovery,
6668        ),
6669        "setup": doctor_setup_report_json(config, workspace),
6670        "api_key": {
6671            "source": doctor_api_key_source_label(credential.source),
6672            "availability": credential.availability.label(),
6673        },
6674        "external_credentials": doctor_external_credential_consent_json(config),
6675        "dsh_integration": doctor_dsh_integration_json(config, workspace),
6676        "base_url": crate::doctor::structural_url_authority(&api_target.base_url),
6677        "default_text_model": api_target.model,
6678        // DGF-01: this report describes the route a session launched now
6679        // would resolve; a running session keeps its launch-time route.
6680        "route_scope": "configured_at_launch",
6681        "model_resolution": match api_target.resolution {
6682            DoctorModelResolution::Resolved => "resolved",
6683            DoctorModelResolution::ConfiguredOnly => "configured_unresolved",
6684        },
6685        "route": doctor_route_report(config),
6686        "strict_tool_mode": doctor_strict_tool_mode_report_json(&strict_tool_mode),
6687        "tls": {
6688            "certificate_verification": tls_status.certificate_verification,
6689            "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
6690            "provider": tls_status.provider,
6691            "message": tls_status.message,
6692        },
6693        "search_provider": doctor_search_provider_json(config),
6694        "memory": memory_summary,
6695        "mcp": mcp_summary,
6696        "skills": {
6697            "selected": selected_skills_dir.display().to_string(),
6698            "global": {
6699                "path": global_skills_dir.display().to_string(),
6700                "present": global_skills_dir.exists(),
6701                "count": skills_count_for(&global_skills_dir),
6702            },
6703            "agents": {
6704                "path": agents_skills_dir.display().to_string(),
6705                "present": agents_skills_dir.exists(),
6706                "count": skills_count_for(&agents_skills_dir),
6707            },
6708            "agents_global": agents_global_summary,
6709            "local": {
6710                "path": local_skills_dir.display().to_string(),
6711                "present": local_skills_dir.exists(),
6712                "count": skills_count_for(&local_skills_dir),
6713            },
6714            "opencode": {
6715                "path": opencode_skills_dir.display().to_string(),
6716                "present": opencode_skills_dir.exists(),
6717                "count": skills_count_for(&opencode_skills_dir),
6718            },
6719            "claude": {
6720                "path": claude_skills_dir.display().to_string(),
6721                "present": claude_skills_dir.exists(),
6722                "count": skills_count_for(&claude_skills_dir),
6723            },
6724        },
6725        "tools": {
6726            "path": tools_dir.display().to_string(),
6727            "present": tools_dir.exists(),
6728            "count": if tools_dir.exists() { count_dir_entries(&tools_dir) } else { 0 },
6729        },
6730        "plugins": {
6731            "path": plugins_dir.display().to_string(),
6732            "present": plugins_dir.exists(),
6733            "count": if plugins_dir.exists() { count_dir_entries(&plugins_dir) } else { 0 },
6734        },
6735        "storage": {
6736            "spillover": {
6737                "path": crate::tools::truncate::spillover_root()
6738                    .map(|p| p.display().to_string())
6739                    .unwrap_or_default(),
6740                "present": crate::tools::truncate::spillover_root()
6741                    .is_some_and(|p| p.is_dir()),
6742                "count": crate::tools::truncate::spillover_root()
6743                    .filter(|p| p.is_dir())
6744                    .map(|p| count_dir_entries(&p))
6745                    .unwrap_or(0),
6746            },
6747            "stash": {
6748                "path": stash
6749                    .path
6750                    .as_ref()
6751                    .map(|path| path.display().to_string())
6752                    .unwrap_or_default(),
6753                "present": stash.present,
6754                "count": stash.count,
6755                "error": stash.error,
6756            },
6757        },
6758        "sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
6759            config.prefer_bwrap.unwrap_or(false),
6760        ) {
6761            Some(kind) => json!({"available": true, "kind": kind.to_string()}),
6762            None => json!({"available": false, "kind": null}),
6763        },
6764        "platform": {
6765            "os": std::env::consts::OS,
6766            "arch": std::env::consts::ARCH,
6767        },
6768        "api_connectivity": {
6769            "checked": false,
6770            "status": "not_probed",
6771            "note": "JSON doctor is offline; use `codewhale doctor --probe-api` or `--probe-local` for an explicit live check.",
6772        },
6773        "capability": provider_capability_report(config),
6774    });
6775
6776    println!("{}", serde_json::to_string_pretty(&report)?);
6777    Ok(())
6778}
6779
6780fn run_doctor_context_json(config: &Config, workspace: &Path) -> Result<()> {
6781    let report = crate::context_report::build_headless_context_report(config, workspace);
6782    println!("{}", crate::context_report::context_report_json(&report));
6783    Ok(())
6784}
6785
6786/// Build the `capability` section for the machine-readable doctor report.
6787///
6788/// Returns a JSON value with the resolved provider, resolved model, context
6789/// window, max output, thinking support, cache telemetry support, and request
6790/// payload mode.
6791fn provider_capability_report(config: &Config) -> serde_json::Value {
6792    use serde_json::json;
6793
6794    let provider = config.api_provider();
6795    let configured_model = config.default_model();
6796    let route_result =
6797        crate::route_runtime::resolve_runtime_route(config, provider, Some(&configured_model));
6798    let route_error = route_result
6799        .is_err()
6800        .then_some("route_resolution_failed_details_omitted");
6801    let route = route_result.ok();
6802    let resolved_model = route
6803        .as_ref()
6804        .map_or(configured_model.as_str(), |route| route.model.as_str());
6805    let cap = crate::config::provider_capability(provider, resolved_model);
6806    let route_profile = route.as_ref().map(|route| {
6807        crate::model_profile::resolved_capability_profile_for_route(
6808            provider,
6809            resolved_model,
6810            route.candidate.capabilities(),
6811            route.candidate.limits(),
6812        )
6813    });
6814    let context_window = route
6815        .as_ref()
6816        .map_or(cap.context_window, |route| route.context_window.tokens);
6817    let context_window_source = route.as_ref().map_or(
6818        crate::route_runtime::ContextWindowSource::Fallback.label(),
6819        |route| route.context_window.source.label(),
6820    );
6821    // `null` when neither the resolved route nor the compatibility matrix
6822    // publishes an output ceiling — doctor must not invent one.
6823    let max_output = route_profile
6824        .as_ref()
6825        .and_then(|profile| profile.max_output)
6826        .or(cap.max_output);
6827    let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
6828        crate::config::is_exact_kimi_code_k3_route(
6829            provider,
6830            &route.candidate.endpoint().base_url,
6831            route.candidate.wire_model_id().as_str(),
6832        )
6833    });
6834    let thinking_supported = is_exact_kimi_code_k3
6835        || route_profile
6836            .as_ref()
6837            .map_or(cap.thinking_supported, |profile| {
6838                profile.supports_reasoning()
6839            });
6840    let cache_telemetry_supported = route_profile
6841        .as_ref()
6842        .map_or(cap.cache_telemetry_supported, |profile| {
6843            profile.prompt_caching.is_supported()
6844        });
6845    let request_payload_mode = route_profile
6846        .as_ref()
6847        .map_or(cap.request_payload_mode, |profile| {
6848            profile.request_payload_mode
6849        });
6850    let alias_deprecation = config.active_deepseek_alias_deprecation();
6851
6852    json!({
6853        "resolved_provider": config.provider_identity_for(provider),
6854        "resolved_model": resolved_model,
6855        "context_window": context_window,
6856        "context_window_source": context_window_source,
6857        "max_output": max_output,
6858        "thinking_supported": thinking_supported,
6859        "cache_telemetry_supported": cache_telemetry_supported,
6860        "request_payload_mode": serde_json::to_value(request_payload_mode).unwrap_or_default(),
6861        "route_error": route_error,
6862        "alias_deprecation": alias_deprecation,
6863    })
6864}
6865
6866fn doctor_route_report(config: &Config) -> serde_json::Value {
6867    use serde_json::json;
6868
6869    let target = doctor_api_target(config);
6870    let provider = config.api_provider();
6871    let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
6872    let route_result =
6873        crate::route_runtime::resolve_runtime_route(config, provider, Some(&target.model));
6874    let route_error = route_result
6875        .is_err()
6876        .then_some("route_resolution_failed_details_omitted");
6877    let context_window = route_result
6878        .ok()
6879        .map(|route| {
6880        json!({
6881            "tokens": route.context_window.tokens,
6882            "source": route.context_window.source.label(),
6883        })
6884    })
6885    .unwrap_or_else(|| {
6886        json!({
6887            "tokens": crate::config::provider_capability(provider, &target.model).context_window,
6888            "source": crate::route_runtime::ContextWindowSource::Fallback.label(),
6889        })
6890    });
6891
6892    let route_identity =
6893        crate::config::moonshot_k3_route_display_name(&target.base_url, &target.model);
6894    let credential = resolve_credential_diagnostic(config);
6895
6896    json!({
6897        "provider": target.provider,
6898        "provider_source": doctor_provider_source(config),
6899        "provider_config_table": doctor_provider_config_table(config, provider),
6900        "model": target.model,
6901        "route_identity": route_identity,
6902        "wire_protocol": doctor_wire_protocol(provider),
6903        "base_url": {
6904            "redacted": redacted_base_url,
6905            "class": doctor_base_url_class(provider, &target.base_url),
6906            "fingerprint": crate::utils::redacted_identifier_for_log(&target.base_url),
6907        },
6908        "auth": {
6909            "scheme": doctor_auth_scheme(config),
6910            "source": doctor_api_key_source_label(credential.source),
6911            "availability": credential.availability.label(),
6912        },
6913        "context_window": context_window,
6914        "route_error": route_error,
6915    })
6916}
6917
6918fn doctor_provider_config_table(config: &Config, provider: crate::config::ApiProvider) -> String {
6919    if provider != crate::config::ApiProvider::Custom {
6920        return provider_config_table_key(provider).to_string();
6921    }
6922    if config.uses_legacy_literal_custom_route() {
6923        "root (legacy literal custom)".to_string()
6924    } else {
6925        format!("providers.{}", config.provider_identity_for(provider))
6926    }
6927}
6928
6929fn doctor_provider_source(config: &Config) -> &'static str {
6930    if config
6931        .provider
6932        .as_ref()
6933        .is_some_and(|provider| !provider.trim().is_empty())
6934    {
6935        "config"
6936    } else {
6937        "default"
6938    }
6939}
6940
6941fn doctor_wire_protocol(provider: crate::config::ApiProvider) -> &'static str {
6942    let policy = provider
6943        .metadata()
6944        .map(|metadata| metadata.wire_policy())
6945        .unwrap_or(codewhale_config::provider::WirePolicy::Fixed(
6946            codewhale_config::provider::WireFormat::ChatCompletions,
6947        ));
6948    match policy.fixed() {
6949        Some(codewhale_config::provider::WireFormat::ChatCompletions) => "chat_completions",
6950        Some(codewhale_config::provider::WireFormat::Responses) => "responses",
6951        Some(codewhale_config::provider::WireFormat::AnthropicMessages) => "anthropic_messages",
6952        None => "model_aware",
6953    }
6954}
6955
6956fn doctor_base_url_class(provider: crate::config::ApiProvider, base_url: &str) -> &'static str {
6957    let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
6958    if normalized.starts_with("http://localhost")
6959        || normalized.starts_with("http://127.0.0.1")
6960        || normalized.starts_with("http://[::1]")
6961    {
6962        return "local";
6963    }
6964    if normalized
6965        == provider
6966            .default_base_url()
6967            .trim_end_matches('/')
6968            .to_ascii_lowercase()
6969    {
6970        "default"
6971    } else {
6972        "custom"
6973    }
6974}
6975
6976fn doctor_auth_scheme(config: &Config) -> &'static str {
6977    let provider = config.api_provider();
6978    if crate::config::auth_mode_disables_api_key(config.auth_mode_for_provider(provider).as_deref())
6979    {
6980        "none"
6981    } else if provider == crate::config::ApiProvider::Anthropic {
6982        "x-api-key"
6983    } else if provider == crate::config::ApiProvider::XiaomiMimo
6984        && doctor_xiaomi_mimo_base_url_uses_token_plan(&config.deepseek_base_url())
6985    {
6986        "api-key"
6987    } else if provider == crate::config::ApiProvider::XiaomiMimo {
6988        // The alternate MiMo scheme depends on a credential prefix. Ordinary
6989        // doctor does not read credentials merely to make this label precise.
6990        "unknown"
6991    } else if matches!(
6992        provider,
6993        crate::config::ApiProvider::Sglang
6994            | crate::config::ApiProvider::Vllm
6995            | crate::config::ApiProvider::Ollama
6996    ) {
6997        "optional_bearer"
6998    } else {
6999        "bearer"
7000    }
7001}
7002
7003fn doctor_xiaomi_mimo_base_url_uses_token_plan(base_url: &str) -> bool {
7004    let normalized = base_url.trim_end_matches('/');
7005    [
7006        crate::config::XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL,
7007        crate::config::XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL,
7008        crate::config::XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL,
7009    ]
7010    .iter()
7011    .any(|candidate| normalized.eq_ignore_ascii_case(candidate.trim_end_matches('/')))
7012}
7013
7014fn doctor_api_key_source_label(source: ApiKeySource) -> &'static str {
7015    match source {
7016        ApiKeySource::ConfigDeclared => "config_declared",
7017        ApiKeySource::EnvDeclared => "env_declared",
7018        ApiKeySource::ExternalAuthDeclared => "external_auth_declared",
7019        ApiKeySource::SecretStoreUnprobed => "secret_store_unprobed",
7020        ApiKeySource::SecretStoreUnavailable => "secret_store_unavailable",
7021        ApiKeySource::OAuth => "oauth_unprobed",
7022        ApiKeySource::ExternalConsent => "external_consent",
7023        ApiKeySource::NoAuth => "none",
7024        ApiKeySource::LocalRuntime => "local_runtime",
7025        ApiKeySource::Unknown => "unknown",
7026    }
7027}
7028
7029fn doctor_search_provider_line(config: &Config) -> String {
7030    let search_provider = config.search_provider_resolution();
7031    let switch_hint = if matches!(
7032        (search_provider.provider, search_provider.source),
7033        (
7034            crate::config::SearchProvider::Firecrawl,
7035            crate::config::SearchProviderSource::Default
7036        )
7037    ) {
7038        "; set [search] provider = \"baidu\" | \"metaso\" | \"volcengine\" for China"
7039    } else {
7040        ""
7041    };
7042
7043    format!(
7044        "search_provider: {} (source: {}{})",
7045        search_provider.provider.as_str(),
7046        search_provider.source.as_str(),
7047        switch_hint
7048    )
7049}
7050
7051fn doctor_search_provider_json(config: &Config) -> serde_json::Value {
7052    use serde_json::json;
7053
7054    let search_provider = config.search_provider_resolution();
7055    json!({
7056        "provider": search_provider.provider.as_str(),
7057        "source": search_provider.source.as_str(),
7058    })
7059}
7060
7061/// Whether the model in a [`DoctorApiTarget`] is the wire id the engine
7062/// resolver produced, or only the raw configured value because resolution
7063/// failed. Doctor never prints resolution error details — the JSON route
7064/// report already redacts them for the same reason.
7065#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7066enum DoctorModelResolution {
7067    Resolved,
7068    ConfiguredOnly,
7069}
7070
7071#[derive(Debug, Clone, PartialEq, Eq)]
7072struct DoctorApiTarget {
7073    provider: String,
7074    base_url: String,
7075    model: String,
7076    resolution: DoctorModelResolution,
7077}
7078
7079#[derive(Debug, Clone, PartialEq, Eq)]
7080struct DoctorStrictToolModeStatus {
7081    enabled: bool,
7082    status: &'static str,
7083    function_strict_sent: bool,
7084    message: String,
7085    recommended_base_url: Option<String>,
7086}
7087
7088fn doctor_api_target(config: &Config) -> DoctorApiTarget {
7089    let provider = config.api_provider();
7090    // Report the model through the same resolver the live client uses at
7091    // session launch (`client.rs` → `resolve_runtime_route`), so doctor's
7092    // answer matches what a session started now would actually serve —
7093    // saved provider models, alias normalization, and roster preference
7094    // included — instead of re-deriving a config default that can diverge
7095    // from the engine (DGF-01, dogfood 2026-08-02).
7096    let (model, resolution) =
7097        match crate::route_runtime::resolve_runtime_route(config, provider, None) {
7098            Ok(route) => (route.model.clone(), DoctorModelResolution::Resolved),
7099            Err(_) => (
7100                config.default_model(),
7101                DoctorModelResolution::ConfiguredOnly,
7102            ),
7103        };
7104    DoctorApiTarget {
7105        provider: config.provider_identity_for(provider),
7106        base_url: config.deepseek_base_url(),
7107        model,
7108        resolution,
7109    }
7110}
7111
7112fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus {
7113    if !config.strict_tool_mode.unwrap_or(false) {
7114        return DoctorStrictToolModeStatus {
7115            enabled: false,
7116            status: "disabled",
7117            function_strict_sent: false,
7118            message: "disabled".to_string(),
7119            recommended_base_url: None,
7120        };
7121    }
7122
7123    let target = doctor_api_target(config);
7124    match known_deepseek_base_url_kind(&target.base_url) {
7125        Some(DeepSeekBaseUrlKind::Beta) => DoctorStrictToolModeStatus {
7126            enabled: true,
7127            status: "ready",
7128            function_strict_sent: true,
7129            message: "enabled; DeepSeek strict schemas use the beta endpoint".to_string(),
7130            recommended_base_url: None,
7131        },
7132        Some(DeepSeekBaseUrlKind::NonBeta) => {
7133            let recommended = recommended_strict_base_url(config, &target.base_url);
7134            DoctorStrictToolModeStatus {
7135                enabled: true,
7136                status: "fallback_non_beta",
7137                function_strict_sent: false,
7138                message:
7139                    "enabled, but function.strict is stripped for this non-beta DeepSeek endpoint"
7140                        .to_string(),
7141                recommended_base_url: Some(recommended.to_string()),
7142            }
7143        }
7144        None => DoctorStrictToolModeStatus {
7145            enabled: true,
7146            status: "custom_endpoint",
7147            function_strict_sent: true,
7148            message: "enabled; function.strict will be sent to this custom endpoint".to_string(),
7149            recommended_base_url: None,
7150        },
7151    }
7152}
7153
7154fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> serde_json::Value {
7155    serde_json::json!({
7156        "enabled": status.enabled,
7157        "status": status.status,
7158        "function_strict_sent": status.function_strict_sent,
7159        "message": status.message,
7160        "recommended_base_url": status
7161            .recommended_base_url
7162            .as_deref()
7163            .map(crate::doctor::structural_url_authority),
7164    })
7165}
7166
7167#[derive(Debug, Clone, PartialEq, Eq)]
7168struct DoctorTlsStatus {
7169    certificate_verification: bool,
7170    insecure_skip_tls_verify: bool,
7171    provider: String,
7172    message: String,
7173}
7174
7175fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
7176    let provider = config.provider_identity_for(config.api_provider());
7177    let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
7178    let message = if insecure_skip_tls_verify {
7179        format!(
7180            "TLS certificate verification cannot be disabled for provider {provider}; use SSL_CERT_FILE with a trusted custom CA bundle"
7181        )
7182    } else {
7183        "TLS certificate verification enabled".to_string()
7184    };
7185    DoctorTlsStatus {
7186        certificate_verification: true,
7187        insecure_skip_tls_verify,
7188        provider,
7189        message,
7190    }
7191}
7192
7193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7194enum DeepSeekBaseUrlKind {
7195    Beta,
7196    NonBeta,
7197}
7198
7199fn known_deepseek_base_url_kind(base_url: &str) -> Option<DeepSeekBaseUrlKind> {
7200    let normalized = base_url.trim_end_matches('/');
7201    if normalized.eq_ignore_ascii_case("https://api.deepseek.com/beta")
7202        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/beta")
7203    {
7204        Some(DeepSeekBaseUrlKind::Beta)
7205    } else if normalized.eq_ignore_ascii_case("https://api.deepseek.com")
7206        || normalized.eq_ignore_ascii_case("https://api.deepseek.com/v1")
7207        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com")
7208        || normalized.eq_ignore_ascii_case("https://api.deepseeki.com/v1")
7209    {
7210        Some(DeepSeekBaseUrlKind::NonBeta)
7211    } else {
7212        None
7213    }
7214}
7215
7216fn recommended_strict_base_url(_config: &Config, _base_url: &str) -> &'static str {
7217    crate::config::DEFAULT_DEEPSEEK_BASE_URL
7218}
7219
7220fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
7221    let target = doctor_api_target(config);
7222    let mut lines = vec![format!(
7223        "Connection timed out while reaching {}.",
7224        crate::doctor::structural_url_authority(&target.base_url)
7225    )];
7226
7227    match config.api_provider() {
7228        crate::config::ApiProvider::Deepseek
7229            if target.base_url.contains("api.deepseek.com")
7230                && !target.base_url.contains("api.deepseeki.com") =>
7231        {
7232            lines.push(
7233                "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.codewhale/config.toml and rerun `codewhale doctor`."
7234                    .to_string(),
7235            );
7236        }
7237        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => {
7238            lines.push(
7239                "If this is a custom DeepSeek-compatible endpoint, confirm it serves `/v1/models` and `/v1/chat/completions` over HTTPS."
7240                    .to_string(),
7241            );
7242        }
7243        _ => {
7244            lines.push(
7245                "Confirm the configured provider endpoint is reachable and OpenAI-compatible for `/v1/models` and `/v1/chat/completions`."
7246                    .to_string(),
7247            );
7248        }
7249    }
7250
7251    lines.push(
7252        "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue."
7253            .to_string(),
7254    );
7255    lines
7256}
7257
7258fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
7259    match command.command {
7260        FeaturesSubcommand::List => {
7261            print!("{}", render_feature_table(&config.features()));
7262            Ok(())
7263        }
7264    }
7265}
7266
7267async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
7268    use crate::client::DeepSeekClient;
7269
7270    let client = DeepSeekClient::new(config)?;
7271    let mut models = client.list_models().await?;
7272    models.sort_by(|a, b| a.id.cmp(&b.id));
7273
7274    if args.json {
7275        println!("{}", serde_json::to_string_pretty(&models)?);
7276        return Ok(());
7277    }
7278
7279    if models.is_empty() {
7280        println!("No models returned by the API.");
7281        return Ok(());
7282    }
7283
7284    let default_model = config.default_model();
7285
7286    println!("Available models (default: {default_model})");
7287    for model in models {
7288        let marker = if model.id == default_model { "*" } else { " " };
7289        if let Some(owner) = model.owned_by {
7290            println!("{marker} {} ({owner})", model.id);
7291        } else {
7292            println!("{marker} {}", model.id);
7293        }
7294    }
7295
7296    Ok(())
7297}
7298
7299async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
7300    use crate::client::{DeepSeekClient, SpeechSynthesisRequest};
7301    use crate::config::ApiProvider;
7302    use crate::tools::speech::{
7303        DEFAULT_VOICE, SPEECH_MODEL_EXAMPLES, combine_speech_instructions,
7304        default_speech_output_name, describe_speech_voice, encode_voice_clone_sample_data_uri,
7305        infer_speech_model, normalize_speech_format,
7306    };
7307
7308    let SpeechArgs {
7309        text,
7310        output,
7311        output_dir,
7312        model,
7313        voice,
7314        instruction,
7315        voice_prompt,
7316        clone_voice,
7317        format,
7318        json: json_output,
7319    } = args;
7320
7321    if config.api_provider() != ApiProvider::XiaomiMimo {
7322        bail!(
7323            "`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
7324            config.api_provider().as_str()
7325        );
7326    }
7327
7328    if text.trim().is_empty() {
7329        bail!("Speech text cannot be empty");
7330    }
7331    let voice_is_data_uri = voice
7332        .as_deref()
7333        .map(str::trim)
7334        .is_some_and(|value| value.starts_with("data:audio/"));
7335    if clone_voice.is_some() && voice.is_some() {
7336        bail!("Use either --clone-voice or --voice for cloned voice data, not both");
7337    }
7338    let model = infer_speech_model(
7339        model.as_deref(),
7340        clone_voice.is_some() || voice_is_data_uri,
7341        voice_prompt.is_some(),
7342    );
7343    let model_lower = model.to_ascii_lowercase();
7344    if !model_lower.contains("tts") {
7345        bail!(
7346            "speech requires a TTS model (examples: {}); got {model}",
7347            SPEECH_MODEL_EXAMPLES.join(", ")
7348        );
7349    }
7350    let is_voice_design = model_lower.contains("voicedesign");
7351    let is_voice_clone = model_lower.contains("voiceclone");
7352
7353    let instruction = combine_speech_instructions(instruction, voice_prompt);
7354    if is_voice_design
7355        && instruction
7356            .as_deref()
7357            .is_none_or(|value| value.trim().is_empty())
7358    {
7359        bail!(
7360            "mimo-v2.5-tts-voicedesign requires --voice-prompt or --instruction to describe the voice"
7361        );
7362    }
7363
7364    let voice = if let Some(clone_path) = clone_voice {
7365        Some(encode_voice_clone_sample_data_uri(&clone_path)?)
7366    } else if is_voice_design {
7367        None
7368    } else if let Some(value) = voice.filter(|value| !value.trim().is_empty()) {
7369        Some(value)
7370    } else if is_voice_clone {
7371        bail!("mimo-v2.5-tts-voiceclone requires --clone-voice <mp3|wav> or --voice <data-uri>");
7372    } else {
7373        Some(DEFAULT_VOICE.to_string())
7374    };
7375    let format = normalize_speech_format(&format).with_context(|| {
7376        format!("Unsupported speech format '{format}' (allowed: wav, mp3, pcm16)")
7377    })?;
7378    let output = output.unwrap_or_else(|| {
7379        output_dir
7380            .or_else(|| config.speech_output_dir())
7381            .unwrap_or_default()
7382            .join(default_speech_output_name(&format))
7383    });
7384
7385    let client = DeepSeekClient::new(config)?;
7386    let response = client
7387        .synthesize_speech(SpeechSynthesisRequest {
7388            model: model.clone(),
7389            text,
7390            instruction,
7391            audio_format: format.clone(),
7392            voice,
7393        })
7394        .await?;
7395
7396    if let Some(parent) = output.parent().filter(|path| !path.as_os_str().is_empty()) {
7397        std::fs::create_dir_all(parent)
7398            .with_context(|| format!("Failed to create output directory {}", parent.display()))?;
7399    }
7400    std::fs::write(&output, &response.audio_bytes)
7401        .with_context(|| format!("Failed to write audio file {}", output.display()))?;
7402
7403    if json_output {
7404        println!(
7405            "{}",
7406            serde_json::to_string_pretty(&serde_json::json!({
7407                "mode": "speech",
7408                "success": true,
7409                "model": response.model,
7410                "format": response.audio_format,
7411                "output": output.display().to_string(),
7412                "bytes": response.audio_bytes.len(),
7413                "voice": response.voice.as_deref().map(describe_speech_voice),
7414                "transcript": response.transcript,
7415            }))?
7416        );
7417    } else {
7418        println!(
7419            "Generated speech: {} ({} bytes, model: {}, format: {})",
7420            output.display(),
7421            response.audio_bytes.len(),
7422            response.model,
7423            response.audio_format
7424        );
7425    }
7426
7427    Ok(())
7428}
7429
7430#[cfg(test)]
7431mod speech_cli_tests {
7432    use super::*;
7433    use crate::tools::speech::{
7434        default_speech_output_name, infer_speech_model, normalize_speech_format,
7435    };
7436
7437    #[test]
7438    fn normalizes_documented_speech_formats() {
7439        assert_eq!(normalize_speech_format("WAV").as_deref(), Some("wav"));
7440        assert_eq!(normalize_speech_format("pcm16").as_deref(), Some("pcm16"));
7441        assert_eq!(normalize_speech_format("pcm").as_deref(), Some("pcm16"));
7442        assert_eq!(normalize_speech_format("flac"), None);
7443    }
7444
7445    #[test]
7446    fn default_speech_output_tracks_requested_format() {
7447        assert_eq!(
7448            PathBuf::from(default_speech_output_name("mp3")),
7449            PathBuf::from("speech.mp3")
7450        );
7451        assert_eq!(
7452            PathBuf::from("audio").join(default_speech_output_name("pcm")),
7453            PathBuf::from("audio").join("speech.pcm16")
7454        );
7455    }
7456
7457    #[test]
7458    fn speech_command_parses_cli_passthrough_smoke() {
7459        let cli = Cli::try_parse_from([
7460            "codewhale-tui",
7461            "speech",
7462            "hello",
7463            "--model",
7464            "tts",
7465            "--format",
7466            "pcm",
7467            "--output-dir",
7468            "audio",
7469            "--voice",
7470            "Mia",
7471        ])
7472        .expect("speech command parses");
7473
7474        let Some(Commands::Speech(args)) = cli.command else {
7475            panic!("expected speech command");
7476        };
7477        assert_eq!(args.text, "hello");
7478        assert_eq!(
7479            infer_speech_model(args.model.as_deref(), false, false),
7480            "mimo-v2.5-tts"
7481        );
7482        assert_eq!(
7483            normalize_speech_format(&args.format).as_deref(),
7484            Some("pcm16")
7485        );
7486        assert_eq!(args.output_dir, Some(PathBuf::from("audio")));
7487        assert_eq!(args.voice.as_deref(), Some("Mia"));
7488    }
7489}
7490
7491/// Test API connectivity by making a minimal request
7492async fn test_api_connectivity(config: &Config) -> Result<()> {
7493    use crate::client::DeepSeekClient;
7494    use crate::models::{ContentBlock, Message, MessageRequest};
7495
7496    let client = DeepSeekClient::new(config)?;
7497    let model = client.model().to_string();
7498
7499    if crate::doctor::is_keyless_ds4_route(config) {
7500        return crate::doctor::probe_ds4_models(config).await;
7501    }
7502
7503    // Minimal request: single word prompt, 1 max token
7504    let request = MessageRequest {
7505        model: model.clone(),
7506        messages: vec![Message {
7507            role: "user".to_string(),
7508            content: vec![ContentBlock::Text {
7509                text: "hi".to_string(),
7510                cache_control: None,
7511            }],
7512        }],
7513        max_tokens: 1,
7514        system: None,
7515        tools: None,
7516        tool_choice: None,
7517        metadata: None,
7518        thinking: None,
7519        // This is a one-token transport probe, not a reasoning task.
7520        reasoning_effort: Some("off".to_string()),
7521        stream: Some(false),
7522        temperature: None,
7523        top_p: None,
7524    };
7525
7526    // Use tokio timeout to catch hanging requests
7527    let timeout_duration = std::time::Duration::from_secs(15);
7528    match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
7529        Ok(Ok(_response)) => Ok(()),
7530        Ok(Err(e)) => Err(e),
7531        Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
7532    }
7533}
7534
7535fn rustc_version() -> String {
7536    let Some(mut cmd) = crate::dependencies::RustC::command() else {
7537        return "unknown".to_string();
7538    };
7539    let Ok(output) = cmd.arg("--version").output() else {
7540        return "unknown".to_string();
7541    };
7542    String::from_utf8(output.stdout)
7543        .map(|s| s.trim().to_string())
7544        .unwrap_or_else(|_| "unknown".to_string())
7545}
7546
7547/// List saved sessions
7548fn sessions_resume_command() -> &'static str {
7549    "codewhale resume"
7550}
7551
7552fn list_sessions(limit: usize, search: Option<String>) -> Result<()> {
7553    use crate::palette;
7554    use colored::Colorize;
7555    use session_manager::{SessionManager, format_session_line};
7556
7557    let (action_r, action_g, action_b) = palette::WHALE_ACTION_RGB;
7558    let (human_r, human_g, human_b) = palette::WHALE_HUMAN_RGB;
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
7562    let manager = SessionManager::default_location()?;
7563
7564    let sessions = if let Some(query) = search {
7565        manager.search_sessions(&query)?
7566    } else {
7567        manager.list_sessions()?
7568    };
7569
7570    if sessions.is_empty() {
7571        println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b));
7572        println!(
7573            "Start a new session with: {}",
7574            "codewhale".truecolor(human_r, human_g, human_b)
7575        );
7576        return Ok(());
7577    }
7578
7579    println!(
7580        "{}",
7581        "Saved Sessions"
7582            .truecolor(action_r, action_g, action_b)
7583            .bold()
7584    );
7585    println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
7586    println!();
7587
7588    for (i, session) in sessions.iter().take(limit).enumerate() {
7589        let line = format_session_line(session);
7590        if i == 0 {
7591            println!("  {} {}", "*".truecolor(aqua_r, aqua_g, aqua_b), line);
7592        } else {
7593            println!("    {line}");
7594        }
7595    }
7596
7597    let total = sessions.len();
7598    if total > limit {
7599        println!();
7600        println!(
7601            "  {} more session(s). Use --limit to show more.",
7602            total - limit
7603        );
7604    }
7605
7606    println!();
7607    println!(
7608        "Resume with: {} {}",
7609        sessions_resume_command().truecolor(action_r, action_g, action_b),
7610        "<session-id>".dimmed()
7611    );
7612    println!(
7613        "Continue latest in this workspace: {}",
7614        "codewhale --continue".truecolor(action_r, action_g, action_b)
7615    );
7616
7617    Ok(())
7618}
7619
7620/// Initialize a new project with AGENTS.md
7621fn init_project() -> Result<()> {
7622    use crate::palette;
7623    use colored::Colorize;
7624    use project_context::create_default_agents_md;
7625
7626    let (sky_r, sky_g, sky_b) = palette::WHALE_INFO_RGB;
7627    let (aqua_r, aqua_g, aqua_b) = palette::WHALE_INFO_RGB;
7628    let (red_r, red_g, red_b) = palette::WHALE_ERROR_RGB;
7629
7630    let workspace = std::env::current_dir()?;
7631    let agents_path = workspace.join("AGENTS.md");
7632
7633    if agents_path.exists() {
7634        println!(
7635            "{} AGENTS.md already exists at {}",
7636            "!".truecolor(sky_r, sky_g, sky_b),
7637            agents_path.display()
7638        );
7639        return Ok(());
7640    }
7641
7642    match create_default_agents_md(&workspace) {
7643        Ok(path) => {
7644            println!(
7645                "{} Created {}",
7646                "✓".truecolor(aqua_r, aqua_g, aqua_b),
7647                path.display()
7648            );
7649            println!();
7650            println!("Edit this file to customize how the AI agent works with your project.");
7651            println!("The instructions will be loaded automatically when you run codewhale.");
7652        }
7653        Err(e) => {
7654            println!(
7655                "{} Failed to create AGENTS.md: {}",
7656                "✗".truecolor(red_r, red_g, red_b),
7657                e
7658            );
7659        }
7660    }
7661
7662    Ok(())
7663}
7664
7665fn resolve_workspace(cli: &Cli) -> PathBuf {
7666    cli.workspace
7667        .clone()
7668        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
7669}
7670
7671fn load_config_from_cli(cli: &Cli) -> Result<Config> {
7672    load_config_from_cli_with_effective_profile(cli).map(|(config, _)| config)
7673}
7674
7675/// Doctor is a structural report unless the user explicitly asks it to probe
7676/// a provider endpoint. Keep credential-bearing environment values out of the
7677/// regular diagnostic configuration so an unrelated renderer or error path
7678/// cannot disclose them.
7679fn load_doctor_config_from_cli(cli: &Cli, args: &DoctorArgs) -> Result<Config> {
7680    if args.probe_api || args.probe_local {
7681        return load_config_from_cli(cli);
7682    }
7683    load_structural_config_from_cli(cli)
7684}
7685
7686fn load_structural_config_from_cli(cli: &Cli) -> Result<Config> {
7687    let profile = effective_config_profile(cli);
7688    let mut config = Config::load_structural(cli.config.clone(), profile.as_deref())?;
7689    if let Ok(settings) = crate::settings::Settings::load_read_only() {
7690        apply_saved_reasoning_preference(&mut config, &settings);
7691    }
7692    cli.feature_toggles.apply(&mut config)?;
7693    Ok(config)
7694}
7695
7696fn effective_config_profile(cli: &Cli) -> Option<String> {
7697    cli.profile
7698        .clone()
7699        .or_else(|| std::env::var("CODEWHALE_PROFILE").ok())
7700        .or_else(|| std::env::var("DEEPSEEK_PROFILE").ok())
7701}
7702
7703fn load_config_from_cli_with_effective_profile(cli: &Cli) -> Result<(Config, Option<String>)> {
7704    let profile = effective_config_profile(cli);
7705    let mut config = Config::load(cli.config.clone(), profile.as_deref())?;
7706    // Config loading is shared by diagnostics and mutating runtimes. Read the
7707    // saved preference without migrating or creating state here; interactive
7708    // startup performs any permitted migration later through `Settings::load`.
7709    if let Ok(settings) = crate::settings::Settings::load_read_only() {
7710        apply_saved_reasoning_preference(&mut config, &settings);
7711    }
7712    cli.feature_toggles.apply(&mut config)?;
7713    Ok((config, profile))
7714}
7715
7716/// Apply the same reasoning-preference precedence as interactive `App`
7717/// construction to non-TUI runtimes.
7718///
7719/// `/model` and the config editor persist this preference in `settings.toml`.
7720/// Exec, review, workflow, ACP, and runtime-thread launches all begin with a
7721/// `Config`, so copying the saved value here keeps those entry points from
7722/// silently falling back to a route classifier or an older config.toml value.
7723fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::settings::Settings) {
7724    let Some(reasoning_effort) = settings.reasoning_effort.as_ref() else {
7725        return;
7726    };
7727    config.reasoning_effort = Some(reasoning_effort.clone());
7728    config.reasoning_effort_inferred_from_legacy_alias = false;
7729}
7730
7731fn read_api_key_from_stdin() -> Result<String> {
7732    let mut stdin = io::stdin();
7733    if stdin.is_terminal() {
7734        bail!("No API key provided. Pass --api-key or pipe one via stdin.");
7735    }
7736    let mut buffer = String::new();
7737    stdin.read_to_string(&mut buffer)?;
7738    let api_key = buffer.trim().to_string();
7739    if api_key.is_empty() {
7740        bail!("No API key provided via stdin.");
7741    }
7742    Ok(api_key)
7743}
7744
7745fn run_login(api_key: Option<String>) -> Result<()> {
7746    let api_key = match api_key {
7747        Some(key) => key,
7748        None => read_api_key_from_stdin()?,
7749    };
7750    let saved = config::save_api_key(&api_key)?;
7751    println!("Saved API key to {}", saved.describe());
7752    Ok(())
7753}
7754
7755fn run_logout() -> Result<()> {
7756    config::clear_api_key()?;
7757    println!("Cleared saved API key.");
7758    Ok(())
7759}
7760
7761async fn run_xai_device_auth(config_path: Option<&Path>) -> Result<()> {
7762    let pending = xai_oauth::device_code_login().await?;
7763    let activation = xai_oauth::activate_device_login(pending, config_path, None)?;
7764    println!(
7765        "xAI OAuth is ready; activated {} via {}",
7766        codewhale_config::quote_os_path(&activation.auth_path),
7767        codewhale_config::quote_os_path(&activation.config_path)
7768    );
7769    Ok(())
7770}
7771
7772fn resolve_session_id(session_id: Option<String>, last: bool, workspace: &Path) -> Result<String> {
7773    if last {
7774        return latest_session_id_for_workspace(workspace)?.ok_or_else(|| {
7775            anyhow!(
7776                "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume <SESSION_ID>` to resume one explicitly.",
7777                workspace.display()
7778            )
7779        });
7780    }
7781    if let Some(id) = session_id {
7782        return Ok(id);
7783    }
7784    pick_session_id()
7785}
7786
7787fn latest_session_id_for_workspace(workspace: &Path) -> std::io::Result<Option<String>> {
7788    let manager = SessionManager::default_location()?;
7789    Ok(manager
7790        .get_latest_session_for_workspace(workspace)?
7791        .map(|session| session.id))
7792}
7793
7794fn fork_session(
7795    config: &Config,
7796    session_id: Option<String>,
7797    last: bool,
7798    workspace: &Path,
7799) -> Result<String> {
7800    let manager = SessionManager::default_location()?;
7801    let saved = if last {
7802        let Some(meta) = manager.get_latest_session_for_workspace(workspace)? else {
7803            bail!(
7804                "No saved sessions found for workspace {}.",
7805                workspace.display()
7806            );
7807        };
7808        manager.load_session(&meta.id)?
7809    } else {
7810        let id = resolve_session_id(session_id, false, workspace)?;
7811        manager.load_session_by_prefix(&id)?
7812    };
7813    let saved_provider_identity = saved
7814        .metadata
7815        .model_provider_id
7816        .as_deref()
7817        .filter(|identity| !identity.trim().is_empty())
7818        .unwrap_or(&saved.metadata.model_provider);
7819    let provider_identity = config
7820        .resolve_persisted_provider_identity(
7821            Some(&saved.metadata.model_provider),
7822            saved.metadata.model_provider_id.as_deref(),
7823        )
7824        .map_err(anyhow::Error::msg)
7825        .with_context(|| {
7826            format!(
7827                "saved session provider '{}' is unavailable; fork will not fall back",
7828                saved_provider_identity
7829            )
7830        })?;
7831
7832    let system_prompt = saved
7833        .system_prompt
7834        .as_ref()
7835        .map(|text| SystemPrompt::Text(text.clone()));
7836    let mut forked = create_saved_session(
7837        &saved.messages,
7838        &saved.metadata.model,
7839        &saved.metadata.workspace,
7840        saved.metadata.total_tokens,
7841        system_prompt.as_ref(),
7842    );
7843    forked.metadata.set_model_provider_route(
7844        provider_identity.provider.as_str(),
7845        provider_identity.persisted_id(),
7846    );
7847    forked.metadata.copy_cost_from(&saved.metadata);
7848    forked.metadata.mark_forked_from(&saved.metadata);
7849    manager.save_session(&forked)?;
7850
7851    let source_title = saved.metadata.title.trim();
7852    let source_label = if source_title.is_empty() {
7853        "session".to_string()
7854    } else {
7855        format!("\"{source_title}\"")
7856    };
7857    println!(
7858        "Forked {source_label} ({source_id}) → new session {new_id}",
7859        source_id = truncate_id(&saved.metadata.id),
7860        new_id = truncate_id(&forked.metadata.id),
7861    );
7862
7863    Ok(forked.metadata.id)
7864}
7865
7866fn pick_session_id() -> Result<String> {
7867    let manager = SessionManager::default_location()?;
7868    let sessions = manager.list_sessions()?;
7869    if sessions.is_empty() {
7870        bail!("No saved sessions found.");
7871    }
7872
7873    println!("Select a session to resume:");
7874    for (idx, session) in sessions.iter().enumerate() {
7875        println!("  {:>2}. {} ({})", idx + 1, session.title, session.id);
7876    }
7877    print!("Enter a number (or press Enter to cancel): ");
7878    io::stdout().flush()?;
7879
7880    let mut input = String::new();
7881    io::stdin().read_line(&mut input)?;
7882    let input = input.trim();
7883    if input.is_empty() {
7884        bail!("No session selected.");
7885    }
7886    let idx: usize = input
7887        .parse()
7888        .map_err(|_| anyhow::anyhow!("Invalid input"))?;
7889    let session = sessions
7890        .get(idx.saturating_sub(1))
7891        .ok_or_else(|| anyhow::anyhow!("Selection out of range"))?;
7892    Ok(session.id.clone())
7893}
7894
7895async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
7896    use crate::client::DeepSeekClient;
7897
7898    let diff = collect_diff(&args)?;
7899    if diff.trim().is_empty() {
7900        bail!("No diff to review.");
7901    }
7902    validate_review_receipt_args(&args)?;
7903    if args.check_receipt {
7904        return run_review_receipt_check(&diff, &args);
7905    }
7906
7907    let model = resolve_review_model(config, args.model.as_deref());
7908    let route = resolve_cli_exec_route(config, &model, &diff, args.model.is_none()).await?;
7909    let execution_config = config_for_cli_route(config, &route);
7910    let route_provider = execution_config.provider_identity_for(route.provider);
7911    let model = route.model.clone();
7912    let user_prompt =
7913        format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
7914    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
7915        cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
7916    });
7917
7918    let system = SystemPrompt::Text(
7919        "You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
7920Provide findings ordered by severity with file references, then open questions, then a brief summary."
7921            .to_string(),
7922    );
7923    let client = DeepSeekClient::new(&execution_config)?;
7924    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
7925    let request = MessageRequest {
7926        model: model.clone(),
7927        messages: vec![Message {
7928            role: "user".to_string(),
7929            content: vec![ContentBlock::Text {
7930                text: user_prompt,
7931                cache_control: None,
7932            }],
7933        }],
7934        max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
7935            request_route.provider,
7936            &request_route.model,
7937            None,
7938        ),
7939        system: Some(system),
7940        tools: None,
7941        tool_choice: None,
7942        metadata: None,
7943        thinking: None,
7944        reasoning_effort,
7945        stream: Some(false),
7946        temperature: None,
7947        top_p: None,
7948    };
7949
7950    let response = client.create_message(request).await?;
7951    let review_stop_reason = response.stop_reason.clone();
7952    let review_incomplete = crate::models::is_incomplete_stop_reason(review_stop_reason.as_deref());
7953    let mut output = String::new();
7954    for block in response.content {
7955        if let ContentBlock::Text { text, .. } = block {
7956            output.push_str(&text);
7957        }
7958    }
7959    // A truncated review must not become a receipt or a success. The partial
7960    // text is still printed for diagnostics below.
7961    let receipt = if args.write_receipt && !review_incomplete {
7962        let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
7963        let receipt = crate::tools::review::build_review_receipt(
7964            review_target_label(&args),
7965            &diff,
7966            &route_provider,
7967            &model,
7968            &parsed_output,
7969            &output,
7970            Vec::new(),
7971        );
7972        let path =
7973            crate::tools::review::write_review_receipt(&receipt, args.receipt_path.as_deref())?;
7974        Some((path, receipt))
7975    } else {
7976        None
7977    };
7978    let review_error = review_incomplete.then(|| {
7979        format!(
7980            "Model response incomplete: provider stop reason `{}`; the partial review was not accepted.",
7981            crate::models::stop_reason_detail(review_stop_reason.as_deref())
7982        )
7983    });
7984    if args.json {
7985        println!(
7986            "{}",
7987            serde_json::to_string_pretty(&serde_json::json!({
7988                "mode": "review",
7989                "provider": route_provider,
7990                "model": model,
7991                "success": !review_incomplete,
7992                "content": output,
7993                "stop_reason": review_stop_reason,
7994                "error": review_error,
7995                "receipt_path": receipt
7996                    .as_ref()
7997                    .map(|(path, _)| path.display().to_string()),
7998                "receipt": receipt.as_ref().map(|(_, receipt)| receipt),
7999            }))?
8000        );
8001        if let Some(error) = review_error {
8002            anyhow::bail!(error);
8003        }
8004    } else {
8005        println!("{output}");
8006        if let Some((path, _)) = receipt {
8007            eprintln!("Review receipt written: {}", path.display());
8008        }
8009        if let Some(error) = review_error {
8010            anyhow::bail!(error);
8011        }
8012    }
8013    Ok(())
8014}
8015
8016fn resolve_review_model(config: &Config, explicit_model: Option<&str>) -> String {
8017    explicit_model
8018        .map(str::trim)
8019        .filter(|model| !model.is_empty())
8020        .map(str::to_string)
8021        .unwrap_or_else(|| config.default_model())
8022}
8023
8024fn validate_review_receipt_args(args: &ReviewArgs) -> Result<()> {
8025    if args.receipt_path.is_some() && !args.write_receipt && !args.check_receipt {
8026        bail!("--receipt-path requires --write-receipt or --check-receipt");
8027    }
8028    if args.write_receipt && args.check_receipt {
8029        bail!("--write-receipt and --check-receipt are mutually exclusive");
8030    }
8031    Ok(())
8032}
8033
8034fn run_review_receipt_check(diff: &str, args: &ReviewArgs) -> Result<()> {
8035    let (path, receipt) = if let Some(path) = args.receipt_path.as_ref() {
8036        (
8037            path.clone(),
8038            crate::tools::review::read_review_receipt(path)
8039                .with_context(|| format!("failed to read review receipt {}", path.display()))?,
8040        )
8041    } else {
8042        crate::tools::review::latest_review_receipt_for_diff(diff)?.ok_or_else(|| {
8043            anyhow!(
8044                "No review receipt found for the current diff. Run `codewhale review --write-receipt` first, or pass --receipt-path."
8045            )
8046        })?
8047    };
8048    let validation =
8049        crate::tools::review::validate_review_receipt_for_diff(diff, &receipt, Some(path.clone()));
8050
8051    if args.json {
8052        println!(
8053            "{}",
8054            serde_json::to_string_pretty(&serde_json::json!({
8055                "mode": "review_receipt_check",
8056                "success": validation.passed,
8057                "validation": review_receipt_validation_public_json(&validation),
8058            }))?
8059        );
8060    } else if validation.passed {
8061        println!("Review receipt valid: {}", path.display());
8062    }
8063
8064    if !validation.passed {
8065        bail!("Review receipt check failed: {}", validation.reason);
8066    }
8067    Ok(())
8068}
8069
8070fn review_receipt_validation_public_json(
8071    validation: &crate::tools::review::ReviewReceiptValidation,
8072) -> serde_json::Value {
8073    let unresolved_risk = validation.unresolved_risk.as_ref();
8074    serde_json::json!({
8075        "passed": validation.passed,
8076        "status": review_receipt_validation_status(validation),
8077        "diff_fingerprint": validation.diff_fingerprint.as_str(),
8078        "receipt_fingerprint": validation.receipt_fingerprint.as_deref(),
8079        "unresolved": unresolved_risk.is_some_and(|risk| risk.unresolved),
8080        "risk_level": unresolved_risk.map(|risk| risk.level.as_str()),
8081    })
8082}
8083
8084fn review_receipt_validation_status(
8085    validation: &crate::tools::review::ReviewReceiptValidation,
8086) -> &'static str {
8087    if validation.passed {
8088        "valid"
8089    } else if validation
8090        .receipt_fingerprint
8091        .as_deref()
8092        .is_some_and(|fingerprint| fingerprint != validation.diff_fingerprint.as_str())
8093    {
8094        "diff_mismatch"
8095    } else if validation
8096        .unresolved_risk
8097        .as_ref()
8098        .is_some_and(|risk| risk.unresolved)
8099    {
8100        "unresolved_risk"
8101    } else if validation
8102        .reason
8103        .starts_with("unsupported review receipt schema version")
8104    {
8105        "unsupported_schema"
8106    } else if validation.reason.starts_with("review receipt check ") {
8107        "check_failed"
8108    } else {
8109        "invalid"
8110    }
8111}
8112
8113/// `codewhale pr <N>` (#451) — fetch a GitHub PR via `gh`, format
8114/// title + body + diff as the composer's first message, and launch
8115/// the interactive TUI. Falls back gracefully if `gh` is missing.
8116async fn run_pr(
8117    cli: &Cli,
8118    config: &Config,
8119    number: u32,
8120    repo: Option<&str>,
8121    checkout: bool,
8122    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
8123    plugin_registry: Arc<crate::plugins::PluginRegistry>,
8124) -> Result<()> {
8125    if !is_command_available("gh") {
8126        bail!(
8127            "`gh` CLI not found on PATH. Install GitHub CLI \
8128             (https://cli.github.com) and authenticate (`gh auth login`) \
8129             so `codewhale pr <N>` can fetch PR metadata and the diff."
8130        );
8131    }
8132
8133    let view = run_gh_pr_view(number, repo)?;
8134    let diff = run_gh_pr_diff(number, repo)?;
8135
8136    if checkout {
8137        match run_gh_pr_checkout(number, repo) {
8138            Ok(()) => eprintln!("Checked out PR #{number} into the current workspace."),
8139            Err(err) => eprintln!(
8140                "warning: gh pr checkout #{number} failed ({err}). Continuing without checkout."
8141            ),
8142        }
8143    }
8144
8145    let prompt = format_pr_prompt(number, &view, &diff);
8146    let resume_session_id = if cli.continue_session {
8147        let workspace = resolve_workspace(cli);
8148        latest_session_id_for_workspace(&workspace).ok().flatten()
8149    } else {
8150        cli.resume.clone()
8151    };
8152    run_interactive(
8153        cli,
8154        config,
8155        resume_session_id,
8156        Some(tui::InitialInput::Prefill(prompt)),
8157        pending_telemetry_notice,
8158        plugin_registry,
8159    )
8160    .await
8161}
8162
8163/// Return true if `name` resolves to an executable on the current `PATH`.
8164///
8165/// Walks `$PATH` directly instead of probing with `--version`. The
8166/// previous implementation invoked `Command::new(name).arg("--version")`,
8167/// which fails on the Ubuntu CI runner because `/bin/sh` is `dash` —
8168/// `dash --version` exits with status 2 ("invalid option") even though
8169/// `sh` is plainly on PATH. macOS happens to ship bash as `sh`, which
8170/// does honor `--version`, so the bug was invisible locally and only
8171/// surfaced in CI logs.
8172///
8173/// Windows: also checks the `.exe` extension when `name` doesn't have
8174/// one, matching the platform's PATHEXT lookup behavior for the common
8175/// case.
8176fn is_command_available(name: &str) -> bool {
8177    let Some(path) = std::env::var_os("PATH") else {
8178        return false;
8179    };
8180    for dir in std::env::split_paths(&path) {
8181        let candidate = dir.join(name);
8182        if candidate.is_file() {
8183            return true;
8184        }
8185        #[cfg(windows)]
8186        {
8187            // PATHEXT gives `.exe`/`.cmd`/`.bat` etc. priority — we only
8188            // probe `.exe` because that's the case that actually trips
8189            // up the negative case (`gh` resolves as `gh.exe`).
8190            if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
8191                return true;
8192            }
8193        }
8194    }
8195    false
8196}
8197
8198#[derive(Debug, Clone, Default)]
8199struct GhPullRequest {
8200    title: String,
8201    body: String,
8202    base: String,
8203    head: String,
8204    url: String,
8205}
8206
8207fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
8208    let mut cmd = crate::dependencies::Gh::command()
8209        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8210    cmd.arg("pr").arg("view").arg(number.to_string());
8211    if let Some(r) = repo {
8212        cmd.arg("--repo").arg(r);
8213    }
8214    cmd.arg("--json")
8215        .arg("title,body,baseRefName,headRefName,url");
8216    let output = cmd
8217        .output()
8218        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
8219    if !output.status.success() {
8220        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8221        bail!("gh pr view #{number} failed: {stderr}");
8222    }
8223    let raw = String::from_utf8_lossy(&output.stdout).to_string();
8224    let value: serde_json::Value = serde_json::from_str(&raw)
8225        .map_err(|e| anyhow::anyhow!("gh pr view returned non-JSON output: {e}"))?;
8226    let pick = |key: &str| {
8227        value
8228            .get(key)
8229            .and_then(serde_json::Value::as_str)
8230            .unwrap_or_default()
8231            .to_string()
8232    };
8233    Ok(GhPullRequest {
8234        title: pick("title"),
8235        body: pick("body"),
8236        base: pick("baseRefName"),
8237        head: pick("headRefName"),
8238        url: pick("url"),
8239    })
8240}
8241
8242fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result<String> {
8243    let mut cmd = crate::dependencies::Gh::command()
8244        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8245    cmd.arg("pr").arg("diff").arg(number.to_string());
8246    if let Some(r) = repo {
8247        cmd.arg("--repo").arg(r);
8248    }
8249    let output = cmd
8250        .output()
8251        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr diff`: {e}"))?;
8252    if !output.status.success() {
8253        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8254        bail!("gh pr diff #{number} failed: {stderr}");
8255    }
8256    Ok(String::from_utf8_lossy(&output.stdout).to_string())
8257}
8258
8259fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
8260    let mut cmd = crate::dependencies::Gh::command()
8261        .ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
8262    cmd.arg("pr").arg("checkout").arg(number.to_string());
8263    if let Some(r) = repo {
8264        cmd.arg("--repo").arg(r);
8265    }
8266    let output = cmd
8267        .output()
8268        .map_err(|e| anyhow::anyhow!("Failed to run `gh pr checkout`: {e}"))?;
8269    if !output.status.success() {
8270        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
8271        bail!("gh pr checkout #{number} failed: {stderr}");
8272    }
8273    Ok(())
8274}
8275
8276/// Format the PR review prompt that lands in the composer. Caps the
8277/// diff at 200 KiB so a massive PR doesn't blow the model's context
8278/// window before the user even hits Enter — they can always ask the
8279/// model to fetch more via `gh pr diff #N` from inside the session.
8280fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
8281    const MAX_DIFF_BYTES: usize = 200 * 1024;
8282    let diff_section = if diff.len() > MAX_DIFF_BYTES {
8283        let cut = (0..=MAX_DIFF_BYTES)
8284            .rev()
8285            .find(|&i| diff.is_char_boundary(i))
8286            .unwrap_or(0);
8287        format!(
8288            "{}\n\n[…diff truncated at {} KiB; ask me to fetch more if needed]\n",
8289            &diff[..cut],
8290            MAX_DIFF_BYTES / 1024
8291        )
8292    } else {
8293        diff.to_string()
8294    };
8295    let body = if view.body.trim().is_empty() {
8296        "(no description)".to_string()
8297    } else {
8298        view.body.trim().to_string()
8299    };
8300    let title = if view.title.trim().is_empty() {
8301        format!("(PR #{number})")
8302    } else {
8303        view.title.trim().to_string()
8304    };
8305    let branches = match (view.base.is_empty(), view.head.is_empty()) {
8306        (false, false) => format!("{} ← {}", view.base, view.head),
8307        (false, true) => view.base.clone(),
8308        (true, false) => view.head.clone(),
8309        _ => "(unknown)".to_string(),
8310    };
8311    format!(
8312        "Review PR #{number} — {title}\n\
8313         \n\
8314         URL: {url}\n\
8315         Branches: {branches}\n\
8316         \n\
8317         ## Description\n\
8318         \n\
8319         {body}\n\
8320         \n\
8321         ## Diff\n\
8322         \n\
8323         ```diff\n\
8324         {diff_section}\n\
8325         ```\n",
8326        url = if view.url.is_empty() {
8327            "(unavailable)"
8328        } else {
8329            view.url.as_str()
8330        },
8331    )
8332}
8333
8334fn collect_diff(args: &ReviewArgs) -> Result<String> {
8335    let mut cmd = crate::dependencies::Git::command()
8336        .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
8337    cmd.arg("diff");
8338    if args.staged {
8339        cmd.arg("--cached");
8340    }
8341    if let Some(base) = &args.base {
8342        cmd.arg(format!("{base}...HEAD"));
8343    }
8344    if let Some(path) = &args.path {
8345        cmd.arg("--").arg(path);
8346    }
8347
8348    let output = cmd
8349        .output()
8350        .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
8351    if !output.status.success() {
8352        let stderr = String::from_utf8_lossy(&output.stderr);
8353        bail!("git diff failed: {}", stderr.trim());
8354    }
8355    let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
8356    if diff.len() > args.max_chars {
8357        diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
8358    }
8359    Ok(diff)
8360}
8361
8362fn review_target_label(args: &ReviewArgs) -> String {
8363    let mut label = if args.staged {
8364        "staged".to_string()
8365    } else if let Some(base) = args
8366        .base
8367        .as_deref()
8368        .map(str::trim)
8369        .filter(|base| !base.is_empty())
8370    {
8371        format!("base:{base}")
8372    } else {
8373        "working-tree".to_string()
8374    };
8375    if let Some(path) = &args.path {
8376        label.push(' ');
8377        label.push_str(path.to_string_lossy().as_ref());
8378    }
8379    label
8380}
8381
8382fn run_apply(args: ApplyArgs) -> Result<()> {
8383    let patch = if let Some(path) = args.patch_file {
8384        std::fs::read_to_string(&path)
8385            .map_err(|e| anyhow::anyhow!("Failed to read patch {}: {}", path.display(), e))?
8386    } else {
8387        read_patch_from_stdin()?
8388    };
8389    if patch.trim().is_empty() {
8390        bail!("Patch is empty.");
8391    }
8392
8393    let mut tmp = NamedTempFile::new()?;
8394    tmp.write_all(patch.as_bytes())?;
8395    let tmp_path = tmp.path().to_path_buf();
8396
8397    let output = crate::dependencies::Git::command()
8398        .ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?
8399        .arg("apply")
8400        .arg("--whitespace=nowarn")
8401        .arg(&tmp_path)
8402        .output()
8403        .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?;
8404
8405    if !output.status.success() {
8406        let stderr = String::from_utf8_lossy(&output.stderr);
8407        bail!("git apply failed: {}", stderr.trim());
8408    }
8409    println!("Applied patch successfully.");
8410    Ok(())
8411}
8412
8413fn read_patch_from_stdin() -> Result<String> {
8414    let mut stdin = io::stdin();
8415    if stdin.is_terminal() {
8416        bail!("No patch file provided and stdin is empty.");
8417    }
8418    let mut buffer = String::new();
8419    stdin.read_to_string(&mut buffer)?;
8420    Ok(buffer)
8421}
8422
8423async fn run_mcp_command(
8424    config: &Config,
8425    workspace: &Path,
8426    command: McpCommand,
8427    plugins: &crate::plugins::PluginRegistry,
8428) -> Result<()> {
8429    let config_path = config.mcp_config_path();
8430    match command {
8431        McpCommand::Init { force } => {
8432            let status = init_mcp_config(&config_path, force)?;
8433            match status {
8434                WriteStatus::Created => {
8435                    println!("Created MCP config at {}", config_path.display());
8436                }
8437                WriteStatus::Overwritten => {
8438                    println!("Overwrote MCP config at {}", config_path.display());
8439                }
8440                WriteStatus::SkippedExists => {
8441                    println!(
8442                        "MCP config already exists at {} (use --force to overwrite)",
8443                        config_path.display()
8444                    );
8445                }
8446            }
8447            println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`.");
8448            Ok(())
8449        }
8450        McpCommand::List => {
8451            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8452                &config_path,
8453                workspace,
8454                plugins,
8455            )?;
8456            if cfg.servers.is_empty() {
8457                println!(
8458                    "No MCP servers configured in {} or {}",
8459                    config_path.display(),
8460                    crate::mcp::workspace_mcp_config_path(workspace).display()
8461                );
8462                return Ok(());
8463            }
8464            println!("MCP servers ({}):", cfg.servers.len());
8465            for (name, server) in cfg.servers {
8466                let status = if server.enabled && !server.disabled {
8467                    "enabled"
8468                } else {
8469                    "disabled"
8470                };
8471                let auth_status = crate::mcp::oauth::auth_status_for_server(&name, &server).await;
8472                let auth = if auth_status == crate::mcp::oauth::McpAuthStatus::Unsupported {
8473                    String::new()
8474                } else {
8475                    format!(
8476                        " auth={}",
8477                        auth_status
8478                            .to_string()
8479                            .to_ascii_lowercase()
8480                            .replace(' ', "-")
8481                    )
8482                };
8483                let args = if server.args.is_empty() {
8484                    "".to_string()
8485                } else {
8486                    format!(" {}", server.args.join(" "))
8487                };
8488                let cmd_str = if let Some(cmd) = server.command {
8489                    format!("{cmd}{args}")
8490                } else if let Some(url) = server.url {
8491                    url
8492                } else {
8493                    "unknown".to_string()
8494                };
8495                let required = if server.required { " required" } else { "" };
8496                println!("  - {name} [{status}{required}{auth}] {cmd_str}");
8497            }
8498            Ok(())
8499        }
8500        McpCommand::Connect { server } => {
8501            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8502                &config_path,
8503                workspace,
8504                std::sync::Arc::new(plugins.clone()),
8505            )?;
8506            if let Some(name) = server {
8507                if let Err(err) = pool.get_or_connect(&name).await {
8508                    if crate::mcp::oauth::error_looks_auth_required(&err) {
8509                        let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8510                        return Err(err).context(hint);
8511                    }
8512                    return Err(err);
8513                }
8514                println!("Connected to MCP server: {name}");
8515            } else {
8516                let errors = pool.connect_all().await;
8517                if errors.is_empty() {
8518                    println!("Connected to all configured MCP servers.");
8519                } else {
8520                    for (name, err) in errors {
8521                        eprintln!("Failed to connect {name}: {err:#}");
8522                        if crate::mcp::oauth::error_looks_auth_required(&err) {
8523                            eprintln!("  {}", crate::mcp::oauth::auth_required_login_hint(&name));
8524                        }
8525                    }
8526                }
8527            }
8528            Ok(())
8529        }
8530        McpCommand::Tools { server } => {
8531            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8532                &config_path,
8533                workspace,
8534                std::sync::Arc::new(plugins.clone()),
8535            )?;
8536            if let Some(name) = server {
8537                let conn = match pool.get_or_connect(&name).await {
8538                    Ok(conn) => conn,
8539                    Err(err) => {
8540                        if crate::mcp::oauth::error_looks_auth_required(&err) {
8541                            let hint = crate::mcp::oauth::auth_required_login_hint(&name);
8542                            return Err(err).context(hint);
8543                        }
8544                        return Err(err);
8545                    }
8546                };
8547                if conn.tools().is_empty() {
8548                    println!("No tools found for MCP server: {name}");
8549                } else {
8550                    println!("Tools for {name}:");
8551                    for tool in conn.tools() {
8552                        println!(
8553                            "  - {}{}",
8554                            tool.name,
8555                            crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8556                        );
8557                    }
8558                }
8559            } else {
8560                let errors = pool.connect_all().await;
8561                for (name, err) in errors {
8562                    eprintln!("Failed to connect {name}: {err:#}");
8563                    if crate::mcp::oauth::error_looks_auth_required(&err) {
8564                        eprintln!("  {}", crate::mcp::oauth::auth_required_login_hint(&name));
8565                    }
8566                }
8567                let tools = pool.all_tools();
8568                if tools.is_empty() {
8569                    println!("No MCP tools discovered.");
8570                } else {
8571                    println!("MCP tools:");
8572                    for (name, tool) in tools {
8573                        println!(
8574                            "  - {}{}",
8575                            name,
8576                            crate::mcp::format_mcp_tool_description(tool.description.as_deref())
8577                        );
8578                    }
8579                }
8580            }
8581            Ok(())
8582        }
8583        McpCommand::Add {
8584            name,
8585            command,
8586            url,
8587            transport,
8588            bearer_token_env_var,
8589            oauth_client_id,
8590            oauth_resource,
8591            scopes,
8592            args,
8593        } => {
8594            if command.is_none() && url.is_none() {
8595                bail!("Provide either --command or --url for `mcp add`.");
8596            }
8597            if let Some(transport) = transport.as_deref()
8598                && !transport.trim().eq_ignore_ascii_case("sse")
8599            {
8600                bail!("Unsupported MCP transport '{transport}'. Supported values: sse");
8601            }
8602            let added_server = McpServerConfig {
8603                command,
8604                args,
8605                env: std::collections::HashMap::new(),
8606                cwd: None,
8607                url,
8608                transport,
8609                connect_timeout: None,
8610                execute_timeout: None,
8611                read_timeout: None,
8612                disabled: false,
8613                enabled: true,
8614                required: false,
8615                enabled_tools: Vec::new(),
8616                disabled_tools: Vec::new(),
8617                headers: std::collections::HashMap::new(),
8618                env_headers: std::collections::HashMap::new(),
8619                bearer_token_env_var,
8620                scopes,
8621                oauth: oauth_client_id.map(|client_id| McpServerOAuthConfig {
8622                    client_id: Some(client_id),
8623                }),
8624                oauth_resource,
8625                reviewed_plugin: None,
8626            };
8627            let can_suggest_oauth = added_server.url.is_some()
8628                && added_server.bearer_token_env_var.is_none()
8629                && added_server
8630                    .headers
8631                    .keys()
8632                    .all(|key| !key.trim().eq_ignore_ascii_case("authorization"))
8633                && added_server
8634                    .env_headers
8635                    .keys()
8636                    .all(|key| !key.trim().eq_ignore_ascii_case("authorization"));
8637            let mut cfg = load_mcp_config(&config_path)?;
8638            cfg.servers.insert(name.clone(), added_server.clone());
8639            save_mcp_config(&config_path, &cfg)?;
8640            println!("Added MCP server '{name}' in {}", config_path.display());
8641            if can_suggest_oauth
8642                && crate::mcp::oauth::oauth_login_support(&added_server)
8643                    .await
8644                    .is_ok_and(|support| support.is_some())
8645            {
8646                println!(
8647                    "OAuth is available for '{name}'. Run `codewhale mcp login {name}` to authenticate."
8648                );
8649            }
8650            Ok(())
8651        }
8652        McpCommand::Login { name, scopes } => {
8653            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8654                &config_path,
8655                workspace,
8656                plugins,
8657            )?;
8658            let server = cfg
8659                .servers
8660                .get(&name)
8661                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8662            let explicit_scopes = (!scopes.is_empty()).then_some(scopes);
8663            crate::mcp::oauth::perform_oauth_login_for_server(
8664                &name,
8665                server,
8666                explicit_scopes,
8667                config.mcp_oauth_callback_port,
8668                config.mcp_oauth_callback_url.as_deref(),
8669            )
8670            .await?;
8671            println!("Stored OAuth credentials for MCP server '{name}'.");
8672            Ok(())
8673        }
8674        McpCommand::Logout { name } => {
8675            let cfg = crate::mcp::load_config_with_workspace_and_plugins(
8676                &config_path,
8677                workspace,
8678                plugins,
8679            )?;
8680            let server = cfg
8681                .servers
8682                .get(&name)
8683                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8684            if crate::mcp::oauth::delete_oauth_tokens_for_server(&name, server)? {
8685                println!("Deleted stored OAuth credentials for MCP server '{name}'.");
8686            } else {
8687                println!("No stored OAuth credentials found for MCP server '{name}'.");
8688            }
8689            Ok(())
8690        }
8691        McpCommand::Remove { name } => {
8692            let mut cfg = load_mcp_config(&config_path)?;
8693            if cfg.servers.remove(&name).is_none() {
8694                bail!("MCP server '{name}' not found");
8695            }
8696            save_mcp_config(&config_path, &cfg)?;
8697            println!("Removed MCP server '{name}'");
8698            Ok(())
8699        }
8700        McpCommand::Enable { name } => {
8701            let mut cfg = load_mcp_config(&config_path)?;
8702            let server = cfg
8703                .servers
8704                .get_mut(&name)
8705                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8706            server.enabled = true;
8707            server.disabled = false;
8708            save_mcp_config(&config_path, &cfg)?;
8709            println!("Enabled MCP server '{name}'");
8710            Ok(())
8711        }
8712        McpCommand::Disable { name } => {
8713            let mut cfg = load_mcp_config(&config_path)?;
8714            let server = cfg
8715                .servers
8716                .get_mut(&name)
8717                .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
8718            server.enabled = false;
8719            server.disabled = true;
8720            save_mcp_config(&config_path, &cfg)?;
8721            println!("Disabled MCP server '{name}'");
8722            Ok(())
8723        }
8724        McpCommand::Validate => {
8725            let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
8726                &config_path,
8727                workspace,
8728                std::sync::Arc::new(plugins.clone()),
8729            )?;
8730            let errors = pool.connect_all().await;
8731            if errors.is_empty() {
8732                println!("MCP config is valid. All enabled servers connected.");
8733                return Ok(());
8734            }
8735            eprintln!("MCP validation failed:");
8736            for (name, err) in errors {
8737                eprintln!("  - {name}: {err:#}");
8738            }
8739            bail!("one or more MCP servers failed validation");
8740        }
8741        McpCommand::AddSelf { name, workspace } => {
8742            let exe_path = std::env::current_exe()
8743                .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?;
8744            let exe_str = exe_path.to_string_lossy().to_string();
8745
8746            let mut args = vec!["serve".to_string(), "--mcp".to_string()];
8747            if let Some(ref ws) = workspace {
8748                args.push("--workspace".to_string());
8749                args.push(ws.clone());
8750            }
8751
8752            let mut cfg = load_mcp_config(&config_path)?;
8753            if cfg.servers.contains_key(&name) {
8754                bail!(
8755                    "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.",
8756                    config_path.display()
8757                );
8758            }
8759            cfg.servers.insert(
8760                name.clone(),
8761                McpServerConfig {
8762                    command: Some(exe_str.clone()),
8763                    args,
8764                    env: std::collections::HashMap::new(),
8765                    cwd: None,
8766                    url: None,
8767                    transport: None,
8768                    connect_timeout: None,
8769                    execute_timeout: None,
8770                    read_timeout: None,
8771                    disabled: false,
8772                    enabled: true,
8773                    required: false,
8774                    enabled_tools: Vec::new(),
8775                    disabled_tools: Vec::new(),
8776                    headers: std::collections::HashMap::new(),
8777                    env_headers: std::collections::HashMap::new(),
8778                    bearer_token_env_var: None,
8779                    scopes: Vec::new(),
8780                    oauth: None,
8781                    oauth_resource: None,
8782                    reviewed_plugin: None,
8783                },
8784            );
8785            save_mcp_config(&config_path, &cfg)?;
8786            println!(
8787                "Registered Codewhale as MCP server '{name}' in {}",
8788                config_path.display()
8789            );
8790            println!("  command: {exe_str}");
8791            println!(
8792                "  args:    serve --mcp{}",
8793                workspace.map_or(String::new(), |ws| format!(" --workspace {ws}"))
8794            );
8795            println!();
8796            println!("Tip: Use `codewhale mcp validate` to test the connection.");
8797            println!("     Use `codewhale serve --http` for the HTTP/SSE runtime API instead.");
8798            Ok(())
8799        }
8800    }
8801}
8802
8803fn load_mcp_config(path: &Path) -> Result<McpConfig> {
8804    if !path.exists() {
8805        return Ok(McpConfig::default());
8806    }
8807    let contents = std::fs::read_to_string(path)
8808        .map_err(|e| anyhow::anyhow!("Failed to read MCP config {}: {}", path.display(), e))?;
8809    let cfg: McpConfig = serde_json::from_str(&contents).map_err(|_| {
8810        anyhow::anyhow!(
8811            "Failed to parse MCP config {}; file contents were omitted",
8812            codewhale_config::quote_os_path(path)
8813        )
8814    })?;
8815    Ok(cfg)
8816}
8817
8818/// Diagnostic status for an MCP server entry.
8819#[derive(Debug)]
8820enum McpServerDoctorStatus {
8821    Ok(String),
8822    Warning(String),
8823    Error(String),
8824}
8825
8826impl McpServerDoctorStatus {
8827    fn legacy_status(&self) -> &'static str {
8828        match self {
8829            Self::Ok(_) => "ok",
8830            Self::Warning(_) => "warning",
8831            Self::Error(_) => "error",
8832        }
8833    }
8834
8835    fn configuration_status(&self) -> &'static str {
8836        match self {
8837            Self::Ok(_) => "valid",
8838            Self::Warning(_) => "warning",
8839            Self::Error(_) => "invalid",
8840        }
8841    }
8842
8843    fn detail(&self) -> &str {
8844        match self {
8845            Self::Ok(detail) | Self::Warning(detail) | Self::Error(detail) => detail,
8846        }
8847    }
8848}
8849
8850/// Inspect command availability without starting the configured MCP server.
8851fn doctor_mcp_command_status(server: &McpServerConfig) -> McpCommandAvailability {
8852    if server.url.is_some() {
8853        return McpCommandAvailability::NotApplicable;
8854    }
8855    match server.command.as_deref() {
8856        Some("") => McpCommandAvailability::Missing,
8857        Some(_) | None => McpCommandAvailability::NotChecked,
8858    }
8859}
8860
8861fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::Value {
8862    use serde_json::json;
8863
8864    let status = doctor_check_mcp_server(server);
8865    json!({
8866        "name": name,
8867        "enabled": server.enabled && !server.disabled,
8868        // Compatibility field retained for existing doctor JSON consumers.
8869        // Its scope is now explicit in `checks.configuration` below.
8870        "status": status.legacy_status(),
8871        "detail": status.detail(),
8872        "transport": if server.url.is_some() { "http" } else { "stdio" },
8873        "endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
8874        "command_configured": server.command.is_some(),
8875        "args_count": server.args.len(),
8876        "env_count": server.env.len(),
8877        "headers_count": server.headers.len(),
8878        "env_headers_count": server.env_headers.len(),
8879        "check_scope": "configuration",
8880        "checks": {
8881            "configuration": {
8882                "status": status.configuration_status(),
8883                "detail": status.detail(),
8884            },
8885            "command": {
8886                "status": doctor_mcp_command_status(server).as_str(),
8887            },
8888            "process_reachable": {
8889                "status": "not_checked",
8890            },
8891            "protocol_initialized": {
8892                "status": "not_checked",
8893            },
8894            "backend_tool_health": {
8895                "status": "not_checked",
8896            },
8897        },
8898    })
8899}
8900
8901/// Check an MCP server config entry for common issues.
8902fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
8903    // No command or URL — incomplete entry.
8904    if server.command.is_none() && server.url.is_none() {
8905        return McpServerDoctorStatus::Error("no command or url configured".to_string());
8906    }
8907
8908    // URL-based server: omit userinfo, query, and fragment entirely.
8909    if let Some(ref url) = server.url {
8910        let authority = crate::doctor::structural_url_authority(url);
8911        return if authority.starts_with("unparseable") {
8912            McpServerDoctorStatus::Warning(
8913                "HTTP/SSE server URL is invalid; configured value omitted".to_string(),
8914            )
8915        } else {
8916            McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {authority}"))
8917        };
8918    }
8919
8920    // Command-based: validate command path exists.
8921    let cmd = server.command.as_deref().unwrap_or("");
8922    if cmd.is_empty() {
8923        return McpServerDoctorStatus::Error("empty command".to_string());
8924    }
8925
8926    if server.cwd.is_none() {
8927        if is_relative_stdio_path_arg(cmd) {
8928            return McpServerDoctorStatus::Warning(
8929                "stdio server uses a relative command without cwd; command value omitted"
8930                    .to_string(),
8931            );
8932        }
8933        if server
8934            .args
8935            .iter()
8936            .any(|arg| is_relative_stdio_path_arg(arg))
8937        {
8938            return McpServerDoctorStatus::Warning(
8939                "stdio server uses a relative path argument without cwd; argument values omitted"
8940                    .to_string(),
8941            );
8942        }
8943    }
8944
8945    McpServerDoctorStatus::Ok(format!(
8946        "stdio server configured (command omitted; {} argument(s), {} environment binding(s))",
8947        server.args.len(),
8948        server.env.len()
8949    ))
8950}
8951
8952fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
8953    if let Some(parent) = path.parent() {
8954        std::fs::create_dir_all(parent).with_context(|| {
8955            format!("Failed to create MCP config directory {}", parent.display())
8956        })?;
8957    }
8958    let rendered = serde_json::to_string_pretty(cfg)
8959        .map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
8960    crate::utils::write_atomic(path, rendered.as_bytes())
8961        .map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
8962    Ok(())
8963}
8964
8965fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
8966    use crate::sandbox::{CommandSpec, SandboxManager};
8967
8968    let SandboxCommand::Run {
8969        policy,
8970        network,
8971        writable_root,
8972        exclude_tmpdir,
8973        exclude_slash_tmp,
8974        cwd,
8975        timeout_ms,
8976        command,
8977    } = args.command;
8978
8979    let policy = parse_sandbox_policy(
8980        &policy,
8981        network,
8982        writable_root,
8983        exclude_tmpdir,
8984        exclude_slash_tmp,
8985    )?;
8986    let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
8987    let timeout = Duration::from_millis(timeout_ms.clamp(1000, 600_000));
8988
8989    let (program, args) = command
8990        .split_first()
8991        .ok_or_else(|| anyhow::anyhow!("Command is required"))?;
8992    let spec =
8993        CommandSpec::program(program, args.to_vec(), cwd.clone(), timeout).with_policy(policy);
8994    let manager = SandboxManager::new();
8995    let exec_env = manager.prepare(&spec);
8996
8997    let mut cmd = Command::new(exec_env.program());
8998    cmd.args(exec_env.args())
8999        .current_dir(&exec_env.cwd)
9000        .stdout(Stdio::piped())
9001        .stderr(Stdio::piped());
9002    child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env));
9003
9004    let mut child = cmd
9005        .spawn()
9006        .map_err(|e| anyhow::anyhow!("Failed to run command: {e}"))?;
9007    let stdout_handle = child
9008        .stdout
9009        .take()
9010        .ok_or_else(|| anyhow::anyhow!("stdout unavailable"))?;
9011    let stderr_handle = child
9012        .stderr
9013        .take()
9014        .ok_or_else(|| anyhow::anyhow!("stderr unavailable"))?;
9015
9016    let timeout = exec_env.timeout;
9017    let stdout_thread = std::thread::spawn(move || {
9018        let mut reader = stdout_handle;
9019        let mut buf = Vec::new();
9020        let _ = reader.read_to_end(&mut buf);
9021        buf
9022    });
9023    let stderr_thread = std::thread::spawn(move || {
9024        let mut reader = stderr_handle;
9025        let mut buf = Vec::new();
9026        let _ = reader.read_to_end(&mut buf);
9027        buf
9028    });
9029
9030    if let Some(status) = child.wait_timeout(timeout)? {
9031        let stdout = stdout_thread.join().unwrap_or_default();
9032        let stderr = stderr_thread.join().unwrap_or_default();
9033        let stderr_str = String::from_utf8_lossy(&stderr);
9034        let exit_code = status.code().unwrap_or(-1);
9035        let sandbox_type = exec_env.sandbox_type;
9036        let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
9037
9038        if !stdout.is_empty() {
9039            print!("{}", String::from_utf8_lossy(&stdout));
9040        }
9041        if !stderr.is_empty() {
9042            eprint!("{stderr_str}");
9043        }
9044        if sandbox_denied {
9045            eprintln!(
9046                "{}",
9047                SandboxManager::denial_message(sandbox_type, &stderr_str)
9048            );
9049        }
9050
9051        if !status.success() {
9052            bail!("Command failed with exit code {exit_code}");
9053        }
9054    } else {
9055        let _ = child.kill();
9056        let _ = child.wait();
9057        bail!("Command timed out after {}ms", timeout.as_millis());
9058    }
9059    Ok(())
9060}
9061
9062fn parse_sandbox_policy(
9063    policy: &str,
9064    network: bool,
9065    writable_root: Vec<PathBuf>,
9066    exclude_tmpdir: bool,
9067    exclude_slash_tmp: bool,
9068) -> Result<crate::sandbox::SandboxPolicy> {
9069    use crate::sandbox::SandboxPolicy;
9070
9071    match policy {
9072        "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
9073        "read-only" => Ok(SandboxPolicy::ReadOnly),
9074        "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
9075            network_access: network,
9076        }),
9077        "workspace-write" => Ok(SandboxPolicy::WorkspaceWrite {
9078            writable_roots: writable_root,
9079            network_access: network,
9080            exclude_tmpdir,
9081            exclude_slash_tmp,
9082        }),
9083        other => bail!("Unknown sandbox policy: {other}"),
9084    }
9085}
9086
9087fn should_use_alt_screen(_cli: &Cli, _config: &Config) -> bool {
9088    true
9089}
9090
9091fn should_use_mouse_capture(cli: &Cli, config: &Config, use_alt_screen: bool) -> bool {
9092    let terminal_emulator = std::env::var("TERMINAL_EMULATOR").ok();
9093    let wt_session = std::env::var("WT_SESSION").ok().filter(|s| !s.is_empty());
9094    let conemu_pid = std::env::var("ConEmuPID").ok().filter(|s| !s.is_empty());
9095    should_use_mouse_capture_with(
9096        cli,
9097        config,
9098        use_alt_screen,
9099        terminal_emulator.as_deref(),
9100        wt_session.as_deref(),
9101        conemu_pid.as_deref(),
9102    )
9103}
9104
9105fn should_use_mouse_capture_with(
9106    cli: &Cli,
9107    config: &Config,
9108    use_alt_screen: bool,
9109    terminal_emulator: Option<&str>,
9110    wt_session: Option<&str>,
9111    conemu_pid: Option<&str>,
9112) -> bool {
9113    if !use_alt_screen || cli.no_mouse_capture {
9114        return false;
9115    }
9116    if cli.mouse_capture {
9117        return true;
9118    }
9119    config
9120        .tui
9121        .as_ref()
9122        .and_then(|tui| tui.mouse_capture)
9123        .unwrap_or_else(|| default_mouse_capture_enabled(terminal_emulator, wt_session, conemu_pid))
9124}
9125
9126/// Whether to enable terminal mouse capture by default for this platform/host.
9127///
9128/// On Windows the default depends on the host: Windows Terminal (which sets
9129/// `WT_SESSION`) and ConEmu/Cmder (which set `ConEmuPID`) handle mouse-mode
9130/// reporting cleanly, so default-on there gives users in-app text selection
9131/// and keeps the application's selection clamped to the transcript area
9132/// (#1169). Legacy conhost (CMD without either env var) stays default-off
9133/// because its mouse-mode reporting can leak SGR escape sequences as raw
9134/// text into the composer (#878 / #898).
9135///
9136/// Off elsewhere only for JetBrains' JediTerm, which advertises mouse
9137/// support but forwards the same SGR escape sequences as raw input. The
9138/// user can still opt back in with `[tui] mouse_capture = true` in
9139/// `~/.codewhale/config.toml` or `--mouse-capture`.
9140fn default_mouse_capture_enabled(
9141    terminal_emulator: Option<&str>,
9142    wt_session: Option<&str>,
9143    conemu_pid: Option<&str>,
9144) -> bool {
9145    if cfg!(windows) {
9146        return wt_session.is_some() || conemu_pid.is_some();
9147    }
9148    if matches!(terminal_emulator, Some(t) if t.eq_ignore_ascii_case("JetBrains-JediTerm")) {
9149        return false;
9150    }
9151    true
9152}
9153
9154/// A loadable crash-recovery checkpoint candidate: session content, file
9155/// age, and which slot it came from (per-session file or the legacy single
9156/// slot).
9157struct RecentCheckpoint {
9158    session: session_manager::SavedSession,
9159    age: std::time::Duration,
9160    source: session_manager::CheckpointSource,
9161}
9162
9163const CHECKPOINT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
9164
9165/// Load all recent crash-recovery checkpoints, pruning stale ones first.
9166///
9167/// Candidates are the per-session checkpoint files plus the legacy
9168/// single-slot `checkpoints/latest.json` (compatibility read). Files older
9169/// than 24 hours are removed; unreadable files are skipped. The result is
9170/// sorted most recent first.
9171fn load_recent_checkpoints(manager: &session_manager::SessionManager) -> Vec<RecentCheckpoint> {
9172    let refs = manager.list_checkpoints().unwrap_or_default();
9173    let mut recent = Vec::new();
9174    for checkpoint_ref in refs {
9175        let Ok(age) = std::time::SystemTime::now().duration_since(checkpoint_ref.modified) else {
9176            continue;
9177        };
9178        if age > CHECKPOINT_MAX_AGE {
9179            let _ = match &checkpoint_ref.source {
9180                session_manager::CheckpointSource::Session(id) => {
9181                    manager.clear_session_checkpoint(id)
9182                }
9183                session_manager::CheckpointSource::Legacy => manager.clear_legacy_checkpoint(),
9184            };
9185            continue;
9186        }
9187        let loaded = match &checkpoint_ref.source {
9188            session_manager::CheckpointSource::Session(id) => manager.load_session_checkpoint(id),
9189            session_manager::CheckpointSource::Legacy => manager.load_legacy_checkpoint(),
9190        };
9191        let Ok(Some(session)) = loaded else {
9192            continue;
9193        };
9194        recent.push(RecentCheckpoint {
9195            session,
9196            age,
9197            source: checkpoint_ref.source,
9198        });
9199    }
9200    // `list_checkpoints` sorts newest-first already; keep it explicit here so
9201    // selection does not silently depend on the manager's ordering.
9202    recent.sort_by_key(|c| c.age);
9203    recent
9204}
9205
9206fn checkpoint_age_label(age: std::time::Duration) -> String {
9207    if age.as_secs() < 60 {
9208        format!("{}s ago", age.as_secs())
9209    } else if age.as_secs() < 3600 {
9210        format!("{}m ago", age.as_secs() / 60)
9211    } else {
9212        format!("{}h ago", age.as_secs() / 3600)
9213    }
9214}
9215
9216/// Check for a crash-recovery checkpoint and return the session ID if explicit
9217/// recovery was requested *and* the checkpoint belongs to the current
9218/// workspace.
9219///
9220/// Candidates are all per-session checkpoint files plus the legacy
9221/// single-slot `checkpoints/latest.json`; each must be younger than 24 hours
9222/// **and its workspace must match the resolved launch workspace after
9223/// canonicalisation** — the newest matching candidate wins. If no candidate
9224/// matches, a one-line notice points at `codewhale sessions`, and nothing is
9225/// auto-loaded: another workspace's checkpoint file is never touched (it may
9226/// belong to a live session there).
9227fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<String> {
9228    let manager = session_manager::SessionManager::default_location().ok()?;
9229    let candidates = load_recent_checkpoints(&manager);
9230    if candidates.is_empty() {
9231        return None;
9232    }
9233
9234    // Refuse to silently restore a session from another workspace. Compare
9235    // against the resolved launch workspace, not the shell cwd, so callers
9236    // using `--workspace` cannot accidentally recover a checkpoint from the
9237    // directory their shell happened to be in.
9238    let (matching, mismatched): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|candidate| {
9239        session_manager::workspace_scope_matches(
9240            &candidate.session.metadata.workspace,
9241            launch_workspace,
9242        )
9243    });
9244
9245    let Some(best) = matching.into_iter().next() else {
9246        if let Some(newest) = mismatched.first() {
9247            eprintln!(
9248                "Note: an interrupted session from another workspace ({}) is \
9249                 available. Run `codewhale sessions` to list saved sessions. Starting \
9250                 fresh in {}.",
9251                newest.session.metadata.workspace.display(),
9252                launch_workspace.display(),
9253            );
9254        }
9255        return None;
9256    };
9257
9258    let session_id = best.session.metadata.id.clone();
9259
9260    // Persist the checkpoint as a regular session so the TUI can load it by
9261    // id — unless a newer regular session file for the same id already
9262    // exists (e.g. `--continue` ran before and the session advanced since).
9263    // A stale checkpoint must never overwrite newer durable session state.
9264    if !saved_session_is_newer(&manager, &best.session)
9265        && manager.save_session(&best.session).is_err()
9266    {
9267        return None;
9268    }
9269
9270    match &best.source {
9271        session_manager::CheckpointSource::Session(id) => {
9272            // Consume the per-session checkpoint now that it is recovered.
9273            let _ = manager.clear_session_checkpoint(id);
9274        }
9275        session_manager::CheckpointSource::Legacy => {
9276            // Migrate the legacy slot to a per-session file (never
9277            // overwriting an existing one) and leave `latest.json` in place
9278            // so an older binary can still find it; its writer is already
9279            // gone and the file ages out within 24 hours.
9280            let _ = manager.write_session_checkpoint_if_absent(&best.session);
9281        }
9282    }
9283
9284    let age_str = checkpoint_age_label(best.age);
9285    eprintln!("Recovered interrupted session ({age_str}). Use --fresh to start fresh.",);
9286
9287    Some(session_id)
9288}
9289
9290/// Whether a regular session file for the checkpoint's id already exists and
9291/// is at least as recent as the checkpoint. When it is, persisting the
9292/// checkpoint over it would replace newer durable state with older in-flight
9293/// state.
9294fn saved_session_is_newer(
9295    manager: &session_manager::SessionManager,
9296    checkpoint: &session_manager::SavedSession,
9297) -> bool {
9298    manager
9299        .load_session(&checkpoint.metadata.id)
9300        .is_ok_and(|existing| existing.metadata.updated_at >= checkpoint.metadata.updated_at)
9301}
9302
9303/// Preserve an interrupted checkpoint on a normal fresh launch without
9304/// attaching it to the new TUI instance. This keeps "open another codewhale in
9305/// the same folder" from re-entering the previous in-flight session while still
9306/// leaving an explicit resume path.
9307///
9308/// Only the newest recent checkpoint drives the notice. The legacy
9309/// single-slot file is persisted as a regular session and consumed (today's
9310/// behavior for that slot); per-session checkpoint files are persisted but
9311/// left in place — they may belong to a live session in another terminal,
9312/// and `--continue` reads them directly.
9313fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) {
9314    let Some(manager) = session_manager::SessionManager::default_location().ok() else {
9315        return;
9316    };
9317    let Some(newest) = load_recent_checkpoints(&manager).into_iter().next() else {
9318        return;
9319    };
9320
9321    let session_workspace = newest.session.metadata.workspace.clone();
9322    // #4479: removed save_session call — checkpoint should not be auto-promoted to session
9323    if newest.source == session_manager::CheckpointSource::Legacy {
9324        // Migrate legacy single-slot checkpoint to per-session format
9325        // before clearing the legacy file, or the data is unrecoverable.
9326        let _ = manager.save_checkpoint(&newest.session);
9327        let _ = manager.clear_legacy_checkpoint();
9328    }
9329
9330    let age_str = checkpoint_age_label(newest.age);
9331    if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
9332        eprintln!(
9333            "Found an in-flight session snapshot ({age_str}). Starting a new \
9334             session. Run `codewhale --continue` to resume it."
9335        );
9336    } else {
9337        eprintln!(
9338            "Note: an interrupted session from another workspace ({}) is \
9339             available. Run `codewhale sessions` to list saved sessions. Starting \
9340             fresh in {}.",
9341            session_workspace.display(),
9342            launch_workspace.display(),
9343        );
9344    }
9345}
9346
9347/// Load project-level config from `$WORKSPACE/.codewhale/config.toml`, with
9348/// legacy `$WORKSPACE/.deepseek/config.toml` fallback, then apply its fields as
9349/// overrides on top of the global config (#485).
9350/// Only explicitly set fields in the project file are applied; everything
9351/// else falls back to the global value.
9352#[cfg(test)]
9353fn merge_project_config(config: &mut Config, workspace: &Path) {
9354    merge_project_config_with_approval_baseline(config, workspace, None);
9355}
9356
9357/// Apply project config while evaluating approval tightening against the
9358/// user's effective interactive baseline. `Config::approval_policy` remains
9359/// authoritative when present; the saved TUI posture is used only when the
9360/// root config leaves approval unset.
9361fn merge_project_config_with_approval_baseline(
9362    config: &mut Config,
9363    workspace: &Path,
9364    saved_permission_posture: Option<&str>,
9365) {
9366    // When the workspace is the user's home directory, the project-scope
9367    // config file is also the global config file. Skip the merge to avoid
9368    // redundant processing and a misleading "project-scope config key
9369    // ignored" warning on every launch from ~.
9370    if let Some(home) = effective_home_dir()
9371        && let (Ok(w), Ok(h)) = (
9372            std::fs::canonicalize(workspace),
9373            std::fs::canonicalize(&home),
9374        )
9375        && w == h
9376    {
9377        return;
9378    }
9379
9380    // v0.8.44: prefer .codewhale/config.toml, fall back to .deepseek/
9381    let path = workspace
9382        .join(codewhale_config::CODEWHALE_APP_DIR)
9383        .join("config.toml");
9384    let raw = match read_project_config_file(&path) {
9385        Ok(Some(r)) => r,
9386        Ok(None) => {
9387            let legacy = workspace
9388                .join(codewhale_config::LEGACY_APP_DIR)
9389                .join("config.toml");
9390            match read_project_config_file(&legacy) {
9391                Ok(Some(r)) => r,
9392                Ok(None) => return,
9393                Err(err) => {
9394                    eprintln!(
9395                        "warning: failed to read project-scope config {}: {err}",
9396                        legacy.display()
9397                    );
9398                    return;
9399                }
9400            }
9401        }
9402        Err(err) => {
9403            eprintln!(
9404                "warning: failed to read project-scope config {}: {err}",
9405                path.display()
9406            );
9407            return;
9408        }
9409    };
9410    let project: toml::Value = match toml::from_str(&raw) {
9411        Ok(v) => v,
9412        Err(_) => return,
9413    };
9414    let table = match project.as_table() {
9415        Some(t) => t,
9416        None => return,
9417    };
9418
9419    // #417: dangerous keys are denied at project scope. A malicious
9420    // `<workspace>/.deepseek/config.toml` could otherwise:
9421    // * `api_key` / `base_url` / `provider` — exfiltrate prompts to a
9422    //   look-alike endpoint by swapping the user's credentials and
9423    //   target host with project-controlled values.
9424    // * `mcp_config_path` — point the loader at an MCP config that
9425    //   spawns arbitrary stdio servers under the user's identity.
9426    // * `mcp_oauth_callback_*` — choose local OAuth redirect listener
9427    //   behavior for user-owned MCP credentials.
9428    //
9429    // The overlay path is non-interactive; users can't visually
9430    // confirm a rogue project config is hijacking these. We surface
9431    // a stderr warning on first encounter so a user who *did* expect
9432    // the override has a chance to notice the deny instead of silent
9433    // discard.
9434    const DENY_AT_PROJECT_SCOPE: &[&str] = &[
9435        "api_key",
9436        "base_url",
9437        "provider",
9438        "mcp_config_path",
9439        "mcp_oauth_callback_port",
9440        "mcp_oauth_callback_url",
9441    ];
9442    for key in DENY_AT_PROJECT_SCOPE {
9443        if table.contains_key(*key) {
9444            eprintln!(
9445                "warning: project-scope config key `{key}` is ignored — \
9446                 set it in `~/.codewhale/config.toml` instead. \
9447                 (See #417 for the deny-list rationale.)"
9448            );
9449        }
9450    }
9451
9452    // String fields a project may legitimately override (model,
9453    // approval/sandbox tightening, notes path, reasoning effort).
9454    for (key, field) in [
9455        ("model", &mut config.default_text_model),
9456        ("reasoning_effort", &mut config.reasoning_effort),
9457        ("notes_path", &mut config.notes_path),
9458    ] {
9459        if let Some(v) = table.get(key).and_then(toml::Value::as_str)
9460            && !v.is_empty()
9461        {
9462            *field = Some(v.to_string());
9463        }
9464    }
9465
9466    if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
9467        && !v.is_empty()
9468    {
9469        let saved_approval_baseline =
9470            crate::config::approval_policy_baseline_from_permission_posture(
9471                saved_permission_posture,
9472            );
9473        let approval_baseline = config
9474            .approval_policy
9475            .as_deref()
9476            .or(saved_approval_baseline);
9477        if codewhale_config::project_approval_policy_is_allowed(approval_baseline, v) {
9478            config.approval_policy = Some(v.to_string());
9479        } else {
9480            eprintln!(
9481                "warning: project-scope `approval_policy = \"{v}\"` is ignored — \
9482                 project config can only tighten the user's approval policy. \
9483                 (See #417.)"
9484            );
9485        }
9486    }
9487
9488    if let Some(v) = table.get("sandbox_mode").and_then(toml::Value::as_str)
9489        && !v.is_empty()
9490    {
9491        if codewhale_config::project_sandbox_mode_is_allowed(config.sandbox_mode.as_deref(), v) {
9492            config.sandbox_mode = Some(v.to_string());
9493        } else {
9494            eprintln!(
9495                "warning: project-scope `sandbox_mode = \"{v}\"` is ignored — \
9496                 project config can only tighten the user's sandbox mode. \
9497                 (See #417.)"
9498            );
9499        }
9500    }
9501
9502    // Numeric / bool fields that benefit from per-project overrides.
9503    if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
9504        && v > 0
9505    {
9506        config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
9507    }
9508    if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
9509        if v {
9510            eprintln!(
9511                "warning: project-scope `allow_shell = true` is ignored — \
9512                 enable shell from user config for this workspace instead. \
9513                 (See #417.)"
9514            );
9515        } else {
9516            config.allow_shell = Some(false);
9517        }
9518    }
9519
9520    if table.contains_key("instructions") {
9521        eprintln!(
9522            "warning: project-scope `instructions` is ignored — \
9523             configure instruction files from user config instead. \
9524             (See #417.)"
9525        );
9526    }
9527}
9528
9529fn read_project_config_file(path: &Path) -> io::Result<Option<String>> {
9530    let metadata = match std::fs::symlink_metadata(path) {
9531        Ok(metadata) => metadata,
9532        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
9533        Err(err) => return Err(err),
9534    };
9535    let file_type = metadata.file_type();
9536    if file_type.is_symlink() {
9537        return Err(io::Error::new(
9538            io::ErrorKind::InvalidInput,
9539            "project-scope config must not be a symlink",
9540        ));
9541    }
9542    if !file_type.is_file() {
9543        return Ok(None);
9544    }
9545
9546    let mut file = open_project_config_file(path)?;
9547    let mut raw = String::new();
9548    file.read_to_string(&mut raw)?;
9549    Ok(Some(raw))
9550}
9551
9552#[cfg(unix)]
9553fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9554    use std::os::unix::fs::OpenOptionsExt;
9555
9556    std::fs::OpenOptions::new()
9557        .read(true)
9558        .custom_flags(libc::O_NOFOLLOW)
9559        .open(path)
9560}
9561
9562#[cfg(not(unix))]
9563fn open_project_config_file(path: &Path) -> io::Result<std::fs::File> {
9564    std::fs::File::open(path)
9565}
9566
9567fn merge_user_workspace_config(
9568    config: &mut Config,
9569    config_path: Option<PathBuf>,
9570    workspace: &Path,
9571) {
9572    if config.managed_config_path.is_some() || config.requirements_path.is_some() {
9573        return;
9574    }
9575    let allow_shell_before = config.allow_shell;
9576    let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
9577        || std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
9578    let path = match crate::config::resolve_load_config_path(config_path) {
9579        Ok(Some(path)) => path,
9580        Ok(None) => return,
9581        Err(error) => {
9582            tracing::error!(
9583                error = %error,
9584                "failed to resolve workspace config overlay; refusing to substitute another file"
9585            );
9586            return;
9587        }
9588    };
9589    let raw = match std::fs::read_to_string(&path) {
9590        Ok(raw) => raw,
9591        Err(error) => {
9592            eprintln!(
9593                "warning: could not read user config at {}: {error}. \
9594                 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9595                 revert to defaults for this session. Fix or remove the file to \
9596                 restore them.",
9597                path.display()
9598            );
9599            return;
9600        }
9601    };
9602    let doc = match toml::from_str::<toml::Value>(&raw) {
9603        Ok(doc) => doc,
9604        Err(error) => {
9605            eprintln!(
9606                "warning: could not parse user config at {}: {error}. \
9607                 Ignoring it — `[workspace]`/`[projects]` grants (e.g. `allow_shell`) \
9608                 revert to defaults for this session. Fix the TOML syntax to \
9609                 restore them.",
9610                path.display()
9611            );
9612            return;
9613        }
9614    };
9615    merge_user_workspace_config_from_doc(config, &doc, workspace);
9616    if allow_shell_from_env {
9617        config.allow_shell = allow_shell_before;
9618    }
9619}
9620
9621fn merge_user_workspace_config_from_doc(config: &mut Config, doc: &toml::Value, workspace: &Path) {
9622    for table_name in ["workspace", "projects"] {
9623        let Some(entries) = doc.get(table_name).and_then(toml::Value::as_table) else {
9624            continue;
9625        };
9626        for (raw_path, entry) in entries {
9627            if !workspace_config_path_matches(raw_path, workspace) {
9628                continue;
9629            }
9630            if let Some(allow_shell) = entry.get("allow_shell").and_then(toml::Value::as_bool) {
9631                config.allow_shell = Some(allow_shell);
9632            }
9633        }
9634    }
9635}
9636
9637fn workspace_config_path_matches(raw_path: &str, workspace: &Path) -> bool {
9638    let configured = crate::config::expand_path(raw_path);
9639    let configured = configured.canonicalize().unwrap_or(configured);
9640    let workspace = workspace
9641        .canonicalize()
9642        .unwrap_or_else(|_| workspace.to_path_buf());
9643    paths_equal_for_config(&configured, &workspace)
9644}
9645
9646#[cfg(windows)]
9647fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9648    normalize_windows_config_path_for_compare(left)
9649        == normalize_windows_config_path_for_compare(right)
9650}
9651
9652#[cfg(not(windows))]
9653fn paths_equal_for_config(left: &Path, right: &Path) -> bool {
9654    left == right
9655}
9656
9657#[cfg(windows)]
9658fn normalize_windows_config_path_for_compare(path: &Path) -> String {
9659    normalize_windows_config_path_str(&path.to_string_lossy())
9660}
9661
9662#[cfg(any(windows, test))]
9663fn normalize_windows_config_path_str(path: &str) -> String {
9664    let mut normalized = path.replace('/', "\\");
9665    if let Some(rest) = normalized.strip_prefix(r"\\?\UNC\") {
9666        normalized = format!("\\\\{rest}");
9667    } else if let Some(rest) = normalized.strip_prefix(r"\\?\") {
9668        normalized = rest.to_string();
9669    }
9670    while normalized.len() > 3 && normalized.ends_with('\\') {
9671        normalized.pop();
9672    }
9673    normalized.to_ascii_lowercase()
9674}
9675
9676fn interactive_tui_allow_shell(yolo: bool, config: &Config) -> bool {
9677    yolo || config.interactive_allow_shell()
9678}
9679
9680async fn run_interactive(
9681    cli: &Cli,
9682    config: &Config,
9683    resume_session_id: Option<String>,
9684    initial_input: Option<tui::InitialInput>,
9685    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9686    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9687) -> Result<()> {
9688    run_interactive_with_notice(
9689        cli,
9690        config,
9691        resume_session_id,
9692        initial_input,
9693        None,
9694        pending_telemetry_notice,
9695        plugin_registry,
9696    )
9697    .await
9698}
9699
9700/// As [`run_interactive`], but carrying a one-line startup receipt to show in
9701/// the transcript — used by auto-resume to explain why it did or did not
9702/// reattach to a previous session (#2934).
9703async fn run_interactive_with_notice(
9704    cli: &Cli,
9705    config: &Config,
9706    resume_session_id: Option<String>,
9707    initial_input: Option<tui::InitialInput>,
9708    startup_notice: Option<String>,
9709    pending_telemetry_notice: Option<crate::telemetry_notice::PendingTelemetryNotice>,
9710    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
9711) -> Result<()> {
9712    let initial_input = if cli.remote_control {
9713        Some(tui::InitialInput::RemoteControl)
9714    } else {
9715        initial_input
9716    };
9717    let workspace = cli
9718        .workspace
9719        .clone()
9720        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
9721
9722    // Merge project-level config from $WORKSPACE/.codewhale/config.toml
9723    // or legacy $WORKSPACE/.deepseek/config.toml
9724    // unless --no-project-config was passed (#485).
9725    let mut merged_config = config.clone();
9726    merge_user_workspace_config(&mut merged_config, cli.config.clone(), &workspace);
9727    if !cli.no_project_config {
9728        let saved_permission_posture = crate::settings::Settings::load_persisted()
9729            .ok()
9730            .and_then(|settings| settings.permission_posture);
9731        merge_project_config_with_approval_baseline(
9732            &mut merged_config,
9733            &workspace,
9734            saved_permission_posture.as_deref(),
9735        );
9736    }
9737    let config = &merged_config;
9738
9739    if !cli.skip_onboarding {
9740        match crate::config::ensure_config_file_exists(cli.config.clone()) {
9741            Ok(Some(path)) => logging::info(format!(
9742                "Created first-run config file at {}",
9743                path.display()
9744            )),
9745            Ok(None) => {}
9746            Err(err) => logging::warn(format!("Failed to create first-run config file: {err}")),
9747        }
9748    }
9749
9750    // v0.8.44: migrate config from ~/.deepseek/ to ~/.codewhale/ on first
9751    // launch. Non-fatal — existing installs keep working either way.
9752    match codewhale_config::migrate_config_if_needed() {
9753        Ok(Some(migration)) => {
9754            eprintln!("{}", migration.user_notice());
9755        }
9756        Ok(None) => {}
9757        Err(err) => logging::warn(format!("Config migration skipped: {err}")),
9758    }
9759
9760    let model = config.default_model();
9761    let provider = config.api_provider();
9762    let max_subagents = cli.max_subagents.map_or_else(
9763        || config.max_subagents_for_provider(provider),
9764        |value| value.clamp(1, MAX_SUBAGENTS),
9765    );
9766    let use_alt_screen = should_use_alt_screen(cli, config);
9767    let use_mouse_capture = should_use_mouse_capture(cli, config, use_alt_screen);
9768    let use_bracketed_paste = crate::settings::Settings::load()
9769        .map(|s| s.effective_bracketed_paste())
9770        .unwrap_or_else(|_| !crate::settings::detected_legacy_windows_console_host());
9771
9772    // Auto-install bundled system skills (e.g. skill-creator) on first launch.
9773    // Errors are non-fatal: log a warning and continue.
9774    let skills_dir = config.skills_dir();
9775    if let Err(e) = crate::skills::install_system_skills(&skills_dir) {
9776        logging::warn(format!("Failed to install system skills: {e}"));
9777    }
9778
9779    startup_trace::mark("interactive_config");
9780
9781    // Seed ProviderLake from the secret-free Models.dev disk cache before any
9782    // picker/inventory read, then kick a best-effort background refresh (#4187).
9783    // Failures are quiet: bundled catalog rows always remain available.
9784    crate::models_dev_live::maybe_load_persisted_cache();
9785    crate::models_dev_live::spawn_background_refresh();
9786    // Best-effort per-provider catalog refresh: fetches the active provider's
9787    // own /v1/models endpoint and merges live rows into the provider lake
9788    // alongside the Models.dev snapshot. Currently active for TelecomJS, whose
9789    // model list is not covered by the Models.dev catalog.
9790    crate::client::DeepSeekClient::spawn_active_provider_catalog_refresh(config);
9791
9792    // Boot janitors — snapshot prune (7-day default), spillover prune
9793    // (#422), and managed-session cleanup (v0.8.44) — are best-effort disk
9794    // hygiene. On a large ~/.codewhale they were the dominant startup cost
9795    // (a git object walk plus thousands of stat/read calls), so they run on
9796    // a blocking worker while the TUI brings up its first frame (#3757).
9797    // All three were already documented as non-fatal.
9798    let snapshots = config.snapshots_config();
9799    let janitor_snapshots_enabled = snapshots.enabled;
9800    let janitor_max_age = snapshots.max_age();
9801    let janitor_workspace = workspace.clone();
9802    // Session cleanup races session restore: skip it entirely when a session
9803    // is being resumed/continued this launch (the just-resumed session could
9804    // be pruned before its first save bumps `updated_at`). It runs next
9805    // clean launch. When we do run it, exclude the explicit resume id too.
9806    let janitor_resume_id = resume_session_id.clone();
9807    let janitor_skip_session_cleanup = resume_session_id.is_some() || cli.continue_session;
9808    tokio::task::spawn_blocking(move || {
9809        if janitor_snapshots_enabled {
9810            session_manager::prune_workspace_snapshots(&janitor_workspace, janitor_max_age);
9811        }
9812
9813        match crate::tools::truncate::prune_older_than(crate::tools::truncate::SPILLOVER_MAX_AGE) {
9814            Ok(0) => {}
9815            Ok(n) => tracing::debug!(
9816                target: "spillover",
9817                "boot prune removed {n} spillover file(s)"
9818            ),
9819            Err(err) => tracing::warn!(
9820                target: "spillover",
9821                ?err,
9822                "spillover prune skipped on boot"
9823            ),
9824        }
9825
9826        if !janitor_skip_session_cleanup
9827            && let Ok(manager) = session_manager::SessionManager::default_location()
9828        {
9829            let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
9830        }
9831    });
9832
9833    // The `deepseek` launcher forwards `--yolo` to this binary via the
9834    // DEEPSEEK_YOLO env var (config.yolo), not as a CLI flag. Honour either.
9835    let yolo = cli.yolo || config.yolo.unwrap_or(false);
9836
9837    tui::run_tui(
9838        config,
9839        tui::TuiOptions {
9840            model,
9841            workspace,
9842            config_path: cli.config.clone(),
9843            config_profile: effective_config_profile(cli),
9844            allow_shell: interactive_tui_allow_shell(yolo, config),
9845            use_alt_screen,
9846            use_mouse_capture,
9847            use_bracketed_paste,
9848            skills_dir,
9849            memory_path: config.memory_path(),
9850            notes_path: config.notes_path(),
9851            mcp_config_path: config.mcp_config_path(),
9852            use_memory: config.memory_enabled(),
9853            start_in_agent_mode: yolo,
9854            skip_onboarding: cli.skip_onboarding,
9855            yolo, // YOLO mode auto-approves all tool executions
9856            resume_session_id,
9857            initial_input,
9858            startup_notice,
9859            max_subagents,
9860        },
9861        plugin_registry,
9862        pending_telemetry_notice,
9863    )
9864    .await
9865}
9866
9867#[derive(Debug)]
9868struct CliAutoRoute {
9869    provider: crate::config::ApiProvider,
9870    model: String,
9871    reasoning_effort: Option<crate::tui::app::ReasoningEffort>,
9872    /// Whether the runtime should continue resolving reasoning per prompt.
9873    ///
9874    /// This is independent from `auto_model`: an Auto model can carry a fixed
9875    /// saved effort, while a fixed Fleet model can still request Auto effort.
9876    auto_controls_reasoning: bool,
9877    auto_model: bool,
9878}
9879
9880fn cli_reasoning_effort_value(
9881    config: &Config,
9882    model: &str,
9883    effort: crate::tui::app::ReasoningEffort,
9884) -> Option<String> {
9885    effort
9886        .api_value_for_route(config.api_provider(), &config.deepseek_base_url(), model)
9887        .map(str::to_string)
9888}
9889
9890fn cli_reasoning_effort_value_for_prompt(
9891    config: &Config,
9892    model: &str,
9893    effort: crate::tui::app::ReasoningEffort,
9894    prompt: &str,
9895) -> Option<String> {
9896    let resolved = if effort == crate::tui::app::ReasoningEffort::Auto {
9897        crate::auto_reasoning::select(false, prompt)
9898    } else {
9899        effort
9900    };
9901    cli_reasoning_effort_value(config, model, resolved)
9902}
9903
9904fn normalize_cli_reasoning_effort(value: &str) -> Result<Option<String>> {
9905    let trimmed = value.trim();
9906    if trimmed.is_empty() {
9907        return Ok(None);
9908    }
9909    if matches!(
9910        trimmed.to_ascii_lowercase().as_str(),
9911        "inherit" | "parent" | "same" | "current" | "default" | "unset"
9912    ) {
9913        return Ok(None);
9914    }
9915    crate::tui::app::ReasoningEffort::parse_strict(trimmed)
9916        .map(|effort| Some(effort.as_setting().to_string()))
9917        .map_err(anyhow::Error::msg)
9918}
9919
9920fn config_for_cli_route(config: &Config, route: &CliAutoRoute) -> Config {
9921    let mut execution_config = config.clone();
9922    execution_config.provider = Some(config.provider_identity_for(route.provider));
9923    execution_config.set_provider_model_override(route.provider, Some(route.model.clone()));
9924    if matches!(
9925        route.provider,
9926        crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN
9927    ) {
9928        execution_config.default_text_model = Some(route.model.clone());
9929    }
9930    execution_config
9931}
9932
9933async fn resolve_cli_auto_route(
9934    config: &Config,
9935    model: &str,
9936    prompt: &str,
9937) -> Result<CliAutoRoute> {
9938    if model.trim().eq_ignore_ascii_case("auto") {
9939        let selection =
9940            model_routing::resolve_auto_route_with_inventory(config, prompt, "", "auto", "auto")
9941                .await?;
9942        let preference = config
9943            .reasoning_effort()
9944            .filter(|_| config.reasoning_effort_is_explicit())
9945            .map(crate::tui::app::ReasoningEffort::from_setting);
9946        let (reasoning_effort, auto_controls_reasoning) =
9947            model_routing::resolve_auto_model_reasoning(preference, selection.reasoning_effort);
9948        Ok(CliAutoRoute {
9949            provider: selection.provider,
9950            model: selection.model,
9951            reasoning_effort,
9952            auto_controls_reasoning,
9953            auto_model: true,
9954        })
9955    } else {
9956        if let Some(selection) = model_routing::resolve_explicit_route_with_inventory(config, model)
9957        {
9958            let auto_controls_reasoning = matches!(
9959                selection.reasoning_effort,
9960                Some(crate::tui::app::ReasoningEffort::Auto)
9961            );
9962            return Ok(CliAutoRoute {
9963                provider: selection.provider,
9964                model: selection.model,
9965                reasoning_effort: selection.reasoning_effort,
9966                auto_controls_reasoning,
9967                auto_model: false,
9968            });
9969        }
9970
9971        let candidate_providers = model_routing::explicit_route_candidate_providers(config, model);
9972        if !candidate_providers.is_empty() && !candidate_providers.contains(&config.api_provider())
9973        {
9974            let providers = candidate_providers
9975                .iter()
9976                .map(|provider| provider.as_str())
9977                .collect::<Vec<_>>()
9978                .join(", ");
9979            bail!(
9980                "model `{model}` is available from configured provider route(s): {providers}. \
9981                 Pass `--provider <provider>` with `--model {model}` to choose one explicitly. \
9982                 In the TUI, use `/provider`, `/model`, or `/setup` to resolve the route before sending."
9983            );
9984        }
9985
9986        // When --model is not `auto`, fall back to the reasoning_effort
9987        // declared in the user's config.toml. The previous hard-coded `None`
9988        // silently dropped the user's setting on every non-auto-route exec
9989        // call, which (for example) prevented vllm + Qwen3 users from
9990        // disabling thinking via `reasoning_effort = "off"` and caused
9991        // 30+ second SSE idle timeouts on trivial prompts.
9992        let reasoning_effort = config
9993            .reasoning_effort()
9994            .map(crate::tui::app::ReasoningEffort::from_setting);
9995        Ok(CliAutoRoute {
9996            provider: config.api_provider(),
9997            model: model.to_string(),
9998            auto_controls_reasoning: matches!(
9999                reasoning_effort,
10000                Some(crate::tui::app::ReasoningEffort::Auto)
10001            ),
10002            reasoning_effort,
10003            auto_model: false,
10004        })
10005    }
10006}
10007
10008async fn resolve_cli_exec_route(
10009    config: &Config,
10010    model: &str,
10011    prompt: &str,
10012    force_configured_route: bool,
10013) -> Result<CliAutoRoute> {
10014    if force_configured_route && !model.trim().eq_ignore_ascii_case("auto") {
10015        let reasoning_effort = config
10016            .reasoning_effort()
10017            .map(crate::tui::app::ReasoningEffort::from_setting);
10018        return Ok(CliAutoRoute {
10019            provider: config.api_provider(),
10020            model: model.to_string(),
10021            auto_controls_reasoning: matches!(
10022                reasoning_effort,
10023                Some(crate::tui::app::ReasoningEffort::Auto)
10024            ),
10025            reasoning_effort,
10026            auto_model: false,
10027        });
10028    }
10029    resolve_cli_auto_route(config, model, prompt).await
10030}
10031
10032fn should_force_configured_exec_route(
10033    resuming: bool,
10034    explicit_provider: Option<&str>,
10035    explicit_model: Option<&str>,
10036) -> bool {
10037    // A configured/default model belongs to the configured provider route.
10038    // Cross-provider inventory inference is reserved for an explicit model
10039    // override without an explicit provider. Resume remains route-authoritative
10040    // even when its model is overridden because it restores the saved provider.
10041    resuming || explicit_provider.is_some() || explicit_model.is_none()
10042}
10043
10044async fn run_one_shot(
10045    config: &Config,
10046    model: &str,
10047    prompt: &str,
10048    force_configured_route: bool,
10049) -> Result<()> {
10050    use crate::client::DeepSeekClient;
10051    use crate::models::{
10052        ContentBlock, Message, MessageRequest, is_incomplete_stop_reason, stop_reason_detail,
10053    };
10054
10055    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10056    let execution_config = config_for_cli_route(config, &route);
10057    let client = DeepSeekClient::new(&execution_config)?;
10058    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
10059        cli_reasoning_effort_value_for_prompt(&execution_config, &route.model, effort, prompt)
10060    });
10061    let model = route.model;
10062    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
10063
10064    let request = MessageRequest {
10065        model,
10066        messages: vec![Message {
10067            role: "user".to_string(),
10068            content: vec![ContentBlock::Text {
10069                text: prompt.to_string(),
10070                cache_control: None,
10071            }],
10072        }],
10073        max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
10074            request_route.provider,
10075            &request_route.model,
10076            None,
10077        ),
10078        system: None,
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
10092    for block in response.content {
10093        if let ContentBlock::Text { text, .. } = block {
10094            println!("{text}");
10095        }
10096    }
10097
10098    if is_incomplete_stop_reason(stop_reason.as_deref()) {
10099        anyhow::bail!(
10100            "Model response incomplete: provider stop reason `{}`; the partial response was printed but the command did not succeed.",
10101            stop_reason_detail(stop_reason.as_deref())
10102        );
10103    }
10104
10105    Ok(())
10106}
10107
10108async fn run_one_shot_json(
10109    config: &Config,
10110    model: &str,
10111    prompt: &str,
10112    force_configured_route: bool,
10113) -> Result<()> {
10114    use crate::client::DeepSeekClient;
10115    use crate::models::{
10116        ContentBlock, Message, MessageRequest, SystemPrompt, is_incomplete_stop_reason,
10117        stop_reason_detail,
10118    };
10119
10120    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
10121    let execution_config = config_for_cli_route(config, &route);
10122    let provider = execution_config.provider_identity_for(route.provider);
10123    let client = DeepSeekClient::new(&execution_config)?;
10124    let model = route.model.clone();
10125    let reasoning_effort = route.reasoning_effort.and_then(|effort| {
10126        cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, prompt)
10127    });
10128    let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
10129    let request = MessageRequest {
10130        model: model.clone(),
10131        messages: vec![Message {
10132            role: "user".to_string(),
10133            content: vec![ContentBlock::Text {
10134                text: prompt.to_string(),
10135                cache_control: None,
10136            }],
10137        }],
10138        max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
10139            request_route.provider,
10140            &request_route.model,
10141            None,
10142        ),
10143        system: Some(SystemPrompt::Text(
10144            "You are a coding assistant. Give concise, actionable responses.".to_string(),
10145        )),
10146        tools: None,
10147        tool_choice: None,
10148        metadata: None,
10149        thinking: None,
10150        reasoning_effort,
10151        stream: Some(false),
10152        temperature: None,
10153        top_p: None,
10154    };
10155
10156    let response = client.create_message(request).await?;
10157    let stop_reason = response.stop_reason.clone();
10158    let usage = response.usage.clone();
10159    let mut output = String::new();
10160    for block in response.content {
10161        if let ContentBlock::Text { text, .. } = block {
10162            output.push_str(&text);
10163        }
10164    }
10165    println!(
10166        "{}",
10167        serde_json::to_string_pretty(&one_shot_exec_json_receipt(
10168            provider,
10169            model,
10170            output,
10171            stop_reason.clone(),
10172            usage,
10173        ))?
10174    );
10175    if is_incomplete_stop_reason(stop_reason.as_deref()) {
10176        anyhow::bail!(
10177            "Model response incomplete: provider stop reason `{}`; the JSON receipt records success=false.",
10178            stop_reason_detail(stop_reason.as_deref())
10179        );
10180    }
10181    Ok(())
10182}
10183
10184fn one_shot_exec_json_receipt(
10185    provider: String,
10186    model: String,
10187    output: String,
10188    stop_reason: Option<String>,
10189    usage: crate::models::Usage,
10190) -> serde_json::Value {
10191    let incomplete = crate::models::is_incomplete_stop_reason(stop_reason.as_deref());
10192    let error = incomplete.then(|| {
10193        format!(
10194            "Model response incomplete: provider stop reason `{}`.",
10195            crate::models::stop_reason_detail(stop_reason.as_deref())
10196        )
10197    });
10198    serde_json::json!({
10199        "mode": "one-shot",
10200        "provider": provider,
10201        "model": model,
10202        "success": !incomplete,
10203        "output": output,
10204        "stop_reason": stop_reason,
10205        "usage": usage,
10206        "error": error,
10207    })
10208}
10209
10210fn exec_stream_provider_route(
10211    identity: &crate::config::ProviderIdentity,
10212) -> (String, Option<String>) {
10213    let provider = identity.provider.as_str().to_string();
10214    let provider_id = if identity.provider == crate::config::ApiProvider::Custom {
10215        identity.exact_id.clone()
10216    } else {
10217        None
10218    };
10219    (provider, provider_id)
10220}
10221
10222#[derive(serde::Serialize)]
10223struct ExecStreamMeta {
10224    receipt_kind: &'static str,
10225    provider: String,
10226    /// Exact configured provider-table id, when one selected the route.
10227    /// `None` deliberately distinguishes the legacy idless root custom route
10228    /// from literal `[providers.custom]`, whose exact id is `"custom"`.
10229    #[serde(skip_serializing_if = "Option::is_none")]
10230    provider_id: Option<String>,
10231    model: String,
10232    route_source: String,
10233    #[serde(skip_serializing_if = "Option::is_none")]
10234    input_tokens: Option<u32>,
10235    #[serde(skip_serializing_if = "Option::is_none")]
10236    output_tokens: Option<u32>,
10237    #[serde(skip_serializing_if = "Option::is_none")]
10238    prompt_cache_hit_tokens: Option<u32>,
10239    #[serde(skip_serializing_if = "Option::is_none")]
10240    prompt_cache_miss_tokens: Option<u32>,
10241    #[serde(skip_serializing_if = "Option::is_none")]
10242    prompt_cache_write_tokens: Option<u32>,
10243    #[serde(skip_serializing_if = "Option::is_none")]
10244    reasoning_tokens: Option<u32>,
10245    /// Resolved output ceiling the route actually requested (post-catalogue).
10246    #[serde(skip_serializing_if = "Option::is_none")]
10247    codewhale_max_output_tokens: Option<u32>,
10248    /// Provenance of that ceiling: `documented`, `uncatalogued`, or
10249    /// `route-declared`.
10250    #[serde(skip_serializing_if = "Option::is_none")]
10251    codewhale_max_output_tokens_source: Option<&'static str>,
10252    duration_ms: u64,
10253    #[serde(skip_serializing_if = "Option::is_none")]
10254    retry_count: Option<u32>,
10255    approval_posture: String,
10256    sandbox_posture: String,
10257    #[serde(skip_serializing_if = "Option::is_none")]
10258    binary_sha256: Option<String>,
10259    #[serde(skip_serializing_if = "Option::is_none")]
10260    config_sha256: Option<String>,
10261    prompt_sha256: String,
10262    #[serde(skip_serializing_if = "Option::is_none")]
10263    tool_catalog_sha256: Option<String>,
10264    input_analysis: ExecStreamInputAnalysis,
10265    visible_final_answer_chars: usize,
10266    session_id: String,
10267    resume_command: String,
10268    workspace: String,
10269    message_count: usize,
10270    #[serde(skip_serializing_if = "Option::is_none")]
10271    status: Option<String>,
10272    #[serde(skip_serializing_if = "Option::is_none")]
10273    termination_reason: Option<String>,
10274    #[serde(skip_serializing_if = "Option::is_none")]
10275    error_category: Option<String>,
10276    #[serde(skip_serializing_if = "Option::is_none")]
10277    error: Option<String>,
10278}
10279
10280#[derive(Debug, Default, Clone, serde::Serialize, PartialEq, Eq)]
10281struct ExecStreamInputAnalysis {
10282    estimated_request_tokens: usize,
10283    estimated_message_content_tokens: usize,
10284    estimated_system_tokens: usize,
10285    estimated_framing_tokens: usize,
10286    user_message_count: usize,
10287    assistant_message_count: usize,
10288    tool_message_count: usize,
10289    tool_use_count: usize,
10290    tool_result_count: usize,
10291    text_chars: usize,
10292    thinking_chars: usize,
10293    tool_use_input_chars: usize,
10294    tool_result_chars: usize,
10295    text_estimated_tokens: usize,
10296    thinking_estimated_tokens: usize,
10297    tool_use_input_estimated_tokens: usize,
10298    tool_result_estimated_tokens: usize,
10299}
10300
10301#[derive(serde::Serialize)]
10302#[serde(tag = "type")]
10303// Keep receipts flat for stable JSONL consumers. Boxing the whole tool_result
10304// payload would introduce a nested object and break the stream schema.
10305#[allow(clippy::large_enum_variant)]
10306enum ExecStreamEvent {
10307    #[serde(rename = "content")]
10308    Content { content: String },
10309    #[serde(rename = "tool_use")]
10310    ToolUse {
10311        name: String,
10312        id: String,
10313        input: serde_json::Value,
10314        started_at: String,
10315    },
10316    #[serde(rename = "tool_result")]
10317    ToolResult {
10318        id: String,
10319        name: String,
10320        output: String,
10321        status: String,
10322        started_at: String,
10323        completed_at: String,
10324        duration_ms: u64,
10325        side_effect_status: String,
10326        #[serde(skip_serializing_if = "Option::is_none")]
10327        error_category: Option<String>,
10328        #[serde(skip_serializing_if = "Option::is_none")]
10329        truncated: Option<bool>,
10330        #[serde(skip_serializing_if = "Option::is_none")]
10331        artifact: Option<serde_json::Value>,
10332        #[serde(skip_serializing_if = "Option::is_none")]
10333        result_metadata: Option<serde_json::Value>,
10334    },
10335    /// A sub-agent was launched, and the model it was launched on.
10336    ///
10337    /// Without this, a delegated child is invisible to anything reading the
10338    /// stream: a parent turn on one route could spawn children billed on
10339    /// another and the only place it surfaced was the invoice. That is not
10340    /// hypothetical — the `Fast` loadout re-priced scout children onto a
10341    /// cheaper sibling until it was fixed, and nothing in the output said so.
10342    #[serde(rename = "agent_spawned")]
10343    AgentSpawned {
10344        id: String,
10345        model: String,
10346        spawn_depth: u32,
10347        #[serde(skip_serializing_if = "Option::is_none")]
10348        parent_run_id: Option<String>,
10349        /// Why the child got this route, when the spawn path resolved one.
10350        #[serde(skip_serializing_if = "Option::is_none")]
10351        route_source: Option<String>,
10352    },
10353    #[serde(rename = "sandbox_denied")]
10354    SandboxDenied {
10355        tool_id: String,
10356        tool_name: String,
10357        reason: String,
10358        outcome: String,
10359    },
10360    #[serde(rename = "workflow_event")]
10361    WorkflowEvent {
10362        run_id: String,
10363        event: serde_json::Value,
10364    },
10365    #[serde(rename = "session_capture")]
10366    SessionCapture { content: String },
10367    #[serde(rename = "service_released")]
10368    #[cfg(unix)]
10369    ServiceReleased {
10370        task_id: String,
10371        pid: u32,
10372        process_group_id: u32,
10373        ownership: String,
10374    },
10375    /// Per-model-call usage receipt. Field names mirror the terminal
10376    /// `metadata` receipt (`prompt_cache_hit_tokens` is the provider's
10377    /// cache-read count, `prompt_cache_write_tokens` the cache-creation
10378    /// count). Optional fields are omitted — never emitted as null or zero —
10379    /// when the provider does not report them; the whole event is skipped
10380    /// for model calls whose provider reported no usage at all.
10381    #[serde(rename = "turn_usage")]
10382    TurnUsage {
10383        /// 1-based index of the model call within this exec run.
10384        turn: u32,
10385        input_tokens: u32,
10386        output_tokens: u32,
10387        #[serde(skip_serializing_if = "Option::is_none")]
10388        reasoning_tokens: Option<u32>,
10389        #[serde(skip_serializing_if = "Option::is_none")]
10390        prompt_cache_hit_tokens: Option<u32>,
10391        #[serde(skip_serializing_if = "Option::is_none")]
10392        prompt_cache_miss_tokens: Option<u32>,
10393        #[serde(skip_serializing_if = "Option::is_none")]
10394        prompt_cache_write_tokens: Option<u32>,
10395        #[serde(skip_serializing_if = "Option::is_none")]
10396        reasoning_replay_tokens: Option<u32>,
10397        duration_ms: u64,
10398    },
10399    #[serde(rename = "metadata")]
10400    Metadata { meta: Box<ExecStreamMeta> },
10401    #[serde(rename = "done")]
10402    Done,
10403    #[serde(rename = "error")]
10404    Error { error: String },
10405}
10406
10407fn exec_sandbox_elevation_authorized(
10408    allow_sandbox_elevation: bool,
10409    explicit_sandbox: Option<&str>,
10410) -> bool {
10411    allow_sandbox_elevation
10412        || explicit_sandbox.is_some_and(|policy| policy.eq_ignore_ascii_case("danger-full-access"))
10413}
10414
10415fn emit_exec_stream_event(event: &ExecStreamEvent) -> Result<()> {
10416    println!("{}", serde_json::to_string(&exec_stream_value(event)?)?);
10417    Ok(())
10418}
10419
10420/// Process exit code `codewhale exec` uses when a turn ends on a retryable
10421/// infrastructure failure (provider/transport) rather than a genuine task
10422/// failure. 75 is `EX_TEMPFAIL` from sysexits.h — "temporary failure; the
10423/// invocation is expected to succeed on retry" — so bench harnesses and
10424/// supervisors can distinguish retryable infra exits from genuine task
10425/// failures (exit 1) without parsing the stream-json metadata.
10426const EXEC_EXIT_RETRYABLE_INFRA: i32 = 75; // EX_TEMPFAIL
10427
10428/// Map a terminal exec error category to the process exit code.
10429///
10430/// `network` / `timeout` mean the provider connection dropped or stalled
10431/// after every in-session retry budget was exhausted: the task itself
10432/// neither passed nor failed, and re-running the same command is safe.
10433/// `rate_limit` is deliberately NOT mapped to the retryable code — the same
10434/// category also covers quota exhaustion, which a blind retry would hammer.
10435fn exec_failure_exit_code(error_category: Option<&str>) -> i32 {
10436    match error_category {
10437        Some("network" | "timeout") => EXEC_EXIT_RETRYABLE_INFRA,
10438        _ => 1,
10439    }
10440}
10441
10442/// Should a mid-turn engine error event force the final exec summary into
10443/// failure? Only non-recoverable envelopes do. Recoverable warnings (stream
10444/// stall notices, transient retry noise) are emitted on the stream for
10445/// visibility, but the terminal `TurnComplete` event carries the
10446/// authoritative turn outcome — a warning must never fail a run whose turn
10447/// later completes.
10448fn exec_error_event_is_fatal(envelope: &crate::error_taxonomy::ErrorEnvelope) -> bool {
10449    !envelope.recoverable
10450}
10451
10452fn exec_stream_value(event: &ExecStreamEvent) -> Result<serde_json::Value> {
10453    let mut value = serde_json::to_value(event)?;
10454    if let Some(object) = value.as_object_mut() {
10455        object.insert("schema_version".to_string(), serde_json::json!(1));
10456        object.insert(
10457            "schema".to_string(),
10458            serde_json::json!("codewhale.exec-stream"),
10459        );
10460    }
10461    Ok(value)
10462}
10463
10464fn tool_error_receipt_category(error: &crate::tools::spec::ToolError) -> &'static str {
10465    use crate::tools::spec::ToolError;
10466    match error {
10467        ToolError::InvalidInput { .. } => "invalid_input",
10468        ToolError::MissingField { .. } => "missing_field",
10469        ToolError::PathEscape { .. } => "path_escape",
10470        ToolError::ExecutionFailed { .. } => "execution_failed",
10471        ToolError::Timeout { .. } => "timeout",
10472        ToolError::Cancelled { .. } => "cancelled",
10473        ToolError::NotAvailable { .. } => "not_available",
10474        ToolError::PermissionDenied { .. } => "permission_denied",
10475    }
10476}
10477
10478fn tool_artifact_receipt(metadata: Option<&serde_json::Value>) -> Option<serde_json::Value> {
10479    let object = metadata?.as_object()?;
10480    let mut artifact = serde_json::Map::new();
10481    for key in [
10482        "artifact_id",
10483        "artifact_path",
10484        "artifact_relative_path",
10485        "artifact_byte_size",
10486        "spillover_path",
10487        "content_digest",
10488        "original_byte_count",
10489        "retained_head_bytes",
10490        "retained_tail_bytes",
10491    ] {
10492        if let Some(value) = object.get(key) {
10493            artifact.insert(key.to_string(), value.clone());
10494        }
10495    }
10496    (!artifact.is_empty()).then_some(serde_json::Value::Object(artifact))
10497}
10498
10499fn current_binary_sha256() -> Option<String> {
10500    let bytes = std::fs::read(std::env::current_exe().ok()?).ok()?;
10501    Some(format!("sha256:{}", crate::hashing::sha256_hex(&bytes)))
10502}
10503
10504async fn run_workflow_tool_command(
10505    cli: &Cli,
10506    args: WorkflowToolArgs,
10507    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10508) -> Result<()> {
10509    match run_workflow_tool_command_inner(cli, args, plugin_registry).await {
10510        Ok(()) => Ok(()),
10511        Err(error) => {
10512            let _ = emit_exec_stream_event(&ExecStreamEvent::Error {
10513                error: format!("{error:#}"),
10514            });
10515            exit_workflow_tool_failure();
10516        }
10517    }
10518}
10519
10520async fn run_workflow_tool_command_inner(
10521    cli: &Cli,
10522    args: WorkflowToolArgs,
10523    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10524) -> Result<()> {
10525    use crate::tools::spec::ToolSpec;
10526
10527    if args.approval_source != "explicit-workflow-command" {
10528        bail!("workflow-tool requires --approval-source explicit-workflow-command");
10529    }
10530    let input: serde_json::Value = serde_json::from_str(&args.input_json)
10531        .context("--input-json must be a valid Workflow tool input object")?;
10532    if !input.is_object() {
10533        bail!("--input-json must be a JSON object");
10534    }
10535    if !input
10536        .get("action")
10537        .and_then(serde_json::Value::as_str)
10538        .is_some_and(|action| action.eq_ignore_ascii_case("run"))
10539    {
10540        bail!("workflow-tool accepts only action=run");
10541    }
10542
10543    let workspace = resolve_workspace(cli);
10544    let mut config = load_config_from_cli(cli)?;
10545    merge_user_workspace_config(&mut config, cli.config.clone(), &workspace);
10546    if let Ok(env_url) =
10547        std::env::var("CODEWHALE_BASE_URL").or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
10548    {
10549        let trimmed = env_url.trim();
10550        if !trimmed.is_empty() {
10551            config.base_url = Some(trimmed.to_string());
10552        }
10553    }
10554
10555    let model = resolve_exec_model(&config, None);
10556    let route = resolve_cli_exec_route(
10557        &config,
10558        &model,
10559        "Run a checked-in Workflow through the host runtime",
10560        true,
10561    )
10562    .await?;
10563    let execution_config = config_for_cli_route(&config, &route);
10564    let route_identity = execution_config
10565        .active_provider_identity(route.provider)
10566        .map_err(anyhow::Error::msg)
10567        .context("workflow terminal route lost its exact provider identity")?;
10568    let (route_provider, route_provider_id) = exec_stream_provider_route(&route_identity);
10569    let workflow_input_sha256 = format!(
10570        "sha256:{}",
10571        crate::hashing::sha256_hex(&serde_json::to_vec(&input)?)
10572    );
10573    let tool_id = format!("workflow_host_{}", &uuid::Uuid::new_v4().to_string()[..8]);
10574    let tool_started = Instant::now();
10575    let tool_started_at = chrono::Utc::now().to_rfc3339();
10576
10577    emit_exec_stream_event(&ExecStreamEvent::ToolUse {
10578        name: "workflow".to_string(),
10579        id: tool_id.clone(),
10580        input: input.clone(),
10581        started_at: tool_started_at.clone(),
10582    })?;
10583
10584    let (event_tx, event_rx) = tokio::sync::mpsc::channel(1024);
10585    let (stop_tx, stop_rx) = tokio::sync::oneshot::channel();
10586    let event_forwarder = tokio::spawn(forward_direct_workflow_events(event_rx, stop_rx));
10587    let (tool, context) = match build_direct_workflow_tool(
10588        &execution_config,
10589        &route,
10590        &workspace,
10591        event_tx,
10592        plugin_registry,
10593    )
10594    .await
10595    {
10596        Ok(built) => built,
10597        Err(err) => {
10598            let _ = stop_tx.send(());
10599            let _ = event_forwarder.await;
10600            exit_workflow_tool_error(&tool_id, err.to_string());
10601        }
10602    };
10603
10604    let result = tool.execute(input, &context).await;
10605    drop(tool);
10606    let _ = stop_tx.send(());
10607    event_forwarder
10608        .await
10609        .context("workflow event forwarder task failed")??;
10610
10611    let result = match result {
10612        Ok(result) => result,
10613        Err(err) => {
10614            let error = err.to_string();
10615            exit_workflow_tool_error(&tool_id, error);
10616        }
10617    };
10618
10619    let workflow_status =
10620        direct_workflow_status(&result.content).unwrap_or_else(|| "unknown".to_string());
10621    let completed = result.success && workflow_status == "completed";
10622    emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10623        id: tool_id,
10624        name: "workflow".to_string(),
10625        output: result.content.clone(),
10626        status: if completed { "success" } else { "error" }.to_string(),
10627        started_at: tool_started_at,
10628        completed_at: chrono::Utc::now().to_rfc3339(),
10629        duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10630        side_effect_status: result
10631            .metadata
10632            .as_ref()
10633            .and_then(|metadata| metadata.get("side_effect_status"))
10634            .and_then(serde_json::Value::as_str)
10635            .unwrap_or("unknown")
10636            .to_string(),
10637        error_category: (!completed).then(|| "tool_error".to_string()),
10638        truncated: result
10639            .metadata
10640            .as_ref()
10641            .and_then(|metadata| metadata.get("truncated"))
10642            .and_then(serde_json::Value::as_bool),
10643        artifact: tool_artifact_receipt(result.metadata.as_ref()),
10644        result_metadata: result.metadata.clone(),
10645    })?;
10646    emit_exec_stream_event(&ExecStreamEvent::Metadata {
10647        meta: Box::new(ExecStreamMeta {
10648            receipt_kind: "terminal",
10649            provider: route_provider,
10650            provider_id: route_provider_id,
10651            // No parent/operator model call occurs on this host-owned path;
10652            // child model/provider usage remains attributable in typed task
10653            // receipts rather than being misreported as one root model.
10654            model: "host-workflow".to_string(),
10655            route_source: "host_workflow".to_string(),
10656            input_tokens: None,
10657            output_tokens: None,
10658            prompt_cache_hit_tokens: None,
10659            prompt_cache_miss_tokens: None,
10660            prompt_cache_write_tokens: None,
10661            reasoning_tokens: None,
10662            codewhale_max_output_tokens: None,
10663            codewhale_max_output_tokens_source: None,
10664            duration_ms: u64::try_from(tool_started.elapsed().as_millis()).unwrap_or(u64::MAX),
10665            retry_count: None,
10666            approval_posture: "explicit_workflow_command".to_string(),
10667            sandbox_posture: "configured".to_string(),
10668            binary_sha256: current_binary_sha256(),
10669            config_sha256: None,
10670            prompt_sha256: workflow_input_sha256,
10671            tool_catalog_sha256: None,
10672            input_analysis: ExecStreamInputAnalysis::default(),
10673            visible_final_answer_chars: result.content.chars().count(),
10674            session_id: String::new(),
10675            resume_command: String::new(),
10676            workspace: workspace.display().to_string(),
10677            message_count: 0,
10678            status: Some(workflow_status.clone()),
10679            termination_reason: Some(if completed { "resolved" } else { "tool_error" }.to_string()),
10680            error_category: (!completed).then(|| "tool".to_string()),
10681            error: (!completed)
10682                .then(|| format!("workflow run ended with terminal status {workflow_status}")),
10683        }),
10684    })?;
10685    if !completed {
10686        let error = format!("workflow run ended with terminal status {workflow_status}");
10687        emit_exec_stream_event(&ExecStreamEvent::Error {
10688            error: error.clone(),
10689        })?;
10690        exit_workflow_tool_failure();
10691    }
10692    emit_exec_stream_event(&ExecStreamEvent::Done)?;
10693    Ok(())
10694}
10695
10696fn exit_workflow_tool_failure() -> ! {
10697    let _ = io::stdout().flush();
10698    std::process::exit(1)
10699}
10700
10701fn exit_workflow_tool_error(tool_id: &str, error: String) -> ! {
10702    let now = chrono::Utc::now().to_rfc3339();
10703    let _ = emit_exec_stream_event(&ExecStreamEvent::ToolResult {
10704        id: tool_id.to_string(),
10705        name: "workflow".to_string(),
10706        output: error.clone(),
10707        status: "error".to_string(),
10708        started_at: now.clone(),
10709        completed_at: now,
10710        duration_ms: 0,
10711        side_effect_status: "unknown".to_string(),
10712        error_category: Some("execution_failed".to_string()),
10713        truncated: None,
10714        artifact: None,
10715        result_metadata: None,
10716    });
10717    let _ = emit_exec_stream_event(&ExecStreamEvent::Error { error });
10718    exit_workflow_tool_failure()
10719}
10720
10721async fn initialize_direct_workflow_mcp_pool(
10722    config: &Config,
10723    workspace: &Path,
10724    network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
10725    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10726) -> Option<(
10727    std::sync::Arc<tokio::sync::Mutex<crate::mcp::McpPool>>,
10728    Vec<(String, String)>,
10729)> {
10730    if !config.features().enabled(Feature::Mcp) {
10731        return None;
10732    }
10733    let mut pool = crate::mcp::McpPool::from_config_path_with_workspace_and_plugins(
10734        &config.mcp_config_path(),
10735        workspace,
10736        plugin_registry,
10737    )
10738    .unwrap_or_else(|error| {
10739        tracing::debug!("No MCP config for direct Workflow runtime: {error:#}");
10740        crate::mcp::McpPool::new(crate::mcp::McpConfig::default())
10741    });
10742    if let Some(policy) = network_policy {
10743        pool = pool.with_network_policy(policy);
10744    }
10745    let failures = pool
10746        .connect_all()
10747        .await
10748        .into_iter()
10749        .map(|(server, error)| (server, format!("{error:#}")))
10750        .collect();
10751    Some((std::sync::Arc::new(tokio::sync::Mutex::new(pool)), failures))
10752}
10753
10754async fn build_direct_workflow_tool(
10755    config: &Config,
10756    route: &CliAutoRoute,
10757    workspace: &Path,
10758    event_tx: tokio::sync::mpsc::Sender<crate::core::events::Event>,
10759    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
10760) -> Result<(
10761    crate::tools::workflow::WorkflowTool,
10762    crate::tools::ToolContext,
10763)> {
10764    use std::sync::Arc;
10765
10766    use crate::client::DeepSeekClient;
10767    use crate::core::authority::shell_policy_for_mode;
10768    use crate::fleet::roster::FleetRoster;
10769    use crate::tools::AgentToolSurfaceOptions;
10770    use crate::tools::goal::new_shared_goal_state;
10771    use crate::tools::subagent::{SubAgentRuntime, new_shared_subagent_manager_with_timeout};
10772    use crate::tools::todo::new_shared_todo_list;
10773    use crate::tui::app::AppMode;
10774
10775    let provider = config.api_provider();
10776    if !config.subagents_enabled_for_provider(provider) {
10777        bail!(
10778            "Workflow dispatch requires sub-agents for provider {} ({})",
10779            provider.as_str(),
10780            config
10781                .subagents_disabled_reason()
10782                .unwrap_or("provider-specific sub-agent configuration disabled it")
10783        );
10784    }
10785
10786    let yolo = config.yolo.unwrap_or(false);
10787    let mode = if yolo {
10788        AppMode::Yolo
10789    } else {
10790        AppMode::Operate
10791    };
10792    let allow_shell = yolo || config.allow_shell();
10793    let shell_policy = shell_policy_for_mode(mode, allow_shell);
10794    let trusted = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
10795    let mut context = crate::tools::ToolContext::with_auto_approve(
10796        workspace.to_path_buf(),
10797        yolo,
10798        config.notes_path(),
10799        config.mcp_config_path(),
10800        yolo,
10801    )
10802    .with_features(config.features())
10803    .with_skills_config(
10804        config.skills_dir(),
10805        config.skills_config().scan_codewhale_only(),
10806    )
10807    .with_plugin_registry(std::sync::Arc::clone(&plugin_registry))
10808    .with_shell_policy(shell_policy)
10809    .with_trusted_external_paths(trusted.paths().to_vec())
10810    .with_elevated_sandbox_policy(crate::core::authority::sandbox_policy_for_turn(
10811        mode,
10812        if yolo {
10813            crate::tui::approval::ApprovalMode::Bypass
10814        } else {
10815            crate::tui::approval::ApprovalMode::Suggest
10816        },
10817        config.sandbox_mode.as_deref(),
10818        workspace,
10819    ));
10820    let network_policy = config.network.clone().map(|network| {
10821        crate::network_policy::NetworkPolicyDecider::with_default_audit(network.into_runtime())
10822    });
10823    if let Some(policy) = network_policy.as_ref() {
10824        context = context.with_network_policy(policy.clone());
10825    }
10826    if config.memory_enabled() {
10827        context.memory_path = Some(config.memory_path());
10828    }
10829    context.search_provider = config.search_provider();
10830    context.search_api_key = config
10831        .search
10832        .as_ref()
10833        .and_then(|search| search.api_key.clone());
10834    context.search_base_url = config
10835        .search
10836        .as_ref()
10837        .and_then(|search| search.base_url.clone());
10838    if let Some(backend) = crate::sandbox::backend::create_backend(config)? {
10839        context = context.with_sandbox_backend(Arc::from(backend));
10840    }
10841
10842    let max_subagents = config.max_subagents_for_provider(provider);
10843    let manager = new_shared_subagent_manager_with_timeout(
10844        workspace.to_path_buf(),
10845        max_subagents,
10846        config
10847            .max_admitted_subagents_for_provider(provider)
10848            .max(max_subagents),
10849        Duration::from_secs(config.subagent_heartbeat_timeout_secs_for_provider(provider)),
10850        config.launch_concurrency_for_provider(provider),
10851        config.subagent_token_budget_for_provider(provider),
10852    );
10853    let roster = Arc::new(FleetRoster::load(&config.fleet_config(), workspace));
10854    let mut role_models = roster.model_overrides();
10855    role_models.extend(config.subagent_model_overrides());
10856
10857    let features = config.features();
10858    let mut surface = AgentToolSurfaceOptions::new(shell_policy);
10859    surface.apply_patch_enabled = features.enabled(Feature::ApplyPatch);
10860    surface.web_search_enabled = features.enabled(Feature::WebSearch);
10861    surface.memory_tool_enabled = config.memory_enabled();
10862    surface.vision_config = features
10863        .enabled(Feature::VisionModel)
10864        .then(|| config.vision_model_config())
10865        .flatten();
10866    surface.speech_output_dir = config.speech_output_dir();
10867    surface.goal_state = Some(new_shared_goal_state());
10868
10869    let client = DeepSeekClient::new(config)?;
10870    // A FIXED model with `reasoning_effort = auto` (the shape a Fleet worker
10871    // subprocess launches with: `--model <exact> --reasoning-effort auto`) is
10872    // still Auto. Deriving the auto flag from `route.auto_model` alone left it
10873    // raw AND non-auto: the runtime carried the literal string `"auto"` while
10874    // nothing was allowed to resolve it. Auto is a reasoning decision, not a
10875    // model decision — it does not require `--model auto`.
10876    let reasoning_effort_auto = route.auto_controls_reasoning;
10877    let reasoning_effort = route
10878        .reasoning_effort
10879        .and_then(|effort| cli_reasoning_effort_value(config, &route.model, effort));
10880    let mcp_pool = if let Some((pool, failures)) =
10881        initialize_direct_workflow_mcp_pool(config, workspace, network_policy, plugin_registry)
10882            .await
10883    {
10884        for (server, error) in failures {
10885            tracing::warn!(
10886                server = %server,
10887                error = %error,
10888                "direct Workflow runtime could not connect MCP server"
10889            );
10890        }
10891        Some(pool)
10892    } else {
10893        None
10894    };
10895    let runtime = SubAgentRuntime::new(
10896        client,
10897        route.model.clone(),
10898        context.clone(),
10899        allow_shell,
10900        Some(event_tx),
10901        manager.clone(),
10902    )
10903    .with_locale_tag(
10904        crate::localization::resolve_locale(
10905            &crate::settings::Settings::load_persisted()
10906                .unwrap_or_default()
10907                .locale,
10908        )
10909        .tag(),
10910    )
10911    .with_role_models(role_models)
10912    .with_api_config(config.clone())
10913    .with_fleet_roster(roster)
10914    .with_auto_model(route.auto_model)
10915    .with_reasoning_effort(reasoning_effort, reasoning_effort_auto)
10916    .with_agent_tool_surface_options(surface)
10917    .with_max_spawn_depth(config.subagent_max_spawn_depth_for_provider(provider))
10918    .with_step_api_timeout(Duration::from_secs(
10919        config.subagent_api_timeout_secs_for_provider(provider),
10920    ))
10921    .with_speech_output_dir(config.speech_output_dir())
10922    .with_mcp_pool(mcp_pool)
10923    .with_todos(new_shared_todo_list())
10924    .with_parent_mode(mode);
10925
10926    Ok((
10927        crate::tools::workflow::WorkflowTool::new(manager, runtime).with_explicit_cli_approval(),
10928        context,
10929    ))
10930}
10931
10932async fn forward_direct_workflow_events(
10933    mut event_rx: tokio::sync::mpsc::Receiver<crate::core::events::Event>,
10934    mut stop_rx: tokio::sync::oneshot::Receiver<()>,
10935) -> Result<()> {
10936    loop {
10937        tokio::select! {
10938            biased;
10939            event = event_rx.recv() => match event {
10940                Some(event) => emit_direct_workflow_event(event)?,
10941                None => return Ok(()),
10942            },
10943            _ = &mut stop_rx => {
10944                while let Ok(event) = event_rx.try_recv() {
10945                    emit_direct_workflow_event(event)?;
10946                }
10947                return Ok(());
10948            }
10949        }
10950    }
10951}
10952
10953fn emit_direct_workflow_event(event: crate::core::events::Event) -> Result<()> {
10954    if let crate::core::events::Event::WorkflowUi { run_id, event } = event {
10955        emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
10956    }
10957    Ok(())
10958}
10959
10960fn direct_workflow_status(content: &str) -> Option<String> {
10961    serde_json::from_str::<serde_json::Value>(content)
10962        .ok()?
10963        .get("status")?
10964        .as_str()
10965        .map(str::to_ascii_lowercase)
10966}
10967
10968fn exec_stream_input_analysis(
10969    messages: &[Message],
10970    system: Option<&SystemPrompt>,
10971) -> ExecStreamInputAnalysis {
10972    let mut analysis = ExecStreamInputAnalysis {
10973        estimated_request_tokens: crate::compaction::estimate_input_tokens_conservative(
10974            messages, system,
10975        ),
10976        estimated_message_content_tokens: crate::compaction::estimate_tokens(messages),
10977        estimated_system_tokens: exec_stream_estimate_system_tokens(system),
10978        estimated_framing_tokens: messages.len().saturating_mul(12).saturating_add(48),
10979        ..ExecStreamInputAnalysis::default()
10980    };
10981
10982    for message in messages {
10983        match message.role.as_str() {
10984            "user" => analysis.user_message_count += 1,
10985            "assistant" => analysis.assistant_message_count += 1,
10986            "tool" => analysis.tool_message_count += 1,
10987            _ => {}
10988        }
10989
10990        for block in &message.content {
10991            match block {
10992                ContentBlock::Text { text, .. } => {
10993                    exec_stream_add_text_estimate(
10994                        text,
10995                        &mut analysis.text_chars,
10996                        &mut analysis.text_estimated_tokens,
10997                    );
10998                }
10999                ContentBlock::Thinking { thinking, .. } => {
11000                    exec_stream_add_text_estimate(
11001                        thinking,
11002                        &mut analysis.thinking_chars,
11003                        &mut analysis.thinking_estimated_tokens,
11004                    );
11005                }
11006                ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
11007                    analysis.tool_use_count += 1;
11008                    exec_stream_add_json_estimate(
11009                        input,
11010                        &mut analysis.tool_use_input_chars,
11011                        &mut analysis.tool_use_input_estimated_tokens,
11012                    );
11013                }
11014                ContentBlock::ToolResult {
11015                    content,
11016                    content_blocks,
11017                    ..
11018                } => {
11019                    analysis.tool_result_count += 1;
11020                    exec_stream_add_text_estimate(
11021                        content,
11022                        &mut analysis.tool_result_chars,
11023                        &mut analysis.tool_result_estimated_tokens,
11024                    );
11025                    if let Some(blocks) = content_blocks {
11026                        exec_stream_add_json_estimate(
11027                            blocks,
11028                            &mut analysis.tool_result_chars,
11029                            &mut analysis.tool_result_estimated_tokens,
11030                        );
11031                    }
11032                }
11033                ContentBlock::ToolSearchToolResult { content, .. }
11034                | ContentBlock::CodeExecutionToolResult { content, .. } => {
11035                    analysis.tool_result_count += 1;
11036                    exec_stream_add_json_estimate(
11037                        content,
11038                        &mut analysis.tool_result_chars,
11039                        &mut analysis.tool_result_estimated_tokens,
11040                    );
11041                }
11042                ContentBlock::ImageUrl { .. } => {}
11043            }
11044        }
11045    }
11046
11047    analysis
11048}
11049
11050fn exec_stream_add_text_estimate(text: &str, chars: &mut usize, tokens: &mut usize) {
11051    *chars = chars.saturating_add(text.chars().count());
11052    *tokens = tokens.saturating_add(crate::compaction::estimate_text_tokens_conservative(text));
11053}
11054
11055fn exec_stream_add_json_estimate<T: serde::Serialize>(
11056    value: &T,
11057    chars: &mut usize,
11058    tokens: &mut usize,
11059) {
11060    let text = serde_json::to_string(value).unwrap_or_default();
11061    exec_stream_add_text_estimate(&text, chars, tokens);
11062}
11063
11064fn exec_stream_estimate_system_tokens(system: Option<&SystemPrompt>) -> usize {
11065    match system {
11066        Some(SystemPrompt::Text(text)) => {
11067            crate::compaction::estimate_text_tokens_conservative(text)
11068        }
11069        Some(SystemPrompt::Blocks(blocks)) => blocks
11070            .iter()
11071            .map(|block| crate::compaction::estimate_text_tokens_conservative(&block.text))
11072            .sum(),
11073        None => 0,
11074    }
11075}
11076
11077fn exec_saved_session_line(session_id: &str) -> String {
11078    format!("session: {}", truncate_id(session_id))
11079}
11080
11081fn exec_resumed_session_line(session_id: &str) -> String {
11082    format!("resumed session: {}", truncate_id(session_id))
11083}
11084
11085fn exec_stream_session_ref(session_id: &str) -> String {
11086    crate::utils::redacted_identifier_for_log(session_id)
11087}
11088
11089fn exec_stream_resume_hint(session_id: &str) -> String {
11090    if session_id.trim().is_empty() {
11091        String::new()
11092    } else {
11093        "codewhale exec --resume <redacted-session-id>".to_string()
11094    }
11095}
11096
11097#[derive(Clone, Copy)]
11098struct PersistedProviderRoute<'a> {
11099    kind: &'a str,
11100    id: Option<&'a str>,
11101}
11102
11103fn persist_exec_session(
11104    messages: &[Message],
11105    model: &str,
11106    provider_route: PersistedProviderRoute<'_>,
11107    workspace: &Path,
11108    system_prompt: &Option<SystemPrompt>,
11109    session_id: Option<&str>,
11110    total_tokens: u64,
11111) -> Result<String> {
11112    let manager =
11113        SessionManager::default_location().context("could not open session manager for save")?;
11114    let mut saved = if let Some(id) = session_id.filter(|id| !id.trim().is_empty()) {
11115        match manager.load_session(id) {
11116            Ok(existing) => session_manager::update_session(
11117                existing,
11118                messages,
11119                total_tokens,
11120                system_prompt.as_ref(),
11121            ),
11122            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
11123                session_manager::create_saved_session_with_id_and_mode(
11124                    id.to_string(),
11125                    messages,
11126                    model,
11127                    workspace,
11128                    total_tokens,
11129                    system_prompt.as_ref(),
11130                    Some("exec"),
11131                )
11132            }
11133            Err(err) => return Err(err).context("could not load existing exec session"),
11134        }
11135    } else {
11136        session_manager::create_saved_session_with_mode(
11137            messages,
11138            model,
11139            workspace,
11140            total_tokens,
11141            system_prompt.as_ref(),
11142            Some("exec"),
11143        )
11144    };
11145    stamp_exec_session_metadata(
11146        &mut saved,
11147        model,
11148        provider_route.kind,
11149        provider_route.id,
11150        workspace,
11151    );
11152    let id = saved.metadata.id.clone();
11153    manager
11154        .save_session(&saved)
11155        .context("could not save exec session")?;
11156    Ok(id)
11157}
11158
11159fn stamp_exec_session_metadata(
11160    saved: &mut session_manager::SavedSession,
11161    model: &str,
11162    model_provider_kind: &str,
11163    model_provider_id: Option<&str>,
11164    workspace: &Path,
11165) {
11166    saved.metadata.model = model.to_string();
11167    saved
11168        .metadata
11169        .set_model_provider_route(model_provider_kind, model_provider_id);
11170    saved.metadata.workspace = workspace.to_path_buf();
11171    saved.metadata.mode = Some("exec".to_string());
11172}
11173
11174#[derive(serde::Serialize)]
11175struct ExecToolEntry {
11176    name: String,
11177    success: bool,
11178    output: String,
11179}
11180
11181#[derive(serde::Serialize)]
11182struct ExecOutcome {
11183    kind: String,
11184    outcome: String,
11185    tool_name: String,
11186    reason: String,
11187}
11188
11189#[derive(serde::Serialize, Default)]
11190struct ExecSummary {
11191    mode: String,
11192    provider: String,
11193    model: String,
11194    prompt: String,
11195    output: String,
11196    tools: Vec<ExecToolEntry>,
11197    outcomes: Vec<ExecOutcome>,
11198    status: Option<String>,
11199    termination_reason: Option<String>,
11200    error_category: Option<String>,
11201    error: Option<String>,
11202    #[serde(skip_serializing_if = "Vec::is_empty")]
11203    released_services: Vec<crate::tools::shell::PersistentServiceReceipt>,
11204}
11205
11206fn validate_exec_tool_authority_resume(
11207    tool_authority_json: Option<&str>,
11208    resuming: bool,
11209) -> Result<()> {
11210    if tool_authority_json.is_some() && resuming {
11211        bail!(
11212            "Fleet tool authority cannot be combined with exec --resume, --session-id, or --continue"
11213        );
11214    }
11215    Ok(())
11216}
11217
11218fn exec_network_policy(
11219    config: &Config,
11220    outer_network_access: Option<bool>,
11221) -> Option<crate::network_policy::NetworkPolicyDecider> {
11222    // Fleet caps are an outer authority boundary: user configuration may
11223    // narrow them further, but it may never widen an explicit network denial.
11224    if outer_network_access == Some(false) {
11225        return Some(crate::network_policy::NetworkPolicyDecider::new(
11226            crate::network_policy::NetworkPolicy {
11227                default: crate::network_policy::DecisionToml::Deny,
11228                ..crate::network_policy::NetworkPolicy::default()
11229            },
11230            None,
11231        ));
11232    }
11233    config.network.clone().map(|toml_cfg| {
11234        crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
11235    })
11236}
11237
11238fn apply_fleet_engine_feature_caps(
11239    features: &mut crate::features::Features,
11240    fleet_authority_active: bool,
11241    outer_network_access: Option<bool>,
11242    shell_authority: crate::tools::spec::ToolShellAuthority,
11243) {
11244    if fleet_authority_active {
11245        features.disable(crate::features::Feature::Subagents);
11246        features.disable(crate::features::Feature::Mcp);
11247        if shell_authority != crate::tools::spec::ToolShellAuthority::ReadOnly {
11248            features.disable(crate::features::Feature::ShellTool);
11249        }
11250    }
11251    if outer_network_access == Some(false) {
11252        features.disable(crate::features::Feature::WebSearch);
11253    }
11254}
11255
11256/// Resolve the optional headless safety budget without imposing a hidden
11257/// default. Benchmarks and other long-running exec callers continue until the
11258/// model finishes unless they opt into a finite `--max-turns` value.
11259fn exec_max_steps(max_turns: Option<u32>) -> u32 {
11260    max_turns.unwrap_or(u32::MAX)
11261}
11262
11263#[allow(clippy::too_many_arguments)]
11264async fn run_exec_agent(
11265    config: &Config,
11266    model: &str,
11267    prompt: &str,
11268    workspace: PathBuf,
11269    max_subagents: usize,
11270    auto_approve: bool,
11271    allow_sandbox_elevation: bool,
11272    explicit_sandbox: Option<&str>,
11273    trust_mode: bool,
11274    json_output: bool,
11275    resume_session: Option<session_manager::SavedSession>,
11276    force_configured_route: bool,
11277    output_format: ExecOutputFormat,
11278    max_turns: u32,
11279    allowed_tools: Option<Vec<String>>,
11280    disallowed_tools: Option<Vec<String>>,
11281    append_system_prompt: Option<String>,
11282    tool_authority_json: Option<String>,
11283    plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
11284) -> Result<()> {
11285    use crate::compaction::CompactionConfig;
11286    use crate::core::engine::{EngineConfig, spawn_engine};
11287    use crate::core::events::Event;
11288    use crate::core::ops::Op;
11289    use crate::tools::plan::new_shared_plan_state;
11290    use crate::tools::todo::new_shared_todo_list;
11291    use crate::tui::app::AppMode;
11292
11293    validate_exec_tool_authority_resume(tool_authority_json.as_deref(), resume_session.is_some())?;
11294    let fleet_authority = tool_authority_json
11295        .as_deref()
11296        .map(crate::tools::spec::ToolAuthorityEnvelope::from_json)
11297        .transpose()
11298        .map_err(anyhow::Error::msg)?;
11299    let fleet_authority_active = fleet_authority.is_some();
11300    let outer_network_access = fleet_authority
11301        .as_ref()
11302        .and_then(|authority| authority.network_access);
11303    let outer_shell_authority = fleet_authority
11304        .as_ref()
11305        .map(|authority| authority.shell)
11306        .unwrap_or_default();
11307    if let Some(envelope) = fleet_authority {
11308        crate::tools::spec::install_process_tool_authority(envelope).map_err(anyhow::Error::msg)?;
11309    }
11310
11311    let route = resolve_cli_exec_route(config, model, prompt, force_configured_route).await?;
11312    let execution_config = config_for_cli_route(config, &route);
11313    let auto_model = route.auto_model;
11314    let effective_provider = route.provider;
11315    let effective_model = route.model;
11316    let validated_route = crate::route_runtime::resolve_runtime_route(
11317        &execution_config,
11318        effective_provider,
11319        Some(&effective_model),
11320    )
11321    .map_err(anyhow::Error::msg)?
11322    .validate()
11323    .map_err(anyhow::Error::msg)?;
11324    let effective_provider_name = validated_route.identity.key.clone();
11325    let effective_provider_id = validated_route.identity.exact_id.clone();
11326    let (effective_provider_kind, effective_stream_provider_id) =
11327        exec_stream_provider_route(&validated_route.identity);
11328    let route_source = if auto_model {
11329        "auto_resolver"
11330    } else {
11331        "explicit_or_configured"
11332    }
11333    .to_string();
11334    let exec_started = Instant::now();
11335    let prompt_sha256 = format!("sha256:{}", crate::hashing::sha256_hex(prompt.as_bytes()));
11336    let binary_sha256 = current_binary_sha256();
11337    let approval_posture = if auto_approve { "auto_tools" } else { "ask" }.to_string();
11338    let sandbox_posture = explicit_sandbox.unwrap_or("configured_default").to_string();
11339    let active_route_limits =
11340        crate::route_budget::known_route_limits(validated_route.candidate.limits());
11341    let max_subagents = if max_subagents == config.max_subagents_for_provider(config.api_provider())
11342    {
11343        execution_config
11344            .max_subagents_for_provider(effective_provider)
11345            .clamp(1, MAX_SUBAGENTS)
11346    } else {
11347        max_subagents
11348    };
11349    // A FIXED model with `--reasoning-effort auto` (the exact shape a Fleet
11350    // worker subprocess launches with: `--model <exact> --reasoning-effort
11351    // auto`) is still Auto. `auto_model` is a *model* decision and is false
11352    // here, so deriving the auto flag from it left this path both raw and
11353    // non-auto: the literal string `"auto"` travelled to the engine while the
11354    // receipt claimed no Auto was in play.
11355    let reasoning_effort_auto = route.auto_controls_reasoning;
11356    // Resolve Auto against this run's prompt at the CLI boundary, exactly like
11357    // `run_one_shot`/`run_one_shot_json` and the interactive launch path do,
11358    // so the tier the engine (and the receipt below) sees is concrete.
11359    let effective_reasoning_effort = route.reasoning_effort.and_then(|effort| {
11360        cli_reasoning_effort_value_for_prompt(&execution_config, &effective_model, effort, prompt)
11361    });
11362
11363    let settings = crate::settings::Settings::load().unwrap_or_default();
11364    let auto_compact_enabled = if crate::settings::Settings::auto_compact_explicitly_configured() {
11365        settings.auto_compact
11366    } else {
11367        crate::route_budget::auto_compact_default_for_route(
11368            effective_provider,
11369            &effective_model,
11370            active_route_limits,
11371        )
11372    };
11373    let compaction = CompactionConfig {
11374        enabled: auto_compact_enabled,
11375        model: effective_model.clone(),
11376        effective_context_window: Some(crate::route_budget::route_context_window_tokens(
11377            effective_provider,
11378            &effective_model,
11379            active_route_limits,
11380        )),
11381        token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
11382            effective_provider,
11383            &effective_model,
11384            active_route_limits,
11385            settings.auto_compact_threshold_percent,
11386        ),
11387        ..Default::default()
11388    };
11389
11390    let network_policy = exec_network_policy(&execution_config, outer_network_access);
11391
11392    let lsp_config = (!fleet_authority_active)
11393        .then(|| {
11394            execution_config
11395                .lsp
11396                .clone()
11397                .map(crate::config::LspConfigToml::into_runtime)
11398        })
11399        .flatten();
11400    let mut engine_features = execution_config.features();
11401    apply_fleet_engine_feature_caps(
11402        &mut engine_features,
11403        fleet_authority_active,
11404        outer_network_access,
11405        outer_shell_authority,
11406    );
11407    if crate::core::allowlist_is_native_file_and_shell_only(allowed_tools.as_deref()) {
11408        engine_features.disable(crate::features::Feature::Mcp);
11409    }
11410    let engine_plugin_registry = if fleet_authority_active {
11411        std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
11412    } else {
11413        plugin_registry
11414    };
11415    let exec_allow_shell = crate::tools::spec::fleet_exec_shell_enabled(
11416        fleet_authority_active,
11417        outer_shell_authority,
11418        disallowed_tools.as_deref(),
11419    ) || (!fleet_authority_active
11420        && (auto_approve || execution_config.allow_shell()));
11421    let persist_services_enabled = cfg!(unix)
11422        && !fleet_authority_active
11423        && exec_allow_shell
11424        && explicit_sandbox
11425            .is_some_and(|sandbox| sandbox.eq_ignore_ascii_case("danger-full-access"));
11426    let exec_shell_manager = crate::tools::shell::new_shared_shell_manager(workspace.clone());
11427    let runtime_services = crate::tools::spec::RuntimeToolServices {
11428        shell_manager: Some(exec_shell_manager.clone()),
11429        persist_services_enabled,
11430        ..crate::tools::spec::RuntimeToolServices::default()
11431    };
11432
11433    let engine_config = EngineConfig {
11434        model: effective_model.clone(),
11435        active_route_limits,
11436        workspace: workspace.clone(),
11437        subagent_state_root: None,
11438        plugin_registry: Some(engine_plugin_registry),
11439        allow_shell: exec_allow_shell,
11440        trust_mode,
11441        notes_path: execution_config.notes_path(),
11442        mcp_config_path: execution_config.mcp_config_path(),
11443        skills_dir: execution_config.skills_dir(),
11444        skills_scan_codewhale_only: execution_config.skills_config().scan_codewhale_only(),
11445        instructions: {
11446            let mut instrs: Vec<crate::prompts::InstructionSource> = execution_config
11447                .instructions_paths()
11448                .into_iter()
11449                .map(Into::into)
11450                .collect();
11451            if let Some(ref extra) = append_system_prompt {
11452                instrs.push(crate::prompts::InstructionSource::Inline {
11453                    name: "cli:append-system-prompt".into(),
11454                    content: extra.clone(),
11455                });
11456            }
11457            instrs
11458        },
11459        project_context_pack_enabled: execution_config.project_context_pack_enabled(),
11460        translation_enabled: false,
11461        max_steps: max_turns,
11462        max_subagents,
11463        max_admitted_subagents: execution_config
11464            .max_admitted_subagents_for_provider(effective_provider)
11465            .max(max_subagents),
11466        launch_concurrency: execution_config.launch_concurrency_for_provider(effective_provider),
11467        subagents_enabled: !fleet_authority_active
11468            && execution_config.subagents_enabled_for_provider(effective_provider),
11469        features: engine_features,
11470        auto_review_policy: execution_config.auto_review_policy(),
11471        compaction: compaction.clone(),
11472        todos: new_shared_todo_list(),
11473        plan_state: new_shared_plan_state(),
11474        goal_state: crate::tools::goal::new_shared_goal_state(),
11475        max_spawn_depth: if fleet_authority_active {
11476            0
11477        } else {
11478            execution_config.subagent_max_spawn_depth_for_provider(effective_provider)
11479        },
11480        subagent_token_budget: execution_config
11481            .subagent_token_budget_for_provider(effective_provider),
11482        network_policy,
11483        snapshots_enabled: !fleet_authority_active && execution_config.snapshots_config().enabled,
11484        snapshots_max_workspace_bytes: execution_config
11485            .snapshots_config()
11486            .max_workspace_gb
11487            .saturating_mul(1024 * 1024 * 1024),
11488        lsp_config,
11489        runtime_services,
11490        subagent_model_overrides: execution_config.subagent_model_overrides(),
11491        fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::load(
11492            &execution_config.fleet_config(),
11493            &workspace,
11494        )),
11495        subagent_api_timeout: std::time::Duration::from_secs(
11496            execution_config.subagent_api_timeout_secs_for_provider(effective_provider),
11497        ),
11498        stream_chunk_timeout: std::time::Duration::from_secs(
11499            execution_config.stream_chunk_timeout_secs(),
11500        ),
11501        subagent_heartbeat_timeout: std::time::Duration::from_secs(
11502            execution_config.subagent_heartbeat_timeout_secs_for_provider(effective_provider),
11503        ),
11504        prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
11505        bwrap_extensions: crate::sandbox::BwrapMountExtensions {
11506            read_only_roots: execution_config.bwrap_ro_roots.clone(),
11507            device_roots: execution_config.bwrap_dev_roots.clone(),
11508        },
11509        memory_enabled: execution_config.memory_enabled(),
11510        memory_path: execution_config.memory_path(),
11511        speech_output_dir: execution_config.speech_output_dir(),
11512        vision_config: execution_config.vision_model_config(),
11513        strict_tool_mode: execution_config.strict_tool_mode.unwrap_or(false),
11514        goal_objective: None,
11515        goal_token_budget: None,
11516        goal_status: crate::tools::goal::GoalStatus::Active,
11517        goal_max_continuations: execution_config.goal_max_continuations(),
11518        allowed_tools: allowed_tools.clone(),
11519        disallowed_tools: disallowed_tools.clone(),
11520        max_tool_calls: None,
11521        hook_executor: None,
11522        locale_tag: crate::localization::resolve_locale(&settings.locale)
11523            .tag()
11524            .to_string(),
11525        workshop: {
11526            crate::tools::large_output_router::WorkshopConfig::install_active(
11527                config.workshop.as_ref(),
11528            );
11529            config.workshop.clone()
11530        },
11531        search_provider: execution_config.search_provider(),
11532        search_api_key: execution_config
11533            .search
11534            .as_ref()
11535            .and_then(|s| s.api_key.clone()),
11536        search_base_url: execution_config
11537            .search
11538            .as_ref()
11539            .and_then(|s| s.base_url.clone()),
11540        tools_always_load: if fleet_authority_active {
11541            std::collections::HashSet::new()
11542        } else {
11543            execution_config.tools_always_load()
11544        },
11545        tools: if fleet_authority_active {
11546            None
11547        } else {
11548            execution_config.tools.clone()
11549        },
11550        verbosity: execution_config.verbosity.clone(),
11551        workspace_follow_symlinks: settings.workspace_follow_symlinks,
11552        exec_policy_engine: execution_config.exec_policy_engine.clone(),
11553        terminal_chrome_enabled: false,
11554        advisor_config: execution_config
11555            .advisor
11556            .as_ref()
11557            .map(crate::tools::subagent::AdvisorConfig::from_toml)
11558            .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
11559    };
11560
11561    let engine_handle = spawn_engine(engine_config, &execution_config);
11562    let mode = if auto_approve {
11563        AppMode::Yolo
11564    } else {
11565        AppMode::Agent
11566    };
11567
11568    let resuming_session = resume_session.is_some();
11569    let mut loaded_session_id = None;
11570    if let Some(saved) = resume_session {
11571        let saved_id = saved.metadata.id.clone();
11572        if saved.metadata.workspace != workspace && output_format == ExecOutputFormat::Text {
11573            eprintln!(
11574                "Warning: session {} was created in a different workspace ({}). Resuming anyway.",
11575                truncate_id(&saved_id),
11576                saved.metadata.workspace.display(),
11577            );
11578        }
11579
11580        engine_handle
11581            .send(Op::SyncSession {
11582                session_id: Some(saved_id.clone()),
11583                messages: saved.messages,
11584                system_prompt: saved.system_prompt.map(SystemPrompt::Text),
11585                system_prompt_override: false,
11586                model: saved.metadata.model,
11587                workspace: saved.metadata.workspace,
11588                mode,
11589            })
11590            .await?;
11591        loaded_session_id = Some(saved_id.clone());
11592        if output_format == ExecOutputFormat::Text && !json_output {
11593            eprintln!("{}", exec_resumed_session_line(&saved_id));
11594        }
11595    }
11596
11597    engine_handle
11598        .send(Op::SendMessage {
11599            content: prompt.to_string(),
11600            mode,
11601            route: Box::new(validated_route.into_resolved()),
11602            compaction: Box::new(compaction.clone()),
11603            goal_objective: None,
11604            goal_token_budget: None,
11605            goal_status: crate::tools::goal::GoalStatus::Active,
11606            allowed_tools: allowed_tools.clone(),
11607            dynamic_tools: Vec::new(),
11608            hook_executor: None,
11609            reasoning_effort: effective_reasoning_effort,
11610            reasoning_effort_auto,
11611            auto_model,
11612            allow_shell: auto_approve || execution_config.allow_shell(),
11613            trust_mode,
11614            auto_approve,
11615            translation_enabled: false,
11616            approval_mode: if auto_approve {
11617                crate::tui::approval::ApprovalMode::Bypass
11618            } else {
11619                execution_config
11620                    .approval_policy
11621                    .as_deref()
11622                    .and_then(crate::tui::approval::ApprovalMode::from_config_value)
11623                    .unwrap_or_default()
11624            },
11625            verbosity: execution_config.verbosity.clone(),
11626            provenance: crate::core::ops::UserInputProvenance::ExternalUser,
11627        })
11628        .await?;
11629
11630    let mut summary = ExecSummary {
11631        mode: "agent".to_string(),
11632        provider: effective_provider_name.clone(),
11633        model: effective_model.clone(),
11634        prompt: prompt.to_string(),
11635        ..ExecSummary::default()
11636    };
11637    let can_elevate_sandbox =
11638        exec_sandbox_elevation_authorized(allow_sandbox_elevation, explicit_sandbox);
11639    let mut sandbox_denied = false;
11640    let mut approval_required = false;
11641    let mut tool_error_seen = false;
11642    let mut last_error_category = None;
11643    let mut reported_sandbox_contract = false;
11644
11645    let should_persist_session = resuming_session || output_format == ExecOutputFormat::StreamJson;
11646    let mut latest_session_id = loaded_session_id;
11647    let mut latest_messages: Vec<Message> = Vec::new();
11648    let mut latest_system_prompt: Option<SystemPrompt> = None;
11649    let mut latest_model = effective_model;
11650    let mut latest_workspace = workspace.clone();
11651    let mut tool_starts: HashMap<String, (Instant, String)> = HashMap::new();
11652    let mut turn_usage_seq: u32 = 0;
11653
11654    let mut stdout = io::stdout();
11655    let mut ends_with_newline = false;
11656    loop {
11657        let event = {
11658            let mut rx = engine_handle.rx_event.write().await;
11659            rx.recv().await
11660        };
11661
11662        let Some(event) = event else {
11663            break;
11664        };
11665
11666        match event {
11667            Event::MessageDelta { content, .. } => {
11668                summary.output.push_str(&content);
11669                if output_format == ExecOutputFormat::StreamJson {
11670                    emit_exec_stream_event(&ExecStreamEvent::Content { content })?;
11671                } else if !json_output {
11672                    print!("{content}");
11673                    stdout.flush()?;
11674                }
11675                ends_with_newline = summary.output.ends_with('\n');
11676            }
11677            Event::MessageComplete { .. }
11678                if output_format == ExecOutputFormat::Text
11679                    && !json_output
11680                    && !ends_with_newline =>
11681            {
11682                println!();
11683            }
11684            Event::ThinkingDelta { .. } => {
11685                // Exec stream-json intentionally omits reasoning deltas; the
11686                // TUI transcript retains its existing Activity Detail surface.
11687            }
11688            Event::ToolCallStarted { id, name, input } => {
11689                let started_at = chrono::Utc::now().to_rfc3339();
11690                tool_starts.insert(id.clone(), (Instant::now(), started_at.clone()));
11691                if output_format == ExecOutputFormat::StreamJson {
11692                    emit_exec_stream_event(&ExecStreamEvent::ToolUse {
11693                        name,
11694                        id,
11695                        input,
11696                        started_at,
11697                    })?;
11698                } else if !json_output {
11699                    let summary = summarize_tool_args(&input);
11700                    if let Some(summary) = summary {
11701                        eprintln!("tool: {name} ({summary})");
11702                    } else {
11703                        eprintln!("tool: {name}");
11704                    }
11705                }
11706            }
11707            Event::ToolCallComplete {
11708                id, name, result, ..
11709            } => {
11710                let (duration_ms, started_at) = tool_starts
11711                    .remove(&id)
11712                    .map(|(started, timestamp)| {
11713                        (
11714                            u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
11715                            timestamp,
11716                        )
11717                    })
11718                    .unwrap_or_else(|| (0, chrono::Utc::now().to_rfc3339()));
11719                let receipt_name = name.clone();
11720                match result {
11721                    Ok(output) => {
11722                        tool_error_seen |= !output.success;
11723                        summary.tools.push(ExecToolEntry {
11724                            name: name.clone(),
11725                            success: output.success,
11726                            output: output.content.clone(),
11727                        });
11728                        if output_format == ExecOutputFormat::StreamJson {
11729                            emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11730                                id,
11731                                name: receipt_name,
11732                                output: output.content,
11733                                status: if output.success {
11734                                    "success".to_string()
11735                                } else {
11736                                    "error".to_string()
11737                                },
11738                                started_at,
11739                                completed_at: chrono::Utc::now().to_rfc3339(),
11740                                duration_ms,
11741                                side_effect_status: output
11742                                    .metadata
11743                                    .as_ref()
11744                                    .and_then(|metadata| metadata.get("side_effect_status"))
11745                                    .and_then(serde_json::Value::as_str)
11746                                    .unwrap_or("unknown")
11747                                    .to_string(),
11748                                error_category: (!output.success).then(|| {
11749                                    output
11750                                        .metadata
11751                                        .as_ref()
11752                                        .and_then(|metadata| metadata.get("error_category"))
11753                                        .and_then(serde_json::Value::as_str)
11754                                        .unwrap_or("tool_reported_failure")
11755                                        .to_string()
11756                                }),
11757                                truncated: output
11758                                    .metadata
11759                                    .as_ref()
11760                                    .and_then(|metadata| metadata.get("truncated"))
11761                                    .and_then(serde_json::Value::as_bool),
11762                                artifact: tool_artifact_receipt(output.metadata.as_ref()),
11763                                result_metadata: output.metadata,
11764                            })?;
11765                        } else if !json_output {
11766                            if name == "exec_shell" && !output.content.trim().is_empty() {
11767                                eprintln!("tool {name} completed");
11768                                eprintln!(
11769                                    "--- stdout/stderr ---\n{}\n---------------------",
11770                                    output.content
11771                                );
11772                            } else {
11773                                eprintln!(
11774                                    "tool {name} completed: {}",
11775                                    summarize_tool_output(&output.content)
11776                                );
11777                            }
11778                        }
11779                    }
11780                    Err(err) => {
11781                        tool_error_seen = true;
11782                        let error_text = err.to_string();
11783                        summary.tools.push(ExecToolEntry {
11784                            name: name.clone(),
11785                            success: false,
11786                            output: error_text.clone(),
11787                        });
11788                        if output_format == ExecOutputFormat::StreamJson {
11789                            emit_exec_stream_event(&ExecStreamEvent::ToolResult {
11790                                id,
11791                                name: receipt_name,
11792                                output: error_text,
11793                                status: "error".to_string(),
11794                                started_at,
11795                                completed_at: chrono::Utc::now().to_rfc3339(),
11796                                duration_ms,
11797                                side_effect_status: "not_started_or_unknown".to_string(),
11798                                error_category: Some(tool_error_receipt_category(&err).to_string()),
11799                                truncated: None,
11800                                artifact: None,
11801                                result_metadata: None,
11802                            })?;
11803                        } else if !json_output {
11804                            eprintln!("tool {name} failed: {err}");
11805                        }
11806                    }
11807                }
11808            }
11809            Event::AgentSpawned { id, prompt, .. }
11810                if output_format == ExecOutputFormat::Text && !json_output =>
11811            {
11812                eprintln!("sub-agent {id} spawned: {}", summarize_tool_output(&prompt));
11813            }
11814            Event::AgentProgress { id, status, .. }
11815                if output_format == ExecOutputFormat::Text && !json_output =>
11816            {
11817                eprintln!("sub-agent {id}: {status}");
11818            }
11819            Event::AgentComplete { id, result }
11820                if output_format == ExecOutputFormat::Text && !json_output =>
11821            {
11822                eprintln!(
11823                    "sub-agent {id} completed: {}",
11824                    summarize_tool_output(&result)
11825                );
11826            }
11827            Event::AgentSpawned {
11828                id,
11829                parent_run_id,
11830                spawn_depth,
11831                model,
11832                route_source,
11833                ..
11834            } if output_format == ExecOutputFormat::StreamJson => {
11835                emit_exec_stream_event(&ExecStreamEvent::AgentSpawned {
11836                    id,
11837                    model,
11838                    spawn_depth,
11839                    parent_run_id,
11840                    route_source,
11841                })?;
11842            }
11843            Event::AgentSpawned { .. }
11844            | Event::AgentProgress { .. }
11845            | Event::AgentComplete { .. } => {}
11846            Event::WorkflowUi { run_id, event }
11847                if output_format == ExecOutputFormat::StreamJson =>
11848            {
11849                emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?;
11850            }
11851            Event::ApprovalRequired { id, .. } => {
11852                if auto_approve {
11853                    let _ = engine_handle.approve_tool_call(id).await;
11854                } else {
11855                    approval_required = true;
11856                    let _ = engine_handle.deny_tool_call(id).await;
11857                }
11858            }
11859            Event::ElevationRequired {
11860                tool_id,
11861                tool_name,
11862                denial_reason,
11863                ..
11864            } => {
11865                if can_elevate_sandbox {
11866                    let policy = crate::sandbox::SandboxPolicy::DangerFullAccess;
11867                    let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await;
11868                } else {
11869                    sandbox_denied = true;
11870                    approval_required = true;
11871                    summary.outcomes.push(ExecOutcome {
11872                        kind: "sandbox_denied".to_string(),
11873                        outcome: "approval_required".to_string(),
11874                        tool_name: tool_name.clone(),
11875                        reason: denial_reason.clone(),
11876                    });
11877                    if !reported_sandbox_contract {
11878                        eprintln!(
11879                            "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"
11880                        );
11881                        reported_sandbox_contract = true;
11882                    }
11883                    if output_format == ExecOutputFormat::StreamJson {
11884                        emit_exec_stream_event(&ExecStreamEvent::SandboxDenied {
11885                            tool_id: tool_id.clone(),
11886                            tool_name,
11887                            reason: denial_reason,
11888                            outcome: "approval_required".to_string(),
11889                        })?;
11890                    }
11891                    let _ = engine_handle.deny_tool_call(tool_id).await;
11892                }
11893            }
11894            Event::Error {
11895                envelope,
11896                recoverable: _,
11897            } => {
11898                // Only a non-recoverable envelope may force the run summary
11899                // into failure. Recoverable warnings (stream-stall notices,
11900                // transient retry noise) are still streamed for visibility,
11901                // but the terminal TurnComplete event carries the
11902                // authoritative turn outcome — letting a warning set
11903                // `summary.error` here would exit an otherwise-successful
11904                // `exec` run non-zero.
11905                if exec_error_event_is_fatal(&envelope) {
11906                    last_error_category = Some(envelope.category);
11907                    summary.error_category = Some(envelope.category.to_string());
11908                    summary.error = Some(envelope.message.clone());
11909                }
11910                if output_format == ExecOutputFormat::StreamJson {
11911                    emit_exec_stream_event(&ExecStreamEvent::Error {
11912                        error: envelope.message,
11913                    })?;
11914                } else if !json_output {
11915                    eprintln!("error: {}", envelope.message);
11916                }
11917            }
11918            Event::TurnUsage {
11919                usage, duration_ms, ..
11920            } => {
11921                if output_format == ExecOutputFormat::StreamJson {
11922                    turn_usage_seq = turn_usage_seq.saturating_add(1);
11923                    emit_exec_stream_event(&ExecStreamEvent::TurnUsage {
11924                        turn: turn_usage_seq,
11925                        input_tokens: usage.input_tokens,
11926                        output_tokens: usage.output_tokens,
11927                        reasoning_tokens: usage.reasoning_tokens,
11928                        prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
11929                        prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
11930                        prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
11931                        reasoning_replay_tokens: usage.reasoning_replay_tokens,
11932                        duration_ms,
11933                    })?;
11934                }
11935            }
11936            Event::TurnComplete {
11937                status,
11938                error,
11939                usage,
11940                tool_catalog,
11941                ..
11942            } => {
11943                let (terminal_status, terminal_error) = (status, error);
11944                #[cfg(unix)]
11945                let (mut terminal_status, mut terminal_error) = (terminal_status, terminal_error);
11946                if matches!(
11947                    terminal_status,
11948                    crate::core::events::TurnOutcomeStatus::Completed
11949                ) && terminal_error.is_none()
11950                {
11951                    #[cfg(unix)]
11952                    match exec_shell_manager.lock() {
11953                        Ok(mut manager) => match manager.commit_persistent_services() {
11954                            Ok(receipts) => {
11955                                for receipt in &receipts {
11956                                    if output_format == ExecOutputFormat::StreamJson {
11957                                        emit_exec_stream_event(
11958                                            &ExecStreamEvent::ServiceReleased {
11959                                                task_id: receipt.task_id.clone(),
11960                                                pid: receipt.pid,
11961                                                process_group_id: receipt.process_group_id,
11962                                                ownership: receipt.ownership.clone(),
11963                                            },
11964                                        )?;
11965                                    } else if !json_output {
11966                                        eprintln!(
11967                                            "persistent service released: {} pid={} pgid={} ownership={}",
11968                                            receipt.task_id,
11969                                            receipt.pid,
11970                                            receipt.process_group_id,
11971                                            receipt.ownership
11972                                        );
11973                                    }
11974                                }
11975                                summary.released_services.extend(receipts);
11976                            }
11977                            Err(error) => {
11978                                manager.abort_persistent_services();
11979                                terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11980                                terminal_error = Some(format!(
11981                                    "Persistent service ownership transfer failed: {error}"
11982                                ));
11983                            }
11984                        },
11985                        Err(_) => {
11986                            terminal_status = crate::core::events::TurnOutcomeStatus::Failed;
11987                            terminal_error = Some(
11988                                "Persistent service ownership transfer failed: shell manager lock poisoned"
11989                                    .to_string(),
11990                            );
11991                        }
11992                    }
11993                } else if let Ok(mut manager) = exec_shell_manager.lock() {
11994                    manager.abort_persistent_services();
11995                }
11996                summary.status = Some(format!("{terminal_status:?}").to_lowercase());
11997                if terminal_error.is_some() {
11998                    summary.error = terminal_error;
11999                }
12000                if sandbox_denied
12001                    && summary.error.is_none()
12002                    && matches!(
12003                        terminal_status,
12004                        crate::core::events::TurnOutcomeStatus::Failed
12005                    )
12006                {
12007                    summary.error = Some(
12008                        "exec turn failed after sandbox denial; explicit sandbox elevation was not authorized"
12009                            .to_string(),
12010                    );
12011                }
12012                if last_error_category.is_none() {
12013                    last_error_category = summary
12014                        .error
12015                        .as_deref()
12016                        .map(crate::error_taxonomy::classify_error_message);
12017                    summary.error_category =
12018                        last_error_category.map(|category| category.to_string());
12019                }
12020                let termination_reason = crate::core::termination::classify_turn_termination(
12021                    terminal_status,
12022                    last_error_category,
12023                    tool_error_seen,
12024                    approval_required,
12025                );
12026                summary.termination_reason = Some(termination_reason.as_str().to_string());
12027                // State the exit class here rather than inferring it later
12028                // from the process exit code: `Canceled` exits 130, the same
12029                // value the SIGINT path uses, so a code-based derivation would
12030                // report every Esc-cancelled turn as a signal. A no-op unless
12031                // this process was armed.
12032                if !termination_reason.is_success() {
12033                    codewhale_telemetry::set_exit_class(codewhale_telemetry::ExitClass::Error);
12034                }
12035                let saved_session_id = if should_persist_session && !latest_messages.is_empty() {
12036                    match persist_exec_session(
12037                        &latest_messages,
12038                        &latest_model,
12039                        PersistedProviderRoute {
12040                            kind: effective_provider.as_str(),
12041                            id: effective_provider_id.as_deref(),
12042                        },
12043                        &latest_workspace,
12044                        &latest_system_prompt,
12045                        latest_session_id.as_deref(),
12046                        u64::from(usage.input_tokens) + u64::from(usage.output_tokens),
12047                    ) {
12048                        Ok(id) => {
12049                            if output_format == ExecOutputFormat::Text && !json_output {
12050                                eprintln!("{}", exec_saved_session_line(&id));
12051                            }
12052                            Some(id)
12053                        }
12054                        Err(err) => {
12055                            if output_format == ExecOutputFormat::Text && !json_output {
12056                                eprintln!("warning: failed to save exec session: {err}");
12057                            }
12058                            latest_session_id.clone()
12059                        }
12060                    }
12061                } else {
12062                    latest_session_id.clone()
12063                };
12064                if output_format == ExecOutputFormat::StreamJson {
12065                    if let Some(id) = saved_session_id.as_ref() {
12066                        emit_exec_stream_event(&ExecStreamEvent::SessionCapture {
12067                            content: exec_stream_session_ref(id),
12068                        })?;
12069                    }
12070                    // Resolved output ceiling and its provenance, surfaced so a
12071                    // wrong ceiling is visible in the receipt rather than
12072                    // requiring packet capture.
12073                    let codewhale_max_output_tokens =
12074                        crate::route_budget::effective_max_output_tokens_for_route(
12075                            effective_provider,
12076                            &latest_model,
12077                            active_route_limits,
12078                        );
12079                    let codewhale_max_output_tokens_source =
12080                        crate::route_budget::output_ceiling_source(
12081                            effective_provider,
12082                            &latest_model,
12083                        )
12084                        .as_str();
12085                    emit_exec_stream_event(&ExecStreamEvent::Metadata {
12086                        meta: Box::new(ExecStreamMeta {
12087                            receipt_kind: "terminal",
12088                            provider: effective_provider_kind.clone(),
12089                            provider_id: effective_stream_provider_id.clone(),
12090                            model: latest_model.clone(),
12091                            route_source: route_source.clone(),
12092                            input_tokens: Some(usage.input_tokens),
12093                            output_tokens: Some(usage.output_tokens),
12094                            prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens,
12095                            prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens,
12096                            prompt_cache_write_tokens: usage.prompt_cache_write_tokens,
12097                            reasoning_tokens: usage.reasoning_tokens,
12098                            codewhale_max_output_tokens: Some(codewhale_max_output_tokens),
12099                            codewhale_max_output_tokens_source: Some(
12100                                codewhale_max_output_tokens_source,
12101                            ),
12102                            duration_ms: u64::try_from(exec_started.elapsed().as_millis())
12103                                .unwrap_or(u64::MAX),
12104                            retry_count: None,
12105                            approval_posture: approval_posture.clone(),
12106                            sandbox_posture: sandbox_posture.clone(),
12107                            binary_sha256: binary_sha256.clone(),
12108                            config_sha256: None,
12109                            prompt_sha256: prompt_sha256.clone(),
12110                            tool_catalog_sha256: tool_catalog.as_ref().and_then(|catalog| {
12111                                serde_json::to_vec(catalog).ok().map(|bytes| {
12112                                    format!("sha256:{}", crate::hashing::sha256_hex(&bytes))
12113                                })
12114                            }),
12115                            input_analysis: exec_stream_input_analysis(
12116                                &latest_messages,
12117                                latest_system_prompt.as_ref(),
12118                            ),
12119                            visible_final_answer_chars: summary.output.chars().count(),
12120                            resume_command: saved_session_id
12121                                .as_deref()
12122                                .map(exec_stream_resume_hint)
12123                                .unwrap_or_default(),
12124                            session_id: saved_session_id
12125                                .as_deref()
12126                                .map(exec_stream_session_ref)
12127                                .unwrap_or_default(),
12128                            workspace: latest_workspace.display().to_string(),
12129                            message_count: latest_messages.len(),
12130                            status: summary.status.clone(),
12131                            termination_reason: summary.termination_reason.clone(),
12132                            error_category: summary.error_category.clone(),
12133                            error: summary.error.clone(),
12134                        }),
12135                    })?;
12136                    emit_exec_stream_event(&ExecStreamEvent::Done)?;
12137                }
12138                let _ = engine_handle.send(Op::Shutdown).await;
12139                break;
12140            }
12141            Event::SessionUpdated {
12142                session_id,
12143                messages,
12144                system_prompt,
12145                model,
12146                workspace,
12147            } => {
12148                latest_session_id = Some(session_id);
12149                latest_messages = messages;
12150                latest_system_prompt = system_prompt;
12151                latest_model = model;
12152                latest_workspace = workspace;
12153            }
12154            // #3027: surface the engine's max-steps notice in text mode so a
12155            // --max-turns run that stops early says why instead of going quiet.
12156            Event::Status { message }
12157                if output_format == ExecOutputFormat::Text
12158                    && !json_output
12159                    && message.contains("Maximum model steps") =>
12160            {
12161                eprintln!("{message}");
12162            }
12163            _ => {}
12164        }
12165    }
12166
12167    if summary.status.is_none() {
12168        if let Ok(mut manager) = exec_shell_manager.lock() {
12169            manager.abort_persistent_services();
12170        }
12171        let error = summary.error.clone().unwrap_or_else(|| {
12172            "Engine event channel closed before a terminal turn receipt".to_string()
12173        });
12174        let category = last_error_category
12175            .unwrap_or_else(|| crate::error_taxonomy::classify_error_message(&error));
12176        let termination_reason = crate::core::termination::classify_turn_termination(
12177            crate::core::events::TurnOutcomeStatus::Failed,
12178            Some(category),
12179            tool_error_seen,
12180            approval_required,
12181        );
12182        summary.status = Some("failed".to_string());
12183        summary.error_category = Some(category.to_string());
12184        summary.termination_reason = Some(termination_reason.as_str().to_string());
12185        summary.error = Some(error.clone());
12186        if output_format == ExecOutputFormat::StreamJson {
12187            emit_exec_stream_event(&ExecStreamEvent::Error { error })?;
12188        }
12189    }
12190
12191    if json_output {
12192        println!("{}", serde_json::to_string_pretty(&summary)?);
12193    }
12194
12195    if let Some(error) = summary.error.as_ref()
12196        && !error.trim().is_empty()
12197    {
12198        // Distinguish retryable infrastructure failures (provider/transport,
12199        // after all in-session retries are exhausted) from genuine task
12200        // failures so supervisors and bench harnesses can tell them apart at
12201        // the process level without parsing the stream. Genuine failures
12202        // keep the historical `bail!` → exit 1 path.
12203        let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
12204        if exit_code != 1 {
12205            eprintln!("Error: exec turn failed: {error}");
12206            let _ = io::stdout().flush();
12207            std::process::exit(exit_code);
12208        }
12209        bail!("exec turn failed: {error}");
12210    }
12211
12212    if matches!(
12213        summary.status.as_deref(),
12214        Some("failed" | "canceled" | "interrupted")
12215    ) {
12216        let status = summary.status.as_deref().unwrap_or("unknown");
12217        bail!("exec turn ended with status {status}");
12218    }
12219
12220    Ok(())
12221}
12222
12223#[cfg(test)]
12224mod serve_bind_host_tests {
12225    use super::*;
12226
12227    #[test]
12228    fn http_defaults_to_loopback() {
12229        assert_eq!(
12230            resolve_serve_bind_host(false, None),
12231            ServeBindHost {
12232                host: "127.0.0.1".to_string(),
12233                mobile_rebound_to_lan: false,
12234            }
12235        );
12236    }
12237
12238    #[test]
12239    fn mobile_default_rebinds_to_lan_with_warning_flag() {
12240        assert_eq!(
12241            resolve_serve_bind_host(true, None),
12242            ServeBindHost {
12243                host: "0.0.0.0".to_string(),
12244                mobile_rebound_to_lan: true,
12245            }
12246        );
12247    }
12248
12249    #[test]
12250    fn mobile_respects_explicit_loopback_host() {
12251        assert_eq!(
12252            resolve_serve_bind_host(true, Some("127.0.0.1".to_string())),
12253            ServeBindHost {
12254                host: "127.0.0.1".to_string(),
12255                mobile_rebound_to_lan: false,
12256            }
12257        );
12258    }
12259
12260    #[test]
12261    fn http_and_mobile_are_mutually_exclusive() {
12262        let err = validate_serve_mode_selection(false, true, true, false, false).unwrap_err();
12263        assert!(
12264            err.to_string()
12265                .contains("--http and --mobile are mutually exclusive")
12266        );
12267    }
12268
12269    #[test]
12270    fn web_is_a_distinct_loopback_runtime_mode() {
12271        assert!(validate_serve_mode_selection(false, false, false, true, false).unwrap());
12272        let err = validate_serve_mode_selection(false, true, false, true, false).unwrap_err();
12273        assert!(err.to_string().contains("--web is mutually exclusive"));
12274        assert_eq!(
12275            resolve_serve_bind_host(false, None),
12276            ServeBindHost {
12277                host: "127.0.0.1".to_string(),
12278                mobile_rebound_to_lan: false,
12279            }
12280        );
12281    }
12282}
12283
12284#[cfg(test)]
12285#[path = "tests/exec_exit_semantics.rs"]
12286mod exec_exit_semantics_tests;
12287#[cfg(test)]
12288mod doctor_legacy_state_tests {
12289    use super::*;
12290    use std::env;
12291    use std::ffi::OsString;
12292    use std::fs;
12293    use tempfile::TempDir;
12294
12295    struct EnvVarRestore {
12296        key: &'static str,
12297        previous: Option<OsString>,
12298    }
12299
12300    impl EnvVarRestore {
12301        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
12302            let previous = env::var_os(key);
12303            unsafe {
12304                env::set_var(key, value);
12305            }
12306            Self { key, previous }
12307        }
12308    }
12309
12310    impl Drop for EnvVarRestore {
12311        fn drop(&mut self) {
12312            unsafe {
12313                match &self.previous {
12314                    Some(value) => env::set_var(self.key, value),
12315                    None => env::remove_var(self.key),
12316                }
12317            }
12318        }
12319    }
12320
12321    fn roots(tmp: &TempDir) -> (PathBuf, PathBuf) {
12322        (tmp.path().join(".codewhale"), tmp.path().join(".deepseek"))
12323    }
12324
12325    fn entry<'a>(report: &'a [DoctorLegacyStateEntry], name: &str) -> &'a DoctorLegacyStateEntry {
12326        report
12327            .iter()
12328            .find(|entry| entry.name == name)
12329            .expect("legacy state entry should exist")
12330    }
12331
12332    #[test]
12333    fn doctor_legacy_state_report_marks_unmigrated_legacy_entries() {
12334        let tmp = TempDir::new().expect("tempdir");
12335        let (primary_root, legacy_root) = roots(&tmp);
12336        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12337        fs::create_dir_all(legacy_root.join("tasks")).expect("legacy tasks");
12338        fs::create_dir_all(&primary_root).expect("primary root");
12339        fs::write(legacy_root.join("config.toml"), "api_key = 'old'").expect("legacy config");
12340
12341        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12342        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12343
12344        assert_eq!(
12345            entry(&report, "sessions").status,
12346            DoctorLegacyStateStatus::LegacyOnly
12347        );
12348        assert_eq!(
12349            entry(&report, "config.toml").status,
12350            DoctorLegacyStateStatus::LegacyOnly
12351        );
12352        assert_eq!(
12353            entry(&report, "skills").status,
12354            DoctorLegacyStateStatus::Absent
12355        );
12356
12357        let json =
12358            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12359        assert_eq!(json["needs_attention"], true);
12360        assert_eq!(json["legacy_only_count"], 3);
12361        assert_eq!(json["dual_present_count"], 0);
12362    }
12363
12364    #[test]
12365    fn doctor_legacy_state_report_marks_dual_present_entries() {
12366        let tmp = TempDir::new().expect("tempdir");
12367        let (primary_root, legacy_root) = roots(&tmp);
12368        fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12369        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12370        fs::write(primary_root.join("mcp.json"), "{}").expect("primary mcp");
12371        fs::write(legacy_root.join("mcp.json"), "{}").expect("legacy mcp");
12372
12373        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12374        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12375
12376        assert_eq!(
12377            entry(&report, "sessions").status,
12378            DoctorLegacyStateStatus::Both
12379        );
12380        assert_eq!(
12381            entry(&report, "mcp.json").status,
12382            DoctorLegacyStateStatus::Both
12383        );
12384
12385        let json =
12386            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12387        assert_eq!(json["needs_attention"], true);
12388        assert_eq!(json["legacy_only_count"], 0);
12389        assert_eq!(json["dual_present_count"], 2);
12390    }
12391
12392    #[test]
12393    fn doctor_legacy_state_report_is_clear_when_only_primary_exists() {
12394        let tmp = TempDir::new().expect("tempdir");
12395        let (primary_root, legacy_root) = roots(&tmp);
12396        fs::create_dir_all(primary_root.join("sessions")).expect("primary sessions");
12397        fs::write(primary_root.join("settings.toml"), "default_mode = 'ask'")
12398            .expect("primary settings");
12399
12400        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12401        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12402
12403        assert_eq!(
12404            entry(&report, "sessions").status,
12405            DoctorLegacyStateStatus::PrimaryOnly
12406        );
12407        assert!(!report.iter().any(legacy_state_needs_attention));
12408
12409        let json =
12410            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12411        assert_eq!(json["needs_attention"], false);
12412        assert_eq!(json["legacy_only_count"], 0);
12413        assert_eq!(json["dual_present_count"], 0);
12414    }
12415
12416    #[test]
12417    fn doctor_legacy_state_report_is_clear_when_neither_root_exists() {
12418        let tmp = TempDir::new().expect("tempdir");
12419        let (primary_root, legacy_root) = roots(&tmp);
12420
12421        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12422        let session_recovery = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12423
12424        assert!(
12425            report
12426                .iter()
12427                .all(|entry| entry.status == DoctorLegacyStateStatus::Absent)
12428        );
12429        assert!(!report.iter().any(legacy_state_needs_attention));
12430
12431        let json =
12432            doctor_legacy_state_json(&primary_root, &legacy_root, &report, &session_recovery);
12433        assert_eq!(json["needs_attention"], false);
12434        assert_eq!(json["legacy_only_count"], 0);
12435        assert_eq!(json["dual_present_count"], 0);
12436    }
12437
12438    #[test]
12439    fn doctor_reports_incomplete_session_migration_without_mutating_files() {
12440        let tmp = TempDir::new().expect("tempdir");
12441        let (primary_root, legacy_root) = roots(&tmp);
12442        let primary_sessions = primary_root.join("sessions");
12443        let legacy_sessions = legacy_root.join("sessions");
12444        fs::create_dir_all(&primary_sessions).expect("primary sessions");
12445        fs::create_dir_all(legacy_sessions.join("checkpoints")).expect("legacy checkpoints");
12446        fs::write(primary_sessions.join("already-there.json"), b"primary")
12447            .expect("primary session");
12448        fs::write(legacy_sessions.join("already-there.json"), b"legacy")
12449            .expect("legacy matching session");
12450        fs::write(
12451            legacy_sessions.join("recover-me.json"),
12452            b"not parsed by doctor",
12453        )
12454        .expect("legacy recoverable session");
12455        fs::write(
12456            legacy_sessions.join("checkpoints").join("latest.json"),
12457            b"checkpoint not inspected",
12458        )
12459        .expect("legacy checkpoint");
12460
12461        let legacy_before = fs::read(legacy_sessions.join("recover-me.json"))
12462            .expect("read legacy fixture before diagnostic");
12463        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12464
12465        assert_eq!(
12466            report.status,
12467            DoctorSessionRecoveryStatus::MigrationIncomplete
12468        );
12469        assert_eq!(report.legacy_session_file_count, 2);
12470        assert_eq!(report.already_present_file_count, 1);
12471        assert_eq!(report.recoverable_file_count, 1);
12472        assert_eq!(report.recoverable.len(), 1);
12473        assert_eq!(report.recoverable[0].name, PathBuf::from("recover-me.json"));
12474        assert!(
12475            !primary_sessions.join("recover-me.json").exists(),
12476            "doctor must not copy a recoverable session"
12477        );
12478        assert_eq!(
12479            fs::read(legacy_sessions.join("recover-me.json"))
12480                .expect("legacy file remains after diagnostic"),
12481            legacy_before,
12482            "doctor must not rewrite or delete the legacy source"
12483        );
12484
12485        let json = doctor_session_recovery_json(&report);
12486        assert_eq!(json["needs_attention"], true);
12487        assert_eq!(json["read_only"], true);
12488        assert_eq!(json["chat_contents_read"], false);
12489        assert_eq!(json["checkpoint_internals_scanned"], false);
12490        assert_eq!(json["recoverable_file_count"], 1);
12491        assert_eq!(json["recovery_command"], "codewhale sessions");
12492        assert_eq!(json["recoverable_files"][0]["name"], "recover-me.json");
12493        let serialized = json.to_string();
12494        assert!(
12495            !serialized.contains("not parsed by doctor"),
12496            "the report must not expose session contents"
12497        );
12498        assert!(
12499            !serialized.contains("checkpoint not inspected"),
12500            "the report must not expose checkpoint contents"
12501        );
12502    }
12503
12504    #[test]
12505    fn doctor_treats_preserved_legacy_sessions_as_complete_by_filename() {
12506        let tmp = TempDir::new().expect("tempdir");
12507        let (primary_root, legacy_root) = roots(&tmp);
12508        let primary_sessions = primary_root.join("sessions");
12509        let legacy_sessions = legacy_root.join("sessions");
12510        fs::create_dir_all(&primary_sessions).expect("primary sessions");
12511        fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12512        fs::write(primary_sessions.join("same-name.json"), b"primary").expect("primary session");
12513        fs::write(legacy_sessions.join("same-name.json"), b"legacy").expect("legacy session");
12514
12515        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12516
12517        assert_eq!(
12518            report.status,
12519            DoctorSessionRecoveryStatus::MigrationComplete
12520        );
12521        assert!(!report.needs_attention());
12522        assert_eq!(report.recoverable_file_count, 0);
12523        assert!(report.recoverable.is_empty());
12524        assert_eq!(report.already_present_file_count, 1);
12525        let json = doctor_session_recovery_json(&report);
12526        assert_eq!(json["session_descriptors_compared"], false);
12527        assert_eq!(
12528            json["counterpart_check"],
12529            "top_level_filename_and_regular_file_only"
12530        );
12531    }
12532
12533    #[test]
12534    fn doctor_bounds_recoverable_session_filename_samples() {
12535        let tmp = TempDir::new().expect("tempdir");
12536        let (primary_root, legacy_root) = roots(&tmp);
12537        let legacy_sessions = legacy_root.join("sessions");
12538        fs::create_dir_all(&legacy_sessions).expect("legacy sessions");
12539        for index in 0..DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT {
12540            fs::write(
12541                legacy_sessions.join(format!("late-{index:03}.json")),
12542                b"fixture",
12543            )
12544            .expect("legacy session fixture");
12545        }
12546        fs::write(legacy_sessions.join("early-000.json"), b"fixture")
12547            .expect("earliest legacy session fixture");
12548        fs::write(legacy_sessions.join("early-001.json"), b"fixture")
12549            .expect("second earliest legacy session fixture");
12550        let total = DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT + 2;
12551
12552        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12553        let json = doctor_session_recovery_json(&report);
12554
12555        assert_eq!(report.recoverable_file_count, total);
12556        assert_eq!(
12557            report.recoverable.len(),
12558            DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT
12559        );
12560        assert_eq!(
12561            json["recoverable_files"].as_array().map(Vec::len),
12562            Some(DOCTOR_SESSION_RECOVERY_JSON_SAMPLE_LIMIT)
12563        );
12564        assert_eq!(
12565            report.recoverable.first().map(|entry| entry.name.as_path()),
12566            Some(Path::new("early-000.json")),
12567            "the bounded sample must not depend on read_dir order"
12568        );
12569        assert_eq!(
12570            report.recoverable.last().map(|entry| entry.name.as_path()),
12571            Some(Path::new("late-097.json")),
12572            "the bounded sample must retain the lexical prefix"
12573        );
12574        assert_eq!(json["recoverable_files_truncated"], true);
12575    }
12576
12577    #[test]
12578    fn doctor_session_recovery_fails_closed_on_an_unreadable_path_shape() {
12579        let tmp = TempDir::new().expect("tempdir");
12580        let (primary_root, legacy_root) = roots(&tmp);
12581        fs::create_dir_all(&legacy_root).expect("legacy root");
12582        fs::write(legacy_root.join("sessions"), b"not a directory")
12583            .expect("invalid legacy sessions path");
12584
12585        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12586
12587        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12588        assert!(report.needs_attention());
12589        assert!(report.error.as_deref().is_some_and(|error| {
12590            error.contains("legacy sessions root") && error.contains("not a directory")
12591        }));
12592    }
12593
12594    #[test]
12595    fn doctor_session_recovery_rejects_a_non_directory_legacy_state_root() {
12596        let tmp = TempDir::new().expect("tempdir");
12597        let (primary_root, legacy_root) = roots(&tmp);
12598        fs::write(&legacy_root, b"not a state directory").expect("invalid legacy root");
12599
12600        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12601
12602        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12603        assert!(report.error.as_deref().is_some_and(|error| {
12604            error.contains("legacy state root") && error.contains("not a directory")
12605        }));
12606    }
12607
12608    #[test]
12609    fn doctor_session_recovery_rejects_a_non_directory_primary_state_root() {
12610        let tmp = TempDir::new().expect("tempdir");
12611        let (primary_root, legacy_root) = roots(&tmp);
12612        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12613        fs::write(&primary_root, b"not a state directory").expect("invalid primary root");
12614
12615        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12616
12617        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12618        assert!(report.error.as_deref().is_some_and(|error| {
12619            error.contains("primary state root") && error.contains("not a directory")
12620        }));
12621    }
12622
12623    #[test]
12624    fn doctor_session_recovery_rejects_a_non_directory_primary_sessions_root() {
12625        let tmp = TempDir::new().expect("tempdir");
12626        let (primary_root, legacy_root) = roots(&tmp);
12627        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12628        fs::create_dir_all(&primary_root).expect("primary root");
12629        fs::write(primary_root.join("sessions"), b"not a sessions directory")
12630            .expect("invalid primary sessions path");
12631
12632        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12633
12634        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12635        assert!(report.error.as_deref().is_some_and(|error| {
12636            error.contains("primary sessions root") && error.contains("not a directory")
12637        }));
12638    }
12639
12640    #[cfg(unix)]
12641    #[test]
12642    fn doctor_session_recovery_rejects_a_symlinked_legacy_sessions_root() {
12643        use std::os::unix::fs::symlink;
12644
12645        let tmp = TempDir::new().expect("tempdir");
12646        let (primary_root, legacy_root) = roots(&tmp);
12647        let external_sessions = tmp.path().join("external-sessions");
12648        fs::create_dir_all(&external_sessions).expect("external sessions");
12649        fs::write(
12650            external_sessions.join("must-not-be-enumerated.json"),
12651            b"session contents must stay unread",
12652        )
12653        .expect("external session fixture");
12654        fs::create_dir_all(&legacy_root).expect("legacy root");
12655        symlink(&external_sessions, legacy_root.join("sessions"))
12656            .expect("symlinked legacy sessions root");
12657
12658        let report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12659
12660        assert_eq!(report.status, DoctorSessionRecoveryStatus::ScanFailed);
12661        assert!(report.needs_attention());
12662        assert_eq!(report.legacy_session_file_count, 0);
12663        assert!(report.recoverable.is_empty());
12664        assert!(
12665            report
12666                .error
12667                .as_deref()
12668                .is_some_and(|error| error.contains("legacy sessions root")
12669                    && error.contains("path is a symlink"))
12670        );
12671    }
12672
12673    #[cfg(unix)]
12674    #[test]
12675    fn doctor_session_recovery_rejects_symlinked_primary_root_and_sessions_root() {
12676        use std::os::unix::fs::symlink;
12677
12678        let tmp = TempDir::new().expect("tempdir");
12679        let (primary_root, legacy_root) = roots(&tmp);
12680        let external_primary = tmp.path().join("external-primary");
12681        fs::create_dir_all(external_primary.join("sessions")).expect("external primary");
12682        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12683        symlink(&external_primary, &primary_root).expect("symlinked primary root");
12684
12685        let root_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12686        assert_eq!(root_report.status, DoctorSessionRecoveryStatus::ScanFailed);
12687        assert!(root_report.error.as_deref().is_some_and(|error| {
12688            error.contains("primary state root") && error.contains("path is a symlink")
12689        }));
12690
12691        fs::remove_file(&primary_root).expect("remove primary root symlink");
12692        fs::create_dir_all(&primary_root).expect("primary root");
12693        symlink(&external_primary, primary_root.join("sessions"))
12694            .expect("symlinked primary sessions root");
12695
12696        let sessions_report = doctor_session_recovery_report(&primary_root, &legacy_root, false);
12697        assert_eq!(
12698            sessions_report.status,
12699            DoctorSessionRecoveryStatus::ScanFailed
12700        );
12701        assert!(sessions_report.error.as_deref().is_some_and(|error| {
12702            error.contains("primary sessions root") && error.contains("path is a symlink")
12703        }));
12704    }
12705
12706    #[test]
12707    fn explicit_codewhale_home_skips_session_recovery_scan() {
12708        let tmp = TempDir::new().expect("tempdir");
12709        let (primary_root, legacy_root) = roots(&tmp);
12710        fs::create_dir_all(legacy_root.join("sessions")).expect("legacy sessions");
12711        fs::write(legacy_root.join("sessions").join("ambient.json"), b"legacy")
12712            .expect("legacy session");
12713
12714        let report = doctor_session_recovery_report(&primary_root, &legacy_root, true);
12715
12716        assert_eq!(report.status, DoctorSessionRecoveryStatus::Isolated);
12717        assert!(report.codewhale_home_is_explicit);
12718        assert_eq!(report.legacy_session_file_count, 0);
12719        assert_eq!(report.recoverable_file_count, 0);
12720        assert!(report.recoverable.is_empty());
12721        assert!(!report.needs_attention());
12722    }
12723
12724    #[test]
12725    fn doctor_state_roots_ignore_ambient_legacy_home_when_codewhale_home_is_explicit() {
12726        let _env_lock = crate::test_support::lock_test_env();
12727        let tmp = TempDir::new().expect("tempdir");
12728        let explicit_home = tmp.path().join("isolated-codewhale");
12729        let ambient_legacy = tmp.path().join(".deepseek");
12730        fs::create_dir_all(&ambient_legacy).expect("ambient legacy root");
12731        fs::write(
12732            ambient_legacy.join("config.toml"),
12733            "provider = 'deepseek'\n",
12734        )
12735        .expect("ambient legacy config");
12736        let _home = EnvVarRestore::set("HOME", tmp.path());
12737        let _codewhale_home = EnvVarRestore::set("CODEWHALE_HOME", &explicit_home);
12738
12739        let (primary_root, legacy_root) = doctor_state_roots();
12740        let report = doctor_legacy_state_report(&primary_root, &legacy_root);
12741        let session_recovery = doctor_session_recovery_report(
12742            &primary_root,
12743            &legacy_root,
12744            codewhale_config::codewhale_home_is_explicit(),
12745        );
12746
12747        assert_eq!(primary_root, explicit_home);
12748        assert_eq!(
12749            legacy_root,
12750            primary_root.join(codewhale_config::LEGACY_APP_DIR)
12751        );
12752        assert!(
12753            report
12754                .iter()
12755                .all(|entry| entry.status == DoctorLegacyStateStatus::Absent),
12756            "doctor must not report ambient legacy state when CODEWHALE_HOME is explicit"
12757        );
12758        assert!(!report.iter().any(legacy_state_needs_attention));
12759        assert_eq!(
12760            session_recovery.status,
12761            DoctorSessionRecoveryStatus::Isolated
12762        );
12763        assert!(session_recovery.recoverable.is_empty());
12764    }
12765}
12766
12767#[cfg(test)]
12768mod doctor_setup_state_tests {
12769    use super::*;
12770    use std::fs;
12771    use tempfile::TempDir;
12772
12773    fn prepare_env(tmp: &TempDir) -> (crate::test_support::EnvVarGuard, PathBuf) {
12774        let codewhale_home = tmp.path().join(".codewhale");
12775        fs::create_dir_all(&codewhale_home).expect("codewhale home");
12776        (
12777            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str()),
12778            codewhale_home,
12779        )
12780    }
12781
12782    fn provider_step(report: &serde_json::Value) -> &serde_json::Value {
12783        report["steps"]
12784            .as_array()
12785            .expect("steps array")
12786            .iter()
12787            .find(|step| step["step"] == "provider_model")
12788            .expect("provider/model step")
12789    }
12790
12791    #[test]
12792    fn doctor_setup_consistency_flags_missing_user_constitution() {
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 state = codewhale_config::SetupState {
12802            constitution_source: codewhale_config::ConstitutionSource::UserGlobal,
12803            ..Default::default()
12804        };
12805        state.save().expect("persist setup state");
12806
12807        let report = doctor_setup_report_json(&Config::default(), &workspace);
12808
12809        assert_eq!(report["source"], "persisted");
12810        assert_eq!(report["consistency"]["status"], "inconsistent");
12811        let issues = report["consistency"]["issues"].to_string();
12812        assert!(
12813            issues.contains("setup_state_points_at_missing_user_constitution"),
12814            "{issues}"
12815        );
12816    }
12817
12818    #[test]
12819    fn doctor_setup_consistency_flags_stale_temp_files() {
12820        let _guard = crate::test_support::lock_test_env();
12821        let tmp = TempDir::new().expect("tempdir");
12822        let (_home_guard, codewhale_home) = prepare_env(&tmp);
12823        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12824        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12825        let workspace = tmp.path().join("workspace");
12826        fs::create_dir_all(&workspace).expect("workspace");
12827        fs::write(codewhale_home.join(".tmpAbC123"), b"orphaned atomic write")
12828            .expect("stale temp file");
12829
12830        let report = doctor_setup_report_json(&Config::default(), &workspace);
12831
12832        assert_eq!(report["consistency"]["status"], "inconsistent");
12833        let issues = report["consistency"]["issues"].to_string();
12834        assert!(
12835            issues.contains("stale_setup_temp_files_in_codewhale_home"),
12836            "{issues}"
12837        );
12838    }
12839
12840    #[test]
12841    fn doctor_setup_consistency_reports_consistent_for_clean_home() {
12842        let _guard = crate::test_support::lock_test_env();
12843        let tmp = TempDir::new().expect("tempdir");
12844        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12845        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12846        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12847        let workspace = tmp.path().join("workspace");
12848        fs::create_dir_all(&workspace).expect("workspace");
12849
12850        let report = doctor_setup_report_json(&Config::default(), &workspace);
12851
12852        assert_eq!(report["consistency"]["status"], "consistent");
12853        assert_eq!(
12854            report["consistency"]["issues"]
12855                .as_array()
12856                .map(Vec::len)
12857                .unwrap_or_default(),
12858            0
12859        );
12860    }
12861
12862    #[test]
12863    fn doctor_setup_report_json_derives_state_without_sidecar() {
12864        let _guard = crate::test_support::lock_test_env();
12865        let tmp = TempDir::new().expect("tempdir");
12866        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12867        let _key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12868        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12869        let workspace = tmp.path().join("workspace");
12870        fs::create_dir_all(&workspace).expect("workspace");
12871
12872        let report = doctor_setup_report_json(&Config::default(), &workspace);
12873
12874        assert_eq!(report["source"], "derived");
12875        assert_eq!(report["inherited"], true);
12876        assert_eq!(report["next_actions"]["constitution"], "/constitution");
12877        assert_eq!(report["next_actions"]["setup_report"], "/setup report");
12878        assert_eq!(
12879            report["next_actions"]["provider_model"],
12880            "/setup provider, /provider setup <name>, or /model"
12881        );
12882        assert_eq!(report["next_actions"]["runtime_posture"], "/config");
12883        assert_eq!(
12884            report["next_actions"]["operate_fleet"],
12885            "/setup fleet (readiness), /fleet setup (explicit profile authoring)"
12886        );
12887        assert_eq!(report["next_actions"]["hotbar"], "/setup hotbar");
12888        assert_eq!(report["next_actions"]["tools_mcp"], "/setup tools");
12889        assert_eq!(report["next_actions"]["remote_runtime"], "/setup remote");
12890        assert_eq!(report["next_actions"]["persistence"], "/setup persistence");
12891        assert_eq!(
12892            report["checkpoint_version"],
12893            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
12894        );
12895        assert_eq!(report["update_ready"], false);
12896        assert_eq!(report["operate_ready"], false);
12897        assert_eq!(
12898            report["operate_fleet"]["concurrency"]["plan_limit_probed"],
12899            false
12900        );
12901        assert_eq!(
12902            report["operate_fleet"]["roster"]["readiness_rule"],
12903            "built-in starter roster or custom roster"
12904        );
12905        assert_eq!(report["provider_model"]["provider"]["id"], "deepseek");
12906        assert_eq!(report["provider_model"]["provider"]["display"], "DeepSeek");
12907        assert_eq!(
12908            report["provider_model"]["model"]["resolved"],
12909            crate::config::DEFAULT_TEXT_MODEL
12910        );
12911        assert_eq!(
12912            report["provider_model"]["auth"]["source"],
12913            "secret_store_unprobed"
12914        );
12915        assert_eq!(
12916            report["provider_model"]["auth"]["availability"],
12917            "not_probed"
12918        );
12919        assert_eq!(
12920            report["provider_model"]["auth"]["credential_url"],
12921            "https://platform.deepseek.com"
12922        );
12923        assert_eq!(
12924            report["provider_model"]["auth"]["credential_mode"],
12925            "api_key"
12926        );
12927        assert_eq!(
12928            report["provider_model"]["auth"]["env_vars"][0],
12929            "DEEPSEEK_API_KEY"
12930        );
12931        assert_eq!(report["provider_model"]["health"]["live_validation"], false);
12932        assert_eq!(report["constitution"]["source"], "bundled");
12933        assert_eq!(report["constitution"]["autonomy_preference"], "unspecified");
12934        assert_eq!(report["runtime_posture"]["source"], "unset");
12935        assert_eq!(report["runtime_posture"]["default_mode"]["value"], "agent");
12936        assert_eq!(
12937            report["runtime_posture"]["approval_policy"]["value"],
12938            "on-request"
12939        );
12940        assert_eq!(report["runtime_posture"]["allow_shell"]["value"], true);
12941        assert_eq!(
12942            report["runtime_posture"]["sandbox_mode"]["value"],
12943            "mode-derived"
12944        );
12945        assert_eq!(
12946            report["runtime_posture"]["network_default"]["value"],
12947            "prompt"
12948        );
12949        assert_eq!(provider_step(&report)["status"], "needs_action");
12950    }
12951
12952    #[test]
12953    fn doctor_setup_provider_model_json_covers_cn_codex_and_local_matrix() {
12954        let _guard = crate::test_support::lock_test_env();
12955        let tmp = TempDir::new().expect("tempdir");
12956        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
12957        let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
12958        let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
12959        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
12960        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
12961        let _codex_key = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
12962        let _codex_legacy_key = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
12963        let codex_auth_path = tmp.path().join("external-codex-auth.json");
12964        let codex_auth_raw = serde_json::json!({
12965            "tokens": {
12966                "access_token": crate::test_support::future_test_jwt("doctor"),
12967                "account_id": "acct-doctor-read-only",
12968                "refresh_token": "must-never-be-used",
12969                "unknown": {"preserve": true}
12970            }
12971        })
12972        .to_string();
12973        fs::write(&codex_auth_path, &codex_auth_raw).expect("Codex auth trap fixture");
12974        let _codex_auth =
12975            crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_auth_path);
12976        let workspace = tmp.path().join("workspace");
12977        fs::create_dir_all(&workspace).expect("workspace");
12978
12979        let cn_config = Config {
12980            provider: Some("deepseek-cn".to_string()),
12981            ..Config::default()
12982        };
12983        let cn_report = doctor_setup_report_json(&cn_config, &workspace);
12984        assert_eq!(cn_report["provider_model"]["provider"]["id"], "deepseek-cn");
12985        assert_eq!(
12986            cn_report["provider_model"]["provider"]["display"],
12987            "DeepSeek (legacy alias)"
12988        );
12989        assert_eq!(
12990            cn_report["provider_model"]["auth"]["env_vars"][0],
12991            "DEEPSEEK_API_KEY"
12992        );
12993        assert_eq!(
12994            cn_report["provider_model"]["auth"]["credential_url"],
12995            "https://platform.deepseek.com"
12996        );
12997        assert_eq!(cn_report["provider_model"]["auth"]["oauth_only"], false);
12998        assert_eq!(
12999            cn_report["provider_model"]["health"]["live_validation"],
13000            false
13001        );
13002
13003        let codex_config = Config {
13004            provider: Some("openai-codex".to_string()),
13005            ..Config::default()
13006        };
13007        crate::external_credentials::reset_side_effect_trap();
13008        let codex_report = doctor_setup_report_json(&codex_config, &workspace);
13009        assert_eq!(
13010            codex_report["provider_model"]["provider"]["id"],
13011            crate::config::ApiProvider::OpenaiCodex.as_str()
13012        );
13013        assert!(codex_report["provider_model"]["auth"]["credential_url"].is_null());
13014        assert_eq!(
13015            codex_report["provider_model"]["auth"]["credential_mode"],
13016            "oauth"
13017        );
13018        assert_eq!(codex_report["provider_model"]["auth"]["oauth_only"], true);
13019        assert_eq!(
13020            codex_report["provider_model"]["health"]["next_action"],
13021            "/setup provider or /provider setup <name>"
13022        );
13023        assert_eq!(
13024            crate::external_credentials::side_effect_trap_counts(),
13025            (0, 0),
13026            "doctor must not stat or read external credentials without consent"
13027        );
13028
13029        let mut consent = codewhale_config::ExternalCredentialConsentToml::read_only(
13030            codewhale_config::ProviderKind::OpenaiCodex,
13031            codewhale_config::ExternalCredentialSource::CodexCli,
13032            codex_auth_path.clone(),
13033        );
13034        let codex_read_only = Config {
13035            provider: Some("openai-codex".to_string()),
13036            providers: Some(crate::config::ProvidersConfig {
13037                openai_codex: crate::config::ProviderConfig {
13038                    auth_mode: Some("oauth".to_string()),
13039                    external_credentials: Some(consent.clone()),
13040                    ..Default::default()
13041                },
13042                ..Default::default()
13043            }),
13044            ..Config::default()
13045        };
13046        let changed_ambient_path = tmp.path().join("new-ambient-codex-auth.json");
13047        let _changed_codex_auth =
13048            crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &changed_ambient_path);
13049        crate::external_credentials::reset_side_effect_trap();
13050        let codex_read_only_report = doctor_setup_report_json(&codex_read_only, &workspace);
13051        assert_eq!(
13052            codex_read_only_report["provider_model"]["auth"]["present_or_local"],
13053            false
13054        );
13055        assert_eq!(
13056            codex_read_only_report["provider_model"]["auth"]["source"],
13057            "external_consent"
13058        );
13059        let status_json = doctor_external_credential_consent_json(&codex_read_only);
13060        let codex_status = status_json
13061            .as_array()
13062            .and_then(|rows| rows.first())
13063            .expect("Codex structural status");
13064        assert_eq!(codex_status["access"], "read_only");
13065        assert_eq!(codex_status["provider"], "openai-codex");
13066        assert_eq!(codex_status["source"], "codex_cli");
13067        assert_eq!(codex_status["route_state"], "active");
13068        assert_eq!(codex_status["ambient_path_changed"], true);
13069        assert!(
13070            codex_status["ambient_path_warning"]
13071                .as_str()
13072                .is_some_and(|warning| warning.contains("remains pinned"))
13073        );
13074        assert_eq!(
13075            codex_status["revoke_command"],
13076            "codewhale auth external-revoke --provider openai-codex"
13077        );
13078        let human = doctor_external_credential_consent_lines(&codex_read_only).join("\n");
13079        assert!(human.contains("path="), "{human}");
13080        assert!(human.contains("version=1"), "{human}");
13081        assert!(human.contains("no refresh, identity-provider or discovery requests"));
13082        assert!(human.contains("normal requests to the explicitly selected provider"));
13083        assert!(human.contains("consent remains pinned"), "{human}");
13084        assert!(
13085            human.contains(&codewhale_config::quote_os_path(&codex_auth_path)),
13086            "{human}"
13087        );
13088        assert!(!human.contains(&changed_ambient_path.display().to_string()));
13089        assert_eq!(
13090            crate::external_credentials::complete_side_effect_trap_counts(),
13091            (0, 0, 0, 0, 0),
13092            "doctor consent status is structural and must not inspect the file"
13093        );
13094        assert_eq!(
13095            fs::read_to_string(&codex_auth_path).expect("unchanged Codex auth fixture"),
13096            codex_auth_raw
13097        );
13098
13099        consent.access = codewhale_config::ExternalCredentialAccess::Managed;
13100        let codex_managed = Config {
13101            provider: Some("openai-codex".to_string()),
13102            providers: Some(crate::config::ProvidersConfig {
13103                openai_codex: crate::config::ProviderConfig {
13104                    auth_mode: Some("oauth".to_string()),
13105                    external_credentials: Some(consent),
13106                    ..Default::default()
13107                },
13108                ..Default::default()
13109            }),
13110            ..Config::default()
13111        };
13112        crate::external_credentials::reset_side_effect_trap();
13113        let codex_managed_report = doctor_setup_report_json(&codex_managed, &workspace);
13114        assert_eq!(
13115            codex_managed_report["provider_model"]["auth"]["present_or_local"],
13116            false
13117        );
13118        assert_eq!(
13119            crate::external_credentials::side_effect_trap_counts(),
13120            (0, 0),
13121            "unsupported managed mode must fail before external I/O"
13122        );
13123        assert_eq!(
13124            fs::read_to_string(&codex_auth_path).expect("unchanged managed auth fixture"),
13125            codex_auth_raw
13126        );
13127
13128        let local_config = Config {
13129            provider: Some("ollama".to_string()),
13130            ..Config::default()
13131        };
13132        let local_report = doctor_setup_report_json(&local_config, &workspace);
13133        assert_eq!(local_report["provider_model"]["provider"]["id"], "ollama");
13134        assert_eq!(
13135            local_report["provider_model"]["auth"]["present_or_local"],
13136            true
13137        );
13138        assert!(local_report["provider_model"]["auth"]["credential_url"].is_null());
13139        assert_eq!(
13140            local_report["provider_model"]["auth"]["credential_mode"],
13141            "local_optional"
13142        );
13143        assert_eq!(local_report["provider_model"]["auth"]["oauth_only"], false);
13144        assert_eq!(
13145            local_report["provider_model"]["health"]["next_action"],
13146            "/model"
13147        );
13148
13149        let kimi_config = Config {
13150            provider: Some("moonshot".to_string()),
13151            ..Config::default()
13152        };
13153        let kimi_report = doctor_setup_report_json(&kimi_config, &workspace);
13154        assert_eq!(
13155            kimi_report["provider_model"]["auth"]["credential_url"],
13156            "https://platform.kimi.ai"
13157        );
13158        assert_eq!(
13159            kimi_report["provider_model"]["auth"]["credential_docs_url"],
13160            "https://platform.kimi.ai"
13161        );
13162        assert_eq!(
13163            kimi_report["provider_model"]["auth"]["credential_mode"],
13164            "api_key"
13165        );
13166        assert!(
13167            kimi_report["provider_model"]["auth"]["credential_guidance"]
13168                .as_str()
13169                .is_some_and(|guidance| guidance.contains("OAuth is not available"))
13170        );
13171    }
13172
13173    #[test]
13174    fn doctor_setup_report_json_uses_persisted_state() {
13175        let _guard = crate::test_support::lock_test_env();
13176        let tmp = TempDir::new().expect("tempdir");
13177        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13178        let workspace = tmp.path().join("workspace");
13179        fs::create_dir_all(&workspace).expect("workspace");
13180        let mut state = codewhale_config::SetupState::default();
13181        state.set_step(
13182            codewhale_config::SetupStep::Language,
13183            codewhale_config::StepEntry::new(
13184                codewhale_config::StepStatus::Verified,
13185                true,
13186                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13187            ),
13188        );
13189        state.set_step(
13190            codewhale_config::SetupStep::ProviderModel,
13191            codewhale_config::StepEntry::new(
13192                codewhale_config::StepStatus::Verified,
13193                true,
13194                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13195            )
13196            .with_result("deepseek/deepseek-chat"),
13197        );
13198        state.set_step(
13199            codewhale_config::SetupStep::TrustSandbox,
13200            codewhale_config::StepEntry::new(
13201                codewhale_config::StepStatus::Verified,
13202                true,
13203                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13204            ),
13205        );
13206        state
13207            .complete_constitution_checkpoint(
13208                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13209                codewhale_config::ConstitutionChoice::Bundled,
13210            )
13211            .set_step(
13212                codewhale_config::SetupStep::Constitution,
13213                codewhale_config::StepEntry::new(
13214                    codewhale_config::StepStatus::Verified,
13215                    true,
13216                    crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13217                ),
13218            );
13219        state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13220        state.save().expect("persist setup state");
13221        codewhale_config::UserConstitution {
13222            autonomy_preference: codewhale_config::AutonomyPreference::Balanced,
13223            ..Default::default()
13224        }
13225        .save()
13226        .expect("persist user constitution");
13227        let config = Config {
13228            api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13229            approval_policy: Some("never".to_string()),
13230            allow_shell: Some(false),
13231            sandbox_mode: Some("read-only".to_string()),
13232            network: Some(crate::config::NetworkPolicyToml {
13233                default: "deny".to_string(),
13234                ..Default::default()
13235            }),
13236            ..Config::default()
13237        };
13238
13239        let report = doctor_setup_report_json(&config, &workspace);
13240
13241        assert_eq!(report["source"], "persisted");
13242        assert_eq!(report["first_run_ready"], true);
13243        assert_eq!(report["update_ready"], true);
13244        assert_eq!(report["operate_ready"], false);
13245        assert_eq!(report["constitution"]["choice"], "bundled");
13246        assert_eq!(
13247            report["constitution"]["checkpoint_completed_for"],
13248            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION
13249        );
13250        assert_eq!(report["constitution"]["autonomy_preference"], "balanced");
13251        assert_eq!(report["runtime_posture_source"], "confirmed");
13252        assert_eq!(report["runtime_posture"]["source"], "confirmed");
13253        assert_eq!(
13254            report["runtime_posture"]["approval_policy"]["value"],
13255            "never"
13256        );
13257        assert_eq!(
13258            report["runtime_posture"]["approval_policy"]["source"],
13259            "config"
13260        );
13261        assert_eq!(report["runtime_posture"]["allow_shell"]["value"], false);
13262        assert_eq!(report["runtime_posture"]["allow_shell"]["source"], "config");
13263        assert_eq!(
13264            report["runtime_posture"]["sandbox_mode"]["value"],
13265            "read-only"
13266        );
13267        assert_eq!(
13268            report["runtime_posture"]["sandbox_mode"]["source"],
13269            "config"
13270        );
13271        assert_eq!(
13272            report["runtime_posture"]["network_default"]["value"],
13273            "deny"
13274        );
13275        assert_eq!(
13276            report["runtime_posture"]["network_default"]["source"],
13277            "config"
13278        );
13279        assert_eq!(provider_step(&report)["result"], "deepseek/deepseek-chat");
13280
13281        let unprobed_config = Config {
13282            api_key: Some(crate::config::API_KEYRING_SENTINEL.to_string()),
13283            ..config.clone()
13284        };
13285        let unprobed_report = doctor_setup_report_json(&unprobed_config, &workspace);
13286        assert_eq!(unprobed_report["credential"]["ready"], false);
13287        assert_eq!(unprobed_report["credential"]["availability"], "not_probed");
13288        assert_eq!(unprobed_report["first_run_ready"], true);
13289        assert_eq!(unprobed_report["update_ready"], true);
13290    }
13291
13292    #[test]
13293    fn doctor_reports_settings_permission_posture_when_approval_policy_unset() {
13294        let _guard = crate::test_support::lock_test_env();
13295        let tmp = TempDir::new().expect("tempdir");
13296        let (_home_guard, codewhale_home) = prepare_env(&tmp);
13297        let workspace = tmp.path().join("workspace");
13298        fs::create_dir_all(&workspace).expect("workspace");
13299        fs::write(
13300            codewhale_home.join("settings.toml"),
13301            "permission_posture = \"full-access\"\n",
13302        )
13303        .expect("write settings.toml");
13304
13305        let config = Config::default();
13306        assert!(config.approval_policy.is_none());
13307
13308        let line = doctor_runtime_posture_line(&config, &workspace);
13309        assert!(
13310            line.contains("permission_posture=full-access (settings)"),
13311            "text doctor should report saved settings posture: {line}"
13312        );
13313        assert!(
13314            line.contains("approval_policy=on-request (default)"),
13315            "text doctor should keep unset config approval_policy default: {line}"
13316        );
13317
13318        let report = doctor_setup_report_json(&config, &workspace);
13319        assert_eq!(
13320            report["runtime_posture"]["permission_posture"]["value"],
13321            "full-access"
13322        );
13323        assert_eq!(
13324            report["runtime_posture"]["permission_posture"]["source"],
13325            "settings"
13326        );
13327        assert_eq!(
13328            report["runtime_posture"]["approval_policy"]["value"],
13329            "on-request"
13330        );
13331        assert_eq!(
13332            report["runtime_posture"]["approval_policy"]["source"],
13333            "default"
13334        );
13335    }
13336
13337    /// #5441: telemetry ships ON by default, and the runtime-posture doctor
13338    /// section must say so — with the source that decided it — instead of
13339    /// staying silent about the one default users never opted into.
13340    #[test]
13341    fn doctor_reports_resolved_telemetry_with_its_source() {
13342        let _guard = crate::test_support::lock_test_env();
13343        let tmp = TempDir::new().expect("tempdir");
13344        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13345        let _telemetry_env = crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY");
13346        let _telemetry_alias_env = crate::test_support::EnvVarGuard::remove("DEEPSEEK_TELEMETRY");
13347        let _telemetry_floor =
13348            crate::test_support::EnvVarGuard::remove("CODEWHALE_TELEMETRY_FLOOR");
13349        let workspace = tmp.path().join("workspace");
13350        fs::create_dir_all(&workspace).expect("workspace");
13351
13352        // Nothing configured anywhere: the shipped default applies and is
13353        // named, in both the text line and the JSON posture section.
13354        let config = Config::default();
13355        assert!(config.telemetry.is_none());
13356        let line = doctor_runtime_posture_line(&config, &workspace);
13357        assert!(
13358            line.contains("telemetry=on (default)"),
13359            "doctor line should name the defaulted consent: {line}"
13360        );
13361        let report = doctor_setup_report_json(&config, &workspace);
13362        assert_eq!(report["runtime_posture"]["telemetry"]["value"], true);
13363        assert_eq!(report["runtime_posture"]["telemetry"]["source"], "default");
13364
13365        // A persisted opt-out is reported as the config file's decision.
13366        let config = Config {
13367            telemetry: Some(false),
13368            ..Config::default()
13369        };
13370        let line = doctor_runtime_posture_line(&config, &workspace);
13371        assert!(
13372            line.contains("telemetry=off (config)"),
13373            "doctor line should name the persisted opt-out: {line}"
13374        );
13375        let report = doctor_setup_report_json(&config, &workspace);
13376        assert_eq!(report["runtime_posture"]["telemetry"]["value"], false);
13377        assert_eq!(report["runtime_posture"]["telemetry"]["source"], "config");
13378    }
13379
13380    #[test]
13381    fn doctor_setup_report_json_fails_closed_without_operate_receipts() {
13382        let _guard = crate::test_support::lock_test_env();
13383        let tmp = TempDir::new().expect("tempdir");
13384        let (_home_guard, _codewhale_home) = prepare_env(&tmp);
13385        let workspace = tmp.path().join("workspace");
13386        fs::create_dir_all(&workspace).expect("workspace");
13387        let mut state = codewhale_config::SetupState::default();
13388        state.set_step(
13389            codewhale_config::SetupStep::Language,
13390            codewhale_config::StepEntry::new(
13391                codewhale_config::StepStatus::Verified,
13392                true,
13393                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13394            ),
13395        );
13396        state.set_step(
13397            codewhale_config::SetupStep::ProviderModel,
13398            codewhale_config::StepEntry::new(
13399                codewhale_config::StepStatus::Verified,
13400                true,
13401                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13402            ),
13403        );
13404        state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
13405        state.complete_constitution_checkpoint(
13406            crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13407            codewhale_config::ConstitutionChoice::Bundled,
13408        );
13409        state.set_step(
13410            codewhale_config::SetupStep::OperateFleet,
13411            codewhale_config::StepEntry::new(
13412                codewhale_config::StepStatus::Verified,
13413                false,
13414                crate::tui::setup::CONSTITUTION_CHECKPOINT_VERSION,
13415            )
13416            .with_result(
13417                "provider=ready, runtime=ready, roster=ready, concurrency=plan limit not probed",
13418            ),
13419        );
13420        state.save().expect("persist setup state");
13421
13422        let config = Config {
13423            api_key: Some("TEST-STRUCTURAL-LITERAL".to_string()),
13424            ..Config::default()
13425        };
13426        let report = doctor_setup_report_json(&config, &workspace);
13427
13428        assert_eq!(report["first_run_ready"], true);
13429        assert_eq!(report["operate_ready"], false);
13430        assert_eq!(
13431            report["operate_fleet"]["concurrency"]["plan_limit_probed"],
13432            false
13433        );
13434        assert!(
13435            report["operate_fleet"]["roster"]["built_in"]
13436                .as_u64()
13437                .is_some_and(|count| count > 0)
13438        );
13439        let operate_step = report["steps"]
13440            .as_array()
13441            .expect("steps array")
13442            .iter()
13443            .find(|step| step["step"] == "operate_fleet")
13444            .expect("operate/fleet step");
13445        assert_eq!(operate_step["status"], "verified");
13446        assert!(
13447            operate_step["result"]
13448                .as_str()
13449                .is_some_and(|result| result.contains("plan limit not probed"))
13450        );
13451    }
13452}
13453
13454#[cfg(test)]
13455mod doctor_endpoint_tests {
13456    use super::*;
13457
13458    #[test]
13459    fn doctor_api_target_reports_default_endpoint() {
13460        let config = Config::default();
13461
13462        let target = doctor_api_target(&config);
13463
13464        assert_eq!(target.provider, "deepseek");
13465        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13466        assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13467        assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13468    }
13469
13470    #[test]
13471    fn doctor_api_target_falls_back_to_configured_model_when_resolution_fails() {
13472        // `custom` with no custom provider table cannot resolve an identity;
13473        // doctor must fall back to the raw configured model and say so
13474        // instead of presenting an unresolved value as the engine's route.
13475        let config = Config {
13476            provider: Some("custom".to_string()),
13477            ..Default::default()
13478        };
13479
13480        let target = doctor_api_target(&config);
13481
13482        assert_eq!(target.resolution, DoctorModelResolution::ConfiguredOnly);
13483        assert_eq!(target.model, config.default_model());
13484    }
13485
13486    #[test]
13487    fn doctor_api_target_routes_deepseek_cn_alias_to_beta_endpoint() {
13488        let config = Config {
13489            provider: Some("deepseek-cn".to_string()),
13490            ..Default::default()
13491        };
13492
13493        let target = doctor_api_target(&config);
13494
13495        assert_eq!(target.provider, "deepseek-cn");
13496        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEKCN_BASE_URL);
13497        assert_eq!(target.base_url, crate::config::DEFAULT_DEEPSEEK_BASE_URL);
13498        assert_eq!(target.model, crate::config::DEFAULT_TEXT_MODEL);
13499        assert_eq!(target.resolution, DoctorModelResolution::Resolved);
13500    }
13501
13502    #[test]
13503    fn strict_tool_mode_doctor_reports_disabled_by_default() {
13504        let config = Config::default();
13505
13506        let status = doctor_strict_tool_mode_status(&config);
13507
13508        assert!(!status.enabled);
13509        assert_eq!(status.status, "disabled");
13510        assert!(!status.function_strict_sent);
13511        assert!(status.recommended_base_url.is_none());
13512    }
13513
13514    #[test]
13515    fn doctor_known_base_urls_are_ascii_case_insensitive() {
13516        assert!(doctor_xiaomi_mimo_base_url_uses_token_plan(
13517            "HTTPS://TOKEN-PLAN-CN.XIAOMIMIMO.COM/V1/"
13518        ));
13519        assert_eq!(
13520            known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/BETA/"),
13521            Some(DeepSeekBaseUrlKind::Beta)
13522        );
13523        assert_eq!(
13524            known_deepseek_base_url_kind("HTTPS://API.DEEPSEEK.COM/V1/"),
13525            Some(DeepSeekBaseUrlKind::NonBeta)
13526        );
13527    }
13528
13529    #[test]
13530    fn strict_tool_mode_doctor_accepts_default_beta_endpoint() {
13531        let config = Config {
13532            strict_tool_mode: Some(true),
13533            ..Default::default()
13534        };
13535
13536        let status = doctor_strict_tool_mode_status(&config);
13537
13538        assert!(status.enabled);
13539        assert_eq!(status.status, "ready");
13540        assert!(status.function_strict_sent);
13541        assert!(status.message.contains("beta endpoint"));
13542        assert!(status.recommended_base_url.is_none());
13543    }
13544
13545    #[test]
13546    fn strict_tool_mode_doctor_warns_for_non_beta_deepseek_endpoint() {
13547        let config = Config {
13548            strict_tool_mode: Some(true),
13549            base_url: Some("https://api.deepseek.com".to_string()),
13550            ..Default::default()
13551        };
13552
13553        let status = doctor_strict_tool_mode_status(&config);
13554
13555        assert_eq!(status.status, "fallback_non_beta");
13556        assert!(!status.function_strict_sent);
13557        assert_eq!(
13558            status.recommended_base_url.as_deref(),
13559            Some(crate::config::DEFAULT_DEEPSEEK_BASE_URL)
13560        );
13561        assert_eq!(
13562            doctor_strict_tool_mode_report_json(&status)["recommended_base_url"],
13563            "https://api.deepseek.com"
13564        );
13565    }
13566
13567    #[test]
13568    fn strict_tool_mode_doctor_accepts_deepseek_cn_alias_default_endpoint() {
13569        let config = Config {
13570            provider: Some("deepseek-cn".to_string()),
13571            strict_tool_mode: Some(true),
13572            ..Default::default()
13573        };
13574
13575        let status = doctor_strict_tool_mode_status(&config);
13576
13577        assert_eq!(status.status, "ready");
13578        assert!(status.function_strict_sent);
13579        assert!(status.message.contains("beta endpoint"));
13580        assert!(status.recommended_base_url.is_none());
13581    }
13582
13583    #[test]
13584    fn strict_tool_mode_doctor_marks_custom_endpoint_as_forwarded() {
13585        let config = Config {
13586            provider: Some("vllm".to_string()),
13587            strict_tool_mode: Some(true),
13588            ..Default::default()
13589        };
13590
13591        let status = doctor_strict_tool_mode_status(&config);
13592
13593        assert_eq!(status.status, "custom_endpoint");
13594        assert!(status.function_strict_sent);
13595        assert!(status.message.contains("custom endpoint"));
13596    }
13597
13598    #[test]
13599    fn doctor_tls_status_reports_verification_enabled_by_default() {
13600        let status = doctor_tls_status(&Config::default());
13601
13602        assert!(status.certificate_verification);
13603        assert!(!status.insecure_skip_tls_verify);
13604        assert_eq!(status.provider, "deepseek");
13605        assert!(status.message.contains("enabled"));
13606    }
13607
13608    #[test]
13609    fn doctor_tls_status_warns_when_active_provider_skips_verification() {
13610        let mut providers = crate::config::ProvidersConfig::default();
13611        providers.openai.insecure_skip_tls_verify = Some(true);
13612        let config = Config {
13613            provider: Some("openai".to_string()),
13614            providers: Some(providers),
13615            ..Default::default()
13616        };
13617
13618        let status = doctor_tls_status(&config);
13619
13620        assert!(status.certificate_verification);
13621        assert!(status.insecure_skip_tls_verify);
13622        assert_eq!(status.provider, "openai");
13623        assert!(status.message.contains("cannot be disabled"));
13624        assert!(status.message.contains("SSL_CERT_FILE"));
13625    }
13626
13627    #[test]
13628    fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
13629        let mut config = Config {
13630            default_text_model: Some("deepseek-chat".to_string()),
13631            ..Default::default()
13632        };
13633        crate::config::normalize_model_config_for_test(&mut config);
13634
13635        let report = provider_capability_report(&config);
13636
13637        assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13638        assert_eq!(report["context_window"], 1_000_000);
13639        assert_eq!(report["thinking_supported"], true);
13640        assert_eq!(report["alias_deprecation"]["alias"], "deepseek-chat");
13641        assert_eq!(
13642            report["alias_deprecation"]["replacement"],
13643            "deepseek-v4-flash"
13644        );
13645        assert_eq!(
13646            report["alias_deprecation"]["retirement_utc"],
13647            "2026-07-24T15:59:00Z"
13648        );
13649    }
13650
13651    #[test]
13652    fn provider_capability_report_preserves_custom_deepseek_alias_namespace() {
13653        let mut config = Config {
13654            base_url: Some("https://models.example/v1".to_string()),
13655            default_text_model: Some("deepseek-chat".to_string()),
13656            ..Default::default()
13657        };
13658        crate::config::normalize_model_config_for_test(&mut config);
13659
13660        let report = provider_capability_report(&config);
13661
13662        assert_eq!(report["resolved_model"], "deepseek-chat");
13663        assert!(report["alias_deprecation"].is_null());
13664    }
13665
13666    #[test]
13667    fn provider_capability_report_leaves_canonical_flash_alias_metadata_null() {
13668        let config = Config {
13669            default_text_model: Some("deepseek-v4-flash".to_string()),
13670            ..Default::default()
13671        };
13672
13673        let report = provider_capability_report(&config);
13674
13675        assert_eq!(report["resolved_model"], "deepseek-v4-flash");
13676        assert!(report["alias_deprecation"].is_null());
13677    }
13678
13679    #[test]
13680    fn doctor_route_report_exposes_tokenhub_openai_compatible_route_without_secret() {
13681        let mut providers = crate::config::ProvidersConfig::default();
13682        providers.openai.api_key = Some("tokenhub-secret-value".to_string());
13683        providers.openai.base_url = Some("https://tokenhub.tencentmaas.com/v1".to_string());
13684        providers.openai.model = Some("deepseek-ai/DeepSeek-V4-Pro".to_string());
13685        let config = Config {
13686            provider: Some("openai".to_string()),
13687            providers: Some(providers),
13688            ..Default::default()
13689        };
13690
13691        let report = doctor_route_report(&config);
13692        let serialized = report.to_string();
13693
13694        assert_eq!(report["provider"], "openai");
13695        assert_eq!(report["provider_source"], "config");
13696        assert_eq!(report["provider_config_table"], "openai");
13697        assert_eq!(report["model"], "deepseek-ai/DeepSeek-V4-Pro");
13698        assert_eq!(report["wire_protocol"], "chat_completions");
13699        assert_eq!(
13700            report["base_url"]["redacted"],
13701            "https://tokenhub.tencentmaas.com"
13702        );
13703        assert_eq!(report["base_url"]["class"], "custom");
13704        assert_eq!(report["auth"]["scheme"], "bearer");
13705        assert_eq!(report["auth"]["source"], "config_declared");
13706        assert!(
13707            report["base_url"]["fingerprint"]
13708                .as_str()
13709                .is_some_and(|value| value.starts_with("<redacted:"))
13710        );
13711        assert!(!serialized.contains("tokenhub-secret-value"));
13712    }
13713
13714    #[test]
13715    fn doctor_route_report_exposes_siliconflow_cn_provider_route() {
13716        let mut providers = crate::config::ProvidersConfig::default();
13717        providers.siliconflow_cn.api_key = Some("sf-cn-secret-value".to_string());
13718        providers.siliconflow_cn.base_url =
13719            Some(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL.to_string());
13720        providers.siliconflow_cn.model = Some(crate::config::DEFAULT_SILICONFLOW_MODEL.to_string());
13721        let config = Config {
13722            provider: Some("siliconflow-CN".to_string()),
13723            providers: Some(providers),
13724            ..Default::default()
13725        };
13726
13727        let report = doctor_route_report(&config);
13728        let serialized = report.to_string();
13729
13730        assert_eq!(report["provider"], "siliconflow-CN");
13731        assert_eq!(report["provider_config_table"], "siliconflow_cn");
13732        assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
13733        assert_eq!(
13734            report["base_url"]["redacted"],
13735            crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
13736        );
13737        assert_eq!(report["base_url"]["class"], "default");
13738        assert_eq!(report["auth"]["scheme"], "bearer");
13739        assert_eq!(report["auth"]["source"], "config_declared");
13740        assert!(!serialized.contains("sf-cn-secret-value"));
13741    }
13742
13743    #[test]
13744    fn doctor_route_report_names_kimi_code_context_provenance() {
13745        let config = Config {
13746            provider: Some("moonshot".to_string()),
13747            providers: Some(crate::config::ProvidersConfig {
13748                moonshot: crate::config::ProviderConfig {
13749                    api_key: Some("kimi-plan-secret".to_string()),
13750                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13751                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13752                    ..Default::default()
13753                },
13754                ..Default::default()
13755            }),
13756            ..Default::default()
13757        };
13758
13759        let report = doctor_route_report(&config);
13760        let serialized = report.to_string();
13761
13762        assert_eq!(report["context_window"]["tokens"], 262_144);
13763        assert_eq!(
13764            report["context_window"]["source"],
13765            "static Kimi Code safe floor"
13766        );
13767        assert!(!serialized.contains("kimi-plan-secret"));
13768    }
13769
13770    #[test]
13771    fn provider_capability_report_uses_exact_kimi_code_route_facts() {
13772        let config = Config {
13773            provider: Some("moonshot".to_string()),
13774            providers: Some(crate::config::ProvidersConfig {
13775                moonshot: crate::config::ProviderConfig {
13776                    api_key: Some("kimi-plan-secret".to_string()),
13777                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13778                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13779                    ..Default::default()
13780                },
13781                ..Default::default()
13782            }),
13783            ..Default::default()
13784        };
13785
13786        let report = provider_capability_report(&config);
13787
13788        assert_eq!(report["resolved_model"], crate::config::KIMI_CODE_K3_MODEL);
13789        assert_eq!(report["context_window"], 262_144);
13790        assert_eq!(
13791            report["context_window_source"],
13792            "static Kimi Code safe floor"
13793        );
13794        assert_eq!(report["thinking_supported"], true);
13795    }
13796
13797    #[test]
13798    fn provider_capability_report_honors_kimi_code_context_override() {
13799        let config = Config {
13800            provider: Some("moonshot".to_string()),
13801            providers: Some(crate::config::ProvidersConfig {
13802                moonshot: crate::config::ProviderConfig {
13803                    api_key: Some("kimi-plan-secret".to_string()),
13804                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
13805                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
13806                    context_window: Some(1_048_576),
13807                    ..Default::default()
13808                },
13809                ..Default::default()
13810            }),
13811            ..Default::default()
13812        };
13813
13814        let report = provider_capability_report(&config);
13815
13816        assert_eq!(
13817            report["resolved_model"],
13818            crate::config::KIMI_CODE_K3_MODEL,
13819            "the configured window must preserve Kimi Code's bare wire id"
13820        );
13821        assert_eq!(report["context_window"], 1_048_576);
13822        assert_eq!(report["context_window_source"], "configured");
13823        assert_eq!(report["thinking_supported"], true);
13824    }
13825
13826    #[test]
13827    fn provider_capability_report_uses_direct_moonshot_k3_route_facts() {
13828        let config = Config {
13829            provider: Some("moonshot".to_string()),
13830            providers: Some(crate::config::ProvidersConfig {
13831                moonshot: crate::config::ProviderConfig {
13832                    api_key: Some("moonshot-secret".to_string()),
13833                    base_url: Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()),
13834                    model: Some("kimi-k3".to_string()),
13835                    ..Default::default()
13836                },
13837                ..Default::default()
13838            }),
13839            ..Default::default()
13840        };
13841
13842        let report = provider_capability_report(&config);
13843
13844        assert_eq!(report["resolved_model"], "kimi-k3");
13845        assert_eq!(report["context_window"], 1_048_576);
13846        assert_eq!(report["context_window_source"], "catalog");
13847        assert_eq!(report["max_output"], 1_048_576);
13848        assert_eq!(report["thinking_supported"], true);
13849    }
13850
13851    #[test]
13852    fn doctor_search_provider_line_includes_firecrawl_default_source_and_switch_hint() {
13853        let _guard = crate::test_support::lock_test_env();
13854        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13855        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13856
13857        let line = doctor_search_provider_line(&Config::default());
13858
13859        match prev {
13860            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13861            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13862        }
13863        assert!(line.contains("search_provider: firecrawl"));
13864        assert!(line.contains("source: default"));
13865        assert!(line.contains("[search] provider"));
13866        assert!(line.contains("provider = \"baidu\""));
13867    }
13868
13869    #[test]
13870    fn doctor_search_provider_json_reports_config_source() {
13871        let _guard = crate::test_support::lock_test_env();
13872        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13873        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13874        let config = Config {
13875            search: Some(crate::config::SearchConfig {
13876                provider: Some(crate::config::SearchProvider::DuckDuckGo),
13877                base_url: None,
13878                api_key: None,
13879            }),
13880            ..Default::default()
13881        };
13882
13883        let report = doctor_search_provider_json(&config);
13884
13885        match prev {
13886            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13887            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13888        }
13889        assert_eq!(report["provider"], "duckduckgo");
13890        assert_eq!(report["source"], "config");
13891    }
13892
13893    #[test]
13894    fn doctor_search_provider_json_reports_env_override_source() {
13895        let _guard = crate::test_support::lock_test_env();
13896        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13897        unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", "tavily") };
13898
13899        let report = doctor_search_provider_json(&Config::default());
13900
13901        match prev {
13902            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13903            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13904        }
13905        assert_eq!(report["provider"], "tavily");
13906        assert_eq!(report["source"], "env override");
13907    }
13908
13909    #[test]
13910    fn doctor_search_provider_line_omits_switch_hint_when_bing_is_configured() {
13911        let _guard = crate::test_support::lock_test_env();
13912        let prev = std::env::var_os("DEEPSEEK_SEARCH_PROVIDER");
13913        unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") };
13914        let config = Config {
13915            search: Some(crate::config::SearchConfig {
13916                provider: Some(crate::config::SearchProvider::Bing),
13917                base_url: None,
13918                api_key: None,
13919            }),
13920            ..Default::default()
13921        };
13922
13923        let line = doctor_search_provider_line(&config);
13924
13925        match prev {
13926            Some(value) => unsafe { std::env::set_var("DEEPSEEK_SEARCH_PROVIDER", value) },
13927            None => unsafe { std::env::remove_var("DEEPSEEK_SEARCH_PROVIDER") },
13928        }
13929        assert!(line.contains("search_provider: bing"));
13930        assert!(line.contains("source: config"));
13931        assert!(!line.contains("[search] provider"));
13932    }
13933
13934    #[test]
13935    fn timeout_recovery_keeps_default_deepseek_users_on_default_endpoint() {
13936        let config = Config::default();
13937
13938        let text = doctor_timeout_recovery_lines(&config).join("\n");
13939
13940        assert!(text.contains("api.deepseek.com"));
13941        assert!(text.contains("custom DeepSeek-compatible endpoint"));
13942        assert!(!text.contains("provider = \"deepseek-cn\""));
13943        assert!(text.contains("codewhale doctor --json"));
13944    }
13945
13946    #[test]
13947    fn timeout_recovery_for_custom_provider_checks_openai_compatibility() {
13948        let config = Config {
13949            provider: Some("vllm".to_string()),
13950            ..Default::default()
13951        };
13952
13953        let text = doctor_timeout_recovery_lines(&config).join("\n");
13954
13955        assert!(text.contains("/v1/models"));
13956        assert!(text.contains("/v1/chat/completions"));
13957        assert!(!text.contains("api.deepseeki.com"));
13958    }
13959}
13960
13961#[cfg(test)]
13962mod terminal_mode_tests {
13963    use super::*;
13964    use clap::Parser;
13965
13966    fn parse_cli(args: &[&str]) -> Cli {
13967        Cli::try_parse_from(args).expect("CLI args should parse")
13968    }
13969
13970    #[test]
13971    fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
13972        let config = Config {
13973            network: Some(crate::config::NetworkPolicyToml {
13974                default: "allow".to_string(),
13975                audit: false,
13976                ..crate::config::NetworkPolicyToml::default()
13977            }),
13978            ..Config::default()
13979        };
13980        let authority = crate::tools::spec::ToolAuthorityEnvelope {
13981            schema_version: 1,
13982            owner: "consultant-1".to_string(),
13983            authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
13984            network_access: Some(false),
13985            shell: crate::tools::spec::ToolShellAuthority::None,
13986            verification: crate::tools::spec::ToolVerificationAuthority::None,
13987            writable_roots: Vec::new(),
13988            writable_files: Vec::new(),
13989            coordination_contracts: Vec::new(),
13990        }
13991        .normalized()
13992        .expect("Consultant authority");
13993
13994        let policy = exec_network_policy(&config, authority.network_access)
13995            .expect("explicit network=false always installs a policy");
13996        assert_eq!(
13997            policy.evaluate("example.com", "web_search"),
13998            crate::network_policy::Decision::Deny,
13999            "the permissive user config must not widen Consultant network authority"
14000        );
14001        let mut features = crate::features::Features::default();
14002        features.enable(crate::features::Feature::ShellTool);
14003        features.enable(crate::features::Feature::WebSearch);
14004        apply_fleet_engine_feature_caps(
14005            &mut features,
14006            true,
14007            authority.network_access,
14008            authority.shell,
14009        );
14010        assert!(!features.enabled(crate::features::Feature::WebSearch));
14011        assert!(!features.enabled(crate::features::Feature::ShellTool));
14012
14013        let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
14014        assert_eq!(
14015            worker_policy.evaluate("example.com", "web_search"),
14016            crate::network_policy::Decision::Allow,
14017            "a network-capable role keeps the configured policy"
14018        );
14019    }
14020    #[test]
14021    fn hidden_remote_control_flag_starts_the_interactive_handoff() {
14022        let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
14023        assert!(cli.remote_control);
14024    }
14025
14026    #[test]
14027    fn plugin_registry_discovery_is_route_independent_and_read_only() {
14028        let _env_lock = crate::test_support::lock_test_env();
14029        let temp = tempfile::tempdir().unwrap();
14030        let workspace = temp.path().join("workspace");
14031        let codewhale_home = temp.path().join("home");
14032        std::fs::create_dir_all(&workspace).unwrap();
14033        let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
14034        let workspace_arg = workspace.to_string_lossy().into_owned();
14035
14036        for route in [
14037            Vec::<&str>::new(),
14038            vec!["resume", "--last"],
14039            vec!["fork", "--last"],
14040            vec!["exec", "hello"],
14041            vec!["serve", "--mcp"],
14042        ] {
14043            let mut args = vec![
14044                "codewhale-tui".to_string(),
14045                "--workspace".to_string(),
14046                workspace_arg.clone(),
14047            ];
14048            args.extend(route.into_iter().map(str::to_string));
14049            let cli = Cli::try_parse_from(args).expect("route should parse");
14050            let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
14051            let registry = discovery
14052                .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
14053            assert_eq!(registry.workspace(), workspace.as_path());
14054            assert!(
14055                !codewhale_home.join("plugins/state.json").exists(),
14056                "startup discovery must remain read-only"
14057            );
14058        }
14059    }
14060
14061    fn custom_exec_config(active: &str) -> Config {
14062        let mut custom = std::collections::HashMap::new();
14063        for (name, base_url, model) in [
14064            (
14065                "custom-a",
14066                "http://127.0.0.1:18181/v1",
14067                crate::config::ZAI_GLM_5_2_MODEL,
14068            ),
14069            ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
14070        ] {
14071            custom.insert(
14072                name.to_string(),
14073                crate::config::ProviderConfig {
14074                    kind: Some("openai-compatible".to_string()),
14075                    base_url: Some(base_url.to_string()),
14076                    model: Some(model.to_string()),
14077                    api_key: Some("local-test-key".to_string()),
14078                    ..Default::default()
14079                },
14080            );
14081        }
14082        Config {
14083            provider: Some(active.to_string()),
14084            providers: Some(crate::config::ProvidersConfig {
14085                custom,
14086                ..Default::default()
14087            }),
14088            ..Default::default()
14089        }
14090    }
14091
14092    #[test]
14093    fn doctor_json_surfaces_keep_exact_named_custom_provider() {
14094        let config = custom_exec_config("custom-a");
14095        let workspace = tempfile::tempdir().expect("doctor workspace");
14096
14097        let operate = doctor_operate_fleet_report_json(&config, workspace.path());
14098        let provider_model = doctor_provider_model_report_json(&config);
14099        let capability = provider_capability_report(&config);
14100        let route = doctor_route_report(&config);
14101
14102        assert_eq!(operate["provider"]["id"], "custom-a");
14103        assert_eq!(provider_model["provider"]["id"], "custom-a");
14104        assert_eq!(capability["resolved_provider"], "custom-a");
14105        assert_eq!(route["provider"], "custom-a");
14106        assert_eq!(route["provider_config_table"], "providers.custom-a");
14107        let serialized = serde_json::to_string(&serde_json::json!({
14108            "operate": operate,
14109            "provider_model": provider_model,
14110            "capability": capability,
14111            "route": route,
14112        }))
14113        .expect("doctor JSON");
14114        assert!(!serialized.contains("local-test-key"));
14115    }
14116
14117    #[test]
14118    fn doctor_operate_fleet_json_lists_multi_layer_profile_paths() {
14119        // #5098: doctor must name the winning layer and every losing path
14120        // when project and personal both define the same id.
14121        let _env_lock = crate::test_support::lock_test_env();
14122        let tmp = tempfile::TempDir::new().expect("tempdir");
14123        let home = tmp.path().join("home");
14124        let workspace = tmp.path().join("workspace");
14125        let personal = home.join("agents");
14126        let project = workspace.join(".codewhale").join("agents");
14127        std::fs::create_dir_all(&personal).expect("personal agents");
14128        std::fs::create_dir_all(&project).expect("project agents");
14129        std::fs::write(
14130            personal.join("builder.toml"),
14131            "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-flash\"\n",
14132        )
14133        .expect("personal builder");
14134        std::fs::write(
14135            project.join("builder.toml"),
14136            "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-pro\"\n",
14137        )
14138        .expect("project builder");
14139        let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
14140
14141        let operate = doctor_operate_fleet_report_json(&Config::default(), &workspace);
14142        let layers = operate["roster"]["multi_layer"]
14143            .as_array()
14144            .expect("multi_layer array");
14145        let builder = layers
14146            .iter()
14147            .find(|entry| entry["id"] == "builder")
14148            .expect("builder multi-layer entry");
14149        assert_eq!(builder["effective"], "project");
14150        let paths: Vec<&str> = builder["layers"]
14151            .as_array()
14152            .expect("layers")
14153            .iter()
14154            .filter_map(|layer| layer["path"].as_str())
14155            .collect();
14156        assert!(
14157            paths.iter().any(|path| path.ends_with("builder.toml")),
14158            "layer paths include the profile files: {builder}"
14159        );
14160        assert!(
14161            builder["layers"]
14162                .as_array()
14163                .expect("layers")
14164                .iter()
14165                .any(|layer| layer["origin"] == "personal" && layer["wins"] == false),
14166            "personal layer is listed as ignored: {builder}"
14167        );
14168        assert!(
14169            builder["layers"]
14170                .as_array()
14171                .expect("layers")
14172                .iter()
14173                .any(|layer| layer["origin"] == "project" && layer["wins"] == true),
14174            "project layer wins: {builder}"
14175        );
14176    }
14177
14178    fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
14179        let mut saved = session_manager::create_saved_session_with_mode(
14180            &[],
14181            model,
14182            Path::new("/tmp/exec-resume"),
14183            0,
14184            None,
14185            Some("exec"),
14186        );
14187        let kind = crate::config::ApiProvider::parse(provider)
14188            .unwrap_or(crate::config::ApiProvider::Custom)
14189            .as_str();
14190        let exact_id = (!provider
14191            .eq_ignore_ascii_case(crate::config::ApiProvider::Custom.as_str()))
14192        .then_some(provider);
14193        saved.metadata.set_model_provider_route(kind, exact_id);
14194        saved
14195    }
14196
14197    #[test]
14198    fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
14199        let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
14200
14201        assert_eq!(cli.prompt, vec!["hello", "world"]);
14202    }
14203
14204    #[test]
14205    fn prompt_flag_starts_interactive_submit_input() {
14206        let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
14207
14208        assert_eq!(
14209            top_level_prompt_initial_input(&cli.prompt),
14210            Some(tui::InitialInput::Submit("read the project".to_string()))
14211        );
14212    }
14213
14214    #[test]
14215    fn companion_binary_reports_its_own_name() {
14216        assert_eq!(Cli::command().get_name(), "codewhale-tui");
14217    }
14218
14219    #[test]
14220    fn xai_device_auth_subcommand_parses() {
14221        let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
14222        assert!(matches!(
14223            cli.command,
14224            Some(Commands::Auth(TuiAuthArgs {
14225                command: TuiAuthCommand::XaiDevice
14226            }))
14227        ));
14228    }
14229
14230    #[test]
14231    fn workflow_tool_internal_subcommand_parses_exact_json() {
14232        let cli = parse_cli(&[
14233            "codewhale-tui",
14234            "workflow-tool",
14235            "--approval-source",
14236            "explicit-workflow-command",
14237            "--input-json",
14238            r#"{"action":"run","source_path":"workflows/demo.js"}"#,
14239        ]);
14240        let Some(Commands::WorkflowTool(args)) = cli.command else {
14241            panic!("expected workflow-tool command");
14242        };
14243        assert!(args.input_json.contains("\"action\":\"run\""));
14244    }
14245
14246    #[tokio::test]
14247    async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
14248        use crate::tools::spec::ToolSpec;
14249
14250        let workspace = tempfile::tempdir().expect("workspace");
14251        let config = Config {
14252            provider: Some("vllm".to_string()),
14253            mcp_config_path: Some(
14254                workspace
14255                    .path()
14256                    .join("missing-mcp.json")
14257                    .display()
14258                    .to_string(),
14259            ),
14260            providers: Some(crate::config::ProvidersConfig {
14261                vllm: crate::config::ProviderConfig {
14262                    base_url: Some("http://127.0.0.1:9/v1".to_string()),
14263                    model: Some("offline-test-model".to_string()),
14264                    ..Default::default()
14265                },
14266                ..Default::default()
14267            }),
14268            ..Default::default()
14269        };
14270        let route = CliAutoRoute {
14271            provider: crate::config::ApiProvider::Vllm,
14272            model: "offline-test-model".to_string(),
14273            reasoning_effort: None,
14274            auto_controls_reasoning: false,
14275            auto_model: false,
14276        };
14277        let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
14278        let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14279        let (tool, context) =
14280            build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
14281                .await
14282                .expect("build direct workflow runtime");
14283
14284        let result = tool
14285            .execute(
14286                serde_json::json!({
14287                    "action": "run",
14288                    "script": "phase('offline'); return { ok: true };",
14289                    "token_budget": 1_000_000
14290                }),
14291                &context,
14292            )
14293            .await
14294            .expect("model-free workflow run");
14295        let payload: serde_json::Value =
14296            serde_json::from_str(&result.content).expect("workflow JSON");
14297
14298        assert_eq!(payload["status"], "completed");
14299        assert_eq!(payload["result"]["ok"], true);
14300        assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
14301        assert_eq!(
14302            payload["plan_approval"]["decision"],
14303            "approved_explicit_cli_command"
14304        );
14305        assert!(!context.auto_approve);
14306        assert!(!context.trust_mode);
14307        assert_eq!(
14308            context.shell_policy,
14309            crate::worker_profile::ShellPolicy::None
14310        );
14311        assert!(matches!(
14312            context.elevated_sandbox_policy,
14313            Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
14314        ));
14315        let mut event_types = Vec::new();
14316        while let Ok(event) = event_rx.try_recv() {
14317            if let crate::core::events::Event::WorkflowUi { event, .. } = event
14318                && let Some(kind) = event["type"].as_str()
14319            {
14320                event_types.push(kind.to_string());
14321            }
14322        }
14323        assert!(event_types.iter().any(|kind| kind == "run_started"));
14324        assert!(event_types.iter().any(|kind| kind == "run_completed"));
14325    }
14326
14327    #[tokio::test]
14328    async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
14329        let workspace = tempfile::tempdir().expect("workspace");
14330        let mcp_path = workspace.path().join("mcp.json");
14331        std::fs::write(
14332            &mcp_path,
14333            r#"{
14334                "mcpServers": {
14335                    "blocked": { "url": "https://blocked.invalid/mcp" }
14336                }
14337            }"#,
14338        )
14339        .expect("write MCP config");
14340        let config = Config {
14341            mcp_config_path: Some(mcp_path.display().to_string()),
14342            ..Default::default()
14343        };
14344        let policy = crate::network_policy::NetworkPolicyDecider::new(
14345            crate::network_policy::NetworkPolicy {
14346                default: crate::network_policy::DecisionToml::Deny,
14347                allow: Vec::new(),
14348                deny: Vec::new(),
14349                proxy: Vec::new(),
14350                proxy_fake_ip_cidrs: Vec::new(),
14351                audit: false,
14352            },
14353            None,
14354        );
14355
14356        let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
14357        let (_pool, failures) =
14358            initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
14359                .await
14360                .expect("MCP feature enabled");
14361        assert_eq!(failures.len(), 1, "failures={failures:?}");
14362        assert_eq!(failures[0].0, "blocked");
14363        assert!(failures[0].1.contains("blocked by network policy"));
14364    }
14365
14366    #[test]
14367    fn exec_model_resolution_uses_provider_scoped_default() {
14368        let _env_lock = crate::test_support::lock_test_env();
14369        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14370        let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14371        let config = Config {
14372            provider: Some("openrouter".to_string()),
14373            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14374            providers: Some(crate::config::ProvidersConfig {
14375                openrouter: crate::config::ProviderConfig {
14376                    model: Some("arcee-ai/trinity-large-thinking".to_string()),
14377                    ..Default::default()
14378                },
14379                ..Default::default()
14380            }),
14381            ..Default::default()
14382        };
14383
14384        assert_eq!(
14385            resolve_exec_model(&config, None),
14386            "arcee-ai/trinity-large-thinking"
14387        );
14388        assert_eq!(
14389            resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
14390            "arcee-ai/trinity-large-thinking"
14391        );
14392    }
14393
14394    #[test]
14395    fn exec_model_resolution_prefers_codewhale_model_env_override() {
14396        let _env_lock = crate::test_support::lock_test_env();
14397        let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
14398        let _deepseek_model =
14399            crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
14400        let config = Config {
14401            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14402            ..Default::default()
14403        };
14404
14405        assert_eq!(resolve_exec_model(&config, None), "auto");
14406    }
14407
14408    #[test]
14409    fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
14410        let _env_lock = crate::test_support::lock_test_env();
14411        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14412        let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
14413        let config = Config {
14414            default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
14415            ..Default::default()
14416        };
14417
14418        assert_eq!(resolve_exec_model(&config, None), "auto");
14419    }
14420
14421    #[test]
14422    fn exec_model_resolution_uses_provider_safe_default_for_zai() {
14423        let _env_lock = crate::test_support::lock_test_env();
14424        let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
14425        let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
14426        let config = Config {
14427            provider: Some("zai".to_string()),
14428            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14429            ..Default::default()
14430        };
14431
14432        assert_eq!(
14433            resolve_exec_model(&config, None),
14434            crate::config::DEFAULT_ZAI_MODEL
14435        );
14436    }
14437
14438    #[tokio::test]
14439    #[allow(clippy::await_holding_lock)]
14440    async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
14441        let _env_lock = crate::test_support::lock_test_env();
14442        let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14443        let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
14444        let config = Config {
14445            provider: Some("deepseek".to_string()),
14446            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14447            ..Default::default()
14448        };
14449
14450        let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14451            .await
14452            .expect("explicit GLM should route to the configured Z.ai provider");
14453
14454        assert_eq!(route.provider, crate::config::ApiProvider::Zai);
14455        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14456        assert!(!route.auto_model);
14457    }
14458
14459    #[tokio::test]
14460    #[allow(clippy::await_holding_lock)]
14461    async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
14462        let _env_lock = crate::test_support::lock_test_env();
14463        let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
14464        let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
14465        let config = Config {
14466            provider: Some("deepseek".to_string()),
14467            default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
14468            ..Default::default()
14469        };
14470
14471        let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
14472            .await
14473            .expect_err("ambiguous GLM route should ask for an explicit provider");
14474        let message = err.to_string();
14475
14476        assert!(message.contains("model `GLM-5.2` is available"));
14477        assert!(message.contains("openrouter"));
14478        assert!(message.contains("zai"));
14479        assert!(message.contains("--provider"));
14480        assert!(message.contains("/provider"));
14481        assert!(message.contains("/model"));
14482        assert!(message.contains("/setup"));
14483    }
14484
14485    #[tokio::test]
14486    async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
14487        let config = Config {
14488            provider: Some("vllm".to_string()),
14489            reasoning_effort: Some("low".to_string()),
14490            providers: Some(crate::config::ProvidersConfig {
14491                vllm: crate::config::ProviderConfig {
14492                    base_url: Some("http://127.0.0.1:18190/v1".to_string()),
14493                    model: Some("local-auto-model".to_string()),
14494                    ..Default::default()
14495                },
14496                ..Default::default()
14497            }),
14498            ..Default::default()
14499        };
14500
14501        let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
14502            .await
14503            .expect("Auto model route");
14504
14505        assert!(route.auto_model);
14506        assert_eq!(
14507            route.reasoning_effort,
14508            Some(crate::tui::app::ReasoningEffort::Low)
14509        );
14510        assert!(
14511            !route.auto_controls_reasoning,
14512            "a fixed saved tier must not be replaced per prompt"
14513        );
14514    }
14515
14516    #[test]
14517    fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
14518        let mut providers = crate::config::ProvidersConfig::default();
14519        providers.deepseek.model = Some("deepseek-v4-pro".to_string());
14520        let config = Config {
14521            provider: Some("deepseek".to_string()),
14522            providers: Some(providers),
14523            ..Default::default()
14524        };
14525        let route = CliAutoRoute {
14526            provider: crate::config::ApiProvider::Deepseek,
14527            model: "deepseek-v4-flash".to_string(),
14528            reasoning_effort: None,
14529            auto_controls_reasoning: true,
14530            auto_model: true,
14531        };
14532
14533        let execution_config = config_for_cli_route(&config, &route);
14534
14535        assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
14536        assert_eq!(
14537            execution_config
14538                .provider_config_for(crate::config::ApiProvider::Deepseek)
14539                .and_then(|entry| entry.model.as_deref()),
14540            Some("deepseek-v4-flash")
14541        );
14542    }
14543
14544    #[test]
14545    fn cli_route_execution_config_preserves_legacy_literal_custom_root_route() {
14546        let _lock = crate::test_support::lock_test_env();
14547        let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
14548        let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
14549        let config = Config {
14550            provider: Some("custom".to_string()),
14551            api_key: Some("legacy-root-key".to_string()),
14552            base_url: Some("http://127.0.0.1:18183/v1".to_string()),
14553            default_text_model: Some("legacy-model".to_string()),
14554            ..Default::default()
14555        };
14556        let route = CliAutoRoute {
14557            provider: crate::config::ApiProvider::Custom,
14558            model: "routed-legacy-model".to_string(),
14559            reasoning_effort: None,
14560            auto_controls_reasoning: false,
14561            auto_model: false,
14562        };
14563
14564        let execution = config_for_cli_route(&config, &route);
14565
14566        assert!(execution.uses_legacy_literal_custom_route());
14567        assert!(
14568            execution
14569                .providers
14570                .as_ref()
14571                .is_none_or(|providers| !providers.custom.contains_key("custom"))
14572        );
14573        assert_eq!(execution.provider.as_deref(), Some("custom"));
14574        assert_eq!(execution.default_model(), "routed-legacy-model");
14575        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18183/v1");
14576        assert_eq!(execution.deepseek_api_key().unwrap(), "legacy-root-key");
14577        for _ in 0..2 {
14578            let identity = execution
14579                .resolve_provider_identity("custom")
14580                .expect("legacy identity remains repeatedly resolvable");
14581            assert_eq!(identity.key, "custom");
14582        }
14583        let client =
14584            crate::client::DeepSeekClient::new(&execution).expect("legacy execution client");
14585        assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
14586    }
14587
14588    #[test]
14589    fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
14590        let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
14591        let Some(Commands::Exec(args)) = cli.command else {
14592            panic!("expected exec command");
14593        };
14594
14595        assert_eq!(args.prompt, vec!["hello", "world"]);
14596    }
14597
14598    #[test]
14599    fn exec_keeps_model_flag_before_split_prompt_words() {
14600        let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
14601        let Some(Commands::Exec(args)) = cli.command else {
14602            panic!("expected exec command");
14603        };
14604
14605        assert_eq!(args.model.as_deref(), Some("auto"));
14606        assert_eq!(args.prompt, vec!["hello", "world"]);
14607    }
14608
14609    #[test]
14610    fn exec_keeps_flags_before_split_prompt_words() {
14611        let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
14612        let Some(Commands::Exec(args)) = cli.command else {
14613            panic!("expected exec command");
14614        };
14615
14616        assert!(args.json);
14617        assert_eq!(args.prompt, vec!["hello", "world"]);
14618    }
14619
14620    #[test]
14621    fn exec_parses_provider_flag_alongside_model() {
14622        // #4093: Fleet threads `--provider <id>` so a worker launches on its
14623        // profile-pinned provider even when the parent session is elsewhere.
14624        let cli = parse_cli(&[
14625            "codewhale",
14626            "exec",
14627            "--provider",
14628            "openrouter",
14629            "--model",
14630            "glm-5.2",
14631            "audit",
14632        ]);
14633        let Some(Commands::Exec(args)) = cli.command else {
14634            panic!("expected exec command");
14635        };
14636
14637        assert_eq!(args.provider.as_deref(), Some("openrouter"));
14638        assert_eq!(args.model.as_deref(), Some("glm-5.2"));
14639        assert_eq!(args.prompt, vec!["audit"]);
14640        // The threaded id round-trips through the provider vocabulary the exec
14641        // handler validates against — never a model-id sniff (EPIC #2608).
14642        assert_eq!(
14643            crate::config::ApiProvider::parse(args.provider.as_deref().unwrap()),
14644            Some(crate::config::ApiProvider::Openrouter)
14645        );
14646    }
14647
14648    #[test]
14649    fn exec_provider_override_accepts_configured_custom_provider() {
14650        let mut custom = std::collections::HashMap::new();
14651        custom.insert(
14652            "lm-studio".to_string(),
14653            crate::config::ProviderConfig {
14654                kind: Some("openai-compatible".to_string()),
14655                base_url: Some("http://127.0.0.1:1234/v1".to_string()),
14656                model: Some("qwen-2.5-7b".to_string()),
14657                api_key: Some("lm-studio".to_string()),
14658                ..Default::default()
14659            },
14660        );
14661        let mut config = Config {
14662            provider: Some("deepseek".to_string()),
14663            providers: Some(crate::config::ProvidersConfig {
14664                custom,
14665                ..Default::default()
14666            }),
14667            ..Default::default()
14668        };
14669
14670        apply_exec_provider_override(&mut config, "lm-studio")
14671            .expect("configured custom provider should be accepted");
14672
14673        assert_eq!(config.provider.as_deref(), Some("lm-studio"));
14674        assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14675    }
14676
14677    #[test]
14678    fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
14679        let mut config = Config {
14680            provider: Some("deepseek".to_string()),
14681            providers: Some(crate::config::ProvidersConfig {
14682                custom: std::collections::HashMap::from([(
14683                    "CUSTOM".to_string(),
14684                    crate::config::ProviderConfig {
14685                        kind: Some("openai-compatible".to_string()),
14686                        base_url: Some("http://127.0.0.1:5678/v1".to_string()),
14687                        model: Some("case-model".to_string()),
14688                        api_key: Some("case-key".to_string()),
14689                        ..Default::default()
14690                    },
14691                )]),
14692                ..Default::default()
14693            }),
14694            ..Default::default()
14695        };
14696
14697        apply_exec_provider_override(&mut config, "CUSTOM")
14698            .expect("exact case-colliding custom provider");
14699        assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
14700        assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom);
14701        assert_eq!(
14702            config.provider_identity_for(crate::config::ApiProvider::Custom),
14703            "CUSTOM"
14704        );
14705        let route = crate::route_runtime::resolve_runtime_route(
14706            &config,
14707            crate::config::ApiProvider::Custom,
14708            Some("case-model"),
14709        )
14710        .expect("resolve exact case-colliding route")
14711        .validate()
14712        .expect("preflight exact case-colliding route");
14713        assert_eq!(route.identity.key, "CUSTOM");
14714        assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
14715    }
14716
14717    #[test]
14718    fn exec_provider_override_rejects_unknown_provider() {
14719        let mut config = Config {
14720            provider: Some("deepseek".to_string()),
14721            ..Default::default()
14722        };
14723
14724        let err = apply_exec_provider_override(&mut config, "lm-studio")
14725            .expect_err("unconfigured custom provider should fail closed");
14726        let message = err.to_string();
14727
14728        assert!(message.contains("Unrecognized --provider"));
14729        assert!(message.contains("[providers.<name>] custom provider"));
14730        assert_eq!(config.provider.as_deref(), Some("deepseek"));
14731    }
14732
14733    #[test]
14734    fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
14735        let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
14736
14737        let mut restored = custom_exec_config("custom-b");
14738        let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
14739            .expect("plain resume restores saved route");
14740        assert_eq!(restored.provider.as_deref(), Some("custom-a"));
14741        assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
14742
14743        let mut explicit_provider = custom_exec_config("custom-a");
14744        apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
14745        let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
14746            .expect("explicit provider wins");
14747        assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
14748        assert_eq!(model, "model-b");
14749
14750        let mut explicit_model = custom_exec_config("custom-b");
14751        let model =
14752            resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
14753                .expect("explicit model keeps saved provider");
14754        assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
14755        assert_eq!(model, "override-model");
14756
14757        let mut missing = custom_exec_config("custom-b");
14758        missing
14759            .providers
14760            .as_mut()
14761            .expect("providers")
14762            .custom
14763            .remove("custom-a");
14764        let before = missing.provider.clone();
14765        let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
14766            .expect_err("removed saved provider must fail closed");
14767        assert!(err.to_string().contains("will not fall back"), "{err}");
14768        assert_eq!(missing.provider, before);
14769    }
14770
14771    #[test]
14772    fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
14773        let (started_tx, started_rx) = std::sync::mpsc::channel();
14774        let (tx, rx) = std::sync::mpsc::channel();
14775
14776        let (reader, expected_after_restore) = {
14777            let lock = crate::test_support::lock_test_env();
14778            let expected_after_restore = exec_model_env_override();
14779            let temporary =
14780                crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
14781            let reader = std::thread::spawn(move || {
14782                started_tx.send(()).expect("signal model read start");
14783                tx.send(exec_model_env_override())
14784                    .expect("send resolved model override");
14785            });
14786
14787            started_rx
14788                .recv_timeout(std::time::Duration::from_secs(2))
14789                .expect("reader reached model read");
14790            assert!(
14791                rx.recv_timeout(std::time::Duration::from_millis(50))
14792                    .is_err(),
14793                "a foreign reader observed another test's temporary model override"
14794            );
14795            drop(temporary);
14796            drop(lock);
14797            (reader, expected_after_restore)
14798        };
14799
14800        let observed = rx
14801            .recv_timeout(std::time::Duration::from_secs(2))
14802            .expect("reader resumed after model override was restored");
14803        reader.join().expect("reader thread");
14804        assert_eq!(observed, expected_after_restore);
14805    }
14806
14807    #[tokio::test]
14808    async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
14809        let config = custom_exec_config("custom-a");
14810
14811        let route =
14812            resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
14813                .await
14814                .expect("forced route");
14815        let execution = config_for_cli_route(&config, &route);
14816
14817        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14818        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14819        assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14820    }
14821
14822    #[tokio::test]
14823    async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
14824        let mut config = custom_exec_config("custom-a");
14825        config
14826            .providers
14827            .as_mut()
14828            .expect("providers")
14829            .custom
14830            .get_mut("custom-a")
14831            .expect("custom A")
14832            .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
14833        let model = resolve_exec_model(&config, None);
14834        let force = should_force_configured_exec_route(false, None, None);
14835
14836        assert!(force, "configured/default exec route must be authoritative");
14837        assert!(!should_force_configured_exec_route(
14838            false,
14839            None,
14840            Some(crate::config::ZAI_GLM_5_2_MODEL)
14841        ));
14842        assert!(should_force_configured_exec_route(
14843            false,
14844            Some("custom-a"),
14845            Some(crate::config::ZAI_GLM_5_2_MODEL)
14846        ));
14847        assert!(should_force_configured_exec_route(
14848            true,
14849            None,
14850            Some("override-model")
14851        ));
14852
14853        let route = resolve_cli_exec_route(&config, &model, "audit", force)
14854            .await
14855            .expect("no-flag configured route");
14856        let execution = config_for_cli_route(&config, &route);
14857        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14858        assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
14859        assert_eq!(execution.provider.as_deref(), Some("custom-a"));
14860    }
14861
14862    #[tokio::test]
14863    async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
14864        let mut config = custom_exec_config("custom-a");
14865        config
14866            .providers
14867            .as_mut()
14868            .expect("providers")
14869            .custom
14870            .get_mut("custom-a")
14871            .expect("custom A")
14872            .model = Some("model-a".to_string());
14873        config.default_text_model = Some("stale-root-deepseek-model".to_string());
14874        let model = resolve_review_model(&config, None);
14875        assert_eq!(model, "model-a");
14876        assert_eq!(
14877            resolve_review_model(&config, Some("explicit-review-model")),
14878            "explicit-review-model"
14879        );
14880
14881        let route = resolve_cli_exec_route(&config, &model, "review diff", true)
14882            .await
14883            .expect("configured review route");
14884        let execution = config_for_cli_route(&config, &route);
14885        let provider = execution.provider_identity_for(route.provider);
14886
14887        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14888        assert_eq!(provider, "custom-a");
14889        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14890        let output = crate::tools::review::ReviewOutput::from_str("{}");
14891        let receipt = crate::tools::review::build_review_receipt(
14892            "working tree",
14893            "diff --git a/a b/a",
14894            provider,
14895            &route.model,
14896            &output,
14897            "{}",
14898            Vec::new(),
14899        );
14900        assert_eq!(receipt.provider, "custom-a");
14901        let serialized = serde_json::to_string(&receipt).expect("review receipt");
14902        assert!(!serialized.contains("127.0.0.1"));
14903        assert!(!serialized.contains("local-test-key"));
14904    }
14905
14906    #[tokio::test]
14907    async fn configured_workflow_default_keeps_named_custom_route() {
14908        let config = custom_exec_config("custom-a");
14909        let model = config.default_model();
14910
14911        let route = resolve_cli_exec_route(
14912            &config,
14913            &model,
14914            "Run a checked-in Workflow through the host runtime",
14915            true,
14916        )
14917        .await
14918        .expect("configured workflow route");
14919        let execution = config_for_cli_route(&config, &route);
14920
14921        assert_eq!(route.provider, crate::config::ApiProvider::Custom);
14922        assert_eq!(execution.provider_identity_for(route.provider), "custom-a");
14923        assert_eq!(execution.deepseek_base_url(), "http://127.0.0.1:18181/v1");
14924        let client = crate::client::DeepSeekClient::new(&execution).expect("workflow client");
14925        assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
14926    }
14927
14928    #[test]
14929    fn exec_json_receipts_keep_exact_named_custom_provider() {
14930        let config = custom_exec_config("custom-a");
14931        let provider = config.provider_identity_for(crate::config::ApiProvider::Custom);
14932        let one_shot = one_shot_exec_json_receipt(
14933            provider.clone(),
14934            "model-a".to_string(),
14935            "done".to_string(),
14936            Some("end_turn".to_string()),
14937            crate::models::Usage {
14938                input_tokens: 12,
14939                output_tokens: 3,
14940                ..Default::default()
14941            },
14942        );
14943        assert_eq!(one_shot["provider"], "custom-a");
14944        assert_eq!(one_shot["success"], true);
14945
14946        let truncated = one_shot_exec_json_receipt(
14947            provider.clone(),
14948            "model-a".to_string(),
14949            "partial".to_string(),
14950            Some("max_output_tokens".to_string()),
14951            crate::models::Usage {
14952                input_tokens: 20,
14953                output_tokens: 9,
14954                ..Default::default()
14955            },
14956        );
14957        assert_eq!(truncated["success"], false);
14958        assert_eq!(truncated["stop_reason"], "max_output_tokens");
14959        assert_eq!(truncated["usage"]["input_tokens"], 20);
14960        assert_eq!(truncated["usage"]["output_tokens"], 9);
14961        assert!(truncated["error"].as_str().is_some_and(|error| {
14962            error.contains("Model response incomplete") && error.contains("max_output_tokens")
14963        }));
14964
14965        let agent = serde_json::to_value(ExecSummary {
14966            mode: "agent".to_string(),
14967            provider,
14968            model: "model-a".to_string(),
14969            ..ExecSummary::default()
14970        })
14971        .expect("agent exec JSON receipt");
14972        assert_eq!(agent["provider"], "custom-a");
14973        let serialized = serde_json::to_string(&agent).expect("serialize receipt");
14974        assert!(!serialized.contains("127.0.0.1"));
14975        assert!(!serialized.contains("local-test-key"));
14976    }
14977
14978    #[test]
14979    fn exec_stream_provider_pair_preserves_named_literal_and_root_custom_provenance() {
14980        let named = crate::config::ProviderIdentity {
14981            provider: crate::config::ApiProvider::Custom,
14982            key: "lm-studio".to_string(),
14983            exact_id: Some("lm-studio".to_string()),
14984            migrated_legacy_ollama_cloud_route: false,
14985        };
14986        let literal = crate::config::ProviderIdentity {
14987            provider: crate::config::ApiProvider::Custom,
14988            key: "custom".to_string(),
14989            exact_id: Some("custom".to_string()),
14990            migrated_legacy_ollama_cloud_route: false,
14991        };
14992        let root = crate::config::ProviderIdentity {
14993            provider: crate::config::ApiProvider::Custom,
14994            key: "custom".to_string(),
14995            exact_id: None,
14996            migrated_legacy_ollama_cloud_route: false,
14997        };
14998        let built_in = crate::config::ProviderIdentity {
14999            provider: crate::config::ApiProvider::Deepseek,
15000            key: "deepseek".to_string(),
15001            exact_id: Some("deepseek".to_string()),
15002            migrated_legacy_ollama_cloud_route: false,
15003        };
15004
15005        assert_eq!(
15006            exec_stream_provider_route(&named),
15007            ("custom".to_string(), Some("lm-studio".to_string()))
15008        );
15009        assert_eq!(
15010            exec_stream_provider_route(&literal),
15011            ("custom".to_string(), Some("custom".to_string()))
15012        );
15013        assert_eq!(
15014            exec_stream_provider_route(&root),
15015            ("custom".to_string(), None)
15016        );
15017        assert_eq!(
15018            exec_stream_provider_route(&built_in),
15019            ("deepseek".to_string(), None)
15020        );
15021    }
15022
15023    #[test]
15024    fn resumed_exec_persistence_updates_provider_and_model_as_one_route() {
15025        let saved_a = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
15026        let mut config = custom_exec_config("custom-a");
15027        apply_exec_provider_override(&mut config, "custom-b").expect("custom B");
15028        let model = resolve_exec_resume_route(&mut config, &saved_a, true, None)
15029            .expect("explicit provider route");
15030        let mut persisted = saved_a;
15031        stamp_exec_session_metadata(
15032            &mut persisted,
15033            &model,
15034            crate::config::ApiProvider::Custom.as_str(),
15035            Some("custom-b"),
15036            Path::new("/tmp/exec-resume"),
15037        );
15038
15039        let mut next_config = custom_exec_config("custom-a");
15040        let resumed_model = resolve_exec_resume_route(&mut next_config, &persisted, false, None)
15041            .expect("next plain resume");
15042
15043        assert_eq!(persisted.metadata.model_provider, "custom");
15044        assert_eq!(
15045            persisted.metadata.model_provider_id.as_deref(),
15046            Some("custom-b")
15047        );
15048        assert_eq!(persisted.metadata.model, "model-b");
15049        assert_eq!(next_config.provider.as_deref(), Some("custom-b"));
15050        assert_eq!(resumed_model, "model-b");
15051    }
15052
15053    #[test]
15054    fn exec_persistence_omits_id_for_legacy_root_custom_route() {
15055        let mut saved = session_manager::create_saved_session_with_mode(
15056            &[],
15057            "legacy-root-model",
15058            Path::new("/tmp/exec-root"),
15059            0,
15060            None,
15061            Some("exec"),
15062        );
15063        stamp_exec_session_metadata(
15064            &mut saved,
15065            "legacy-root-model",
15066            crate::config::ApiProvider::Custom.as_str(),
15067            None,
15068            Path::new("/tmp/exec-root"),
15069        );
15070
15071        assert_eq!(saved.metadata.model_provider, "custom");
15072        assert_eq!(saved.metadata.model_provider_id, None);
15073        assert!(
15074            !serde_json::to_string(&saved)
15075                .expect("serialize exec session")
15076                .contains("model_provider_id")
15077        );
15078    }
15079
15080    #[test]
15081    fn exec_parses_reasoning_effort_flag_alongside_provider() {
15082        let cli = parse_cli(&[
15083            "codewhale",
15084            "exec",
15085            "--provider",
15086            "openrouter",
15087            "--model",
15088            "glm-5.2",
15089            "--reasoning-effort",
15090            "max",
15091            "audit",
15092        ]);
15093        let Some(Commands::Exec(args)) = cli.command else {
15094            panic!("expected exec command");
15095        };
15096
15097        assert_eq!(args.provider.as_deref(), Some("openrouter"));
15098        assert_eq!(args.model.as_deref(), Some("glm-5.2"));
15099        assert_eq!(args.reasoning_effort.as_deref(), Some("max"));
15100        assert_eq!(args.prompt, vec!["audit"]);
15101    }
15102
15103    #[test]
15104    fn cli_reasoning_effort_normalizes_aliases_and_rejects_typos() {
15105        // The thinking ladder split these: `xhigh` is a tier the CLI can now
15106        // name, `ultracode` is still an alias and resolves to `ultra`.
15107        assert_eq!(
15108            normalize_cli_reasoning_effort("xhigh").unwrap().as_deref(),
15109            Some("xhigh")
15110        );
15111        assert_eq!(
15112            normalize_cli_reasoning_effort("ultracode")
15113                .unwrap()
15114                .as_deref(),
15115            Some("ultra")
15116        );
15117        assert_eq!(normalize_cli_reasoning_effort("default").unwrap(), None);
15118        assert!(normalize_cli_reasoning_effort("expensive").is_err());
15119    }
15120
15121    #[test]
15122    fn cli_prompt_paths_resolve_auto_before_k3_route_normalization() {
15123        let config = Config {
15124            provider: Some("moonshot".to_string()),
15125            providers: Some(crate::config::ProvidersConfig {
15126                moonshot: crate::config::ProviderConfig {
15127                    base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
15128                    model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
15129                    ..Default::default()
15130                },
15131                ..Default::default()
15132            }),
15133            ..Default::default()
15134        };
15135
15136        for (prompt, expected) in [
15137            ("lookup the public docs", "low"),
15138            ("debug this error", "max"),
15139            ("review this ordinary change", "high"),
15140        ] {
15141            assert_eq!(
15142                cli_reasoning_effort_value_for_prompt(
15143                    &config,
15144                    crate::config::KIMI_CODE_K3_MODEL,
15145                    crate::tui::app::ReasoningEffort::Auto,
15146                    prompt,
15147                )
15148                .as_deref(),
15149                Some(expected),
15150                "prompt selector must resolve Auto for `{prompt}`"
15151            );
15152        }
15153
15154        assert_eq!(
15155            cli_reasoning_effort_value_for_prompt(
15156                &config,
15157                crate::config::KIMI_CODE_K3_MODEL,
15158                crate::tui::app::ReasoningEffort::Off,
15159                "debug must not override an explicit effort",
15160            )
15161            .as_deref(),
15162            Some("low"),
15163            "membership K3 still applies its exact-route always-thinking floor"
15164        );
15165    }
15166
15167    #[test]
15168    fn cli_route_tracks_auto_reasoning_independently_from_auto_model() {
15169        use crate::tui::app::ReasoningEffort;
15170
15171        let fixed_model_auto_reasoning = CliAutoRoute {
15172            provider: crate::config::ApiProvider::Deepseek,
15173            model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
15174            reasoning_effort: Some(ReasoningEffort::Auto),
15175            auto_controls_reasoning: true,
15176            auto_model: false,
15177        };
15178        let auto_model_fixed_reasoning = CliAutoRoute {
15179            provider: crate::config::ApiProvider::OpenaiCodex,
15180            model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(),
15181            reasoning_effort: Some(ReasoningEffort::High),
15182            auto_controls_reasoning: false,
15183            auto_model: true,
15184        };
15185
15186        assert!(fixed_model_auto_reasoning.auto_controls_reasoning);
15187        assert!(!fixed_model_auto_reasoning.auto_model);
15188        assert!(!auto_model_fixed_reasoning.auto_controls_reasoning);
15189        assert!(auto_model_fixed_reasoning.auto_model);
15190    }
15191
15192    #[test]
15193    fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() {
15194        let mut config = Config {
15195            reasoning_effort: Some("max".to_string()),
15196            reasoning_effort_inferred_from_legacy_alias: true,
15197            ..Default::default()
15198        };
15199        let settings = crate::settings::Settings {
15200            reasoning_effort: Some("low".to_string()),
15201            ..Default::default()
15202        };
15203
15204        apply_saved_reasoning_preference(&mut config, &settings);
15205
15206        assert_eq!(config.reasoning_effort(), Some("low"));
15207        assert!(config.reasoning_effort_is_explicit());
15208    }
15209
15210    /// `run_exec_agent` must hand the engine a concrete tier, never the literal
15211    /// `"auto"` sentinel, for a fixed-model Auto launch.
15212    #[test]
15213    fn fixed_model_exec_auto_resolves_to_a_concrete_tier_not_the_auto_sentinel() {
15214        let config = Config {
15215            provider: Some("zai".to_string()),
15216            ..Default::default()
15217        };
15218
15219        let resolved = cli_reasoning_effort_value_for_prompt(
15220            &config,
15221            crate::config::ZAI_GLM_5_2_MODEL,
15222            crate::tui::app::ReasoningEffort::Auto,
15223            "debug this failing integration test",
15224        )
15225        .expect("Auto must resolve to a concrete tier");
15226
15227        assert_ne!(
15228            resolved, "auto",
15229            "the literal auto sentinel must never reach a provider"
15230        );
15231        assert!(
15232            matches!(resolved.as_str(), "off" | "low" | "medium" | "high" | "max"),
15233            "unexpected resolved tier: {resolved}"
15234        );
15235    }
15236
15237    #[test]
15238    fn exec_accepts_resume_session_flags_for_harnesses() {
15239        let cli = parse_cli(&[
15240            "codewhale",
15241            "exec",
15242            "--resume",
15243            "abc123",
15244            "--output-format",
15245            "stream-json",
15246            "follow up",
15247        ]);
15248        let Some(Commands::Exec(args)) = cli.command else {
15249            panic!("expected exec command");
15250        };
15251
15252        assert_eq!(args.resume.as_deref(), Some("abc123"));
15253        assert_eq!(args.output_format, ExecOutputFormat::StreamJson);
15254        assert_eq!(args.prompt, vec!["follow up"]);
15255    }
15256
15257    #[test]
15258    fn exec_accepts_session_id_alias() {
15259        let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]);
15260        let Some(Commands::Exec(args)) = cli.command else {
15261            panic!("expected exec command");
15262        };
15263
15264        assert_eq!(args.session_id.as_deref(), Some("abc123"));
15265        assert_eq!(args.output_format, ExecOutputFormat::Text);
15266    }
15267
15268    #[test]
15269    fn exec_parses_tool_gate_and_hardening_flags() {
15270        let envelope = r#"{"schema_version":1,"owner":"fleet-worker-1","authority":"read_only"}"#;
15271        let cli = parse_cli(&[
15272            "codewhale",
15273            "exec",
15274            "--allowed-tools",
15275            "File,Git",
15276            "--disallowed-tools",
15277            "Bash",
15278            "--max-turns",
15279            "7",
15280            "--append-system-prompt",
15281            "extra rules",
15282            "--tool-authority-json",
15283            envelope,
15284            "do the thing",
15285        ]);
15286        let Some(Commands::Exec(args)) = cli.command else {
15287            panic!("expected exec command");
15288        };
15289
15290        assert_eq!(
15291            args.allowed_tools.as_deref(),
15292            Some(&["File".to_string(), "Git".to_string()][..])
15293        );
15294        assert_eq!(
15295            args.disallowed_tools.as_deref(),
15296            Some(&["Bash".to_string()][..])
15297        );
15298        assert_eq!(args.max_turns, Some(7));
15299        assert_eq!(args.append_system_prompt.as_deref(), Some("extra rules"));
15300        assert_eq!(args.tool_authority_json.as_deref(), Some(envelope));
15301        assert_eq!(args.prompt, vec!["do the thing"]);
15302    }
15303
15304    #[test]
15305    fn fleet_tool_authority_cannot_cross_an_exec_resume_boundary() {
15306        assert!(validate_exec_tool_authority_resume(None, true).is_ok());
15307        assert!(validate_exec_tool_authority_resume(Some("{}"), false).is_ok());
15308        let error = validate_exec_tool_authority_resume(Some("{}"), true)
15309            .expect_err("authority must remain bound to its fresh Fleet launch")
15310            .to_string();
15311        assert!(error.contains("cannot be combined with exec --resume"));
15312    }
15313
15314    #[test]
15315    fn exec_auto_does_not_authorize_sandbox_elevation() {
15316        let cli = parse_cli(&["codewhale", "exec", "--auto", "run it"]);
15317        let Some(Commands::Exec(args)) = cli.command else {
15318            panic!("expected exec command");
15319        };
15320
15321        assert!(!exec_sandbox_elevation_authorized(
15322            args.allow_sandbox_elevation,
15323            args.sandbox.as_deref()
15324        ));
15325    }
15326
15327    #[test]
15328    fn exec_explicit_sandbox_elevation_opt_ins_authorize_retry() {
15329        let danger = parse_cli(&[
15330            "codewhale",
15331            "exec",
15332            "--auto",
15333            "--sandbox",
15334            "danger-full-access",
15335            "run it",
15336        ]);
15337        let Some(Commands::Exec(args)) = danger.command else {
15338            panic!("expected exec command");
15339        };
15340        assert!(exec_sandbox_elevation_authorized(
15341            args.allow_sandbox_elevation,
15342            args.sandbox.as_deref()
15343        ));
15344
15345        let flag = parse_cli(&[
15346            "codewhale",
15347            "exec",
15348            "--auto",
15349            "--allow-sandbox-elevation",
15350            "run it",
15351        ]);
15352        let Some(Commands::Exec(args)) = flag.command else {
15353            panic!("expected exec command");
15354        };
15355        assert!(exec_sandbox_elevation_authorized(
15356            args.allow_sandbox_elevation,
15357            args.sandbox.as_deref()
15358        ));
15359    }
15360
15361    #[test]
15362    fn exec_sandbox_denial_stream_event_is_typed() {
15363        let event = ExecStreamEvent::SandboxDenied {
15364            tool_id: "call_1".to_string(),
15365            tool_name: "exec_shell".to_string(),
15366            reason: "write blocked".to_string(),
15367            outcome: "approval_required".to_string(),
15368        };
15369        let value: serde_json::Value =
15370            serde_json::from_str(&serde_json::to_string(&event).expect("serializes"))
15371                .expect("valid json");
15372        assert_eq!(value["type"], "sandbox_denied");
15373        assert_eq!(value["outcome"], "approval_required");
15374    }
15375
15376    #[test]
15377    fn exec_help_separates_agent_mode_from_sandbox_elevation() {
15378        let mut cli = Cli::command();
15379        let help = cli
15380            .find_subcommand_mut("exec")
15381            .expect("exec command")
15382            .render_help()
15383            .to_string();
15384        assert!(help.contains("--auto"));
15385        assert!(help.contains("--sandbox"));
15386        assert!(help.contains("--allow-sandbox-elevation"));
15387        assert!(help.contains("does not change the"));
15388        assert!(help.contains("explicitly authorize sandbox elevation"));
15389    }
15390
15391    #[test]
15392    fn exec_shell_only_tool_surface_env_sets_shell_allowlist() {
15393        let _env_lock = crate::test_support::lock_test_env();
15394        let _surface =
15395            crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, " shell-only ");
15396
15397        let allowed_tools = resolve_exec_allowed_tools(None, exec_tool_surface_from_env())
15398            .expect("shell-only surface should set an allowlist");
15399
15400        assert_eq!(allowed_tools, vec!["bash".to_string()]);
15401    }
15402
15403    #[test]
15404    fn exec_explicit_allowed_tools_override_shell_only_env() {
15405        let _env_lock = crate::test_support::lock_test_env();
15406        let _surface =
15407            crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "shell-only");
15408        let explicit = vec![" File ".to_string(), "GIT".to_string()];
15409
15410        let allowed_tools =
15411            resolve_exec_allowed_tools(Some(&explicit), exec_tool_surface_from_env())
15412                .expect("explicit allowlist should be preserved");
15413
15414        assert_eq!(allowed_tools, vec!["file".to_string(), "git".to_string()]);
15415    }
15416
15417    #[test]
15418    fn exec_full_tool_surface_env_leaves_allowlist_unset() {
15419        let _env_lock = crate::test_support::lock_test_env();
15420        let _surface = crate::test_support::EnvVarGuard::set(CODEWHALE_TOOL_SURFACE_ENV, "full");
15421
15422        assert_eq!(
15423            resolve_exec_allowed_tools(None, exec_tool_surface_from_env()),
15424            None
15425        );
15426    }
15427
15428    #[test]
15429    fn exec_unknown_tool_surface_env_warns_without_allowlist() {
15430        assert!(should_warn_unknown_exec_tool_surface("shell_onyl"));
15431        assert!(!should_warn_unknown_exec_tool_surface("shell-only"));
15432        assert!(!should_warn_unknown_exec_tool_surface("native-tools"));
15433        assert!(!should_warn_unknown_exec_tool_surface("full"));
15434        assert!(!should_warn_unknown_exec_tool_surface(" "));
15435        assert_eq!(parse_exec_tool_surface("shell_onyl"), None);
15436    }
15437
15438    #[test]
15439    fn exec_rejects_zero_max_turns() {
15440        let err = Cli::try_parse_from(["codewhale", "exec", "--max-turns", "0", "hello"])
15441            .expect_err("max-turns must be >= 1");
15442        assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
15443    }
15444
15445    #[test]
15446    fn exec_omits_the_headless_turn_cap_by_default() {
15447        let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
15448        let Some(Commands::Exec(args)) = cli.command else {
15449            panic!("expected exec command");
15450        };
15451
15452        assert_eq!(args.max_turns, None);
15453        assert_eq!(exec_max_steps(args.max_turns), u32::MAX);
15454        assert_eq!(exec_max_steps(Some(7)), 7);
15455    }
15456
15457    #[test]
15458    fn exec_accepts_continue_for_latest_workspace_session() {
15459        let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]);
15460        let Some(Commands::Exec(args)) = cli.command else {
15461            panic!("expected exec command");
15462        };
15463
15464        assert!(args.continue_session);
15465    }
15466
15467    #[test]
15468    fn sessions_footer_points_to_resume_subcommand() {
15469        let cli = parse_cli(&["codewhale", "resume", "abc123"]);
15470        let Some(Commands::Resume { session_id, last }) = cli.command else {
15471            panic!("expected resume command");
15472        };
15473
15474        assert_eq!(session_id.as_deref(), Some("abc123"));
15475        assert!(!last);
15476        assert_eq!(sessions_resume_command(), "codewhale resume");
15477        assert!(!sessions_resume_command().contains("--resume"));
15478    }
15479
15480    #[test]
15481    fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() {
15482        use std::cell::Cell;
15483
15484        #[derive(Clone, Copy)]
15485        enum Expected {
15486            Plain,
15487            Resume,
15488            Fork,
15489            Exec,
15490            Serve,
15491        }
15492
15493        let cases: &[(&[&str], Expected)] = &[
15494            (&["codewhale"], Expected::Plain),
15495            (&["codewhale", "resume", "--last"], Expected::Resume),
15496            (&["codewhale", "fork", "--last"], Expected::Fork),
15497            (&["codewhale", "exec", "probe"], Expected::Exec),
15498            (&["codewhale", "serve", "--mcp"], Expected::Serve),
15499        ];
15500
15501        for (args, expected) in cases {
15502            let phase = Cell::new(0);
15503            let (_cli, command) = prepare_cli_startup(
15504                parse_cli(args),
15505                || {
15506                    assert_eq!(phase.get(), 0, "plugin init order for {args:?}");
15507                    phase.set(1);
15508                },
15509                || {
15510                    assert_eq!(phase.get(), 1, "dotenv load order for {args:?}");
15511                    phase.set(2);
15512                },
15513            );
15514
15515            assert_eq!(phase.get(), 2, "startup phases for {args:?}");
15516            let correct_variant = matches!(
15517                (expected, command.as_ref()),
15518                (Expected::Plain, None)
15519                    | (Expected::Resume, Some(Commands::Resume { .. }))
15520                    | (Expected::Fork, Some(Commands::Fork { .. }))
15521                    | (Expected::Exec, Some(Commands::Exec(_)))
15522                    | (Expected::Serve, Some(Commands::Serve(_)))
15523            );
15524            assert!(correct_variant, "unexpected command for {args:?}");
15525        }
15526    }
15527
15528    #[test]
15529    fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() {
15530        let _lock = crate::test_support::lock_test_env();
15531        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15532        let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key");
15533        let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
15534        let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
15535        let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL");
15536        let tmp = tempfile::TempDir::new().expect("temp workspace");
15537        let dotenv = tmp.path().join(".env");
15538        std::fs::write(
15539            &dotenv,
15540            "DEEPSEEK_API_KEY=workspace-key\n\
15541             NVIDIA_API_KEY=repo-must-not-override-shell\n\
15542             CODEWHALE_HOME=./attacker-home\n\
15543             CODEWHALE_CONFIG_PATH=./attacker.toml\n\
15544             DEEPSEEK_ALLOW_SHELL=true\n",
15545        )
15546        .expect("write dotenv");
15547
15548        let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load");
15549
15550        assert_eq!(
15551            std::env::var("DEEPSEEK_API_KEY").as_deref(),
15552            Ok("workspace-key")
15553        );
15554        assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key"));
15555        assert!(std::env::var_os("CODEWHALE_HOME").is_none());
15556        assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none());
15557        assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none());
15558        assert_eq!(
15559            report.loaded,
15560            BTreeSet::from(["DEEPSEEK_API_KEY".to_string()])
15561        );
15562        assert_eq!(
15563            report.ignored,
15564            BTreeSet::from([
15565                "CODEWHALE_CONFIG_PATH".to_string(),
15566                "CODEWHALE_HOME".to_string(),
15567                "DEEPSEEK_ALLOW_SHELL".to_string(),
15568            ])
15569        );
15570    }
15571
15572    #[test]
15573    fn workspace_dotenv_rejects_ambient_variable_substitution() {
15574        let _lock = crate::test_support::lock_test_env();
15575        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15576        let _ambient = crate::test_support::EnvVarGuard::set(
15577            "CODEWHALE_JS_SECRET_LEAK_TEST",
15578            "ambient-secret-must-not-expand",
15579        );
15580        let tmp = tempfile::TempDir::new().expect("temp workspace");
15581        let dotenv = tmp.path().join(".env");
15582        std::fs::write(
15583            &dotenv,
15584            "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n",
15585        )
15586        .expect("write dotenv");
15587
15588        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15589            .expect_err("expansion must fail closed")
15590            .to_string();
15591
15592        assert!(error.contains("variable expansion"));
15593        assert!(!error.contains("ambient-secret-must-not-expand"));
15594        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15595    }
15596
15597    #[test]
15598    fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() {
15599        let _lock = crate::test_support::lock_test_env();
15600        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15601        let _ambient = crate::test_support::EnvVarGuard::set(
15602            "CODEWHALE_JS_SECRET_LEAK_TEST",
15603            "ambient-secret-must-not-expand",
15604        );
15605        let tmp = tempfile::TempDir::new().expect("temp workspace");
15606        let dotenv = tmp.path().join(".env");
15607        std::fs::write(
15608            &dotenv,
15609            "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n",
15610        )
15611        .expect("write dotenv");
15612
15613        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15614            .expect_err("multiline expansion must fail closed")
15615            .to_string();
15616
15617        assert!(error.contains("variable expansion"));
15618        assert!(!error.contains("ambient-secret-must-not-expand"));
15619        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15620    }
15621
15622    #[test]
15623    fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() {
15624        let _lock = crate::test_support::lock_test_env();
15625        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15626        let _ambient = crate::test_support::EnvVarGuard::set(
15627            "CODEWHALE_JS_SECRET_LEAK_TEST",
15628            "ambient-secret-must-not-expand",
15629        );
15630        let tmp = tempfile::TempDir::new().expect("temp workspace");
15631        let dotenv = tmp.path().join(".env");
15632        std::fs::write(
15633            &dotenv,
15634            "# unmatched quote in ignored comment: '\n\
15635             DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n",
15636        )
15637        .expect("write dotenv");
15638
15639        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15640            .expect_err("comment quote must not hide expansion")
15641            .to_string();
15642
15643        assert!(error.contains("variable expansion"));
15644        assert!(!error.contains("ambient-secret-must-not-expand"));
15645        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15646    }
15647
15648    #[test]
15649    fn workspace_dotenv_allows_single_quoted_literal_dollar() {
15650        let _lock = crate::test_support::lock_test_env();
15651        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15652        let tmp = tempfile::TempDir::new().expect("temp workspace");
15653        let dotenv = tmp.path().join(".env");
15654        std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv");
15655
15656        load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load");
15657
15658        assert_eq!(
15659            std::env::var("DEEPSEEK_API_KEY").as_deref(),
15660            Ok("$literal-value")
15661        );
15662    }
15663
15664    #[test]
15665    fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() {
15666        let _lock = crate::test_support::lock_test_env();
15667        let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
15668        let tmp = tempfile::TempDir::new().expect("temp workspace");
15669        let dotenv = tmp.path().join(".env");
15670        std::fs::write(
15671            &dotenv,
15672            "DEEPSEEK_API_KEY=must-not-survive\nBROKEN=\"unterminated\n",
15673        )
15674        .expect("write dotenv");
15675
15676        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15677            .expect_err("parse failure must be transactional")
15678            .to_string();
15679
15680        assert!(error.contains("could not be parsed safely"), "{error}");
15681        assert!(!error.contains("must-not-survive"));
15682        assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none());
15683    }
15684
15685    #[test]
15686    fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() {
15687        for provider in codewhale_config::provider::providers_sorted_for_display() {
15688            for key in provider.env_vars() {
15689                assert!(
15690                    is_workspace_dotenv_credential_key(key),
15691                    "provider credential {key} must remain supported"
15692                );
15693            }
15694        }
15695        for key in [
15696            "CODEWHALE_HOME",
15697            "CODEWHALE_CONFIG_PATH",
15698            "DEEPSEEK_CONFIG_PATH",
15699            "DEEPSEEK_PROFILE",
15700            "DEEPSEEK_MANAGED_CONFIG_PATH",
15701            "DEEPSEEK_REQUIREMENTS_PATH",
15702            "DEEPSEEK_PROVIDER",
15703            "DEEPSEEK_BASE_URL",
15704            "DEEPSEEK_MODEL",
15705            "DEEPSEEK_APPROVAL_POLICY",
15706            "DEEPSEEK_SANDBOX_MODE",
15707            "DEEPSEEK_ALLOW_SHELL",
15708            "DEEPSEEK_YOLO",
15709            "DEEPSEEK_MCP_CONFIG",
15710            "CODEWHALE_RUNTIME_TOKEN",
15711            "PATH",
15712            "NODE_OPTIONS",
15713            "PYTHONPATH",
15714            "LD_PRELOAD",
15715            "DYLD_INSERT_LIBRARIES",
15716        ] {
15717            assert!(
15718                !is_workspace_dotenv_credential_key(key),
15719                "control-plane variable {key} must not load from a workspace"
15720            );
15721        }
15722    }
15723
15724    #[cfg(unix)]
15725    #[test]
15726    fn workspace_dotenv_does_not_follow_symbolic_links() {
15727        use std::os::unix::fs::symlink;
15728
15729        let tmp = tempfile::TempDir::new().expect("temp workspace");
15730        let external = tmp.path().join("external-credentials");
15731        let dotenv = tmp.path().join(".env");
15732        std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15733            .expect("write external fixture");
15734        symlink(&external, &dotenv).expect("create dotenv symlink");
15735
15736        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15737            .expect_err("symlink must fail closed")
15738            .to_string();
15739
15740        assert!(error.contains("securely open"), "{error}");
15741        assert!(!error.contains("external-secret"));
15742    }
15743
15744    #[cfg(unix)]
15745    #[test]
15746    fn workspace_dotenv_rejects_hard_links_to_external_files() {
15747        let tmp = tempfile::TempDir::new().expect("temp workspace");
15748        let external = tmp.path().join("external-credentials");
15749        let dotenv = tmp.path().join(".env");
15750        std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n")
15751            .expect("write external fixture");
15752        std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link");
15753
15754        let error = load_workspace_dotenv_credentials_from_path(&dotenv)
15755            .expect_err("hard link must fail closed")
15756            .to_string();
15757
15758        assert!(error.contains("multiple filesystem links"), "{error}");
15759        assert!(!error.contains("external-secret"));
15760    }
15761
15762    #[cfg(unix)]
15763    #[test]
15764    fn workspace_dotenv_rejects_fifo_without_blocking_startup() {
15765        use std::ffi::CString;
15766        use std::os::unix::ffi::OsStrExt;
15767        use std::sync::mpsc;
15768        use std::time::Duration;
15769
15770        let tmp = tempfile::TempDir::new().expect("temp workspace");
15771        let dotenv = tmp.path().join(".env");
15772        let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path");
15773        // SAFETY: `c_path` is a live, NUL-terminated path and the requested
15774        // mode grants access only to the current user.
15775        let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) };
15776        assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error());
15777
15778        let (tx, rx) = mpsc::channel();
15779        let worker_path = dotenv.clone();
15780        let worker = std::thread::spawn(move || {
15781            let result = load_workspace_dotenv_credentials_from_path(&worker_path)
15782                .map(|_| "unexpected success".to_string())
15783                .unwrap_or_else(|error| error.to_string());
15784            tx.send(result).expect("send loader result");
15785        });
15786
15787        let error = match rx.recv_timeout(Duration::from_secs(1)) {
15788            Ok(error) => error,
15789            Err(timeout) => {
15790                // Release a regressed blocking reader so the test can fail
15791                // promptly instead of leaving a stuck process behind.
15792                let _writer = std::fs::OpenOptions::new()
15793                    .write(true)
15794                    .open(&dotenv)
15795                    .expect("open fifo writer to release blocked reader");
15796                let _ = rx.recv_timeout(Duration::from_secs(1));
15797                worker.join().expect("join released loader");
15798                panic!("workspace .env FIFO blocked startup: {timeout}");
15799            }
15800        };
15801        worker.join().expect("join loader");
15802
15803        assert!(error.contains("not a regular file"), "{error}");
15804    }
15805
15806    #[test]
15807    fn exec_json_conflicts_with_stream_json_output() {
15808        let err = Cli::try_parse_from([
15809            "codewhale",
15810            "exec",
15811            "--json",
15812            "--output-format",
15813            "stream-json",
15814            "hello",
15815        ])
15816        .expect_err("json summary and stream-json must not mix");
15817
15818        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
15819    }
15820
15821    #[test]
15822    fn exec_stream_turn_usage_event_serializes_reported_fields() {
15823        let event = ExecStreamEvent::TurnUsage {
15824            turn: 2,
15825            input_tokens: 1200,
15826            output_tokens: 180,
15827            reasoning_tokens: Some(90),
15828            prompt_cache_hit_tokens: Some(900),
15829            prompt_cache_miss_tokens: Some(300),
15830            prompt_cache_write_tokens: Some(0),
15831            reasoning_replay_tokens: Some(40),
15832            duration_ms: 1834,
15833        };
15834
15835        let value = exec_stream_value(&event).expect("serializes");
15836        let json = serde_json::to_string(&value).expect("serializes");
15837        assert!(!json.contains('\n'));
15838        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15839        assert_eq!(parsed["type"], "turn_usage");
15840        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15841        assert_eq!(parsed["schema_version"], 1);
15842        assert_eq!(parsed["turn"], 2);
15843        assert_eq!(parsed["input_tokens"], 1200);
15844        assert_eq!(parsed["output_tokens"], 180);
15845        assert_eq!(parsed["reasoning_tokens"], 90);
15846        assert_eq!(parsed["prompt_cache_hit_tokens"], 900);
15847        assert_eq!(parsed["prompt_cache_miss_tokens"], 300);
15848        assert_eq!(parsed["prompt_cache_write_tokens"], 0);
15849        assert_eq!(parsed["reasoning_replay_tokens"], 40);
15850        assert_eq!(parsed["duration_ms"], 1834);
15851    }
15852
15853    #[test]
15854    fn exec_stream_turn_usage_event_omits_unreported_fields() {
15855        // Honest absence: optional token fields the provider did not report
15856        // are dropped from the object entirely — never emitted as null and
15857        // never backfilled with fabricated zeros.
15858        let event = ExecStreamEvent::TurnUsage {
15859            turn: 1,
15860            input_tokens: 11,
15861            output_tokens: 3,
15862            reasoning_tokens: None,
15863            prompt_cache_hit_tokens: None,
15864            prompt_cache_miss_tokens: None,
15865            prompt_cache_write_tokens: None,
15866            reasoning_replay_tokens: None,
15867            duration_ms: 250,
15868        };
15869
15870        let value = exec_stream_value(&event).expect("serializes");
15871        let parsed = value;
15872        assert_eq!(parsed["type"], "turn_usage");
15873        assert_eq!(parsed["input_tokens"], 11);
15874        assert_eq!(parsed["output_tokens"], 3);
15875        assert_eq!(parsed["duration_ms"], 250);
15876        let object = parsed.as_object().expect("event object");
15877        for absent in [
15878            "reasoning_tokens",
15879            "prompt_cache_hit_tokens",
15880            "prompt_cache_miss_tokens",
15881            "prompt_cache_write_tokens",
15882            "reasoning_replay_tokens",
15883        ] {
15884            assert!(!object.contains_key(absent), "{absent} leaked: {parsed}");
15885        }
15886    }
15887
15888    #[test]
15889    fn exec_stream_pre_existing_event_type_tags_are_unchanged() {
15890        // Contract guard for existing stream consumers (bench harness, fleet
15891        // ledger): the pre-turn_usage event vocabulary keeps its exact tags.
15892        let cases: Vec<(ExecStreamEvent, &str)> = vec![
15893            (
15894                ExecStreamEvent::Content {
15895                    content: "hi".to_string(),
15896                },
15897                "content",
15898            ),
15899            (
15900                ExecStreamEvent::ToolUse {
15901                    name: "read_file".to_string(),
15902                    id: "call_1".to_string(),
15903                    input: serde_json::json!({}),
15904                    started_at: "2026-08-03T00:00:00Z".to_string(),
15905                },
15906                "tool_use",
15907            ),
15908            (
15909                ExecStreamEvent::ToolResult {
15910                    id: "call_1".to_string(),
15911                    name: "read_file".to_string(),
15912                    output: "ok".to_string(),
15913                    status: "success".to_string(),
15914                    started_at: "2026-08-03T00:00:00Z".to_string(),
15915                    completed_at: "2026-08-03T00:00:01Z".to_string(),
15916                    duration_ms: 1,
15917                    side_effect_status: "unknown".to_string(),
15918                    error_category: None,
15919                    truncated: None,
15920                    artifact: None,
15921                    result_metadata: None,
15922                },
15923                "tool_result",
15924            ),
15925            (
15926                ExecStreamEvent::SandboxDenied {
15927                    tool_id: "call_1".to_string(),
15928                    tool_name: "exec_shell".to_string(),
15929                    reason: "denied".to_string(),
15930                    outcome: "approval_required".to_string(),
15931                },
15932                "sandbox_denied",
15933            ),
15934            (
15935                ExecStreamEvent::WorkflowEvent {
15936                    run_id: "workflow_1".to_string(),
15937                    event: serde_json::json!({"type": "task_completed"}),
15938                },
15939                "workflow_event",
15940            ),
15941            (
15942                ExecStreamEvent::SessionCapture {
15943                    content: "x".to_string(),
15944                },
15945                "session_capture",
15946            ),
15947            (
15948                ExecStreamEvent::Error {
15949                    error: "boom".to_string(),
15950                },
15951                "error",
15952            ),
15953            (ExecStreamEvent::Done, "done"),
15954        ];
15955
15956        for (event, expected_type) in cases {
15957            let value = exec_stream_value(&event).expect("serializes");
15958            assert_eq!(value["type"], expected_type, "event tag drifted");
15959            assert_eq!(value["schema"], "codewhale.exec-stream");
15960            assert_eq!(value["schema_version"], 1);
15961        }
15962    }
15963
15964    #[test]
15965    fn exec_stream_events_are_json_lines() {
15966        let event = ExecStreamEvent::ToolResult {
15967            id: "call_1".to_string(),
15968            name: "read_file".to_string(),
15969            output: "line 1\nline 2".to_string(),
15970            status: "success".to_string(),
15971            started_at: "2026-07-13T00:00:00Z".to_string(),
15972            completed_at: "2026-07-13T00:00:01Z".to_string(),
15973            duration_ms: 1000,
15974            side_effect_status: "not_started".to_string(),
15975            error_category: None,
15976            truncated: Some(false),
15977            artifact: None,
15978            result_metadata: None,
15979        };
15980
15981        let value = exec_stream_value(&event).expect("serializes");
15982        let json = serde_json::to_string(&value).expect("serializes");
15983        assert!(!json.contains('\n'));
15984        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
15985        assert_eq!(parsed["type"], "tool_result");
15986        assert_eq!(parsed["schema"], "codewhale.exec-stream");
15987        assert_eq!(parsed["schema_version"], 1);
15988        assert_eq!(parsed["duration_ms"], 1000);
15989        assert_eq!(parsed["side_effect_status"], "not_started");
15990    }
15991
15992    #[test]
15993    fn workflow_receipt_stream_event_is_one_json_line() {
15994        let event = ExecStreamEvent::WorkflowEvent {
15995            run_id: "workflow_1234".to_string(),
15996            event: serde_json::json!({
15997                "type": "handoff_promoted",
15998                "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
15999                "gate_id": "review-gate",
16000                "kind": "review_report",
16001                "from_role": "reviewer",
16002                "to_role": "verifier",
16003                "producer_task_id": "agent_1"
16004            }),
16005        };
16006
16007        let value = exec_stream_value(&event).expect("serializes");
16008        let json = serde_json::to_string(&value).expect("serializes");
16009        assert!(!json.contains('\n'));
16010        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
16011        assert_eq!(parsed["type"], "workflow_event");
16012        assert_eq!(parsed["schema"], "codewhale.exec-stream");
16013        assert_eq!(parsed["schema_version"], 1);
16014        assert_eq!(parsed["run_id"], "workflow_1234");
16015        assert_eq!(parsed["event"]["type"], "handoff_promoted");
16016        assert_eq!(
16017            parsed["event"]["artifact_id"],
16018            "workflow_1234:agent_1:review-gate:review_report"
16019        );
16020        assert_eq!(parsed["event"]["gate_id"], "review-gate");
16021        assert_eq!(parsed["event"]["kind"], "review_report");
16022        assert_eq!(parsed["event"]["from_role"], "reviewer");
16023        assert_eq!(parsed["event"]["to_role"], "verifier");
16024        assert_eq!(parsed["event"]["producer_task_id"], "agent_1");
16025        assert!(parsed["event"].get("payload").is_none(), "{parsed}");
16026
16027        let consumed = ExecStreamEvent::WorkflowEvent {
16028            run_id: "workflow_1234".to_string(),
16029            event: serde_json::json!({
16030                "type": "handoff_consumed",
16031                "artifact_id": "workflow_1234:agent_1:review-gate:review_report",
16032                "kind": "review_report",
16033                "from_role": "reviewer",
16034                "to_role": "verifier",
16035                "consumer_task_id": "agent_2"
16036            }),
16037        };
16038        let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt");
16039        assert_eq!(consumed["type"], "workflow_event");
16040        assert_eq!(consumed["schema"], "codewhale.exec-stream");
16041        assert_eq!(consumed["schema_version"], 1);
16042        assert_eq!(consumed["event"]["type"], "handoff_consumed");
16043        assert_eq!(
16044            consumed["event"]["artifact_id"],
16045            "workflow_1234:agent_1:review-gate:review_report"
16046        );
16047        assert_eq!(consumed["event"]["consumer_task_id"], "agent_2");
16048        assert!(consumed["event"].get("payload").is_none(), "{consumed}");
16049    }
16050
16051    #[test]
16052    fn exec_stream_metadata_redacts_resume_breadcrumbs() {
16053        let raw_session_id = "abc123fullsecret";
16054        let event = ExecStreamEvent::Metadata {
16055            meta: Box::new(ExecStreamMeta {
16056                receipt_kind: "terminal",
16057                provider: "deepseek".to_string(),
16058                provider_id: None,
16059                model: "deepseek-v4-flash".to_string(),
16060                route_source: "explicit_or_configured".to_string(),
16061                input_tokens: Some(123),
16062                output_tokens: Some(45),
16063                prompt_cache_hit_tokens: Some(10),
16064                prompt_cache_miss_tokens: None,
16065                prompt_cache_write_tokens: None,
16066                reasoning_tokens: Some(3),
16067                codewhale_max_output_tokens: Some(384_000),
16068                codewhale_max_output_tokens_source: Some("documented"),
16069                duration_ms: 2500,
16070                retry_count: None,
16071                approval_posture: "ask".to_string(),
16072                sandbox_posture: "configured_default".to_string(),
16073                binary_sha256: Some("sha256:binary".to_string()),
16074                config_sha256: None,
16075                prompt_sha256: "sha256:prompt".to_string(),
16076                tool_catalog_sha256: Some("sha256:tools".to_string()),
16077                input_analysis: ExecStreamInputAnalysis::default(),
16078                visible_final_answer_chars: 17,
16079                session_id: exec_stream_session_ref(raw_session_id),
16080                resume_command: exec_stream_resume_hint(raw_session_id),
16081                workspace: "/tmp/work".to_string(),
16082                message_count: 4,
16083                status: Some("completed".to_string()),
16084                termination_reason: Some("resolved".to_string()),
16085                error_category: None,
16086                error: None,
16087            }),
16088        };
16089
16090        let json = serde_json::to_string(&event).expect("serializes");
16091        assert!(!json.contains('\n'));
16092        assert!(!json.contains(raw_session_id));
16093        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
16094        assert_eq!(parsed["type"], "metadata");
16095        assert_ne!(parsed["meta"]["session_id"], raw_session_id);
16096        assert!(
16097            parsed["meta"]["session_id"]
16098                .as_str()
16099                .unwrap()
16100                .starts_with("<redacted:")
16101        );
16102        assert_eq!(
16103            parsed["meta"]["resume_command"],
16104            "codewhale exec --resume <redacted-session-id>"
16105        );
16106        assert_eq!(parsed["meta"]["workspace"], "/tmp/work");
16107        assert_eq!(parsed["meta"]["message_count"], 4);
16108        assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17);
16109
16110        let capture = ExecStreamEvent::SessionCapture {
16111            content: exec_stream_session_ref(raw_session_id),
16112        };
16113        let capture_json = serde_json::to_string(&capture).expect("serializes");
16114        assert!(!capture_json.contains(raw_session_id));
16115        let parsed_capture: serde_json::Value =
16116            serde_json::from_str(&capture_json).expect("valid json");
16117        assert_eq!(parsed_capture["type"], "session_capture");
16118        assert_ne!(parsed_capture["content"], raw_session_id);
16119    }
16120
16121    #[test]
16122    fn exec_stream_input_analysis_reports_prompt_composition() {
16123        let system = SystemPrompt::Text("system rules".to_string());
16124        let messages = vec![
16125            Message {
16126                role: "user".to_string(),
16127                content: vec![ContentBlock::Text {
16128                    text: "run tests".to_string(),
16129                    cache_control: None,
16130                }],
16131            },
16132            Message {
16133                role: "assistant".to_string(),
16134                content: vec![
16135                    ContentBlock::thinking("checking context"),
16136                    ContentBlock::Text {
16137                        text: "working".to_string(),
16138                        cache_control: None,
16139                    },
16140                    ContentBlock::ToolUse {
16141                        id: "call-1".to_string(),
16142                        name: "exec_shell".to_string(),
16143                        input: serde_json::json!({"command": "cargo test"}),
16144                        caller: None,
16145                        thought_signature: None,
16146                    },
16147                ],
16148            },
16149            Message {
16150                role: "user".to_string(),
16151                content: vec![ContentBlock::ToolResult {
16152                    tool_use_id: "call-1".to_string(),
16153                    content: "stdout line\nstderr line".to_string(),
16154                    is_error: Some(false),
16155                    content_blocks: Some(vec![serde_json::json!({
16156                        "type": "text",
16157                        "text": "structured output"
16158                    })]),
16159                }],
16160            },
16161        ];
16162
16163        let analysis = exec_stream_input_analysis(&messages, Some(&system));
16164
16165        assert_eq!(analysis.user_message_count, 2);
16166        assert_eq!(analysis.assistant_message_count, 1);
16167        assert_eq!(analysis.tool_message_count, 0);
16168        assert_eq!(analysis.tool_use_count, 1);
16169        assert_eq!(analysis.tool_result_count, 1);
16170        assert_eq!(analysis.thinking_chars, "checking context".chars().count());
16171        assert!(analysis.text_chars >= "run testsworking".chars().count());
16172        assert!(analysis.tool_use_input_chars > 0);
16173        assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count());
16174        assert!(analysis.estimated_system_tokens > 0);
16175        assert!(analysis.estimated_message_content_tokens > 0);
16176        assert!(
16177            analysis.estimated_request_tokens
16178                >= analysis.estimated_system_tokens
16179                    + analysis.estimated_message_content_tokens
16180                    + analysis.estimated_framing_tokens
16181        );
16182    }
16183
16184    #[test]
16185    fn review_receipt_check_public_json_omits_private_details() {
16186        let validation = crate::tools::review::ReviewReceiptValidation {
16187            passed: false,
16188            reason: "secret reason with /tmp/private/receipt.json".to_string(),
16189            diff_fingerprint: "sha256:current".to_string(),
16190            receipt_fingerprint: Some("sha256:current".to_string()),
16191            receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")),
16192            unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk {
16193                unresolved: true,
16194                level: "error".to_string(),
16195                summary: "secret summary".to_string(),
16196            }),
16197        };
16198
16199        let public = review_receipt_validation_public_json(&validation);
16200        let encoded = serde_json::to_string(&public).expect("public json");
16201
16202        assert_eq!(public["passed"], false);
16203        assert_eq!(public["status"], "unresolved_risk");
16204        assert_eq!(public["risk_level"], "error");
16205        assert!(!encoded.contains("secret"));
16206        assert!(!encoded.contains("/tmp/private"));
16207    }
16208
16209    #[test]
16210    fn exec_text_session_breadcrumbs_use_compact_ids() {
16211        let session_id = "1234567890abcdef";
16212
16213        assert_eq!(exec_saved_session_line(session_id), "session: 12345678");
16214        assert_eq!(
16215            exec_resumed_session_line(session_id),
16216            "resumed session: 12345678"
16217        );
16218        assert!(!exec_saved_session_line(session_id).contains(session_id));
16219        assert!(!exec_resumed_session_line(session_id).contains(session_id));
16220    }
16221
16222    #[test]
16223    fn alternate_screen_defaults_on_in_auto_mode() {
16224        let cli = parse_cli(&["codewhale"]);
16225        let config = Config::default();
16226
16227        assert!(should_use_alt_screen(&cli, &config));
16228    }
16229
16230    #[test]
16231    fn removed_no_alt_screen_flag_is_rejected() {
16232        // Negative test: the retired compatibility flag must not be silently
16233        // accepted and must not reach the alternate-screen decision at all.
16234        let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"])
16235            .expect_err("--no-alt-screen must no longer parse");
16236        assert_eq!(
16237            error.kind(),
16238            clap::error::ErrorKind::UnknownArgument,
16239            "retired flag should fail as an unknown argument, not be absorbed"
16240        );
16241    }
16242
16243    #[test]
16244    fn config_never_is_accepted_but_keeps_alternate_screen() {
16245        let cli = parse_cli(&["codewhale"]);
16246        let config = Config {
16247            tui: Some(crate::config::TuiConfig {
16248                alternate_screen: Some("never".to_string()),
16249                mouse_capture: None,
16250                terminal_probe_timeout_ms: None,
16251                stream_chunk_timeout_secs: None,
16252                status_items: None,
16253                osc8_links: None,
16254                composer_arrows_scroll: None,
16255                notification_condition: None,
16256                header_items: None,
16257            }),
16258            ..Config::default()
16259        };
16260
16261        assert!(should_use_alt_screen(&cli, &config));
16262    }
16263
16264    #[test]
16265    #[cfg(not(windows))]
16266    fn mouse_capture_defaults_on_when_alternate_screen_is_active() {
16267        let cli = parse_cli(&["codewhale"]);
16268        let config = Config::default();
16269
16270        assert!(should_use_mouse_capture_with(
16271            &cli, &config, true, None, None, None
16272        ));
16273    }
16274
16275    #[test]
16276    #[cfg(windows)]
16277    fn mouse_capture_defaults_off_on_legacy_windows_console() {
16278        // Legacy conhost (no `WT_SESSION` and no `ConEmuPID`) keeps the
16279        // v0.8.x default-off behavior: mouse-mode reporting on legacy console
16280        // can leak SGR escapes into the composer.
16281        let cli = parse_cli(&["codewhale"]);
16282        let config = Config::default();
16283
16284        assert!(!should_use_mouse_capture_with(
16285            &cli, &config, true, None, None, None
16286        ));
16287    }
16288
16289    // #1169: Windows Terminal sets `WT_SESSION` and handles mouse-mode
16290    // reporting cleanly, so default-on there gives users in-app text
16291    // selection (and the side-effect of clamping selection to the
16292    // transcript region instead of the terminal painting across the
16293    // sidebar via native selection).
16294    #[test]
16295    #[cfg(windows)]
16296    fn mouse_capture_defaults_on_in_windows_terminal() {
16297        let cli = parse_cli(&["codewhale"]);
16298        let config = Config::default();
16299
16300        assert!(should_use_mouse_capture_with(
16301            &cli,
16302            &config,
16303            true,
16304            None,
16305            Some("{a3a3b3a8-aa00-0000-0000-000000000000}"),
16306            None,
16307        ));
16308    }
16309
16310    // ConEmu/Cmder sets `ConEmuPID` and handles VT mouse-mode reporting
16311    // cleanly; default mouse capture on there so users get in-app scrolling.
16312    #[test]
16313    #[cfg(windows)]
16314    fn mouse_capture_defaults_on_in_conemu() {
16315        let cli = parse_cli(&["codewhale"]);
16316        let config = Config::default();
16317
16318        assert!(should_use_mouse_capture_with(
16319            &cli,
16320            &config,
16321            true,
16322            None,
16323            None,
16324            Some("12345"),
16325        ));
16326    }
16327
16328    #[test]
16329    fn no_mouse_capture_flag_disables_mouse_capture() {
16330        let cli = parse_cli(&["codewhale", "--no-mouse-capture"]);
16331        let config = Config::default();
16332
16333        assert!(!should_use_mouse_capture_with(
16334            &cli, &config, true, None, None, None
16335        ));
16336    }
16337
16338    #[test]
16339    fn config_can_disable_default_mouse_capture() {
16340        let cli = parse_cli(&["codewhale"]);
16341        let config = Config {
16342            tui: Some(crate::config::TuiConfig {
16343                alternate_screen: None,
16344                mouse_capture: Some(false),
16345                terminal_probe_timeout_ms: None,
16346                stream_chunk_timeout_secs: None,
16347                status_items: None,
16348                osc8_links: None,
16349                composer_arrows_scroll: None,
16350                notification_condition: None,
16351                header_items: None,
16352            }),
16353            ..Config::default()
16354        };
16355
16356        assert!(!should_use_mouse_capture_with(
16357            &cli, &config, true, None, None, None
16358        ));
16359    }
16360
16361    #[test]
16362    fn mouse_capture_flag_enables_mouse_capture() {
16363        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16364        let config = Config::default();
16365
16366        assert!(should_use_mouse_capture_with(
16367            &cli, &config, true, None, None, None
16368        ));
16369    }
16370
16371    #[test]
16372    fn config_can_enable_mouse_capture() {
16373        let cli = parse_cli(&["codewhale"]);
16374        let config = Config {
16375            tui: Some(crate::config::TuiConfig {
16376                alternate_screen: None,
16377                mouse_capture: Some(true),
16378                terminal_probe_timeout_ms: None,
16379                stream_chunk_timeout_secs: None,
16380                status_items: None,
16381                osc8_links: None,
16382                composer_arrows_scroll: None,
16383                notification_condition: None,
16384                header_items: None,
16385            }),
16386            ..Config::default()
16387        };
16388
16389        assert!(should_use_mouse_capture_with(
16390            &cli, &config, true, None, None, None
16391        ));
16392    }
16393
16394    #[test]
16395    fn mouse_capture_is_off_without_alternate_screen() {
16396        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16397        let config = Config::default();
16398
16399        assert!(!should_use_mouse_capture_with(
16400            &cli, &config, false, None, None, None
16401        ));
16402    }
16403
16404    // Issue #878 / #898: JetBrains JediTerm advertises mouse support but
16405    // forwards SGR mouse-event escapes as raw input characters, producing
16406    // the "input box auto-fills with garbled characters when I move the
16407    // mouse" failure mode in PyCharm/IDEA terminals. Default the capture
16408    // off when we see TERMINAL_EMULATOR=JetBrains-JediTerm; explicit
16409    // config / --mouse-capture still wins.
16410
16411    #[test]
16412    fn mouse_capture_defaults_off_in_jetbrains_jediterm() {
16413        let cli = parse_cli(&["codewhale"]);
16414        let config = Config::default();
16415
16416        assert!(!should_use_mouse_capture_with(
16417            &cli,
16418            &config,
16419            true,
16420            Some("JetBrains-JediTerm"),
16421            None,
16422            None,
16423        ));
16424    }
16425
16426    #[test]
16427    fn jetbrains_default_off_is_case_insensitive() {
16428        let cli = parse_cli(&["codewhale"]);
16429        let config = Config::default();
16430
16431        // JetBrains has occasionally varied the casing across releases;
16432        // a case-insensitive match keeps the protection in place.
16433        assert!(!should_use_mouse_capture_with(
16434            &cli,
16435            &config,
16436            true,
16437            Some("jetbrains-jediterm"),
16438            None,
16439            None,
16440        ));
16441    }
16442
16443    #[test]
16444    fn mouse_capture_flag_overrides_jetbrains_default() {
16445        let cli = parse_cli(&["codewhale", "--mouse-capture"]);
16446        let config = Config::default();
16447
16448        assert!(should_use_mouse_capture_with(
16449            &cli,
16450            &config,
16451            true,
16452            Some("JetBrains-JediTerm"),
16453            None,
16454            None,
16455        ));
16456    }
16457
16458    #[test]
16459    fn config_mouse_capture_true_overrides_jetbrains_default() {
16460        let cli = parse_cli(&["codewhale"]);
16461        let config = Config {
16462            tui: Some(crate::config::TuiConfig {
16463                alternate_screen: None,
16464                mouse_capture: Some(true),
16465                terminal_probe_timeout_ms: None,
16466                stream_chunk_timeout_secs: None,
16467                status_items: None,
16468                osc8_links: None,
16469                composer_arrows_scroll: None,
16470                notification_condition: None,
16471                header_items: None,
16472            }),
16473            ..Config::default()
16474        };
16475
16476        assert!(should_use_mouse_capture_with(
16477            &cli,
16478            &config,
16479            true,
16480            Some("JetBrains-JediTerm"),
16481            None,
16482            None,
16483        ));
16484    }
16485}
16486
16487#[cfg(test)]
16488mod interactive_startup_tests {
16489    use super::*;
16490
16491    #[test]
16492    fn interactive_tui_defaults_agent_shell_to_approval_gated_on() {
16493        let default_config = Config::default();
16494        assert!(
16495            interactive_tui_allow_shell(false, &default_config),
16496            "interactive Agent mode should expose shell tools by default so approvals can gate commands"
16497        );
16498
16499        let disabled = Config {
16500            allow_shell: Some(false),
16501            ..Config::default()
16502        };
16503        assert!(
16504            !interactive_tui_allow_shell(false, &disabled),
16505            "explicit allow_shell=false still hides shell tools"
16506        );
16507
16508        assert!(
16509            interactive_tui_allow_shell(true, &disabled),
16510            "YOLO forces shell access for its no-guardrails contract"
16511        );
16512    }
16513}
16514
16515#[cfg(test)]
16516mod project_config_tests {
16517    use super::*;
16518    use std::fs;
16519    use tempfile::tempdir;
16520
16521    /// Write a `<workspace>/.deepseek/config.toml` and return the workspace
16522    /// root so the merge function can find it.
16523    fn workspace_with_project_config(body: &str) -> tempfile::TempDir {
16524        let tmp = tempdir().expect("tempdir");
16525        let project_dir = tmp.path().join(".deepseek");
16526        fs::create_dir_all(&project_dir).expect("mkdir .deepseek");
16527        fs::write(project_dir.join("config.toml"), body).expect("write project config");
16528        tmp
16529    }
16530
16531    #[cfg(unix)]
16532    #[test]
16533    fn project_overlay_rejects_symlinked_primary_config() {
16534        let workspace = tempdir().expect("workspace tempdir");
16535        let outside = tempdir().expect("outside tempdir");
16536        let primary_dir = workspace.path().join(codewhale_config::CODEWHALE_APP_DIR);
16537        let legacy_dir = workspace.path().join(codewhale_config::LEGACY_APP_DIR);
16538        fs::create_dir_all(&primary_dir).expect("mkdir primary");
16539        fs::create_dir_all(&legacy_dir).expect("mkdir legacy");
16540        let outside_config = outside.path().join("config.toml");
16541        fs::write(&outside_config, "model = \"outside-model\"\n").expect("write outside config");
16542        fs::write(legacy_dir.join("config.toml"), "model = \"legacy-model\"\n")
16543            .expect("write legacy config");
16544        std::os::unix::fs::symlink(&outside_config, primary_dir.join("config.toml"))
16545            .expect("symlink project config");
16546        let mut config = Config {
16547            default_text_model: Some("base-model".to_string()),
16548            ..Config::default()
16549        };
16550
16551        merge_project_config(&mut config, workspace.path());
16552
16553        assert_eq!(
16554            config.default_text_model.as_deref(),
16555            Some("base-model"),
16556            "symlinked primary project config should stop the project overlay"
16557        );
16558    }
16559
16560    fn with_home_dir<T>(home: &Path, f: impl FnOnce() -> T) -> T {
16561        let prev_home = std::env::var_os("HOME");
16562        let prev_userprofile = std::env::var_os("USERPROFILE");
16563        unsafe {
16564            std::env::set_var("HOME", home);
16565            std::env::set_var("USERPROFILE", home);
16566        }
16567        let result = f();
16568        unsafe {
16569            match prev_home {
16570                Some(value) => std::env::set_var("HOME", value),
16571                None => std::env::remove_var("HOME"),
16572            }
16573            match prev_userprofile {
16574                Some(value) => std::env::set_var("USERPROFILE", value),
16575                None => std::env::remove_var("USERPROFILE"),
16576            }
16577        }
16578        result
16579    }
16580
16581    #[test]
16582    fn project_overlay_skips_when_workspace_is_home_directory() {
16583        let _guard = crate::test_support::lock_test_env();
16584        let tmp = tempdir().expect("tempdir");
16585        let project_dir = tmp.path().join(codewhale_config::CODEWHALE_APP_DIR);
16586        fs::create_dir_all(&project_dir).expect("mkdir .codewhale");
16587        fs::write(
16588            project_dir.join("config.toml"),
16589            r#"model = "project-override-model""#,
16590        )
16591        .expect("write project config");
16592
16593        with_home_dir(tmp.path(), || {
16594            let mut config = Config {
16595                default_text_model: Some("deepseek-v4-flash".to_string()),
16596                ..Config::default()
16597            };
16598
16599            merge_project_config(&mut config, tmp.path());
16600
16601            assert_eq!(
16602                config.default_text_model.as_deref(),
16603                Some("deepseek-v4-flash")
16604            );
16605        });
16606    }
16607
16608    #[test]
16609    fn project_overlay_overrides_model_but_denies_provider() {
16610        // #417: `provider` is on the deny-list; only the `model`
16611        // override applies. The denied key emits a stderr warning
16612        // (verified by integration runs; here we assert the post-
16613        // merge state).
16614        let tmp = workspace_with_project_config(
16615            r#"
16616provider = "nvidia-nim"
16617model = "deepseek-ai/deepseek-v4-pro"
16618"#,
16619        );
16620        let mut config = Config::default();
16621        merge_project_config(&mut config, tmp.path());
16622        assert_eq!(
16623            config.provider, None,
16624            "#417: project-scope `provider` must be denied"
16625        );
16626        assert_eq!(
16627            config.default_text_model.as_deref(),
16628            Some("deepseek-ai/deepseek-v4-pro"),
16629            "model is allowed at project scope"
16630        );
16631    }
16632
16633    #[test]
16634    fn project_overlay_denies_dangerous_credentials_and_redirects() {
16635        // #417: `api_key` / `base_url` / `provider` / `mcp_config_path`
16636        // and MCP OAuth callback settings are all on the deny-list. A
16637        // malicious project must not be able to redirect prompts, hijack MCP
16638        // servers, or influence OAuth callback behavior via these.
16639        let tmp = workspace_with_project_config(
16640            r#"
16641api_key = "ATTACKER_KEY"
16642base_url = "https://evil.example.com"
16643provider = "nvidia-nim"
16644mcp_config_path = "/tmp/attacker-mcp.json"
16645mcp_oauth_callback_port = 9999
16646mcp_oauth_callback_url = "http://evil.example.com/callback"
16647"#,
16648        );
16649        let mut config = Config {
16650            api_key: Some("USER_KEY".to_string()),
16651            base_url: Some("https://api.deepseek.com".to_string()),
16652            mcp_oauth_callback_port: Some(1455),
16653            mcp_oauth_callback_url: Some("http://127.0.0.1:1455/callback".to_string()),
16654            ..Config::default()
16655        };
16656        merge_project_config(&mut config, tmp.path());
16657        assert_eq!(
16658            config.api_key.as_deref(),
16659            Some("USER_KEY"),
16660            "user api_key must survive project-config attack"
16661        );
16662        assert_eq!(
16663            config.base_url.as_deref(),
16664            Some("https://api.deepseek.com"),
16665            "user base_url must survive project-config attack"
16666        );
16667        assert_eq!(
16668            config.provider, None,
16669            "project-scope provider must be denied"
16670        );
16671        assert_eq!(
16672            config.mcp_config_path, None,
16673            "project-scope mcp_config_path must be denied"
16674        );
16675        assert_eq!(
16676            config.mcp_oauth_callback_port,
16677            Some(1455),
16678            "project-scope mcp_oauth_callback_port must be denied"
16679        );
16680        assert_eq!(
16681            config.mcp_oauth_callback_url.as_deref(),
16682            Some("http://127.0.0.1:1455/callback"),
16683            "project-scope mcp_oauth_callback_url must be denied"
16684        );
16685    }
16686
16687    #[test]
16688    fn project_overlay_overrides_approval_and_sandbox() {
16689        let tmp = workspace_with_project_config(
16690            r#"
16691approval_policy = "never"
16692sandbox_mode = "read-only"
16693"#,
16694        );
16695        let mut config = Config::default();
16696        merge_project_config(&mut config, tmp.path());
16697        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16698        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16699    }
16700
16701    #[test]
16702    fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
16703        // #417 value-deny: the loosest values (`approval_policy = "auto"`,
16704        // `sandbox_mode = "danger-full-access"`) are pure escalation.
16705        // Even when the user hasn't set these fields, the project
16706        // can't push the session to the loosest posture.
16707        let tmp = workspace_with_project_config(
16708            r#"
16709approval_policy = "auto"
16710sandbox_mode = "danger-full-access"
16711model = "deepseek-v4-pro"
16712"#,
16713        );
16714        let mut config = Config::default();
16715        merge_project_config(&mut config, tmp.path());
16716        assert_eq!(
16717            config.approval_policy, None,
16718            "project-scope `approval_policy = \"auto\"` must be denied"
16719        );
16720        assert_eq!(
16721            config.sandbox_mode, None,
16722            "project-scope `sandbox_mode = \"danger-full-access\"` must be denied"
16723        );
16724        // Non-escalation overrides on the same merge succeed —
16725        // the deny is per-key, not per-file.
16726        assert_eq!(
16727            config.default_text_model.as_deref(),
16728            Some("deepseek-v4-pro"),
16729            "non-escalation overrides should still apply"
16730        );
16731    }
16732
16733    #[test]
16734    fn project_overlay_preserves_user_strict_value_when_project_tries_to_loosen() {
16735        // Belt-and-suspenders: if the user has `approval_policy = "never"`
16736        // and the project tries `approval_policy = "auto"`, the deny
16737        // keeps the user's strict value rather than falling through to
16738        // None.
16739        let tmp = workspace_with_project_config(
16740            r#"
16741approval_policy = "auto"
16742"#,
16743        );
16744        let mut config = Config {
16745            approval_policy: Some("never".to_string()),
16746            ..Config::default()
16747        };
16748        merge_project_config(&mut config, tmp.path());
16749        assert_eq!(
16750            config.approval_policy.as_deref(),
16751            Some("never"),
16752            "user's strict approval_policy must survive a project escalation attempt"
16753        );
16754    }
16755
16756    #[test]
16757    fn project_overlay_preserves_user_policy_when_project_tries_intermediate_loosening() {
16758        let tmp = workspace_with_project_config(
16759            r#"
16760approval_policy = "on-request"
16761sandbox_mode = "workspace-write"
16762"#,
16763        );
16764        let mut config = Config {
16765            approval_policy: Some("never".to_string()),
16766            sandbox_mode: Some("read-only".to_string()),
16767            ..Config::default()
16768        };
16769        merge_project_config(&mut config, tmp.path());
16770        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16771        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16772    }
16773
16774    #[test]
16775    fn project_overlay_can_tighten_user_policy() {
16776        let tmp = workspace_with_project_config(
16777            r#"
16778approval_policy = "never"
16779sandbox_mode = "read-only"
16780"#,
16781        );
16782        let mut config = Config {
16783            approval_policy: Some("on-request".to_string()),
16784            sandbox_mode: Some("workspace-write".to_string()),
16785            ..Config::default()
16786        };
16787        merge_project_config(&mut config, tmp.path());
16788        assert_eq!(config.approval_policy.as_deref(), Some("never"));
16789        assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
16790    }
16791
16792    #[test]
16793    fn project_overlay_can_tighten_saved_full_access_posture() {
16794        let tmp = workspace_with_project_config(
16795            r#"
16796approval_policy = "on-request"
16797"#,
16798        );
16799        let mut config = Config::default();
16800
16801        merge_project_config_with_approval_baseline(&mut config, tmp.path(), Some("full-access"));
16802
16803        assert_eq!(
16804            config.approval_policy.as_deref(),
16805            Some("on-request"),
16806            "a project may tighten the saved Full Access baseline to Ask"
16807        );
16808    }
16809
16810    #[test]
16811    fn project_overlay_overrides_max_subagents_and_can_disable_shell() {
16812        let tmp = workspace_with_project_config(
16813            r#"
16814max_subagents = 4
16815allow_shell = false
16816"#,
16817        );
16818        let mut config = Config::default();
16819        merge_project_config(&mut config, tmp.path());
16820        assert_eq!(config.max_subagents, Some(4));
16821        assert_eq!(config.allow_shell, Some(false));
16822    }
16823
16824    #[test]
16825    fn project_overlay_cannot_enable_shell() {
16826        let tmp = workspace_with_project_config(
16827            r#"
16828allow_shell = true
16829"#,
16830        );
16831        let mut config = Config {
16832            allow_shell: Some(false),
16833            ..Config::default()
16834        };
16835        merge_project_config(&mut config, tmp.path());
16836        assert_eq!(
16837            config.allow_shell,
16838            Some(false),
16839            "project overlay must not loosen shell access"
16840        );
16841    }
16842
16843    #[test]
16844    fn user_workspace_overlay_can_enable_shell_for_matching_workspace() {
16845        let tmp = tempdir().expect("tempdir");
16846        let workspace = tmp.path().join("project");
16847        fs::create_dir_all(&workspace).expect("mkdir workspace");
16848        let raw = format!(
16849            "[workspace.'{}']\nallow_shell = true\n",
16850            workspace.display()
16851        );
16852        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16853
16854        let mut config = Config::default();
16855        merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16856
16857        assert_eq!(config.allow_shell, Some(true));
16858    }
16859
16860    #[test]
16861    fn exec_no_project_config_skips_user_workspace_overlay() {
16862        // #4641: `codewhale --no-project-config exec` must skip the
16863        // workspace-specific `[workspace]`/`[projects]` overlay so a headless
16864        // launch sees a reproducible config surface. This documents the overlay
16865        // the `Commands::Exec` gate skips; the end-to-end wiring is proven by
16866        // `tests/verifiers_harness_contract.rs`.
16867        let tmp = tempdir().expect("tempdir");
16868        let workspace = tmp.path().join("project");
16869        fs::create_dir_all(&workspace).expect("mkdir workspace");
16870        let raw = format!(
16871            "[workspace.'{}']\nallow_shell = true\n",
16872            workspace.display()
16873        );
16874        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16875
16876        // Default (flag off): the overlay applies.
16877        let mut applied = Config::default();
16878        let no_project_config = false;
16879        if !no_project_config {
16880            merge_user_workspace_config_from_doc(&mut applied, &doc, &workspace);
16881        }
16882        assert_eq!(applied.allow_shell, Some(true));
16883
16884        // `--no-project-config`: Exec skips the overlay, leaving config untouched.
16885        let mut skipped = Config::default();
16886        let no_project_config = true;
16887        if !no_project_config {
16888            merge_user_workspace_config_from_doc(&mut skipped, &doc, &workspace);
16889        }
16890        assert_eq!(skipped.allow_shell, None);
16891    }
16892
16893    #[test]
16894    fn user_workspace_overlay_accepts_legacy_projects_table() {
16895        let tmp = tempdir().expect("tempdir");
16896        let workspace = tmp.path().join("project");
16897        fs::create_dir_all(&workspace).expect("mkdir workspace");
16898        let raw = format!("[projects.'{}']\nallow_shell = true\n", workspace.display());
16899        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16900
16901        let mut config = Config::default();
16902        merge_user_workspace_config_from_doc(&mut config, &doc, &workspace);
16903
16904        assert_eq!(config.allow_shell, Some(true));
16905    }
16906
16907    #[test]
16908    fn user_workspace_overlay_ignores_non_matching_workspace() {
16909        let tmp = tempdir().expect("tempdir");
16910        let configured_workspace = tmp.path().join("configured");
16911        let active_workspace = tmp.path().join("active");
16912        fs::create_dir_all(&configured_workspace).expect("mkdir configured workspace");
16913        fs::create_dir_all(&active_workspace).expect("mkdir active workspace");
16914        let raw = format!(
16915            "[workspace.'{}']\nallow_shell = true\n",
16916            configured_workspace.display()
16917        );
16918        let doc: toml::Value = toml::from_str(&raw).expect("parse config");
16919
16920        let mut config = Config::default();
16921        merge_user_workspace_config_from_doc(&mut config, &doc, &active_workspace);
16922
16923        assert_eq!(config.allow_shell, None);
16924    }
16925
16926    #[test]
16927    fn user_workspace_overlay_preserves_allow_shell_env_override() {
16928        let _guard = crate::test_support::lock_test_env();
16929        let tmp = tempdir().expect("tempdir");
16930        let workspace = tmp.path().join("project");
16931        fs::create_dir_all(&workspace).expect("mkdir workspace");
16932        let config_path = tmp.path().join("config.toml");
16933        fs::write(
16934            &config_path,
16935            format!(
16936                "[workspace.'{}']\nallow_shell = true\n",
16937                workspace.display()
16938            ),
16939        )
16940        .expect("write config");
16941
16942        unsafe {
16943            std::env::set_var("DEEPSEEK_ALLOW_SHELL", "false");
16944        }
16945        let mut config = Config {
16946            allow_shell: Some(false),
16947            ..Config::default()
16948        };
16949        merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16950        unsafe {
16951            std::env::remove_var("DEEPSEEK_ALLOW_SHELL");
16952        }
16953
16954        assert_eq!(config.allow_shell, Some(false));
16955    }
16956
16957    #[test]
16958    fn user_workspace_overlay_does_not_override_managed_config() {
16959        let tmp = tempdir().expect("tempdir");
16960        let workspace = tmp.path().join("project");
16961        fs::create_dir_all(&workspace).expect("mkdir workspace");
16962        let config_path = tmp.path().join("config.toml");
16963        fs::write(
16964            &config_path,
16965            format!(
16966                "[workspace.'{}']\nallow_shell = true\n",
16967                workspace.display()
16968            ),
16969        )
16970        .expect("write config");
16971
16972        let mut config = Config {
16973            allow_shell: Some(false),
16974            managed_config_path: Some("managed.toml".to_string()),
16975            ..Config::default()
16976        };
16977        merge_user_workspace_config(&mut config, Some(config_path), &workspace);
16978
16979        assert_eq!(config.allow_shell, Some(false));
16980    }
16981
16982    #[test]
16983    fn windows_config_path_compare_normalizes_mixed_separators() {
16984        assert_eq!(
16985            normalize_windows_config_path_str(r"C:\Users\me\repo"),
16986            normalize_windows_config_path_str(r"C:/Users/me/repo/")
16987        );
16988    }
16989
16990    #[test]
16991    fn windows_config_path_compare_normalizes_verbatim_and_unc_prefixes() {
16992        assert_eq!(
16993            normalize_windows_config_path_str(r"\\?\C:\Users\me\repo"),
16994            normalize_windows_config_path_str(r"C:/Users/me/repo")
16995        );
16996        assert_eq!(
16997            normalize_windows_config_path_str(r"\\?\UNC\server\share\repo"),
16998            normalize_windows_config_path_str(r"\\server/share/repo/")
16999        );
17000    }
17001
17002    #[test]
17003    fn project_overlay_clamps_max_subagents_to_safe_range() {
17004        let tmp = workspace_with_project_config(
17005            r#"
17006max_subagents = 500
17007"#,
17008        );
17009        let mut config = Config::default();
17010        merge_project_config(&mut config, tmp.path());
17011        assert_eq!(
17012            config.max_subagents,
17013            Some(crate::config::MAX_SUBAGENTS),
17014            "should clamp to MAX_SUBAGENTS"
17015        );
17016    }
17017
17018    #[test]
17019    fn project_overlay_ignores_negative_max_subagents() {
17020        let tmp = workspace_with_project_config(
17021            r#"
17022max_subagents = -3
17023"#,
17024        );
17025        let mut config = Config::default();
17026        merge_project_config(&mut config, tmp.path());
17027        assert_eq!(config.max_subagents, None, "negative should be ignored");
17028    }
17029
17030    #[test]
17031    fn project_overlay_skips_missing_config_file() {
17032        let tmp = tempdir().expect("tempdir");
17033        let mut config = Config {
17034            provider: Some("codewhale".to_string()),
17035            ..Config::default()
17036        };
17037        merge_project_config(&mut config, tmp.path());
17038        // Untouched.
17039        assert_eq!(config.provider.as_deref(), Some("codewhale"));
17040    }
17041
17042    #[test]
17043    fn project_overlay_skips_malformed_toml() {
17044        let tmp = workspace_with_project_config("this is not valid TOML !!");
17045        let mut config = Config {
17046            provider: Some("codewhale".to_string()),
17047            ..Config::default()
17048        };
17049        merge_project_config(&mut config, tmp.path());
17050        // Untouched on parse error — better to fall back to global than crash.
17051        assert_eq!(config.provider.as_deref(), Some("codewhale"));
17052    }
17053
17054    #[test]
17055    fn project_overlay_ignores_empty_string_values() {
17056        let tmp = workspace_with_project_config(
17057            r#"
17058provider = ""
17059model = ""
17060"#,
17061        );
17062        let mut config = Config {
17063            provider: Some("codewhale".to_string()),
17064            default_text_model: Some("deepseek-v4-pro".to_string()),
17065            ..Config::default()
17066        };
17067        merge_project_config(&mut config, tmp.path());
17068        // Empty strings are ignored — they're rarely a deliberate override.
17069        assert_eq!(config.provider.as_deref(), Some("codewhale"));
17070        assert_eq!(
17071            config.default_text_model.as_deref(),
17072            Some("deepseek-v4-pro")
17073        );
17074    }
17075
17076    #[test]
17077    fn project_overlay_ignores_project_instructions_array() {
17078        let tmp = workspace_with_project_config(
17079            r#"
17080instructions = ["./AGENTS.md", "./extra.md"]
17081"#,
17082        );
17083        let user = vec!["~/global.md".to_string()];
17084        let mut config = Config {
17085            instructions: Some(user.clone()),
17086            ..Config::default()
17087        };
17088        merge_project_config(&mut config, tmp.path());
17089        assert_eq!(
17090            config.instructions.as_deref(),
17091            Some(user.as_slice()),
17092            "project overlay must not replace user-owned instructions"
17093        );
17094    }
17095
17096    #[test]
17097    fn project_overlay_empty_instructions_array_preserves_user_list() {
17098        let tmp = workspace_with_project_config(
17099            r#"
17100instructions = []
17101"#,
17102        );
17103        let user = vec!["~/global.md".to_string(), "~/team-prefs.md".to_string()];
17104        let mut config = Config {
17105            instructions: Some(user.clone()),
17106            ..Config::default()
17107        };
17108        merge_project_config(&mut config, tmp.path());
17109        assert_eq!(
17110            config.instructions.as_deref(),
17111            Some(user.as_slice()),
17112            "project overlay must not clear user-owned instructions"
17113        );
17114    }
17115
17116    #[test]
17117    fn project_overlay_preserves_user_instructions_when_field_absent() {
17118        let tmp = workspace_with_project_config(
17119            r#"
17120provider = "deepseek"
17121"#,
17122        );
17123        let user = vec!["~/global.md".to_string()];
17124        let mut config = Config {
17125            instructions: Some(user.clone()),
17126            ..Config::default()
17127        };
17128        merge_project_config(&mut config, tmp.path());
17129        // No `instructions` key in the project file → user list intact.
17130        assert_eq!(
17131            config.instructions.as_deref(),
17132            Some(user.as_slice()),
17133            "absent project field must not clobber the user list"
17134        );
17135    }
17136
17137    #[test]
17138    fn project_overlay_ignores_new_instructions_when_user_has_none() {
17139        let tmp = workspace_with_project_config(
17140            r#"
17141instructions = ["./AGENTS.md", "", "  ", "./extra.md"]
17142"#,
17143        );
17144        let mut config = Config::default();
17145        merge_project_config(&mut config, tmp.path());
17146        assert_eq!(
17147            config.instructions.as_deref(),
17148            None,
17149            "project overlay must not introduce instruction paths"
17150        );
17151    }
17152}
17153
17154#[cfg(test)]
17155mod doctor_mcp_tests {
17156    use super::*;
17157
17158    fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig {
17159        McpServerConfig {
17160            command: command.map(String::from),
17161            args: args.iter().map(|s| s.to_string()).collect(),
17162            env: std::collections::HashMap::new(),
17163            cwd: None,
17164            url: url.map(String::from),
17165            transport: None,
17166            connect_timeout: None,
17167            execute_timeout: None,
17168            read_timeout: None,
17169            disabled: false,
17170            enabled: true,
17171            required: false,
17172            enabled_tools: Vec::new(),
17173            disabled_tools: Vec::new(),
17174            headers: std::collections::HashMap::new(),
17175            env_headers: std::collections::HashMap::new(),
17176            bearer_token_env_var: None,
17177            scopes: Vec::new(),
17178            oauth: None,
17179            oauth_resource: None,
17180            reviewed_plugin: None,
17181        }
17182    }
17183
17184    #[test]
17185    fn test_no_command_or_url_is_error() {
17186        let server = make_server(None, &[], None);
17187        assert!(matches!(
17188            doctor_check_mcp_server(&server),
17189            McpServerDoctorStatus::Error(_)
17190        ));
17191    }
17192
17193    #[test]
17194    fn test_url_server_is_ok() {
17195        let server = make_server(None, &[], Some("http://localhost:3000/mcp"));
17196        match doctor_check_mcp_server(&server) {
17197            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")),
17198            other => panic!("Expected Ok, got {other:?}"),
17199        }
17200    }
17201
17202    #[test]
17203    fn test_command_server_is_ok() {
17204        let executable = std::env::current_exe().expect("current test executable");
17205        let executable = executable.to_string_lossy();
17206        let server = make_server(Some(&executable), &["server.js"], None);
17207        match doctor_check_mcp_server(&server) {
17208            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17209            other => panic!("Expected Ok, got {other:?}"),
17210        }
17211    }
17212
17213    #[test]
17214    fn test_relative_stdio_path_arg_without_cwd_warns() {
17215        let executable = std::env::current_exe().expect("current test executable");
17216        let executable = executable.to_string_lossy();
17217        let server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17218        match doctor_check_mcp_server(&server) {
17219            McpServerDoctorStatus::Warning(detail) => {
17220                assert!(detail.contains("relative path argument"));
17221                assert!(detail.contains("cwd"));
17222            }
17223            other => panic!("Expected Warning for relative path argument, got {other:?}"),
17224        }
17225    }
17226
17227    #[test]
17228    fn test_relative_stdio_path_arg_with_cwd_is_ok() {
17229        let executable = std::env::current_exe().expect("current test executable");
17230        let executable = executable.to_string_lossy();
17231        let mut server = make_server(Some(&executable), &["server/mcp_server.py"], None);
17232        server.cwd = Some(PathBuf::from("/tmp/codewhale-project"));
17233        match doctor_check_mcp_server(&server) {
17234            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")),
17235            other => panic!("Expected Ok when cwd anchors relative path, got {other:?}"),
17236        }
17237    }
17238
17239    #[test]
17240    fn test_self_hosted_absolute_is_ok() {
17241        let executable = std::env::current_exe().expect("current test executable");
17242        let executable = executable.to_string_lossy();
17243        let server = make_server(Some(&executable), &["serve", "--mcp"], None);
17244        match doctor_check_mcp_server(&server) {
17245            McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio server")),
17246            McpServerDoctorStatus::Warning(detail) => {
17247                panic!("Absolute path should not warn: {detail}")
17248            }
17249            McpServerDoctorStatus::Error(detail) => panic!("unexpected error: {detail}"),
17250        }
17251    }
17252
17253    #[cfg(test)]
17254    mod mcp_auth_guidance_tests {
17255        #[test]
17256        fn mcp_auth_hint_is_actionable_for_connect_failures() {
17257            let hint = crate::mcp::oauth::auth_required_login_hint("nordic-mcp");
17258            assert_eq!(
17259                hint,
17260                "MCP server 'nordic-mcp' requires OAuth authentication. Run `codewhale mcp login nordic-mcp` to authenticate."
17261            );
17262        }
17263    }
17264
17265    #[test]
17266    fn test_empty_command_is_error() {
17267        let server = make_server(Some(""), &[], None);
17268        assert!(matches!(
17269            doctor_check_mcp_server(&server),
17270            McpServerDoctorStatus::Error(_)
17271        ));
17272    }
17273
17274    #[test]
17275    fn doctor_json_separates_configuration_from_live_health() {
17276        let server = make_server(None, &[], Some("http://127.0.0.1:3000/mcp"));
17277        let report = doctor_mcp_server_json("tools-only", &server);
17278
17279        assert_eq!(report["check_scope"], "configuration");
17280        assert_eq!(report["checks"]["configuration"]["status"], "valid");
17281        assert_eq!(report["checks"]["command"]["status"], "not_applicable");
17282        assert_eq!(
17283            report["checks"]["process_reachable"]["status"],
17284            "not_checked"
17285        );
17286        assert_eq!(
17287            report["checks"]["protocol_initialized"]["status"],
17288            "not_checked"
17289        );
17290        assert_eq!(
17291            report["checks"]["backend_tool_health"]["status"],
17292            "not_checked"
17293        );
17294        assert!(!report.to_string().contains("healthy"));
17295    }
17296
17297    #[cfg(unix)]
17298    #[test]
17299    fn static_mcp_check_never_starts_the_configured_command() {
17300        use std::os::unix::fs::PermissionsExt;
17301
17302        let temp = tempfile::tempdir().expect("tempdir");
17303        let marker = temp.path().join("started");
17304        let script = temp.path().join("mcp-server");
17305        std::fs::write(
17306            &script,
17307            format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
17308        )
17309        .expect("write test server");
17310        let mut permissions = std::fs::metadata(&script)
17311            .expect("script metadata")
17312            .permissions();
17313        permissions.set_mode(0o755);
17314        std::fs::set_permissions(&script, permissions).expect("make script executable");
17315
17316        let script = script.to_string_lossy();
17317        let server = make_server(Some(&script), &[], None);
17318        assert!(matches!(
17319            doctor_check_mcp_server(&server),
17320            McpServerDoctorStatus::Ok(_)
17321        ));
17322        assert!(!marker.exists(), "static doctor check started MCP server");
17323    }
17324}
17325
17326#[cfg(test)]
17327mod doctor_live_probe_tests {
17328    use super::*;
17329
17330    #[test]
17331    fn local_provider_probe_requires_explicit_opt_in() {
17332        assert!(!doctor_should_probe_api(
17333            crate::config::ApiProvider::Ollama,
17334            "http://127.0.0.1:11434/v1",
17335            crate::doctor::DoctorProbeRequest::default(),
17336        ));
17337        assert!(doctor_should_probe_api(
17338            crate::config::ApiProvider::Ollama,
17339            "http://127.0.0.1:11434/v1",
17340            crate::doctor::DoctorProbeRequest {
17341                probe_local: true,
17342                ..crate::doctor::DoctorProbeRequest::default()
17343            },
17344        ));
17345    }
17346
17347    #[test]
17348    fn ollama_cloud_probe_uses_hosted_opt_in_not_local_opt_in() {
17349        let cloud = codewhale_config::provider::OLLAMA_CLOUD_BASE_URL;
17350        assert!(!doctor_should_probe_api(
17351            crate::config::ApiProvider::OllamaCloud,
17352            cloud,
17353            crate::doctor::DoctorProbeRequest::default(),
17354        ));
17355        assert!(doctor_should_probe_api(
17356            crate::config::ApiProvider::OllamaCloud,
17357            cloud,
17358            crate::doctor::DoctorProbeRequest {
17359                probe_api: true,
17360                ..crate::doctor::DoctorProbeRequest::default()
17361            },
17362        ));
17363        assert!(!doctor_should_probe_api(
17364            crate::config::ApiProvider::OllamaCloud,
17365            cloud,
17366            crate::doctor::DoctorProbeRequest {
17367                probe_local: true,
17368                ..crate::doctor::DoctorProbeRequest::default()
17369            },
17370        ));
17371    }
17372
17373    #[test]
17374    fn custom_loopback_probe_also_requires_explicit_opt_in() {
17375        assert!(!doctor_should_probe_api(
17376            crate::config::ApiProvider::Custom,
17377            "http://localhost:8000/v1",
17378            crate::doctor::DoctorProbeRequest::default(),
17379        ));
17380    }
17381
17382    #[test]
17383    fn oauth_routes_skip_live_probe_to_keep_doctor_non_mutating() {
17384        let codex = Config {
17385            provider: Some("openai-codex".to_string()),
17386            ..Config::default()
17387        };
17388        assert!(!doctor_should_probe_auth(&codex));
17389
17390        let xai = Config {
17391            provider: Some("xai".to_string()),
17392            providers: Some(crate::config::ProvidersConfig {
17393                xai: crate::config::ProviderConfig {
17394                    auth_mode: Some("oauth".to_string()),
17395                    ..Default::default()
17396                },
17397                ..Default::default()
17398            }),
17399            ..Config::default()
17400        };
17401        assert!(!doctor_should_probe_auth(&xai));
17402        assert!(doctor_should_probe_auth(&Config::default()));
17403    }
17404}
17405
17406#[cfg(test)]
17407mod setup_helper_tests {
17408    use super::*;
17409    use std::collections::BTreeSet;
17410    use tempfile::TempDir;
17411
17412    #[test]
17413    fn init_tools_dir_creates_readme_and_example() {
17414        let tmp = TempDir::new().unwrap();
17415        let dir = tmp.path().join("tools");
17416        let (returned_dir, readme_status, example_status) =
17417            init_tools_dir(&dir, false).expect("init_tools_dir should succeed");
17418
17419        assert_eq!(returned_dir, dir);
17420        assert!(matches!(readme_status, WriteStatus::Created));
17421        assert!(matches!(example_status, WriteStatus::Created));
17422        assert!(dir.join("README.md").exists());
17423        assert!(dir.join("example.sh").exists());
17424
17425        let readme = std::fs::read_to_string(dir.join("README.md")).unwrap();
17426        assert!(
17427            readme.contains("# name:"),
17428            "README must show frontmatter convention"
17429        );
17430
17431        let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17432        assert!(example.starts_with("#!/usr/bin/env sh"));
17433        assert!(example.contains("# name: example"));
17434        assert!(example.contains("# description:"));
17435    }
17436
17437    #[test]
17438    fn init_tools_dir_skips_existing_without_force() {
17439        let tmp = TempDir::new().unwrap();
17440        let dir = tmp.path().join("tools");
17441        let _ = init_tools_dir(&dir, false).unwrap();
17442        let (_, readme_status, example_status) = init_tools_dir(&dir, false).unwrap();
17443        assert!(matches!(readme_status, WriteStatus::SkippedExists));
17444        assert!(matches!(example_status, WriteStatus::SkippedExists));
17445    }
17446
17447    #[test]
17448    fn init_tools_dir_force_overwrites() {
17449        let tmp = TempDir::new().unwrap();
17450        let dir = tmp.path().join("tools");
17451        let _ = init_tools_dir(&dir, false).unwrap();
17452        std::fs::write(dir.join("example.sh"), "stale").unwrap();
17453        let (_, _, example_status) = init_tools_dir(&dir, true).unwrap();
17454        assert!(matches!(example_status, WriteStatus::Overwritten));
17455        let example = std::fs::read_to_string(dir.join("example.sh")).unwrap();
17456        assert_ne!(example, "stale");
17457    }
17458
17459    #[test]
17460    fn init_plugins_dir_creates_readme_and_example_layout() {
17461        let tmp = TempDir::new().unwrap();
17462        let dir = tmp.path().join("plugins");
17463        let (readme_path, manifest_path, skill_path, readme_status, manifest_status, skill_status) =
17464            init_plugins_dir(&dir, false).unwrap();
17465
17466        assert_eq!(readme_path, dir.join("README.md"));
17467        assert_eq!(manifest_path, dir.join("example").join("plugin.toml"));
17468        assert_eq!(
17469            skill_path,
17470            dir.join("example/skills/hello").join("SKILL.md")
17471        );
17472        assert!(matches!(readme_status, WriteStatus::Created));
17473        assert!(matches!(manifest_status, WriteStatus::Created));
17474        assert!(matches!(skill_status, WriteStatus::Created));
17475        assert!(readme_path.exists());
17476        assert!(manifest_path.exists());
17477        assert!(skill_path.exists());
17478
17479        let manifest = std::fs::read_to_string(&manifest_path).unwrap();
17480        assert!(manifest.contains("schema_version = 1"));
17481        assert!(manifest.contains("name = \"example\""));
17482        let validated =
17483            crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
17484                .expect("scaffolded plugin should validate");
17485        assert_eq!(validated.inventory.skills, 1);
17486    }
17487
17488    #[test]
17489    fn collect_clean_targets_finds_all_checkpoint_json_files() {
17490        let tmp = TempDir::new().unwrap();
17491        let dir = tmp.path();
17492        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17493        std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17494        // Per-session crash checkpoint files are clean targets too.
17495        std::fs::write(dir.join("some-session-id.json"), "{}").unwrap();
17496        // Non-JSON files and subdirectories are left alone.
17497        std::fs::write(dir.join("notes.txt"), "keep").unwrap();
17498        std::fs::create_dir_all(dir.join("subdir")).unwrap();
17499
17500        let plan = collect_clean_targets(dir);
17501        assert_eq!(plan.targets.len(), 3);
17502        assert!(plan.targets.iter().any(|p| p.ends_with("latest.json")));
17503        assert!(
17504            plan.targets
17505                .iter()
17506                .any(|p| p.ends_with("offline_queue.json"))
17507        );
17508        assert!(
17509            plan.targets
17510                .iter()
17511                .any(|p| p.ends_with("some-session-id.json"))
17512        );
17513        assert!(!plan.targets.iter().any(|p| p.ends_with("notes.txt")));
17514    }
17515
17516    #[test]
17517    fn execute_clean_plan_removes_files_and_returns_them() {
17518        let tmp = TempDir::new().unwrap();
17519        let dir = tmp.path();
17520        let latest = dir.join("latest.json");
17521        let queue = dir.join("offline_queue.json");
17522        std::fs::write(&latest, "{}").unwrap();
17523        std::fs::write(&queue, "[]").unwrap();
17524
17525        let plan = collect_clean_targets(dir);
17526        let removed = execute_clean_plan(&plan).unwrap();
17527        assert_eq!(removed.len(), 2);
17528        assert!(!latest.exists());
17529        assert!(!queue.exists());
17530    }
17531
17532    #[test]
17533    fn run_setup_clean_dry_run_lists_targets_without_force() {
17534        let tmp = TempDir::new().unwrap();
17535        let dir = tmp.path();
17536        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17537        run_setup_clean(dir, false).unwrap();
17538        // Without --force, files must remain on disk.
17539        assert!(dir.join("latest.json").exists());
17540    }
17541
17542    #[test]
17543    fn run_setup_clean_force_removes_files() {
17544        let tmp = TempDir::new().unwrap();
17545        let dir = tmp.path();
17546        std::fs::write(dir.join("latest.json"), "{}").unwrap();
17547        std::fs::write(dir.join("offline_queue.json"), "[]").unwrap();
17548        run_setup_clean(dir, true).unwrap();
17549        assert!(!dir.join("latest.json").exists());
17550        assert!(!dir.join("offline_queue.json").exists());
17551    }
17552
17553    #[test]
17554    fn run_setup_clean_handles_missing_dir() {
17555        let tmp = TempDir::new().unwrap();
17556        let dir = tmp.path().join("does-not-exist");
17557        // Should print and return Ok without error.
17558        run_setup_clean(&dir, true).unwrap();
17559        assert!(!dir.exists());
17560    }
17561
17562    fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
17563        let prev_home = std::env::var_os("HOME");
17564        let prev_userprofile = std::env::var_os("USERPROFILE");
17565        unsafe {
17566            std::env::set_var("HOME", home);
17567            std::env::set_var("USERPROFILE", home);
17568        }
17569        let result = f();
17570        unsafe {
17571            match prev_home {
17572                Some(value) => std::env::set_var("HOME", value),
17573                None => std::env::remove_var("HOME"),
17574            }
17575            match prev_userprofile {
17576                Some(value) => std::env::set_var("USERPROFILE", value),
17577                None => std::env::remove_var("USERPROFILE"),
17578            }
17579        }
17580        result
17581    }
17582
17583    #[test]
17584    fn plain_launch_preserves_checkpoint_but_starts_fresh() {
17585        let _guard = crate::test_support::lock_test_env();
17586        let tmp = TempDir::new().unwrap();
17587        let workspace = tmp.path().join("workspace");
17588        std::fs::create_dir_all(&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: "in flight".to_string(),
17596                    cache_control: None,
17597                }],
17598            }];
17599            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17600            let session_id = session.metadata.id.clone();
17601            manager.save_checkpoint(&session).expect("save checkpoint");
17602
17603            preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17604
17605            assert!(
17606                manager
17607                    .load_session_checkpoint(&session_id)
17608                    .expect("load checkpoint")
17609                    .is_some(),
17610                "normal launch must leave the per-session checkpoint in place \
17611                 (it may belong to a live session; `--continue` consumes it)"
17612            );
17613            // #4479: checkpoint is no longer promoted to session file.
17614            assert!(
17615                manager
17616                    .load_session_checkpoint(&session_id)
17617                    .expect("load checkpoint")
17618                    .is_some(),
17619                "checkpoint stays in checkpoints/ for --continue"
17620            );
17621        });
17622    }
17623
17624    #[test]
17625    fn plain_launch_consumes_legacy_checkpoint_after_preserving_it() {
17626        let _guard = crate::test_support::lock_test_env();
17627        let tmp = TempDir::new().unwrap();
17628        let workspace = tmp.path().join("workspace");
17629        std::fs::create_dir_all(&workspace).unwrap();
17630
17631        with_home(tmp.path(), || {
17632            let manager = SessionManager::default_location().expect("manager");
17633            let session = create_saved_session(
17634                &[Message {
17635                    role: "user".to_string(),
17636                    content: vec![ContentBlock::Text {
17637                        text: "legacy in flight".to_string(),
17638                        cache_control: None,
17639                    }],
17640                }],
17641                "test-model",
17642                &workspace,
17643                0,
17644                None,
17645            );
17646            let session_id = session.metadata.id.clone();
17647            write_legacy_checkpoint(&manager, &session);
17648
17649            preserve_interrupted_checkpoint_for_explicit_resume(&workspace);
17650
17651            assert!(
17652                manager
17653                    .load_legacy_checkpoint()
17654                    .expect("load legacy checkpoint")
17655                    .is_none(),
17656                "normal launch should consume the legacy single-slot checkpoint"
17657            );
17658            // #4479: checkpoint is no longer promoted to session file.
17659            assert!(
17660                manager
17661                    .load_session_checkpoint(&session_id)
17662                    .expect("load checkpoint")
17663                    .is_some(),
17664                "checkpoint stays in checkpoints/ for --continue"
17665            );
17666        });
17667    }
17668
17669    #[test]
17670    fn continue_recovers_same_workspace_checkpoint() {
17671        let _guard = crate::test_support::lock_test_env();
17672        let tmp = TempDir::new().unwrap();
17673        let workspace = tmp.path().join("workspace");
17674        std::fs::create_dir_all(&workspace).unwrap();
17675
17676        with_home(tmp.path(), || {
17677            let manager = SessionManager::default_location().expect("manager");
17678            let messages = vec![Message {
17679                role: "user".to_string(),
17680                content: vec![ContentBlock::Text {
17681                    text: "continue me".to_string(),
17682                    cache_control: None,
17683                }],
17684            }];
17685            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17686            let session_id = session.metadata.id.clone();
17687            manager.save_checkpoint(&session).expect("save checkpoint");
17688
17689            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17690
17691            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17692            assert!(
17693                manager
17694                    .load_session_checkpoint(&session_id)
17695                    .expect("load checkpoint")
17696                    .is_none(),
17697                "--continue should consume the per-session checkpoint"
17698            );
17699            assert!(manager.load_session(&session_id).is_ok());
17700        });
17701    }
17702
17703    /// Write a legacy single-slot checkpoint file the way pre-cutover
17704    /// binaries did. The current binary only reads this slot.
17705    fn write_legacy_checkpoint(manager: &SessionManager, session: &session_manager::SavedSession) {
17706        let checkpoints = manager.sessions_dir().join("checkpoints");
17707        std::fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
17708        let content = serde_json::to_string_pretty(session).expect("serialize checkpoint");
17709        std::fs::write(checkpoints.join("latest.json"), content).expect("write legacy checkpoint");
17710    }
17711
17712    #[test]
17713    fn continue_recovers_legacy_checkpoint_and_migrates_it() {
17714        let _guard = crate::test_support::lock_test_env();
17715        let tmp = TempDir::new().unwrap();
17716        let workspace = tmp.path().join("workspace");
17717        std::fs::create_dir_all(&workspace).unwrap();
17718
17719        with_home(tmp.path(), || {
17720            let manager = SessionManager::default_location().expect("manager");
17721            let messages = vec![Message {
17722                role: "user".to_string(),
17723                content: vec![ContentBlock::Text {
17724                    text: "legacy continue".to_string(),
17725                    cache_control: None,
17726                }],
17727            }];
17728            let session = create_saved_session(&messages, "test-model", &workspace, 0, None);
17729            let session_id = session.metadata.id.clone();
17730            write_legacy_checkpoint(&manager, &session);
17731
17732            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17733
17734            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17735            assert!(
17736                manager.load_session(&session_id).is_ok(),
17737                "recovered legacy checkpoint must be loadable as a session"
17738            );
17739            assert!(
17740                manager
17741                    .load_session_checkpoint(&session_id)
17742                    .expect("load per-session checkpoint")
17743                    .is_some(),
17744                "legacy recovery must migrate to a per-session checkpoint file"
17745            );
17746            assert!(
17747                manager
17748                    .load_legacy_checkpoint()
17749                    .expect("load legacy checkpoint")
17750                    .is_some(),
17751                "legacy latest.json stays in place for one more release"
17752            );
17753        });
17754    }
17755
17756    #[test]
17757    fn continue_refuses_checkpoint_from_other_workspace() {
17758        let _guard = crate::test_support::lock_test_env();
17759        let tmp = TempDir::new().unwrap();
17760        let launch_workspace = tmp.path().join("launch-workspace");
17761        let other_workspace = tmp.path().join("other-workspace");
17762        std::fs::create_dir_all(&launch_workspace).unwrap();
17763        std::fs::create_dir_all(&other_workspace).unwrap();
17764
17765        with_home(tmp.path(), || {
17766            let manager = SessionManager::default_location().expect("manager");
17767            let messages = vec![Message {
17768                role: "user".to_string(),
17769                content: vec![ContentBlock::Text {
17770                    text: "belongs elsewhere".to_string(),
17771                    cache_control: None,
17772                }],
17773            }];
17774            let session = create_saved_session(&messages, "test-model", &other_workspace, 0, None);
17775            let session_id = session.metadata.id.clone();
17776            manager.save_checkpoint(&session).expect("save checkpoint");
17777
17778            let recovered = recover_interrupted_checkpoint_for_resume(&launch_workspace);
17779
17780            assert_eq!(recovered, None, "workspace mismatch must refuse recovery");
17781            assert!(
17782                manager
17783                    .load_session_checkpoint(&session_id)
17784                    .expect("load checkpoint")
17785                    .is_some(),
17786                "another workspace's checkpoint file must be left untouched"
17787            );
17788        });
17789    }
17790
17791    #[test]
17792    fn continue_twice_does_not_clobber_newer_session_with_stale_legacy_checkpoint() {
17793        let _guard = crate::test_support::lock_test_env();
17794        let tmp = TempDir::new().unwrap();
17795        let workspace = tmp.path().join("workspace");
17796        std::fs::create_dir_all(&workspace).unwrap();
17797
17798        with_home(tmp.path(), || {
17799            let manager = SessionManager::default_location().expect("manager");
17800            let stale = create_saved_session(
17801                &[Message {
17802                    role: "user".to_string(),
17803                    content: vec![ContentBlock::Text {
17804                        text: "crash-time state".to_string(),
17805                        cache_control: None,
17806                    }],
17807                }],
17808                "test-model",
17809                &workspace,
17810                0,
17811                None,
17812            );
17813            let session_id = stale.metadata.id.clone();
17814            write_legacy_checkpoint(&manager, &stale);
17815
17816            // The session advanced after the checkpoint was taken: a newer
17817            // regular session file exists for the same id.
17818            let mut advanced = stale.clone();
17819            advanced.messages.push(Message {
17820                role: "assistant".to_string(),
17821                content: vec![ContentBlock::Text {
17822                    text: "post-recovery progress".to_string(),
17823                    cache_control: None,
17824                }],
17825            });
17826            advanced.metadata.message_count = advanced.messages.len();
17827            advanced.metadata.updated_at = stale.metadata.updated_at + chrono::Duration::hours(1);
17828            manager.save_session(&advanced).expect("save newer session");
17829
17830            let recovered = recover_interrupted_checkpoint_for_resume(&workspace);
17831
17832            assert_eq!(recovered.as_deref(), Some(session_id.as_str()));
17833            let persisted = manager.load_session(&session_id).expect("load session");
17834            assert_eq!(
17835                persisted.messages.len(),
17836                advanced.messages.len(),
17837                "stale checkpoint content must not overwrite the newer session"
17838            );
17839        });
17840    }
17841
17842    #[test]
17843    fn dotenv_status_points_to_example_when_present() {
17844        let tmp = TempDir::new().unwrap();
17845        std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
17846
17847        assert_eq!(
17848            dotenv_status_line(tmp.path()),
17849            ".env not present in workspace (run `cp .env.example .env` and edit)"
17850        );
17851
17852        std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
17853        assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
17854    }
17855
17856    #[test]
17857    fn env_example_is_trackable_and_every_key_is_wired() {
17858        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
17859        let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
17860        let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
17861
17862        assert!(gitignore.contains("!.env.example"));
17863
17864        let keys = documented_env_keys(&env_example);
17865        for required in [
17866            "DEEPSEEK_API_KEY",
17867            "NVIDIA_API_KEY",
17868            "NVIDIA_NIM_API_KEY",
17869            "ATLASCLOUD_API_KEY",
17870        ] {
17871            assert!(
17872                keys.contains(required),
17873                ".env.example is missing {required}"
17874            );
17875        }
17876
17877        for key in &keys {
17878            assert!(
17879                is_workspace_dotenv_credential_key(key),
17880                ".env.example documents non-credential control setting {key}"
17881            );
17882        }
17883
17884        let sources = [
17885            include_str!("config.rs"),
17886            include_str!("logging.rs"),
17887            include_str!("../../config/src/lib.rs"),
17888            include_str!("../../config/src/provider.rs"),
17889            include_str!("../../cli/src/main.rs"),
17890        ]
17891        .join("\n");
17892
17893        for key in keys {
17894            assert!(
17895                sources.contains(&key),
17896                ".env.example documents {key}, but no source file references it"
17897            );
17898        }
17899    }
17900
17901    fn documented_env_keys(content: &str) -> BTreeSet<String> {
17902        content
17903            .lines()
17904            .filter_map(|line| {
17905                let trimmed = line.trim();
17906                let uncommented = trimmed
17907                    .strip_prefix('#')
17908                    .map(str::trim_start)
17909                    .unwrap_or(trimmed);
17910                let (key, _) = uncommented.split_once('=')?;
17911                let key = key.trim();
17912                let is_env_key = key
17913                    .chars()
17914                    .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
17915                    && key.chars().any(|ch| ch == '_');
17916                is_env_key.then(|| key.to_string())
17917            })
17918            .collect()
17919    }
17920
17921    #[test]
17922    fn custom_provider_env_source_precedes_saved_secret_store() {
17923        let _lock = crate::test_support::lock_test_env();
17924        let temp = TempDir::new().expect("temp home");
17925        let codewhale_home = temp.path().join("codewhale-home");
17926        std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17927        let _home =
17928            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17929        let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17930        let _declared_env =
17931            crate::test_support::EnvVarGuard::set("QA_CUSTOM_API_KEY", "declared-env-key");
17932        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17933        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17934        codewhale_secrets::Secrets::auto_detect()
17935            .set("custom", "saved-custom-secret")
17936            .expect("save secret");
17937
17938        let mut custom = std::collections::HashMap::new();
17939        custom.insert(
17940            "qa-gateway".to_string(),
17941            crate::config::ProviderConfig {
17942                kind: Some("openai-compatible".to_string()),
17943                base_url: Some("https://gateway.example.test/v1".to_string()),
17944                model: Some("qa-model".to_string()),
17945                api_key_env: Some("QA_CUSTOM_API_KEY".to_string()),
17946                ..Default::default()
17947            },
17948        );
17949        let config = Config {
17950            provider: Some("qa-gateway".to_string()),
17951            providers: Some(crate::config::ProvidersConfig {
17952                custom,
17953                ..Default::default()
17954            }),
17955            ..Config::default()
17956        };
17957
17958        assert_eq!(resolve_api_key_source(&config), ApiKeySource::EnvDeclared);
17959        assert_eq!(
17960            config.deepseek_api_key().expect("custom key"),
17961            "declared-env-key"
17962        );
17963    }
17964
17965    #[test]
17966    fn named_custom_provider_does_not_report_generic_secret_store() {
17967        let _lock = crate::test_support::lock_test_env();
17968        let temp = TempDir::new().expect("temp home");
17969        let codewhale_home = temp.path().join("codewhale-home");
17970        std::fs::create_dir_all(&codewhale_home).expect("create codewhale home");
17971        let _home =
17972            crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
17973        let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
17974        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
17975        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
17976        codewhale_secrets::Secrets::auto_detect()
17977            .set("custom", "unrelated-custom-secret")
17978            .expect("save secret");
17979
17980        let mut custom = std::collections::HashMap::new();
17981        custom.insert(
17982            "qa-gateway".to_string(),
17983            crate::config::ProviderConfig {
17984                kind: Some("openai-compatible".to_string()),
17985                base_url: Some("https://gateway.example.test/v1".to_string()),
17986                model: Some("qa-model".to_string()),
17987                auth_mode: Some("api_key".to_string()),
17988                ..Default::default()
17989            },
17990        );
17991        let config = Config {
17992            provider: Some("qa-gateway".to_string()),
17993            providers: Some(crate::config::ProvidersConfig {
17994                custom,
17995                ..Default::default()
17996            }),
17997            ..Config::default()
17998        };
17999
18000        assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
18001        assert!(config.deepseek_api_key().is_err());
18002    }
18003
18004    #[test]
18005    fn custom_built_in_endpoint_does_not_report_ambient_provider_key() {
18006        let _lock = crate::test_support::lock_test_env();
18007        let _openrouter =
18008            crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
18009        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18010        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18011        let mut providers = crate::config::ProvidersConfig::default();
18012        providers.openrouter.base_url = Some("https://gateway.example.test/v1".to_string());
18013        let config = Config {
18014            provider: Some("openrouter".to_string()),
18015            providers: Some(providers),
18016            ..Config::default()
18017        };
18018
18019        assert_eq!(resolve_api_key_source(&config), ApiKeySource::Unknown);
18020        assert!(config.deepseek_api_key().is_err());
18021    }
18022
18023    #[test]
18024    fn ollama_doctor_credential_source_is_route_aware() {
18025        let local = Config {
18026            provider: Some("ollama".to_string()),
18027            ..Config::default()
18028        };
18029        assert_eq!(resolve_api_key_source(&local), ApiKeySource::LocalRuntime);
18030        assert_eq!(
18031            resolve_credential_diagnostic(&local).availability,
18032            CredentialAvailability::NotRequired
18033        );
18034
18035        let ollama_config = |base_url: &str| Config {
18036            provider: Some("ollama".to_string()),
18037            providers: Some(crate::config::ProvidersConfig {
18038                ollama: crate::config::ProviderConfig {
18039                    base_url: Some(base_url.to_string()),
18040                    ..Default::default()
18041                },
18042                ..Default::default()
18043            }),
18044            ..Config::default()
18045        };
18046        let cloud = ollama_config(codewhale_config::provider::OLLAMA_CLOUD_BASE_URL);
18047        assert_eq!(
18048            cloud.api_provider(),
18049            crate::config::ApiProvider::OllamaCloud
18050        );
18051        assert_eq!(
18052            resolve_api_key_source(&cloud),
18053            ApiKeySource::SecretStoreUnprobed
18054        );
18055        assert_eq!(
18056            resolve_credential_diagnostic(&cloud).availability,
18057            CredentialAvailability::NotProbed
18058        );
18059        assert_eq!(doctor_auth_scheme(&cloud), "bearer");
18060        let report = doctor_route_report(&cloud);
18061        assert_eq!(report["provider"], "ollama-cloud");
18062        assert_eq!(report["provider_config_table"], "ollama_cloud");
18063
18064        let custom_remote = ollama_config("https://ollama-gateway.example.test/v1");
18065        assert_eq!(
18066            resolve_api_key_source(&custom_remote),
18067            ApiKeySource::Unknown
18068        );
18069        assert_eq!(
18070            resolve_credential_diagnostic(&custom_remote).availability,
18071            CredentialAvailability::Unknown
18072        );
18073    }
18074
18075    #[test]
18076    fn auth_mode_none_reports_distinct_no_auth_source_and_scheme() {
18077        let _lock = crate::test_support::lock_test_env();
18078        let _openrouter =
18079            crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "ambient-key");
18080        let mut providers = crate::config::ProvidersConfig::default();
18081        providers.openrouter.auth_mode = Some("none".to_string());
18082        providers.openrouter.api_key = Some("configured-key".to_string());
18083        let config = Config {
18084            provider: Some("openrouter".to_string()),
18085            providers: Some(providers),
18086            ..Config::default()
18087        };
18088
18089        assert_eq!(resolve_api_key_source(&config), ApiKeySource::NoAuth);
18090        assert_eq!(doctor_api_key_source_label(ApiKeySource::NoAuth), "none");
18091        assert_eq!(doctor_auth_scheme(&config), "none");
18092        assert_eq!(config.deepseek_api_key().expect("no-auth route"), "");
18093    }
18094
18095    #[test]
18096    fn resolve_api_key_source_prefers_config_over_env() {
18097        let _guard = crate::test_support::lock_test_env();
18098        let prev = std::env::var("DEEPSEEK_API_KEY").ok();
18099        let prev_source = std::env::var("DEEPSEEK_API_KEY_SOURCE").ok();
18100        unsafe {
18101            std::env::set_var("DEEPSEEK_API_KEY", "stale-env-key");
18102            std::env::remove_var("DEEPSEEK_API_KEY_SOURCE");
18103        }
18104        let cfg = Config {
18105            api_key: Some("fresh-config-key".to_string()),
18106            ..Config::default()
18107        };
18108        let source = resolve_api_key_source(&cfg);
18109        match prev {
18110            Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY", value) },
18111            None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY") },
18112        }
18113        match prev_source {
18114            Some(value) => unsafe { std::env::set_var("DEEPSEEK_API_KEY_SOURCE", value) },
18115            None => unsafe { std::env::remove_var("DEEPSEEK_API_KEY_SOURCE") },
18116        }
18117        assert_eq!(source, ApiKeySource::ConfigDeclared);
18118    }
18119
18120    #[test]
18121    fn resolve_api_key_source_reports_active_provider_env_from_metadata() {
18122        let _guard = crate::test_support::lock_test_env();
18123        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18124        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18125        let _anthropic_key =
18126            crate::test_support::EnvVarGuard::set("ANTHROPIC_API_KEY", "test-anthropic-key");
18127        let cfg = Config {
18128            provider: Some("anthropic".to_string()),
18129            ..Config::default()
18130        };
18131
18132        let source = resolve_api_key_source(&cfg);
18133
18134        assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
18135    }
18136
18137    #[test]
18138    fn resolve_api_key_source_ignores_unresolved_provider_command_metadata() {
18139        let _guard = crate::test_support::lock_test_env();
18140        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18141        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18142        let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
18143        let mut providers = crate::config::ProvidersConfig::default();
18144        providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
18145            source: codewhale_config::AuthSourceKind::Command,
18146            command: vec!["secret-tool".to_string(), "lookup".to_string()],
18147            timeout_ms: Some(2000),
18148            secret_id: None,
18149        });
18150        let cfg = Config {
18151            provider: Some("openai".to_string()),
18152            providers: Some(providers),
18153            ..Config::default()
18154        };
18155
18156        let source = resolve_api_key_source(&cfg);
18157
18158        assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
18159        assert!(cfg.deepseek_api_key().is_err());
18160    }
18161
18162    #[test]
18163    fn resolve_api_key_source_ignores_unresolved_provider_secret_metadata() {
18164        let _guard = crate::test_support::lock_test_env();
18165        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18166        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18167        let _openai_key = crate::test_support::EnvVarGuard::remove("OPENAI_API_KEY");
18168        let mut providers = crate::config::ProvidersConfig::default();
18169        providers.openai.auth = Some(codewhale_config::ProviderAuthSourceToml {
18170            source: codewhale_config::AuthSourceKind::Secret,
18171            command: Vec::new(),
18172            timeout_ms: None,
18173            secret_id: Some("codewhale/openai".to_string()),
18174        });
18175        let cfg = Config {
18176            provider: Some("openai".to_string()),
18177            providers: Some(providers),
18178            ..Config::default()
18179        };
18180
18181        let source = resolve_api_key_source(&cfg);
18182
18183        assert_eq!(source, ApiKeySource::ExternalAuthDeclared);
18184        assert!(cfg.deepseek_api_key().is_err());
18185    }
18186
18187    #[test]
18188    fn resolve_api_key_source_ignores_root_deepseek_key_for_other_provider() {
18189        let _guard = crate::test_support::lock_test_env();
18190        let _deepseek_key = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY");
18191        let _deepseek_source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
18192        let _openrouter_key = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
18193        let cfg = Config {
18194            provider: Some("openrouter".to_string()),
18195            api_key: Some("legacy-deepseek-root-key".to_string()),
18196            ..Config::default()
18197        };
18198
18199        let source = resolve_api_key_source(&cfg);
18200
18201        assert_eq!(source, ApiKeySource::SecretStoreUnprobed);
18202    }
18203
18204    #[test]
18205    fn provider_status_helpers_use_provider_metadata() {
18206        assert_eq!(
18207            provider_config_table_key(crate::config::ApiProvider::Anthropic),
18208            "anthropic"
18209        );
18210        assert_eq!(
18211            provider_config_table_key(crate::config::ApiProvider::SiliconflowCn),
18212            "siliconflow_cn"
18213        );
18214    }
18215
18216    #[test]
18217    fn skills_count_for_returns_zero_for_missing_dir() {
18218        let tmp = TempDir::new().unwrap();
18219        let dir = tmp.path().join("nope");
18220        assert_eq!(skills_count_for(&dir), 0);
18221    }
18222
18223    #[test]
18224    fn skills_count_for_counts_valid_skill_dirs() {
18225        let tmp = TempDir::new().unwrap();
18226        let dir = tmp.path().join("skills");
18227        let skill_dir = dir.join("getting-started");
18228        std::fs::create_dir_all(&skill_dir).unwrap();
18229        std::fs::write(
18230            skill_dir.join("SKILL.md"),
18231            "---\nname: getting-started\ndescription: hi\n---\nbody",
18232        )
18233        .unwrap();
18234        assert_eq!(skills_count_for(&dir), 1);
18235    }
18236}
18237
18238#[cfg(test)]
18239#[path = "tests/pr_prompt.rs"]
18240mod pr_prompt_tests;
18241
18242#[cfg(test)]
18243#[path = "tests/telemetry_surface.rs"]
18244mod telemetry_surface_tests;
18245
18246#[cfg(test)]
18247#[path = "tests/telemetry_counters.rs"]
18248mod telemetry_counter_tests;