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