Skip to main content

deepseek_tui_cli/
lib.rs

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