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 "deepseek-tui binary not found at {}. Build workspace default members to install it, or set DEEPSEEK_TUI_BIN to its absolute path.",
1115 expected.display()
1116 );
1117}
1118
1119fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
1123 let primary =
1126 dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1127 if primary.is_file() {
1128 return Some(primary);
1129 }
1130 if cfg!(windows) {
1133 let suffixless = dispatcher.with_file_name("deepseek-tui");
1134 if suffixless.is_file() {
1135 return Some(suffixless);
1136 }
1137 }
1138 None
1139}
1140
1141fn run_metrics_command(args: MetricsArgs) -> Result<()> {
1142 let since = match args.since.as_deref() {
1143 Some(s) => {
1144 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
1145 }
1146 None => None,
1147 };
1148 metrics::run(metrics::MetricsArgs {
1149 json: args.json,
1150 since,
1151 })
1152}
1153
1154fn read_api_key_from_stdin() -> Result<String> {
1155 let mut input = String::new();
1156 io::stdin()
1157 .read_to_string(&mut input)
1158 .context("failed to read api key from stdin")?;
1159 let key = input.trim().to_string();
1160 if key.is_empty() {
1161 bail!("empty API key provided");
1162 }
1163 Ok(key)
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168 use super::*;
1169 use clap::error::ErrorKind;
1170
1171 fn parse_ok(argv: &[&str]) -> Cli {
1172 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
1173 }
1174
1175 fn help_for(argv: &[&str]) -> String {
1176 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
1177 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
1178 err.to_string()
1179 }
1180
1181 #[test]
1182 fn clap_command_definition_is_consistent() {
1183 Cli::command().debug_assert();
1184 }
1185
1186 #[test]
1187 fn parses_config_command_matrix() {
1188 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
1189 assert!(matches!(
1190 cli.command,
1191 Some(Commands::Config(ConfigArgs {
1192 command: ConfigCommand::Get { ref key }
1193 })) if key == "provider"
1194 ));
1195
1196 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
1197 assert!(matches!(
1198 cli.command,
1199 Some(Commands::Config(ConfigArgs {
1200 command: ConfigCommand::Set { ref key, ref value }
1201 })) if key == "model" && value == "deepseek-v4-flash"
1202 ));
1203
1204 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
1205 assert!(matches!(
1206 cli.command,
1207 Some(Commands::Config(ConfigArgs {
1208 command: ConfigCommand::Unset { ref key }
1209 })) if key == "model"
1210 ));
1211
1212 assert!(matches!(
1213 parse_ok(&["deepseek", "config", "list"]).command,
1214 Some(Commands::Config(ConfigArgs {
1215 command: ConfigCommand::List
1216 }))
1217 ));
1218 assert!(matches!(
1219 parse_ok(&["deepseek", "config", "path"]).command,
1220 Some(Commands::Config(ConfigArgs {
1221 command: ConfigCommand::Path
1222 }))
1223 ));
1224 }
1225
1226 #[test]
1227 fn parses_model_command_matrix() {
1228 let cli = parse_ok(&["deepseek", "model", "list"]);
1229 assert!(matches!(
1230 cli.command,
1231 Some(Commands::Model(ModelArgs {
1232 command: ModelCommand::List { provider: None }
1233 }))
1234 ));
1235
1236 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
1237 assert!(matches!(
1238 cli.command,
1239 Some(Commands::Model(ModelArgs {
1240 command: ModelCommand::List {
1241 provider: Some(ProviderArg::Openai)
1242 }
1243 }))
1244 ));
1245
1246 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
1247 assert!(matches!(
1248 cli.command,
1249 Some(Commands::Model(ModelArgs {
1250 command: ModelCommand::Resolve {
1251 model: Some(ref model),
1252 provider: None
1253 }
1254 })) if model == "deepseek-v4-flash"
1255 ));
1256
1257 let cli = parse_ok(&[
1258 "deepseek",
1259 "model",
1260 "resolve",
1261 "--provider",
1262 "deepseek",
1263 "deepseek-v4-pro",
1264 ]);
1265 assert!(matches!(
1266 cli.command,
1267 Some(Commands::Model(ModelArgs {
1268 command: ModelCommand::Resolve {
1269 model: Some(ref model),
1270 provider: Some(ProviderArg::Deepseek)
1271 }
1272 })) if model == "deepseek-v4-pro"
1273 ));
1274 }
1275
1276 #[test]
1277 fn parses_thread_command_matrix() {
1278 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
1279 assert!(matches!(
1280 cli.command,
1281 Some(Commands::Thread(ThreadArgs {
1282 command: ThreadCommand::List {
1283 all: true,
1284 limit: Some(50)
1285 }
1286 }))
1287 ));
1288
1289 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
1290 assert!(matches!(
1291 cli.command,
1292 Some(Commands::Thread(ThreadArgs {
1293 command: ThreadCommand::Read { ref thread_id }
1294 })) if thread_id == "thread-1"
1295 ));
1296
1297 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
1298 assert!(matches!(
1299 cli.command,
1300 Some(Commands::Thread(ThreadArgs {
1301 command: ThreadCommand::Resume { ref thread_id }
1302 })) if thread_id == "thread-2"
1303 ));
1304
1305 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
1306 assert!(matches!(
1307 cli.command,
1308 Some(Commands::Thread(ThreadArgs {
1309 command: ThreadCommand::Fork { ref thread_id }
1310 })) if thread_id == "thread-3"
1311 ));
1312
1313 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
1314 assert!(matches!(
1315 cli.command,
1316 Some(Commands::Thread(ThreadArgs {
1317 command: ThreadCommand::Archive { ref thread_id }
1318 })) if thread_id == "thread-4"
1319 ));
1320
1321 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
1322 assert!(matches!(
1323 cli.command,
1324 Some(Commands::Thread(ThreadArgs {
1325 command: ThreadCommand::Unarchive { ref thread_id }
1326 })) if thread_id == "thread-5"
1327 ));
1328
1329 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
1330 assert!(matches!(
1331 cli.command,
1332 Some(Commands::Thread(ThreadArgs {
1333 command: ThreadCommand::SetName {
1334 ref thread_id,
1335 ref name
1336 }
1337 })) if thread_id == "thread-6" && name == "My Thread"
1338 ));
1339 }
1340
1341 #[test]
1342 fn parses_sandbox_app_server_and_completion_matrix() {
1343 let cli = parse_ok(&[
1344 "deepseek",
1345 "sandbox",
1346 "check",
1347 "echo hello",
1348 "--ask",
1349 "on-failure",
1350 ]);
1351 assert!(matches!(
1352 cli.command,
1353 Some(Commands::Sandbox(SandboxArgs {
1354 command: SandboxCommand::Check {
1355 ref command,
1356 ask: ApprovalModeArg::OnFailure
1357 }
1358 })) if command == "echo hello"
1359 ));
1360
1361 let cli = parse_ok(&[
1362 "deepseek",
1363 "app-server",
1364 "--host",
1365 "0.0.0.0",
1366 "--port",
1367 "9999",
1368 ]);
1369 assert!(matches!(
1370 cli.command,
1371 Some(Commands::AppServer(AppServerArgs {
1372 ref host,
1373 port: 9999,
1374 stdio: false,
1375 ..
1376 })) if host == "0.0.0.0"
1377 ));
1378
1379 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
1380 assert!(matches!(
1381 cli.command,
1382 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
1383 ));
1384
1385 let cli = parse_ok(&["deepseek", "completion", "bash"]);
1386 assert!(matches!(
1387 cli.command,
1388 Some(Commands::Completion { shell: Shell::Bash })
1389 ));
1390 }
1391
1392 #[test]
1393 fn parses_direct_tui_command_aliases() {
1394 let cli = parse_ok(&["deepseek", "doctor"]);
1395 assert!(matches!(
1396 cli.command,
1397 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
1398 ));
1399
1400 let cli = parse_ok(&["deepseek", "models", "--json"]);
1401 assert!(matches!(
1402 cli.command,
1403 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
1404 ));
1405
1406 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
1407 assert!(matches!(
1408 cli.command,
1409 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
1410 ));
1411
1412 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
1413 assert!(matches!(
1414 cli.command,
1415 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
1416 if args == &["--skills", "--local"]
1417 ));
1418 }
1419
1420 #[test]
1421 fn deepseek_login_writes_tui_compatible_config() {
1422 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1423 let path = std::env::temp_dir().join(format!(
1424 "deepseek-cli-login-test-{}-{nanos}.toml",
1425 std::process::id()
1426 ));
1427 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1428
1429 run_login_command(
1430 &mut store,
1431 LoginArgs {
1432 provider: ProviderArg::Deepseek,
1433 api_key: Some("sk-test".to_string()),
1434 chatgpt: false,
1435 device_code: false,
1436 token: None,
1437 },
1438 )
1439 .expect("login should write config");
1440
1441 assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
1442 assert_eq!(
1443 store.config.default_text_model.as_deref(),
1444 Some("deepseek-v4-pro")
1445 );
1446 let saved = std::fs::read_to_string(&path).expect("config should be written");
1447 assert!(saved.contains("api_key = \"sk-test\""));
1448 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
1449
1450 let _ = std::fs::remove_file(path);
1451 }
1452
1453 #[test]
1454 fn parses_auth_subcommand_matrix() {
1455 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
1456 assert!(matches!(
1457 cli.command,
1458 Some(Commands::Auth(AuthArgs {
1459 command: AuthCommand::Set {
1460 provider: ProviderArg::Deepseek,
1461 api_key: None,
1462 api_key_stdin: false,
1463 }
1464 }))
1465 ));
1466
1467 let cli = parse_ok(&[
1468 "deepseek",
1469 "auth",
1470 "set",
1471 "--provider",
1472 "openrouter",
1473 "--api-key-stdin",
1474 ]);
1475 assert!(matches!(
1476 cli.command,
1477 Some(Commands::Auth(AuthArgs {
1478 command: AuthCommand::Set {
1479 provider: ProviderArg::Openrouter,
1480 api_key: None,
1481 api_key_stdin: true,
1482 }
1483 }))
1484 ));
1485
1486 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
1487 assert!(matches!(
1488 cli.command,
1489 Some(Commands::Auth(AuthArgs {
1490 command: AuthCommand::Get {
1491 provider: ProviderArg::Novita
1492 }
1493 }))
1494 ));
1495
1496 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
1497 assert!(matches!(
1498 cli.command,
1499 Some(Commands::Auth(AuthArgs {
1500 command: AuthCommand::Clear {
1501 provider: ProviderArg::NvidiaNim
1502 }
1503 }))
1504 ));
1505
1506 let cli = parse_ok(&["deepseek", "auth", "list"]);
1507 assert!(matches!(
1508 cli.command,
1509 Some(Commands::Auth(AuthArgs {
1510 command: AuthCommand::List
1511 }))
1512 ));
1513
1514 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
1515 assert!(matches!(
1516 cli.command,
1517 Some(Commands::Auth(AuthArgs {
1518 command: AuthCommand::Migrate { dry_run: false }
1519 }))
1520 ));
1521
1522 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
1523 assert!(matches!(
1524 cli.command,
1525 Some(Commands::Auth(AuthArgs {
1526 command: AuthCommand::Migrate { dry_run: true }
1527 }))
1528 ));
1529 }
1530
1531 #[test]
1532 fn auth_set_writes_to_keyring_and_not_to_config_file() {
1533 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
1534 use std::sync::Arc;
1535
1536 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1537 let path = std::env::temp_dir().join(format!(
1538 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
1539 std::process::id()
1540 ));
1541 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1542 let inner = Arc::new(InMemoryKeyringStore::new());
1543 let secrets = Secrets::new(inner.clone());
1544
1545 run_auth_command_with_secrets(
1546 &mut store,
1547 AuthCommand::Set {
1548 provider: ProviderArg::Deepseek,
1549 api_key: Some("sk-keyring".to_string()),
1550 api_key_stdin: false,
1551 },
1552 &secrets,
1553 )
1554 .expect("set should succeed");
1555
1556 assert_eq!(
1557 inner.get("deepseek").unwrap(),
1558 Some("sk-keyring".to_string())
1559 );
1560 assert!(store.config.api_key.is_none());
1562 assert!(store.config.providers.deepseek.api_key.is_none());
1563 let saved = std::fs::read_to_string(&path).unwrap_or_default();
1564 assert!(
1565 !saved.contains("sk-keyring"),
1566 "plaintext key leaked into config: {saved}"
1567 );
1568
1569 let _ = std::fs::remove_file(path);
1570 }
1571
1572 #[test]
1573 fn auth_clear_removes_from_keyring_and_config() {
1574 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
1575 use std::sync::Arc;
1576
1577 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1578 let path = std::env::temp_dir().join(format!(
1579 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
1580 std::process::id()
1581 ));
1582 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1583 store.config.api_key = Some("sk-stale".to_string());
1584 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
1585 store.save().unwrap();
1586
1587 let inner = Arc::new(InMemoryKeyringStore::new());
1588 inner.set("deepseek", "sk-keyring").unwrap();
1589 let secrets = Secrets::new(inner.clone());
1590
1591 run_auth_command_with_secrets(
1592 &mut store,
1593 AuthCommand::Clear {
1594 provider: ProviderArg::Deepseek,
1595 },
1596 &secrets,
1597 )
1598 .expect("clear should succeed");
1599
1600 assert_eq!(inner.get("deepseek").unwrap(), None);
1601 assert!(store.config.api_key.is_none());
1602 assert!(store.config.providers.deepseek.api_key.is_none());
1603
1604 let _ = std::fs::remove_file(path);
1605 }
1606
1607 #[test]
1608 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
1609 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
1610 use std::sync::Arc;
1611
1612 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1613 let path = std::env::temp_dir().join(format!(
1614 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
1615 std::process::id()
1616 ));
1617 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1618 store.config.api_key = Some("sk-deep".to_string());
1619 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
1620 store.config.providers.openrouter.api_key = Some("or-key".to_string());
1621 store.config.providers.novita.api_key = Some("nv-key".to_string());
1622 store.save().unwrap();
1623
1624 let inner = Arc::new(InMemoryKeyringStore::new());
1625 let secrets = Secrets::new(inner.clone());
1626
1627 run_auth_command_with_secrets(
1628 &mut store,
1629 AuthCommand::Migrate { dry_run: false },
1630 &secrets,
1631 )
1632 .expect("migrate should succeed");
1633
1634 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
1635 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
1636 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
1637
1638 assert!(store.config.api_key.is_none());
1640 assert!(store.config.providers.deepseek.api_key.is_none());
1641 assert!(store.config.providers.openrouter.api_key.is_none());
1642 assert!(store.config.providers.novita.api_key.is_none());
1643
1644 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
1645 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
1646 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
1647 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
1648
1649 let _ = std::fs::remove_file(path);
1650 }
1651
1652 #[test]
1653 fn auth_migrate_dry_run_does_not_modify_anything() {
1654 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
1655 use std::sync::Arc;
1656
1657 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1658 let path = std::env::temp_dir().join(format!(
1659 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
1660 std::process::id()
1661 ));
1662 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1663 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
1664 store.save().unwrap();
1665
1666 let inner = Arc::new(InMemoryKeyringStore::new());
1667 let secrets = Secrets::new(inner.clone());
1668
1669 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
1670 .expect("dry-run should succeed");
1671
1672 assert_eq!(inner.get("openrouter").unwrap(), None);
1673 assert_eq!(
1674 store.config.providers.openrouter.api_key.as_deref(),
1675 Some("or-stay")
1676 );
1677
1678 let _ = std::fs::remove_file(path);
1679 }
1680
1681 #[test]
1682 fn parses_global_override_flags() {
1683 let cli = parse_ok(&[
1684 "deepseek",
1685 "--provider",
1686 "openai",
1687 "--config",
1688 "/tmp/deepseek.toml",
1689 "--profile",
1690 "work",
1691 "--model",
1692 "gpt-4.1",
1693 "--output-mode",
1694 "json",
1695 "--log-level",
1696 "debug",
1697 "--telemetry",
1698 "true",
1699 "--approval-policy",
1700 "on-request",
1701 "--sandbox-mode",
1702 "workspace-write",
1703 "--base-url",
1704 "https://api.openai.com/v1",
1705 "--api-key",
1706 "sk-test",
1707 "--no-alt-screen",
1708 "--no-mouse-capture",
1709 "--skip-onboarding",
1710 "model",
1711 "resolve",
1712 "gpt-4.1",
1713 ]);
1714
1715 assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
1716 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
1717 assert_eq!(cli.profile.as_deref(), Some("work"));
1718 assert_eq!(cli.model.as_deref(), Some("gpt-4.1"));
1719 assert_eq!(cli.output_mode.as_deref(), Some("json"));
1720 assert_eq!(cli.log_level.as_deref(), Some("debug"));
1721 assert_eq!(cli.telemetry, Some(true));
1722 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
1723 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
1724 assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1"));
1725 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
1726 assert!(cli.no_alt_screen);
1727 assert!(cli.no_mouse_capture);
1728 assert!(!cli.mouse_capture);
1729 assert!(cli.skip_onboarding);
1730 }
1731
1732 #[test]
1733 fn parses_top_level_prompt_flag_for_canonical_one_shot() {
1734 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
1735
1736 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
1737 assert_eq!(cli.prompt, None);
1738 }
1739
1740 #[test]
1741 fn root_help_surface_contains_expected_subcommands_and_globals() {
1742 let rendered = help_for(&["deepseek", "--help"]);
1743
1744 for token in [
1745 "run",
1746 "doctor",
1747 "models",
1748 "sessions",
1749 "resume",
1750 "setup",
1751 "login",
1752 "logout",
1753 "auth",
1754 "mcp-server",
1755 "config",
1756 "model",
1757 "thread",
1758 "sandbox",
1759 "app-server",
1760 "completion",
1761 "metrics",
1762 "--provider",
1763 "--model",
1764 "--config",
1765 "--profile",
1766 "--output-mode",
1767 "--log-level",
1768 "--telemetry",
1769 "--base-url",
1770 "--api-key",
1771 "--approval-policy",
1772 "--sandbox-mode",
1773 "--no-alt-screen",
1774 "--mouse-capture",
1775 "--no-mouse-capture",
1776 "--skip-onboarding",
1777 "--prompt",
1778 ] {
1779 assert!(
1780 rendered.contains(token),
1781 "expected help to contain token: {token}"
1782 );
1783 }
1784 }
1785
1786 #[test]
1787 fn subcommand_help_surfaces_are_stable() {
1788 let cases = [
1789 ("config", vec!["get", "set", "unset", "list", "path"]),
1790 ("model", vec!["list", "resolve"]),
1791 (
1792 "thread",
1793 vec![
1794 "list",
1795 "read",
1796 "resume",
1797 "fork",
1798 "archive",
1799 "unarchive",
1800 "set-name",
1801 ],
1802 ),
1803 ("sandbox", vec!["check"]),
1804 (
1805 "app-server",
1806 vec!["--host", "--port", "--config", "--stdio"],
1807 ),
1808 ("completion", vec!["<SHELL>", "bash"]),
1809 ("metrics", vec!["--json", "--since"]),
1810 ];
1811
1812 for (subcommand, expected_tokens) in cases {
1813 let argv = ["deepseek", subcommand, "--help"];
1814 let rendered = help_for(&argv);
1815 for token in expected_tokens {
1816 assert!(
1817 rendered.contains(token),
1818 "expected help for `{subcommand}` to include `{token}`"
1819 );
1820 }
1821 }
1822 }
1823
1824 #[test]
1830 fn sibling_tui_candidate_picks_platform_correct_name() {
1831 let dir = tempfile::TempDir::new().expect("tempdir");
1832 let dispatcher = dir
1833 .path()
1834 .join("deepseek")
1835 .with_extension(std::env::consts::EXE_EXTENSION);
1836 std::fs::write(&dispatcher, b"").unwrap();
1838
1839 assert!(sibling_tui_candidate(&dispatcher).is_none());
1841
1842 let target =
1843 dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1844 std::fs::write(&target, b"").unwrap();
1845
1846 let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
1847 assert_eq!(found, target, "primary platform-correct name wins");
1848 }
1849
1850 #[cfg(windows)]
1855 #[test]
1856 fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
1857 let dir = tempfile::TempDir::new().expect("tempdir");
1858 let dispatcher = dir.path().join("deepseek.exe");
1859 std::fs::write(&dispatcher, b"").unwrap();
1860
1861 let suffixless = dispatcher.with_file_name("deepseek-tui");
1863 std::fs::write(&suffixless, b"").unwrap();
1864
1865 let found = sibling_tui_candidate(&dispatcher)
1866 .expect("Windows fallback must locate suffixless deepseek-tui");
1867 assert_eq!(found, suffixless);
1868 }
1869
1870 #[test]
1873 fn locate_sibling_tui_binary_honours_env_override() {
1874 let dir = tempfile::TempDir::new().expect("tempdir");
1875 let custom = dir
1876 .path()
1877 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
1878 std::fs::write(&custom, b"").unwrap();
1879
1880 struct EnvGuard;
1882 impl Drop for EnvGuard {
1883 fn drop(&mut self) {
1884 unsafe { std::env::remove_var("DEEPSEEK_TUI_BIN") };
1888 }
1889 }
1890 let _g = EnvGuard;
1891 unsafe { std::env::set_var("DEEPSEEK_TUI_BIN", &custom) };
1893
1894 let resolved = locate_sibling_tui_binary().expect("override must resolve");
1895 assert_eq!(resolved, custom);
1896 }
1897}