Skip to main content

deepseek_tui_cli/
lib.rs

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