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