Skip to main content

deepseek_tui_cli/
lib.rs

1mod metrics;
2mod update;
3
4use std::io::{self, Read};
5use std::net::SocketAddr;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result, 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::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};
17use deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
18use deepseek_mcp::{McpServerDefinition, run_stdio_server};
19use deepseek_secrets::Secrets;
20use deepseek_state::{StateStore, ThreadListFilters};
21
22#[derive(Debug, Clone, Copy, ValueEnum)]
23enum ProviderArg {
24    Deepseek,
25    NvidiaNim,
26    Openai,
27    Openrouter,
28    Novita,
29}
30
31impl From<ProviderArg> for ProviderKind {
32    fn from(value: ProviderArg) -> Self {
33        match value {
34            ProviderArg::Deepseek => ProviderKind::Deepseek,
35            ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
36            ProviderArg::Openai => ProviderKind::Openai,
37            ProviderArg::Openrouter => ProviderKind::Openrouter,
38            ProviderArg::Novita => ProviderKind::Novita,
39        }
40    }
41}
42
43#[derive(Debug, Parser)]
44#[command(
45    name = "deepseek",
46    version,
47    bin_name = "deepseek",
48    override_usage = "deepseek [OPTIONS] [PROMPT]\n       deepseek [OPTIONS] <COMMAND> [ARGS]"
49)]
50struct Cli {
51    #[arg(long)]
52    config: Option<PathBuf>,
53    #[arg(long)]
54    profile: Option<String>,
55    #[arg(
56        long,
57        value_enum,
58        help = "Advanced provider selector for non-TUI registry/config commands"
59    )]
60    provider: Option<ProviderArg>,
61    #[arg(long)]
62    model: Option<String>,
63    #[arg(long = "output-mode")]
64    output_mode: Option<String>,
65    #[arg(long = "log-level")]
66    log_level: Option<String>,
67    #[arg(long)]
68    telemetry: Option<bool>,
69    #[arg(long)]
70    approval_policy: Option<String>,
71    #[arg(long)]
72    sandbox_mode: Option<String>,
73    #[arg(long)]
74    api_key: Option<String>,
75    #[arg(long)]
76    base_url: Option<String>,
77    #[arg(long = "no-alt-screen")]
78    no_alt_screen: bool,
79    #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
80    mouse_capture: bool,
81    #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
82    no_mouse_capture: bool,
83    #[arg(long = "skip-onboarding")]
84    skip_onboarding: bool,
85    #[arg(
86        short = 'p',
87        long = "prompt",
88        value_name = "PROMPT",
89        conflicts_with = "prompt"
90    )]
91    prompt_flag: Option<String>,
92    #[arg(value_name = "PROMPT")]
93    prompt: Option<String>,
94    #[command(subcommand)]
95    command: Option<Commands>,
96}
97
98#[derive(Debug, Subcommand)]
99enum Commands {
100    /// Run interactive/non-interactive flows via the TUI binary.
101    Run(RunArgs),
102    /// Run DeepSeek TUI diagnostics.
103    Doctor(TuiPassthroughArgs),
104    /// List live DeepSeek API models via the TUI binary.
105    Models(TuiPassthroughArgs),
106    /// List saved TUI sessions.
107    Sessions(TuiPassthroughArgs),
108    /// Resume a saved TUI session.
109    Resume(TuiPassthroughArgs),
110    /// Fork a saved TUI session.
111    Fork(TuiPassthroughArgs),
112    /// Create a default AGENTS.md in the current directory.
113    Init(TuiPassthroughArgs),
114    /// Bootstrap MCP config and/or skills directories.
115    Setup(TuiPassthroughArgs),
116    /// Run the DeepSeek TUI non-interactive agent command.
117    Exec(TuiPassthroughArgs),
118    /// Run a DeepSeek-powered code review over a git diff.
119    Review(TuiPassthroughArgs),
120    /// Apply a patch file or stdin to the working tree.
121    Apply(TuiPassthroughArgs),
122    /// Run the offline TUI evaluation harness.
123    Eval(TuiPassthroughArgs),
124    /// Manage TUI MCP servers.
125    Mcp(TuiPassthroughArgs),
126    /// Inspect TUI feature flags.
127    Features(TuiPassthroughArgs),
128    /// Run a local TUI server.
129    Serve(TuiPassthroughArgs),
130    /// Generate shell completions for the TUI binary.
131    Completions(TuiPassthroughArgs),
132    /// Save a DeepSeek API key to the shared config.
133    Login(LoginArgs),
134    /// Remove saved authentication state.
135    Logout,
136    /// Manage authentication credentials and provider mode.
137    Auth(AuthArgs),
138    /// Run MCP server mode over stdio.
139    McpServer,
140    /// Read/write/list config values.
141    Config(ConfigArgs),
142    /// Resolve or list available models across providers.
143    Model(ModelArgs),
144    /// Manage thread/session metadata and resume/fork flows.
145    Thread(ThreadArgs),
146    /// Evaluate sandbox/approval policy decisions.
147    Sandbox(SandboxArgs),
148    /// Run the app-server transport.
149    AppServer(AppServerArgs),
150    /// Generate shell completions.
151    Completion {
152        #[arg(value_enum)]
153        shell: Shell,
154    },
155    /// Print a usage rollup from the audit log and session store.
156    Metrics(MetricsArgs),
157    /// Check for and apply updates to the `deepseek` binary.
158    Update,
159}
160
161#[derive(Debug, Args)]
162struct MetricsArgs {
163    /// Emit machine-readable JSON.
164    #[arg(long)]
165    json: bool,
166    /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
167    #[arg(long, value_name = "DURATION")]
168    since: Option<String>,
169}
170
171#[derive(Debug, Args)]
172struct RunArgs {
173    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
174    args: Vec<String>,
175}
176
177#[derive(Debug, Args, Clone)]
178struct TuiPassthroughArgs {
179    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
180    args: Vec<String>,
181}
182
183#[derive(Debug, Args)]
184struct LoginArgs {
185    #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]
186    provider: ProviderArg,
187    #[arg(long)]
188    api_key: Option<String>,
189    #[arg(long, default_value_t = false, hide = true)]
190    chatgpt: bool,
191    #[arg(long, default_value_t = false, hide = true)]
192    device_code: bool,
193    #[arg(long, hide = true)]
194    token: Option<String>,
195}
196
197#[derive(Debug, Args)]
198struct AuthArgs {
199    #[command(subcommand)]
200    command: AuthCommand,
201}
202
203#[derive(Debug, Subcommand)]
204enum AuthCommand {
205    /// Show current provider, env vars, and config-file presence.
206    Status,
207    /// Save an API key to the OS keyring (never written to disk in
208    /// plaintext). Reads from `--api-key`, `--api-key-stdin`, or
209    /// prompts on stdin when neither is given. Does not echo the key.
210    Set {
211        #[arg(long, value_enum)]
212        provider: ProviderArg,
213        /// Inline value (discouraged — appears in shell history).
214        #[arg(long)]
215        api_key: Option<String>,
216        /// Read the key from stdin instead of prompting.
217        #[arg(long = "api-key-stdin", default_value_t = false)]
218        api_key_stdin: bool,
219    },
220    /// Report whether a provider has a key configured. Never prints
221    /// the value; just `set` / `not set` plus the source layer.
222    Get {
223        #[arg(long, value_enum)]
224        provider: ProviderArg,
225    },
226    /// Delete a provider's key from the OS keyring (and from the
227    /// plaintext config slot, if present, for parity).
228    Clear {
229        #[arg(long, value_enum)]
230        provider: ProviderArg,
231    },
232    /// List all known providers with their auth state, without
233    /// revealing keys.
234    List,
235    /// Migrate plaintext `api_key` values from `~/.deepseek/config.toml`
236    /// into the OS keyring, then strip them from the file.
237    Migrate {
238        /// Don't actually write anything; print what would change.
239        #[arg(long, default_value_t = false)]
240        dry_run: bool,
241    },
242}
243
244#[derive(Debug, Args)]
245struct ConfigArgs {
246    #[command(subcommand)]
247    command: ConfigCommand,
248}
249
250#[derive(Debug, Subcommand)]
251enum ConfigCommand {
252    Get { key: String },
253    Set { key: String, value: String },
254    Unset { key: String },
255    List,
256    Path,
257}
258
259#[derive(Debug, Args)]
260struct ModelArgs {
261    #[command(subcommand)]
262    command: ModelCommand,
263}
264
265#[derive(Debug, Subcommand)]
266enum ModelCommand {
267    List {
268        #[arg(long, value_enum)]
269        provider: Option<ProviderArg>,
270    },
271    Resolve {
272        model: Option<String>,
273        #[arg(long, value_enum)]
274        provider: Option<ProviderArg>,
275    },
276}
277
278#[derive(Debug, Args)]
279struct ThreadArgs {
280    #[command(subcommand)]
281    command: ThreadCommand,
282}
283
284#[derive(Debug, Subcommand)]
285enum ThreadCommand {
286    List {
287        #[arg(long, default_value_t = false)]
288        all: bool,
289        #[arg(long)]
290        limit: Option<usize>,
291    },
292    Read {
293        thread_id: String,
294    },
295    Resume {
296        thread_id: String,
297    },
298    Fork {
299        thread_id: String,
300    },
301    Archive {
302        thread_id: String,
303    },
304    Unarchive {
305        thread_id: String,
306    },
307    SetName {
308        thread_id: String,
309        name: String,
310    },
311}
312
313#[derive(Debug, Args)]
314struct SandboxArgs {
315    #[command(subcommand)]
316    command: SandboxCommand,
317}
318
319#[derive(Debug, Subcommand)]
320enum SandboxCommand {
321    Check {
322        command: String,
323        #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
324        ask: ApprovalModeArg,
325    },
326}
327
328#[derive(Debug, Clone, Copy, ValueEnum)]
329enum ApprovalModeArg {
330    UnlessTrusted,
331    OnFailure,
332    OnRequest,
333    Never,
334}
335
336impl From<ApprovalModeArg> for AskForApproval {
337    fn from(value: ApprovalModeArg) -> Self {
338        match value {
339            ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
340            ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
341            ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
342            ApprovalModeArg::Never => AskForApproval::Never,
343        }
344    }
345}
346
347#[derive(Debug, Args)]
348struct AppServerArgs {
349    #[arg(long, default_value = "127.0.0.1")]
350    host: String,
351    #[arg(long, default_value_t = 8787)]
352    port: u16,
353    #[arg(long)]
354    config: Option<PathBuf>,
355    #[arg(long, default_value_t = false)]
356    stdio: bool,
357}
358
359const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
360
361pub fn run_cli() -> std::process::ExitCode {
362    match run() {
363        Ok(()) => std::process::ExitCode::SUCCESS,
364        Err(err) => {
365            eprintln!("error: {err}");
366            std::process::ExitCode::FAILURE
367        }
368    }
369}
370
371fn run() -> Result<()> {
372    let mut cli = Cli::parse();
373
374    let mut store = ConfigStore::load(cli.config.clone())?;
375    let runtime_overrides = CliRuntimeOverrides {
376        provider: cli.provider.map(Into::into),
377        model: cli.model.clone(),
378        api_key: cli.api_key.clone(),
379        base_url: cli.base_url.clone(),
380        auth_mode: None,
381        output_mode: cli.output_mode.clone(),
382        log_level: cli.log_level.clone(),
383        telemetry: cli.telemetry,
384        approval_policy: cli.approval_policy.clone(),
385        sandbox_mode: cli.sandbox_mode.clone(),
386    };
387    let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);
388
389    let command = cli.command.take();
390
391    match command {
392        Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),
393        Some(Commands::Doctor(args)) => {
394            delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
395        }
396        Some(Commands::Models(args)) => {
397            delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
398        }
399        Some(Commands::Sessions(args)) => {
400            delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
401        }
402        Some(Commands::Resume(args)) => {
403            delegate_to_tui(&cli, &resolved_runtime, tui_args("resume", args))
404        }
405        Some(Commands::Fork(args)) => {
406            delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
407        }
408        Some(Commands::Init(args)) => {
409            delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
410        }
411        Some(Commands::Setup(args)) => {
412            delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
413        }
414        Some(Commands::Exec(args)) => {
415            delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
416        }
417        Some(Commands::Review(args)) => {
418            delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
419        }
420        Some(Commands::Apply(args)) => {
421            delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
422        }
423        Some(Commands::Eval(args)) => {
424            delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
425        }
426        Some(Commands::Mcp(args)) => {
427            delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
428        }
429        Some(Commands::Features(args)) => {
430            delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
431        }
432        Some(Commands::Serve(args)) => {
433            delegate_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
434        }
435        Some(Commands::Completions(args)) => {
436            delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
437        }
438        Some(Commands::Login(args)) => run_login_command(&mut store, args),
439        Some(Commands::Logout) => run_logout_command(&mut store),
440        Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
441        Some(Commands::McpServer) => run_mcp_server_command(&mut store),
442        Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
443        Some(Commands::Model(args)) => run_model_command(args.command),
444        Some(Commands::Thread(args)) => run_thread_command(args.command),
445        Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
446        Some(Commands::AppServer(args)) => run_app_server_command(args),
447        Some(Commands::Completion { shell }) => {
448            let mut cmd = Cli::command();
449            generate(shell, &mut cmd, "deepseek", &mut io::stdout());
450            Ok(())
451        }
452        Some(Commands::Metrics(args)) => run_metrics_command(args),
453        Some(Commands::Update) => update::run_update(),
454        None => {
455            let mut forwarded = Vec::new();
456            if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {
457                forwarded.push("--prompt".to_string());
458                forwarded.push(prompt);
459            }
460            delegate_to_tui(&cli, &resolved_runtime, forwarded)
461        }
462    }
463}
464
465fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
466    let mut forwarded = Vec::with_capacity(args.args.len() + 1);
467    forwarded.push(command.to_string());
468    forwarded.extend(args.args);
469    forwarded
470}
471
472fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
473    let provider: ProviderKind = args.provider.into();
474    store.config.provider = provider;
475
476    if args.chatgpt {
477        let token = match args.token {
478            Some(token) => token,
479            None => read_api_key_from_stdin()?,
480        };
481        store.config.auth_mode = Some("chatgpt".to_string());
482        store.config.chatgpt_access_token = Some(token);
483        store.config.device_code_session = None;
484        store.save()?;
485        println!("logged in using chatgpt token mode ({})", provider.as_str());
486        return Ok(());
487    }
488
489    if args.device_code {
490        let token = match args.token {
491            Some(token) => token,
492            None => read_api_key_from_stdin()?,
493        };
494        store.config.auth_mode = Some("device_code".to_string());
495        store.config.device_code_session = Some(token);
496        store.config.chatgpt_access_token = None;
497        store.save()?;
498        println!(
499            "logged in using device code session mode ({})",
500            provider.as_str()
501        );
502        return Ok(());
503    }
504
505    let api_key = match args.api_key {
506        Some(v) => v,
507        None => read_api_key_from_stdin()?,
508    };
509    store.config.auth_mode = Some("api_key".to_string());
510    store.config.providers.for_provider_mut(provider).api_key = Some(api_key);
511    if provider == ProviderKind::Deepseek {
512        store.config.api_key = store.config.providers.deepseek.api_key.clone();
513        if store.config.default_text_model.is_none() {
514            store.config.default_text_model = Some(
515                store
516                    .config
517                    .providers
518                    .deepseek
519                    .model
520                    .clone()
521                    .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
522            );
523        }
524    }
525    store.save()?;
526    if provider == ProviderKind::Deepseek {
527        println!(
528            "logged in using API key mode (deepseek). This also updates the shared deepseek-tui config."
529        );
530    } else {
531        println!("logged in using API key mode ({})", provider.as_str());
532    }
533    Ok(())
534}
535
536fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
537    store.config.api_key = None;
538    store.config.providers.deepseek.api_key = None;
539    store.config.providers.nvidia_nim.api_key = None;
540    store.config.providers.openai.api_key = None;
541    store.config.auth_mode = None;
542    store.config.chatgpt_access_token = None;
543    store.config.device_code_session = None;
544    store.save()?;
545    println!("logged out");
546    Ok(())
547}
548
549/// Map [`ProviderKind`] to the canonical keyring slot name (`-a` arg
550/// in `security find-generic-password`).
551fn keyring_slot(provider: ProviderKind) -> &'static str {
552    match provider {
553        ProviderKind::Deepseek => "deepseek",
554        ProviderKind::NvidiaNim => "nvidia-nim",
555        ProviderKind::Openai => "openai",
556        ProviderKind::Openrouter => "openrouter",
557        ProviderKind::Novita => "novita",
558    }
559}
560
561/// Provider order used by the `auth list` and `auth status` outputs.
562const PROVIDER_LIST: [ProviderKind; 5] = [
563    ProviderKind::Deepseek,
564    ProviderKind::NvidiaNim,
565    ProviderKind::Openrouter,
566    ProviderKind::Novita,
567    ProviderKind::Openai,
568];
569
570fn provider_env_set(provider: ProviderKind) -> bool {
571    deepseek_secrets::env_for(keyring_slot(provider)).is_some()
572}
573
574fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
575    let slot = store
576        .config
577        .providers
578        .for_provider(provider)
579        .api_key
580        .as_ref();
581    let root = (provider == ProviderKind::Deepseek)
582        .then_some(store.config.api_key.as_ref())
583        .flatten();
584    slot.or(root).is_some_and(|v| !v.trim().is_empty())
585}
586
587fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
588    run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
589}
590
591fn run_auth_command_with_secrets(
592    store: &mut ConfigStore,
593    command: AuthCommand,
594    secrets: &Secrets,
595) -> Result<()> {
596    match command {
597        AuthCommand::Status => {
598            println!("provider: {}", store.config.provider.as_str());
599            println!("keyring backend: {}", secrets.backend_name());
600            for provider in PROVIDER_LIST {
601                let slot = keyring_slot(provider);
602                let keyring_set = secrets
603                    .get(slot)
604                    .ok()
605                    .flatten()
606                    .is_some_and(|v| !v.trim().is_empty());
607                let env_set = provider_env_set(provider);
608                let file_set = provider_config_set(store, provider);
609                println!(
610                    "{slot} auth: keyring={}, env={}, config={}",
611                    keyring_set, env_set, file_set
612                );
613            }
614            Ok(())
615        }
616        AuthCommand::Set {
617            provider,
618            api_key,
619            api_key_stdin,
620        } => {
621            let provider: ProviderKind = provider.into();
622            let slot = keyring_slot(provider);
623            let api_key = match (api_key, api_key_stdin) {
624                (Some(v), _) => v,
625                (None, true) => read_api_key_from_stdin()?,
626                (None, false) => prompt_api_key(slot)?,
627            };
628            secrets
629                .set(slot, &api_key)
630                .with_context(|| format!("failed to write {slot} key to keyring"))?;
631            // Don't print the key. Don't echo length.
632            println!("saved API key for {slot} to {}", secrets.backend_name());
633            Ok(())
634        }
635        AuthCommand::Get { provider } => {
636            let provider: ProviderKind = provider.into();
637            let slot = keyring_slot(provider);
638            let in_keyring = secrets
639                .get(slot)
640                .ok()
641                .flatten()
642                .is_some_and(|v| !v.trim().is_empty());
643            let in_env = provider_env_set(provider);
644            let in_file = provider_config_set(store, provider);
645            // Report the highest-priority source that has it.
646            let resolved = secrets.resolve(slot).is_some() || in_file;
647            if resolved {
648                let source = if in_keyring {
649                    "keyring"
650                } else if in_env {
651                    "env"
652                } else {
653                    "config-file"
654                };
655                println!("{slot}: set (source: {source})");
656            } else {
657                println!("{slot}: not set");
658            }
659            Ok(())
660        }
661        AuthCommand::Clear { provider } => {
662            let provider: ProviderKind = provider.into();
663            let slot = keyring_slot(provider);
664            secrets
665                .delete(slot)
666                .with_context(|| format!("failed to delete {slot} key from keyring"))?;
667            // Also clear the plaintext slot in config.toml for parity.
668            store.config.providers.for_provider_mut(provider).api_key = None;
669            if provider == ProviderKind::Deepseek {
670                store.config.api_key = None;
671            }
672            store.save()?;
673            println!("cleared API key for {slot}");
674            Ok(())
675        }
676        AuthCommand::List => {
677            println!("keyring backend: {}", secrets.backend_name());
678            println!("provider     keyring  env  config");
679            for provider in PROVIDER_LIST {
680                let slot = keyring_slot(provider);
681                let kr = secrets
682                    .get(slot)
683                    .ok()
684                    .flatten()
685                    .is_some_and(|v| !v.trim().is_empty());
686                let env = provider_env_set(provider);
687                let file = provider_config_set(store, provider);
688                println!(
689                    "{slot:<12}  {}        {}     {}",
690                    yes_no(kr),
691                    yes_no(env),
692                    yes_no(file)
693                );
694            }
695            Ok(())
696        }
697        AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
698    }
699}
700
701fn yes_no(b: bool) -> &'static str {
702    if b { "yes" } else { "no " }
703}
704
705fn prompt_api_key(slot: &str) -> Result<String> {
706    use std::io::{IsTerminal, Write};
707    eprint!("Enter API key for {slot}: ");
708    io::stderr().flush().ok();
709    if !io::stdin().is_terminal() {
710        // Non-interactive: read directly without prompting twice.
711        return read_api_key_from_stdin();
712    }
713    let mut buf = String::new();
714    io::stdin()
715        .read_line(&mut buf)
716        .context("failed to read API key from stdin")?;
717    let key = buf.trim().to_string();
718    if key.is_empty() {
719        bail!("empty API key provided");
720    }
721    Ok(key)
722}
723
724/// Move plaintext keys from config.toml into the keyring. Stays
725/// idempotent: rerunning is a no-op once the file is clean.
726fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
727    let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
728    let mut warnings: Vec<String> = Vec::new();
729
730    for provider in PROVIDER_LIST {
731        let slot = keyring_slot(provider);
732        let from_provider_block = store
733            .config
734            .providers
735            .for_provider(provider)
736            .api_key
737            .clone()
738            .filter(|v| !v.trim().is_empty());
739        let from_root = (provider == ProviderKind::Deepseek)
740            .then(|| store.config.api_key.clone())
741            .flatten()
742            .filter(|v| !v.trim().is_empty());
743        let value = from_provider_block.or(from_root);
744        let Some(value) = value else { continue };
745
746        if let Ok(Some(existing)) = secrets.get(slot)
747            && existing == value
748        {
749            // Already migrated; safe to strip the file slot.
750        } else if dry_run {
751            migrated.push((provider, slot));
752            continue;
753        } else if let Err(err) = secrets.set(slot, &value) {
754            warnings.push(format!("skipped {slot}: failed to write to keyring: {err}"));
755            continue;
756        }
757        if !dry_run {
758            store.config.providers.for_provider_mut(provider).api_key = None;
759            if provider == ProviderKind::Deepseek {
760                store.config.api_key = None;
761            }
762        }
763        migrated.push((provider, slot));
764    }
765
766    if !dry_run && !migrated.is_empty() {
767        store
768            .save()
769            .context("failed to write updated config.toml")?;
770    }
771
772    println!("keyring backend: {}", secrets.backend_name());
773    if migrated.is_empty() {
774        println!("nothing to migrate (config.toml has no plaintext api_key entries)");
775    } else {
776        println!(
777            "{} {} provider key(s):",
778            if dry_run { "would migrate" } else { "migrated" },
779            migrated.len()
780        );
781        for (_, slot) in &migrated {
782            println!("  - {slot}");
783        }
784        if !dry_run {
785            println!(
786                "config.toml at {} no longer contains api_key entries for migrated providers.",
787                store.path().display()
788            );
789        }
790    }
791    for w in warnings {
792        eprintln!("warning: {w}");
793    }
794    Ok(())
795}
796
797fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
798    match command {
799        ConfigCommand::Get { key } => {
800            if let Some(value) = store.config.get_value(&key) {
801                println!("{value}");
802                return Ok(());
803            }
804            bail!("key not found: {key}");
805        }
806        ConfigCommand::Set { key, value } => {
807            store.config.set_value(&key, &value)?;
808            store.save()?;
809            println!("set {key}");
810            Ok(())
811        }
812        ConfigCommand::Unset { key } => {
813            store.config.unset_value(&key)?;
814            store.save()?;
815            println!("unset {key}");
816            Ok(())
817        }
818        ConfigCommand::List => {
819            for (key, value) in store.config.list_values() {
820                println!("{key} = {value}");
821            }
822            Ok(())
823        }
824        ConfigCommand::Path => {
825            println!("{}", store.path().display());
826            Ok(())
827        }
828    }
829}
830
831fn run_model_command(command: ModelCommand) -> Result<()> {
832    let registry = ModelRegistry::default();
833    match command {
834        ModelCommand::List { provider } => {
835            let filter = provider.map(ProviderKind::from);
836            for model in registry.list().into_iter().filter(|m| match filter {
837                Some(p) => m.provider == p,
838                None => true,
839            }) {
840                println!("{} ({})", model.id, model.provider.as_str());
841            }
842            Ok(())
843        }
844        ModelCommand::Resolve { model, provider } => {
845            let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));
846            println!("requested: {}", resolved.requested.unwrap_or_default());
847            println!("resolved: {}", resolved.resolved.id);
848            println!("provider: {}", resolved.resolved.provider.as_str());
849            println!("used_fallback: {}", resolved.used_fallback);
850            Ok(())
851        }
852    }
853}
854
855fn run_thread_command(command: ThreadCommand) -> Result<()> {
856    let state = StateStore::open(None)?;
857    match command {
858        ThreadCommand::List { all, limit } => {
859            let threads = state.list_threads(ThreadListFilters {
860                include_archived: all,
861                limit,
862            })?;
863            for thread in threads {
864                println!(
865                    "{} | {} | {} | {}",
866                    thread.id,
867                    thread
868                        .name
869                        .clone()
870                        .unwrap_or_else(|| "(unnamed)".to_string()),
871                    thread.model_provider,
872                    thread.cwd.display()
873                );
874            }
875            Ok(())
876        }
877        ThreadCommand::Read { thread_id } => {
878            let thread = state.get_thread(&thread_id)?;
879            println!("{}", serde_json::to_string_pretty(&thread)?);
880            Ok(())
881        }
882        ThreadCommand::Resume { thread_id } => {
883            let args = vec!["resume".to_string(), thread_id];
884            delegate_simple_tui(args)
885        }
886        ThreadCommand::Fork { thread_id } => {
887            let args = vec!["fork".to_string(), thread_id];
888            delegate_simple_tui(args)
889        }
890        ThreadCommand::Archive { thread_id } => {
891            state.mark_archived(&thread_id)?;
892            println!("archived {thread_id}");
893            Ok(())
894        }
895        ThreadCommand::Unarchive { thread_id } => {
896            state.mark_unarchived(&thread_id)?;
897            println!("unarchived {thread_id}");
898            Ok(())
899        }
900        ThreadCommand::SetName { thread_id, name } => {
901            let mut thread = state
902                .get_thread(&thread_id)?
903                .with_context(|| format!("thread not found: {thread_id}"))?;
904            thread.name = Some(name);
905            thread.updated_at = chrono::Utc::now().timestamp();
906            state.upsert_thread(&thread)?;
907            println!("renamed {thread_id}");
908            Ok(())
909        }
910    }
911}
912
913fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
914    match command {
915        SandboxCommand::Check { command, ask } => {
916            let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
917            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
918            let decision = engine.check(ExecPolicyContext {
919                command: &command,
920                cwd: &cwd.display().to_string(),
921                ask_for_approval: ask.into(),
922                sandbox_mode: Some("workspace-write"),
923            })?;
924            println!("{}", serde_json::to_string_pretty(&decision)?);
925            Ok(())
926        }
927    }
928}
929
930fn run_app_server_command(args: AppServerArgs) -> Result<()> {
931    let runtime = tokio::runtime::Builder::new_multi_thread()
932        .enable_all()
933        .build()
934        .context("failed to create tokio runtime")?;
935    if args.stdio {
936        return runtime.block_on(run_app_server_stdio(args.config));
937    }
938    let listen: SocketAddr = format!("{}:{}", args.host, args.port)
939        .parse()
940        .with_context(|| {
941            format!(
942                "invalid app-server listen address {}:{}",
943                args.host, args.port
944            )
945        })?;
946    runtime.block_on(run_app_server(AppServerOptions {
947        listen,
948        config_path: args.config,
949    }))
950}
951
952fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
953    let persisted = load_mcp_server_definitions(store);
954    let updated = run_stdio_server(persisted)?;
955    persist_mcp_server_definitions(store, &updated)
956}
957
958fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
959    let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
960        return Vec::new();
961    };
962
963    match parse_mcp_server_definitions(&raw) {
964        Ok(definitions) => definitions,
965        Err(err) => {
966            eprintln!(
967                "warning: failed to parse persisted MCP server definitions ({}): {}",
968                MCP_SERVER_DEFINITIONS_KEY, err
969            );
970            Vec::new()
971        }
972    }
973}
974
975fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
976    if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
977        return Ok(parsed);
978    }
979
980    let unwrapped: String = serde_json::from_str(raw)
981        .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
982    serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
983        format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
984    })
985}
986
987fn persist_mcp_server_definitions(
988    store: &mut ConfigStore,
989    definitions: &[McpServerDefinition],
990) -> Result<()> {
991    let encoded =
992        serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
993    store
994        .config
995        .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
996    store.save()
997}
998
999fn delegate_to_tui(
1000    cli: &Cli,
1001    resolved_runtime: &ResolvedRuntimeOptions,
1002    passthrough: Vec<String>,
1003) -> Result<()> {
1004    let tui = locate_sibling_tui_binary()?;
1005
1006    let mut cmd = Command::new(tui);
1007    if let Some(config) = cli.config.as_ref() {
1008        cmd.arg("--config").arg(config);
1009    }
1010    if let Some(profile) = cli.profile.as_ref() {
1011        cmd.arg("--profile").arg(profile);
1012    }
1013    if cli.no_alt_screen {
1014        cmd.arg("--no-alt-screen");
1015    }
1016    if cli.mouse_capture {
1017        cmd.arg("--mouse-capture");
1018    }
1019    if cli.no_mouse_capture {
1020        cmd.arg("--no-mouse-capture");
1021    }
1022    if cli.skip_onboarding {
1023        cmd.arg("--skip-onboarding");
1024    }
1025    cmd.args(passthrough);
1026
1027    if !matches!(
1028        resolved_runtime.provider,
1029        ProviderKind::Deepseek
1030            | ProviderKind::NvidiaNim
1031            | ProviderKind::Openrouter
1032            | ProviderKind::Novita
1033    ) {
1034        bail!(
1035            "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, and Novita providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.",
1036            resolved_runtime.provider.as_str()
1037        );
1038    }
1039
1040    cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model);
1041    cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url);
1042    cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str());
1043    if let Some(api_key) = resolved_runtime.api_key.as_ref() {
1044        cmd.env("DEEPSEEK_API_KEY", api_key);
1045    }
1046
1047    if let Some(model) = cli.model.as_ref() {
1048        cmd.env("DEEPSEEK_MODEL", model);
1049    }
1050    if let Some(output_mode) = cli.output_mode.as_ref() {
1051        cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
1052    }
1053    if let Some(log_level) = cli.log_level.as_ref() {
1054        cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
1055    }
1056    if let Some(telemetry) = cli.telemetry {
1057        cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
1058    }
1059    if let Some(policy) = cli.approval_policy.as_ref() {
1060        cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
1061    }
1062    if let Some(mode) = cli.sandbox_mode.as_ref() {
1063        cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
1064    }
1065    if let Some(api_key) = cli.api_key.as_ref() {
1066        cmd.env("DEEPSEEK_API_KEY", api_key);
1067    }
1068    if let Some(base_url) = cli.base_url.as_ref() {
1069        cmd.env("DEEPSEEK_BASE_URL", base_url);
1070    }
1071
1072    let status = cmd.status().context("failed to spawn deepseek-tui")?;
1073    match status.code() {
1074        Some(code) => std::process::exit(code),
1075        None => bail!("deepseek-tui terminated by signal"),
1076    }
1077}
1078
1079fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
1080    let tui = locate_sibling_tui_binary()?;
1081    let status = Command::new(tui).args(args).status()?;
1082    match status.code() {
1083        Some(code) => std::process::exit(code),
1084        None => bail!("deepseek-tui terminated by signal"),
1085    }
1086}
1087
1088/// Resolve the sibling `deepseek-tui` executable next to the running
1089/// dispatcher. Honours platform executable suffix (`.exe` on Windows) so
1090/// the npm-distributed Windows package — which ships
1091/// `bin/downloads/deepseek-tui.exe` — is found by `Path::exists` (#247).
1092///
1093/// `DEEPSEEK_TUI_BIN` is consulted first as an explicit override for
1094/// custom installs and CI test layouts. On Windows we additionally try
1095/// the suffix-less name as a fallback for users who already manually
1096/// renamed the file before this fix landed.
1097fn locate_sibling_tui_binary() -> Result<PathBuf> {
1098    if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
1099        let candidate = PathBuf::from(override_path);
1100        if candidate.is_file() {
1101            return Ok(candidate);
1102        }
1103        bail!(
1104            "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
1105            candidate.display()
1106        );
1107    }
1108
1109    let current = std::env::current_exe().context("failed to locate current executable path")?;
1110    if let Some(found) = sibling_tui_candidate(&current) {
1111        return Ok(found);
1112    }
1113
1114    // Build a stable error path so the user sees the platform-correct
1115    // expected name, not "deepseek-tui" on Windows.
1116    let expected = current.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1117    bail!(
1118        "Companion `deepseek-tui` binary not found at {}.\n\
1119\n\
1120The `deepseek` dispatcher delegates interactive sessions to a sibling \
1121`deepseek-tui` binary. To fix this, install one of:\n\
1122  • npm:    npm install -g deepseek-tui            (downloads both binaries)\n\
1123  • cargo:  cargo install deepseek-tui-cli deepseek-tui --locked\n\
1124  • GitHub Releases: download BOTH `deepseek-<platform>` AND \
1125`deepseek-tui-<platform>` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \
1126and place them in the same directory.\n\
1127\n\
1128Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.",
1129        expected.display()
1130    );
1131}
1132
1133/// Return the first existing sibling-binary path under any of the names
1134/// `deepseek-tui` might use on this platform. Pure function to keep
1135/// `locate_sibling_tui_binary` testable.
1136fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
1137    // Primary: platform-correct name. EXE_SUFFIX is "" on Unix and ".exe"
1138    // on Windows.
1139    let primary =
1140        dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1141    if primary.is_file() {
1142        return Some(primary);
1143    }
1144    // Windows fallback: a user who manually renamed `.exe` away (per the
1145    // workaround in #247) still launches successfully under the new code.
1146    if cfg!(windows) {
1147        let suffixless = dispatcher.with_file_name("deepseek-tui");
1148        if suffixless.is_file() {
1149            return Some(suffixless);
1150        }
1151    }
1152    None
1153}
1154
1155fn run_metrics_command(args: MetricsArgs) -> Result<()> {
1156    let since = match args.since.as_deref() {
1157        Some(s) => {
1158            Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
1159        }
1160        None => None,
1161    };
1162    metrics::run(metrics::MetricsArgs {
1163        json: args.json,
1164        since,
1165    })
1166}
1167
1168fn read_api_key_from_stdin() -> Result<String> {
1169    let mut input = String::new();
1170    io::stdin()
1171        .read_to_string(&mut input)
1172        .context("failed to read api key from stdin")?;
1173    let key = input.trim().to_string();
1174    if key.is_empty() {
1175        bail!("empty API key provided");
1176    }
1177    Ok(key)
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183    use clap::error::ErrorKind;
1184
1185    fn parse_ok(argv: &[&str]) -> Cli {
1186        Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
1187    }
1188
1189    fn help_for(argv: &[&str]) -> String {
1190        let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
1191        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
1192        err.to_string()
1193    }
1194
1195    #[test]
1196    fn clap_command_definition_is_consistent() {
1197        Cli::command().debug_assert();
1198    }
1199
1200    #[test]
1201    fn parses_config_command_matrix() {
1202        let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
1203        assert!(matches!(
1204            cli.command,
1205            Some(Commands::Config(ConfigArgs {
1206                command: ConfigCommand::Get { ref key }
1207            })) if key == "provider"
1208        ));
1209
1210        let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
1211        assert!(matches!(
1212            cli.command,
1213            Some(Commands::Config(ConfigArgs {
1214                command: ConfigCommand::Set { ref key, ref value }
1215            })) if key == "model" && value == "deepseek-v4-flash"
1216        ));
1217
1218        let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
1219        assert!(matches!(
1220            cli.command,
1221            Some(Commands::Config(ConfigArgs {
1222                command: ConfigCommand::Unset { ref key }
1223            })) if key == "model"
1224        ));
1225
1226        assert!(matches!(
1227            parse_ok(&["deepseek", "config", "list"]).command,
1228            Some(Commands::Config(ConfigArgs {
1229                command: ConfigCommand::List
1230            }))
1231        ));
1232        assert!(matches!(
1233            parse_ok(&["deepseek", "config", "path"]).command,
1234            Some(Commands::Config(ConfigArgs {
1235                command: ConfigCommand::Path
1236            }))
1237        ));
1238    }
1239
1240    #[test]
1241    fn parses_model_command_matrix() {
1242        let cli = parse_ok(&["deepseek", "model", "list"]);
1243        assert!(matches!(
1244            cli.command,
1245            Some(Commands::Model(ModelArgs {
1246                command: ModelCommand::List { provider: None }
1247            }))
1248        ));
1249
1250        let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
1251        assert!(matches!(
1252            cli.command,
1253            Some(Commands::Model(ModelArgs {
1254                command: ModelCommand::List {
1255                    provider: Some(ProviderArg::Openai)
1256                }
1257            }))
1258        ));
1259
1260        let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
1261        assert!(matches!(
1262            cli.command,
1263            Some(Commands::Model(ModelArgs {
1264                command: ModelCommand::Resolve {
1265                    model: Some(ref model),
1266                    provider: None
1267                }
1268            })) if model == "deepseek-v4-flash"
1269        ));
1270
1271        let cli = parse_ok(&[
1272            "deepseek",
1273            "model",
1274            "resolve",
1275            "--provider",
1276            "deepseek",
1277            "deepseek-v4-pro",
1278        ]);
1279        assert!(matches!(
1280            cli.command,
1281            Some(Commands::Model(ModelArgs {
1282                command: ModelCommand::Resolve {
1283                    model: Some(ref model),
1284                    provider: Some(ProviderArg::Deepseek)
1285                }
1286            })) if model == "deepseek-v4-pro"
1287        ));
1288    }
1289
1290    #[test]
1291    fn parses_thread_command_matrix() {
1292        let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
1293        assert!(matches!(
1294            cli.command,
1295            Some(Commands::Thread(ThreadArgs {
1296                command: ThreadCommand::List {
1297                    all: true,
1298                    limit: Some(50)
1299                }
1300            }))
1301        ));
1302
1303        let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
1304        assert!(matches!(
1305            cli.command,
1306            Some(Commands::Thread(ThreadArgs {
1307                command: ThreadCommand::Read { ref thread_id }
1308            })) if thread_id == "thread-1"
1309        ));
1310
1311        let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
1312        assert!(matches!(
1313            cli.command,
1314            Some(Commands::Thread(ThreadArgs {
1315                command: ThreadCommand::Resume { ref thread_id }
1316            })) if thread_id == "thread-2"
1317        ));
1318
1319        let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
1320        assert!(matches!(
1321            cli.command,
1322            Some(Commands::Thread(ThreadArgs {
1323                command: ThreadCommand::Fork { ref thread_id }
1324            })) if thread_id == "thread-3"
1325        ));
1326
1327        let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
1328        assert!(matches!(
1329            cli.command,
1330            Some(Commands::Thread(ThreadArgs {
1331                command: ThreadCommand::Archive { ref thread_id }
1332            })) if thread_id == "thread-4"
1333        ));
1334
1335        let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
1336        assert!(matches!(
1337            cli.command,
1338            Some(Commands::Thread(ThreadArgs {
1339                command: ThreadCommand::Unarchive { ref thread_id }
1340            })) if thread_id == "thread-5"
1341        ));
1342
1343        let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
1344        assert!(matches!(
1345            cli.command,
1346            Some(Commands::Thread(ThreadArgs {
1347                command: ThreadCommand::SetName {
1348                    ref thread_id,
1349                    ref name
1350                }
1351            })) if thread_id == "thread-6" && name == "My Thread"
1352        ));
1353    }
1354
1355    #[test]
1356    fn parses_sandbox_app_server_and_completion_matrix() {
1357        let cli = parse_ok(&[
1358            "deepseek",
1359            "sandbox",
1360            "check",
1361            "echo hello",
1362            "--ask",
1363            "on-failure",
1364        ]);
1365        assert!(matches!(
1366            cli.command,
1367            Some(Commands::Sandbox(SandboxArgs {
1368                command: SandboxCommand::Check {
1369                    ref command,
1370                    ask: ApprovalModeArg::OnFailure
1371                }
1372            })) if command == "echo hello"
1373        ));
1374
1375        let cli = parse_ok(&[
1376            "deepseek",
1377            "app-server",
1378            "--host",
1379            "0.0.0.0",
1380            "--port",
1381            "9999",
1382        ]);
1383        assert!(matches!(
1384            cli.command,
1385            Some(Commands::AppServer(AppServerArgs {
1386                ref host,
1387                port: 9999,
1388                stdio: false,
1389                ..
1390            })) if host == "0.0.0.0"
1391        ));
1392
1393        let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
1394        assert!(matches!(
1395            cli.command,
1396            Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
1397        ));
1398
1399        let cli = parse_ok(&["deepseek", "completion", "bash"]);
1400        assert!(matches!(
1401            cli.command,
1402            Some(Commands::Completion { shell: Shell::Bash })
1403        ));
1404    }
1405
1406    #[test]
1407    fn parses_direct_tui_command_aliases() {
1408        let cli = parse_ok(&["deepseek", "doctor"]);
1409        assert!(matches!(
1410            cli.command,
1411            Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
1412        ));
1413
1414        let cli = parse_ok(&["deepseek", "models", "--json"]);
1415        assert!(matches!(
1416            cli.command,
1417            Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
1418        ));
1419
1420        let cli = parse_ok(&["deepseek", "resume", "abc123"]);
1421        assert!(matches!(
1422            cli.command,
1423            Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
1424        ));
1425
1426        let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
1427        assert!(matches!(
1428            cli.command,
1429            Some(Commands::Setup(TuiPassthroughArgs { ref args }))
1430                if args == &["--skills", "--local"]
1431        ));
1432    }
1433
1434    #[test]
1435    fn deepseek_login_writes_tui_compatible_config() {
1436        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1437        let path = std::env::temp_dir().join(format!(
1438            "deepseek-cli-login-test-{}-{nanos}.toml",
1439            std::process::id()
1440        ));
1441        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1442
1443        run_login_command(
1444            &mut store,
1445            LoginArgs {
1446                provider: ProviderArg::Deepseek,
1447                api_key: Some("sk-test".to_string()),
1448                chatgpt: false,
1449                device_code: false,
1450                token: None,
1451            },
1452        )
1453        .expect("login should write config");
1454
1455        assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
1456        assert_eq!(
1457            store.config.default_text_model.as_deref(),
1458            Some("deepseek-v4-pro")
1459        );
1460        let saved = std::fs::read_to_string(&path).expect("config should be written");
1461        assert!(saved.contains("api_key = \"sk-test\""));
1462        assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
1463
1464        let _ = std::fs::remove_file(path);
1465    }
1466
1467    #[test]
1468    fn parses_auth_subcommand_matrix() {
1469        let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
1470        assert!(matches!(
1471            cli.command,
1472            Some(Commands::Auth(AuthArgs {
1473                command: AuthCommand::Set {
1474                    provider: ProviderArg::Deepseek,
1475                    api_key: None,
1476                    api_key_stdin: false,
1477                }
1478            }))
1479        ));
1480
1481        let cli = parse_ok(&[
1482            "deepseek",
1483            "auth",
1484            "set",
1485            "--provider",
1486            "openrouter",
1487            "--api-key-stdin",
1488        ]);
1489        assert!(matches!(
1490            cli.command,
1491            Some(Commands::Auth(AuthArgs {
1492                command: AuthCommand::Set {
1493                    provider: ProviderArg::Openrouter,
1494                    api_key: None,
1495                    api_key_stdin: true,
1496                }
1497            }))
1498        ));
1499
1500        let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
1501        assert!(matches!(
1502            cli.command,
1503            Some(Commands::Auth(AuthArgs {
1504                command: AuthCommand::Get {
1505                    provider: ProviderArg::Novita
1506                }
1507            }))
1508        ));
1509
1510        let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
1511        assert!(matches!(
1512            cli.command,
1513            Some(Commands::Auth(AuthArgs {
1514                command: AuthCommand::Clear {
1515                    provider: ProviderArg::NvidiaNim
1516                }
1517            }))
1518        ));
1519
1520        let cli = parse_ok(&["deepseek", "auth", "list"]);
1521        assert!(matches!(
1522            cli.command,
1523            Some(Commands::Auth(AuthArgs {
1524                command: AuthCommand::List
1525            }))
1526        ));
1527
1528        let cli = parse_ok(&["deepseek", "auth", "migrate"]);
1529        assert!(matches!(
1530            cli.command,
1531            Some(Commands::Auth(AuthArgs {
1532                command: AuthCommand::Migrate { dry_run: false }
1533            }))
1534        ));
1535
1536        let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
1537        assert!(matches!(
1538            cli.command,
1539            Some(Commands::Auth(AuthArgs {
1540                command: AuthCommand::Migrate { dry_run: true }
1541            }))
1542        ));
1543    }
1544
1545    #[test]
1546    fn auth_set_writes_to_keyring_and_not_to_config_file() {
1547        use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
1548        use std::sync::Arc;
1549
1550        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1551        let path = std::env::temp_dir().join(format!(
1552            "deepseek-cli-auth-set-test-{}-{nanos}.toml",
1553            std::process::id()
1554        ));
1555        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1556        let inner = Arc::new(InMemoryKeyringStore::new());
1557        let secrets = Secrets::new(inner.clone());
1558
1559        run_auth_command_with_secrets(
1560            &mut store,
1561            AuthCommand::Set {
1562                provider: ProviderArg::Deepseek,
1563                api_key: Some("sk-keyring".to_string()),
1564                api_key_stdin: false,
1565            },
1566            &secrets,
1567        )
1568        .expect("set should succeed");
1569
1570        assert_eq!(
1571            inner.get("deepseek").unwrap(),
1572            Some("sk-keyring".to_string())
1573        );
1574        // Plaintext config slot must not be written.
1575        assert!(store.config.api_key.is_none());
1576        assert!(store.config.providers.deepseek.api_key.is_none());
1577        let saved = std::fs::read_to_string(&path).unwrap_or_default();
1578        assert!(
1579            !saved.contains("sk-keyring"),
1580            "plaintext key leaked into config: {saved}"
1581        );
1582
1583        let _ = std::fs::remove_file(path);
1584    }
1585
1586    #[test]
1587    fn auth_clear_removes_from_keyring_and_config() {
1588        use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
1589        use std::sync::Arc;
1590
1591        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1592        let path = std::env::temp_dir().join(format!(
1593            "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
1594            std::process::id()
1595        ));
1596        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1597        store.config.api_key = Some("sk-stale".to_string());
1598        store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
1599        store.save().unwrap();
1600
1601        let inner = Arc::new(InMemoryKeyringStore::new());
1602        inner.set("deepseek", "sk-keyring").unwrap();
1603        let secrets = Secrets::new(inner.clone());
1604
1605        run_auth_command_with_secrets(
1606            &mut store,
1607            AuthCommand::Clear {
1608                provider: ProviderArg::Deepseek,
1609            },
1610            &secrets,
1611        )
1612        .expect("clear should succeed");
1613
1614        assert_eq!(inner.get("deepseek").unwrap(), None);
1615        assert!(store.config.api_key.is_none());
1616        assert!(store.config.providers.deepseek.api_key.is_none());
1617
1618        let _ = std::fs::remove_file(path);
1619    }
1620
1621    #[test]
1622    fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
1623        use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
1624        use std::sync::Arc;
1625
1626        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1627        let path = std::env::temp_dir().join(format!(
1628            "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
1629            std::process::id()
1630        ));
1631        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1632        store.config.api_key = Some("sk-deep".to_string());
1633        store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
1634        store.config.providers.openrouter.api_key = Some("or-key".to_string());
1635        store.config.providers.novita.api_key = Some("nv-key".to_string());
1636        store.save().unwrap();
1637
1638        let inner = Arc::new(InMemoryKeyringStore::new());
1639        let secrets = Secrets::new(inner.clone());
1640
1641        run_auth_command_with_secrets(
1642            &mut store,
1643            AuthCommand::Migrate { dry_run: false },
1644            &secrets,
1645        )
1646        .expect("migrate should succeed");
1647
1648        assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
1649        assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
1650        assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
1651
1652        // Config file must no longer contain the api keys.
1653        assert!(store.config.api_key.is_none());
1654        assert!(store.config.providers.deepseek.api_key.is_none());
1655        assert!(store.config.providers.openrouter.api_key.is_none());
1656        assert!(store.config.providers.novita.api_key.is_none());
1657
1658        let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
1659        assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
1660        assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
1661        assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
1662
1663        let _ = std::fs::remove_file(path);
1664    }
1665
1666    #[test]
1667    fn auth_migrate_dry_run_does_not_modify_anything() {
1668        use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
1669        use std::sync::Arc;
1670
1671        let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1672        let path = std::env::temp_dir().join(format!(
1673            "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
1674            std::process::id()
1675        ));
1676        let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1677        store.config.providers.openrouter.api_key = Some("or-stay".to_string());
1678        store.save().unwrap();
1679
1680        let inner = Arc::new(InMemoryKeyringStore::new());
1681        let secrets = Secrets::new(inner.clone());
1682
1683        run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
1684            .expect("dry-run should succeed");
1685
1686        assert_eq!(inner.get("openrouter").unwrap(), None);
1687        assert_eq!(
1688            store.config.providers.openrouter.api_key.as_deref(),
1689            Some("or-stay")
1690        );
1691
1692        let _ = std::fs::remove_file(path);
1693    }
1694
1695    #[test]
1696    fn parses_global_override_flags() {
1697        let cli = parse_ok(&[
1698            "deepseek",
1699            "--provider",
1700            "openai",
1701            "--config",
1702            "/tmp/deepseek.toml",
1703            "--profile",
1704            "work",
1705            "--model",
1706            "gpt-4.1",
1707            "--output-mode",
1708            "json",
1709            "--log-level",
1710            "debug",
1711            "--telemetry",
1712            "true",
1713            "--approval-policy",
1714            "on-request",
1715            "--sandbox-mode",
1716            "workspace-write",
1717            "--base-url",
1718            "https://api.openai.com/v1",
1719            "--api-key",
1720            "sk-test",
1721            "--no-alt-screen",
1722            "--no-mouse-capture",
1723            "--skip-onboarding",
1724            "model",
1725            "resolve",
1726            "gpt-4.1",
1727        ]);
1728
1729        assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
1730        assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
1731        assert_eq!(cli.profile.as_deref(), Some("work"));
1732        assert_eq!(cli.model.as_deref(), Some("gpt-4.1"));
1733        assert_eq!(cli.output_mode.as_deref(), Some("json"));
1734        assert_eq!(cli.log_level.as_deref(), Some("debug"));
1735        assert_eq!(cli.telemetry, Some(true));
1736        assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
1737        assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
1738        assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1"));
1739        assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
1740        assert!(cli.no_alt_screen);
1741        assert!(cli.no_mouse_capture);
1742        assert!(!cli.mouse_capture);
1743        assert!(cli.skip_onboarding);
1744    }
1745
1746    #[test]
1747    fn parses_top_level_prompt_flag_for_canonical_one_shot() {
1748        let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
1749
1750        assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
1751        assert_eq!(cli.prompt, None);
1752    }
1753
1754    #[test]
1755    fn root_help_surface_contains_expected_subcommands_and_globals() {
1756        let rendered = help_for(&["deepseek", "--help"]);
1757
1758        for token in [
1759            "run",
1760            "doctor",
1761            "models",
1762            "sessions",
1763            "resume",
1764            "setup",
1765            "login",
1766            "logout",
1767            "auth",
1768            "mcp-server",
1769            "config",
1770            "model",
1771            "thread",
1772            "sandbox",
1773            "app-server",
1774            "completion",
1775            "metrics",
1776            "--provider",
1777            "--model",
1778            "--config",
1779            "--profile",
1780            "--output-mode",
1781            "--log-level",
1782            "--telemetry",
1783            "--base-url",
1784            "--api-key",
1785            "--approval-policy",
1786            "--sandbox-mode",
1787            "--no-alt-screen",
1788            "--mouse-capture",
1789            "--no-mouse-capture",
1790            "--skip-onboarding",
1791            "--prompt",
1792        ] {
1793            assert!(
1794                rendered.contains(token),
1795                "expected help to contain token: {token}"
1796            );
1797        }
1798    }
1799
1800    #[test]
1801    fn subcommand_help_surfaces_are_stable() {
1802        let cases = [
1803            ("config", vec!["get", "set", "unset", "list", "path"]),
1804            ("model", vec!["list", "resolve"]),
1805            (
1806                "thread",
1807                vec![
1808                    "list",
1809                    "read",
1810                    "resume",
1811                    "fork",
1812                    "archive",
1813                    "unarchive",
1814                    "set-name",
1815                ],
1816            ),
1817            ("sandbox", vec!["check"]),
1818            (
1819                "app-server",
1820                vec!["--host", "--port", "--config", "--stdio"],
1821            ),
1822            ("completion", vec!["<SHELL>", "bash"]),
1823            ("metrics", vec!["--json", "--since"]),
1824        ];
1825
1826        for (subcommand, expected_tokens) in cases {
1827            let argv = ["deepseek", subcommand, "--help"];
1828            let rendered = help_for(&argv);
1829            for token in expected_tokens {
1830                assert!(
1831                    rendered.contains(token),
1832                    "expected help for `{subcommand}` to include `{token}`"
1833                );
1834            }
1835        }
1836    }
1837
1838    /// Regression for issue #247: on Windows the dispatcher must find the
1839    /// sibling `deepseek-tui.exe`, not bail out looking for an
1840    /// extension-less `deepseek-tui`. The candidate resolver also accepts
1841    /// the suffix-less name on Windows so users who manually renamed the
1842    /// file as a workaround keep working after the upgrade.
1843    #[test]
1844    fn sibling_tui_candidate_picks_platform_correct_name() {
1845        let dir = tempfile::TempDir::new().expect("tempdir");
1846        let dispatcher = dir
1847            .path()
1848            .join("deepseek")
1849            .with_extension(std::env::consts::EXE_EXTENSION);
1850        // Touch the dispatcher so its parent dir is the lookup root.
1851        std::fs::write(&dispatcher, b"").unwrap();
1852
1853        // No sibling yet — resolver returns None.
1854        assert!(sibling_tui_candidate(&dispatcher).is_none());
1855
1856        let target =
1857            dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1858        std::fs::write(&target, b"").unwrap();
1859
1860        let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
1861        assert_eq!(found, target, "primary platform-correct name wins");
1862    }
1863
1864    /// Windows-only fallback: the user from #247 manually renamed the
1865    /// file to drop `.exe`. After the fix lands, that workaround must
1866    /// still resolve via the suffix-less fallback so they don't have to
1867    /// rename it back.
1868    #[cfg(windows)]
1869    #[test]
1870    fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
1871        let dir = tempfile::TempDir::new().expect("tempdir");
1872        let dispatcher = dir.path().join("deepseek.exe");
1873        std::fs::write(&dispatcher, b"").unwrap();
1874
1875        // Only the suffixless name exists — emulates the manual rename.
1876        let suffixless = dispatcher.with_file_name("deepseek-tui");
1877        std::fs::write(&suffixless, b"").unwrap();
1878
1879        let found = sibling_tui_candidate(&dispatcher)
1880            .expect("Windows fallback must locate suffixless deepseek-tui");
1881        assert_eq!(found, suffixless);
1882    }
1883
1884    /// `DEEPSEEK_TUI_BIN` overrides the discovery path. Useful for
1885    /// custom Windows install layouts and CI test rigs.
1886    #[test]
1887    fn locate_sibling_tui_binary_honours_env_override() {
1888        let dir = tempfile::TempDir::new().expect("tempdir");
1889        let custom = dir
1890            .path()
1891            .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
1892        std::fs::write(&custom, b"").unwrap();
1893
1894        // Use a guard so even on test failure the env var clears.
1895        struct EnvGuard;
1896        impl Drop for EnvGuard {
1897            fn drop(&mut self) {
1898                // SAFETY: tests own this env key for the duration of the
1899                // guard; clearing on drop matches the documented teardown
1900                // pattern for `std::env::set_var` in single-threaded tests.
1901                unsafe { std::env::remove_var("DEEPSEEK_TUI_BIN") };
1902            }
1903        }
1904        let _g = EnvGuard;
1905        // SAFETY: same single-threaded scope contract as the guard above.
1906        unsafe { std::env::set_var("DEEPSEEK_TUI_BIN", &custom) };
1907
1908        let resolved = locate_sibling_tui_binary().expect("override must resolve");
1909        assert_eq!(resolved, custom);
1910    }
1911}