Skip to main content

codewhale_cli/
lib.rs

1#![allow(clippy::uninlined_format_args)]
2
3mod metrics;
4mod update;
5
6use std::io::{self, Read, Write};
7use std::net::SocketAddr;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use anyhow::{Context, Result, anyhow, bail};
12use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
13use clap_complete::{Shell, generate};
14use codewhale_agent::ModelRegistry;
15use codewhale_app_server::{
16    AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
17};
18use codewhale_config::{
19    CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,
20};
21use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
22use codewhale_mcp::{McpServerDefinition, run_stdio_server};
23use codewhale_secrets::Secrets;
24use codewhale_state::{StateStore, ThreadListFilters};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
27enum ProviderArg {
28    Deepseek,
29    NvidiaNim,
30    Openai,
31    Atlascloud,
32    WanjieArk,
33    Volcengine,
34    Openrouter,
35    XiaomiMimo,
36    Novita,
37    Fireworks,
38    Siliconflow,
39    #[value(
40        alias = "silicon-flow-cn",
41        alias = "siliconflow-CN",
42        alias = "silicon_flow_cn",
43        alias = "siliconflow_cn",
44        alias = "siliconflow-china",
45        alias = "siliconflow_china"
46    )]
47    SiliconflowCn,
48    Arcee,
49    Moonshot,
50    Sglang,
51    Vllm,
52    Ollama,
53    Huggingface,
54    Together,
55    OpenaiCodex,
56    Anthropic,
57    Zai,
58    Stepfun,
59    Minimax,
60    #[value(alias = "deep-infra", alias = "deep_infra")]
61    Deepinfra,
62}
63
64impl From<ProviderArg> for ProviderKind {
65    fn from(value: ProviderArg) -> Self {
66        match value {
67            ProviderArg::Deepseek => ProviderKind::Deepseek,
68            ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
69            ProviderArg::Openai => ProviderKind::Openai,
70            ProviderArg::Atlascloud => ProviderKind::Atlascloud,
71            ProviderArg::WanjieArk => ProviderKind::WanjieArk,
72            ProviderArg::Volcengine => ProviderKind::Volcengine,
73            ProviderArg::Openrouter => ProviderKind::Openrouter,
74            ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
75            ProviderArg::Novita => ProviderKind::Novita,
76            ProviderArg::Fireworks => ProviderKind::Fireworks,
77            ProviderArg::Siliconflow => ProviderKind::Siliconflow,
78            ProviderArg::SiliconflowCn => ProviderKind::SiliconflowCN,
79            ProviderArg::Arcee => ProviderKind::Arcee,
80            ProviderArg::Moonshot => ProviderKind::Moonshot,
81            ProviderArg::Sglang => ProviderKind::Sglang,
82            ProviderArg::Vllm => ProviderKind::Vllm,
83            ProviderArg::Ollama => ProviderKind::Ollama,
84            ProviderArg::Huggingface => ProviderKind::Huggingface,
85            ProviderArg::Together => ProviderKind::Together,
86            ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
87            ProviderArg::Anthropic => ProviderKind::Anthropic,
88            ProviderArg::Zai => ProviderKind::Zai,
89            ProviderArg::Stepfun => ProviderKind::Stepfun,
90            ProviderArg::Minimax => ProviderKind::Minimax,
91            ProviderArg::Deepinfra => ProviderKind::Deepinfra,
92        }
93    }
94}
95
96#[derive(Debug, Parser)]
97#[command(
98    name = "codewhale",
99    version = env!("DEEPSEEK_BUILD_VERSION"),
100    bin_name = "codewhale",
101    override_usage = "codewhale [OPTIONS] [PROMPT]\n       codewhale [OPTIONS] <COMMAND> [ARGS]"
102)]
103struct Cli {
104    #[arg(long)]
105    config: Option<PathBuf>,
106    #[arg(long)]
107    profile: Option<String>,
108    #[arg(
109        long,
110        value_enum,
111        help = "Advanced provider selector for non-TUI registry/config commands"
112    )]
113    provider: Option<ProviderArg>,
114    #[arg(long)]
115    model: Option<String>,
116    #[arg(long = "output-mode")]
117    output_mode: Option<String>,
118    #[arg(
119        long = "verbosity",
120        value_name = "LEVEL",
121        help = "Controls transcript and output verbosity (normal, concise)"
122    )]
123    verbosity: Option<String>,
124    #[arg(long = "log-level")]
125    log_level: Option<String>,
126    #[arg(long)]
127    telemetry: Option<bool>,
128    #[arg(long)]
129    approval_policy: Option<String>,
130    #[arg(long)]
131    sandbox_mode: Option<String>,
132    #[arg(long)]
133    api_key: Option<String>,
134    #[arg(long)]
135    base_url: Option<String>,
136    /// Workspace directory for TUI file tools
137    #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
138    workspace: Option<PathBuf>,
139    #[arg(long = "no-alt-screen", hide = true)]
140    no_alt_screen: bool,
141    #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
142    mouse_capture: bool,
143    #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
144    no_mouse_capture: bool,
145    #[arg(long = "skip-onboarding")]
146    skip_onboarding: bool,
147    /// YOLO mode: auto-approve all tools
148    #[arg(long)]
149    yolo: bool,
150    /// Continue the most recent interactive session for this workspace.
151    #[arg(short = 'c', long = "continue")]
152    continue_session: bool,
153    #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
154    prompt_flag: Option<String>,
155    #[arg(
156        value_name = "PROMPT",
157        trailing_var_arg = true,
158        allow_hyphen_values = true
159    )]
160    prompt: Vec<String>,
161    #[command(subcommand)]
162    command: Option<Commands>,
163}
164
165#[derive(Debug, Subcommand)]
166enum Commands {
167    /// Run interactive/non-interactive flows via the TUI binary.
168    Run(RunArgs),
169    /// Run CodeWhale diagnostics.
170    Doctor(TuiPassthroughArgs),
171    /// List live provider API models via the TUI binary.
172    Models(TuiPassthroughArgs),
173    /// Generate speech audio with Xiaomi MiMo TTS models via the TUI binary.
174    #[command(visible_alias = "tts")]
175    Speech(TuiPassthroughArgs),
176    /// List saved TUI sessions.
177    Sessions(TuiPassthroughArgs),
178    /// Resume a saved TUI session.
179    Resume(TuiPassthroughArgs),
180    /// Fork a saved TUI session.
181    Fork(TuiPassthroughArgs),
182    /// Create a default AGENTS.md in the current directory.
183    Init(TuiPassthroughArgs),
184    /// Bootstrap MCP config and/or skills directories.
185    Setup(TuiPassthroughArgs),
186    /// Generate a remote CodeWhale agent deploy bundle (cloud + chat bridge).
187    RemoteSetup(RemoteSetupArgs),
188    /// Run a non-interactive prompt through the TUI runtime.
189    #[command(after_help = "\
190Examples:
191  codewhale exec \"explain this function\"
192  codewhale exec --auto \"list crates/ with ls\"
193  codewhale exec --auto --output-format stream-json \"fix the failing test\"
194
195Common forwarded flags:
196  --auto                           Enable tool-backed agent mode with auto-approvals
197  --json                           Emit summary JSON
198  --resume <SESSION_ID>            Resume a previous session by ID or prefix
199  --session-id <SESSION_ID>        Resume a previous session by ID or prefix
200  --continue                       Continue the most recent session for this workspace
201  --output-format <FORMAT>         Output format: text or stream-json
202
203Plain `codewhale exec` is a one-shot model response. Use `--auto` for
204non-interactive filesystem/shell tool use, matching the supported automation
205path used by stream-json wrappers.
206")]
207    Exec(TuiPassthroughArgs),
208    /// Manage durable Agent Fleet runs via the TUI runtime.
209    Fleet(TuiPassthroughArgs),
210    /// Run a CodeWhale-powered code review over a git diff.
211    Review(TuiPassthroughArgs),
212    /// Apply a patch file or stdin to the working tree.
213    Apply(TuiPassthroughArgs),
214    /// Run the offline TUI evaluation harness.
215    Eval(TuiPassthroughArgs),
216    /// Manage TUI MCP servers.
217    Mcp(TuiPassthroughArgs),
218    /// Inspect TUI feature flags.
219    Features(TuiPassthroughArgs),
220    /// Run a local TUI server.
221    #[command(after_help = "\
222Forwarded serve options:
223      --mcp                 Start MCP server over stdio
224      --http                Start runtime HTTP/SSE API server
225      --mobile              Start runtime HTTP/SSE API server with the mobile control page
226      --qr                  Show a QR code for the mobile URL (requires --mobile)
227      --acp                 Start ACP server over stdio for editor clients
228      --host <HOST>         Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
229      --port <PORT>         Bind port [default: 7878]
230      --workers <WORKERS>   Background task worker count (1-8)
231      --cors-origin <URL>   Additional CORS origin to allow (repeatable)
232      --auth-token <TOKEN>  Require this bearer token for /v1/* runtime API routes
233      --insecure            Disable runtime API auth when no token is configured
234
235`codewhale serve --http` and `codewhale serve --mobile` remain compatibility
236aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
237New integrations should prefer `codewhale app-server`.")]
238    Serve(TuiPassthroughArgs),
239    /// Generate shell completions for the TUI binary.
240    Completions(TuiPassthroughArgs),
241    /// Configure provider credentials.
242    Login(LoginArgs),
243    /// Remove saved authentication state.
244    Logout,
245    /// Manage authentication credentials and provider mode.
246    Auth(AuthArgs),
247    /// Run MCP server mode over stdio.
248    McpServer,
249    /// Read/write/list config values.
250    Config(ConfigArgs),
251    /// Resolve or list available models across providers.
252    Model(ModelArgs),
253    /// Manage thread/session metadata and resume/fork flows.
254    Thread(ThreadArgs),
255    /// Evaluate sandbox/approval policy decisions.
256    Sandbox(SandboxArgs),
257    /// Run the canonical runtime API / control plane (HTTP/SSE, mobile, stdio).
258    #[command(after_help = "\
259Transports:
260  codewhale app-server --http              Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
261  codewhale app-server --mobile            Runtime API + phone control page (binds 0.0.0.0)
262  codewhale app-server --stdio             JSON-RPC control transport over stdio (no listener)
263  codewhale app-server                     Legacy in-process app-server HTTP on 127.0.0.1:8787
264
265`--http` and `--mobile` serve the same mature runtime API as `codewhale serve
266--http`/`--mobile`, which remain as compatibility aliases. The runtime API token
267is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
268
269See docs/RUNTIME_API.md.")]
270    AppServer(AppServerArgs),
271    /// Generate shell completions.
272    #[command(after_help = r#"Examples:
273  Bash (current shell only):
274    source <(codewhale completion bash)
275
276  Bash (persistent, Linux/bash-completion):
277    mkdir -p ~/.local/share/bash-completion/completions
278    codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
279    # Requires bash-completion to be installed and loaded by your shell.
280
281  Zsh:
282    mkdir -p ~/.zfunc
283    codewhale completion zsh > ~/.zfunc/_codewhale
284    # Add to ~/.zshrc if needed:
285    #   fpath=(~/.zfunc $fpath)
286    #   autoload -Uz compinit && compinit
287
288  Fish:
289    mkdir -p ~/.config/fish/completions
290    codewhale completion fish > ~/.config/fish/completions/codewhale.fish
291
292  PowerShell (current shell only):
293    codewhale completion powershell | Out-String | Invoke-Expression
294
295The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
296    Completion {
297        #[arg(value_enum)]
298        shell: Shell,
299    },
300    /// Print a usage rollup from the audit log and session store.
301    Metrics(MetricsArgs),
302    /// Check for and apply updates to the `codewhale` binary.
303    Update(UpdateArgs),
304}
305
306#[derive(Debug, Args)]
307struct UpdateArgs {
308    /// Update to the latest beta release instead of the latest stable release.
309    #[arg(long)]
310    beta: bool,
311    /// Only check the latest release; do not download or replace binaries.
312    #[arg(long)]
313    check: bool,
314    /// Proxy URL to use for update HTTP requests.
315    #[arg(long, value_name = "URL")]
316    proxy: Option<String>,
317}
318
319#[derive(Debug, Args)]
320struct MetricsArgs {
321    /// Emit machine-readable JSON.
322    #[arg(long)]
323    json: bool,
324    /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
325    #[arg(long, value_name = "DURATION")]
326    since: Option<String>,
327}
328
329#[derive(Debug, Args)]
330struct RunArgs {
331    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
332    args: Vec<String>,
333}
334
335#[derive(Debug, Args, Clone)]
336struct TuiPassthroughArgs {
337    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
338    args: Vec<String>,
339}
340
341/// Flags for `codewhale remote-setup`. Forwarded to the TUI binary, which owns
342/// the interactive wizard and bundle generation.
343#[derive(Debug, Args, Clone, Default)]
344struct RemoteSetupArgs {
345    /// Cloud target slug (lighthouse, azure, digitalocean). Skips the prompt.
346    #[arg(long)]
347    cloud: Option<String>,
348    /// Chat bridge slug (feishu, telegram). Skips the prompt.
349    #[arg(long)]
350    bridge: Option<String>,
351    /// Provider slug; validated against the provider registry. Skips the prompt.
352    #[arg(long)]
353    provider: Option<String>,
354    /// Bundle output directory (default `./codewhale-deploy/<cloud>-<bridge>`).
355    #[arg(long, value_name = "DIR")]
356    out: Option<PathBuf>,
357    /// Emit the bundle, do not provision (default).
358    #[arg(long, default_value_t = false)]
359    generate_only: bool,
360    /// Run the cloud CLI to auto-provision (not yet implemented).
361    #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
362    apply: bool,
363    /// Skip the final confirmation gate (CI / non-interactive).
364    #[arg(long, default_value_t = false)]
365    yes: bool,
366    /// Fail instead of prompting if any required value is missing.
367    #[arg(long, default_value_t = false)]
368    non_interactive: bool,
369}
370
371/// Build the forwarded argv for the TUI `remote-setup` subcommand from the
372/// structured CLI flags. Mirrors the named flags exactly so the TUI clap parser
373/// re-derives the same `RemoteSetupArgs`.
374fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
375    let mut forwarded = vec!["remote-setup".to_string()];
376    if let Some(cloud) = args.cloud {
377        forwarded.push("--cloud".to_string());
378        forwarded.push(cloud);
379    }
380    if let Some(bridge) = args.bridge {
381        forwarded.push("--bridge".to_string());
382        forwarded.push(bridge);
383    }
384    if let Some(provider) = args.provider {
385        forwarded.push("--provider".to_string());
386        forwarded.push(provider);
387    }
388    if let Some(out) = args.out {
389        forwarded.push("--out".to_string());
390        forwarded.push(out.to_string_lossy().into_owned());
391    }
392    if args.generate_only {
393        forwarded.push("--generate-only".to_string());
394    }
395    if args.apply {
396        forwarded.push("--apply".to_string());
397    }
398    if args.yes {
399        forwarded.push("--yes".to_string());
400    }
401    if args.non_interactive {
402        forwarded.push("--non-interactive".to_string());
403    }
404    forwarded
405}
406
407#[derive(Debug, Args)]
408struct LoginArgs {
409    #[arg(long, value_enum, hide = true)]
410    provider: Option<ProviderArg>,
411    #[arg(long)]
412    api_key: Option<String>,
413}
414
415#[derive(Debug, Args)]
416struct AuthArgs {
417    #[command(subcommand)]
418    command: AuthCommand,
419}
420
421#[derive(Debug, Subcommand)]
422enum AuthCommand {
423    /// Show current provider and credential source state.
424    /// Without `--provider`, shows all known providers.
425    /// With `--provider`, shows detailed status for that provider.
426    Status {
427        /// Show status for a specific provider only.
428        #[arg(long, value_enum)]
429        provider: Option<ProviderArg>,
430    },
431    /// Save an API key to the shared user config file. Reads from
432    /// `--api-key`, `--api-key-stdin`, or prompts on stdin when
433    /// neither is given. Does not echo the key.
434    Set {
435        #[arg(long, value_enum)]
436        provider: ProviderArg,
437        /// Inline value (discouraged — appears in shell history).
438        #[arg(long)]
439        api_key: Option<String>,
440        /// Read the key from stdin instead of prompting.
441        #[arg(long = "api-key-stdin", default_value_t = false)]
442        api_key_stdin: bool,
443    },
444    /// Report whether a provider has a key configured. Never prints
445    /// the value; just `set` / `not set` plus the source layer.
446    Get {
447        #[arg(long, value_enum)]
448        provider: ProviderArg,
449    },
450    /// Delete a provider's key from config and secret-store storage.
451    Clear {
452        #[arg(long, value_enum)]
453        provider: ProviderArg,
454    },
455    /// List all known providers with their auth state, without
456    /// revealing keys.
457    List,
458    /// Advanced: migrate config-file keys into a platform credential store.
459    #[command(hide = true)]
460    Migrate {
461        /// Don't actually write anything; print what would change.
462        #[arg(long, default_value_t = false)]
463        dry_run: bool,
464    },
465}
466
467#[derive(Debug, Args)]
468struct ConfigArgs {
469    #[command(subcommand)]
470    command: ConfigCommand,
471}
472
473#[derive(Debug, Subcommand)]
474enum ConfigCommand {
475    Get { key: String },
476    Set { key: String, value: String },
477    Unset { key: String },
478    List,
479    Path,
480}
481
482#[derive(Debug, Args)]
483struct ModelArgs {
484    #[command(subcommand)]
485    command: ModelCommand,
486}
487
488#[derive(Debug, Subcommand)]
489enum ModelCommand {
490    List {
491        #[arg(long, value_enum)]
492        provider: Option<ProviderArg>,
493    },
494    Resolve {
495        model: Option<String>,
496        #[arg(long, value_enum)]
497        provider: Option<ProviderArg>,
498    },
499    /// Set the default model (e.g. "pro", "flash", "deepseek-v4-pro").
500    Set { model: String },
501}
502
503#[derive(Debug, Args)]
504struct ThreadArgs {
505    #[command(subcommand)]
506    command: ThreadCommand,
507}
508
509#[derive(Debug, Subcommand)]
510enum ThreadCommand {
511    List {
512        #[arg(long, default_value_t = false)]
513        all: bool,
514        #[arg(long)]
515        limit: Option<usize>,
516    },
517    Read {
518        thread_id: String,
519    },
520    Resume {
521        thread_id: String,
522    },
523    Fork {
524        thread_id: String,
525    },
526    Archive {
527        thread_id: String,
528    },
529    Unarchive {
530        thread_id: String,
531    },
532    SetName {
533        thread_id: String,
534        name: String,
535    },
536    /// Remove the custom name from a thread, restoring the default
537    /// `(unnamed)` rendering in `thread list`.
538    ClearName {
539        thread_id: String,
540    },
541}
542
543#[derive(Debug, Args)]
544struct SandboxArgs {
545    #[command(subcommand)]
546    command: SandboxCommand,
547}
548
549#[derive(Debug, Subcommand)]
550enum SandboxCommand {
551    Check {
552        command: String,
553        #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
554        ask: ApprovalModeArg,
555    },
556}
557
558#[derive(Debug, Clone, Copy, ValueEnum)]
559enum ApprovalModeArg {
560    UnlessTrusted,
561    OnFailure,
562    OnRequest,
563    Never,
564}
565
566impl From<ApprovalModeArg> for AskForApproval {
567    fn from(value: ApprovalModeArg) -> Self {
568        match value {
569            ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
570            ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
571            ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
572            ApprovalModeArg::Never => AskForApproval::Never,
573        }
574    }
575}
576
577#[derive(Debug, Args)]
578struct AppServerArgs {
579    /// Serve the full HTTP/SSE runtime API (`/v1/*`: sessions, threads, turns,
580    /// approvals, events, usage, fleet, tasks). This is the canonical runtime
581    /// API surface; it delegates to the same server as `codewhale serve --http`.
582    #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
583    http: bool,
584    /// Serve the runtime API plus the phone-friendly mobile control page.
585    /// Equivalent to the legacy `codewhale serve --mobile`.
586    #[arg(long, conflicts_with = "stdio")]
587    mobile: bool,
588    /// Run the app-server JSON-RPC control transport over stdio (no listener).
589    /// Used by local SDKs and JSON-RPC integrations.
590    #[arg(long, default_value_t = false)]
591    stdio: bool,
592    /// Show a QR code for the mobile URL in the terminal (requires --mobile).
593    #[arg(long, requires = "mobile")]
594    qr: bool,
595    /// Bind host. Defaults to 127.0.0.1; with --mobile and no host, binds
596    /// 0.0.0.0 so LAN devices can reach the mobile page.
597    #[arg(long)]
598    host: Option<String>,
599    /// Bind port. Defaults to 7878 for --http/--mobile (the runtime API) and
600    /// 8787 for the legacy in-process app-server HTTP transport.
601    #[arg(long)]
602    port: Option<u16>,
603    /// Background task worker count (1-8). Only used with --http/--mobile.
604    #[arg(long)]
605    workers: Option<usize>,
606    #[arg(long)]
607    config: Option<PathBuf>,
608    #[arg(long = "auth-token")]
609    auth_token: Option<String>,
610    #[arg(long, default_value_t = false)]
611    insecure_no_auth: bool,
612    #[arg(long = "cors-origin")]
613    cors_origin: Vec<String>,
614}
615
616const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
617
618fn install_rustls_crypto_provider() {
619    let _ = rustls::crypto::ring::default_provider().install_default();
620}
621
622pub fn run_cli() -> std::process::ExitCode {
623    install_rustls_crypto_provider();
624
625    match run() {
626        Ok(()) => std::process::ExitCode::SUCCESS,
627        Err(err) => {
628            // Use the full anyhow chain so callers see the underlying
629            // cause (e.g. the actual TOML parse error with line/column)
630            // instead of just the top-level context message. The bare
631            // `{err}` Display impl drops the chain — see #767, where
632            // users hit "failed to parse config at <path>" with no
633            // hint that the real error was a stray BOM or unbalanced
634            // quote a few lines down.
635            eprintln!("error: {err}");
636            for cause in err.chain().skip(1) {
637                eprintln!("  caused by: {cause}");
638            }
639            std::process::ExitCode::FAILURE
640        }
641    }
642}
643
644fn run() -> Result<()> {
645    let mut cli = Cli::parse();
646
647    let mut store = ConfigStore::load(cli.config.clone())?;
648    let runtime_overrides = CliRuntimeOverrides {
649        provider: cli.provider.map(Into::into),
650        model: cli.model.clone(),
651        api_key: cli.api_key.clone(),
652        base_url: cli.base_url.clone(),
653        auth_mode: None,
654        output_mode: cli.output_mode.clone(),
655        log_level: cli.log_level.clone(),
656        telemetry: cli.telemetry,
657        approval_policy: cli.approval_policy.clone(),
658        sandbox_mode: cli.sandbox_mode.clone(),
659        yolo: Some(cli.yolo),
660        verbosity: cli.verbosity.clone(),
661    };
662    let command = cli.command.take();
663
664    match command {
665        Some(Commands::Run(args)) => {
666            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
667            delegate_to_tui(&cli, &resolved_runtime, args.args)
668        }
669        Some(Commands::Doctor(args)) => {
670            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
671            delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
672        }
673        Some(Commands::Models(args)) => {
674            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
675            delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
676        }
677        Some(Commands::Speech(args)) => {
678            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
679            delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
680        }
681        Some(Commands::Sessions(args)) => {
682            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
683            delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
684        }
685        Some(Commands::Resume(args)) => {
686            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
687            run_resume_command(&cli, &resolved_runtime, args)
688        }
689        Some(Commands::Fork(args)) => {
690            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
691            delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
692        }
693        Some(Commands::Init(args)) => {
694            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
695            delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
696        }
697        Some(Commands::Setup(args)) => {
698            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
699            delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
700        }
701        Some(Commands::RemoteSetup(args)) => {
702            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
703            delegate_to_tui(&cli, &resolved_runtime, remote_setup_tui_args(args))
704        }
705        Some(Commands::Exec(args)) => {
706            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
707            delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
708        }
709        Some(Commands::Fleet(args)) => {
710            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
711            delegate_to_tui(&cli, &resolved_runtime, tui_args("fleet", args))
712        }
713        Some(Commands::Review(args)) => {
714            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
715            delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
716        }
717        Some(Commands::Apply(args)) => {
718            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
719            delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
720        }
721        Some(Commands::Eval(args)) => {
722            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
723            delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
724        }
725        Some(Commands::Mcp(args)) => {
726            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
727            delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
728        }
729        Some(Commands::Features(args)) => {
730            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
731            delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
732        }
733        Some(Commands::Serve(args)) => {
734            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
735            // `serve` starts a long-running runtime API listener; supervise the
736            // delegated child so it is torn down with the dispatcher (#3259).
737            delegate_server_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
738        }
739        Some(Commands::Completions(args)) => {
740            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
741            delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
742        }
743        Some(Commands::Login(args)) => run_login_command(&mut store, args),
744        Some(Commands::Logout) => run_logout_command(&mut store),
745        Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
746        Some(Commands::McpServer) => run_mcp_server_command(&mut store),
747        Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
748        Some(Commands::Model(args)) => {
749            run_model_command(&mut store, args.command, runtime_overrides.provider)
750        }
751        Some(Commands::Thread(args)) => run_thread_command(args.command),
752        Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
753        Some(Commands::AppServer(args)) => {
754            // The HTTP/mobile runtime API is delegated to the mature `serve` path
755            // in the TUI binary, which reads the *global* --config. app-server has
756            // historically taken a subcommand-level --config, so bridge it before
757            // resolving runtime options (provider/keyring) for the delegated run.
758            if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
759                cli.config = args.config.clone();
760                store = ConfigStore::load(cli.config.clone())?;
761            }
762            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
763            run_app_server_command(&cli, &resolved_runtime, args)
764        }
765        Some(Commands::Completion { shell }) => {
766            let mut cmd = Cli::command();
767            generate(shell, &mut cmd, "codewhale", &mut io::stdout());
768            Ok(())
769        }
770        Some(Commands::Metrics(args)) => run_metrics_command(args),
771        Some(Commands::Update(args)) => update::run_update(args.beta, args.check, args.proxy),
772        None => {
773            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
774            let forwarded = root_tui_passthrough(&cli)?;
775            delegate_to_tui(&cli, &resolved_runtime, forwarded)
776        }
777    }
778}
779
780fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
781    let mut forwarded = Vec::new();
782    if cli.continue_session {
783        forwarded.push("--continue".to_string());
784    }
785
786    let prompt =
787        cli.prompt_flag
788            .iter()
789            .chain(cli.prompt.iter())
790            .fold(String::new(), |mut acc, part| {
791                if !acc.is_empty() {
792                    acc.push(' ');
793                }
794                acc.push_str(part);
795                acc
796            });
797    if !prompt.is_empty() {
798        if cli.continue_session {
799            bail!(
800                "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
801            );
802        }
803        forwarded.push("--prompt".to_string());
804        forwarded.push(prompt);
805    }
806
807    Ok(forwarded)
808}
809
810fn resolve_runtime_for_dispatch(
811    store: &mut ConfigStore,
812    runtime_overrides: &CliRuntimeOverrides,
813) -> ResolvedRuntimeOptions {
814    let runtime_secrets = Secrets::auto_detect();
815    resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
816}
817
818fn resolve_runtime_for_dispatch_with_secrets(
819    store: &mut ConfigStore,
820    runtime_overrides: &CliRuntimeOverrides,
821    secrets: &Secrets,
822) -> ResolvedRuntimeOptions {
823    let mut resolved = store
824        .config
825        .resolve_runtime_options_with_secrets(runtime_overrides, secrets);
826
827    if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)
828        && !provider_config_set(store, resolved.provider)
829        && let Some(api_key) = resolved.api_key.clone()
830    {
831        write_provider_api_key_to_config(store, resolved.provider, &api_key);
832        match store.save() {
833            Ok(()) => {
834                eprintln!(
835                    "info: recovered API key from secret store and saved it to {}",
836                    store.path().display()
837                );
838                resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);
839            }
840            Err(err) => {
841                eprintln!(
842                    "warning: recovered API key from secret store but failed to save {}: {err}",
843                    store.path().display()
844                );
845            }
846        }
847    }
848
849    resolved
850}
851
852fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
853    let mut forwarded = Vec::with_capacity(args.args.len() + 1);
854    forwarded.push(command.to_string());
855    forwarded.extend(args.args);
856    forwarded
857}
858
859fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
860    run_login_command_with_secrets(store, args, &Secrets::auto_detect())
861}
862
863fn run_login_command_with_secrets(
864    store: &mut ConfigStore,
865    args: LoginArgs,
866    secrets: &Secrets,
867) -> Result<()> {
868    let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
869    store.config.provider = provider;
870
871    let api_key = match args.api_key {
872        Some(v) => v,
873        None => read_api_key_from_stdin()?,
874    };
875    write_provider_api_key_to_config(store, provider, &api_key);
876    let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
877    store.save()?;
878    let destination = if keyring_saved {
879        format!("{} and {}", store.path().display(), secrets.backend_name())
880    } else {
881        store.path().display().to_string()
882    };
883    if provider == ProviderKind::Deepseek {
884        println!("logged in using API key mode (deepseek); saved key to {destination}");
885    } else {
886        println!(
887            "logged in using API key mode ({}); saved key to {destination}",
888            provider.as_str(),
889        );
890    }
891    Ok(())
892}
893
894fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
895    run_logout_command_with_secrets(store, &Secrets::auto_detect())
896}
897
898fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
899    let active_provider = store.config.provider;
900    store.config.api_key = None;
901    for provider in ProviderKind::ALL {
902        clear_provider_api_key_from_config(store, provider);
903    }
904    clear_provider_api_key_from_keyring(secrets, active_provider);
905    store.config.auth_mode = None;
906    store.save()?;
907    println!("logged out");
908    Ok(())
909}
910
911/// Map [`ProviderKind`] to the canonical provider credential slot.
912fn provider_slot(provider: ProviderKind) -> &'static str {
913    match provider {
914        // Keep the historical shared credential slot for the China endpoint.
915        ProviderKind::SiliconflowCN => "siliconflow",
916        _ => provider.provider().id(),
917    }
918}
919
920#[cfg(test)]
921fn no_keyring_secrets() -> Secrets {
922    Secrets::new(std::sync::Arc::new(
923        codewhale_secrets::InMemoryKeyringStore::new(),
924    ))
925}
926
927fn write_provider_api_key_to_config(
928    store: &mut ConfigStore,
929    provider: ProviderKind,
930    api_key: &str,
931) {
932    store.config.auth_mode = Some("api_key".to_string());
933    store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
934    if provider == ProviderKind::Deepseek {
935        store.config.api_key = Some(api_key.to_string());
936        if store.config.default_text_model.is_none() {
937            store.config.default_text_model = Some(
938                store
939                    .config
940                    .providers
941                    .deepseek
942                    .model
943                    .clone()
944                    .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
945            );
946        }
947    }
948}
949
950fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
951    store.config.providers.for_provider_mut(provider).api_key = None;
952    if provider == ProviderKind::Deepseek {
953        store.config.api_key = None;
954    }
955}
956
957fn provider_env_set(provider: ProviderKind) -> bool {
958    provider_env_value(provider).is_some()
959}
960
961fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
962    provider.provider().env_vars()
963}
964
965fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
966    provider_env_vars(provider).iter().find_map(|var| {
967        std::env::var(var)
968            .ok()
969            .filter(|value| !value.trim().is_empty())
970            .map(|value| (*var, value))
971    })
972}
973
974fn openai_codex_auth_file_path() -> PathBuf {
975    if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
976        let path = PathBuf::from(path);
977        if !path.as_os_str().is_empty() {
978            return path;
979        }
980    }
981
982    let codex_home = std::env::var("CODEX_HOME")
983        .map(PathBuf::from)
984        .unwrap_or_else(|_| {
985            dirs::home_dir()
986                .unwrap_or_else(|| PathBuf::from("."))
987                .join(".codex")
988        });
989    codex_home.join("auth.json")
990}
991
992fn provider_oauth_file_path(provider: ProviderKind) -> Option<PathBuf> {
993    (provider == ProviderKind::OpenaiCodex).then(openai_codex_auth_file_path)
994}
995
996fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
997    let slot = store
998        .config
999        .providers
1000        .for_provider(provider)
1001        .api_key
1002        .as_deref();
1003    let root = (provider == ProviderKind::Deepseek)
1004        .then_some(store.config.api_key.as_deref())
1005        .flatten();
1006    slot.or(root).filter(|v| !v.trim().is_empty())
1007}
1008
1009fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
1010    provider_config_api_key(store, provider).is_some()
1011}
1012
1013fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
1014    secrets
1015        .get(provider_slot(provider))
1016        .ok()
1017        .flatten()
1018        .filter(|v| !v.trim().is_empty())
1019}
1020
1021fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
1022    provider_keyring_api_key(secrets, provider).is_some()
1023}
1024
1025fn write_provider_api_key_to_keyring(
1026    secrets: &Secrets,
1027    provider: ProviderKind,
1028    api_key: &str,
1029) -> bool {
1030    secrets.set(provider_slot(provider), api_key).is_ok()
1031}
1032
1033fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
1034    let _ = secrets.delete(provider_slot(provider));
1035}
1036
1037fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
1038    let active_provider = store.config.provider;
1039    let mut lines = Vec::new();
1040    lines.push(format!(
1041        "active provider: {} (set via config or CODEWHALE_PROVIDER)",
1042        active_provider.as_str()
1043    ));
1044    lines.push(String::new());
1045    lines.push(format!(
1046        "{:<14} {:<8} {:<10} {:<8} {}",
1047        "provider", "config", "keyring", "env", "status"
1048    ));
1049    lines.push("-".repeat(70));
1050
1051    for provider in ProviderKind::ALL {
1052        let config_key = provider_config_api_key(store, provider);
1053        let keyring_key = provider_keyring_api_key(secrets, provider);
1054        let env_key = provider_env_value(provider);
1055        let oauth_file_present = provider_oauth_file_path(provider).is_some_and(|p| p.exists());
1056
1057        let config_status = config_key.map(|_| "set").unwrap_or("-");
1058        let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
1059        let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
1060
1061        let source = if provider == ProviderKind::OpenaiCodex {
1062            // Keep the summary consistent with `auth status`: Codex auth is
1063            // OAuth-file (or env token) based — config/keyring keys are not
1064            // consulted for it.
1065            if env_key.is_some() {
1066                "env"
1067            } else if oauth_file_present {
1068                "oauth file"
1069            } else {
1070                "unset"
1071            }
1072        } else if config_key.is_some() {
1073            "config"
1074        } else if keyring_key.is_some() {
1075            "keyring"
1076        } else if env_key.is_some() {
1077            "env"
1078        } else if oauth_file_present {
1079            "oauth file"
1080        } else {
1081            "unset"
1082        };
1083
1084        let active_marker = if provider == active_provider {
1085            " *"
1086        } else {
1087            ""
1088        };
1089
1090        lines.push(format!(
1091            "{:<14} {:<8} {:<10} {:<8} {}{}",
1092            provider.as_str(),
1093            config_status,
1094            keyring_status,
1095            env_status,
1096            source,
1097            active_marker
1098        ));
1099    }
1100
1101    lines.push(String::new());
1102    lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
1103    lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
1104    lines
1105}
1106
1107fn auth_status_lines_for_provider(
1108    store: &ConfigStore,
1109    secrets: &Secrets,
1110    provider: ProviderKind,
1111) -> Vec<String> {
1112    let config_key = provider_config_api_key(store, provider);
1113    let keyring_key = provider_keyring_api_key(secrets, provider);
1114    let env_key = provider_env_value(provider);
1115    let oauth_file = provider_oauth_file_path(provider);
1116    let oauth_file_present = oauth_file.as_ref().is_some_and(|path| path.exists());
1117
1118    let active_source = if provider == ProviderKind::OpenaiCodex {
1119        if env_key.is_some() {
1120            "env"
1121        } else if oauth_file_present {
1122            "Codex OAuth file"
1123        } else {
1124            "missing"
1125        }
1126    } else if config_key.is_some() {
1127        "config"
1128    } else if keyring_key.is_some() {
1129        "secret store"
1130    } else if env_key.is_some() {
1131        "env"
1132    } else {
1133        "missing"
1134    };
1135    let active_last4 = if provider == ProviderKind::OpenaiCodex {
1136        env_key.as_ref().map(|(_, value)| last4_label(value))
1137    } else {
1138        config_key
1139            .map(last4_label)
1140            .or_else(|| keyring_key.as_deref().map(last4_label))
1141            .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
1142    };
1143    let active_label = active_last4
1144        .map(|last4| format!("{active_source} (last4: {last4})"))
1145        .unwrap_or_else(|| active_source.to_string());
1146
1147    let env_var_label = env_key
1148        .as_ref()
1149        .map(|(name, _)| (*name).to_string())
1150        .unwrap_or_else(|| provider_env_vars(provider).join("/"));
1151    let env_status = env_key
1152        .as_ref()
1153        .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
1154        .unwrap_or_else(|| "unset".to_string());
1155
1156    let is_active = provider == store.config.provider;
1157    let active_marker = if is_active { " (active provider)" } else { "" };
1158
1159    let provider_cfg = store.config.providers.for_provider(provider);
1160    let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
1161    let model = provider_cfg.model.as_deref().unwrap_or("(default)");
1162
1163    let lookup_order = if provider == ProviderKind::OpenaiCodex {
1164        "lookup order: env -> Codex OAuth file".to_string()
1165    } else {
1166        "lookup order: config -> secret store -> env".to_string()
1167    };
1168    let auth_mode = if provider == ProviderKind::OpenaiCodex {
1169        "codex_oauth"
1170    } else {
1171        store.config.auth_mode.as_deref().unwrap_or("api_key")
1172    };
1173
1174    let mut lines = vec![
1175        format!("provider: {}{}", provider.as_str(), active_marker),
1176        format!("route: {}", base_url),
1177        format!("model: {}", model),
1178        format!("auth mode: {auth_mode}"),
1179        format!("active source: {active_label}"),
1180        lookup_order,
1181        format!(
1182            "config file: {} ({})",
1183            store.path().display(),
1184            source_status(config_key, "missing")
1185        ),
1186        format!(
1187            "secret store: {} ({})",
1188            secrets.backend_name(),
1189            source_status(keyring_key.as_deref(), "missing")
1190        ),
1191        format!("env var: {env_var_label} ({env_status})"),
1192    ];
1193    if let Some(path) = oauth_file {
1194        let status = if path.exists() { "present" } else { "missing" };
1195        lines.push(format!("Codex OAuth file: {} ({status})", path.display()));
1196    }
1197    lines
1198}
1199
1200fn source_status(value: Option<&str>, missing_label: &str) -> String {
1201    value
1202        .map(|v| format!("set, last4: {}", last4_label(v)))
1203        .unwrap_or_else(|| missing_label.to_string())
1204}
1205
1206fn last4_label(value: &str) -> String {
1207    let trimmed = value.trim();
1208    let chars: Vec<char> = trimmed.chars().collect();
1209    if chars.len() <= 4 {
1210        return "<redacted>".to_string();
1211    }
1212    let last4: String = chars[chars.len() - 4..].iter().collect();
1213    format!("...{last4}")
1214}
1215
1216fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
1217    run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
1218}
1219
1220fn run_auth_command_with_secrets(
1221    store: &mut ConfigStore,
1222    command: AuthCommand,
1223    secrets: &Secrets,
1224) -> Result<()> {
1225    match command {
1226        AuthCommand::Status { provider } => {
1227            match provider {
1228                Some(p) => {
1229                    let provider: ProviderKind = p.into();
1230                    for line in auth_status_lines_for_provider(store, secrets, provider) {
1231                        println!("{line}");
1232                    }
1233                }
1234                None => {
1235                    for line in auth_status_all_providers(store, secrets) {
1236                        println!("{line}");
1237                    }
1238                }
1239            }
1240            Ok(())
1241        }
1242        AuthCommand::Set {
1243            provider,
1244            api_key,
1245            api_key_stdin,
1246        } => {
1247            let provider: ProviderKind = provider.into();
1248            let slot = provider_slot(provider);
1249            if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
1250                let provider_cfg = store.config.providers.for_provider_mut(provider);
1251                if provider_cfg.base_url.is_none() {
1252                    provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
1253                }
1254                store.save()?;
1255                println!(
1256                    "configured {slot} provider in {} (API key optional)",
1257                    store.path().display()
1258                );
1259                return Ok(());
1260            }
1261            let api_key = match (api_key, api_key_stdin) {
1262                (Some(v), _) => v,
1263                (None, true) => read_api_key_from_stdin()?,
1264                (None, false) => prompt_api_key(slot)?,
1265            };
1266            write_provider_api_key_to_config(store, provider, &api_key);
1267            let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
1268            store.save()?;
1269            // Don't print the key. Don't echo length.
1270            if keyring_saved {
1271                println!(
1272                    "saved API key for {slot} to {} and {}",
1273                    store.path().display(),
1274                    secrets.backend_name()
1275                );
1276            } else {
1277                println!("saved API key for {slot} to {}", store.path().display());
1278            }
1279            Ok(())
1280        }
1281        AuthCommand::Get { provider } => {
1282            let provider: ProviderKind = provider.into();
1283            let slot = provider_slot(provider);
1284            let in_file = provider_config_set(store, provider);
1285            let in_keyring = !in_file && provider_keyring_set(secrets, provider);
1286            let in_env = provider_env_set(provider);
1287            // Report the highest-priority source that has it.
1288            let source = if in_file {
1289                Some("config-file")
1290            } else if in_keyring {
1291                Some("secret-store")
1292            } else if in_env {
1293                Some("env")
1294            } else {
1295                None
1296            };
1297            match source {
1298                Some(source) => println!("{slot}: set (source: {source})"),
1299                None => println!("{slot}: not set"),
1300            }
1301            Ok(())
1302        }
1303        AuthCommand::Clear { provider } => {
1304            let provider: ProviderKind = provider.into();
1305            let slot = provider_slot(provider);
1306            clear_provider_api_key_from_config(store, provider);
1307            clear_provider_api_key_from_keyring(secrets, provider);
1308            store.save()?;
1309            println!("cleared API key for {slot} from config and secret store");
1310            Ok(())
1311        }
1312        AuthCommand::List => {
1313            println!("provider     config store env  active");
1314            for provider in ProviderKind::ALL {
1315                let slot = provider_slot(provider);
1316                let file = provider_config_set(store, provider);
1317                let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
1318                let env = provider_env_set(provider);
1319                let active = if file {
1320                    "config"
1321                } else if keyring == Some(true) {
1322                    "store"
1323                } else if env {
1324                    "env"
1325                } else {
1326                    "missing"
1327                };
1328                println!(
1329                    "{slot:<12}  {}     {}      {}   {active}",
1330                    yes_no(file),
1331                    keyring_status_short(keyring),
1332                    yes_no(env)
1333                );
1334            }
1335            Ok(())
1336        }
1337        AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
1338    }
1339}
1340
1341fn yes_no(b: bool) -> &'static str {
1342    if b { "yes" } else { "no " }
1343}
1344
1345fn keyring_status_short(state: Option<bool>) -> &'static str {
1346    match state {
1347        Some(true) => "yes",
1348        Some(false) => "no ",
1349        None => "n/a",
1350    }
1351}
1352
1353fn prompt_api_key(slot: &str) -> Result<String> {
1354    use std::io::{IsTerminal, Write};
1355    eprint!("Enter API key for {slot}: ");
1356    io::stderr().flush().ok();
1357    if !io::stdin().is_terminal() {
1358        // Non-interactive: read directly without prompting twice.
1359        return read_api_key_from_stdin();
1360    }
1361    let mut buf = String::new();
1362    io::stdin()
1363        .read_line(&mut buf)
1364        .context("failed to read API key from stdin")?;
1365    let key = buf.trim().to_string();
1366    if key.is_empty() {
1367        bail!("empty API key provided");
1368    }
1369    Ok(key)
1370}
1371
1372/// Move plaintext keys from config.toml into the configured secret store.
1373/// Hidden in v0.8.8 because the normal setup path is config/env only.
1374fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
1375    let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
1376    let mut warnings: Vec<String> = Vec::new();
1377
1378    for provider in ProviderKind::ALL {
1379        let slot = provider_slot(provider);
1380        let from_provider_block = store
1381            .config
1382            .providers
1383            .for_provider(provider)
1384            .api_key
1385            .clone()
1386            .filter(|v| !v.trim().is_empty());
1387        let from_root = (provider == ProviderKind::Deepseek)
1388            .then(|| store.config.api_key.clone())
1389            .flatten()
1390            .filter(|v| !v.trim().is_empty());
1391        let value = from_provider_block.or(from_root);
1392        let Some(value) = value else { continue };
1393
1394        if let Ok(Some(existing)) = secrets.get(slot)
1395            && existing == value
1396        {
1397            // Already migrated; safe to strip the file slot.
1398        } else if dry_run {
1399            migrated.push((provider, slot));
1400            continue;
1401        } else if let Err(err) = secrets.set(slot, &value) {
1402            warnings.push(format!(
1403                "skipped {slot}: failed to write to secret store: {err}"
1404            ));
1405            continue;
1406        }
1407        if !dry_run {
1408            store.config.providers.for_provider_mut(provider).api_key = None;
1409            if provider == ProviderKind::Deepseek {
1410                store.config.api_key = None;
1411            }
1412        }
1413        migrated.push((provider, slot));
1414    }
1415
1416    if !dry_run && !migrated.is_empty() {
1417        store
1418            .save()
1419            .context("failed to write updated config.toml")?;
1420    }
1421
1422    println!("secret store backend: {}", secrets.backend_name());
1423    if migrated.is_empty() {
1424        println!("nothing to migrate (config.toml has no plaintext api_key entries)");
1425    } else {
1426        println!(
1427            "{} {} provider key(s):",
1428            if dry_run { "would migrate" } else { "migrated" },
1429            migrated.len()
1430        );
1431        for (_, slot) in &migrated {
1432            println!("  - {slot}");
1433        }
1434        if !dry_run {
1435            println!(
1436                "config.toml at {} no longer contains api_key entries for migrated providers.",
1437                store.path().display()
1438            );
1439        }
1440    }
1441    for w in warnings {
1442        eprintln!("warning: {w}");
1443    }
1444    Ok(())
1445}
1446
1447fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
1448    match command {
1449        ConfigCommand::Get { key } => {
1450            if let Some(value) = store.config.get_display_value(&key) {
1451                println!("{value}");
1452                return Ok(());
1453            }
1454            bail!("key not found: {key}");
1455        }
1456        ConfigCommand::Set { key, value } => {
1457            store.config.set_value(&key, &value)?;
1458            store.save()?;
1459            println!("set {key}");
1460            Ok(())
1461        }
1462        ConfigCommand::Unset { key } => {
1463            store.config.unset_value(&key)?;
1464            store.save()?;
1465            println!("unset {key}");
1466            Ok(())
1467        }
1468        ConfigCommand::List => {
1469            for (key, value) in store.config.list_values() {
1470                println!("{key} = {value}");
1471            }
1472            Ok(())
1473        }
1474        ConfigCommand::Path => {
1475            println!("{}", store.path().display());
1476            Ok(())
1477        }
1478    }
1479}
1480
1481fn model_command_provider_hint(
1482    command_provider: Option<ProviderArg>,
1483    top_level_provider: Option<ProviderKind>,
1484) -> Option<ProviderKind> {
1485    command_provider
1486        .map(ProviderKind::from)
1487        .or(top_level_provider)
1488}
1489
1490fn run_model_command(
1491    store: &mut ConfigStore,
1492    command: ModelCommand,
1493    top_level_provider: Option<ProviderKind>,
1494) -> Result<()> {
1495    let registry = ModelRegistry::default();
1496    match command {
1497        ModelCommand::List { provider } => {
1498            let filter = model_command_provider_hint(provider, top_level_provider);
1499            for model in registry.list().into_iter().filter(|m| match filter {
1500                Some(p) => m.provider == p,
1501                None => true,
1502            }) {
1503                println!("{} ({})", model.id, model.provider.as_str());
1504            }
1505            Ok(())
1506        }
1507        ModelCommand::Resolve { model, provider } => {
1508            let provider = model_command_provider_hint(provider, top_level_provider);
1509            let resolved = registry.resolve(model.as_deref(), provider);
1510            println!("requested: {}", resolved.requested.unwrap_or_default());
1511            println!("resolved: {}", resolved.resolved.id);
1512            println!("provider: {}", resolved.resolved.provider.as_str());
1513            println!("used_fallback: {}", resolved.used_fallback);
1514            Ok(())
1515        }
1516        ModelCommand::Set { model } => {
1517            let trimmed = model.trim();
1518            if trimmed.is_empty() {
1519                bail!("Model name cannot be empty");
1520            }
1521            let canonical = match trimmed.to_ascii_lowercase().as_str() {
1522                "pro" | "deepseek-v4pro" => "deepseek-v4-pro",
1523                "flash" | "deepseek-v4flash" => "deepseek-v4-flash",
1524                _ => trimmed,
1525            };
1526            store.config.default_text_model = Some(canonical.to_string());
1527            store.save()?;
1528            println!("Default model set to '{canonical}'");
1529            Ok(())
1530        }
1531    }
1532}
1533
1534fn run_thread_command(command: ThreadCommand) -> Result<()> {
1535    let state = StateStore::open(None)?;
1536    match command {
1537        ThreadCommand::List { all, limit } => {
1538            let threads = state.list_threads(ThreadListFilters {
1539                include_archived: all,
1540                limit,
1541            })?;
1542            for thread in threads {
1543                println!(
1544                    "{} | {} | {} | {}",
1545                    thread.id,
1546                    thread
1547                        .name
1548                        .clone()
1549                        .unwrap_or_else(|| "(unnamed)".to_string()),
1550                    thread.model_provider,
1551                    thread.cwd.display()
1552                );
1553            }
1554            Ok(())
1555        }
1556        ThreadCommand::Read { thread_id } => {
1557            let thread = state.get_thread(&thread_id)?;
1558            println!("{}", serde_json::to_string_pretty(&thread)?);
1559            Ok(())
1560        }
1561        ThreadCommand::Resume { thread_id } => {
1562            let args = vec!["resume".to_string(), thread_id];
1563            delegate_simple_tui(args)
1564        }
1565        ThreadCommand::Fork { thread_id } => {
1566            let args = vec!["fork".to_string(), thread_id];
1567            delegate_simple_tui(args)
1568        }
1569        ThreadCommand::Archive { thread_id } => {
1570            state.mark_archived(&thread_id)?;
1571            println!("archived {thread_id}");
1572            Ok(())
1573        }
1574        ThreadCommand::Unarchive { thread_id } => {
1575            state.mark_unarchived(&thread_id)?;
1576            println!("unarchived {thread_id}");
1577            Ok(())
1578        }
1579        ThreadCommand::SetName { thread_id, name } => {
1580            let mut thread = state
1581                .get_thread(&thread_id)?
1582                .with_context(|| format!("thread not found: {thread_id}"))?;
1583            thread.name = Some(name);
1584            thread.updated_at = chrono::Utc::now().timestamp();
1585            state.upsert_thread(&thread)?;
1586            println!("renamed {thread_id}");
1587            Ok(())
1588        }
1589        ThreadCommand::ClearName { thread_id } => {
1590            let mut thread = state
1591                .get_thread(&thread_id)?
1592                .with_context(|| format!("thread not found: {thread_id}"))?;
1593            thread.name = None;
1594            thread.updated_at = chrono::Utc::now().timestamp();
1595            state.upsert_thread(&thread)?;
1596            println!("cleared name for {thread_id}");
1597            Ok(())
1598        }
1599    }
1600}
1601
1602fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
1603    match command {
1604        SandboxCommand::Check { command, ask } => {
1605            let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
1606            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1607            let decision = engine.check(ExecPolicyContext {
1608                command: &command,
1609                cwd: &cwd.display().to_string(),
1610                tool: Some("exec_shell"),
1611                path: None,
1612                ask_for_approval: ask.into(),
1613                sandbox_mode: Some("workspace-write"),
1614            })?;
1615            println!("{}", serde_json::to_string_pretty(&decision)?);
1616            Ok(())
1617        }
1618    }
1619}
1620
1621fn run_app_server_command(
1622    cli: &Cli,
1623    resolved_runtime: &ResolvedRuntimeOptions,
1624    args: AppServerArgs,
1625) -> Result<()> {
1626    // The full runtime API lives in the TUI crate behind `serve --http`/`--mobile`.
1627    // Rather than duplicate ~6.5k lines or add a CLI→TUI crate dependency, the
1628    // canonical `app-server --http`/`--mobile` entrypoint reuses that mature server
1629    // by delegating to the sibling TUI binary (the same mechanism `serve` uses).
1630    if args.http || args.mobile {
1631        // Delegated runtime API listener — supervise it so the child does not
1632        // outlive the dispatcher (#3259).
1633        return delegate_server_to_tui(cli, resolved_runtime, app_server_serve_passthrough(&args));
1634    }
1635
1636    let runtime = tokio::runtime::Builder::new_multi_thread()
1637        .enable_all()
1638        .build()
1639        .context("failed to create tokio runtime")?;
1640    if args.stdio {
1641        return runtime.block_on(run_app_server_stdio(args.config));
1642    }
1643    // Legacy in-process app-server HTTP transport (`/healthz`, `/thread`, `/app`,
1644    // `/prompt`, `/tool`, `/jobs`). Kept for backward compatibility; defaults to
1645    // 127.0.0.1:8787 to avoid colliding with the runtime API default of :7878.
1646    let host = args.host.as_deref().unwrap_or("127.0.0.1");
1647    let port = args.port.unwrap_or(8787);
1648    let listen: SocketAddr = format!("{host}:{port}")
1649        .parse()
1650        .with_context(|| format!("invalid app-server listen address {host}:{port}"))?;
1651    runtime.block_on(run_app_server(AppServerOptions {
1652        listen,
1653        config_path: args.config,
1654        auth_token: args.auth_token.or_else(app_server_token_from_env),
1655        insecure_no_auth: args.insecure_no_auth,
1656        cors_origins: args.cors_origin,
1657    }))
1658}
1659
1660/// Build the `serve` argv forwarded to the TUI binary for
1661/// `codewhale app-server --http`/`--mobile`. Maps app-server flags onto the
1662/// matching `serve` flags (note `--insecure-no-auth` → `--insecure`). The
1663/// subcommand-level `--config` is bridged through the global `--config` in the
1664/// dispatcher, so it is intentionally not part of this passthrough. An auth
1665/// token from the environment is deliberately *not* forwarded into child argv;
1666/// the runtime API reads CODEWHALE_RUNTIME_TOKEN/DEEPSEEK_RUNTIME_TOKEN itself.
1667fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
1668    let mut forwarded = vec!["serve".to_string()];
1669    forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
1670    if let Some(host) = args.host.as_ref() {
1671        forwarded.push("--host".to_string());
1672        forwarded.push(host.clone());
1673    }
1674    if let Some(port) = args.port {
1675        forwarded.push("--port".to_string());
1676        forwarded.push(port.to_string());
1677    }
1678    if let Some(workers) = args.workers {
1679        forwarded.push("--workers".to_string());
1680        forwarded.push(workers.to_string());
1681    }
1682    for origin in &args.cors_origin {
1683        forwarded.push("--cors-origin".to_string());
1684        forwarded.push(origin.clone());
1685    }
1686    if let Some(token) = args.auth_token.as_ref() {
1687        forwarded.push("--auth-token".to_string());
1688        forwarded.push(token.clone());
1689    }
1690    if args.insecure_no_auth {
1691        forwarded.push("--insecure".to_string());
1692    }
1693    if args.qr {
1694        forwarded.push("--qr".to_string());
1695    }
1696    forwarded
1697}
1698
1699fn app_server_token_from_env() -> Option<String> {
1700    std::env::var("CODEWHALE_APP_SERVER_TOKEN")
1701        .ok()
1702        .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
1703}
1704
1705fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
1706    let persisted = load_mcp_server_definitions(store);
1707    let updated = run_stdio_server(persisted)?;
1708    persist_mcp_server_definitions(store, &updated)
1709}
1710
1711fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
1712    let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
1713        return Vec::new();
1714    };
1715
1716    match parse_mcp_server_definitions(&raw) {
1717        Ok(definitions) => definitions,
1718        Err(err) => {
1719            eprintln!(
1720                "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
1721            );
1722            Vec::new()
1723        }
1724    }
1725}
1726
1727fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
1728    if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
1729        return Ok(parsed);
1730    }
1731
1732    let unwrapped: String = serde_json::from_str(raw)
1733        .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
1734    serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
1735        format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
1736    })
1737}
1738
1739fn persist_mcp_server_definitions(
1740    store: &mut ConfigStore,
1741    definitions: &[McpServerDefinition],
1742) -> Result<()> {
1743    let encoded =
1744        serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
1745    store
1746        .config
1747        .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
1748    store.save()
1749}
1750
1751fn delegate_to_tui(
1752    cli: &Cli,
1753    resolved_runtime: &ResolvedRuntimeOptions,
1754    passthrough: Vec<String>,
1755) -> Result<()> {
1756    let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
1757    let tui = PathBuf::from(cmd.get_program());
1758    let status = cmd
1759        .status()
1760        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1761    exit_with_tui_status(status)
1762}
1763
1764/// Delegate a long-running server command (`serve --http`/`--mobile`,
1765/// `app-server --http`/`--mobile`) to the sibling TUI binary, supervising the
1766/// child so its listener does not outlive the dispatcher (#3259).
1767///
1768/// Plain [`delegate_to_tui`] blocks on `Command::status()`, which reaps the
1769/// child only on the child's own exit. If the dispatcher is terminated while
1770/// the delegated server is still running, the child can be reparented and keep
1771/// its listener bound. Here the child runs under a Tokio supervisor that
1772/// forwards termination (Ctrl+C / SIGTERM / SIGHUP) by killing and reaping the
1773/// child before the dispatcher exits, and `kill_on_drop` tears the child down
1774/// if the dispatcher unwinds.
1775///
1776/// For an *uncatchable* dispatcher death (SIGKILL, a hard crash) the Tokio
1777/// supervisor above can't run, so two OS-level safety nets are installed as
1778/// well (#3259): on Linux the child sets `PR_SET_PDEATHSIG` so the kernel
1779/// signals it when the dispatcher dies; on Windows the child is placed in a
1780/// kill-on-job-close Job Object so closing the dispatcher's handle (which the
1781/// OS does on process death) terminates it. macOS has no equivalent primitive,
1782/// so an uncatchable dispatcher death there can still orphan the child.
1783fn delegate_server_to_tui(
1784    cli: &Cli,
1785    resolved_runtime: &ResolvedRuntimeOptions,
1786    passthrough: Vec<String>,
1787) -> Result<()> {
1788    let mut std_cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
1789    install_server_parent_death_signal(&mut std_cmd);
1790    let tui = PathBuf::from(std_cmd.get_program());
1791    let runtime = tokio::runtime::Builder::new_current_thread()
1792        .enable_all()
1793        .build()
1794        .context("failed to create server-teardown runtime")?;
1795    runtime.block_on(async move {
1796        let mut cmd = tokio::process::Command::from(std_cmd);
1797        cmd.kill_on_drop(true);
1798        let mut child = cmd
1799            .spawn()
1800            .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1801        // Windows: hold a kill-on-job-close Job Object for the dispatcher's
1802        // lifetime so an uncatchable dispatcher death tears the child down.
1803        // Bound for the whole `block_on` scope; never dropped early because the
1804        // match arms below `std::process::exit`.
1805        #[cfg(windows)]
1806        let _child_job = attach_server_child_job(&child);
1807        match supervise_server_child(&mut child, server_shutdown_signal()).await? {
1808            ServerTeardown::Exited(status) => exit_with_tui_status(status),
1809            // The child has been killed and reaped; exit with the conventional
1810            // 128 + signal code for the signal that initiated the shutdown.
1811            ServerTeardown::Signaled(code) => std::process::exit(code),
1812        }
1813    })
1814}
1815
1816/// On Linux, ask the kernel to terminate the delegated server if the dispatcher
1817/// dies before it can run the graceful shutdown supervisor. This covers the
1818/// hard parent-death edge of #3259 for `SIGKILL`, OOM, or abrupt process exit.
1819#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1820fn install_server_parent_death_signal(cmd: &mut Command) {
1821    use std::os::unix::process::CommandExt;
1822    // SAFETY: `pre_exec` runs in the child between fork and exec. The closure
1823    // only calls `libc::prctl` with constant arguments and does not touch heap
1824    // memory or parent-held locks.
1825    unsafe {
1826        cmd.pre_exec(|| {
1827            let result = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0);
1828            if result == -1 {
1829                // Best effort: the child only loses this OS-level safety net.
1830                let _ = std::io::Error::last_os_error();
1831            }
1832            Ok(())
1833        });
1834    }
1835}
1836
1837#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
1838fn install_server_parent_death_signal(_cmd: &mut Command) {}
1839
1840/// Outcome of supervising a delegated server child.
1841#[derive(Debug)]
1842enum ServerTeardown {
1843    /// The child exited on its own; its status is carried for propagation.
1844    Exited(std::process::ExitStatus),
1845    /// A shutdown signal fired; the child was killed and reaped. Carries the
1846    /// conventional `128 + signal` exit code to propagate.
1847    Signaled(i32),
1848}
1849
1850/// Wait for the server `child` to exit, or for `shutdown` to fire first. On
1851/// shutdown, kill the child and reap it so no listener is left reparented.
1852async fn supervise_server_child<F>(
1853    child: &mut tokio::process::Child,
1854    shutdown: F,
1855) -> io::Result<ServerTeardown>
1856where
1857    F: std::future::Future<Output = i32>,
1858{
1859    tokio::select! {
1860        status = child.wait() => Ok(ServerTeardown::Exited(status?)),
1861        code = shutdown => {
1862            // Send the kill, then wait so the PID is reaped before the
1863            // dispatcher returns and exits.
1864            let _ = child.start_kill();
1865            let _ = child.wait().await;
1866            Ok(ServerTeardown::Signaled(code))
1867        }
1868    }
1869}
1870
1871/// Resolve when the dispatcher should tear down a delegated server child, and
1872/// the conventional `128 + signal` exit code to propagate: Ctrl+C on every
1873/// platform (130), plus SIGTERM (143) and SIGHUP (129) on Unix.
1874#[cfg(unix)]
1875async fn server_shutdown_signal() -> i32 {
1876    use tokio::signal::unix::{SignalKind, signal};
1877    let mut terminate = signal(SignalKind::terminate()).ok();
1878    let mut hangup = signal(SignalKind::hangup()).ok();
1879    let term = async {
1880        match terminate.as_mut() {
1881            Some(s) => {
1882                s.recv().await;
1883            }
1884            None => std::future::pending::<()>().await,
1885        }
1886    };
1887    let hup = async {
1888        match hangup.as_mut() {
1889            Some(s) => {
1890                s.recv().await;
1891            }
1892            None => std::future::pending::<()>().await,
1893        }
1894    };
1895    tokio::select! {
1896        _ = tokio::signal::ctrl_c() => 130,
1897        _ = term => 143,
1898        _ = hup => 129,
1899    }
1900}
1901
1902#[cfg(not(unix))]
1903async fn server_shutdown_signal() -> i32 {
1904    let _ = tokio::signal::ctrl_c().await;
1905    130
1906}
1907
1908/// Assign the delegated server `child` to a kill-on-job-close Job Object so the
1909/// OS terminates it when the dispatcher's handle to the job closes — which it
1910/// does on any dispatcher exit, including an uncatchable kill (#3259). The
1911/// returned guard must be held for the dispatcher's lifetime. Best-effort:
1912/// returns `None` if the job cannot be created or assigned. Mirrors the Job
1913/// Object idiom in `crates/tui/src/tools/shell.rs`.
1914#[cfg(windows)]
1915fn attach_server_child_job(child: &tokio::process::Child) -> Option<ServerChildJob> {
1916    let Some(child_handle) = child.raw_handle() else {
1917        tracing::warn!("delegated server child exited before a job object could be attached");
1918        return None;
1919    };
1920
1921    match ServerChildJob::attach(child_handle) {
1922        Ok(job) => Some(job),
1923        Err(err) => {
1924            tracing::warn!("failed to place delegated server child in a job object: {err}");
1925            None
1926        }
1927    }
1928}
1929
1930#[cfg(windows)]
1931struct ServerChildJob {
1932    handle: windows::Win32::Foundation::HANDLE,
1933}
1934
1935// SAFETY: the wrapped value is a process-wide kernel handle; moving it across
1936// threads does not invalidate it, and it is only ever closed once, on drop.
1937#[cfg(windows)]
1938unsafe impl Send for ServerChildJob {}
1939
1940#[cfg(windows)]
1941impl ServerChildJob {
1942    fn attach(child_handle: std::os::windows::io::RawHandle) -> std::io::Result<Self> {
1943        use windows::Win32::Foundation::HANDLE;
1944        use windows::Win32::System::JobObjects::{
1945            AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
1946            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
1947            SetInformationJobObject,
1948        };
1949        use windows::core::PCWSTR;
1950
1951        // SAFETY: FFI calls with valid arguments; results are checked via the
1952        // `windows` Result wrappers and the handle is stored for close-on-drop.
1953        let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) }.map_err(win_io_error)?;
1954        let job = Self { handle };
1955
1956        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
1957        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
1958        unsafe {
1959            SetInformationJobObject(
1960                job.handle,
1961                JobObjectExtendedLimitInformation,
1962                &limits as *const _ as *const core::ffi::c_void,
1963                std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
1964            )
1965            .map_err(win_io_error)?;
1966            AssignProcessToJobObject(job.handle, HANDLE(child_handle)).map_err(win_io_error)?;
1967        }
1968        Ok(job)
1969    }
1970}
1971
1972#[cfg(windows)]
1973impl Drop for ServerChildJob {
1974    fn drop(&mut self) {
1975        // Closing the last handle triggers KILL_ON_JOB_CLOSE. On a normal return
1976        // the child has already been reaped, so this is a no-op cleanup; an
1977        // uncatchable dispatcher death closes the handle via the OS instead.
1978        unsafe {
1979            let _ = windows::Win32::Foundation::CloseHandle(self.handle);
1980        }
1981    }
1982}
1983
1984#[cfg(windows)]
1985fn win_io_error(err: windows::core::Error) -> std::io::Error {
1986    std::io::Error::other(err)
1987}
1988
1989#[cfg(all(test, unix))]
1990mod server_teardown_tests {
1991    use super::*;
1992
1993    #[tokio::test]
1994    async fn supervisor_propagates_child_exit_when_no_shutdown() {
1995        // `true` exits immediately with success; a never-firing shutdown must
1996        // let the child's own exit win.
1997        let mut child = tokio::process::Command::new("true")
1998            .kill_on_drop(true)
1999            .spawn()
2000            .expect("spawn true");
2001        let outcome = supervise_server_child(&mut child, std::future::pending::<i32>())
2002            .await
2003            .expect("supervise");
2004        match outcome {
2005            ServerTeardown::Exited(status) => assert!(status.success()),
2006            other => panic!("expected Exited, got {other:?}"),
2007        }
2008    }
2009
2010    #[tokio::test]
2011    async fn shutdown_signal_kills_and_reaps_long_running_child() {
2012        // A long-lived child stands in for the delegated server listener; the
2013        // regression is that it outlives dispatcher teardown (#3259).
2014        let mut child = tokio::process::Command::new("sleep")
2015            .arg("30")
2016            .kill_on_drop(true)
2017            .spawn()
2018            .expect("spawn sleep");
2019        assert!(
2020            child.id().is_some(),
2021            "child should be running before shutdown"
2022        );
2023        // A ready future models an immediate shutdown signal carrying the
2024        // SIGTERM exit code (143).
2025        let outcome = supervise_server_child(&mut child, async { 143 })
2026            .await
2027            .expect("supervise");
2028        assert!(matches!(outcome, ServerTeardown::Signaled(143)));
2029        // Once supervise returns the child has been killed AND reaped, so tokio
2030        // drops the recorded pid — no listener is left reparented.
2031        assert!(
2032            child.id().is_none(),
2033            "delegated child must be reaped after dispatcher teardown"
2034        );
2035    }
2036
2037    #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
2038    #[test]
2039    fn parent_death_signal_hook_does_not_break_spawn() {
2040        let mut cmd = Command::new("true");
2041        install_server_parent_death_signal(&mut cmd);
2042        let status = cmd.status().expect("spawn true with parent-death hook");
2043        assert!(status.success());
2044    }
2045}
2046
2047fn run_resume_command(
2048    cli: &Cli,
2049    resolved_runtime: &ResolvedRuntimeOptions,
2050    args: TuiPassthroughArgs,
2051) -> Result<()> {
2052    let passthrough = tui_args("resume", args);
2053    if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
2054        return run_dispatcher_resume_picker(cli, resolved_runtime);
2055    }
2056    delegate_to_tui(cli, resolved_runtime, passthrough)
2057}
2058
2059fn run_dispatcher_resume_picker(
2060    cli: &Cli,
2061    resolved_runtime: &ResolvedRuntimeOptions,
2062) -> Result<()> {
2063    let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
2064    let tui = PathBuf::from(sessions_cmd.get_program());
2065    let status = sessions_cmd
2066        .status()
2067        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
2068    if !status.success() {
2069        return exit_with_tui_status(status);
2070    }
2071
2072    println!();
2073    println!("Windows note: enter a session id or prefix from the list above.");
2074    println!("You can also run `codewhale resume --last` to skip this prompt.");
2075    print!("Session id/prefix (Enter to cancel): ");
2076    io::stdout().flush()?;
2077
2078    let mut input = String::new();
2079    io::stdin()
2080        .read_line(&mut input)
2081        .context("failed to read session selection")?;
2082    let session_id = input.trim();
2083    if session_id.is_empty() {
2084        bail!("No session selected.");
2085    }
2086
2087    delegate_to_tui(
2088        cli,
2089        resolved_runtime,
2090        vec!["resume".to_string(), session_id.to_string()],
2091    )
2092}
2093
2094fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
2095    is_windows && passthrough == ["resume"]
2096}
2097
2098fn build_tui_command(
2099    cli: &Cli,
2100    resolved_runtime: &ResolvedRuntimeOptions,
2101    passthrough: Vec<String>,
2102) -> Result<Command> {
2103    let tui = locate_sibling_tui_binary()?;
2104    let mut verbosity = resolved_runtime.verbosity.clone();
2105    if verbosity.is_none()
2106        && passthrough
2107            .iter()
2108            .any(|arg| matches!(arg.as_str(), "exec" | "eval"))
2109    {
2110        verbosity = Some("concise".to_string());
2111    }
2112
2113    let mut cmd = Command::new(&tui);
2114    if let Some(config) = cli.config.as_ref() {
2115        cmd.arg("--config").arg(config);
2116    }
2117    if let Some(profile) = cli.profile.as_ref() {
2118        cmd.arg("--profile").arg(profile);
2119    }
2120    if let Some(workspace) = cli.workspace.as_ref() {
2121        cmd.arg("--workspace").arg(workspace);
2122    }
2123    // Accepted for older scripts, but no longer forwarded: the interactive TUI
2124    // always owns the alternate screen to avoid host scrollback hijacking.
2125    let _ = cli.no_alt_screen;
2126    if cli.mouse_capture {
2127        cmd.arg("--mouse-capture");
2128    }
2129    if cli.no_mouse_capture {
2130        cmd.arg("--no-mouse-capture");
2131    }
2132    if cli.skip_onboarding {
2133        cmd.arg("--skip-onboarding");
2134    }
2135    cmd.args(passthrough);
2136
2137    let keyring_bridge_provider = resolved_runtime.provider;
2138    let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
2139    let keyring_bridge_source = resolved_runtime.api_key_source;
2140
2141    if let Some(provider) = cli.provider.map(ProviderKind::from) {
2142        cmd.env("DEEPSEEK_PROVIDER", provider.as_str());
2143    }
2144    if matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
2145        && let Some(api_key) = keyring_bridge_api_key
2146    {
2147        // TUI reloads auth_mode from config/profile, but it does not re-query the
2148        // platform keyring on normal startup. Bridge only the recovered secret;
2149        // replaying auth_mode here would turn it back into a profile override.
2150        cmd.env("DEEPSEEK_API_KEY", api_key);
2151        for var in provider_env_vars(keyring_bridge_provider) {
2152            if *var != "DEEPSEEK_API_KEY" {
2153                cmd.env(var, api_key);
2154            }
2155        }
2156        cmd.env(
2157            "DEEPSEEK_API_KEY_SOURCE",
2158            RuntimeApiKeySource::Keyring.as_env_value(),
2159        );
2160    }
2161
2162    if let Some(model) = cli.model.as_ref() {
2163        cmd.env("DEEPSEEK_MODEL", model);
2164    }
2165    if let Some(output_mode) = cli.output_mode.as_ref() {
2166        cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
2167    }
2168    if let Some(v) = verbosity.as_ref() {
2169        cmd.env("CODEWHALE_VERBOSITY", v);
2170        cmd.env("DEEPSEEK_VERBOSITY", v);
2171    }
2172    if let Some(log_level) = cli.log_level.as_ref() {
2173        cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
2174    }
2175    if let Some(telemetry) = cli.telemetry {
2176        cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
2177    }
2178    if let Some(policy) = cli.approval_policy.as_ref() {
2179        cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
2180    }
2181    if let Some(mode) = cli.sandbox_mode.as_ref() {
2182        cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
2183    }
2184    if cli.yolo {
2185        cmd.env("DEEPSEEK_YOLO", "true");
2186    }
2187    if let Some(api_key) = cli.api_key.as_ref() {
2188        cmd.env("DEEPSEEK_API_KEY", api_key);
2189        for var in provider_env_vars(resolved_runtime.provider) {
2190            if *var != "DEEPSEEK_API_KEY" {
2191                cmd.env(var, api_key);
2192            }
2193        }
2194        cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
2195    }
2196    if let Some(base_url) = cli.base_url.as_ref() {
2197        cmd.env("DEEPSEEK_BASE_URL", base_url);
2198    }
2199
2200    Ok(cmd)
2201}
2202
2203fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
2204    match status.code() {
2205        Some(code) => std::process::exit(code),
2206        None => bail!("codewhale-tui terminated by signal"),
2207    }
2208}
2209
2210fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
2211    let tui = locate_sibling_tui_binary()?;
2212    let status = Command::new(&tui)
2213        .args(args)
2214        .status()
2215        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
2216    match status.code() {
2217        Some(code) => std::process::exit(code),
2218        None => bail!("codewhale-tui terminated by signal"),
2219    }
2220}
2221
2222fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
2223    format!(
2224        "failed to spawn companion TUI binary at {}: {err}\n\
2225\n\
2226The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
2227to execute it. Common fixes:\n\
2228  - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
2229  - On Windows, run `where codewhale` and `where codewhale-tui`; both should \
2230come from the same install directory.\n\
2231  - If you downloaded release assets manually, keep both `codewhale` and \
2232`codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
2233  - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \
2234binary.",
2235        tui.display()
2236    )
2237}
2238
2239/// Resolve the sibling `codewhale-tui` executable next to the running
2240/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so
2241/// the npm-distributed Windows package — which ships
2242/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).
2243///
2244/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for
2245/// custom installs and CI test layouts. On Windows we additionally try
2246/// the suffix-less name as a fallback for users who already manually
2247/// renamed the file before this fix landed.
2248fn locate_sibling_tui_binary() -> Result<PathBuf> {
2249    if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
2250        let candidate = PathBuf::from(override_path);
2251        if candidate.is_file() {
2252            return Ok(candidate);
2253        }
2254        bail!(
2255            "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
2256            candidate.display()
2257        );
2258    }
2259
2260    let current = std::env::current_exe().context("failed to locate current executable path")?;
2261    if let Some(found) = sibling_tui_candidate(&current) {
2262        return Ok(found);
2263    }
2264
2265    // Build a stable error path so the user sees the platform-correct
2266    // expected name, not "codewhale-tui" on Windows.
2267    let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
2268    bail!(
2269        "Companion `codewhale-tui` binary not found at {}.\n\
2270\n\
2271The `codewhale` dispatcher delegates interactive sessions to a sibling \
2272`codewhale-tui` binary. To fix this, install one of:\n\
2273  • npm:    npm install -g codewhale                (downloads both binaries)\n\
2274  • cargo:  cargo install codewhale-cli codewhale-tui --locked\n\
2275  • GitHub Releases: download BOTH `codewhale-<platform>` AND \
2276`codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
2277and place them in the same directory.\n\
2278\n\
2279Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.",
2280        expected.display()
2281    );
2282}
2283
2284/// Return the first existing sibling-binary path under any of the names
2285/// `codewhale-tui` might use on this platform. Pure function to keep
2286/// `locate_sibling_tui_binary` testable.
2287fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
2288    // Primary: platform-correct name. EXE_SUFFIX is "" on Unix and ".exe"
2289    // on Windows.
2290    let primary =
2291        dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
2292    if primary.is_file() {
2293        return Some(primary);
2294    }
2295    // Windows fallback: a user who manually renamed `.exe` away (per the
2296    // workaround in #247) still launches successfully under the new code.
2297    if cfg!(windows) {
2298        let suffixless = dispatcher.with_file_name("codewhale-tui");
2299        if suffixless.is_file() {
2300            return Some(suffixless);
2301        }
2302    }
2303    None
2304}
2305
2306fn run_metrics_command(args: MetricsArgs) -> Result<()> {
2307    let since = match args.since.as_deref() {
2308        Some(s) => {
2309            Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
2310        }
2311        None => None,
2312    };
2313    metrics::run(metrics::MetricsArgs {
2314        json: args.json,
2315        since,
2316    })
2317}
2318
2319fn read_api_key_from_stdin() -> Result<String> {
2320    let mut input = String::new();
2321    io::stdin()
2322        .read_to_string(&mut input)
2323        .context("failed to read api key from stdin")?;
2324    let key = input.trim().to_string();
2325    if key.is_empty() {
2326        bail!("empty API key provided");
2327    }
2328    Ok(key)
2329}
2330
2331#[cfg(test)]
2332mod tests {
2333    use super::*;
2334    use clap::error::ErrorKind;
2335    use codewhale_config::ProviderSource;
2336    use std::ffi::OsString;
2337    use std::sync::{Mutex, OnceLock};
2338
2339    fn parse_ok(argv: &[&str]) -> Cli {
2340        Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
2341    }
2342
2343    fn help_for(argv: &[&str]) -> String {
2344        let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
2345        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
2346        err.to_string()
2347    }
2348
2349    fn command_env(cmd: &Command, name: &str) -> Option<String> {
2350        let name = std::ffi::OsStr::new(name);
2351        cmd.get_envs().find_map(|(key, value)| {
2352            if key == name {
2353                value.map(|v| v.to_string_lossy().into_owned())
2354            } else {
2355                None
2356            }
2357        })
2358    }
2359
2360    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2361        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2362        LOCK.get_or_init(|| Mutex::new(()))
2363            .lock()
2364            .unwrap_or_else(|p| p.into_inner())
2365    }
2366
2367    struct ScopedEnvVar {
2368        name: &'static str,
2369        previous: Option<OsString>,
2370    }
2371
2372    impl ScopedEnvVar {
2373        fn set(name: &'static str, value: &str) -> Self {
2374            let previous = std::env::var_os(name);
2375            // Safety: tests using this helper serialize with env_lock() and
2376            // restore the original value in Drop.
2377            unsafe { std::env::set_var(name, value) };
2378            Self { name, previous }
2379        }
2380    }
2381
2382    impl Drop for ScopedEnvVar {
2383        fn drop(&mut self) {
2384            // Safety: tests using this helper serialize with env_lock().
2385            unsafe {
2386                if let Some(previous) = self.previous.take() {
2387                    std::env::set_var(self.name, previous);
2388                } else {
2389                    std::env::remove_var(self.name);
2390                }
2391            }
2392        }
2393    }
2394
2395    fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
2396        let dir = tempfile::TempDir::new().expect("tempdir");
2397        let custom = dir
2398            .path()
2399            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2400        std::fs::write(&custom, b"").unwrap();
2401        let custom_str = custom.to_string_lossy().into_owned();
2402        let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2403        (dir, bin)
2404    }
2405
2406    fn resolved_runtime_for_test(
2407        provider: ProviderKind,
2408        provider_source: ProviderSource,
2409    ) -> ResolvedRuntimeOptions {
2410        ResolvedRuntimeOptions {
2411            provider,
2412            provider_source,
2413            model: "test-model".to_string(),
2414            api_key: None,
2415            api_key_source: None,
2416            base_url: "http://localhost:8000/v1".to_string(),
2417            auth_mode: None,
2418            insecure_skip_tls_verify: false,
2419            output_mode: None,
2420            log_level: None,
2421            telemetry: false,
2422            approval_policy: None,
2423            sandbox_mode: None,
2424            yolo: None,
2425            verbosity: None,
2426            http_headers: std::collections::BTreeMap::new(),
2427        }
2428    }
2429
2430    #[test]
2431    fn clap_command_definition_is_consistent() {
2432        Cli::command().debug_assert();
2433    }
2434
2435    // Regression for #767: `run_cli` prints the full anyhow chain so users
2436    // see the underlying TOML parser error (line/column, expected token)
2437    // instead of just the top-level "failed to parse config at <path>"
2438    // wrapper. anyhow's bare `Display` impl drops the chain — pin both
2439    // pieces here so a future refactor of the printing path doesn't
2440    // silently regress.
2441    #[test]
2442    fn anyhow_chain_surfaces_toml_parse_cause() {
2443        use anyhow::Context;
2444        let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
2445        let err = Err::<(), _>(inner)
2446            .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
2447            .unwrap_err();
2448
2449        // What `eprintln!("error: {err}")` prints (top context only).
2450        assert_eq!(
2451            err.to_string(),
2452            "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
2453        );
2454
2455        // What the `for cause in err.chain().skip(1)` loop iterates over.
2456        let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
2457        assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
2458    }
2459
2460    #[test]
2461    fn parses_config_command_matrix() {
2462        let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
2463        assert!(matches!(
2464            cli.command,
2465            Some(Commands::Config(ConfigArgs {
2466                command: ConfigCommand::Get { ref key }
2467            })) if key == "provider"
2468        ));
2469
2470        let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
2471        assert!(matches!(
2472            cli.command,
2473            Some(Commands::Config(ConfigArgs {
2474                command: ConfigCommand::Set { ref key, ref value }
2475            })) if key == "model" && value == "deepseek-v4-flash"
2476        ));
2477
2478        let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
2479        assert!(matches!(
2480            cli.command,
2481            Some(Commands::Config(ConfigArgs {
2482                command: ConfigCommand::Unset { ref key }
2483            })) if key == "model"
2484        ));
2485
2486        assert!(matches!(
2487            parse_ok(&["deepseek", "config", "list"]).command,
2488            Some(Commands::Config(ConfigArgs {
2489                command: ConfigCommand::List
2490            }))
2491        ));
2492        assert!(matches!(
2493            parse_ok(&["deepseek", "config", "path"]).command,
2494            Some(Commands::Config(ConfigArgs {
2495                command: ConfigCommand::Path
2496            }))
2497        ));
2498    }
2499
2500    #[test]
2501    fn parses_update_beta_flag() {
2502        let cli = parse_ok(&["codewhale", "update"]);
2503        assert!(matches!(
2504            cli.command,
2505            Some(Commands::Update(UpdateArgs {
2506                beta: false,
2507                check: false,
2508                proxy: None
2509            }))
2510        ));
2511
2512        let cli = parse_ok(&["codewhale", "update", "--beta"]);
2513        assert!(matches!(
2514            cli.command,
2515            Some(Commands::Update(UpdateArgs {
2516                beta: true,
2517                check: false,
2518                proxy: None
2519            }))
2520        ));
2521
2522        let cli = parse_ok(&["codewhale", "update", "--check"]);
2523        assert!(matches!(
2524            cli.command,
2525            Some(Commands::Update(UpdateArgs {
2526                beta: false,
2527                check: true,
2528                proxy: None
2529            }))
2530        ));
2531
2532        let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
2533        let Some(Commands::Update(args)) = cli.command else {
2534            panic!("expected update command");
2535        };
2536        assert!(!args.beta);
2537        assert!(!args.check);
2538        assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
2539    }
2540
2541    #[test]
2542    fn parses_model_command_matrix() {
2543        let cli = parse_ok(&["deepseek", "model", "list"]);
2544        assert!(matches!(
2545            cli.command,
2546            Some(Commands::Model(ModelArgs {
2547                command: ModelCommand::List { provider: None }
2548            }))
2549        ));
2550
2551        let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
2552        assert!(matches!(
2553            cli.command,
2554            Some(Commands::Model(ModelArgs {
2555                command: ModelCommand::List {
2556                    provider: Some(ProviderArg::Openai)
2557                }
2558            }))
2559        ));
2560
2561        let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
2562        assert!(matches!(
2563            cli.command,
2564            Some(Commands::Model(ModelArgs {
2565                command: ModelCommand::Resolve {
2566                    model: Some(ref model),
2567                    provider: None
2568                }
2569            })) if model == "deepseek-v4-flash"
2570        ));
2571
2572        let cli = parse_ok(&[
2573            "deepseek",
2574            "model",
2575            "resolve",
2576            "--provider",
2577            "deepseek",
2578            "deepseek-v4-pro",
2579        ]);
2580        assert!(matches!(
2581            cli.command,
2582            Some(Commands::Model(ModelArgs {
2583                command: ModelCommand::Resolve {
2584                    model: Some(ref model),
2585                    provider: Some(ProviderArg::Deepseek)
2586                }
2587            })) if model == "deepseek-v4-pro"
2588        ));
2589
2590        let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
2591        assert!(matches!(
2592            cli.command,
2593            Some(Commands::Model(ModelArgs {
2594                command: ModelCommand::Set { ref model }
2595            })) if model == "pro"
2596        ));
2597    }
2598
2599    #[test]
2600    fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
2601        assert_eq!(
2602            model_command_provider_hint(None, Some(ProviderKind::Zai)),
2603            Some(ProviderKind::Zai)
2604        );
2605        assert_eq!(
2606            model_command_provider_hint(Some(ProviderArg::Minimax), Some(ProviderKind::Zai)),
2607            Some(ProviderKind::Minimax)
2608        );
2609        assert_eq!(model_command_provider_hint(None, None), None);
2610
2611        let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
2612        assert_eq!(cli.provider, Some(ProviderArg::Zai));
2613        assert!(matches!(
2614            cli.command,
2615            Some(Commands::Model(ModelArgs {
2616                command: ModelCommand::List { provider: None }
2617            }))
2618        ));
2619    }
2620
2621    #[test]
2622    fn parses_thread_command_matrix() {
2623        let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
2624        assert!(matches!(
2625            cli.command,
2626            Some(Commands::Thread(ThreadArgs {
2627                command: ThreadCommand::List {
2628                    all: true,
2629                    limit: Some(50)
2630                }
2631            }))
2632        ));
2633
2634        let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
2635        assert!(matches!(
2636            cli.command,
2637            Some(Commands::Thread(ThreadArgs {
2638                command: ThreadCommand::Read { ref thread_id }
2639            })) if thread_id == "thread-1"
2640        ));
2641
2642        let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
2643        assert!(matches!(
2644            cli.command,
2645            Some(Commands::Thread(ThreadArgs {
2646                command: ThreadCommand::Resume { ref thread_id }
2647            })) if thread_id == "thread-2"
2648        ));
2649
2650        let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
2651        assert!(matches!(
2652            cli.command,
2653            Some(Commands::Thread(ThreadArgs {
2654                command: ThreadCommand::Fork { ref thread_id }
2655            })) if thread_id == "thread-3"
2656        ));
2657
2658        let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
2659        assert!(matches!(
2660            cli.command,
2661            Some(Commands::Thread(ThreadArgs {
2662                command: ThreadCommand::Archive { ref thread_id }
2663            })) if thread_id == "thread-4"
2664        ));
2665
2666        let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
2667        assert!(matches!(
2668            cli.command,
2669            Some(Commands::Thread(ThreadArgs {
2670                command: ThreadCommand::Unarchive { ref thread_id }
2671            })) if thread_id == "thread-5"
2672        ));
2673
2674        let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
2675        assert!(matches!(
2676            cli.command,
2677            Some(Commands::Thread(ThreadArgs {
2678                command: ThreadCommand::SetName {
2679                    ref thread_id,
2680                    ref name
2681                }
2682            })) if thread_id == "thread-6" && name == "My Thread"
2683        ));
2684
2685        let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
2686        assert!(matches!(
2687            cli.command,
2688            Some(Commands::Thread(ThreadArgs {
2689                command: ThreadCommand::ClearName { ref thread_id }
2690            })) if thread_id == "thread-7"
2691        ));
2692    }
2693
2694    #[test]
2695    fn parses_sandbox_app_server_and_completion_matrix() {
2696        let cli = parse_ok(&[
2697            "deepseek",
2698            "sandbox",
2699            "check",
2700            "echo hello",
2701            "--ask",
2702            "on-failure",
2703        ]);
2704        assert!(matches!(
2705            cli.command,
2706            Some(Commands::Sandbox(SandboxArgs {
2707                command: SandboxCommand::Check {
2708                    ref command,
2709                    ask: ApprovalModeArg::OnFailure
2710                }
2711            })) if command == "echo hello"
2712        ));
2713
2714        let cli = parse_ok(&[
2715            "deepseek",
2716            "app-server",
2717            "--host",
2718            "0.0.0.0",
2719            "--port",
2720            "9999",
2721        ]);
2722        assert!(matches!(
2723            cli.command,
2724            Some(Commands::AppServer(AppServerArgs {
2725                host: Some(ref host),
2726                port: Some(9999),
2727                stdio: false,
2728                http: false,
2729                mobile: false,
2730                ..
2731            })) if host == "0.0.0.0"
2732        ));
2733
2734        let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
2735        assert!(matches!(
2736            cli.command,
2737            Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
2738        ));
2739
2740        let cli = parse_ok(&["deepseek", "completion", "bash"]);
2741        assert!(matches!(
2742            cli.command,
2743            Some(Commands::Completion { shell: Shell::Bash })
2744        ));
2745    }
2746
2747    #[test]
2748    fn app_server_transports_are_mutually_exclusive() {
2749        assert!(matches!(
2750            parse_ok(&["deepseek", "app-server", "--http"]).command,
2751            Some(Commands::AppServer(AppServerArgs {
2752                http: true,
2753                mobile: false,
2754                stdio: false,
2755                ..
2756            }))
2757        ));
2758        assert!(matches!(
2759            parse_ok(&["deepseek", "app-server", "--mobile"]).command,
2760            Some(Commands::AppServer(AppServerArgs {
2761                mobile: true,
2762                http: false,
2763                stdio: false,
2764                ..
2765            }))
2766        ));
2767
2768        for argv in [
2769            ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
2770            ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
2771            ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
2772        ] {
2773            let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
2774            assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
2775        }
2776    }
2777
2778    #[test]
2779    fn app_server_qr_requires_mobile() {
2780        let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
2781            .expect_err("--qr without --mobile must fail");
2782        assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
2783        assert!(matches!(
2784            parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
2785            Some(Commands::AppServer(AppServerArgs {
2786                mobile: true,
2787                qr: true,
2788                ..
2789            }))
2790        ));
2791    }
2792
2793    #[test]
2794    fn app_server_serve_passthrough_maps_flags_to_serve() {
2795        let args = AppServerArgs {
2796            http: true,
2797            mobile: false,
2798            stdio: false,
2799            qr: false,
2800            host: Some("127.0.0.1".to_string()),
2801            port: Some(9000),
2802            workers: Some(4),
2803            config: None,
2804            auth_token: Some("tok".to_string()),
2805            insecure_no_auth: true,
2806            cors_origin: vec!["http://localhost:5173".to_string()],
2807        };
2808        let argv = app_server_serve_passthrough(&args);
2809        let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
2810        // app-server's --insecure-no-auth maps onto serve's --insecure.
2811        assert_eq!(
2812            as_str,
2813            vec![
2814                "serve",
2815                "--http",
2816                "--host",
2817                "127.0.0.1",
2818                "--port",
2819                "9000",
2820                "--workers",
2821                "4",
2822                "--cors-origin",
2823                "http://localhost:5173",
2824                "--auth-token",
2825                "tok",
2826                "--insecure",
2827            ]
2828        );
2829    }
2830
2831    #[test]
2832    fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
2833        let args = AppServerArgs {
2834            http: false,
2835            mobile: true,
2836            stdio: false,
2837            qr: true,
2838            host: None,
2839            port: None,
2840            workers: None,
2841            config: None,
2842            auth_token: None,
2843            insecure_no_auth: false,
2844            cors_origin: vec![],
2845        };
2846        let argv = app_server_serve_passthrough(&args);
2847        let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
2848        // No host/port forwarded → serve applies its own --mobile 0.0.0.0 default.
2849        // No auth token is injected from the environment into child argv.
2850        assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
2851    }
2852
2853    #[test]
2854    fn serve_help_documents_forwarded_runtime_modes() {
2855        let help = help_for(&["codewhale", "serve", "--help"]);
2856        for flag in ["--http", "--mobile", "--mcp", "--acp"] {
2857            assert!(
2858                help.contains(flag),
2859                "serve help should document forwarded flag {flag}; help was:\n{help}"
2860            );
2861        }
2862        assert!(help.contains("compatibility"));
2863    }
2864
2865    #[test]
2866    fn parses_direct_tui_command_aliases() {
2867        let cli = parse_ok(&["deepseek", "doctor"]);
2868        assert!(matches!(
2869            cli.command,
2870            Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
2871        ));
2872
2873        let cli = parse_ok(&["deepseek", "models", "--json"]);
2874        assert!(matches!(
2875            cli.command,
2876            Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
2877        ));
2878
2879        let cli = parse_ok(&["deepseek", "resume", "abc123"]);
2880        assert!(matches!(
2881            cli.command,
2882            Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
2883        ));
2884
2885        let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
2886        assert!(matches!(
2887            cli.command,
2888            Some(Commands::Setup(TuiPassthroughArgs { ref args }))
2889                if args == &["--skills", "--local"]
2890        ));
2891
2892        let cli = parse_ok(&["codewhale", "fleet", "init"]);
2893        assert!(cli.prompt.is_empty());
2894        assert!(matches!(
2895            cli.command,
2896            Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
2897        ));
2898
2899        let cli = parse_ok(&[
2900            "codewhale",
2901            "fleet",
2902            "run",
2903            "tasks.json",
2904            "--max-workers",
2905            "2",
2906        ]);
2907        assert!(cli.prompt.is_empty());
2908        assert!(matches!(
2909            cli.command,
2910            Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
2911                if args == &["run", "tasks.json", "--max-workers", "2"]
2912        ));
2913    }
2914
2915    #[test]
2916    fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
2917        assert!(should_pick_resume_in_dispatcher(
2918            &["resume".to_string()],
2919            true
2920        ));
2921        assert!(!should_pick_resume_in_dispatcher(
2922            &["resume".to_string(), "--last".to_string()],
2923            true
2924        ));
2925        assert!(!should_pick_resume_in_dispatcher(
2926            &["resume".to_string(), "abc123".to_string()],
2927            true
2928        ));
2929        assert!(!should_pick_resume_in_dispatcher(
2930            &["resume".to_string()],
2931            false
2932        ));
2933    }
2934
2935    #[test]
2936    fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
2937        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2938        let path = std::env::temp_dir().join(format!(
2939            "deepseek-cli-login-test-{}-{nanos}.toml",
2940            std::process::id()
2941        ));
2942        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2943        let secrets = no_keyring_secrets();
2944
2945        run_login_command_with_secrets(
2946            &mut store,
2947            LoginArgs {
2948                provider: Some(ProviderArg::Deepseek),
2949                api_key: Some("sk-test".to_string()),
2950            },
2951            &secrets,
2952        )
2953        .expect("login should write config");
2954
2955        assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
2956        assert_eq!(
2957            store.config.providers.deepseek.api_key.as_deref(),
2958            Some("sk-test")
2959        );
2960        assert_eq!(
2961            store.config.default_text_model.as_deref(),
2962            Some("deepseek-v4-pro")
2963        );
2964        let saved = std::fs::read_to_string(&path).expect("config should be written");
2965        assert!(saved.contains("api_key = \"sk-test\""));
2966        assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
2967
2968        let _ = std::fs::remove_file(path);
2969    }
2970
2971    #[test]
2972    fn parses_auth_subcommand_matrix() {
2973        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
2974        assert!(matches!(
2975            cli.command,
2976            Some(Commands::Auth(AuthArgs {
2977                command: AuthCommand::Set {
2978                    provider: ProviderArg::Deepseek,
2979                    api_key: None,
2980                    api_key_stdin: false,
2981                }
2982            }))
2983        ));
2984
2985        let cli = parse_ok(&[
2986            "deepseek",
2987            "auth",
2988            "set",
2989            "--provider",
2990            "openrouter",
2991            "--api-key-stdin",
2992        ]);
2993        assert!(matches!(
2994            cli.command,
2995            Some(Commands::Auth(AuthArgs {
2996                command: AuthCommand::Set {
2997                    provider: ProviderArg::Openrouter,
2998                    api_key: None,
2999                    api_key_stdin: true,
3000                }
3001            }))
3002        ));
3003
3004        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
3005        assert!(matches!(
3006            cli.command,
3007            Some(Commands::Auth(AuthArgs {
3008                command: AuthCommand::Get {
3009                    provider: ProviderArg::Novita
3010                }
3011            }))
3012        ));
3013
3014        let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
3015        assert!(matches!(
3016            cli.command,
3017            Some(Commands::Auth(AuthArgs {
3018                command: AuthCommand::Clear {
3019                    provider: ProviderArg::NvidiaNim
3020                }
3021            }))
3022        ));
3023
3024        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
3025        assert!(matches!(
3026            cli.command,
3027            Some(Commands::Auth(AuthArgs {
3028                command: AuthCommand::Set {
3029                    provider: ProviderArg::Fireworks,
3030                    api_key: None,
3031                    api_key_stdin: false,
3032                }
3033            }))
3034        ));
3035
3036        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
3037        assert!(matches!(
3038            cli.command,
3039            Some(Commands::Auth(AuthArgs {
3040                command: AuthCommand::Set {
3041                    provider: ProviderArg::Siliconflow,
3042                    api_key: None,
3043                    api_key_stdin: false,
3044                }
3045            }))
3046        ));
3047
3048        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
3049        assert!(matches!(
3050            cli.command,
3051            Some(Commands::Auth(AuthArgs {
3052                command: AuthCommand::Set {
3053                    provider: ProviderArg::Arcee,
3054                    api_key: None,
3055                    api_key_stdin: false,
3056                }
3057            }))
3058        ));
3059
3060        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
3061        assert!(matches!(
3062            cli.command,
3063            Some(Commands::Auth(AuthArgs {
3064                command: AuthCommand::Set {
3065                    provider: ProviderArg::Moonshot,
3066                    api_key: None,
3067                    api_key_stdin: false,
3068                }
3069            }))
3070        ));
3071
3072        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
3073        assert!(matches!(
3074            cli.command,
3075            Some(Commands::Auth(AuthArgs {
3076                command: AuthCommand::Set {
3077                    provider: ProviderArg::WanjieArk,
3078                    api_key: None,
3079                    api_key_stdin: false,
3080                }
3081            }))
3082        ));
3083
3084        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
3085        assert!(matches!(
3086            cli.command,
3087            Some(Commands::Auth(AuthArgs {
3088                command: AuthCommand::Get {
3089                    provider: ProviderArg::Sglang
3090                }
3091            }))
3092        ));
3093
3094        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
3095        assert!(matches!(
3096            cli.command,
3097            Some(Commands::Auth(AuthArgs {
3098                command: AuthCommand::Get {
3099                    provider: ProviderArg::Vllm
3100                }
3101            }))
3102        ));
3103
3104        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
3105        assert!(matches!(
3106            cli.command,
3107            Some(Commands::Auth(AuthArgs {
3108                command: AuthCommand::Set {
3109                    provider: ProviderArg::Ollama,
3110                    api_key: None,
3111                    api_key_stdin: false,
3112                }
3113            }))
3114        ));
3115
3116        let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
3117        assert!(matches!(
3118            cli.command,
3119            Some(Commands::Auth(AuthArgs {
3120                command: AuthCommand::Status {
3121                    provider: Some(ProviderArg::OpenaiCodex)
3122                }
3123            }))
3124        ));
3125
3126        for (provider, expected) in [
3127            ("anthropic", ProviderArg::Anthropic),
3128            ("zai", ProviderArg::Zai),
3129            ("stepfun", ProviderArg::Stepfun),
3130            ("minimax", ProviderArg::Minimax),
3131            ("deepinfra", ProviderArg::Deepinfra),
3132            ("deep-infra", ProviderArg::Deepinfra),
3133            ("siliconflow-cn", ProviderArg::SiliconflowCn),
3134            ("siliconflow-CN", ProviderArg::SiliconflowCn),
3135            ("siliconflow_china", ProviderArg::SiliconflowCn),
3136        ] {
3137            let cli = parse_ok(&[
3138                "deepseek",
3139                "auth",
3140                "set",
3141                "--provider",
3142                provider,
3143                "--api-key-stdin",
3144            ]);
3145            assert!(matches!(
3146                cli.command,
3147                Some(Commands::Auth(AuthArgs {
3148                    command: AuthCommand::Set {
3149                        provider,
3150                        api_key: None,
3151                        api_key_stdin: true,
3152                    }
3153                })) if provider == expected
3154            ));
3155        }
3156
3157        let cli = parse_ok(&["deepseek", "auth", "list"]);
3158        assert!(matches!(
3159            cli.command,
3160            Some(Commands::Auth(AuthArgs {
3161                command: AuthCommand::List
3162            }))
3163        ));
3164
3165        let cli = parse_ok(&["deepseek", "auth", "migrate"]);
3166        assert!(matches!(
3167            cli.command,
3168            Some(Commands::Auth(AuthArgs {
3169                command: AuthCommand::Migrate { dry_run: false }
3170            }))
3171        ));
3172
3173        let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
3174        assert!(matches!(
3175            cli.command,
3176            Some(Commands::Auth(AuthArgs {
3177                command: AuthCommand::Migrate { dry_run: true }
3178            }))
3179        ));
3180    }
3181
3182    #[test]
3183    fn auth_set_writes_to_shared_config_file() {
3184        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3185        use std::sync::Arc;
3186
3187        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3188        let path = std::env::temp_dir().join(format!(
3189            "deepseek-cli-auth-set-test-{}-{nanos}.toml",
3190            std::process::id()
3191        ));
3192        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3193        let inner = Arc::new(InMemoryKeyringStore::new());
3194        let secrets = Secrets::new(inner.clone());
3195
3196        run_auth_command_with_secrets(
3197            &mut store,
3198            AuthCommand::Set {
3199                provider: ProviderArg::Deepseek,
3200                api_key: Some("sk-keyring".to_string()),
3201                api_key_stdin: false,
3202            },
3203            &secrets,
3204        )
3205        .expect("set should succeed");
3206
3207        assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
3208        assert_eq!(
3209            store.config.providers.deepseek.api_key.as_deref(),
3210            Some("sk-keyring")
3211        );
3212        let saved = std::fs::read_to_string(&path).unwrap_or_default();
3213        assert!(saved.contains("api_key = \"sk-keyring\""));
3214        assert_eq!(
3215            inner.get("deepseek").unwrap().as_deref(),
3216            Some("sk-keyring")
3217        );
3218
3219        let _ = std::fs::remove_file(path);
3220    }
3221
3222    #[test]
3223    fn auth_set_provider_key_does_not_switch_active_provider() {
3224        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3225        let path = std::env::temp_dir().join(format!(
3226            "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
3227            std::process::id()
3228        ));
3229        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3230        store.config.provider = ProviderKind::Deepseek;
3231        let secrets = no_keyring_secrets();
3232
3233        run_auth_command_with_secrets(
3234            &mut store,
3235            AuthCommand::Set {
3236                provider: ProviderArg::Arcee,
3237                api_key: Some("arcee-key".to_string()),
3238                api_key_stdin: false,
3239            },
3240            &secrets,
3241        )
3242        .expect("set should succeed");
3243
3244        assert_eq!(store.config.provider, ProviderKind::Deepseek);
3245        assert_eq!(
3246            store.config.providers.arcee.api_key.as_deref(),
3247            Some("arcee-key")
3248        );
3249
3250        let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
3251        assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
3252        assert_eq!(
3253            reloaded.config.providers.arcee.api_key.as_deref(),
3254            Some("arcee-key")
3255        );
3256
3257        let _ = std::fs::remove_file(path);
3258    }
3259
3260    #[test]
3261    fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
3262        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3263        let path = std::env::temp_dir().join(format!(
3264            "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
3265            std::process::id()
3266        ));
3267        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3268        store.config.provider = ProviderKind::Deepseek;
3269        let secrets = no_keyring_secrets();
3270
3271        run_auth_command_with_secrets(
3272            &mut store,
3273            AuthCommand::Set {
3274                provider: ProviderArg::Ollama,
3275                api_key: None,
3276                api_key_stdin: false,
3277            },
3278            &secrets,
3279        )
3280        .expect("ollama auth set should not require a key");
3281
3282        assert_eq!(store.config.provider, ProviderKind::Deepseek);
3283        assert_eq!(
3284            store.config.providers.ollama.base_url.as_deref(),
3285            Some("http://localhost:11434/v1")
3286        );
3287        assert_eq!(store.config.providers.ollama.api_key, None);
3288
3289        let _ = std::fs::remove_file(path);
3290    }
3291
3292    #[test]
3293    fn auth_clear_removes_from_config() {
3294        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3295        use std::sync::Arc;
3296
3297        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3298        let path = std::env::temp_dir().join(format!(
3299            "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
3300            std::process::id()
3301        ));
3302        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3303        store.config.api_key = Some("sk-stale".to_string());
3304        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
3305        store.save().unwrap();
3306
3307        let inner = Arc::new(InMemoryKeyringStore::new());
3308        inner.set("deepseek", "sk-stale").unwrap();
3309        let secrets = Secrets::new(inner.clone());
3310
3311        run_auth_command_with_secrets(
3312            &mut store,
3313            AuthCommand::Clear {
3314                provider: ProviderArg::Deepseek,
3315            },
3316            &secrets,
3317        )
3318        .expect("clear should succeed");
3319
3320        assert!(store.config.api_key.is_none());
3321        assert!(store.config.providers.deepseek.api_key.is_none());
3322        assert_eq!(inner.get("deepseek").unwrap(), None);
3323
3324        let _ = std::fs::remove_file(path);
3325    }
3326
3327    #[test]
3328    fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
3329        use codewhale_secrets::{KeyringStore, SecretsError};
3330        use std::sync::{Arc, Mutex};
3331
3332        #[derive(Default)]
3333        struct RecordingStore {
3334            gets: Mutex<Vec<String>>,
3335        }
3336
3337        impl KeyringStore for RecordingStore {
3338            fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
3339                self.gets.lock().unwrap().push(key.to_string());
3340                Ok(None)
3341            }
3342
3343            fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
3344                Ok(())
3345            }
3346
3347            fn delete(&self, _key: &str) -> Result<(), SecretsError> {
3348                Ok(())
3349            }
3350
3351            fn backend_name(&self) -> &'static str {
3352                "recording"
3353            }
3354        }
3355
3356        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3357        let path = std::env::temp_dir().join(format!(
3358            "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
3359            std::process::id()
3360        ));
3361        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3362        store.config.provider = ProviderKind::Deepseek;
3363        let inner = Arc::new(RecordingStore::default());
3364        let secrets = Secrets::new(inner.clone());
3365
3366        run_auth_command_with_secrets(
3367            &mut store,
3368            AuthCommand::Status {
3369                provider: Some(ProviderArg::Deepseek),
3370            },
3371            &secrets,
3372        )
3373        .expect("status should succeed");
3374        run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
3375            .expect("list should succeed");
3376
3377        let probed = inner.gets.lock().unwrap();
3378        // Scoped status probes only the requested provider.
3379        assert_eq!(probed[0], "deepseek");
3380        // List now probes all providers (not just active) to fix the
3381        // stale keyring-only-for-active-provider bug.
3382        assert!(probed.len() > 1, "list should probe all providers");
3383        assert!(
3384            ProviderKind::ALL
3385                .iter()
3386                .all(|p| probed.contains(&provider_slot(*p).to_string())),
3387            "every known provider should be probed by auth list: {:?}",
3388            *probed
3389        );
3390
3391        let _ = std::fs::remove_file(path);
3392    }
3393
3394    #[test]
3395    fn auth_status_reports_all_active_provider_sources_with_last4() {
3396        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3397        use std::sync::Arc;
3398
3399        let _lock = env_lock();
3400        let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
3401
3402        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3403        let path = std::env::temp_dir().join(format!(
3404            "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
3405            std::process::id()
3406        ));
3407        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3408        store.config.provider = ProviderKind::Deepseek;
3409        store.config.api_key = Some("sk-config-3333".to_string());
3410        store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
3411
3412        let inner = Arc::new(InMemoryKeyringStore::new());
3413        inner.set("deepseek", "sk-keyring-2222").unwrap();
3414        let secrets = Secrets::new(inner);
3415
3416        let output =
3417            auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
3418
3419        assert!(output.contains("provider: deepseek"));
3420        assert!(output.contains("active source: config (last4: ...3333)"));
3421        assert!(output.contains("lookup order: config -> secret store -> env"));
3422        assert!(output.contains("config file: "));
3423        assert!(output.contains("set, last4: ...3333"));
3424        assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
3425        assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
3426        assert!(!output.contains("sk-config-3333"));
3427        assert!(!output.contains("sk-keyring-2222"));
3428        assert!(!output.contains("sk-env-1111"));
3429
3430        let _ = std::fs::remove_file(path);
3431    }
3432
3433    #[test]
3434    fn auth_status_all_providers_lists_every_known_provider() {
3435        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3436        use std::sync::Arc;
3437
3438        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3439        let path = std::env::temp_dir().join(format!(
3440            "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
3441            std::process::id()
3442        ));
3443        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3444        store.config.provider = ProviderKind::Deepseek;
3445        store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
3446
3447        let inner = Arc::new(InMemoryKeyringStore::new());
3448        inner.set("openrouter", "sk-or-test5678").unwrap();
3449        let secrets = Secrets::new(inner);
3450
3451        let output = auth_status_all_providers(&store, &secrets).join("\n");
3452
3453        // Should list all known providers
3454        assert!(output.contains("deepseek"));
3455        assert!(output.contains("arcee"));
3456        assert!(output.contains("openrouter"));
3457        assert!(output.contains("huggingface"));
3458        assert!(output.contains("ollama"));
3459
3460        // Active provider should be marked
3461        assert!(output.contains("deepseek") && output.contains("*"));
3462
3463        // Arcee should show config source
3464        assert!(output.contains("config"));
3465
3466        // Should NOT leak raw keys
3467        assert!(!output.contains("sk-arcee-test1234"));
3468        assert!(!output.contains("sk-or-test5678"));
3469
3470        let _ = std::fs::remove_file(path);
3471    }
3472
3473    #[test]
3474    fn auth_status_openai_codex_reports_codex_oauth_file() {
3475        use codewhale_secrets::InMemoryKeyringStore;
3476        use std::sync::Arc;
3477
3478        let _lock = env_lock();
3479        let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
3480        let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
3481
3482        let dir = tempfile::TempDir::new().expect("tempdir");
3483        let config_path = dir.path().join("config.toml");
3484        let auth_path = dir.path().join("auth.json");
3485        std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
3486            .expect("write auth file");
3487        let auth_path_str = auth_path.to_string_lossy().into_owned();
3488        let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
3489
3490        let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
3491        store.config.provider = ProviderKind::OpenaiCodex;
3492        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
3493
3494        let output =
3495            auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
3496
3497        assert!(output.contains("provider: openai-codex"));
3498        assert!(output.contains("auth mode: codex_oauth"));
3499        assert!(output.contains("active source: Codex OAuth file"));
3500        assert!(output.contains("lookup order: env -> Codex OAuth file"));
3501        assert!(output.contains(&format!(
3502            "Codex OAuth file: {} (present)",
3503            auth_path.display()
3504        )));
3505        assert!(!output.contains("secret-token"));
3506    }
3507
3508    #[test]
3509    fn auth_status_scoped_provider_shows_detailed_info() {
3510        use codewhale_secrets::InMemoryKeyringStore;
3511        use std::sync::Arc;
3512
3513        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3514        let path = std::env::temp_dir().join(format!(
3515            "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
3516            std::process::id()
3517        ));
3518        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3519        store.config.provider = ProviderKind::Deepseek;
3520        store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
3521
3522        let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
3523
3524        let output =
3525            auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
3526
3527        assert!(output.contains("provider: arcee"));
3528        assert!(output.contains("active source: config (last4: ...9999)"));
3529        assert!(output.contains("route:"));
3530        assert!(output.contains("model:"));
3531        assert!(!output.contains("sk-arcee-9999"));
3532
3533        let _ = std::fs::remove_file(path);
3534    }
3535
3536    #[test]
3537    fn dispatch_keyring_recovery_self_heals_into_config_file() {
3538        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3539        use std::sync::Arc;
3540
3541        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3542        let path = std::env::temp_dir().join(format!(
3543            "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
3544            std::process::id()
3545        ));
3546        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3547        let inner = Arc::new(InMemoryKeyringStore::new());
3548        inner.set("deepseek", "ring-key").unwrap();
3549        let secrets = Secrets::new(inner);
3550
3551        let resolved = resolve_runtime_for_dispatch_with_secrets(
3552            &mut store,
3553            &CliRuntimeOverrides::default(),
3554            &secrets,
3555        );
3556
3557        assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
3558        assert_eq!(
3559            resolved.api_key_source,
3560            Some(RuntimeApiKeySource::ConfigFile)
3561        );
3562        assert_eq!(store.config.api_key.as_deref(), Some("ring-key"));
3563        assert_eq!(
3564            store.config.providers.deepseek.api_key.as_deref(),
3565            Some("ring-key")
3566        );
3567
3568        let saved = std::fs::read_to_string(&path).expect("config should be written");
3569        assert!(saved.contains("api_key = \"ring-key\""));
3570
3571        let resolved_again = resolve_runtime_for_dispatch_with_secrets(
3572            &mut store,
3573            &CliRuntimeOverrides::default(),
3574            &no_keyring_secrets(),
3575        );
3576        assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
3577        assert_eq!(
3578            resolved_again.api_key_source,
3579            Some(RuntimeApiKeySource::ConfigFile)
3580        );
3581
3582        let _ = std::fs::remove_file(path);
3583    }
3584
3585    #[test]
3586    fn logout_removes_plaintext_provider_keys() {
3587        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3588        let path = std::env::temp_dir().join(format!(
3589            "deepseek-cli-logout-test-{}-{nanos}.toml",
3590            std::process::id()
3591        ));
3592        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3593        store.config.api_key = Some("sk-stale".to_string());
3594        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
3595        store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
3596        store.save().unwrap();
3597
3598        let secrets = no_keyring_secrets();
3599
3600        run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
3601
3602        assert!(store.config.api_key.is_none());
3603        assert!(store.config.providers.deepseek.api_key.is_none());
3604        assert!(store.config.providers.fireworks.api_key.is_none());
3605
3606        let _ = std::fs::remove_file(path);
3607    }
3608
3609    #[test]
3610    fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
3611        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3612        use std::sync::Arc;
3613
3614        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3615        let path = std::env::temp_dir().join(format!(
3616            "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
3617            std::process::id()
3618        ));
3619        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3620        store.config.api_key = Some("sk-deep".to_string());
3621        store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
3622        store.config.providers.openrouter.api_key = Some("or-key".to_string());
3623        store.config.providers.novita.api_key = Some("nv-key".to_string());
3624        store.save().unwrap();
3625
3626        let inner = Arc::new(InMemoryKeyringStore::new());
3627        let secrets = Secrets::new(inner.clone());
3628
3629        run_auth_command_with_secrets(
3630            &mut store,
3631            AuthCommand::Migrate { dry_run: false },
3632            &secrets,
3633        )
3634        .expect("migrate should succeed");
3635
3636        assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
3637        assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
3638        assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
3639
3640        // Config file must no longer contain the api keys.
3641        assert!(store.config.api_key.is_none());
3642        assert!(store.config.providers.deepseek.api_key.is_none());
3643        assert!(store.config.providers.openrouter.api_key.is_none());
3644        assert!(store.config.providers.novita.api_key.is_none());
3645
3646        let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
3647        assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
3648        assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
3649        assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
3650
3651        let _ = std::fs::remove_file(path);
3652    }
3653
3654    #[test]
3655    fn auth_migrate_dry_run_does_not_modify_anything() {
3656        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3657        use std::sync::Arc;
3658
3659        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3660        let path = std::env::temp_dir().join(format!(
3661            "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
3662            std::process::id()
3663        ));
3664        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3665        store.config.providers.openrouter.api_key = Some("or-stay".to_string());
3666        store.save().unwrap();
3667
3668        let inner = Arc::new(InMemoryKeyringStore::new());
3669        let secrets = Secrets::new(inner.clone());
3670
3671        run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
3672            .expect("dry-run should succeed");
3673
3674        assert_eq!(inner.get("openrouter").unwrap(), None);
3675        assert_eq!(
3676            store.config.providers.openrouter.api_key.as_deref(),
3677            Some("or-stay")
3678        );
3679
3680        let _ = std::fs::remove_file(path);
3681    }
3682
3683    #[test]
3684    fn parses_global_override_flags() {
3685        let cli = parse_ok(&[
3686            "deepseek",
3687            "--provider",
3688            "openai",
3689            "--config",
3690            "/tmp/deepseek.toml",
3691            "--profile",
3692            "work",
3693            "--model",
3694            "deepseek-v4-pro",
3695            "--output-mode",
3696            "json",
3697            "--verbosity",
3698            "concise",
3699            "--log-level",
3700            "debug",
3701            "--telemetry",
3702            "true",
3703            "--approval-policy",
3704            "on-request",
3705            "--sandbox-mode",
3706            "workspace-write",
3707            "--base-url",
3708            "https://openai-compatible.example/v1",
3709            "--api-key",
3710            "sk-test",
3711            "--workspace",
3712            "/tmp/workspace",
3713            "--no-alt-screen",
3714            "--no-mouse-capture",
3715            "--skip-onboarding",
3716            "model",
3717            "resolve",
3718            "deepseek-v4-pro",
3719        ]);
3720
3721        assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
3722        assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
3723        assert_eq!(cli.profile.as_deref(), Some("work"));
3724        assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
3725        assert_eq!(cli.output_mode.as_deref(), Some("json"));
3726        assert_eq!(cli.verbosity.as_deref(), Some("concise"));
3727        assert_eq!(cli.log_level.as_deref(), Some("debug"));
3728        assert_eq!(cli.telemetry, Some(true));
3729        assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
3730        assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
3731        assert_eq!(
3732            cli.base_url.as_deref(),
3733            Some("https://openai-compatible.example/v1")
3734        );
3735        assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
3736        assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
3737        assert!(cli.no_alt_screen);
3738        assert!(cli.no_mouse_capture);
3739        assert!(!cli.mouse_capture);
3740        assert!(cli.skip_onboarding);
3741    }
3742
3743    #[test]
3744    fn cli_provider_helpers_follow_config_metadata() {
3745        let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
3746            .iter()
3747            .map(|provider| provider.kind())
3748            .collect();
3749        assert_eq!(registry_kinds, ProviderKind::ALL);
3750
3751        for provider in ProviderKind::ALL {
3752            assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
3753            if provider == ProviderKind::SiliconflowCN {
3754                assert_eq!(
3755                    provider_slot(provider),
3756                    provider_slot(ProviderKind::Siliconflow)
3757                );
3758            } else {
3759                assert_eq!(provider_slot(provider), provider.provider().id());
3760            }
3761        }
3762    }
3763
3764    #[test]
3765    fn build_tui_command_allows_openai_and_forwards_provider_key() {
3766        let _lock = env_lock();
3767        let dir = tempfile::TempDir::new().expect("tempdir");
3768        let custom = dir
3769            .path()
3770            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3771        std::fs::write(&custom, b"").unwrap();
3772        let custom_str = custom.to_string_lossy().into_owned();
3773        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3774
3775        let cli = parse_ok(&[
3776            "deepseek",
3777            "--provider",
3778            "openai",
3779            "--workspace",
3780            "/tmp/codewhale-workspace",
3781        ]);
3782        let resolved = ResolvedRuntimeOptions {
3783            provider: ProviderKind::Openai,
3784            provider_source: ProviderSource::Cli,
3785            model: "glm-5".to_string(),
3786            api_key: Some("resolved-openai-key".to_string()),
3787            api_key_source: Some(RuntimeApiKeySource::Keyring),
3788            base_url: "https://openai-compatible.example/v4".to_string(),
3789            auth_mode: Some("api_key".to_string()),
3790            insecure_skip_tls_verify: false,
3791            output_mode: None,
3792            log_level: None,
3793            telemetry: false,
3794            approval_policy: None,
3795            sandbox_mode: None,
3796            yolo: None,
3797            verbosity: None,
3798            http_headers: std::collections::BTreeMap::new(),
3799        };
3800
3801        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3802        assert_eq!(
3803            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3804            Some("openai")
3805        );
3806        assert_eq!(
3807            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3808            Some("resolved-openai-key")
3809        );
3810        assert_eq!(
3811            command_env(&cmd, "OPENAI_API_KEY").as_deref(),
3812            Some("resolved-openai-key")
3813        );
3814        assert_eq!(
3815            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
3816            Some("keyring")
3817        );
3818        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
3819        let args: Vec<String> = cmd
3820            .get_args()
3821            .map(|arg| arg.to_string_lossy().into_owned())
3822            .collect();
3823        assert!(
3824            args.windows(2)
3825                .any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
3826            "expected workspace forwarding in args: {args:?}"
3827        );
3828    }
3829
3830    #[test]
3831    fn build_tui_command_allows_openai_codex_from_resolved_runtime() {
3832        let _lock = env_lock();
3833        let dir = tempfile::TempDir::new().expect("tempdir");
3834        let custom = dir
3835            .path()
3836            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3837        std::fs::write(&custom, b"").unwrap();
3838        let custom_str = custom.to_string_lossy().into_owned();
3839        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3840
3841        let cli = parse_ok(&["codewhale", "doctor"]);
3842        let resolved = ResolvedRuntimeOptions {
3843            provider: ProviderKind::OpenaiCodex,
3844            provider_source: ProviderSource::Config,
3845            model: "gpt-5.5".to_string(),
3846            api_key: None,
3847            api_key_source: None,
3848            base_url: "https://chatgpt.com/backend-api".to_string(),
3849            auth_mode: Some("oauth".to_string()),
3850            insecure_skip_tls_verify: false,
3851            output_mode: None,
3852            log_level: None,
3853            telemetry: false,
3854            approval_policy: None,
3855            sandbox_mode: None,
3856            yolo: None,
3857            verbosity: None,
3858            http_headers: std::collections::BTreeMap::new(),
3859        };
3860
3861        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3862            .expect("openai-codex should be accepted by the facade");
3863        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
3864        let args: Vec<String> = cmd
3865            .get_args()
3866            .map(|arg| arg.to_string_lossy().into_owned())
3867            .collect();
3868        assert_eq!(args, vec!["doctor"]);
3869    }
3870
3871    #[test]
3872    fn build_tui_command_forwards_explicit_openai_codex_provider() {
3873        let _lock = env_lock();
3874        let dir = tempfile::TempDir::new().expect("tempdir");
3875        let custom = dir
3876            .path()
3877            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3878        std::fs::write(&custom, b"").unwrap();
3879        let custom_str = custom.to_string_lossy().into_owned();
3880        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3881
3882        let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
3883        let resolved = ResolvedRuntimeOptions {
3884            provider: ProviderKind::OpenaiCodex,
3885            provider_source: ProviderSource::Cli,
3886            model: "gpt-5.5".to_string(),
3887            api_key: None,
3888            api_key_source: None,
3889            base_url: "https://chatgpt.com/backend-api".to_string(),
3890            auth_mode: Some("oauth".to_string()),
3891            insecure_skip_tls_verify: false,
3892            output_mode: None,
3893            log_level: None,
3894            telemetry: false,
3895            approval_policy: None,
3896            sandbox_mode: None,
3897            yolo: None,
3898            verbosity: None,
3899            http_headers: std::collections::BTreeMap::new(),
3900        };
3901
3902        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3903            .expect("openai-codex should be accepted by the facade");
3904        assert_eq!(
3905            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3906            Some("openai-codex")
3907        );
3908    }
3909
3910    #[test]
3911    fn build_tui_command_allows_anthropic_cli_provider() {
3912        let _lock = env_lock();
3913        let (_dir, _bin) = install_fake_tui_binary();
3914
3915        let cli = parse_ok(&["codewhale", "--provider", "anthropic", "doctor"]);
3916        let resolved = resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Cli);
3917
3918        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3919            .expect("anthropic should be accepted by the facade");
3920        assert_eq!(
3921            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3922            Some("anthropic")
3923        );
3924    }
3925
3926    #[test]
3927    fn build_tui_command_allows_anthropic_env_provider() {
3928        let _lock = env_lock();
3929        let (_dir, _bin) = install_fake_tui_binary();
3930
3931        let cli = parse_ok(&["codewhale", "doctor"]);
3932        let resolved = resolved_runtime_for_test(
3933            ProviderKind::Anthropic,
3934            ProviderSource::Env("DEEPSEEK_PROVIDER"),
3935        );
3936
3937        build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3938            .expect("anthropic from provider env should be accepted by the facade");
3939    }
3940
3941    #[test]
3942    fn build_tui_command_bridges_anthropic_keyring_secret() {
3943        let _lock = env_lock();
3944        let (_dir, _bin) = install_fake_tui_binary();
3945
3946        let cli = parse_ok(&["codewhale", "doctor"]);
3947        let mut resolved =
3948            resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Config);
3949        resolved.api_key = Some("anthropic-keyring-secret".to_string());
3950        resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
3951
3952        let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3953            .expect("config-sourced anthropic provider should be accepted");
3954
3955        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
3956        assert_eq!(
3957            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3958            Some("anthropic-keyring-secret")
3959        );
3960        assert_eq!(
3961            command_env(&cmd, "ANTHROPIC_API_KEY").as_deref(),
3962            Some("anthropic-keyring-secret")
3963        );
3964        assert_eq!(
3965            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
3966            Some("keyring")
3967        );
3968    }
3969
3970    #[test]
3971    fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
3972        let _lock = env_lock();
3973        let dir = tempfile::TempDir::new().expect("tempdir");
3974        let custom = dir
3975            .path()
3976            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3977        std::fs::write(&custom, b"").unwrap();
3978        let custom_str = custom.to_string_lossy().into_owned();
3979        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3980
3981        let cli = parse_ok(&["deepseek", "--profile", "google"]);
3982        let mut resolved_headers = std::collections::BTreeMap::new();
3983        resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
3984        let resolved = ResolvedRuntimeOptions {
3985            provider: ProviderKind::Deepseek,
3986            provider_source: ProviderSource::Config,
3987            model: "deepseek-v4-pro".to_string(),
3988            api_key: Some("config-file-key".to_string()),
3989            api_key_source: Some(RuntimeApiKeySource::ConfigFile),
3990            base_url: "https://api.deepseek.com/beta".to_string(),
3991            auth_mode: Some("api_key".to_string()),
3992            insecure_skip_tls_verify: false,
3993            output_mode: None,
3994            log_level: None,
3995            telemetry: false,
3996            approval_policy: None,
3997            sandbox_mode: None,
3998            yolo: None,
3999            verbosity: None,
4000            http_headers: resolved_headers,
4001        };
4002
4003        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
4004
4005        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
4006        assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
4007        assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
4008        assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
4009        assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
4010        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
4011        assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
4012        let args: Vec<String> = cmd
4013            .get_args()
4014            .map(|arg| arg.to_string_lossy().into_owned())
4015            .collect();
4016        assert!(
4017            args.windows(2).any(|pair| pair == ["--profile", "google"]),
4018            "expected profile forwarding in args: {args:?}"
4019        );
4020    }
4021
4022    #[test]
4023    fn build_tui_command_defaults_noninteractive_to_concise_verbosity() {
4024        let _lock = env_lock();
4025        let (_dir, _bin) = install_fake_tui_binary();
4026
4027        let cli = parse_ok(&["codewhale"]);
4028        let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
4029
4030        let cmd = build_tui_command(
4031            &cli,
4032            &resolved,
4033            vec!["exec".to_string(), "summarize".to_string()],
4034        )
4035        .expect("command");
4036
4037        assert_eq!(
4038            command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
4039            Some("concise")
4040        );
4041        assert_eq!(
4042            command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
4043            Some("concise")
4044        );
4045    }
4046
4047    #[test]
4048    fn build_tui_command_respects_resolved_verbosity_override() {
4049        let _lock = env_lock();
4050        let (_dir, _bin) = install_fake_tui_binary();
4051
4052        let cli = parse_ok(&["codewhale"]);
4053        let mut resolved =
4054            resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
4055        resolved.verbosity = Some("normal".to_string());
4056
4057        let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string()]).expect("command");
4058
4059        assert_eq!(
4060            command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
4061            Some("normal")
4062        );
4063        assert_eq!(
4064            command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
4065            Some("normal")
4066        );
4067    }
4068
4069    #[test]
4070    fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
4071        let _lock = env_lock();
4072        let dir = tempfile::TempDir::new().expect("tempdir");
4073        let custom = dir
4074            .path()
4075            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
4076        std::fs::write(&custom, b"").unwrap();
4077        let custom_str = custom.to_string_lossy().into_owned();
4078        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
4079
4080        let cli = parse_ok(&[
4081            "codewhale",
4082            "--provider",
4083            "moonshot",
4084            "--model",
4085            "kimi-k2.7-code",
4086            "--workspace",
4087            "/tmp/codewhale-workspace",
4088        ]);
4089        let resolved = ResolvedRuntimeOptions {
4090            provider: ProviderKind::Moonshot,
4091            provider_source: ProviderSource::Cli,
4092            model: "kimi-k2.7-code".to_string(),
4093            api_key: Some("resolved-kimi-key".to_string()),
4094            api_key_source: Some(RuntimeApiKeySource::Keyring),
4095            base_url: "https://api.moonshot.ai/v1".to_string(),
4096            auth_mode: Some("api_key".to_string()),
4097            insecure_skip_tls_verify: false,
4098            output_mode: None,
4099            log_level: None,
4100            telemetry: false,
4101            approval_policy: None,
4102            sandbox_mode: None,
4103            yolo: None,
4104            verbosity: None,
4105            http_headers: std::collections::BTreeMap::new(),
4106        };
4107
4108        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
4109        assert_eq!(
4110            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
4111            Some("moonshot")
4112        );
4113        assert_eq!(
4114            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
4115            Some("kimi-k2.7-code")
4116        );
4117        assert_eq!(
4118            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
4119            Some("resolved-kimi-key")
4120        );
4121        assert_eq!(
4122            command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
4123            Some("resolved-kimi-key")
4124        );
4125        assert_eq!(
4126            command_env(&cmd, "KIMI_API_KEY").as_deref(),
4127            Some("resolved-kimi-key")
4128        );
4129        assert_eq!(
4130            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
4131            Some("keyring")
4132        );
4133        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
4134    }
4135
4136    #[test]
4137    fn build_tui_command_allows_volcengine_and_forwards_ark_keys() {
4138        let _lock = env_lock();
4139        let dir = tempfile::TempDir::new().expect("tempdir");
4140        let custom = dir
4141            .path()
4142            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
4143        std::fs::write(&custom, b"").unwrap();
4144        let custom_str = custom.to_string_lossy().into_owned();
4145        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
4146
4147        let cli = parse_ok(&[
4148            "codewhale",
4149            "--provider",
4150            "volcengine",
4151            "--model",
4152            "DeepSeek-V4-Pro",
4153            "--workspace",
4154            "/tmp/codewhale-workspace",
4155        ]);
4156        let resolved = ResolvedRuntimeOptions {
4157            provider: ProviderKind::Volcengine,
4158            provider_source: ProviderSource::Cli,
4159            model: "DeepSeek-V4-Pro".to_string(),
4160            api_key: Some("resolved-ark-key".to_string()),
4161            api_key_source: Some(RuntimeApiKeySource::Keyring),
4162            base_url: "https://ark.cn-beijing.volces.com/api/coding/v3".to_string(),
4163            auth_mode: Some("api_key".to_string()),
4164            insecure_skip_tls_verify: false,
4165            output_mode: None,
4166            log_level: None,
4167            telemetry: false,
4168            approval_policy: None,
4169            sandbox_mode: None,
4170            yolo: None,
4171            verbosity: None,
4172            http_headers: std::collections::BTreeMap::new(),
4173        };
4174
4175        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
4176        assert_eq!(
4177            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
4178            Some("volcengine")
4179        );
4180        assert_eq!(
4181            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
4182            Some("DeepSeek-V4-Pro")
4183        );
4184        assert_eq!(
4185            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
4186            Some("resolved-ark-key")
4187        );
4188        assert_eq!(
4189            command_env(&cmd, "VOLCENGINE_API_KEY").as_deref(),
4190            Some("resolved-ark-key")
4191        );
4192        assert_eq!(
4193            command_env(&cmd, "VOLCENGINE_ARK_API_KEY").as_deref(),
4194            Some("resolved-ark-key")
4195        );
4196        assert_eq!(
4197            command_env(&cmd, "ARK_API_KEY").as_deref(),
4198            Some("resolved-ark-key")
4199        );
4200    }
4201
4202    #[test]
4203    fn build_tui_command_exports_explicit_provider_model_and_base_url() {
4204        let _lock = env_lock();
4205        let dir = tempfile::TempDir::new().expect("tempdir");
4206        let custom = dir
4207            .path()
4208            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
4209        std::fs::write(&custom, b"").unwrap();
4210        let custom_str = custom.to_string_lossy().into_owned();
4211        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
4212
4213        let cli = parse_ok(&[
4214            "deepseek",
4215            "--profile",
4216            "google",
4217            "--provider",
4218            "openai",
4219            "--model",
4220            "glm-5",
4221            "--base-url",
4222            "https://openai-compatible.example/v4",
4223        ]);
4224        let resolved = ResolvedRuntimeOptions {
4225            provider: ProviderKind::Openai,
4226            provider_source: ProviderSource::Cli,
4227            model: "glm-5".to_string(),
4228            api_key: None,
4229            api_key_source: None,
4230            base_url: "https://openai-compatible.example/v4".to_string(),
4231            auth_mode: None,
4232            insecure_skip_tls_verify: false,
4233            output_mode: None,
4234            log_level: None,
4235            telemetry: false,
4236            approval_policy: None,
4237            sandbox_mode: None,
4238            yolo: None,
4239            verbosity: None,
4240            http_headers: std::collections::BTreeMap::new(),
4241        };
4242
4243        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
4244
4245        assert_eq!(
4246            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
4247            Some("openai")
4248        );
4249        assert_eq!(
4250            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
4251            Some("glm-5")
4252        );
4253        assert_eq!(
4254            command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
4255            Some("https://openai-compatible.example/v4")
4256        );
4257    }
4258
4259    #[test]
4260    fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
4261        let _lock = env_lock();
4262        let dir = tempfile::TempDir::new().expect("tempdir");
4263        let custom = dir
4264            .path()
4265            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
4266        std::fs::write(&custom, b"").unwrap();
4267        let custom_str = custom.to_string_lossy().into_owned();
4268        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
4269
4270        for provider in ProviderKind::ALL {
4271            let cli = parse_ok(&["codewhale", "--workspace", "/tmp/codewhale-workspace"]);
4272            let resolved = ResolvedRuntimeOptions {
4273                provider,
4274                provider_source: ProviderSource::Config,
4275                model: "test-model".to_string(),
4276                api_key: Some("test-key".to_string()),
4277                api_key_source: Some(RuntimeApiKeySource::Keyring),
4278                base_url: "http://localhost:8000/v1".to_string(),
4279                auth_mode: Some("api_key".to_string()),
4280                insecure_skip_tls_verify: false,
4281                output_mode: None,
4282                log_level: None,
4283                telemetry: false,
4284                approval_policy: None,
4285                sandbox_mode: None,
4286                yolo: None,
4287                verbosity: None,
4288                http_headers: std::collections::BTreeMap::new(),
4289            };
4290
4291            let cmd = build_tui_command(&cli, &resolved, Vec::new())
4292                .unwrap_or_else(|e| panic!("{}: {e}", provider.as_str()));
4293
4294            assert_eq!(
4295                command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
4296                Some("test-key"),
4297                "{}: DEEPSEEK_API_KEY not forwarded",
4298                provider.as_str()
4299            );
4300            for var in provider_env_vars(provider)
4301                .iter()
4302                .filter(|var| **var != "DEEPSEEK_API_KEY")
4303            {
4304                assert_eq!(
4305                    command_env(&cmd, var).as_deref(),
4306                    Some("test-key"),
4307                    "{}: {var} not forwarded",
4308                    provider.as_str()
4309                );
4310            }
4311            assert_eq!(
4312                command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
4313                Some("keyring"),
4314                "{}: expected keyring source bridge",
4315                provider.as_str()
4316            );
4317            assert_eq!(
4318                command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
4319                None,
4320                "{}: auth mode should come from config/profile, not env handoff",
4321                provider.as_str()
4322            );
4323        }
4324    }
4325
4326    #[test]
4327    fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
4328        let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
4329
4330        assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
4331        assert!(cli.prompt.is_empty());
4332        assert_eq!(
4333            root_tui_passthrough(&cli).unwrap(),
4334            vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
4335        );
4336    }
4337
4338    #[test]
4339    fn parses_top_level_continue_for_interactive_resume() {
4340        let cli = parse_ok(&["codewhale", "--continue"]);
4341
4342        assert!(cli.continue_session);
4343        assert!(cli.prompt_flag.is_none());
4344        assert!(cli.prompt.is_empty());
4345        assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
4346    }
4347
4348    #[test]
4349    fn top_level_continue_rejects_startup_prompt() {
4350        let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
4351
4352        let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
4353        assert!(
4354            err.to_string()
4355                .contains("codewhale exec --continue <PROMPT>")
4356        );
4357    }
4358
4359    #[test]
4360    fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
4361        let cli = parse_ok(&["deepseek", "hello", "world"]);
4362
4363        assert_eq!(cli.prompt, vec!["hello", "world"]);
4364        assert!(cli.command.is_none());
4365        assert_eq!(
4366            root_tui_passthrough(&cli).unwrap(),
4367            vec!["--prompt".to_string(), "hello world".to_string()]
4368        );
4369    }
4370
4371    #[test]
4372    fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
4373        let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
4374
4375        assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
4376        assert_eq!(cli.prompt, vec!["world"]);
4377        assert_eq!(
4378            root_tui_passthrough(&cli).unwrap(),
4379            vec!["--prompt".to_string(), "hello world".to_string()]
4380        );
4381    }
4382
4383    #[test]
4384    fn known_subcommands_still_parse_before_prompt_tail() {
4385        let cli = parse_ok(&["deepseek", "doctor"]);
4386
4387        assert!(cli.prompt.is_empty());
4388        assert!(matches!(cli.command, Some(Commands::Doctor(_))));
4389    }
4390
4391    #[test]
4392    fn root_help_surface_contains_expected_subcommands_and_globals() {
4393        let rendered = help_for(&["deepseek", "--help"]);
4394
4395        for token in [
4396            "run",
4397            "doctor",
4398            "models",
4399            "sessions",
4400            "resume",
4401            "setup",
4402            "login",
4403            "logout",
4404            "auth",
4405            "mcp-server",
4406            "config",
4407            "model",
4408            "thread",
4409            "sandbox",
4410            "app-server",
4411            "completion",
4412            "metrics",
4413            "--provider",
4414            "--model",
4415            "--config",
4416            "--profile",
4417            "--output-mode",
4418            "--log-level",
4419            "--telemetry",
4420            "--base-url",
4421            "--api-key",
4422            "--approval-policy",
4423            "--sandbox-mode",
4424            "--mouse-capture",
4425            "--no-mouse-capture",
4426            "--skip-onboarding",
4427            "--continue",
4428            "--prompt",
4429        ] {
4430            assert!(
4431                rendered.contains(token),
4432                "expected help to contain token: {token}"
4433            );
4434        }
4435    }
4436
4437    #[test]
4438    fn subcommand_help_surfaces_are_stable() {
4439        let cases = [
4440            ("config", vec!["get", "set", "unset", "list", "path"]),
4441            ("model", vec!["list", "resolve"]),
4442            (
4443                "thread",
4444                vec![
4445                    "list",
4446                    "read",
4447                    "resume",
4448                    "fork",
4449                    "archive",
4450                    "unarchive",
4451                    "set-name",
4452                    "clear-name",
4453                ],
4454            ),
4455            ("sandbox", vec!["check"]),
4456            (
4457                "exec",
4458                vec![
4459                    "--auto",
4460                    "--json",
4461                    "--resume",
4462                    "--session-id",
4463                    "--continue",
4464                    "--output-format",
4465                    "stream-json",
4466                ],
4467            ),
4468            (
4469                "app-server",
4470                vec!["--host", "--port", "--config", "--stdio"],
4471            ),
4472            (
4473                "completion",
4474                vec![
4475                    "<SHELL>",
4476                    "bash",
4477                    "source <(codewhale completion bash)",
4478                    "~/.local/share/bash-completion/completions/codewhale",
4479                    "fpath=(~/.zfunc $fpath)",
4480                    "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
4481                    "codewhale completion powershell | Out-String | Invoke-Expression",
4482                ],
4483            ),
4484            ("metrics", vec!["--json", "--since"]),
4485        ];
4486
4487        for (subcommand, expected_tokens) in cases {
4488            let argv = ["deepseek", subcommand, "--help"];
4489            let rendered = help_for(&argv);
4490            for token in expected_tokens {
4491                assert!(
4492                    rendered.contains(token),
4493                    "expected help for `{subcommand}` to include `{token}`"
4494                );
4495            }
4496        }
4497    }
4498
4499    /// Regression for issue #247: on Windows the dispatcher must find the
4500    /// sibling `codewhale-tui.exe`, not bail out looking for an
4501    /// extension-less `codewhale-tui`. The candidate resolver also accepts
4502    /// the suffix-less name on Windows so users who manually renamed the
4503    /// file as a workaround keep working after the upgrade.
4504    #[test]
4505    fn sibling_tui_candidate_picks_platform_correct_name() {
4506        let dir = tempfile::TempDir::new().expect("tempdir");
4507        let dispatcher = dir
4508            .path()
4509            .join("codewhale")
4510            .with_extension(std::env::consts::EXE_EXTENSION);
4511        // Touch the dispatcher so its parent dir is the lookup root.
4512        std::fs::write(&dispatcher, b"").unwrap();
4513
4514        // No sibling yet — resolver returns None.
4515        assert!(sibling_tui_candidate(&dispatcher).is_none());
4516
4517        let target =
4518            dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
4519        std::fs::write(&target, b"").unwrap();
4520
4521        let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
4522        assert_eq!(found, target, "primary platform-correct name wins");
4523    }
4524
4525    #[test]
4526    fn dispatcher_spawn_error_names_path_and_recovery_checks() {
4527        let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
4528        let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
4529
4530        assert!(message.contains("C:/tools/codewhale-tui.exe"));
4531        assert!(message.contains("access is denied"));
4532        assert!(message.contains("where codewhale"));
4533        assert!(message.contains("DEEPSEEK_TUI_BIN"));
4534    }
4535
4536    /// Windows-only fallback: the user from #247 manually renamed the
4537    /// file to drop `.exe`. After the fix lands, that workaround must
4538    /// still resolve via the suffix-less fallback so they don't have to
4539    /// rename it back.
4540    #[cfg(windows)]
4541    #[test]
4542    fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
4543        let dir = tempfile::TempDir::new().expect("tempdir");
4544        let dispatcher = dir.path().join("codewhale.exe");
4545        std::fs::write(&dispatcher, b"").unwrap();
4546
4547        // Only the suffixless name exists — emulates the manual rename.
4548        let suffixless = dispatcher.with_file_name("codewhale-tui");
4549        std::fs::write(&suffixless, b"").unwrap();
4550
4551        let found = sibling_tui_candidate(&dispatcher)
4552            .expect("Windows fallback must locate suffixless codewhale-tui");
4553        assert_eq!(found, suffixless);
4554    }
4555
4556    /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for
4557    /// custom Windows install layouts and CI test rigs.
4558    #[test]
4559    fn locate_sibling_tui_binary_honours_env_override() {
4560        let _lock = env_lock();
4561        let dir = tempfile::TempDir::new().expect("tempdir");
4562        let custom = dir
4563            .path()
4564            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
4565        std::fs::write(&custom, b"").unwrap();
4566        let custom_str = custom.to_string_lossy().into_owned();
4567        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
4568
4569        let resolved = locate_sibling_tui_binary().expect("override must resolve");
4570        assert_eq!(resolved, custom);
4571    }
4572}