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, ResolvedRuntimeOptions, RuntimeApiKeySource,
18};
19use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
20use codewhale_mcp::{McpServerDefinition, run_stdio_server};
21use codewhale_secrets::Secrets;
22use codewhale_state::{StateStore, ThreadListFilters};
23
24#[derive(Debug, Clone, Copy, ValueEnum)]
25enum ProviderArg {
26    Deepseek,
27    NvidiaNim,
28    Openai,
29    Atlascloud,
30    WanjieArk,
31    Volcengine,
32    Openrouter,
33    XiaomiMimo,
34    Novita,
35    Fireworks,
36    Siliconflow,
37    Moonshot,
38    Sglang,
39    Vllm,
40    Ollama,
41}
42
43impl From<ProviderArg> for ProviderKind {
44    fn from(value: ProviderArg) -> Self {
45        match value {
46            ProviderArg::Deepseek => ProviderKind::Deepseek,
47            ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
48            ProviderArg::Openai => ProviderKind::Openai,
49            ProviderArg::Atlascloud => ProviderKind::Atlascloud,
50            ProviderArg::WanjieArk => ProviderKind::WanjieArk,
51            ProviderArg::Volcengine => ProviderKind::Volcengine,
52            ProviderArg::Openrouter => ProviderKind::Openrouter,
53            ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
54            ProviderArg::Novita => ProviderKind::Novita,
55            ProviderArg::Fireworks => ProviderKind::Fireworks,
56            ProviderArg::Siliconflow => ProviderKind::Siliconflow,
57            ProviderArg::Moonshot => ProviderKind::Moonshot,
58            ProviderArg::Sglang => ProviderKind::Sglang,
59            ProviderArg::Vllm => ProviderKind::Vllm,
60            ProviderArg::Ollama => ProviderKind::Ollama,
61        }
62    }
63}
64
65#[derive(Debug, Parser)]
66#[command(
67    name = "codewhale",
68    version = env!("DEEPSEEK_BUILD_VERSION"),
69    bin_name = "codewhale",
70    override_usage = "codewhale [OPTIONS] [PROMPT]\n       codewhale [OPTIONS] <COMMAND> [ARGS]"
71)]
72struct Cli {
73    #[arg(long)]
74    config: Option<PathBuf>,
75    #[arg(long)]
76    profile: Option<String>,
77    #[arg(
78        long,
79        value_enum,
80        help = "Advanced provider selector for non-TUI registry/config commands"
81    )]
82    provider: Option<ProviderArg>,
83    #[arg(long)]
84    model: Option<String>,
85    #[arg(long = "output-mode")]
86    output_mode: Option<String>,
87    #[arg(long = "log-level")]
88    log_level: Option<String>,
89    #[arg(long)]
90    telemetry: Option<bool>,
91    #[arg(long)]
92    approval_policy: Option<String>,
93    #[arg(long)]
94    sandbox_mode: Option<String>,
95    #[arg(long)]
96    api_key: Option<String>,
97    #[arg(long)]
98    base_url: Option<String>,
99    /// Workspace directory for TUI file tools
100    #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
101    workspace: Option<PathBuf>,
102    #[arg(long = "no-alt-screen", hide = true)]
103    no_alt_screen: bool,
104    #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
105    mouse_capture: bool,
106    #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
107    no_mouse_capture: bool,
108    #[arg(long = "skip-onboarding")]
109    skip_onboarding: bool,
110    /// YOLO mode: auto-approve all tools
111    #[arg(long)]
112    yolo: bool,
113    /// Continue the most recent interactive session for this workspace.
114    #[arg(short = 'c', long = "continue")]
115    continue_session: bool,
116    #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
117    prompt_flag: Option<String>,
118    #[arg(
119        value_name = "PROMPT",
120        trailing_var_arg = true,
121        allow_hyphen_values = true
122    )]
123    prompt: Vec<String>,
124    #[command(subcommand)]
125    command: Option<Commands>,
126}
127
128#[derive(Debug, Subcommand)]
129enum Commands {
130    /// Run interactive/non-interactive flows via the TUI binary.
131    Run(RunArgs),
132    /// Run CodeWhale diagnostics.
133    Doctor(TuiPassthroughArgs),
134    /// List live DeepSeek API models via the TUI binary.
135    Models(TuiPassthroughArgs),
136    /// Generate speech audio with Xiaomi MiMo TTS models via the TUI binary.
137    #[command(visible_alias = "tts")]
138    Speech(TuiPassthroughArgs),
139    /// List saved TUI sessions.
140    Sessions(TuiPassthroughArgs),
141    /// Resume a saved TUI session.
142    Resume(TuiPassthroughArgs),
143    /// Fork a saved TUI session.
144    Fork(TuiPassthroughArgs),
145    /// Create a default AGENTS.md in the current directory.
146    Init(TuiPassthroughArgs),
147    /// Bootstrap MCP config and/or skills directories.
148    Setup(TuiPassthroughArgs),
149    /// Run a non-interactive prompt through the TUI runtime.
150    #[command(after_help = "\
151Examples:
152  codewhale exec \"explain this function\"
153  codewhale exec --auto \"list crates/ with ls\"
154  codewhale exec --auto --output-format stream-json \"fix the failing test\"
155
156Common forwarded flags:
157  --auto                           Enable tool-backed agent mode with auto-approvals
158  --json                           Emit summary JSON
159  --resume <SESSION_ID>            Resume a previous session by ID or prefix
160  --session-id <SESSION_ID>        Resume a previous session by ID or prefix
161  --continue                       Continue the most recent session for this workspace
162  --output-format <FORMAT>         Output format: text or stream-json
163
164Plain `codewhale exec` is a one-shot model response. Use `--auto` for
165non-interactive filesystem/shell tool use, matching the supported automation
166path used by stream-json wrappers.
167")]
168    Exec(TuiPassthroughArgs),
169    /// Generate SWE-bench prediction rows from CodeWhale runs.
170    #[command(after_help = "\
171Examples:
172  codewhale swebench run --instance-id django__django-12345 --issue-file issue.md
173  codewhale swebench export --instance-id django__django-12345 --predictions-path all_preds.jsonl
174
175This command forwards to the TUI runtime. `run` invokes tool-backed agent mode
176and writes a SWE-bench-compatible JSONL prediction row from the resulting
177working-tree diff. `export` only writes the current diff.
178")]
179    Swebench(TuiPassthroughArgs),
180    /// Run a CodeWhale-powered code review over a git diff.
181    Review(TuiPassthroughArgs),
182    /// Apply a patch file or stdin to the working tree.
183    Apply(TuiPassthroughArgs),
184    /// Run the offline TUI evaluation harness.
185    Eval(TuiPassthroughArgs),
186    /// Manage TUI MCP servers.
187    Mcp(TuiPassthroughArgs),
188    /// Inspect TUI feature flags.
189    Features(TuiPassthroughArgs),
190    /// Run a local TUI server.
191    Serve(TuiPassthroughArgs),
192    /// Generate shell completions for the TUI binary.
193    Completions(TuiPassthroughArgs),
194    /// Configure provider credentials.
195    Login(LoginArgs),
196    /// Remove saved authentication state.
197    Logout,
198    /// Manage authentication credentials and provider mode.
199    Auth(AuthArgs),
200    /// Run MCP server mode over stdio.
201    McpServer,
202    /// Read/write/list config values.
203    Config(ConfigArgs),
204    /// Resolve or list available models across providers.
205    Model(ModelArgs),
206    /// Manage thread/session metadata and resume/fork flows.
207    Thread(ThreadArgs),
208    /// Evaluate sandbox/approval policy decisions.
209    Sandbox(SandboxArgs),
210    /// Run the app-server transport.
211    AppServer(AppServerArgs),
212    /// Generate shell completions.
213    #[command(after_help = r#"Examples:
214  Bash (current shell only):
215    source <(codewhale completion bash)
216
217  Bash (persistent, Linux/bash-completion):
218    mkdir -p ~/.local/share/bash-completion/completions
219    codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
220    # Requires bash-completion to be installed and loaded by your shell.
221
222  Zsh:
223    mkdir -p ~/.zfunc
224    codewhale completion zsh > ~/.zfunc/_codewhale
225    # Add to ~/.zshrc if needed:
226    #   fpath=(~/.zfunc $fpath)
227    #   autoload -Uz compinit && compinit
228
229  Fish:
230    mkdir -p ~/.config/fish/completions
231    codewhale completion fish > ~/.config/fish/completions/codewhale.fish
232
233  PowerShell (current shell only):
234    codewhale completion powershell | Out-String | Invoke-Expression
235
236The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
237    Completion {
238        #[arg(value_enum)]
239        shell: Shell,
240    },
241    /// Print a usage rollup from the audit log and session store.
242    Metrics(MetricsArgs),
243    /// Check for and apply updates to the `codewhale` binary.
244    Update(UpdateArgs),
245}
246
247#[derive(Debug, Args)]
248struct UpdateArgs {
249    /// Update to the latest beta release instead of the latest stable release.
250    #[arg(long)]
251    beta: bool,
252    /// Only check the latest release; do not download or replace binaries.
253    #[arg(long)]
254    check: bool,
255    /// Proxy URL to use for update HTTP requests.
256    #[arg(long, value_name = "URL")]
257    proxy: Option<String>,
258}
259
260#[derive(Debug, Args)]
261struct MetricsArgs {
262    /// Emit machine-readable JSON.
263    #[arg(long)]
264    json: bool,
265    /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
266    #[arg(long, value_name = "DURATION")]
267    since: Option<String>,
268}
269
270#[derive(Debug, Args)]
271struct RunArgs {
272    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
273    args: Vec<String>,
274}
275
276#[derive(Debug, Args, Clone)]
277struct TuiPassthroughArgs {
278    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
279    args: Vec<String>,
280}
281
282#[derive(Debug, Args)]
283struct LoginArgs {
284    #[arg(long, value_enum, hide = true)]
285    provider: Option<ProviderArg>,
286    #[arg(long)]
287    api_key: Option<String>,
288}
289
290#[derive(Debug, Args)]
291struct AuthArgs {
292    #[command(subcommand)]
293    command: AuthCommand,
294}
295
296#[derive(Debug, Subcommand)]
297enum AuthCommand {
298    /// Show current provider and credential source state.
299    Status,
300    /// Save an API key to the shared user config file. Reads from
301    /// `--api-key`, `--api-key-stdin`, or prompts on stdin when
302    /// neither is given. Does not echo the key.
303    Set {
304        #[arg(long, value_enum)]
305        provider: ProviderArg,
306        /// Inline value (discouraged — appears in shell history).
307        #[arg(long)]
308        api_key: Option<String>,
309        /// Read the key from stdin instead of prompting.
310        #[arg(long = "api-key-stdin", default_value_t = false)]
311        api_key_stdin: bool,
312    },
313    /// Report whether a provider has a key configured. Never prints
314    /// the value; just `set` / `not set` plus the source layer.
315    Get {
316        #[arg(long, value_enum)]
317        provider: ProviderArg,
318    },
319    /// Delete a provider's key from config and secret-store storage.
320    Clear {
321        #[arg(long, value_enum)]
322        provider: ProviderArg,
323    },
324    /// List all known providers with their auth state, without
325    /// revealing keys.
326    List,
327    /// Advanced: migrate config-file keys into a platform credential store.
328    #[command(hide = true)]
329    Migrate {
330        /// Don't actually write anything; print what would change.
331        #[arg(long, default_value_t = false)]
332        dry_run: bool,
333    },
334}
335
336#[derive(Debug, Args)]
337struct ConfigArgs {
338    #[command(subcommand)]
339    command: ConfigCommand,
340}
341
342#[derive(Debug, Subcommand)]
343enum ConfigCommand {
344    Get { key: String },
345    Set { key: String, value: String },
346    Unset { key: String },
347    List,
348    Path,
349}
350
351#[derive(Debug, Args)]
352struct ModelArgs {
353    #[command(subcommand)]
354    command: ModelCommand,
355}
356
357#[derive(Debug, Subcommand)]
358enum ModelCommand {
359    List {
360        #[arg(long, value_enum)]
361        provider: Option<ProviderArg>,
362    },
363    Resolve {
364        model: Option<String>,
365        #[arg(long, value_enum)]
366        provider: Option<ProviderArg>,
367    },
368}
369
370#[derive(Debug, Args)]
371struct ThreadArgs {
372    #[command(subcommand)]
373    command: ThreadCommand,
374}
375
376#[derive(Debug, Subcommand)]
377enum ThreadCommand {
378    List {
379        #[arg(long, default_value_t = false)]
380        all: bool,
381        #[arg(long)]
382        limit: Option<usize>,
383    },
384    Read {
385        thread_id: String,
386    },
387    Resume {
388        thread_id: String,
389    },
390    Fork {
391        thread_id: String,
392    },
393    Archive {
394        thread_id: String,
395    },
396    Unarchive {
397        thread_id: String,
398    },
399    SetName {
400        thread_id: String,
401        name: String,
402    },
403    /// Remove the custom name from a thread, restoring the default
404    /// `(unnamed)` rendering in `thread list`.
405    ClearName {
406        thread_id: String,
407    },
408}
409
410#[derive(Debug, Args)]
411struct SandboxArgs {
412    #[command(subcommand)]
413    command: SandboxCommand,
414}
415
416#[derive(Debug, Subcommand)]
417enum SandboxCommand {
418    Check {
419        command: String,
420        #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
421        ask: ApprovalModeArg,
422    },
423}
424
425#[derive(Debug, Clone, Copy, ValueEnum)]
426enum ApprovalModeArg {
427    UnlessTrusted,
428    OnFailure,
429    OnRequest,
430    Never,
431}
432
433impl From<ApprovalModeArg> for AskForApproval {
434    fn from(value: ApprovalModeArg) -> Self {
435        match value {
436            ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
437            ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
438            ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
439            ApprovalModeArg::Never => AskForApproval::Never,
440        }
441    }
442}
443
444#[derive(Debug, Args)]
445struct AppServerArgs {
446    #[arg(long, default_value = "127.0.0.1")]
447    host: String,
448    #[arg(long, default_value_t = 8787)]
449    port: u16,
450    #[arg(long)]
451    config: Option<PathBuf>,
452    #[arg(long = "auth-token")]
453    auth_token: Option<String>,
454    #[arg(long, default_value_t = false)]
455    insecure_no_auth: bool,
456    #[arg(long = "cors-origin")]
457    cors_origin: Vec<String>,
458    #[arg(long, default_value_t = false)]
459    stdio: bool,
460}
461
462const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
463
464pub fn run_cli() -> std::process::ExitCode {
465    match run() {
466        Ok(()) => std::process::ExitCode::SUCCESS,
467        Err(err) => {
468            // Use the full anyhow chain so callers see the underlying
469            // cause (e.g. the actual TOML parse error with line/column)
470            // instead of just the top-level context message. The bare
471            // `{err}` Display impl drops the chain — see #767, where
472            // users hit "failed to parse config at <path>" with no
473            // hint that the real error was a stray BOM or unbalanced
474            // quote a few lines down.
475            eprintln!("error: {err}");
476            for cause in err.chain().skip(1) {
477                eprintln!("  caused by: {cause}");
478            }
479            std::process::ExitCode::FAILURE
480        }
481    }
482}
483
484fn run() -> Result<()> {
485    let mut cli = Cli::parse();
486
487    let mut store = ConfigStore::load(cli.config.clone())?;
488    let runtime_overrides = CliRuntimeOverrides {
489        provider: cli.provider.map(Into::into),
490        model: cli.model.clone(),
491        api_key: cli.api_key.clone(),
492        base_url: cli.base_url.clone(),
493        auth_mode: None,
494        output_mode: cli.output_mode.clone(),
495        log_level: cli.log_level.clone(),
496        telemetry: cli.telemetry,
497        approval_policy: cli.approval_policy.clone(),
498        sandbox_mode: cli.sandbox_mode.clone(),
499        yolo: Some(cli.yolo),
500    };
501    let command = cli.command.take();
502
503    match command {
504        Some(Commands::Run(args)) => {
505            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
506            delegate_to_tui(&cli, &resolved_runtime, args.args)
507        }
508        Some(Commands::Doctor(args)) => {
509            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
510            delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
511        }
512        Some(Commands::Models(args)) => {
513            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
514            delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
515        }
516        Some(Commands::Speech(args)) => {
517            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
518            delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
519        }
520        Some(Commands::Sessions(args)) => {
521            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
522            delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
523        }
524        Some(Commands::Resume(args)) => {
525            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
526            run_resume_command(&cli, &resolved_runtime, args)
527        }
528        Some(Commands::Fork(args)) => {
529            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
530            delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
531        }
532        Some(Commands::Init(args)) => {
533            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
534            delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
535        }
536        Some(Commands::Setup(args)) => {
537            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
538            delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
539        }
540        Some(Commands::Exec(args)) => {
541            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
542            delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
543        }
544        Some(Commands::Swebench(args)) => {
545            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
546            delegate_to_tui(&cli, &resolved_runtime, tui_args("swebench", args))
547        }
548        Some(Commands::Review(args)) => {
549            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
550            delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
551        }
552        Some(Commands::Apply(args)) => {
553            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
554            delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
555        }
556        Some(Commands::Eval(args)) => {
557            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
558            delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
559        }
560        Some(Commands::Mcp(args)) => {
561            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
562            delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
563        }
564        Some(Commands::Features(args)) => {
565            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
566            delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
567        }
568        Some(Commands::Serve(args)) => {
569            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
570            delegate_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
571        }
572        Some(Commands::Completions(args)) => {
573            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
574            delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
575        }
576        Some(Commands::Login(args)) => run_login_command(&mut store, args),
577        Some(Commands::Logout) => run_logout_command(&mut store),
578        Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
579        Some(Commands::McpServer) => run_mcp_server_command(&mut store),
580        Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
581        Some(Commands::Model(args)) => run_model_command(args.command),
582        Some(Commands::Thread(args)) => run_thread_command(args.command),
583        Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
584        Some(Commands::AppServer(args)) => run_app_server_command(args),
585        Some(Commands::Completion { shell }) => {
586            let mut cmd = Cli::command();
587            generate(shell, &mut cmd, "codewhale", &mut io::stdout());
588            Ok(())
589        }
590        Some(Commands::Metrics(args)) => run_metrics_command(args),
591        Some(Commands::Update(args)) => update::run_update(args.beta, args.check, args.proxy),
592        None => {
593            let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
594            let forwarded = root_tui_passthrough(&cli)?;
595            delegate_to_tui(&cli, &resolved_runtime, forwarded)
596        }
597    }
598}
599
600fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
601    let mut forwarded = Vec::new();
602    if cli.continue_session {
603        forwarded.push("--continue".to_string());
604    }
605
606    let prompt =
607        cli.prompt_flag
608            .iter()
609            .chain(cli.prompt.iter())
610            .fold(String::new(), |mut acc, part| {
611                if !acc.is_empty() {
612                    acc.push(' ');
613                }
614                acc.push_str(part);
615                acc
616            });
617    if !prompt.is_empty() {
618        if cli.continue_session {
619            bail!(
620                "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
621            );
622        }
623        forwarded.push("--prompt".to_string());
624        forwarded.push(prompt);
625    }
626
627    Ok(forwarded)
628}
629
630fn resolve_runtime_for_dispatch(
631    store: &mut ConfigStore,
632    runtime_overrides: &CliRuntimeOverrides,
633) -> ResolvedRuntimeOptions {
634    let runtime_secrets = Secrets::auto_detect();
635    resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
636}
637
638fn resolve_runtime_for_dispatch_with_secrets(
639    store: &mut ConfigStore,
640    runtime_overrides: &CliRuntimeOverrides,
641    secrets: &Secrets,
642) -> ResolvedRuntimeOptions {
643    let mut resolved = store
644        .config
645        .resolve_runtime_options_with_secrets(runtime_overrides, secrets);
646
647    if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)
648        && !provider_config_set(store, resolved.provider)
649        && let Some(api_key) = resolved.api_key.clone()
650    {
651        write_provider_api_key_to_config(store, resolved.provider, &api_key);
652        match store.save() {
653            Ok(()) => {
654                eprintln!(
655                    "info: recovered API key from secret store and saved it to {}",
656                    store.path().display()
657                );
658                resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);
659            }
660            Err(err) => {
661                eprintln!(
662                    "warning: recovered API key from secret store but failed to save {}: {err}",
663                    store.path().display()
664                );
665            }
666        }
667    }
668
669    resolved
670}
671
672fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
673    let mut forwarded = Vec::with_capacity(args.args.len() + 1);
674    forwarded.push(command.to_string());
675    forwarded.extend(args.args);
676    forwarded
677}
678
679fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
680    run_login_command_with_secrets(store, args, &Secrets::auto_detect())
681}
682
683fn run_login_command_with_secrets(
684    store: &mut ConfigStore,
685    args: LoginArgs,
686    secrets: &Secrets,
687) -> Result<()> {
688    let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
689    store.config.provider = provider;
690
691    let api_key = match args.api_key {
692        Some(v) => v,
693        None => read_api_key_from_stdin()?,
694    };
695    write_provider_api_key_to_config(store, provider, &api_key);
696    let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
697    store.save()?;
698    let destination = if keyring_saved {
699        format!("{} and {}", store.path().display(), secrets.backend_name())
700    } else {
701        store.path().display().to_string()
702    };
703    if provider == ProviderKind::Deepseek {
704        println!("logged in using API key mode (deepseek); saved key to {destination}");
705    } else {
706        println!(
707            "logged in using API key mode ({}); saved key to {destination}",
708            provider.as_str(),
709        );
710    }
711    Ok(())
712}
713
714fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
715    run_logout_command_with_secrets(store, &Secrets::auto_detect())
716}
717
718fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
719    let active_provider = store.config.provider;
720    store.config.api_key = None;
721    for provider in PROVIDER_LIST {
722        clear_provider_api_key_from_config(store, provider);
723    }
724    clear_provider_api_key_from_keyring(secrets, active_provider);
725    store.config.auth_mode = None;
726    store.save()?;
727    println!("logged out");
728    Ok(())
729}
730
731/// Map [`ProviderKind`] to the canonical provider credential slot.
732fn provider_slot(provider: ProviderKind) -> &'static str {
733    match provider {
734        ProviderKind::Deepseek => "deepseek",
735        ProviderKind::NvidiaNim => "nvidia-nim",
736        ProviderKind::Openai => "openai",
737        ProviderKind::Atlascloud => "atlascloud",
738        ProviderKind::WanjieArk => "wanjie-ark",
739        ProviderKind::Volcengine => "volcengine",
740        ProviderKind::Openrouter => "openrouter",
741        ProviderKind::XiaomiMimo => "xiaomi-mimo",
742        ProviderKind::Novita => "novita",
743        ProviderKind::Fireworks => "fireworks",
744        ProviderKind::Siliconflow => "siliconflow",
745        ProviderKind::Moonshot => "moonshot",
746        ProviderKind::Sglang => "sglang",
747        ProviderKind::Vllm => "vllm",
748        ProviderKind::Ollama => "ollama",
749    }
750}
751
752/// Provider order used by the `auth list` and `auth status` outputs.
753const PROVIDER_LIST: [ProviderKind; 15] = [
754    ProviderKind::Deepseek,
755    ProviderKind::NvidiaNim,
756    ProviderKind::Openai,
757    ProviderKind::Atlascloud,
758    ProviderKind::WanjieArk,
759    ProviderKind::Volcengine,
760    ProviderKind::Openrouter,
761    ProviderKind::XiaomiMimo,
762    ProviderKind::Novita,
763    ProviderKind::Fireworks,
764    ProviderKind::Siliconflow,
765    ProviderKind::Moonshot,
766    ProviderKind::Sglang,
767    ProviderKind::Vllm,
768    ProviderKind::Ollama,
769];
770
771#[cfg(test)]
772fn no_keyring_secrets() -> Secrets {
773    Secrets::new(std::sync::Arc::new(
774        codewhale_secrets::InMemoryKeyringStore::new(),
775    ))
776}
777
778fn write_provider_api_key_to_config(
779    store: &mut ConfigStore,
780    provider: ProviderKind,
781    api_key: &str,
782) {
783    store.config.provider = provider;
784    store.config.auth_mode = Some("api_key".to_string());
785    store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
786    if provider == ProviderKind::Deepseek {
787        store.config.api_key = Some(api_key.to_string());
788        if store.config.default_text_model.is_none() {
789            store.config.default_text_model = Some(
790                store
791                    .config
792                    .providers
793                    .deepseek
794                    .model
795                    .clone()
796                    .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
797            );
798        }
799    }
800}
801
802fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
803    store.config.providers.for_provider_mut(provider).api_key = None;
804    if provider == ProviderKind::Deepseek {
805        store.config.api_key = None;
806    }
807}
808
809fn provider_env_set(provider: ProviderKind) -> bool {
810    provider_env_value(provider).is_some()
811}
812
813fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
814    match provider {
815        ProviderKind::Deepseek => &["DEEPSEEK_API_KEY"],
816        ProviderKind::Openrouter => &["OPENROUTER_API_KEY"],
817        ProviderKind::XiaomiMimo => &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"],
818        ProviderKind::Novita => &["NOVITA_API_KEY"],
819        ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
820        ProviderKind::Fireworks => &["FIREWORKS_API_KEY"],
821        ProviderKind::Siliconflow => &["SILICONFLOW_API_KEY"],
822        ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
823        ProviderKind::Sglang => &["SGLANG_API_KEY"],
824        ProviderKind::Vllm => &["VLLM_API_KEY"],
825        ProviderKind::Ollama => &["OLLAMA_API_KEY"],
826        ProviderKind::Openai => &["OPENAI_API_KEY"],
827        ProviderKind::Atlascloud => &["ATLASCLOUD_API_KEY"],
828        ProviderKind::Volcengine => &[
829            "VOLCENGINE_API_KEY",
830            "VOLCENGINE_ARK_API_KEY",
831            "ARK_API_KEY",
832        ],
833        ProviderKind::WanjieArk => &[
834            "WANJIE_ARK_API_KEY",
835            "WANJIE_API_KEY",
836            "WANJIE_MAAS_API_KEY",
837        ],
838    }
839}
840
841fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
842    provider_env_vars(provider).iter().find_map(|var| {
843        std::env::var(var)
844            .ok()
845            .filter(|value| !value.trim().is_empty())
846            .map(|value| (*var, value))
847    })
848}
849
850fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
851    let slot = store
852        .config
853        .providers
854        .for_provider(provider)
855        .api_key
856        .as_deref();
857    let root = (provider == ProviderKind::Deepseek)
858        .then_some(store.config.api_key.as_deref())
859        .flatten();
860    slot.or(root).filter(|v| !v.trim().is_empty())
861}
862
863fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
864    provider_config_api_key(store, provider).is_some()
865}
866
867fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
868    secrets
869        .get(provider_slot(provider))
870        .ok()
871        .flatten()
872        .filter(|v| !v.trim().is_empty())
873}
874
875fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
876    provider_keyring_api_key(secrets, provider).is_some()
877}
878
879fn write_provider_api_key_to_keyring(
880    secrets: &Secrets,
881    provider: ProviderKind,
882    api_key: &str,
883) -> bool {
884    secrets.set(provider_slot(provider), api_key).is_ok()
885}
886
887fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
888    let _ = secrets.delete(provider_slot(provider));
889}
890
891fn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
892    let provider = store.config.provider;
893    let config_key = provider_config_api_key(store, provider);
894    let keyring_key = provider_keyring_api_key(secrets, provider);
895    let env_key = provider_env_value(provider);
896
897    let active_source = if config_key.is_some() {
898        "config"
899    } else if keyring_key.is_some() {
900        "secret store"
901    } else if env_key.is_some() {
902        "env"
903    } else {
904        "missing"
905    };
906    let active_last4 = config_key
907        .map(last4_label)
908        .or_else(|| keyring_key.as_deref().map(last4_label))
909        .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));
910    let active_label = active_last4
911        .map(|last4| format!("{active_source} (last4: {last4})"))
912        .unwrap_or_else(|| active_source.to_string());
913
914    let env_var_label = env_key
915        .as_ref()
916        .map(|(name, _)| (*name).to_string())
917        .unwrap_or_else(|| provider_env_vars(provider).join("/"));
918    let env_status = env_key
919        .as_ref()
920        .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
921        .unwrap_or_else(|| "unset".to_string());
922
923    vec![
924        format!("provider: {}", provider.as_str()),
925        format!(
926            "auth mode: {}",
927            store.config.auth_mode.as_deref().unwrap_or("api_key")
928        ),
929        format!("active source: {active_label}"),
930        "lookup order: config -> secret store -> env".to_string(),
931        format!(
932            "config file: {} ({})",
933            store.path().display(),
934            source_status(config_key, "missing")
935        ),
936        format!(
937            "secret store: {} ({})",
938            secrets.backend_name(),
939            source_status(keyring_key.as_deref(), "missing")
940        ),
941        format!("env var: {env_var_label} ({env_status})"),
942    ]
943}
944
945fn source_status(value: Option<&str>, missing_label: &str) -> String {
946    value
947        .map(|v| format!("set, last4: {}", last4_label(v)))
948        .unwrap_or_else(|| missing_label.to_string())
949}
950
951fn last4_label(value: &str) -> String {
952    let trimmed = value.trim();
953    let chars: Vec<char> = trimmed.chars().collect();
954    if chars.len() <= 4 {
955        return "<redacted>".to_string();
956    }
957    let last4: String = chars[chars.len() - 4..].iter().collect();
958    format!("...{last4}")
959}
960
961fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
962    run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
963}
964
965fn run_auth_command_with_secrets(
966    store: &mut ConfigStore,
967    command: AuthCommand,
968    secrets: &Secrets,
969) -> Result<()> {
970    match command {
971        AuthCommand::Status => {
972            for line in auth_status_lines(store, secrets) {
973                println!("{line}");
974            }
975            Ok(())
976        }
977        AuthCommand::Set {
978            provider,
979            api_key,
980            api_key_stdin,
981        } => {
982            let provider: ProviderKind = provider.into();
983            let slot = provider_slot(provider);
984            if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
985                store.config.provider = provider;
986                let provider_cfg = store.config.providers.for_provider_mut(provider);
987                if provider_cfg.base_url.is_none() {
988                    provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
989                }
990                store.save()?;
991                println!(
992                    "configured {slot} provider in {} (API key optional)",
993                    store.path().display()
994                );
995                return Ok(());
996            }
997            let api_key = match (api_key, api_key_stdin) {
998                (Some(v), _) => v,
999                (None, true) => read_api_key_from_stdin()?,
1000                (None, false) => prompt_api_key(slot)?,
1001            };
1002            write_provider_api_key_to_config(store, provider, &api_key);
1003            let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
1004            store.save()?;
1005            // Don't print the key. Don't echo length.
1006            if keyring_saved {
1007                println!(
1008                    "saved API key for {slot} to {} and {}",
1009                    store.path().display(),
1010                    secrets.backend_name()
1011                );
1012            } else {
1013                println!("saved API key for {slot} to {}", store.path().display());
1014            }
1015            Ok(())
1016        }
1017        AuthCommand::Get { provider } => {
1018            let provider: ProviderKind = provider.into();
1019            let slot = provider_slot(provider);
1020            let in_file = provider_config_set(store, provider);
1021            let in_keyring = !in_file && provider_keyring_set(secrets, provider);
1022            let in_env = provider_env_set(provider);
1023            // Report the highest-priority source that has it.
1024            let source = if in_file {
1025                Some("config-file")
1026            } else if in_keyring {
1027                Some("secret-store")
1028            } else if in_env {
1029                Some("env")
1030            } else {
1031                None
1032            };
1033            match source {
1034                Some(source) => println!("{slot}: set (source: {source})"),
1035                None => println!("{slot}: not set"),
1036            }
1037            Ok(())
1038        }
1039        AuthCommand::Clear { provider } => {
1040            let provider: ProviderKind = provider.into();
1041            let slot = provider_slot(provider);
1042            clear_provider_api_key_from_config(store, provider);
1043            clear_provider_api_key_from_keyring(secrets, provider);
1044            store.save()?;
1045            println!("cleared API key for {slot} from config and secret store");
1046            Ok(())
1047        }
1048        AuthCommand::List => {
1049            println!("provider     config store env  active");
1050            let active_provider = store.config.provider;
1051            for provider in PROVIDER_LIST {
1052                let slot = provider_slot(provider);
1053                let file = provider_config_set(store, provider);
1054                let keyring = (provider == active_provider && !file)
1055                    .then(|| provider_keyring_set(secrets, provider));
1056                let env = provider_env_set(provider);
1057                let active = if file {
1058                    "config"
1059                } else if keyring == Some(true) {
1060                    "store"
1061                } else if env {
1062                    "env"
1063                } else {
1064                    "missing"
1065                };
1066                println!(
1067                    "{slot:<12}  {}     {}      {}   {active}",
1068                    yes_no(file),
1069                    keyring_status_short(keyring),
1070                    yes_no(env)
1071                );
1072            }
1073            Ok(())
1074        }
1075        AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
1076    }
1077}
1078
1079fn yes_no(b: bool) -> &'static str {
1080    if b { "yes" } else { "no " }
1081}
1082
1083fn keyring_status_short(state: Option<bool>) -> &'static str {
1084    match state {
1085        Some(true) => "yes",
1086        Some(false) => "no ",
1087        None => "n/a",
1088    }
1089}
1090
1091fn prompt_api_key(slot: &str) -> Result<String> {
1092    use std::io::{IsTerminal, Write};
1093    eprint!("Enter API key for {slot}: ");
1094    io::stderr().flush().ok();
1095    if !io::stdin().is_terminal() {
1096        // Non-interactive: read directly without prompting twice.
1097        return read_api_key_from_stdin();
1098    }
1099    let mut buf = String::new();
1100    io::stdin()
1101        .read_line(&mut buf)
1102        .context("failed to read API key from stdin")?;
1103    let key = buf.trim().to_string();
1104    if key.is_empty() {
1105        bail!("empty API key provided");
1106    }
1107    Ok(key)
1108}
1109
1110/// Move plaintext keys from config.toml into the configured secret store.
1111/// Hidden in v0.8.8 because the normal setup path is config/env only.
1112fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
1113    let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
1114    let mut warnings: Vec<String> = Vec::new();
1115
1116    for provider in PROVIDER_LIST {
1117        let slot = provider_slot(provider);
1118        let from_provider_block = store
1119            .config
1120            .providers
1121            .for_provider(provider)
1122            .api_key
1123            .clone()
1124            .filter(|v| !v.trim().is_empty());
1125        let from_root = (provider == ProviderKind::Deepseek)
1126            .then(|| store.config.api_key.clone())
1127            .flatten()
1128            .filter(|v| !v.trim().is_empty());
1129        let value = from_provider_block.or(from_root);
1130        let Some(value) = value else { continue };
1131
1132        if let Ok(Some(existing)) = secrets.get(slot)
1133            && existing == value
1134        {
1135            // Already migrated; safe to strip the file slot.
1136        } else if dry_run {
1137            migrated.push((provider, slot));
1138            continue;
1139        } else if let Err(err) = secrets.set(slot, &value) {
1140            warnings.push(format!(
1141                "skipped {slot}: failed to write to secret store: {err}"
1142            ));
1143            continue;
1144        }
1145        if !dry_run {
1146            store.config.providers.for_provider_mut(provider).api_key = None;
1147            if provider == ProviderKind::Deepseek {
1148                store.config.api_key = None;
1149            }
1150        }
1151        migrated.push((provider, slot));
1152    }
1153
1154    if !dry_run && !migrated.is_empty() {
1155        store
1156            .save()
1157            .context("failed to write updated config.toml")?;
1158    }
1159
1160    println!("secret store backend: {}", secrets.backend_name());
1161    if migrated.is_empty() {
1162        println!("nothing to migrate (config.toml has no plaintext api_key entries)");
1163    } else {
1164        println!(
1165            "{} {} provider key(s):",
1166            if dry_run { "would migrate" } else { "migrated" },
1167            migrated.len()
1168        );
1169        for (_, slot) in &migrated {
1170            println!("  - {slot}");
1171        }
1172        if !dry_run {
1173            println!(
1174                "config.toml at {} no longer contains api_key entries for migrated providers.",
1175                store.path().display()
1176            );
1177        }
1178    }
1179    for w in warnings {
1180        eprintln!("warning: {w}");
1181    }
1182    Ok(())
1183}
1184
1185fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
1186    match command {
1187        ConfigCommand::Get { key } => {
1188            if let Some(value) = store.config.get_display_value(&key) {
1189                println!("{value}");
1190                return Ok(());
1191            }
1192            bail!("key not found: {key}");
1193        }
1194        ConfigCommand::Set { key, value } => {
1195            store.config.set_value(&key, &value)?;
1196            store.save()?;
1197            println!("set {key}");
1198            Ok(())
1199        }
1200        ConfigCommand::Unset { key } => {
1201            store.config.unset_value(&key)?;
1202            store.save()?;
1203            println!("unset {key}");
1204            Ok(())
1205        }
1206        ConfigCommand::List => {
1207            for (key, value) in store.config.list_values() {
1208                println!("{key} = {value}");
1209            }
1210            Ok(())
1211        }
1212        ConfigCommand::Path => {
1213            println!("{}", store.path().display());
1214            Ok(())
1215        }
1216    }
1217}
1218
1219fn run_model_command(command: ModelCommand) -> Result<()> {
1220    let registry = ModelRegistry::default();
1221    match command {
1222        ModelCommand::List { provider } => {
1223            let filter = provider.map(ProviderKind::from);
1224            for model in registry.list().into_iter().filter(|m| match filter {
1225                Some(p) => m.provider == p,
1226                None => true,
1227            }) {
1228                println!("{} ({})", model.id, model.provider.as_str());
1229            }
1230            Ok(())
1231        }
1232        ModelCommand::Resolve { model, provider } => {
1233            let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));
1234            println!("requested: {}", resolved.requested.unwrap_or_default());
1235            println!("resolved: {}", resolved.resolved.id);
1236            println!("provider: {}", resolved.resolved.provider.as_str());
1237            println!("used_fallback: {}", resolved.used_fallback);
1238            Ok(())
1239        }
1240    }
1241}
1242
1243fn run_thread_command(command: ThreadCommand) -> Result<()> {
1244    let state = StateStore::open(None)?;
1245    match command {
1246        ThreadCommand::List { all, limit } => {
1247            let threads = state.list_threads(ThreadListFilters {
1248                include_archived: all,
1249                limit,
1250            })?;
1251            for thread in threads {
1252                println!(
1253                    "{} | {} | {} | {}",
1254                    thread.id,
1255                    thread
1256                        .name
1257                        .clone()
1258                        .unwrap_or_else(|| "(unnamed)".to_string()),
1259                    thread.model_provider,
1260                    thread.cwd.display()
1261                );
1262            }
1263            Ok(())
1264        }
1265        ThreadCommand::Read { thread_id } => {
1266            let thread = state.get_thread(&thread_id)?;
1267            println!("{}", serde_json::to_string_pretty(&thread)?);
1268            Ok(())
1269        }
1270        ThreadCommand::Resume { thread_id } => {
1271            let args = vec!["resume".to_string(), thread_id];
1272            delegate_simple_tui(args)
1273        }
1274        ThreadCommand::Fork { thread_id } => {
1275            let args = vec!["fork".to_string(), thread_id];
1276            delegate_simple_tui(args)
1277        }
1278        ThreadCommand::Archive { thread_id } => {
1279            state.mark_archived(&thread_id)?;
1280            println!("archived {thread_id}");
1281            Ok(())
1282        }
1283        ThreadCommand::Unarchive { thread_id } => {
1284            state.mark_unarchived(&thread_id)?;
1285            println!("unarchived {thread_id}");
1286            Ok(())
1287        }
1288        ThreadCommand::SetName { thread_id, name } => {
1289            let mut thread = state
1290                .get_thread(&thread_id)?
1291                .with_context(|| format!("thread not found: {thread_id}"))?;
1292            thread.name = Some(name);
1293            thread.updated_at = chrono::Utc::now().timestamp();
1294            state.upsert_thread(&thread)?;
1295            println!("renamed {thread_id}");
1296            Ok(())
1297        }
1298        ThreadCommand::ClearName { thread_id } => {
1299            let mut thread = state
1300                .get_thread(&thread_id)?
1301                .with_context(|| format!("thread not found: {thread_id}"))?;
1302            thread.name = None;
1303            thread.updated_at = chrono::Utc::now().timestamp();
1304            state.upsert_thread(&thread)?;
1305            println!("cleared name for {thread_id}");
1306            Ok(())
1307        }
1308    }
1309}
1310
1311fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
1312    match command {
1313        SandboxCommand::Check { command, ask } => {
1314            let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
1315            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1316            let decision = engine.check(ExecPolicyContext {
1317                command: &command,
1318                cwd: &cwd.display().to_string(),
1319                tool: Some("exec_shell"),
1320                path: None,
1321                ask_for_approval: ask.into(),
1322                sandbox_mode: Some("workspace-write"),
1323            })?;
1324            println!("{}", serde_json::to_string_pretty(&decision)?);
1325            Ok(())
1326        }
1327    }
1328}
1329
1330fn run_app_server_command(args: AppServerArgs) -> Result<()> {
1331    let runtime = tokio::runtime::Builder::new_multi_thread()
1332        .enable_all()
1333        .build()
1334        .context("failed to create tokio runtime")?;
1335    if args.stdio {
1336        return runtime.block_on(run_app_server_stdio(args.config));
1337    }
1338    let listen: SocketAddr = format!("{}:{}", args.host, args.port)
1339        .parse()
1340        .with_context(|| {
1341            format!(
1342                "invalid app-server listen address {}:{}",
1343                args.host, args.port
1344            )
1345        })?;
1346    runtime.block_on(run_app_server(AppServerOptions {
1347        listen,
1348        config_path: args.config,
1349        auth_token: args.auth_token.or_else(app_server_token_from_env),
1350        insecure_no_auth: args.insecure_no_auth,
1351        cors_origins: args.cors_origin,
1352    }))
1353}
1354
1355fn app_server_token_from_env() -> Option<String> {
1356    std::env::var("CODEWHALE_APP_SERVER_TOKEN")
1357        .ok()
1358        .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
1359}
1360
1361fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
1362    let persisted = load_mcp_server_definitions(store);
1363    let updated = run_stdio_server(persisted)?;
1364    persist_mcp_server_definitions(store, &updated)
1365}
1366
1367fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
1368    let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
1369        return Vec::new();
1370    };
1371
1372    match parse_mcp_server_definitions(&raw) {
1373        Ok(definitions) => definitions,
1374        Err(err) => {
1375            eprintln!(
1376                "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
1377            );
1378            Vec::new()
1379        }
1380    }
1381}
1382
1383fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
1384    if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
1385        return Ok(parsed);
1386    }
1387
1388    let unwrapped: String = serde_json::from_str(raw)
1389        .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
1390    serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
1391        format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
1392    })
1393}
1394
1395fn persist_mcp_server_definitions(
1396    store: &mut ConfigStore,
1397    definitions: &[McpServerDefinition],
1398) -> Result<()> {
1399    let encoded =
1400        serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
1401    store
1402        .config
1403        .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
1404    store.save()
1405}
1406
1407fn delegate_to_tui(
1408    cli: &Cli,
1409    resolved_runtime: &ResolvedRuntimeOptions,
1410    passthrough: Vec<String>,
1411) -> Result<()> {
1412    let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
1413    let tui = PathBuf::from(cmd.get_program());
1414    let status = cmd
1415        .status()
1416        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1417    exit_with_tui_status(status)
1418}
1419
1420fn run_resume_command(
1421    cli: &Cli,
1422    resolved_runtime: &ResolvedRuntimeOptions,
1423    args: TuiPassthroughArgs,
1424) -> Result<()> {
1425    let passthrough = tui_args("resume", args);
1426    if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
1427        return run_dispatcher_resume_picker(cli, resolved_runtime);
1428    }
1429    delegate_to_tui(cli, resolved_runtime, passthrough)
1430}
1431
1432fn run_dispatcher_resume_picker(
1433    cli: &Cli,
1434    resolved_runtime: &ResolvedRuntimeOptions,
1435) -> Result<()> {
1436    let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
1437    let tui = PathBuf::from(sessions_cmd.get_program());
1438    let status = sessions_cmd
1439        .status()
1440        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1441    if !status.success() {
1442        return exit_with_tui_status(status);
1443    }
1444
1445    println!();
1446    println!("Windows note: enter a session id or prefix from the list above.");
1447    println!("You can also run `codewhale resume --last` to skip this prompt.");
1448    print!("Session id/prefix (Enter to cancel): ");
1449    io::stdout().flush()?;
1450
1451    let mut input = String::new();
1452    io::stdin()
1453        .read_line(&mut input)
1454        .context("failed to read session selection")?;
1455    let session_id = input.trim();
1456    if session_id.is_empty() {
1457        bail!("No session selected.");
1458    }
1459
1460    delegate_to_tui(
1461        cli,
1462        resolved_runtime,
1463        vec!["resume".to_string(), session_id.to_string()],
1464    )
1465}
1466
1467fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
1468    is_windows && passthrough == ["resume"]
1469}
1470
1471fn build_tui_command(
1472    cli: &Cli,
1473    resolved_runtime: &ResolvedRuntimeOptions,
1474    passthrough: Vec<String>,
1475) -> Result<Command> {
1476    let tui = locate_sibling_tui_binary()?;
1477
1478    let mut cmd = Command::new(&tui);
1479    if let Some(config) = cli.config.as_ref() {
1480        cmd.arg("--config").arg(config);
1481    }
1482    if let Some(profile) = cli.profile.as_ref() {
1483        cmd.arg("--profile").arg(profile);
1484    }
1485    if let Some(workspace) = cli.workspace.as_ref() {
1486        cmd.arg("--workspace").arg(workspace);
1487    }
1488    // Accepted for older scripts, but no longer forwarded: the interactive TUI
1489    // always owns the alternate screen to avoid host scrollback hijacking.
1490    let _ = cli.no_alt_screen;
1491    if cli.mouse_capture {
1492        cmd.arg("--mouse-capture");
1493    }
1494    if cli.no_mouse_capture {
1495        cmd.arg("--no-mouse-capture");
1496    }
1497    if cli.skip_onboarding {
1498        cmd.arg("--skip-onboarding");
1499    }
1500    cmd.args(passthrough);
1501
1502    if !matches!(
1503        resolved_runtime.provider,
1504        ProviderKind::Deepseek
1505            | ProviderKind::NvidiaNim
1506            | ProviderKind::Openai
1507            | ProviderKind::Atlascloud
1508            | ProviderKind::WanjieArk
1509            | ProviderKind::Openrouter
1510            | ProviderKind::XiaomiMimo
1511            | ProviderKind::Novita
1512            | ProviderKind::Fireworks
1513            | ProviderKind::Siliconflow
1514            | ProviderKind::Moonshot
1515            | ProviderKind::Sglang
1516            | ProviderKind::Vllm
1517            | ProviderKind::Ollama
1518    ) {
1519        bail!(
1520            "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Xiaomi MiMo, Novita, Fireworks, SiliconFlow, Moonshot/Kimi, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.",
1521            resolved_runtime.provider.as_str()
1522        );
1523    }
1524
1525    if let Some(provider) = cli.provider {
1526        let provider: ProviderKind = provider.into();
1527        cmd.env("DEEPSEEK_PROVIDER", provider.as_str());
1528    }
1529    if matches!(
1530        resolved_runtime.api_key_source,
1531        Some(RuntimeApiKeySource::Keyring)
1532    ) && let Some(api_key) = resolved_runtime.api_key.as_ref()
1533    {
1534        // TUI reloads auth_mode from config/profile, but it does not re-query the
1535        // platform keyring on normal startup. Bridge only the recovered secret;
1536        // replaying auth_mode here would turn it back into a profile override.
1537        cmd.env("DEEPSEEK_API_KEY", api_key);
1538        for var in provider_env_vars(resolved_runtime.provider) {
1539            if *var != "DEEPSEEK_API_KEY" {
1540                cmd.env(var, api_key);
1541            }
1542        }
1543        cmd.env(
1544            "DEEPSEEK_API_KEY_SOURCE",
1545            RuntimeApiKeySource::Keyring.as_env_value(),
1546        );
1547    }
1548
1549    if let Some(model) = cli.model.as_ref() {
1550        cmd.env("DEEPSEEK_MODEL", model);
1551    }
1552    if let Some(output_mode) = cli.output_mode.as_ref() {
1553        cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
1554    }
1555    if let Some(log_level) = cli.log_level.as_ref() {
1556        cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
1557    }
1558    if let Some(telemetry) = cli.telemetry {
1559        cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
1560    }
1561    if let Some(policy) = cli.approval_policy.as_ref() {
1562        cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
1563    }
1564    if let Some(mode) = cli.sandbox_mode.as_ref() {
1565        cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
1566    }
1567    if cli.yolo {
1568        cmd.env("DEEPSEEK_YOLO", "true");
1569    }
1570    if let Some(api_key) = cli.api_key.as_ref() {
1571        cmd.env("DEEPSEEK_API_KEY", api_key);
1572        if resolved_runtime.provider == ProviderKind::Openai {
1573            cmd.env("OPENAI_API_KEY", api_key);
1574        }
1575        if resolved_runtime.provider == ProviderKind::Atlascloud {
1576            cmd.env("ATLASCLOUD_API_KEY", api_key);
1577        }
1578        if resolved_runtime.provider == ProviderKind::WanjieArk {
1579            cmd.env("WANJIE_ARK_API_KEY", api_key);
1580        }
1581        if resolved_runtime.provider == ProviderKind::Volcengine {
1582            cmd.env("VOLCENGINE_API_KEY", api_key);
1583        }
1584        if resolved_runtime.provider == ProviderKind::Siliconflow {
1585            cmd.env("SILICONFLOW_API_KEY", api_key);
1586        }
1587        cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
1588    }
1589    if let Some(base_url) = cli.base_url.as_ref() {
1590        cmd.env("DEEPSEEK_BASE_URL", base_url);
1591    }
1592
1593    Ok(cmd)
1594}
1595
1596fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
1597    match status.code() {
1598        Some(code) => std::process::exit(code),
1599        None => bail!("codewhale-tui terminated by signal"),
1600    }
1601}
1602
1603fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
1604    let tui = locate_sibling_tui_binary()?;
1605    let status = Command::new(&tui)
1606        .args(args)
1607        .status()
1608        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1609    match status.code() {
1610        Some(code) => std::process::exit(code),
1611        None => bail!("codewhale-tui terminated by signal"),
1612    }
1613}
1614
1615fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
1616    format!(
1617        "failed to spawn companion TUI binary at {}: {err}\n\
1618\n\
1619The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
1620to execute it. Common fixes:\n\
1621  - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
1622  - On Windows, run `where codewhale` and `where codewhale-tui`; both should \
1623come from the same install directory.\n\
1624  - If you downloaded release assets manually, keep both `codewhale` and \
1625`codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
1626  - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \
1627binary.",
1628        tui.display()
1629    )
1630}
1631
1632/// Resolve the sibling `codewhale-tui` executable next to the running
1633/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so
1634/// the npm-distributed Windows package — which ships
1635/// `bin/downloads/codewhale-tui.exe` — is found by `Path::exists` (#247).
1636///
1637/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for
1638/// custom installs and CI test layouts. On Windows we additionally try
1639/// the suffix-less name as a fallback for users who already manually
1640/// renamed the file before this fix landed.
1641fn locate_sibling_tui_binary() -> Result<PathBuf> {
1642    if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
1643        let candidate = PathBuf::from(override_path);
1644        if candidate.is_file() {
1645            return Ok(candidate);
1646        }
1647        bail!(
1648            "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
1649            candidate.display()
1650        );
1651    }
1652
1653    let current = std::env::current_exe().context("failed to locate current executable path")?;
1654    if let Some(found) = sibling_tui_candidate(&current) {
1655        return Ok(found);
1656    }
1657
1658    // Build a stable error path so the user sees the platform-correct
1659    // expected name, not "codewhale-tui" on Windows.
1660    let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
1661    bail!(
1662        "Companion `codewhale-tui` binary not found at {}.\n\
1663\n\
1664The `codewhale` dispatcher delegates interactive sessions to a sibling \
1665`codewhale-tui` binary. To fix this, install one of:\n\
1666  • npm:    npm install -g codewhale                (downloads both binaries)\n\
1667  • cargo:  cargo install codewhale-cli codewhale-tui --locked\n\
1668  • GitHub Releases: download BOTH `codewhale-<platform>` AND \
1669`codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
1670and place them in the same directory.\n\
1671\n\
1672Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.",
1673        expected.display()
1674    );
1675}
1676
1677/// Return the first existing sibling-binary path under any of the names
1678/// `codewhale-tui` might use on this platform. Pure function to keep
1679/// `locate_sibling_tui_binary` testable.
1680fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
1681    // Primary: platform-correct name. EXE_SUFFIX is "" on Unix and ".exe"
1682    // on Windows.
1683    let primary =
1684        dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
1685    if primary.is_file() {
1686        return Some(primary);
1687    }
1688    // Windows fallback: a user who manually renamed `.exe` away (per the
1689    // workaround in #247) still launches successfully under the new code.
1690    if cfg!(windows) {
1691        let suffixless = dispatcher.with_file_name("codewhale-tui");
1692        if suffixless.is_file() {
1693            return Some(suffixless);
1694        }
1695    }
1696    None
1697}
1698
1699fn run_metrics_command(args: MetricsArgs) -> Result<()> {
1700    let since = match args.since.as_deref() {
1701        Some(s) => {
1702            Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
1703        }
1704        None => None,
1705    };
1706    metrics::run(metrics::MetricsArgs {
1707        json: args.json,
1708        since,
1709    })
1710}
1711
1712fn read_api_key_from_stdin() -> Result<String> {
1713    let mut input = String::new();
1714    io::stdin()
1715        .read_to_string(&mut input)
1716        .context("failed to read api key from stdin")?;
1717    let key = input.trim().to_string();
1718    if key.is_empty() {
1719        bail!("empty API key provided");
1720    }
1721    Ok(key)
1722}
1723
1724#[cfg(test)]
1725mod tests {
1726    use super::*;
1727    use clap::error::ErrorKind;
1728    use std::ffi::OsString;
1729    use std::sync::{Mutex, OnceLock};
1730
1731    fn parse_ok(argv: &[&str]) -> Cli {
1732        Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
1733    }
1734
1735    fn help_for(argv: &[&str]) -> String {
1736        let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
1737        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
1738        err.to_string()
1739    }
1740
1741    fn command_env(cmd: &Command, name: &str) -> Option<String> {
1742        let name = std::ffi::OsStr::new(name);
1743        cmd.get_envs().find_map(|(key, value)| {
1744            if key == name {
1745                value.map(|v| v.to_string_lossy().into_owned())
1746            } else {
1747                None
1748            }
1749        })
1750    }
1751
1752    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1753        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1754        LOCK.get_or_init(|| Mutex::new(()))
1755            .lock()
1756            .unwrap_or_else(|p| p.into_inner())
1757    }
1758
1759    struct ScopedEnvVar {
1760        name: &'static str,
1761        previous: Option<OsString>,
1762    }
1763
1764    impl ScopedEnvVar {
1765        fn set(name: &'static str, value: &str) -> Self {
1766            let previous = std::env::var_os(name);
1767            // Safety: tests using this helper serialize with env_lock() and
1768            // restore the original value in Drop.
1769            unsafe { std::env::set_var(name, value) };
1770            Self { name, previous }
1771        }
1772    }
1773
1774    impl Drop for ScopedEnvVar {
1775        fn drop(&mut self) {
1776            // Safety: tests using this helper serialize with env_lock().
1777            unsafe {
1778                if let Some(previous) = self.previous.take() {
1779                    std::env::set_var(self.name, previous);
1780                } else {
1781                    std::env::remove_var(self.name);
1782                }
1783            }
1784        }
1785    }
1786
1787    #[test]
1788    fn clap_command_definition_is_consistent() {
1789        Cli::command().debug_assert();
1790    }
1791
1792    // Regression for #767: `run_cli` prints the full anyhow chain so users
1793    // see the underlying TOML parser error (line/column, expected token)
1794    // instead of just the top-level "failed to parse config at <path>"
1795    // wrapper. anyhow's bare `Display` impl drops the chain — pin both
1796    // pieces here so a future refactor of the printing path doesn't
1797    // silently regress.
1798    #[test]
1799    fn anyhow_chain_surfaces_toml_parse_cause() {
1800        use anyhow::Context;
1801        let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
1802        let err = Err::<(), _>(inner)
1803            .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
1804            .unwrap_err();
1805
1806        // What `eprintln!("error: {err}")` prints (top context only).
1807        assert_eq!(
1808            err.to_string(),
1809            "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
1810        );
1811
1812        // What the `for cause in err.chain().skip(1)` loop iterates over.
1813        let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
1814        assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
1815    }
1816
1817    #[test]
1818    fn parses_config_command_matrix() {
1819        let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
1820        assert!(matches!(
1821            cli.command,
1822            Some(Commands::Config(ConfigArgs {
1823                command: ConfigCommand::Get { ref key }
1824            })) if key == "provider"
1825        ));
1826
1827        let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
1828        assert!(matches!(
1829            cli.command,
1830            Some(Commands::Config(ConfigArgs {
1831                command: ConfigCommand::Set { ref key, ref value }
1832            })) if key == "model" && value == "deepseek-v4-flash"
1833        ));
1834
1835        let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
1836        assert!(matches!(
1837            cli.command,
1838            Some(Commands::Config(ConfigArgs {
1839                command: ConfigCommand::Unset { ref key }
1840            })) if key == "model"
1841        ));
1842
1843        assert!(matches!(
1844            parse_ok(&["deepseek", "config", "list"]).command,
1845            Some(Commands::Config(ConfigArgs {
1846                command: ConfigCommand::List
1847            }))
1848        ));
1849        assert!(matches!(
1850            parse_ok(&["deepseek", "config", "path"]).command,
1851            Some(Commands::Config(ConfigArgs {
1852                command: ConfigCommand::Path
1853            }))
1854        ));
1855    }
1856
1857    #[test]
1858    fn parses_update_beta_flag() {
1859        let cli = parse_ok(&["codewhale", "update"]);
1860        assert!(matches!(
1861            cli.command,
1862            Some(Commands::Update(UpdateArgs {
1863                beta: false,
1864                check: false,
1865                proxy: None
1866            }))
1867        ));
1868
1869        let cli = parse_ok(&["codewhale", "update", "--beta"]);
1870        assert!(matches!(
1871            cli.command,
1872            Some(Commands::Update(UpdateArgs {
1873                beta: true,
1874                check: false,
1875                proxy: None
1876            }))
1877        ));
1878
1879        let cli = parse_ok(&["codewhale", "update", "--check"]);
1880        assert!(matches!(
1881            cli.command,
1882            Some(Commands::Update(UpdateArgs {
1883                beta: false,
1884                check: true,
1885                proxy: None
1886            }))
1887        ));
1888
1889        let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
1890        let Some(Commands::Update(args)) = cli.command else {
1891            panic!("expected update command");
1892        };
1893        assert!(!args.beta);
1894        assert!(!args.check);
1895        assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
1896    }
1897
1898    #[test]
1899    fn parses_model_command_matrix() {
1900        let cli = parse_ok(&["deepseek", "model", "list"]);
1901        assert!(matches!(
1902            cli.command,
1903            Some(Commands::Model(ModelArgs {
1904                command: ModelCommand::List { provider: None }
1905            }))
1906        ));
1907
1908        let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
1909        assert!(matches!(
1910            cli.command,
1911            Some(Commands::Model(ModelArgs {
1912                command: ModelCommand::List {
1913                    provider: Some(ProviderArg::Openai)
1914                }
1915            }))
1916        ));
1917
1918        let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
1919        assert!(matches!(
1920            cli.command,
1921            Some(Commands::Model(ModelArgs {
1922                command: ModelCommand::Resolve {
1923                    model: Some(ref model),
1924                    provider: None
1925                }
1926            })) if model == "deepseek-v4-flash"
1927        ));
1928
1929        let cli = parse_ok(&[
1930            "deepseek",
1931            "model",
1932            "resolve",
1933            "--provider",
1934            "deepseek",
1935            "deepseek-v4-pro",
1936        ]);
1937        assert!(matches!(
1938            cli.command,
1939            Some(Commands::Model(ModelArgs {
1940                command: ModelCommand::Resolve {
1941                    model: Some(ref model),
1942                    provider: Some(ProviderArg::Deepseek)
1943                }
1944            })) if model == "deepseek-v4-pro"
1945        ));
1946    }
1947
1948    #[test]
1949    fn parses_thread_command_matrix() {
1950        let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
1951        assert!(matches!(
1952            cli.command,
1953            Some(Commands::Thread(ThreadArgs {
1954                command: ThreadCommand::List {
1955                    all: true,
1956                    limit: Some(50)
1957                }
1958            }))
1959        ));
1960
1961        let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
1962        assert!(matches!(
1963            cli.command,
1964            Some(Commands::Thread(ThreadArgs {
1965                command: ThreadCommand::Read { ref thread_id }
1966            })) if thread_id == "thread-1"
1967        ));
1968
1969        let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
1970        assert!(matches!(
1971            cli.command,
1972            Some(Commands::Thread(ThreadArgs {
1973                command: ThreadCommand::Resume { ref thread_id }
1974            })) if thread_id == "thread-2"
1975        ));
1976
1977        let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
1978        assert!(matches!(
1979            cli.command,
1980            Some(Commands::Thread(ThreadArgs {
1981                command: ThreadCommand::Fork { ref thread_id }
1982            })) if thread_id == "thread-3"
1983        ));
1984
1985        let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
1986        assert!(matches!(
1987            cli.command,
1988            Some(Commands::Thread(ThreadArgs {
1989                command: ThreadCommand::Archive { ref thread_id }
1990            })) if thread_id == "thread-4"
1991        ));
1992
1993        let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
1994        assert!(matches!(
1995            cli.command,
1996            Some(Commands::Thread(ThreadArgs {
1997                command: ThreadCommand::Unarchive { ref thread_id }
1998            })) if thread_id == "thread-5"
1999        ));
2000
2001        let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
2002        assert!(matches!(
2003            cli.command,
2004            Some(Commands::Thread(ThreadArgs {
2005                command: ThreadCommand::SetName {
2006                    ref thread_id,
2007                    ref name
2008                }
2009            })) if thread_id == "thread-6" && name == "My Thread"
2010        ));
2011
2012        let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
2013        assert!(matches!(
2014            cli.command,
2015            Some(Commands::Thread(ThreadArgs {
2016                command: ThreadCommand::ClearName { ref thread_id }
2017            })) if thread_id == "thread-7"
2018        ));
2019    }
2020
2021    #[test]
2022    fn parses_sandbox_app_server_and_completion_matrix() {
2023        let cli = parse_ok(&[
2024            "deepseek",
2025            "sandbox",
2026            "check",
2027            "echo hello",
2028            "--ask",
2029            "on-failure",
2030        ]);
2031        assert!(matches!(
2032            cli.command,
2033            Some(Commands::Sandbox(SandboxArgs {
2034                command: SandboxCommand::Check {
2035                    ref command,
2036                    ask: ApprovalModeArg::OnFailure
2037                }
2038            })) if command == "echo hello"
2039        ));
2040
2041        let cli = parse_ok(&[
2042            "deepseek",
2043            "app-server",
2044            "--host",
2045            "0.0.0.0",
2046            "--port",
2047            "9999",
2048        ]);
2049        assert!(matches!(
2050            cli.command,
2051            Some(Commands::AppServer(AppServerArgs {
2052                ref host,
2053                port: 9999,
2054                stdio: false,
2055                ..
2056            })) if host == "0.0.0.0"
2057        ));
2058
2059        let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
2060        assert!(matches!(
2061            cli.command,
2062            Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
2063        ));
2064
2065        let cli = parse_ok(&["deepseek", "completion", "bash"]);
2066        assert!(matches!(
2067            cli.command,
2068            Some(Commands::Completion { shell: Shell::Bash })
2069        ));
2070    }
2071
2072    #[test]
2073    fn parses_direct_tui_command_aliases() {
2074        let cli = parse_ok(&["deepseek", "doctor"]);
2075        assert!(matches!(
2076            cli.command,
2077            Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
2078        ));
2079
2080        let cli = parse_ok(&["deepseek", "models", "--json"]);
2081        assert!(matches!(
2082            cli.command,
2083            Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
2084        ));
2085
2086        let cli = parse_ok(&["deepseek", "resume", "abc123"]);
2087        assert!(matches!(
2088            cli.command,
2089            Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
2090        ));
2091
2092        let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
2093        assert!(matches!(
2094            cli.command,
2095            Some(Commands::Setup(TuiPassthroughArgs { ref args }))
2096                if args == &["--skills", "--local"]
2097        ));
2098    }
2099
2100    #[test]
2101    fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
2102        assert!(should_pick_resume_in_dispatcher(
2103            &["resume".to_string()],
2104            true
2105        ));
2106        assert!(!should_pick_resume_in_dispatcher(
2107            &["resume".to_string(), "--last".to_string()],
2108            true
2109        ));
2110        assert!(!should_pick_resume_in_dispatcher(
2111            &["resume".to_string(), "abc123".to_string()],
2112            true
2113        ));
2114        assert!(!should_pick_resume_in_dispatcher(
2115            &["resume".to_string()],
2116            false
2117        ));
2118    }
2119
2120    #[test]
2121    fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
2122        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2123        let path = std::env::temp_dir().join(format!(
2124            "deepseek-cli-login-test-{}-{nanos}.toml",
2125            std::process::id()
2126        ));
2127        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2128        let secrets = no_keyring_secrets();
2129
2130        run_login_command_with_secrets(
2131            &mut store,
2132            LoginArgs {
2133                provider: Some(ProviderArg::Deepseek),
2134                api_key: Some("sk-test".to_string()),
2135            },
2136            &secrets,
2137        )
2138        .expect("login should write config");
2139
2140        assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
2141        assert_eq!(
2142            store.config.providers.deepseek.api_key.as_deref(),
2143            Some("sk-test")
2144        );
2145        assert_eq!(
2146            store.config.default_text_model.as_deref(),
2147            Some("deepseek-v4-pro")
2148        );
2149        let saved = std::fs::read_to_string(&path).expect("config should be written");
2150        assert!(saved.contains("api_key = \"sk-test\""));
2151        assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
2152
2153        let _ = std::fs::remove_file(path);
2154    }
2155
2156    #[test]
2157    fn parses_auth_subcommand_matrix() {
2158        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
2159        assert!(matches!(
2160            cli.command,
2161            Some(Commands::Auth(AuthArgs {
2162                command: AuthCommand::Set {
2163                    provider: ProviderArg::Deepseek,
2164                    api_key: None,
2165                    api_key_stdin: false,
2166                }
2167            }))
2168        ));
2169
2170        let cli = parse_ok(&[
2171            "deepseek",
2172            "auth",
2173            "set",
2174            "--provider",
2175            "openrouter",
2176            "--api-key-stdin",
2177        ]);
2178        assert!(matches!(
2179            cli.command,
2180            Some(Commands::Auth(AuthArgs {
2181                command: AuthCommand::Set {
2182                    provider: ProviderArg::Openrouter,
2183                    api_key: None,
2184                    api_key_stdin: true,
2185                }
2186            }))
2187        ));
2188
2189        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
2190        assert!(matches!(
2191            cli.command,
2192            Some(Commands::Auth(AuthArgs {
2193                command: AuthCommand::Get {
2194                    provider: ProviderArg::Novita
2195                }
2196            }))
2197        ));
2198
2199        let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
2200        assert!(matches!(
2201            cli.command,
2202            Some(Commands::Auth(AuthArgs {
2203                command: AuthCommand::Clear {
2204                    provider: ProviderArg::NvidiaNim
2205                }
2206            }))
2207        ));
2208
2209        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
2210        assert!(matches!(
2211            cli.command,
2212            Some(Commands::Auth(AuthArgs {
2213                command: AuthCommand::Set {
2214                    provider: ProviderArg::Fireworks,
2215                    api_key: None,
2216                    api_key_stdin: false,
2217                }
2218            }))
2219        ));
2220
2221        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
2222        assert!(matches!(
2223            cli.command,
2224            Some(Commands::Auth(AuthArgs {
2225                command: AuthCommand::Set {
2226                    provider: ProviderArg::Siliconflow,
2227                    api_key: None,
2228                    api_key_stdin: false,
2229                }
2230            }))
2231        ));
2232
2233        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
2234        assert!(matches!(
2235            cli.command,
2236            Some(Commands::Auth(AuthArgs {
2237                command: AuthCommand::Set {
2238                    provider: ProviderArg::Moonshot,
2239                    api_key: None,
2240                    api_key_stdin: false,
2241                }
2242            }))
2243        ));
2244
2245        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
2246        assert!(matches!(
2247            cli.command,
2248            Some(Commands::Auth(AuthArgs {
2249                command: AuthCommand::Set {
2250                    provider: ProviderArg::WanjieArk,
2251                    api_key: None,
2252                    api_key_stdin: false,
2253                }
2254            }))
2255        ));
2256
2257        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
2258        assert!(matches!(
2259            cli.command,
2260            Some(Commands::Auth(AuthArgs {
2261                command: AuthCommand::Get {
2262                    provider: ProviderArg::Sglang
2263                }
2264            }))
2265        ));
2266
2267        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
2268        assert!(matches!(
2269            cli.command,
2270            Some(Commands::Auth(AuthArgs {
2271                command: AuthCommand::Get {
2272                    provider: ProviderArg::Vllm
2273                }
2274            }))
2275        ));
2276
2277        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
2278        assert!(matches!(
2279            cli.command,
2280            Some(Commands::Auth(AuthArgs {
2281                command: AuthCommand::Set {
2282                    provider: ProviderArg::Ollama,
2283                    api_key: None,
2284                    api_key_stdin: false,
2285                }
2286            }))
2287        ));
2288
2289        let cli = parse_ok(&["deepseek", "auth", "list"]);
2290        assert!(matches!(
2291            cli.command,
2292            Some(Commands::Auth(AuthArgs {
2293                command: AuthCommand::List
2294            }))
2295        ));
2296
2297        let cli = parse_ok(&["deepseek", "auth", "migrate"]);
2298        assert!(matches!(
2299            cli.command,
2300            Some(Commands::Auth(AuthArgs {
2301                command: AuthCommand::Migrate { dry_run: false }
2302            }))
2303        ));
2304
2305        let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
2306        assert!(matches!(
2307            cli.command,
2308            Some(Commands::Auth(AuthArgs {
2309                command: AuthCommand::Migrate { dry_run: true }
2310            }))
2311        ));
2312    }
2313
2314    #[test]
2315    fn auth_set_writes_to_shared_config_file() {
2316        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2317        use std::sync::Arc;
2318
2319        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2320        let path = std::env::temp_dir().join(format!(
2321            "deepseek-cli-auth-set-test-{}-{nanos}.toml",
2322            std::process::id()
2323        ));
2324        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2325        let inner = Arc::new(InMemoryKeyringStore::new());
2326        let secrets = Secrets::new(inner.clone());
2327
2328        run_auth_command_with_secrets(
2329            &mut store,
2330            AuthCommand::Set {
2331                provider: ProviderArg::Deepseek,
2332                api_key: Some("sk-keyring".to_string()),
2333                api_key_stdin: false,
2334            },
2335            &secrets,
2336        )
2337        .expect("set should succeed");
2338
2339        assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
2340        assert_eq!(
2341            store.config.providers.deepseek.api_key.as_deref(),
2342            Some("sk-keyring")
2343        );
2344        let saved = std::fs::read_to_string(&path).unwrap_or_default();
2345        assert!(saved.contains("api_key = \"sk-keyring\""));
2346        assert_eq!(
2347            inner.get("deepseek").unwrap().as_deref(),
2348            Some("sk-keyring")
2349        );
2350
2351        let _ = std::fs::remove_file(path);
2352    }
2353
2354    #[test]
2355    fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
2356        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2357        let path = std::env::temp_dir().join(format!(
2358            "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
2359            std::process::id()
2360        ));
2361        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2362        let secrets = no_keyring_secrets();
2363
2364        run_auth_command_with_secrets(
2365            &mut store,
2366            AuthCommand::Set {
2367                provider: ProviderArg::Ollama,
2368                api_key: None,
2369                api_key_stdin: false,
2370            },
2371            &secrets,
2372        )
2373        .expect("ollama auth set should not require a key");
2374
2375        assert_eq!(store.config.provider, ProviderKind::Ollama);
2376        assert_eq!(
2377            store.config.providers.ollama.base_url.as_deref(),
2378            Some("http://localhost:11434/v1")
2379        );
2380        assert_eq!(store.config.providers.ollama.api_key, None);
2381
2382        let _ = std::fs::remove_file(path);
2383    }
2384
2385    #[test]
2386    fn auth_clear_removes_from_config() {
2387        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2388        use std::sync::Arc;
2389
2390        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2391        let path = std::env::temp_dir().join(format!(
2392            "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
2393            std::process::id()
2394        ));
2395        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2396        store.config.api_key = Some("sk-stale".to_string());
2397        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2398        store.save().unwrap();
2399
2400        let inner = Arc::new(InMemoryKeyringStore::new());
2401        inner.set("deepseek", "sk-stale").unwrap();
2402        let secrets = Secrets::new(inner.clone());
2403
2404        run_auth_command_with_secrets(
2405            &mut store,
2406            AuthCommand::Clear {
2407                provider: ProviderArg::Deepseek,
2408            },
2409            &secrets,
2410        )
2411        .expect("clear should succeed");
2412
2413        assert!(store.config.api_key.is_none());
2414        assert!(store.config.providers.deepseek.api_key.is_none());
2415        assert_eq!(inner.get("deepseek").unwrap(), None);
2416
2417        let _ = std::fs::remove_file(path);
2418    }
2419
2420    #[test]
2421    fn auth_status_and_list_only_probe_active_provider_keyring() {
2422        use codewhale_secrets::{KeyringStore, SecretsError};
2423        use std::sync::{Arc, Mutex};
2424
2425        #[derive(Default)]
2426        struct RecordingStore {
2427            gets: Mutex<Vec<String>>,
2428        }
2429
2430        impl KeyringStore for RecordingStore {
2431            fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
2432                self.gets.lock().unwrap().push(key.to_string());
2433                Ok(None)
2434            }
2435
2436            fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
2437                Ok(())
2438            }
2439
2440            fn delete(&self, _key: &str) -> Result<(), SecretsError> {
2441                Ok(())
2442            }
2443
2444            fn backend_name(&self) -> &'static str {
2445                "recording"
2446            }
2447        }
2448
2449        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2450        let path = std::env::temp_dir().join(format!(
2451            "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
2452            std::process::id()
2453        ));
2454        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2455        store.config.provider = ProviderKind::Deepseek;
2456        let inner = Arc::new(RecordingStore::default());
2457        let secrets = Secrets::new(inner.clone());
2458
2459        run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)
2460            .expect("status should succeed");
2461        run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
2462            .expect("list should succeed");
2463
2464        assert_eq!(
2465            inner.gets.lock().unwrap().as_slice(),
2466            ["deepseek", "deepseek"]
2467        );
2468
2469        let _ = std::fs::remove_file(path);
2470    }
2471
2472    #[test]
2473    fn auth_status_reports_all_active_provider_sources_with_last4() {
2474        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2475        use std::sync::Arc;
2476
2477        let _lock = env_lock();
2478        let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
2479
2480        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2481        let path = std::env::temp_dir().join(format!(
2482            "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
2483            std::process::id()
2484        ));
2485        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2486        store.config.provider = ProviderKind::Deepseek;
2487        store.config.api_key = Some("sk-config-3333".to_string());
2488        store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
2489
2490        let inner = Arc::new(InMemoryKeyringStore::new());
2491        inner.set("deepseek", "sk-keyring-2222").unwrap();
2492        let secrets = Secrets::new(inner);
2493
2494        let output = auth_status_lines(&store, &secrets).join("\n");
2495
2496        assert!(output.contains("provider: deepseek"));
2497        assert!(output.contains("active source: config (last4: ...3333)"));
2498        assert!(output.contains("lookup order: config -> secret store -> env"));
2499        assert!(output.contains("config file: "));
2500        assert!(output.contains("set, last4: ...3333"));
2501        assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
2502        assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
2503        assert!(!output.contains("sk-config-3333"));
2504        assert!(!output.contains("sk-keyring-2222"));
2505        assert!(!output.contains("sk-env-1111"));
2506
2507        let _ = std::fs::remove_file(path);
2508    }
2509
2510    #[test]
2511    fn dispatch_keyring_recovery_self_heals_into_config_file() {
2512        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2513        use std::sync::Arc;
2514
2515        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2516        let path = std::env::temp_dir().join(format!(
2517            "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
2518            std::process::id()
2519        ));
2520        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2521        let inner = Arc::new(InMemoryKeyringStore::new());
2522        inner.set("deepseek", "ring-key").unwrap();
2523        let secrets = Secrets::new(inner);
2524
2525        let resolved = resolve_runtime_for_dispatch_with_secrets(
2526            &mut store,
2527            &CliRuntimeOverrides::default(),
2528            &secrets,
2529        );
2530
2531        assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
2532        assert_eq!(
2533            resolved.api_key_source,
2534            Some(RuntimeApiKeySource::ConfigFile)
2535        );
2536        assert_eq!(store.config.api_key.as_deref(), Some("ring-key"));
2537        assert_eq!(
2538            store.config.providers.deepseek.api_key.as_deref(),
2539            Some("ring-key")
2540        );
2541
2542        let saved = std::fs::read_to_string(&path).expect("config should be written");
2543        assert!(saved.contains("api_key = \"ring-key\""));
2544
2545        let resolved_again = resolve_runtime_for_dispatch_with_secrets(
2546            &mut store,
2547            &CliRuntimeOverrides::default(),
2548            &no_keyring_secrets(),
2549        );
2550        assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
2551        assert_eq!(
2552            resolved_again.api_key_source,
2553            Some(RuntimeApiKeySource::ConfigFile)
2554        );
2555
2556        let _ = std::fs::remove_file(path);
2557    }
2558
2559    #[test]
2560    fn logout_removes_plaintext_provider_keys() {
2561        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2562        let path = std::env::temp_dir().join(format!(
2563            "deepseek-cli-logout-test-{}-{nanos}.toml",
2564            std::process::id()
2565        ));
2566        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2567        store.config.api_key = Some("sk-stale".to_string());
2568        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2569        store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
2570        store.save().unwrap();
2571
2572        let secrets = no_keyring_secrets();
2573
2574        run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
2575
2576        assert!(store.config.api_key.is_none());
2577        assert!(store.config.providers.deepseek.api_key.is_none());
2578        assert!(store.config.providers.fireworks.api_key.is_none());
2579
2580        let _ = std::fs::remove_file(path);
2581    }
2582
2583    #[test]
2584    fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
2585        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2586        use std::sync::Arc;
2587
2588        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2589        let path = std::env::temp_dir().join(format!(
2590            "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
2591            std::process::id()
2592        ));
2593        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2594        store.config.api_key = Some("sk-deep".to_string());
2595        store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
2596        store.config.providers.openrouter.api_key = Some("or-key".to_string());
2597        store.config.providers.novita.api_key = Some("nv-key".to_string());
2598        store.save().unwrap();
2599
2600        let inner = Arc::new(InMemoryKeyringStore::new());
2601        let secrets = Secrets::new(inner.clone());
2602
2603        run_auth_command_with_secrets(
2604            &mut store,
2605            AuthCommand::Migrate { dry_run: false },
2606            &secrets,
2607        )
2608        .expect("migrate should succeed");
2609
2610        assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
2611        assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
2612        assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
2613
2614        // Config file must no longer contain the api keys.
2615        assert!(store.config.api_key.is_none());
2616        assert!(store.config.providers.deepseek.api_key.is_none());
2617        assert!(store.config.providers.openrouter.api_key.is_none());
2618        assert!(store.config.providers.novita.api_key.is_none());
2619
2620        let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
2621        assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
2622        assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
2623        assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
2624
2625        let _ = std::fs::remove_file(path);
2626    }
2627
2628    #[test]
2629    fn auth_migrate_dry_run_does_not_modify_anything() {
2630        use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2631        use std::sync::Arc;
2632
2633        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2634        let path = std::env::temp_dir().join(format!(
2635            "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
2636            std::process::id()
2637        ));
2638        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2639        store.config.providers.openrouter.api_key = Some("or-stay".to_string());
2640        store.save().unwrap();
2641
2642        let inner = Arc::new(InMemoryKeyringStore::new());
2643        let secrets = Secrets::new(inner.clone());
2644
2645        run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
2646            .expect("dry-run should succeed");
2647
2648        assert_eq!(inner.get("openrouter").unwrap(), None);
2649        assert_eq!(
2650            store.config.providers.openrouter.api_key.as_deref(),
2651            Some("or-stay")
2652        );
2653
2654        let _ = std::fs::remove_file(path);
2655    }
2656
2657    #[test]
2658    fn parses_global_override_flags() {
2659        let cli = parse_ok(&[
2660            "deepseek",
2661            "--provider",
2662            "openai",
2663            "--config",
2664            "/tmp/deepseek.toml",
2665            "--profile",
2666            "work",
2667            "--model",
2668            "deepseek-v4-pro",
2669            "--output-mode",
2670            "json",
2671            "--log-level",
2672            "debug",
2673            "--telemetry",
2674            "true",
2675            "--approval-policy",
2676            "on-request",
2677            "--sandbox-mode",
2678            "workspace-write",
2679            "--base-url",
2680            "https://openai-compatible.example/v1",
2681            "--api-key",
2682            "sk-test",
2683            "--workspace",
2684            "/tmp/workspace",
2685            "--no-alt-screen",
2686            "--no-mouse-capture",
2687            "--skip-onboarding",
2688            "model",
2689            "resolve",
2690            "deepseek-v4-pro",
2691        ]);
2692
2693        assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
2694        assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
2695        assert_eq!(cli.profile.as_deref(), Some("work"));
2696        assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
2697        assert_eq!(cli.output_mode.as_deref(), Some("json"));
2698        assert_eq!(cli.log_level.as_deref(), Some("debug"));
2699        assert_eq!(cli.telemetry, Some(true));
2700        assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
2701        assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
2702        assert_eq!(
2703            cli.base_url.as_deref(),
2704            Some("https://openai-compatible.example/v1")
2705        );
2706        assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
2707        assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
2708        assert!(cli.no_alt_screen);
2709        assert!(cli.no_mouse_capture);
2710        assert!(!cli.mouse_capture);
2711        assert!(cli.skip_onboarding);
2712    }
2713
2714    #[test]
2715    fn build_tui_command_allows_openai_and_forwards_provider_key() {
2716        let _lock = env_lock();
2717        let dir = tempfile::TempDir::new().expect("tempdir");
2718        let custom = dir
2719            .path()
2720            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2721        std::fs::write(&custom, b"").unwrap();
2722        let custom_str = custom.to_string_lossy().into_owned();
2723        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2724
2725        let cli = parse_ok(&[
2726            "deepseek",
2727            "--provider",
2728            "openai",
2729            "--workspace",
2730            "/tmp/codewhale-workspace",
2731        ]);
2732        let resolved = ResolvedRuntimeOptions {
2733            provider: ProviderKind::Openai,
2734            model: "glm-5".to_string(),
2735            api_key: Some("resolved-openai-key".to_string()),
2736            api_key_source: Some(RuntimeApiKeySource::Keyring),
2737            base_url: "https://openai-compatible.example/v4".to_string(),
2738            auth_mode: Some("api_key".to_string()),
2739            output_mode: None,
2740            log_level: None,
2741            telemetry: false,
2742            approval_policy: None,
2743            sandbox_mode: None,
2744            yolo: None,
2745            http_headers: std::collections::BTreeMap::new(),
2746        };
2747
2748        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
2749        assert_eq!(
2750            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
2751            Some("openai")
2752        );
2753        assert_eq!(
2754            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
2755            Some("resolved-openai-key")
2756        );
2757        assert_eq!(
2758            command_env(&cmd, "OPENAI_API_KEY").as_deref(),
2759            Some("resolved-openai-key")
2760        );
2761        assert_eq!(
2762            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
2763            Some("keyring")
2764        );
2765        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
2766        let args: Vec<String> = cmd
2767            .get_args()
2768            .map(|arg| arg.to_string_lossy().into_owned())
2769            .collect();
2770        assert!(
2771            args.windows(2)
2772                .any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
2773            "expected workspace forwarding in args: {args:?}"
2774        );
2775    }
2776
2777    #[test]
2778    fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
2779        let _lock = env_lock();
2780        let dir = tempfile::TempDir::new().expect("tempdir");
2781        let custom = dir
2782            .path()
2783            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2784        std::fs::write(&custom, b"").unwrap();
2785        let custom_str = custom.to_string_lossy().into_owned();
2786        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2787
2788        let cli = parse_ok(&["deepseek", "--profile", "google"]);
2789        let mut resolved_headers = std::collections::BTreeMap::new();
2790        resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
2791        let resolved = ResolvedRuntimeOptions {
2792            provider: ProviderKind::Deepseek,
2793            model: "deepseek-v4-pro".to_string(),
2794            api_key: Some("config-file-key".to_string()),
2795            api_key_source: Some(RuntimeApiKeySource::ConfigFile),
2796            base_url: "https://api.deepseek.com/beta".to_string(),
2797            auth_mode: Some("api_key".to_string()),
2798            output_mode: None,
2799            log_level: None,
2800            telemetry: false,
2801            approval_policy: None,
2802            sandbox_mode: None,
2803            yolo: None,
2804            http_headers: resolved_headers,
2805        };
2806
2807        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
2808
2809        assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
2810        assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
2811        assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
2812        assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
2813        assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
2814        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
2815        assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
2816        let args: Vec<String> = cmd
2817            .get_args()
2818            .map(|arg| arg.to_string_lossy().into_owned())
2819            .collect();
2820        assert!(
2821            args.windows(2).any(|pair| pair == ["--profile", "google"]),
2822            "expected profile forwarding in args: {args:?}"
2823        );
2824    }
2825
2826    #[test]
2827    fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
2828        let _lock = env_lock();
2829        let dir = tempfile::TempDir::new().expect("tempdir");
2830        let custom = dir
2831            .path()
2832            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2833        std::fs::write(&custom, b"").unwrap();
2834        let custom_str = custom.to_string_lossy().into_owned();
2835        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2836
2837        let cli = parse_ok(&[
2838            "codewhale",
2839            "--provider",
2840            "moonshot",
2841            "--model",
2842            "kimi-k2.6",
2843            "--workspace",
2844            "/tmp/codewhale-workspace",
2845        ]);
2846        let resolved = ResolvedRuntimeOptions {
2847            provider: ProviderKind::Moonshot,
2848            model: "kimi-k2.6".to_string(),
2849            api_key: Some("resolved-kimi-key".to_string()),
2850            api_key_source: Some(RuntimeApiKeySource::Keyring),
2851            base_url: "https://api.moonshot.ai/v1".to_string(),
2852            auth_mode: Some("api_key".to_string()),
2853            output_mode: None,
2854            log_level: None,
2855            telemetry: false,
2856            approval_policy: None,
2857            sandbox_mode: None,
2858            yolo: None,
2859            http_headers: std::collections::BTreeMap::new(),
2860        };
2861
2862        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
2863        assert_eq!(
2864            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
2865            Some("moonshot")
2866        );
2867        assert_eq!(
2868            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
2869            Some("kimi-k2.6")
2870        );
2871        assert_eq!(
2872            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
2873            Some("resolved-kimi-key")
2874        );
2875        assert_eq!(
2876            command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
2877            Some("resolved-kimi-key")
2878        );
2879        assert_eq!(
2880            command_env(&cmd, "KIMI_API_KEY").as_deref(),
2881            Some("resolved-kimi-key")
2882        );
2883        assert_eq!(
2884            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
2885            Some("keyring")
2886        );
2887        assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
2888    }
2889
2890    #[test]
2891    fn build_tui_command_exports_explicit_provider_model_and_base_url() {
2892        let _lock = env_lock();
2893        let dir = tempfile::TempDir::new().expect("tempdir");
2894        let custom = dir
2895            .path()
2896            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2897        std::fs::write(&custom, b"").unwrap();
2898        let custom_str = custom.to_string_lossy().into_owned();
2899        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2900
2901        let cli = parse_ok(&[
2902            "deepseek",
2903            "--profile",
2904            "google",
2905            "--provider",
2906            "openai",
2907            "--model",
2908            "glm-5",
2909            "--base-url",
2910            "https://openai-compatible.example/v4",
2911        ]);
2912        let resolved = ResolvedRuntimeOptions {
2913            provider: ProviderKind::Openai,
2914            model: "glm-5".to_string(),
2915            api_key: None,
2916            api_key_source: None,
2917            base_url: "https://openai-compatible.example/v4".to_string(),
2918            auth_mode: None,
2919            output_mode: None,
2920            log_level: None,
2921            telemetry: false,
2922            approval_policy: None,
2923            sandbox_mode: None,
2924            yolo: None,
2925            http_headers: std::collections::BTreeMap::new(),
2926        };
2927
2928        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
2929
2930        assert_eq!(
2931            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
2932            Some("openai")
2933        );
2934        assert_eq!(
2935            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
2936            Some("glm-5")
2937        );
2938        assert_eq!(
2939            command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
2940            Some("https://openai-compatible.example/v4")
2941        );
2942    }
2943
2944    #[test]
2945    fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
2946        let _lock = env_lock();
2947        let dir = tempfile::TempDir::new().expect("tempdir");
2948        let custom = dir
2949            .path()
2950            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2951        std::fs::write(&custom, b"").unwrap();
2952        let custom_str = custom.to_string_lossy().into_owned();
2953        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2954
2955        // (provider, cli flag, extra env vars that must be forwarded besides DEEPSEEK_API_KEY)
2956        let cases: &[(ProviderKind, &str, &[&str])] = &[
2957            (
2958                ProviderKind::Openrouter,
2959                "openrouter",
2960                &["OPENROUTER_API_KEY"],
2961            ),
2962            (
2963                ProviderKind::XiaomiMimo,
2964                "xiaomi-mimo",
2965                &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"],
2966            ),
2967            (ProviderKind::Novita, "novita", &["NOVITA_API_KEY"]),
2968            (
2969                ProviderKind::NvidiaNim,
2970                "nvidia-nim",
2971                &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"],
2972            ),
2973            (ProviderKind::Fireworks, "fireworks", &["FIREWORKS_API_KEY"]),
2974            (
2975                ProviderKind::Siliconflow,
2976                "siliconflow",
2977                &["SILICONFLOW_API_KEY"],
2978            ),
2979            (ProviderKind::Sglang, "sglang", &["SGLANG_API_KEY"]),
2980            (ProviderKind::Vllm, "vllm", &["VLLM_API_KEY"]),
2981            (ProviderKind::Ollama, "ollama", &["OLLAMA_API_KEY"]),
2982            (
2983                ProviderKind::Atlascloud,
2984                "atlascloud",
2985                &["ATLASCLOUD_API_KEY"],
2986            ),
2987            (
2988                ProviderKind::WanjieArk,
2989                "wanjie-ark",
2990                &[
2991                    "WANJIE_ARK_API_KEY",
2992                    "WANJIE_API_KEY",
2993                    "WANJIE_MAAS_API_KEY",
2994                ],
2995            ),
2996        ];
2997
2998        for &(provider, flag, expected_vars) in cases {
2999            let cli = parse_ok(&[
3000                "codewhale",
3001                "--provider",
3002                flag,
3003                "--workspace",
3004                "/tmp/codewhale-workspace",
3005            ]);
3006            let resolved = ResolvedRuntimeOptions {
3007                provider,
3008                model: "test-model".to_string(),
3009                api_key: Some("test-key".to_string()),
3010                api_key_source: Some(RuntimeApiKeySource::Keyring),
3011                base_url: "http://localhost:8000/v1".to_string(),
3012                auth_mode: Some("api_key".to_string()),
3013                output_mode: None,
3014                log_level: None,
3015                telemetry: false,
3016                approval_policy: None,
3017                sandbox_mode: None,
3018                yolo: None,
3019                http_headers: std::collections::BTreeMap::new(),
3020            };
3021
3022            let cmd = build_tui_command(&cli, &resolved, Vec::new())
3023                .unwrap_or_else(|e| panic!("{flag}: {e}"));
3024
3025            assert_eq!(
3026                command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3027                Some("test-key"),
3028                "{flag}: DEEPSEEK_API_KEY not forwarded"
3029            );
3030            for var in expected_vars {
3031                assert_eq!(
3032                    command_env(&cmd, var).as_deref(),
3033                    Some("test-key"),
3034                    "{flag}: {var} not forwarded"
3035                );
3036            }
3037            assert_eq!(
3038                command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
3039                Some("keyring"),
3040                "{flag}: expected keyring source bridge"
3041            );
3042            assert_eq!(
3043                command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
3044                None,
3045                "{flag}: auth mode should come from config/profile, not env handoff"
3046            );
3047        }
3048    }
3049
3050    #[test]
3051    fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
3052        let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
3053
3054        assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
3055        assert!(cli.prompt.is_empty());
3056        assert_eq!(
3057            root_tui_passthrough(&cli).unwrap(),
3058            vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
3059        );
3060    }
3061
3062    #[test]
3063    fn parses_top_level_continue_for_interactive_resume() {
3064        let cli = parse_ok(&["codewhale", "--continue"]);
3065
3066        assert!(cli.continue_session);
3067        assert!(cli.prompt_flag.is_none());
3068        assert!(cli.prompt.is_empty());
3069        assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
3070    }
3071
3072    #[test]
3073    fn top_level_continue_rejects_startup_prompt() {
3074        let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
3075
3076        let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
3077        assert!(
3078            err.to_string()
3079                .contains("codewhale exec --continue <PROMPT>")
3080        );
3081    }
3082
3083    #[test]
3084    fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
3085        let cli = parse_ok(&["deepseek", "hello", "world"]);
3086
3087        assert_eq!(cli.prompt, vec!["hello", "world"]);
3088        assert!(cli.command.is_none());
3089        assert_eq!(
3090            root_tui_passthrough(&cli).unwrap(),
3091            vec!["--prompt".to_string(), "hello world".to_string()]
3092        );
3093    }
3094
3095    #[test]
3096    fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
3097        let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
3098
3099        assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
3100        assert_eq!(cli.prompt, vec!["world"]);
3101        assert_eq!(
3102            root_tui_passthrough(&cli).unwrap(),
3103            vec!["--prompt".to_string(), "hello world".to_string()]
3104        );
3105    }
3106
3107    #[test]
3108    fn known_subcommands_still_parse_before_prompt_tail() {
3109        let cli = parse_ok(&["deepseek", "doctor"]);
3110
3111        assert!(cli.prompt.is_empty());
3112        assert!(matches!(cli.command, Some(Commands::Doctor(_))));
3113    }
3114
3115    #[test]
3116    fn root_help_surface_contains_expected_subcommands_and_globals() {
3117        let rendered = help_for(&["deepseek", "--help"]);
3118
3119        for token in [
3120            "run",
3121            "doctor",
3122            "models",
3123            "sessions",
3124            "resume",
3125            "setup",
3126            "login",
3127            "logout",
3128            "auth",
3129            "mcp-server",
3130            "config",
3131            "model",
3132            "thread",
3133            "sandbox",
3134            "app-server",
3135            "completion",
3136            "metrics",
3137            "--provider",
3138            "--model",
3139            "--config",
3140            "--profile",
3141            "--output-mode",
3142            "--log-level",
3143            "--telemetry",
3144            "--base-url",
3145            "--api-key",
3146            "--approval-policy",
3147            "--sandbox-mode",
3148            "--mouse-capture",
3149            "--no-mouse-capture",
3150            "--skip-onboarding",
3151            "--continue",
3152            "--prompt",
3153        ] {
3154            assert!(
3155                rendered.contains(token),
3156                "expected help to contain token: {token}"
3157            );
3158        }
3159    }
3160
3161    #[test]
3162    fn subcommand_help_surfaces_are_stable() {
3163        let cases = [
3164            ("config", vec!["get", "set", "unset", "list", "path"]),
3165            ("model", vec!["list", "resolve"]),
3166            (
3167                "thread",
3168                vec![
3169                    "list",
3170                    "read",
3171                    "resume",
3172                    "fork",
3173                    "archive",
3174                    "unarchive",
3175                    "set-name",
3176                    "clear-name",
3177                ],
3178            ),
3179            ("sandbox", vec!["check"]),
3180            (
3181                "exec",
3182                vec![
3183                    "--auto",
3184                    "--json",
3185                    "--resume",
3186                    "--session-id",
3187                    "--continue",
3188                    "--output-format",
3189                    "stream-json",
3190                ],
3191            ),
3192            (
3193                "app-server",
3194                vec!["--host", "--port", "--config", "--stdio"],
3195            ),
3196            (
3197                "completion",
3198                vec![
3199                    "<SHELL>",
3200                    "bash",
3201                    "source <(codewhale completion bash)",
3202                    "~/.local/share/bash-completion/completions/codewhale",
3203                    "fpath=(~/.zfunc $fpath)",
3204                    "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
3205                    "codewhale completion powershell | Out-String | Invoke-Expression",
3206                ],
3207            ),
3208            ("metrics", vec!["--json", "--since"]),
3209        ];
3210
3211        for (subcommand, expected_tokens) in cases {
3212            let argv = ["deepseek", subcommand, "--help"];
3213            let rendered = help_for(&argv);
3214            for token in expected_tokens {
3215                assert!(
3216                    rendered.contains(token),
3217                    "expected help for `{subcommand}` to include `{token}`"
3218                );
3219            }
3220        }
3221    }
3222
3223    /// Regression for issue #247: on Windows the dispatcher must find the
3224    /// sibling `codewhale-tui.exe`, not bail out looking for an
3225    /// extension-less `codewhale-tui`. The candidate resolver also accepts
3226    /// the suffix-less name on Windows so users who manually renamed the
3227    /// file as a workaround keep working after the upgrade.
3228    #[test]
3229    fn sibling_tui_candidate_picks_platform_correct_name() {
3230        let dir = tempfile::TempDir::new().expect("tempdir");
3231        let dispatcher = dir
3232            .path()
3233            .join("codewhale")
3234            .with_extension(std::env::consts::EXE_EXTENSION);
3235        // Touch the dispatcher so its parent dir is the lookup root.
3236        std::fs::write(&dispatcher, b"").unwrap();
3237
3238        // No sibling yet — resolver returns None.
3239        assert!(sibling_tui_candidate(&dispatcher).is_none());
3240
3241        let target =
3242            dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
3243        std::fs::write(&target, b"").unwrap();
3244
3245        let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
3246        assert_eq!(found, target, "primary platform-correct name wins");
3247    }
3248
3249    #[test]
3250    fn dispatcher_spawn_error_names_path_and_recovery_checks() {
3251        let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
3252        let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
3253
3254        assert!(message.contains("C:/tools/codewhale-tui.exe"));
3255        assert!(message.contains("access is denied"));
3256        assert!(message.contains("where codewhale"));
3257        assert!(message.contains("DEEPSEEK_TUI_BIN"));
3258    }
3259
3260    /// Windows-only fallback: the user from #247 manually renamed the
3261    /// file to drop `.exe`. After the fix lands, that workaround must
3262    /// still resolve via the suffix-less fallback so they don't have to
3263    /// rename it back.
3264    #[cfg(windows)]
3265    #[test]
3266    fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
3267        let dir = tempfile::TempDir::new().expect("tempdir");
3268        let dispatcher = dir.path().join("codewhale.exe");
3269        std::fs::write(&dispatcher, b"").unwrap();
3270
3271        // Only the suffixless name exists — emulates the manual rename.
3272        let suffixless = dispatcher.with_file_name("codewhale-tui");
3273        std::fs::write(&suffixless, b"").unwrap();
3274
3275        let found = sibling_tui_candidate(&dispatcher)
3276            .expect("Windows fallback must locate suffixless codewhale-tui");
3277        assert_eq!(found, suffixless);
3278    }
3279
3280    /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for
3281    /// custom Windows install layouts and CI test rigs.
3282    #[test]
3283    fn locate_sibling_tui_binary_honours_env_override() {
3284        let _lock = env_lock();
3285        let dir = tempfile::TempDir::new().expect("tempdir");
3286        let custom = dir
3287            .path()
3288            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3289        std::fs::write(&custom, b"").unwrap();
3290        let custom_str = custom.to_string_lossy().into_owned();
3291        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3292
3293        let resolved = locate_sibling_tui_binary().expect("override must resolve");
3294        assert_eq!(resolved, custom);
3295    }
3296}