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