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