Skip to main content

codewhale_cli/
lib.rs

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