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 secret-store 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 secret store 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 secret store 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        "secret store"
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 -> secret store -> env".to_string(),
836        format!(
837            "config file: {} ({})",
838            store.path().display(),
839            source_status(config_key, "missing")
840        ),
841        format!(
842            "secret store: {} ({})",
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("secret-store")
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 secret store");
951            Ok(())
952        }
953        AuthCommand::List => {
954            println!("provider     config store 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                    "store"
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 the configured secret store.
1016/// 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!(
1046                "skipped {slot}: failed to write to secret store: {err}"
1047            ));
1048            continue;
1049        }
1050        if !dry_run {
1051            store.config.providers.for_provider_mut(provider).api_key = None;
1052            if provider == ProviderKind::Deepseek {
1053                store.config.api_key = None;
1054            }
1055        }
1056        migrated.push((provider, slot));
1057    }
1058
1059    if !dry_run && !migrated.is_empty() {
1060        store
1061            .save()
1062            .context("failed to write updated config.toml")?;
1063    }
1064
1065    println!("secret store backend: {}", secrets.backend_name());
1066    if migrated.is_empty() {
1067        println!("nothing to migrate (config.toml has no plaintext api_key entries)");
1068    } else {
1069        println!(
1070            "{} {} provider key(s):",
1071            if dry_run { "would migrate" } else { "migrated" },
1072            migrated.len()
1073        );
1074        for (_, slot) in &migrated {
1075            println!("  - {slot}");
1076        }
1077        if !dry_run {
1078            println!(
1079                "config.toml at {} no longer contains api_key entries for migrated providers.",
1080                store.path().display()
1081            );
1082        }
1083    }
1084    for w in warnings {
1085        eprintln!("warning: {w}");
1086    }
1087    Ok(())
1088}
1089
1090fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
1091    match command {
1092        ConfigCommand::Get { key } => {
1093            if let Some(value) = store.config.get_display_value(&key) {
1094                println!("{value}");
1095                return Ok(());
1096            }
1097            bail!("key not found: {key}");
1098        }
1099        ConfigCommand::Set { key, value } => {
1100            store.config.set_value(&key, &value)?;
1101            store.save()?;
1102            println!("set {key}");
1103            Ok(())
1104        }
1105        ConfigCommand::Unset { key } => {
1106            store.config.unset_value(&key)?;
1107            store.save()?;
1108            println!("unset {key}");
1109            Ok(())
1110        }
1111        ConfigCommand::List => {
1112            for (key, value) in store.config.list_values() {
1113                println!("{key} = {value}");
1114            }
1115            Ok(())
1116        }
1117        ConfigCommand::Path => {
1118            println!("{}", store.path().display());
1119            Ok(())
1120        }
1121    }
1122}
1123
1124fn run_model_command(command: ModelCommand) -> Result<()> {
1125    let registry = ModelRegistry::default();
1126    match command {
1127        ModelCommand::List { provider } => {
1128            let filter = provider.map(ProviderKind::from);
1129            for model in registry.list().into_iter().filter(|m| match filter {
1130                Some(p) => m.provider == p,
1131                None => true,
1132            }) {
1133                println!("{} ({})", model.id, model.provider.as_str());
1134            }
1135            Ok(())
1136        }
1137        ModelCommand::Resolve { model, provider } => {
1138            let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));
1139            println!("requested: {}", resolved.requested.unwrap_or_default());
1140            println!("resolved: {}", resolved.resolved.id);
1141            println!("provider: {}", resolved.resolved.provider.as_str());
1142            println!("used_fallback: {}", resolved.used_fallback);
1143            Ok(())
1144        }
1145    }
1146}
1147
1148fn run_thread_command(command: ThreadCommand) -> Result<()> {
1149    let state = StateStore::open(None)?;
1150    match command {
1151        ThreadCommand::List { all, limit } => {
1152            let threads = state.list_threads(ThreadListFilters {
1153                include_archived: all,
1154                limit,
1155            })?;
1156            for thread in threads {
1157                println!(
1158                    "{} | {} | {} | {}",
1159                    thread.id,
1160                    thread
1161                        .name
1162                        .clone()
1163                        .unwrap_or_else(|| "(unnamed)".to_string()),
1164                    thread.model_provider,
1165                    thread.cwd.display()
1166                );
1167            }
1168            Ok(())
1169        }
1170        ThreadCommand::Read { thread_id } => {
1171            let thread = state.get_thread(&thread_id)?;
1172            println!("{}", serde_json::to_string_pretty(&thread)?);
1173            Ok(())
1174        }
1175        ThreadCommand::Resume { thread_id } => {
1176            let args = vec!["resume".to_string(), thread_id];
1177            delegate_simple_tui(args)
1178        }
1179        ThreadCommand::Fork { thread_id } => {
1180            let args = vec!["fork".to_string(), thread_id];
1181            delegate_simple_tui(args)
1182        }
1183        ThreadCommand::Archive { thread_id } => {
1184            state.mark_archived(&thread_id)?;
1185            println!("archived {thread_id}");
1186            Ok(())
1187        }
1188        ThreadCommand::Unarchive { thread_id } => {
1189            state.mark_unarchived(&thread_id)?;
1190            println!("unarchived {thread_id}");
1191            Ok(())
1192        }
1193        ThreadCommand::SetName { thread_id, name } => {
1194            let mut thread = state
1195                .get_thread(&thread_id)?
1196                .with_context(|| format!("thread not found: {thread_id}"))?;
1197            thread.name = Some(name);
1198            thread.updated_at = chrono::Utc::now().timestamp();
1199            state.upsert_thread(&thread)?;
1200            println!("renamed {thread_id}");
1201            Ok(())
1202        }
1203    }
1204}
1205
1206fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
1207    match command {
1208        SandboxCommand::Check { command, ask } => {
1209            let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
1210            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1211            let decision = engine.check(ExecPolicyContext {
1212                command: &command,
1213                cwd: &cwd.display().to_string(),
1214                ask_for_approval: ask.into(),
1215                sandbox_mode: Some("workspace-write"),
1216            })?;
1217            println!("{}", serde_json::to_string_pretty(&decision)?);
1218            Ok(())
1219        }
1220    }
1221}
1222
1223fn run_app_server_command(args: AppServerArgs) -> Result<()> {
1224    let runtime = tokio::runtime::Builder::new_multi_thread()
1225        .enable_all()
1226        .build()
1227        .context("failed to create tokio runtime")?;
1228    if args.stdio {
1229        return runtime.block_on(run_app_server_stdio(args.config));
1230    }
1231    let listen: SocketAddr = format!("{}:{}", args.host, args.port)
1232        .parse()
1233        .with_context(|| {
1234            format!(
1235                "invalid app-server listen address {}:{}",
1236                args.host, args.port
1237            )
1238        })?;
1239    runtime.block_on(run_app_server(AppServerOptions {
1240        listen,
1241        config_path: args.config,
1242    }))
1243}
1244
1245fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
1246    let persisted = load_mcp_server_definitions(store);
1247    let updated = run_stdio_server(persisted)?;
1248    persist_mcp_server_definitions(store, &updated)
1249}
1250
1251fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
1252    let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
1253        return Vec::new();
1254    };
1255
1256    match parse_mcp_server_definitions(&raw) {
1257        Ok(definitions) => definitions,
1258        Err(err) => {
1259            eprintln!(
1260                "warning: failed to parse persisted MCP server definitions ({}): {}",
1261                MCP_SERVER_DEFINITIONS_KEY, err
1262            );
1263            Vec::new()
1264        }
1265    }
1266}
1267
1268fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
1269    if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
1270        return Ok(parsed);
1271    }
1272
1273    let unwrapped: String = serde_json::from_str(raw)
1274        .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
1275    serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
1276        format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
1277    })
1278}
1279
1280fn persist_mcp_server_definitions(
1281    store: &mut ConfigStore,
1282    definitions: &[McpServerDefinition],
1283) -> Result<()> {
1284    let encoded =
1285        serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
1286    store
1287        .config
1288        .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
1289    store.save()
1290}
1291
1292fn delegate_to_tui(
1293    cli: &Cli,
1294    resolved_runtime: &ResolvedRuntimeOptions,
1295    passthrough: Vec<String>,
1296) -> Result<()> {
1297    let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
1298    let tui = PathBuf::from(cmd.get_program());
1299    let status = cmd
1300        .status()
1301        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1302    exit_with_tui_status(status)
1303}
1304
1305fn run_resume_command(
1306    cli: &Cli,
1307    resolved_runtime: &ResolvedRuntimeOptions,
1308    args: TuiPassthroughArgs,
1309) -> Result<()> {
1310    let passthrough = tui_args("resume", args);
1311    if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
1312        return run_dispatcher_resume_picker(cli, resolved_runtime);
1313    }
1314    delegate_to_tui(cli, resolved_runtime, passthrough)
1315}
1316
1317fn run_dispatcher_resume_picker(
1318    cli: &Cli,
1319    resolved_runtime: &ResolvedRuntimeOptions,
1320) -> Result<()> {
1321    let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
1322    let tui = PathBuf::from(sessions_cmd.get_program());
1323    let status = sessions_cmd
1324        .status()
1325        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1326    if !status.success() {
1327        return exit_with_tui_status(status);
1328    }
1329
1330    println!();
1331    println!("Windows note: enter a session id or prefix from the list above.");
1332    println!("You can also run `deepseek resume --last` to skip this prompt.");
1333    print!("Session id/prefix (Enter to cancel): ");
1334    io::stdout().flush()?;
1335
1336    let mut input = String::new();
1337    io::stdin()
1338        .read_line(&mut input)
1339        .context("failed to read session selection")?;
1340    let session_id = input.trim();
1341    if session_id.is_empty() {
1342        bail!("No session selected.");
1343    }
1344
1345    delegate_to_tui(
1346        cli,
1347        resolved_runtime,
1348        vec!["resume".to_string(), session_id.to_string()],
1349    )
1350}
1351
1352fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
1353    is_windows && passthrough == ["resume"]
1354}
1355
1356fn build_tui_command(
1357    cli: &Cli,
1358    resolved_runtime: &ResolvedRuntimeOptions,
1359    passthrough: Vec<String>,
1360) -> Result<Command> {
1361    let tui = locate_sibling_tui_binary()?;
1362
1363    let mut cmd = Command::new(&tui);
1364    if let Some(config) = cli.config.as_ref() {
1365        cmd.arg("--config").arg(config);
1366    }
1367    if let Some(profile) = cli.profile.as_ref() {
1368        cmd.arg("--profile").arg(profile);
1369    }
1370    // Accepted for older scripts, but no longer forwarded: the interactive TUI
1371    // always owns the alternate screen to avoid host scrollback hijacking.
1372    let _ = cli.no_alt_screen;
1373    if cli.mouse_capture {
1374        cmd.arg("--mouse-capture");
1375    }
1376    if cli.no_mouse_capture {
1377        cmd.arg("--no-mouse-capture");
1378    }
1379    if cli.skip_onboarding {
1380        cmd.arg("--skip-onboarding");
1381    }
1382    cmd.args(passthrough);
1383
1384    if !matches!(
1385        resolved_runtime.provider,
1386        ProviderKind::Deepseek
1387            | ProviderKind::NvidiaNim
1388            | ProviderKind::Openai
1389            | ProviderKind::Openrouter
1390            | ProviderKind::Novita
1391            | ProviderKind::Fireworks
1392            | ProviderKind::Sglang
1393            | ProviderKind::Vllm
1394            | ProviderKind::Ollama
1395    ) {
1396        bail!(
1397            "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.",
1398            resolved_runtime.provider.as_str()
1399        );
1400    }
1401
1402    cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model);
1403    cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url);
1404    cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str());
1405    if !resolved_runtime.http_headers.is_empty() {
1406        let encoded = resolved_runtime
1407            .http_headers
1408            .iter()
1409            .map(|(name, value)| format!("{}={}", name.trim(), value.trim()))
1410            .collect::<Vec<_>>()
1411            .join(",");
1412        cmd.env("DEEPSEEK_HTTP_HEADERS", encoded);
1413    }
1414    if let Some(api_key) = resolved_runtime.api_key.as_ref() {
1415        cmd.env("DEEPSEEK_API_KEY", api_key);
1416        if resolved_runtime.provider == ProviderKind::Openai {
1417            cmd.env("OPENAI_API_KEY", api_key);
1418        }
1419        let source = resolved_runtime
1420            .api_key_source
1421            .unwrap_or(RuntimeApiKeySource::Env)
1422            .as_env_value();
1423        cmd.env("DEEPSEEK_API_KEY_SOURCE", source);
1424    }
1425
1426    if let Some(model) = cli.model.as_ref() {
1427        cmd.env("DEEPSEEK_MODEL", model);
1428    }
1429    if let Some(output_mode) = cli.output_mode.as_ref() {
1430        cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
1431    }
1432    if let Some(log_level) = cli.log_level.as_ref() {
1433        cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
1434    }
1435    if let Some(telemetry) = cli.telemetry {
1436        cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
1437    }
1438    if let Some(policy) = cli.approval_policy.as_ref() {
1439        cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
1440    }
1441    if let Some(mode) = cli.sandbox_mode.as_ref() {
1442        cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
1443    }
1444    if let Some(api_key) = cli.api_key.as_ref() {
1445        cmd.env("DEEPSEEK_API_KEY", api_key);
1446        if resolved_runtime.provider == ProviderKind::Openai {
1447            cmd.env("OPENAI_API_KEY", api_key);
1448        }
1449        cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
1450    }
1451    if let Some(base_url) = cli.base_url.as_ref() {
1452        cmd.env("DEEPSEEK_BASE_URL", base_url);
1453    }
1454
1455    Ok(cmd)
1456}
1457
1458fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
1459    match status.code() {
1460        Some(code) => std::process::exit(code),
1461        None => bail!("deepseek-tui terminated by signal"),
1462    }
1463}
1464
1465fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
1466    let tui = locate_sibling_tui_binary()?;
1467    let status = Command::new(&tui)
1468        .args(args)
1469        .status()
1470        .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1471    match status.code() {
1472        Some(code) => std::process::exit(code),
1473        None => bail!("deepseek-tui terminated by signal"),
1474    }
1475}
1476
1477fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
1478    format!(
1479        "failed to spawn companion TUI binary at {}: {err}\n\
1480\n\
1481The `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \
1482to execute it. Common fixes:\n\
1483  - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\n\
1484  - On Windows, run `where deepseek` and `where deepseek-tui`; both should \
1485come from the same install directory.\n\
1486  - If you downloaded release assets manually, keep both `deepseek` and \
1487`deepseek-tui` binaries together and make sure the TUI binary is executable.\n\
1488  - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \
1489binary.",
1490        tui.display()
1491    )
1492}
1493
1494/// Resolve the sibling `deepseek-tui` executable next to the running
1495/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so
1496/// the npm-distributed Windows package — which ships
1497/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).
1498///
1499/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for
1500/// custom installs and CI test layouts. On Windows we additionally try
1501/// the suffix-less name as a fallback for users who already manually
1502/// renamed the file before this fix landed.
1503fn locate_sibling_tui_binary() -> Result<PathBuf> {
1504    if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
1505        let candidate = PathBuf::from(override_path);
1506        if candidate.is_file() {
1507            return Ok(candidate);
1508        }
1509        bail!(
1510            "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
1511            candidate.display()
1512        );
1513    }
1514
1515    let current = std::env::current_exe().context("failed to locate current executable path")?;
1516    if let Some(found) = sibling_tui_candidate(&current) {
1517        return Ok(found);
1518    }
1519
1520    // Build a stable error path so the user sees the platform-correct
1521    // expected name, not "deepseek-tui" on Windows.
1522    let expected = current.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1523    bail!(
1524        "Companion `deepseek-tui` binary not found at {}.\n\
1525\n\
1526The `deepseek` dispatcher delegates interactive sessions to a sibling \
1527`deepseek-tui` binary. To fix this, install one of:\n\
1528  • npm:    npm install -g deepseek-tui            (downloads both binaries)\n\
1529  • cargo:  cargo install deepseek-tui-cli deepseek-tui --locked\n\
1530  • GitHub Releases: download BOTH `deepseek-<platform>` AND \
1531`deepseek-tui-<platform>` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \
1532and place them in the same directory.\n\
1533\n\
1534Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.",
1535        expected.display()
1536    );
1537}
1538
1539/// Return the first existing sibling-binary path under any of the names
1540/// `deepseek-tui` might use on this platform. Pure function to keep
1541/// `locate_sibling_tui_binary` testable.
1542fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
1543    // Primary: platform-correct name. EXE_SUFFIX is "" on Unix and ".exe"
1544    // on Windows.
1545    let primary =
1546        dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1547    if primary.is_file() {
1548        return Some(primary);
1549    }
1550    // Windows fallback: a user who manually renamed `.exe` away (per the
1551    // workaround in #247) still launches successfully under the new code.
1552    if cfg!(windows) {
1553        let suffixless = dispatcher.with_file_name("deepseek-tui");
1554        if suffixless.is_file() {
1555            return Some(suffixless);
1556        }
1557    }
1558    None
1559}
1560
1561fn run_metrics_command(args: MetricsArgs) -> Result<()> {
1562    let since = match args.since.as_deref() {
1563        Some(s) => {
1564            Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
1565        }
1566        None => None,
1567    };
1568    metrics::run(metrics::MetricsArgs {
1569        json: args.json,
1570        since,
1571    })
1572}
1573
1574fn read_api_key_from_stdin() -> Result<String> {
1575    let mut input = String::new();
1576    io::stdin()
1577        .read_to_string(&mut input)
1578        .context("failed to read api key from stdin")?;
1579    let key = input.trim().to_string();
1580    if key.is_empty() {
1581        bail!("empty API key provided");
1582    }
1583    Ok(key)
1584}
1585
1586#[cfg(test)]
1587mod tests {
1588    use super::*;
1589    use clap::error::ErrorKind;
1590    use std::ffi::OsString;
1591    use std::sync::{Mutex, OnceLock};
1592
1593    fn parse_ok(argv: &[&str]) -> Cli {
1594        Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
1595    }
1596
1597    fn help_for(argv: &[&str]) -> String {
1598        let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
1599        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
1600        err.to_string()
1601    }
1602
1603    fn command_env(cmd: &Command, name: &str) -> Option<String> {
1604        let name = std::ffi::OsStr::new(name);
1605        cmd.get_envs().find_map(|(key, value)| {
1606            if key == name {
1607                value.map(|v| v.to_string_lossy().into_owned())
1608            } else {
1609                None
1610            }
1611        })
1612    }
1613
1614    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1615        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1616        LOCK.get_or_init(|| Mutex::new(()))
1617            .lock()
1618            .unwrap_or_else(|p| p.into_inner())
1619    }
1620
1621    struct ScopedEnvVar {
1622        name: &'static str,
1623        previous: Option<OsString>,
1624    }
1625
1626    impl ScopedEnvVar {
1627        fn set(name: &'static str, value: &str) -> Self {
1628            let previous = std::env::var_os(name);
1629            // Safety: tests using this helper serialize with env_lock() and
1630            // restore the original value in Drop.
1631            unsafe { std::env::set_var(name, value) };
1632            Self { name, previous }
1633        }
1634    }
1635
1636    impl Drop for ScopedEnvVar {
1637        fn drop(&mut self) {
1638            // Safety: tests using this helper serialize with env_lock().
1639            unsafe {
1640                if let Some(previous) = self.previous.take() {
1641                    std::env::set_var(self.name, previous);
1642                } else {
1643                    std::env::remove_var(self.name);
1644                }
1645            }
1646        }
1647    }
1648
1649    #[test]
1650    fn clap_command_definition_is_consistent() {
1651        Cli::command().debug_assert();
1652    }
1653
1654    // Regression for #767: `run_cli` prints the full anyhow chain so users
1655    // see the underlying TOML parser error (line/column, expected token)
1656    // instead of just the top-level "failed to parse config at <path>"
1657    // wrapper. anyhow's bare `Display` impl drops the chain — pin both
1658    // pieces here so a future refactor of the printing path doesn't
1659    // silently regress.
1660    #[test]
1661    fn anyhow_chain_surfaces_toml_parse_cause() {
1662        use anyhow::Context;
1663        let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
1664        let err = Err::<(), _>(inner)
1665            .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
1666            .unwrap_err();
1667
1668        // What `eprintln!("error: {err}")` prints (top context only).
1669        assert_eq!(
1670            err.to_string(),
1671            "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
1672        );
1673
1674        // What the `for cause in err.chain().skip(1)` loop iterates over.
1675        let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
1676        assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
1677    }
1678
1679    #[test]
1680    fn parses_config_command_matrix() {
1681        let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
1682        assert!(matches!(
1683            cli.command,
1684            Some(Commands::Config(ConfigArgs {
1685                command: ConfigCommand::Get { ref key }
1686            })) if key == "provider"
1687        ));
1688
1689        let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
1690        assert!(matches!(
1691            cli.command,
1692            Some(Commands::Config(ConfigArgs {
1693                command: ConfigCommand::Set { ref key, ref value }
1694            })) if key == "model" && value == "deepseek-v4-flash"
1695        ));
1696
1697        let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
1698        assert!(matches!(
1699            cli.command,
1700            Some(Commands::Config(ConfigArgs {
1701                command: ConfigCommand::Unset { ref key }
1702            })) if key == "model"
1703        ));
1704
1705        assert!(matches!(
1706            parse_ok(&["deepseek", "config", "list"]).command,
1707            Some(Commands::Config(ConfigArgs {
1708                command: ConfigCommand::List
1709            }))
1710        ));
1711        assert!(matches!(
1712            parse_ok(&["deepseek", "config", "path"]).command,
1713            Some(Commands::Config(ConfigArgs {
1714                command: ConfigCommand::Path
1715            }))
1716        ));
1717    }
1718
1719    #[test]
1720    fn parses_model_command_matrix() {
1721        let cli = parse_ok(&["deepseek", "model", "list"]);
1722        assert!(matches!(
1723            cli.command,
1724            Some(Commands::Model(ModelArgs {
1725                command: ModelCommand::List { provider: None }
1726            }))
1727        ));
1728
1729        let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
1730        assert!(matches!(
1731            cli.command,
1732            Some(Commands::Model(ModelArgs {
1733                command: ModelCommand::List {
1734                    provider: Some(ProviderArg::Openai)
1735                }
1736            }))
1737        ));
1738
1739        let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
1740        assert!(matches!(
1741            cli.command,
1742            Some(Commands::Model(ModelArgs {
1743                command: ModelCommand::Resolve {
1744                    model: Some(ref model),
1745                    provider: None
1746                }
1747            })) if model == "deepseek-v4-flash"
1748        ));
1749
1750        let cli = parse_ok(&[
1751            "deepseek",
1752            "model",
1753            "resolve",
1754            "--provider",
1755            "deepseek",
1756            "deepseek-v4-pro",
1757        ]);
1758        assert!(matches!(
1759            cli.command,
1760            Some(Commands::Model(ModelArgs {
1761                command: ModelCommand::Resolve {
1762                    model: Some(ref model),
1763                    provider: Some(ProviderArg::Deepseek)
1764                }
1765            })) if model == "deepseek-v4-pro"
1766        ));
1767    }
1768
1769    #[test]
1770    fn parses_thread_command_matrix() {
1771        let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
1772        assert!(matches!(
1773            cli.command,
1774            Some(Commands::Thread(ThreadArgs {
1775                command: ThreadCommand::List {
1776                    all: true,
1777                    limit: Some(50)
1778                }
1779            }))
1780        ));
1781
1782        let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
1783        assert!(matches!(
1784            cli.command,
1785            Some(Commands::Thread(ThreadArgs {
1786                command: ThreadCommand::Read { ref thread_id }
1787            })) if thread_id == "thread-1"
1788        ));
1789
1790        let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
1791        assert!(matches!(
1792            cli.command,
1793            Some(Commands::Thread(ThreadArgs {
1794                command: ThreadCommand::Resume { ref thread_id }
1795            })) if thread_id == "thread-2"
1796        ));
1797
1798        let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
1799        assert!(matches!(
1800            cli.command,
1801            Some(Commands::Thread(ThreadArgs {
1802                command: ThreadCommand::Fork { ref thread_id }
1803            })) if thread_id == "thread-3"
1804        ));
1805
1806        let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
1807        assert!(matches!(
1808            cli.command,
1809            Some(Commands::Thread(ThreadArgs {
1810                command: ThreadCommand::Archive { ref thread_id }
1811            })) if thread_id == "thread-4"
1812        ));
1813
1814        let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
1815        assert!(matches!(
1816            cli.command,
1817            Some(Commands::Thread(ThreadArgs {
1818                command: ThreadCommand::Unarchive { ref thread_id }
1819            })) if thread_id == "thread-5"
1820        ));
1821
1822        let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
1823        assert!(matches!(
1824            cli.command,
1825            Some(Commands::Thread(ThreadArgs {
1826                command: ThreadCommand::SetName {
1827                    ref thread_id,
1828                    ref name
1829                }
1830            })) if thread_id == "thread-6" && name == "My Thread"
1831        ));
1832    }
1833
1834    #[test]
1835    fn parses_sandbox_app_server_and_completion_matrix() {
1836        let cli = parse_ok(&[
1837            "deepseek",
1838            "sandbox",
1839            "check",
1840            "echo hello",
1841            "--ask",
1842            "on-failure",
1843        ]);
1844        assert!(matches!(
1845            cli.command,
1846            Some(Commands::Sandbox(SandboxArgs {
1847                command: SandboxCommand::Check {
1848                    ref command,
1849                    ask: ApprovalModeArg::OnFailure
1850                }
1851            })) if command == "echo hello"
1852        ));
1853
1854        let cli = parse_ok(&[
1855            "deepseek",
1856            "app-server",
1857            "--host",
1858            "0.0.0.0",
1859            "--port",
1860            "9999",
1861        ]);
1862        assert!(matches!(
1863            cli.command,
1864            Some(Commands::AppServer(AppServerArgs {
1865                ref host,
1866                port: 9999,
1867                stdio: false,
1868                ..
1869            })) if host == "0.0.0.0"
1870        ));
1871
1872        let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
1873        assert!(matches!(
1874            cli.command,
1875            Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
1876        ));
1877
1878        let cli = parse_ok(&["deepseek", "completion", "bash"]);
1879        assert!(matches!(
1880            cli.command,
1881            Some(Commands::Completion { shell: Shell::Bash })
1882        ));
1883    }
1884
1885    #[test]
1886    fn parses_direct_tui_command_aliases() {
1887        let cli = parse_ok(&["deepseek", "doctor"]);
1888        assert!(matches!(
1889            cli.command,
1890            Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
1891        ));
1892
1893        let cli = parse_ok(&["deepseek", "models", "--json"]);
1894        assert!(matches!(
1895            cli.command,
1896            Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
1897        ));
1898
1899        let cli = parse_ok(&["deepseek", "resume", "abc123"]);
1900        assert!(matches!(
1901            cli.command,
1902            Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
1903        ));
1904
1905        let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
1906        assert!(matches!(
1907            cli.command,
1908            Some(Commands::Setup(TuiPassthroughArgs { ref args }))
1909                if args == &["--skills", "--local"]
1910        ));
1911    }
1912
1913    #[test]
1914    fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
1915        assert!(should_pick_resume_in_dispatcher(
1916            &["resume".to_string()],
1917            true
1918        ));
1919        assert!(!should_pick_resume_in_dispatcher(
1920            &["resume".to_string(), "--last".to_string()],
1921            true
1922        ));
1923        assert!(!should_pick_resume_in_dispatcher(
1924            &["resume".to_string(), "abc123".to_string()],
1925            true
1926        ));
1927        assert!(!should_pick_resume_in_dispatcher(
1928            &["resume".to_string()],
1929            false
1930        ));
1931    }
1932
1933    #[test]
1934    fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
1935        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1936        let path = std::env::temp_dir().join(format!(
1937            "deepseek-cli-login-test-{}-{nanos}.toml",
1938            std::process::id()
1939        ));
1940        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1941        let secrets = no_keyring_secrets();
1942
1943        run_login_command_with_secrets(
1944            &mut store,
1945            LoginArgs {
1946                provider: ProviderArg::Deepseek,
1947                api_key: Some("sk-test".to_string()),
1948                chatgpt: false,
1949                device_code: false,
1950                token: None,
1951            },
1952            &secrets,
1953        )
1954        .expect("login should write config");
1955
1956        assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
1957        assert_eq!(
1958            store.config.providers.deepseek.api_key.as_deref(),
1959            Some("sk-test")
1960        );
1961        assert_eq!(
1962            store.config.default_text_model.as_deref(),
1963            Some("deepseek-v4-pro")
1964        );
1965        let saved = std::fs::read_to_string(&path).expect("config should be written");
1966        assert!(saved.contains("api_key = \"sk-test\""));
1967        assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
1968
1969        let _ = std::fs::remove_file(path);
1970    }
1971
1972    #[test]
1973    fn parses_auth_subcommand_matrix() {
1974        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
1975        assert!(matches!(
1976            cli.command,
1977            Some(Commands::Auth(AuthArgs {
1978                command: AuthCommand::Set {
1979                    provider: ProviderArg::Deepseek,
1980                    api_key: None,
1981                    api_key_stdin: false,
1982                }
1983            }))
1984        ));
1985
1986        let cli = parse_ok(&[
1987            "deepseek",
1988            "auth",
1989            "set",
1990            "--provider",
1991            "openrouter",
1992            "--api-key-stdin",
1993        ]);
1994        assert!(matches!(
1995            cli.command,
1996            Some(Commands::Auth(AuthArgs {
1997                command: AuthCommand::Set {
1998                    provider: ProviderArg::Openrouter,
1999                    api_key: None,
2000                    api_key_stdin: true,
2001                }
2002            }))
2003        ));
2004
2005        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
2006        assert!(matches!(
2007            cli.command,
2008            Some(Commands::Auth(AuthArgs {
2009                command: AuthCommand::Get {
2010                    provider: ProviderArg::Novita
2011                }
2012            }))
2013        ));
2014
2015        let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
2016        assert!(matches!(
2017            cli.command,
2018            Some(Commands::Auth(AuthArgs {
2019                command: AuthCommand::Clear {
2020                    provider: ProviderArg::NvidiaNim
2021                }
2022            }))
2023        ));
2024
2025        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
2026        assert!(matches!(
2027            cli.command,
2028            Some(Commands::Auth(AuthArgs {
2029                command: AuthCommand::Set {
2030                    provider: ProviderArg::Fireworks,
2031                    api_key: None,
2032                    api_key_stdin: false,
2033                }
2034            }))
2035        ));
2036
2037        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
2038        assert!(matches!(
2039            cli.command,
2040            Some(Commands::Auth(AuthArgs {
2041                command: AuthCommand::Get {
2042                    provider: ProviderArg::Sglang
2043                }
2044            }))
2045        ));
2046
2047        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
2048        assert!(matches!(
2049            cli.command,
2050            Some(Commands::Auth(AuthArgs {
2051                command: AuthCommand::Get {
2052                    provider: ProviderArg::Vllm
2053                }
2054            }))
2055        ));
2056
2057        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
2058        assert!(matches!(
2059            cli.command,
2060            Some(Commands::Auth(AuthArgs {
2061                command: AuthCommand::Set {
2062                    provider: ProviderArg::Ollama,
2063                    api_key: None,
2064                    api_key_stdin: false,
2065                }
2066            }))
2067        ));
2068
2069        let cli = parse_ok(&["deepseek", "auth", "list"]);
2070        assert!(matches!(
2071            cli.command,
2072            Some(Commands::Auth(AuthArgs {
2073                command: AuthCommand::List
2074            }))
2075        ));
2076
2077        let cli = parse_ok(&["deepseek", "auth", "migrate"]);
2078        assert!(matches!(
2079            cli.command,
2080            Some(Commands::Auth(AuthArgs {
2081                command: AuthCommand::Migrate { dry_run: false }
2082            }))
2083        ));
2084
2085        let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
2086        assert!(matches!(
2087            cli.command,
2088            Some(Commands::Auth(AuthArgs {
2089                command: AuthCommand::Migrate { dry_run: true }
2090            }))
2091        ));
2092    }
2093
2094    #[test]
2095    fn auth_set_writes_to_shared_config_file() {
2096        use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2097        use std::sync::Arc;
2098
2099        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2100        let path = std::env::temp_dir().join(format!(
2101            "deepseek-cli-auth-set-test-{}-{nanos}.toml",
2102            std::process::id()
2103        ));
2104        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2105        let inner = Arc::new(InMemoryKeyringStore::new());
2106        let secrets = Secrets::new(inner.clone());
2107
2108        run_auth_command_with_secrets(
2109            &mut store,
2110            AuthCommand::Set {
2111                provider: ProviderArg::Deepseek,
2112                api_key: Some("sk-keyring".to_string()),
2113                api_key_stdin: false,
2114            },
2115            &secrets,
2116        )
2117        .expect("set should succeed");
2118
2119        assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
2120        assert_eq!(
2121            store.config.providers.deepseek.api_key.as_deref(),
2122            Some("sk-keyring")
2123        );
2124        let saved = std::fs::read_to_string(&path).unwrap_or_default();
2125        assert!(saved.contains("api_key = \"sk-keyring\""));
2126        assert_eq!(
2127            inner.get("deepseek").unwrap().as_deref(),
2128            Some("sk-keyring")
2129        );
2130
2131        let _ = std::fs::remove_file(path);
2132    }
2133
2134    #[test]
2135    fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
2136        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2137        let path = std::env::temp_dir().join(format!(
2138            "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
2139            std::process::id()
2140        ));
2141        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2142        let secrets = no_keyring_secrets();
2143
2144        run_auth_command_with_secrets(
2145            &mut store,
2146            AuthCommand::Set {
2147                provider: ProviderArg::Ollama,
2148                api_key: None,
2149                api_key_stdin: false,
2150            },
2151            &secrets,
2152        )
2153        .expect("ollama auth set should not require a key");
2154
2155        assert_eq!(store.config.provider, ProviderKind::Ollama);
2156        assert_eq!(
2157            store.config.providers.ollama.base_url.as_deref(),
2158            Some("http://localhost:11434/v1")
2159        );
2160        assert_eq!(store.config.providers.ollama.api_key, None);
2161
2162        let _ = std::fs::remove_file(path);
2163    }
2164
2165    #[test]
2166    fn auth_clear_removes_from_config() {
2167        use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2168        use std::sync::Arc;
2169
2170        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2171        let path = std::env::temp_dir().join(format!(
2172            "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
2173            std::process::id()
2174        ));
2175        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2176        store.config.api_key = Some("sk-stale".to_string());
2177        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2178        store.save().unwrap();
2179
2180        let inner = Arc::new(InMemoryKeyringStore::new());
2181        inner.set("deepseek", "sk-stale").unwrap();
2182        let secrets = Secrets::new(inner.clone());
2183
2184        run_auth_command_with_secrets(
2185            &mut store,
2186            AuthCommand::Clear {
2187                provider: ProviderArg::Deepseek,
2188            },
2189            &secrets,
2190        )
2191        .expect("clear should succeed");
2192
2193        assert!(store.config.api_key.is_none());
2194        assert!(store.config.providers.deepseek.api_key.is_none());
2195        assert_eq!(inner.get("deepseek").unwrap(), None);
2196
2197        let _ = std::fs::remove_file(path);
2198    }
2199
2200    #[test]
2201    fn auth_status_and_list_only_probe_active_provider_keyring() {
2202        use deepseek_secrets::{KeyringStore, SecretsError};
2203        use std::sync::{Arc, Mutex};
2204
2205        #[derive(Default)]
2206        struct RecordingStore {
2207            gets: Mutex<Vec<String>>,
2208        }
2209
2210        impl KeyringStore for RecordingStore {
2211            fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
2212                self.gets.lock().unwrap().push(key.to_string());
2213                Ok(None)
2214            }
2215
2216            fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
2217                Ok(())
2218            }
2219
2220            fn delete(&self, _key: &str) -> Result<(), SecretsError> {
2221                Ok(())
2222            }
2223
2224            fn backend_name(&self) -> &'static str {
2225                "recording"
2226            }
2227        }
2228
2229        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2230        let path = std::env::temp_dir().join(format!(
2231            "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
2232            std::process::id()
2233        ));
2234        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2235        store.config.provider = ProviderKind::Deepseek;
2236        let inner = Arc::new(RecordingStore::default());
2237        let secrets = Secrets::new(inner.clone());
2238
2239        run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)
2240            .expect("status should succeed");
2241        run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
2242            .expect("list should succeed");
2243
2244        assert_eq!(
2245            inner.gets.lock().unwrap().as_slice(),
2246            ["deepseek", "deepseek"]
2247        );
2248
2249        let _ = std::fs::remove_file(path);
2250    }
2251
2252    #[test]
2253    fn auth_status_reports_all_active_provider_sources_with_last4() {
2254        use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2255        use std::sync::Arc;
2256
2257        let _lock = env_lock();
2258        let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
2259
2260        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2261        let path = std::env::temp_dir().join(format!(
2262            "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
2263            std::process::id()
2264        ));
2265        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2266        store.config.provider = ProviderKind::Deepseek;
2267        store.config.api_key = Some("sk-config-3333".to_string());
2268        store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
2269
2270        let inner = Arc::new(InMemoryKeyringStore::new());
2271        inner.set("deepseek", "sk-keyring-2222").unwrap();
2272        let secrets = Secrets::new(inner);
2273
2274        let output = auth_status_lines(&store, &secrets).join("\n");
2275
2276        assert!(output.contains("provider: deepseek"));
2277        assert!(output.contains("active source: config (last4: ...3333)"));
2278        assert!(output.contains("lookup order: config -> secret store -> env"));
2279        assert!(output.contains("config file: "));
2280        assert!(output.contains("set, last4: ...3333"));
2281        assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
2282        assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
2283        assert!(!output.contains("sk-config-3333"));
2284        assert!(!output.contains("sk-keyring-2222"));
2285        assert!(!output.contains("sk-env-1111"));
2286
2287        let _ = std::fs::remove_file(path);
2288    }
2289
2290    #[test]
2291    fn dispatch_keyring_recovery_self_heals_into_config_file() {
2292        use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2293        use std::sync::Arc;
2294
2295        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2296        let path = std::env::temp_dir().join(format!(
2297            "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
2298            std::process::id()
2299        ));
2300        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2301        let inner = Arc::new(InMemoryKeyringStore::new());
2302        inner.set("deepseek", "ring-key").unwrap();
2303        let secrets = Secrets::new(inner);
2304
2305        let resolved = resolve_runtime_for_dispatch_with_secrets(
2306            &mut store,
2307            &CliRuntimeOverrides::default(),
2308            &secrets,
2309        );
2310
2311        assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
2312        assert_eq!(
2313            resolved.api_key_source,
2314            Some(RuntimeApiKeySource::ConfigFile)
2315        );
2316        assert_eq!(store.config.api_key.as_deref(), Some("ring-key"));
2317        assert_eq!(
2318            store.config.providers.deepseek.api_key.as_deref(),
2319            Some("ring-key")
2320        );
2321
2322        let saved = std::fs::read_to_string(&path).expect("config should be written");
2323        assert!(saved.contains("api_key = \"ring-key\""));
2324
2325        let resolved_again = resolve_runtime_for_dispatch_with_secrets(
2326            &mut store,
2327            &CliRuntimeOverrides::default(),
2328            &no_keyring_secrets(),
2329        );
2330        assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
2331        assert_eq!(
2332            resolved_again.api_key_source,
2333            Some(RuntimeApiKeySource::ConfigFile)
2334        );
2335
2336        let _ = std::fs::remove_file(path);
2337    }
2338
2339    #[test]
2340    fn logout_removes_plaintext_provider_keys() {
2341        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2342        let path = std::env::temp_dir().join(format!(
2343            "deepseek-cli-logout-test-{}-{nanos}.toml",
2344            std::process::id()
2345        ));
2346        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2347        store.config.api_key = Some("sk-stale".to_string());
2348        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2349        store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
2350        store.save().unwrap();
2351
2352        let secrets = no_keyring_secrets();
2353
2354        run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
2355
2356        assert!(store.config.api_key.is_none());
2357        assert!(store.config.providers.deepseek.api_key.is_none());
2358        assert!(store.config.providers.fireworks.api_key.is_none());
2359
2360        let _ = std::fs::remove_file(path);
2361    }
2362
2363    #[test]
2364    fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
2365        use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2366        use std::sync::Arc;
2367
2368        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2369        let path = std::env::temp_dir().join(format!(
2370            "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
2371            std::process::id()
2372        ));
2373        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2374        store.config.api_key = Some("sk-deep".to_string());
2375        store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
2376        store.config.providers.openrouter.api_key = Some("or-key".to_string());
2377        store.config.providers.novita.api_key = Some("nv-key".to_string());
2378        store.save().unwrap();
2379
2380        let inner = Arc::new(InMemoryKeyringStore::new());
2381        let secrets = Secrets::new(inner.clone());
2382
2383        run_auth_command_with_secrets(
2384            &mut store,
2385            AuthCommand::Migrate { dry_run: false },
2386            &secrets,
2387        )
2388        .expect("migrate should succeed");
2389
2390        assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
2391        assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
2392        assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
2393
2394        // Config file must no longer contain the api keys.
2395        assert!(store.config.api_key.is_none());
2396        assert!(store.config.providers.deepseek.api_key.is_none());
2397        assert!(store.config.providers.openrouter.api_key.is_none());
2398        assert!(store.config.providers.novita.api_key.is_none());
2399
2400        let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
2401        assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
2402        assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
2403        assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
2404
2405        let _ = std::fs::remove_file(path);
2406    }
2407
2408    #[test]
2409    fn auth_migrate_dry_run_does_not_modify_anything() {
2410        use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2411        use std::sync::Arc;
2412
2413        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2414        let path = std::env::temp_dir().join(format!(
2415            "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
2416            std::process::id()
2417        ));
2418        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2419        store.config.providers.openrouter.api_key = Some("or-stay".to_string());
2420        store.save().unwrap();
2421
2422        let inner = Arc::new(InMemoryKeyringStore::new());
2423        let secrets = Secrets::new(inner.clone());
2424
2425        run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
2426            .expect("dry-run should succeed");
2427
2428        assert_eq!(inner.get("openrouter").unwrap(), None);
2429        assert_eq!(
2430            store.config.providers.openrouter.api_key.as_deref(),
2431            Some("or-stay")
2432        );
2433
2434        let _ = std::fs::remove_file(path);
2435    }
2436
2437    #[test]
2438    fn parses_global_override_flags() {
2439        let cli = parse_ok(&[
2440            "deepseek",
2441            "--provider",
2442            "openai",
2443            "--config",
2444            "/tmp/deepseek.toml",
2445            "--profile",
2446            "work",
2447            "--model",
2448            "gpt-4.1",
2449            "--output-mode",
2450            "json",
2451            "--log-level",
2452            "debug",
2453            "--telemetry",
2454            "true",
2455            "--approval-policy",
2456            "on-request",
2457            "--sandbox-mode",
2458            "workspace-write",
2459            "--base-url",
2460            "https://api.openai.com/v1",
2461            "--api-key",
2462            "sk-test",
2463            "--no-alt-screen",
2464            "--no-mouse-capture",
2465            "--skip-onboarding",
2466            "model",
2467            "resolve",
2468            "gpt-4.1",
2469        ]);
2470
2471        assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
2472        assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
2473        assert_eq!(cli.profile.as_deref(), Some("work"));
2474        assert_eq!(cli.model.as_deref(), Some("gpt-4.1"));
2475        assert_eq!(cli.output_mode.as_deref(), Some("json"));
2476        assert_eq!(cli.log_level.as_deref(), Some("debug"));
2477        assert_eq!(cli.telemetry, Some(true));
2478        assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
2479        assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
2480        assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1"));
2481        assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
2482        assert!(cli.no_alt_screen);
2483        assert!(cli.no_mouse_capture);
2484        assert!(!cli.mouse_capture);
2485        assert!(cli.skip_onboarding);
2486    }
2487
2488    #[test]
2489    fn build_tui_command_allows_openai_and_forwards_provider_key() {
2490        let _lock = env_lock();
2491        let dir = tempfile::TempDir::new().expect("tempdir");
2492        let custom = dir
2493            .path()
2494            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2495        std::fs::write(&custom, b"").unwrap();
2496        let custom_str = custom.to_string_lossy().into_owned();
2497        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2498
2499        let cli = parse_ok(&["deepseek", "--provider", "openai"]);
2500        let resolved = ResolvedRuntimeOptions {
2501            provider: ProviderKind::Openai,
2502            model: "glm-5".to_string(),
2503            api_key: Some("resolved-openai-key".to_string()),
2504            api_key_source: Some(RuntimeApiKeySource::Keyring),
2505            base_url: "https://openai-compatible.example/v4".to_string(),
2506            auth_mode: Some("api_key".to_string()),
2507            output_mode: None,
2508            log_level: None,
2509            telemetry: false,
2510            approval_policy: None,
2511            sandbox_mode: None,
2512            http_headers: std::collections::BTreeMap::new(),
2513        };
2514
2515        let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
2516        assert_eq!(
2517            command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
2518            Some("openai")
2519        );
2520        assert_eq!(
2521            command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
2522            Some("glm-5")
2523        );
2524        assert_eq!(
2525            command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
2526            Some("https://openai-compatible.example/v4")
2527        );
2528        assert_eq!(
2529            command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
2530            Some("resolved-openai-key")
2531        );
2532        assert_eq!(
2533            command_env(&cmd, "OPENAI_API_KEY").as_deref(),
2534            Some("resolved-openai-key")
2535        );
2536        assert_eq!(
2537            command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
2538            Some("keyring")
2539        );
2540    }
2541
2542    #[test]
2543    fn parses_top_level_prompt_flag_for_canonical_one_shot() {
2544        let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
2545
2546        assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
2547        assert!(cli.prompt.is_empty());
2548    }
2549
2550    #[test]
2551    fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
2552        let cli = parse_ok(&["deepseek", "hello", "world"]);
2553
2554        assert_eq!(cli.prompt, vec!["hello", "world"]);
2555        assert!(cli.command.is_none());
2556    }
2557
2558    #[test]
2559    fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
2560        let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
2561
2562        assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
2563        assert_eq!(cli.prompt, vec!["world"]);
2564    }
2565
2566    #[test]
2567    fn known_subcommands_still_parse_before_prompt_tail() {
2568        let cli = parse_ok(&["deepseek", "doctor"]);
2569
2570        assert!(cli.prompt.is_empty());
2571        assert!(matches!(cli.command, Some(Commands::Doctor(_))));
2572    }
2573
2574    #[test]
2575    fn root_help_surface_contains_expected_subcommands_and_globals() {
2576        let rendered = help_for(&["deepseek", "--help"]);
2577
2578        for token in [
2579            "run",
2580            "doctor",
2581            "models",
2582            "sessions",
2583            "resume",
2584            "setup",
2585            "login",
2586            "logout",
2587            "auth",
2588            "mcp-server",
2589            "config",
2590            "model",
2591            "thread",
2592            "sandbox",
2593            "app-server",
2594            "completion",
2595            "metrics",
2596            "--provider",
2597            "--model",
2598            "--config",
2599            "--profile",
2600            "--output-mode",
2601            "--log-level",
2602            "--telemetry",
2603            "--base-url",
2604            "--api-key",
2605            "--approval-policy",
2606            "--sandbox-mode",
2607            "--mouse-capture",
2608            "--no-mouse-capture",
2609            "--skip-onboarding",
2610            "--prompt",
2611        ] {
2612            assert!(
2613                rendered.contains(token),
2614                "expected help to contain token: {token}"
2615            );
2616        }
2617    }
2618
2619    #[test]
2620    fn subcommand_help_surfaces_are_stable() {
2621        let cases = [
2622            ("config", vec!["get", "set", "unset", "list", "path"]),
2623            ("model", vec!["list", "resolve"]),
2624            (
2625                "thread",
2626                vec![
2627                    "list",
2628                    "read",
2629                    "resume",
2630                    "fork",
2631                    "archive",
2632                    "unarchive",
2633                    "set-name",
2634                ],
2635            ),
2636            ("sandbox", vec!["check"]),
2637            (
2638                "app-server",
2639                vec!["--host", "--port", "--config", "--stdio"],
2640            ),
2641            (
2642                "completion",
2643                vec![
2644                    "<SHELL>",
2645                    "bash",
2646                    "source <(deepseek completion bash)",
2647                    "~/.local/share/bash-completion/completions/deepseek",
2648                    "fpath=(~/.zfunc $fpath)",
2649                    "deepseek completion fish > ~/.config/fish/completions/deepseek.fish",
2650                    "deepseek completion powershell | Out-String | Invoke-Expression",
2651                ],
2652            ),
2653            ("metrics", vec!["--json", "--since"]),
2654        ];
2655
2656        for (subcommand, expected_tokens) in cases {
2657            let argv = ["deepseek", subcommand, "--help"];
2658            let rendered = help_for(&argv);
2659            for token in expected_tokens {
2660                assert!(
2661                    rendered.contains(token),
2662                    "expected help for `{subcommand}` to include `{token}`"
2663                );
2664            }
2665        }
2666    }
2667
2668    /// Regression for issue #247: on Windows the dispatcher must find the
2669    /// sibling `deepseek-tui.exe`, not bail out looking for an
2670    /// extension-less `deepseek-tui`. The candidate resolver also accepts
2671    /// the suffix-less name on Windows so users who manually renamed the
2672    /// file as a workaround keep working after the upgrade.
2673    #[test]
2674    fn sibling_tui_candidate_picks_platform_correct_name() {
2675        let dir = tempfile::TempDir::new().expect("tempdir");
2676        let dispatcher = dir
2677            .path()
2678            .join("deepseek")
2679            .with_extension(std::env::consts::EXE_EXTENSION);
2680        // Touch the dispatcher so its parent dir is the lookup root.
2681        std::fs::write(&dispatcher, b"").unwrap();
2682
2683        // No sibling yet — resolver returns None.
2684        assert!(sibling_tui_candidate(&dispatcher).is_none());
2685
2686        let target =
2687            dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
2688        std::fs::write(&target, b"").unwrap();
2689
2690        let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
2691        assert_eq!(found, target, "primary platform-correct name wins");
2692    }
2693
2694    #[test]
2695    fn dispatcher_spawn_error_names_path_and_recovery_checks() {
2696        let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
2697        let message = tui_spawn_error(Path::new("C:/tools/deepseek-tui.exe"), &err);
2698
2699        assert!(message.contains("C:/tools/deepseek-tui.exe"));
2700        assert!(message.contains("access is denied"));
2701        assert!(message.contains("where deepseek"));
2702        assert!(message.contains("DEEPSEEK_TUI_BIN"));
2703    }
2704
2705    /// Windows-only fallback: the user from #247 manually renamed the
2706    /// file to drop `.exe`. After the fix lands, that workaround must
2707    /// still resolve via the suffix-less fallback so they don't have to
2708    /// rename it back.
2709    #[cfg(windows)]
2710    #[test]
2711    fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
2712        let dir = tempfile::TempDir::new().expect("tempdir");
2713        let dispatcher = dir.path().join("deepseek.exe");
2714        std::fs::write(&dispatcher, b"").unwrap();
2715
2716        // Only the suffixless name exists — emulates the manual rename.
2717        let suffixless = dispatcher.with_file_name("deepseek-tui");
2718        std::fs::write(&suffixless, b"").unwrap();
2719
2720        let found = sibling_tui_candidate(&dispatcher)
2721            .expect("Windows fallback must locate suffixless deepseek-tui");
2722        assert_eq!(found, suffixless);
2723    }
2724
2725    /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for
2726    /// custom Windows install layouts and CI test rigs.
2727    #[test]
2728    fn locate_sibling_tui_binary_honours_env_override() {
2729        let _lock = env_lock();
2730        let dir = tempfile::TempDir::new().expect("tempdir");
2731        let custom = dir
2732            .path()
2733            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2734        std::fs::write(&custom, b"").unwrap();
2735        let custom_str = custom.to_string_lossy().into_owned();
2736        let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2737
2738        let resolved = locate_sibling_tui_binary().expect("override must resolve");
2739        assert_eq!(resolved, custom);
2740    }
2741}