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(RunArgs),
106 Doctor(TuiPassthroughArgs),
108 Models(TuiPassthroughArgs),
110 Sessions(TuiPassthroughArgs),
112 Resume(TuiPassthroughArgs),
114 Fork(TuiPassthroughArgs),
116 Init(TuiPassthroughArgs),
118 Setup(TuiPassthroughArgs),
120 Exec(TuiPassthroughArgs),
122 Review(TuiPassthroughArgs),
124 Apply(TuiPassthroughArgs),
126 Eval(TuiPassthroughArgs),
128 Mcp(TuiPassthroughArgs),
130 Features(TuiPassthroughArgs),
132 Serve(TuiPassthroughArgs),
134 Completions(TuiPassthroughArgs),
136 Login(LoginArgs),
138 Logout,
140 Auth(AuthArgs),
142 McpServer,
144 Config(ConfigArgs),
146 Model(ModelArgs),
148 Thread(ThreadArgs),
150 Sandbox(SandboxArgs),
152 AppServer(AppServerArgs),
154 Completion {
156 #[arg(value_enum)]
157 shell: Shell,
158 },
159 Metrics(MetricsArgs),
161 Update,
163}
164
165#[derive(Debug, Args)]
166struct MetricsArgs {
167 #[arg(long)]
169 json: bool,
170 #[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 Status,
211 Set {
215 #[arg(long, value_enum)]
216 provider: ProviderArg,
217 #[arg(long)]
219 api_key: Option<String>,
220 #[arg(long = "api-key-stdin", default_value_t = false)]
222 api_key_stdin: bool,
223 },
224 Get {
227 #[arg(long, value_enum)]
228 provider: ProviderArg,
229 },
230 Clear {
232 #[arg(long, value_enum)]
233 provider: ProviderArg,
234 },
235 List,
238 #[command(hide = true)]
240 Migrate {
241 #[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
554fn 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
567const 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 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 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 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
737fn 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 } 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
1105fn 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(¤t) {
1128 return Ok(found);
1129 }
1130
1131 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
1150fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
1154 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 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 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 #[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 std::fs::write(&dispatcher, b"").unwrap();
1906
1907 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 #[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 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 #[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 struct EnvGuard;
1950 impl Drop for EnvGuard {
1951 fn drop(&mut self) {
1952 unsafe { std::env::remove_var("DEEPSEEK_TUI_BIN") };
1956 }
1957 }
1958 let _g = EnvGuard;
1959 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}