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