Skip to main content

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