Skip to main content

codewhale_cli/
lib.rs

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