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