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