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