1mod metrics;
2mod update;
3
4use std::io::{self, Read, Write};
5use std::net::SocketAddr;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result, anyhow, bail};
10use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
11use clap_complete::{Shell, generate};
12use deepseek_agent::ModelRegistry;
13use deepseek_app_server::{
14 AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
15};
16use deepseek_config::{
17 CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,
18};
19use deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
20use deepseek_mcp::{McpServerDefinition, run_stdio_server};
21use deepseek_secrets::Secrets;
22use deepseek_state::{StateStore, ThreadListFilters};
23
24#[derive(Debug, Clone, Copy, ValueEnum)]
25enum ProviderArg {
26 Deepseek,
27 NvidiaNim,
28 Openai,
29 Openrouter,
30 Novita,
31 Fireworks,
32 Sglang,
33 Vllm,
34 Ollama,
35}
36
37impl From<ProviderArg> for ProviderKind {
38 fn from(value: ProviderArg) -> Self {
39 match value {
40 ProviderArg::Deepseek => ProviderKind::Deepseek,
41 ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
42 ProviderArg::Openai => ProviderKind::Openai,
43 ProviderArg::Openrouter => ProviderKind::Openrouter,
44 ProviderArg::Novita => ProviderKind::Novita,
45 ProviderArg::Fireworks => ProviderKind::Fireworks,
46 ProviderArg::Sglang => ProviderKind::Sglang,
47 ProviderArg::Vllm => ProviderKind::Vllm,
48 ProviderArg::Ollama => ProviderKind::Ollama,
49 }
50 }
51}
52
53#[derive(Debug, Parser)]
54#[command(
55 name = "deepseek",
56 version,
57 bin_name = "deepseek",
58 override_usage = "deepseek [OPTIONS] [PROMPT]\n deepseek [OPTIONS] <COMMAND> [ARGS]"
59)]
60struct Cli {
61 #[arg(long)]
62 config: Option<PathBuf>,
63 #[arg(long)]
64 profile: Option<String>,
65 #[arg(
66 long,
67 value_enum,
68 help = "Advanced provider selector for non-TUI registry/config commands"
69 )]
70 provider: Option<ProviderArg>,
71 #[arg(long)]
72 model: Option<String>,
73 #[arg(long = "output-mode")]
74 output_mode: Option<String>,
75 #[arg(long = "log-level")]
76 log_level: Option<String>,
77 #[arg(long)]
78 telemetry: Option<bool>,
79 #[arg(long)]
80 approval_policy: Option<String>,
81 #[arg(long)]
82 sandbox_mode: Option<String>,
83 #[arg(long)]
84 api_key: Option<String>,
85 #[arg(long)]
86 base_url: Option<String>,
87 #[arg(long = "no-alt-screen")]
88 no_alt_screen: bool,
89 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
90 mouse_capture: bool,
91 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
92 no_mouse_capture: bool,
93 #[arg(long = "skip-onboarding")]
94 skip_onboarding: bool,
95 #[arg(
96 short = 'p',
97 long = "prompt",
98 value_name = "PROMPT",
99 conflicts_with = "prompt"
100 )]
101 prompt_flag: Option<String>,
102 #[arg(value_name = "PROMPT")]
103 prompt: Option<String>,
104 #[command(subcommand)]
105 command: Option<Commands>,
106}
107
108#[derive(Debug, Subcommand)]
109enum Commands {
110 Run(RunArgs),
112 Doctor(TuiPassthroughArgs),
114 Models(TuiPassthroughArgs),
116 Sessions(TuiPassthroughArgs),
118 Resume(TuiPassthroughArgs),
120 Fork(TuiPassthroughArgs),
122 Init(TuiPassthroughArgs),
124 Setup(TuiPassthroughArgs),
126 Exec(TuiPassthroughArgs),
128 Review(TuiPassthroughArgs),
130 Apply(TuiPassthroughArgs),
132 Eval(TuiPassthroughArgs),
134 Mcp(TuiPassthroughArgs),
136 Features(TuiPassthroughArgs),
138 Serve(TuiPassthroughArgs),
140 Completions(TuiPassthroughArgs),
142 Login(LoginArgs),
144 Logout,
146 Auth(AuthArgs),
148 McpServer,
150 Config(ConfigArgs),
152 Model(ModelArgs),
154 Thread(ThreadArgs),
156 Sandbox(SandboxArgs),
158 AppServer(AppServerArgs),
160 #[command(after_help = r#"Examples:
162 Bash (current shell only):
163 source <(deepseek completion bash)
164
165 Bash (persistent, Linux/bash-completion):
166 mkdir -p ~/.local/share/bash-completion/completions
167 deepseek completion bash > ~/.local/share/bash-completion/completions/deepseek
168 # Requires bash-completion to be installed and loaded by your shell.
169
170 Zsh:
171 mkdir -p ~/.zfunc
172 deepseek completion zsh > ~/.zfunc/_deepseek
173 # Add to ~/.zshrc if needed:
174 # fpath=(~/.zfunc $fpath)
175 # autoload -Uz compinit && compinit
176
177 Fish:
178 mkdir -p ~/.config/fish/completions
179 deepseek completion fish > ~/.config/fish/completions/deepseek.fish
180
181 PowerShell (current shell only):
182 deepseek completion powershell | Out-String | Invoke-Expression
183
184The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
185 Completion {
186 #[arg(value_enum)]
187 shell: Shell,
188 },
189 Metrics(MetricsArgs),
191 Update,
193}
194
195#[derive(Debug, Args)]
196struct MetricsArgs {
197 #[arg(long)]
199 json: bool,
200 #[arg(long, value_name = "DURATION")]
202 since: Option<String>,
203}
204
205#[derive(Debug, Args)]
206struct RunArgs {
207 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
208 args: Vec<String>,
209}
210
211#[derive(Debug, Args, Clone)]
212struct TuiPassthroughArgs {
213 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
214 args: Vec<String>,
215}
216
217#[derive(Debug, Args)]
218struct LoginArgs {
219 #[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]
220 provider: ProviderArg,
221 #[arg(long)]
222 api_key: Option<String>,
223 #[arg(long, default_value_t = false, hide = true)]
224 chatgpt: bool,
225 #[arg(long, default_value_t = false, hide = true)]
226 device_code: bool,
227 #[arg(long, hide = true)]
228 token: Option<String>,
229}
230
231#[derive(Debug, Args)]
232struct AuthArgs {
233 #[command(subcommand)]
234 command: AuthCommand,
235}
236
237#[derive(Debug, Subcommand)]
238enum AuthCommand {
239 Status,
241 Set {
245 #[arg(long, value_enum)]
246 provider: ProviderArg,
247 #[arg(long)]
249 api_key: Option<String>,
250 #[arg(long = "api-key-stdin", default_value_t = false)]
252 api_key_stdin: bool,
253 },
254 Get {
257 #[arg(long, value_enum)]
258 provider: ProviderArg,
259 },
260 Clear {
262 #[arg(long, value_enum)]
263 provider: ProviderArg,
264 },
265 List,
268 #[command(hide = true)]
270 Migrate {
271 #[arg(long, default_value_t = false)]
273 dry_run: bool,
274 },
275}
276
277#[derive(Debug, Args)]
278struct ConfigArgs {
279 #[command(subcommand)]
280 command: ConfigCommand,
281}
282
283#[derive(Debug, Subcommand)]
284enum ConfigCommand {
285 Get { key: String },
286 Set { key: String, value: String },
287 Unset { key: String },
288 List,
289 Path,
290}
291
292#[derive(Debug, Args)]
293struct ModelArgs {
294 #[command(subcommand)]
295 command: ModelCommand,
296}
297
298#[derive(Debug, Subcommand)]
299enum ModelCommand {
300 List {
301 #[arg(long, value_enum)]
302 provider: Option<ProviderArg>,
303 },
304 Resolve {
305 model: Option<String>,
306 #[arg(long, value_enum)]
307 provider: Option<ProviderArg>,
308 },
309}
310
311#[derive(Debug, Args)]
312struct ThreadArgs {
313 #[command(subcommand)]
314 command: ThreadCommand,
315}
316
317#[derive(Debug, Subcommand)]
318enum ThreadCommand {
319 List {
320 #[arg(long, default_value_t = false)]
321 all: bool,
322 #[arg(long)]
323 limit: Option<usize>,
324 },
325 Read {
326 thread_id: String,
327 },
328 Resume {
329 thread_id: String,
330 },
331 Fork {
332 thread_id: String,
333 },
334 Archive {
335 thread_id: String,
336 },
337 Unarchive {
338 thread_id: String,
339 },
340 SetName {
341 thread_id: String,
342 name: String,
343 },
344}
345
346#[derive(Debug, Args)]
347struct SandboxArgs {
348 #[command(subcommand)]
349 command: SandboxCommand,
350}
351
352#[derive(Debug, Subcommand)]
353enum SandboxCommand {
354 Check {
355 command: String,
356 #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
357 ask: ApprovalModeArg,
358 },
359}
360
361#[derive(Debug, Clone, Copy, ValueEnum)]
362enum ApprovalModeArg {
363 UnlessTrusted,
364 OnFailure,
365 OnRequest,
366 Never,
367}
368
369impl From<ApprovalModeArg> for AskForApproval {
370 fn from(value: ApprovalModeArg) -> Self {
371 match value {
372 ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
373 ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
374 ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
375 ApprovalModeArg::Never => AskForApproval::Never,
376 }
377 }
378}
379
380#[derive(Debug, Args)]
381struct AppServerArgs {
382 #[arg(long, default_value = "127.0.0.1")]
383 host: String,
384 #[arg(long, default_value_t = 8787)]
385 port: u16,
386 #[arg(long)]
387 config: Option<PathBuf>,
388 #[arg(long, default_value_t = false)]
389 stdio: bool,
390}
391
392const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
393
394pub fn run_cli() -> std::process::ExitCode {
395 match run() {
396 Ok(()) => std::process::ExitCode::SUCCESS,
397 Err(err) => {
398 eprintln!("error: {err}");
406 for cause in err.chain().skip(1) {
407 eprintln!(" caused by: {cause}");
408 }
409 std::process::ExitCode::FAILURE
410 }
411 }
412}
413
414fn run() -> Result<()> {
415 let mut cli = Cli::parse();
416
417 let mut store = ConfigStore::load(cli.config.clone())?;
418 let runtime_overrides = CliRuntimeOverrides {
419 provider: cli.provider.map(Into::into),
420 model: cli.model.clone(),
421 api_key: cli.api_key.clone(),
422 base_url: cli.base_url.clone(),
423 auth_mode: None,
424 output_mode: cli.output_mode.clone(),
425 log_level: cli.log_level.clone(),
426 telemetry: cli.telemetry,
427 approval_policy: cli.approval_policy.clone(),
428 sandbox_mode: cli.sandbox_mode.clone(),
429 };
430 let command = cli.command.take();
431
432 match command {
433 Some(Commands::Run(args)) => {
434 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
435 delegate_to_tui(&cli, &resolved_runtime, args.args)
436 }
437 Some(Commands::Doctor(args)) => {
438 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
439 delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
440 }
441 Some(Commands::Models(args)) => {
442 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
443 delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
444 }
445 Some(Commands::Sessions(args)) => {
446 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
447 delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
448 }
449 Some(Commands::Resume(args)) => {
450 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
451 run_resume_command(&cli, &resolved_runtime, args)
452 }
453 Some(Commands::Fork(args)) => {
454 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
455 delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
456 }
457 Some(Commands::Init(args)) => {
458 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
459 delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
460 }
461 Some(Commands::Setup(args)) => {
462 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
463 delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
464 }
465 Some(Commands::Exec(args)) => {
466 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
467 delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
468 }
469 Some(Commands::Review(args)) => {
470 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
471 delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
472 }
473 Some(Commands::Apply(args)) => {
474 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
475 delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
476 }
477 Some(Commands::Eval(args)) => {
478 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
479 delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
480 }
481 Some(Commands::Mcp(args)) => {
482 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
483 delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
484 }
485 Some(Commands::Features(args)) => {
486 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
487 delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
488 }
489 Some(Commands::Serve(args)) => {
490 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
491 delegate_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
492 }
493 Some(Commands::Completions(args)) => {
494 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
495 delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
496 }
497 Some(Commands::Login(args)) => run_login_command(&mut store, args),
498 Some(Commands::Logout) => run_logout_command(&mut store),
499 Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
500 Some(Commands::McpServer) => run_mcp_server_command(&mut store),
501 Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
502 Some(Commands::Model(args)) => run_model_command(args.command),
503 Some(Commands::Thread(args)) => run_thread_command(args.command),
504 Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
505 Some(Commands::AppServer(args)) => run_app_server_command(args),
506 Some(Commands::Completion { shell }) => {
507 let mut cmd = Cli::command();
508 generate(shell, &mut cmd, "deepseek", &mut io::stdout());
509 Ok(())
510 }
511 Some(Commands::Metrics(args)) => run_metrics_command(args),
512 Some(Commands::Update) => update::run_update(),
513 None => {
514 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
515 let mut forwarded = Vec::new();
516 if let Some(prompt) = cli.prompt_flag.clone().or_else(|| cli.prompt.clone()) {
517 forwarded.push("--prompt".to_string());
518 forwarded.push(prompt);
519 }
520 delegate_to_tui(&cli, &resolved_runtime, forwarded)
521 }
522 }
523}
524
525fn resolve_runtime_for_dispatch(
526 store: &mut ConfigStore,
527 runtime_overrides: &CliRuntimeOverrides,
528) -> ResolvedRuntimeOptions {
529 let runtime_secrets = Secrets::auto_detect();
530 resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
531}
532
533fn resolve_runtime_for_dispatch_with_secrets(
534 store: &mut ConfigStore,
535 runtime_overrides: &CliRuntimeOverrides,
536 secrets: &Secrets,
537) -> ResolvedRuntimeOptions {
538 let mut resolved = store
539 .config
540 .resolve_runtime_options_with_secrets(runtime_overrides, secrets);
541
542 if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)
543 && !provider_config_set(store, resolved.provider)
544 && let Some(api_key) = resolved.api_key.clone()
545 {
546 write_provider_api_key_to_config(store, resolved.provider, &api_key);
547 match store.save() {
548 Ok(()) => {
549 eprintln!(
550 "info: recovered API key from OS keyring and saved it to {}",
551 store.path().display()
552 );
553 resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);
554 }
555 Err(err) => {
556 eprintln!(
557 "warning: recovered API key from OS keyring but failed to save {}: {err}",
558 store.path().display()
559 );
560 }
561 }
562 }
563
564 resolved
565}
566
567fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
568 let mut forwarded = Vec::with_capacity(args.args.len() + 1);
569 forwarded.push(command.to_string());
570 forwarded.extend(args.args);
571 forwarded
572}
573
574fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
575 run_login_command_with_secrets(store, args, &Secrets::auto_detect())
576}
577
578fn run_login_command_with_secrets(
579 store: &mut ConfigStore,
580 args: LoginArgs,
581 secrets: &Secrets,
582) -> Result<()> {
583 let provider: ProviderKind = args.provider.into();
584 store.config.provider = provider;
585
586 if args.chatgpt {
587 let token = match args.token {
588 Some(token) => token,
589 None => read_api_key_from_stdin()?,
590 };
591 store.config.auth_mode = Some("chatgpt".to_string());
592 store.config.chatgpt_access_token = Some(token);
593 store.config.device_code_session = None;
594 store.save()?;
595 println!("logged in using chatgpt token mode ({})", provider.as_str());
596 return Ok(());
597 }
598
599 if args.device_code {
600 let token = match args.token {
601 Some(token) => token,
602 None => read_api_key_from_stdin()?,
603 };
604 store.config.auth_mode = Some("device_code".to_string());
605 store.config.device_code_session = Some(token);
606 store.config.chatgpt_access_token = None;
607 store.save()?;
608 println!(
609 "logged in using device code session mode ({})",
610 provider.as_str()
611 );
612 return Ok(());
613 }
614
615 let api_key = match args.api_key {
616 Some(v) => v,
617 None => read_api_key_from_stdin()?,
618 };
619 write_provider_api_key_to_config(store, provider, &api_key);
620 let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
621 store.save()?;
622 let destination = if keyring_saved {
623 format!("{} and {}", store.path().display(), secrets.backend_name())
624 } else {
625 store.path().display().to_string()
626 };
627 if provider == ProviderKind::Deepseek {
628 println!("logged in using API key mode (deepseek); saved key to {destination}");
629 } else {
630 println!(
631 "logged in using API key mode ({}); saved key to {destination}",
632 provider.as_str(),
633 );
634 }
635 Ok(())
636}
637
638fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
639 run_logout_command_with_secrets(store, &Secrets::auto_detect())
640}
641
642fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
643 let active_provider = store.config.provider;
644 store.config.api_key = None;
645 for provider in PROVIDER_LIST {
646 clear_provider_api_key_from_config(store, provider);
647 }
648 clear_provider_api_key_from_keyring(secrets, active_provider);
649 store.config.auth_mode = None;
650 store.config.chatgpt_access_token = None;
651 store.config.device_code_session = None;
652 store.save()?;
653 println!("logged out");
654 Ok(())
655}
656
657fn provider_slot(provider: ProviderKind) -> &'static str {
659 match provider {
660 ProviderKind::Deepseek => "deepseek",
661 ProviderKind::NvidiaNim => "nvidia-nim",
662 ProviderKind::Openai => "openai",
663 ProviderKind::Openrouter => "openrouter",
664 ProviderKind::Novita => "novita",
665 ProviderKind::Fireworks => "fireworks",
666 ProviderKind::Sglang => "sglang",
667 ProviderKind::Vllm => "vllm",
668 ProviderKind::Ollama => "ollama",
669 }
670}
671
672const PROVIDER_LIST: [ProviderKind; 9] = [
674 ProviderKind::Deepseek,
675 ProviderKind::NvidiaNim,
676 ProviderKind::Openrouter,
677 ProviderKind::Novita,
678 ProviderKind::Fireworks,
679 ProviderKind::Sglang,
680 ProviderKind::Vllm,
681 ProviderKind::Ollama,
682 ProviderKind::Openai,
683];
684
685#[cfg(test)]
686fn no_keyring_secrets() -> Secrets {
687 Secrets::new(std::sync::Arc::new(
688 deepseek_secrets::InMemoryKeyringStore::new(),
689 ))
690}
691
692fn write_provider_api_key_to_config(
693 store: &mut ConfigStore,
694 provider: ProviderKind,
695 api_key: &str,
696) {
697 store.config.provider = provider;
698 store.config.auth_mode = Some("api_key".to_string());
699 store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
700 if provider == ProviderKind::Deepseek {
701 store.config.api_key = Some(api_key.to_string());
702 if store.config.default_text_model.is_none() {
703 store.config.default_text_model = Some(
704 store
705 .config
706 .providers
707 .deepseek
708 .model
709 .clone()
710 .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
711 );
712 }
713 }
714}
715
716fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
717 store.config.providers.for_provider_mut(provider).api_key = None;
718 if provider == ProviderKind::Deepseek {
719 store.config.api_key = None;
720 }
721}
722
723fn provider_env_set(provider: ProviderKind) -> bool {
724 deepseek_secrets::env_for(provider_slot(provider)).is_some()
725}
726
727fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
728 let slot = store
729 .config
730 .providers
731 .for_provider(provider)
732 .api_key
733 .as_ref();
734 let root = (provider == ProviderKind::Deepseek)
735 .then_some(store.config.api_key.as_ref())
736 .flatten();
737 slot.or(root).is_some_and(|v| !v.trim().is_empty())
738}
739
740fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
741 secrets
742 .get(provider_slot(provider))
743 .ok()
744 .flatten()
745 .is_some_and(|v| !v.trim().is_empty())
746}
747
748fn write_provider_api_key_to_keyring(
749 secrets: &Secrets,
750 provider: ProviderKind,
751 api_key: &str,
752) -> bool {
753 secrets.set(provider_slot(provider), api_key).is_ok()
754}
755
756fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
757 let _ = secrets.delete(provider_slot(provider));
758}
759
760fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
761 run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
762}
763
764fn run_auth_command_with_secrets(
765 store: &mut ConfigStore,
766 command: AuthCommand,
767 secrets: &Secrets,
768) -> Result<()> {
769 match command {
770 AuthCommand::Status => {
771 let provider = store.config.provider;
772 println!("provider: {}", provider.as_str());
773 println!("credential precedence: config -> keyring -> env");
774 let slot = provider_slot(provider);
775 let file_set = provider_config_set(store, provider);
776 let keyring_set = (!file_set).then(|| provider_keyring_set(secrets, provider));
777 let env_set = provider_env_set(provider);
778 let active = if file_set {
779 "config"
780 } else if keyring_set == Some(true) {
781 "keyring"
782 } else if env_set {
783 "env"
784 } else {
785 "missing"
786 };
787 println!(
788 "{slot} auth: config={}, keyring={}, env={}, active={active}",
789 file_set,
790 keyring_status_short(keyring_set),
791 env_set
792 );
793 Ok(())
794 }
795 AuthCommand::Set {
796 provider,
797 api_key,
798 api_key_stdin,
799 } => {
800 let provider: ProviderKind = provider.into();
801 let slot = provider_slot(provider);
802 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
803 store.config.provider = provider;
804 let provider_cfg = store.config.providers.for_provider_mut(provider);
805 if provider_cfg.base_url.is_none() {
806 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
807 }
808 store.save()?;
809 println!(
810 "configured {slot} provider in {} (API key optional)",
811 store.path().display()
812 );
813 return Ok(());
814 }
815 let api_key = match (api_key, api_key_stdin) {
816 (Some(v), _) => v,
817 (None, true) => read_api_key_from_stdin()?,
818 (None, false) => prompt_api_key(slot)?,
819 };
820 write_provider_api_key_to_config(store, provider, &api_key);
821 let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
822 store.save()?;
823 if keyring_saved {
825 println!(
826 "saved API key for {slot} to {} and {}",
827 store.path().display(),
828 secrets.backend_name()
829 );
830 } else {
831 println!("saved API key for {slot} to {}", store.path().display());
832 }
833 Ok(())
834 }
835 AuthCommand::Get { provider } => {
836 let provider: ProviderKind = provider.into();
837 let slot = provider_slot(provider);
838 let in_file = provider_config_set(store, provider);
839 let in_keyring = !in_file && provider_keyring_set(secrets, provider);
840 let in_env = provider_env_set(provider);
841 let source = if in_file {
843 Some("config-file")
844 } else if in_keyring {
845 Some("keyring")
846 } else if in_env {
847 Some("env")
848 } else {
849 None
850 };
851 match source {
852 Some(source) => println!("{slot}: set (source: {source})"),
853 None => println!("{slot}: not set"),
854 }
855 Ok(())
856 }
857 AuthCommand::Clear { provider } => {
858 let provider: ProviderKind = provider.into();
859 let slot = provider_slot(provider);
860 clear_provider_api_key_from_config(store, provider);
861 clear_provider_api_key_from_keyring(secrets, provider);
862 store.save()?;
863 println!("cleared API key for {slot} from config and keyring");
864 Ok(())
865 }
866 AuthCommand::List => {
867 println!("provider config keyring env active");
868 let active_provider = store.config.provider;
869 for provider in PROVIDER_LIST {
870 let slot = provider_slot(provider);
871 let file = provider_config_set(store, provider);
872 let keyring = (provider == active_provider && !file)
873 .then(|| provider_keyring_set(secrets, provider));
874 let env = provider_env_set(provider);
875 let active = if file {
876 "config"
877 } else if keyring == Some(true) {
878 "keyring"
879 } else if env {
880 "env"
881 } else {
882 "missing"
883 };
884 println!(
885 "{slot:<12} {} {} {} {active}",
886 yes_no(file),
887 keyring_status_short(keyring),
888 yes_no(env)
889 );
890 }
891 Ok(())
892 }
893 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
894 }
895}
896
897fn yes_no(b: bool) -> &'static str {
898 if b { "yes" } else { "no " }
899}
900
901fn keyring_status_short(state: Option<bool>) -> &'static str {
902 match state {
903 Some(true) => "yes",
904 Some(false) => "no ",
905 None => "n/a",
906 }
907}
908
909fn prompt_api_key(slot: &str) -> Result<String> {
910 use std::io::{IsTerminal, Write};
911 eprint!("Enter API key for {slot}: ");
912 io::stderr().flush().ok();
913 if !io::stdin().is_terminal() {
914 return read_api_key_from_stdin();
916 }
917 let mut buf = String::new();
918 io::stdin()
919 .read_line(&mut buf)
920 .context("failed to read API key from stdin")?;
921 let key = buf.trim().to_string();
922 if key.is_empty() {
923 bail!("empty API key provided");
924 }
925 Ok(key)
926}
927
928fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
931 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
932 let mut warnings: Vec<String> = Vec::new();
933
934 for provider in PROVIDER_LIST {
935 let slot = provider_slot(provider);
936 let from_provider_block = store
937 .config
938 .providers
939 .for_provider(provider)
940 .api_key
941 .clone()
942 .filter(|v| !v.trim().is_empty());
943 let from_root = (provider == ProviderKind::Deepseek)
944 .then(|| store.config.api_key.clone())
945 .flatten()
946 .filter(|v| !v.trim().is_empty());
947 let value = from_provider_block.or(from_root);
948 let Some(value) = value else { continue };
949
950 if let Ok(Some(existing)) = secrets.get(slot)
951 && existing == value
952 {
953 } else if dry_run {
955 migrated.push((provider, slot));
956 continue;
957 } else if let Err(err) = secrets.set(slot, &value) {
958 warnings.push(format!("skipped {slot}: failed to write to keyring: {err}"));
959 continue;
960 }
961 if !dry_run {
962 store.config.providers.for_provider_mut(provider).api_key = None;
963 if provider == ProviderKind::Deepseek {
964 store.config.api_key = None;
965 }
966 }
967 migrated.push((provider, slot));
968 }
969
970 if !dry_run && !migrated.is_empty() {
971 store
972 .save()
973 .context("failed to write updated config.toml")?;
974 }
975
976 println!("keyring backend: {}", secrets.backend_name());
977 if migrated.is_empty() {
978 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
979 } else {
980 println!(
981 "{} {} provider key(s):",
982 if dry_run { "would migrate" } else { "migrated" },
983 migrated.len()
984 );
985 for (_, slot) in &migrated {
986 println!(" - {slot}");
987 }
988 if !dry_run {
989 println!(
990 "config.toml at {} no longer contains api_key entries for migrated providers.",
991 store.path().display()
992 );
993 }
994 }
995 for w in warnings {
996 eprintln!("warning: {w}");
997 }
998 Ok(())
999}
1000
1001fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
1002 match command {
1003 ConfigCommand::Get { key } => {
1004 if let Some(value) = store.config.get_value(&key) {
1005 println!("{value}");
1006 return Ok(());
1007 }
1008 bail!("key not found: {key}");
1009 }
1010 ConfigCommand::Set { key, value } => {
1011 store.config.set_value(&key, &value)?;
1012 store.save()?;
1013 println!("set {key}");
1014 Ok(())
1015 }
1016 ConfigCommand::Unset { key } => {
1017 store.config.unset_value(&key)?;
1018 store.save()?;
1019 println!("unset {key}");
1020 Ok(())
1021 }
1022 ConfigCommand::List => {
1023 for (key, value) in store.config.list_values() {
1024 println!("{key} = {value}");
1025 }
1026 Ok(())
1027 }
1028 ConfigCommand::Path => {
1029 println!("{}", store.path().display());
1030 Ok(())
1031 }
1032 }
1033}
1034
1035fn run_model_command(command: ModelCommand) -> Result<()> {
1036 let registry = ModelRegistry::default();
1037 match command {
1038 ModelCommand::List { provider } => {
1039 let filter = provider.map(ProviderKind::from);
1040 for model in registry.list().into_iter().filter(|m| match filter {
1041 Some(p) => m.provider == p,
1042 None => true,
1043 }) {
1044 println!("{} ({})", model.id, model.provider.as_str());
1045 }
1046 Ok(())
1047 }
1048 ModelCommand::Resolve { model, provider } => {
1049 let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));
1050 println!("requested: {}", resolved.requested.unwrap_or_default());
1051 println!("resolved: {}", resolved.resolved.id);
1052 println!("provider: {}", resolved.resolved.provider.as_str());
1053 println!("used_fallback: {}", resolved.used_fallback);
1054 Ok(())
1055 }
1056 }
1057}
1058
1059fn run_thread_command(command: ThreadCommand) -> Result<()> {
1060 let state = StateStore::open(None)?;
1061 match command {
1062 ThreadCommand::List { all, limit } => {
1063 let threads = state.list_threads(ThreadListFilters {
1064 include_archived: all,
1065 limit,
1066 })?;
1067 for thread in threads {
1068 println!(
1069 "{} | {} | {} | {}",
1070 thread.id,
1071 thread
1072 .name
1073 .clone()
1074 .unwrap_or_else(|| "(unnamed)".to_string()),
1075 thread.model_provider,
1076 thread.cwd.display()
1077 );
1078 }
1079 Ok(())
1080 }
1081 ThreadCommand::Read { thread_id } => {
1082 let thread = state.get_thread(&thread_id)?;
1083 println!("{}", serde_json::to_string_pretty(&thread)?);
1084 Ok(())
1085 }
1086 ThreadCommand::Resume { thread_id } => {
1087 let args = vec!["resume".to_string(), thread_id];
1088 delegate_simple_tui(args)
1089 }
1090 ThreadCommand::Fork { thread_id } => {
1091 let args = vec!["fork".to_string(), thread_id];
1092 delegate_simple_tui(args)
1093 }
1094 ThreadCommand::Archive { thread_id } => {
1095 state.mark_archived(&thread_id)?;
1096 println!("archived {thread_id}");
1097 Ok(())
1098 }
1099 ThreadCommand::Unarchive { thread_id } => {
1100 state.mark_unarchived(&thread_id)?;
1101 println!("unarchived {thread_id}");
1102 Ok(())
1103 }
1104 ThreadCommand::SetName { thread_id, name } => {
1105 let mut thread = state
1106 .get_thread(&thread_id)?
1107 .with_context(|| format!("thread not found: {thread_id}"))?;
1108 thread.name = Some(name);
1109 thread.updated_at = chrono::Utc::now().timestamp();
1110 state.upsert_thread(&thread)?;
1111 println!("renamed {thread_id}");
1112 Ok(())
1113 }
1114 }
1115}
1116
1117fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
1118 match command {
1119 SandboxCommand::Check { command, ask } => {
1120 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
1121 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1122 let decision = engine.check(ExecPolicyContext {
1123 command: &command,
1124 cwd: &cwd.display().to_string(),
1125 ask_for_approval: ask.into(),
1126 sandbox_mode: Some("workspace-write"),
1127 })?;
1128 println!("{}", serde_json::to_string_pretty(&decision)?);
1129 Ok(())
1130 }
1131 }
1132}
1133
1134fn run_app_server_command(args: AppServerArgs) -> Result<()> {
1135 let runtime = tokio::runtime::Builder::new_multi_thread()
1136 .enable_all()
1137 .build()
1138 .context("failed to create tokio runtime")?;
1139 if args.stdio {
1140 return runtime.block_on(run_app_server_stdio(args.config));
1141 }
1142 let listen: SocketAddr = format!("{}:{}", args.host, args.port)
1143 .parse()
1144 .with_context(|| {
1145 format!(
1146 "invalid app-server listen address {}:{}",
1147 args.host, args.port
1148 )
1149 })?;
1150 runtime.block_on(run_app_server(AppServerOptions {
1151 listen,
1152 config_path: args.config,
1153 }))
1154}
1155
1156fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
1157 let persisted = load_mcp_server_definitions(store);
1158 let updated = run_stdio_server(persisted)?;
1159 persist_mcp_server_definitions(store, &updated)
1160}
1161
1162fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
1163 let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
1164 return Vec::new();
1165 };
1166
1167 match parse_mcp_server_definitions(&raw) {
1168 Ok(definitions) => definitions,
1169 Err(err) => {
1170 eprintln!(
1171 "warning: failed to parse persisted MCP server definitions ({}): {}",
1172 MCP_SERVER_DEFINITIONS_KEY, err
1173 );
1174 Vec::new()
1175 }
1176 }
1177}
1178
1179fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
1180 if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
1181 return Ok(parsed);
1182 }
1183
1184 let unwrapped: String = serde_json::from_str(raw)
1185 .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
1186 serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
1187 format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
1188 })
1189}
1190
1191fn persist_mcp_server_definitions(
1192 store: &mut ConfigStore,
1193 definitions: &[McpServerDefinition],
1194) -> Result<()> {
1195 let encoded =
1196 serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
1197 store
1198 .config
1199 .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
1200 store.save()
1201}
1202
1203fn delegate_to_tui(
1204 cli: &Cli,
1205 resolved_runtime: &ResolvedRuntimeOptions,
1206 passthrough: Vec<String>,
1207) -> Result<()> {
1208 let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
1209 let tui = PathBuf::from(cmd.get_program());
1210 let status = cmd
1211 .status()
1212 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1213 exit_with_tui_status(status)
1214}
1215
1216fn run_resume_command(
1217 cli: &Cli,
1218 resolved_runtime: &ResolvedRuntimeOptions,
1219 args: TuiPassthroughArgs,
1220) -> Result<()> {
1221 let passthrough = tui_args("resume", args);
1222 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
1223 return run_dispatcher_resume_picker(cli, resolved_runtime);
1224 }
1225 delegate_to_tui(cli, resolved_runtime, passthrough)
1226}
1227
1228fn run_dispatcher_resume_picker(
1229 cli: &Cli,
1230 resolved_runtime: &ResolvedRuntimeOptions,
1231) -> Result<()> {
1232 let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
1233 let tui = PathBuf::from(sessions_cmd.get_program());
1234 let status = sessions_cmd
1235 .status()
1236 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1237 if !status.success() {
1238 return exit_with_tui_status(status);
1239 }
1240
1241 println!();
1242 println!("Windows note: enter a session id or prefix from the list above.");
1243 println!("You can also run `deepseek resume --last` to skip this prompt.");
1244 print!("Session id/prefix (Enter to cancel): ");
1245 io::stdout().flush()?;
1246
1247 let mut input = String::new();
1248 io::stdin()
1249 .read_line(&mut input)
1250 .context("failed to read session selection")?;
1251 let session_id = input.trim();
1252 if session_id.is_empty() {
1253 bail!("No session selected.");
1254 }
1255
1256 delegate_to_tui(
1257 cli,
1258 resolved_runtime,
1259 vec!["resume".to_string(), session_id.to_string()],
1260 )
1261}
1262
1263fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
1264 is_windows && passthrough == ["resume"]
1265}
1266
1267fn build_tui_command(
1268 cli: &Cli,
1269 resolved_runtime: &ResolvedRuntimeOptions,
1270 passthrough: Vec<String>,
1271) -> Result<Command> {
1272 let tui = locate_sibling_tui_binary()?;
1273
1274 let mut cmd = Command::new(&tui);
1275 if let Some(config) = cli.config.as_ref() {
1276 cmd.arg("--config").arg(config);
1277 }
1278 if let Some(profile) = cli.profile.as_ref() {
1279 cmd.arg("--profile").arg(profile);
1280 }
1281 if cli.no_alt_screen {
1282 cmd.arg("--no-alt-screen");
1283 }
1284 if cli.mouse_capture {
1285 cmd.arg("--mouse-capture");
1286 }
1287 if cli.no_mouse_capture {
1288 cmd.arg("--no-mouse-capture");
1289 }
1290 if cli.skip_onboarding {
1291 cmd.arg("--skip-onboarding");
1292 }
1293 cmd.args(passthrough);
1294
1295 if !matches!(
1296 resolved_runtime.provider,
1297 ProviderKind::Deepseek
1298 | ProviderKind::NvidiaNim
1299 | ProviderKind::Openrouter
1300 | ProviderKind::Novita
1301 | ProviderKind::Fireworks
1302 | ProviderKind::Sglang
1303 | ProviderKind::Vllm
1304 | ProviderKind::Ollama
1305 ) {
1306 bail!(
1307 "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.",
1308 resolved_runtime.provider.as_str()
1309 );
1310 }
1311
1312 cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model);
1313 cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url);
1314 cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str());
1315 if !resolved_runtime.http_headers.is_empty() {
1316 let encoded = resolved_runtime
1317 .http_headers
1318 .iter()
1319 .map(|(name, value)| format!("{}={}", name.trim(), value.trim()))
1320 .collect::<Vec<_>>()
1321 .join(",");
1322 cmd.env("DEEPSEEK_HTTP_HEADERS", encoded);
1323 }
1324 if let Some(api_key) = resolved_runtime.api_key.as_ref() {
1325 cmd.env("DEEPSEEK_API_KEY", api_key);
1326 let source = resolved_runtime
1327 .api_key_source
1328 .unwrap_or(RuntimeApiKeySource::Env)
1329 .as_env_value();
1330 cmd.env("DEEPSEEK_API_KEY_SOURCE", source);
1331 }
1332
1333 if let Some(model) = cli.model.as_ref() {
1334 cmd.env("DEEPSEEK_MODEL", model);
1335 }
1336 if let Some(output_mode) = cli.output_mode.as_ref() {
1337 cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
1338 }
1339 if let Some(log_level) = cli.log_level.as_ref() {
1340 cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
1341 }
1342 if let Some(telemetry) = cli.telemetry {
1343 cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
1344 }
1345 if let Some(policy) = cli.approval_policy.as_ref() {
1346 cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
1347 }
1348 if let Some(mode) = cli.sandbox_mode.as_ref() {
1349 cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
1350 }
1351 if let Some(api_key) = cli.api_key.as_ref() {
1352 cmd.env("DEEPSEEK_API_KEY", api_key);
1353 cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
1354 }
1355 if let Some(base_url) = cli.base_url.as_ref() {
1356 cmd.env("DEEPSEEK_BASE_URL", base_url);
1357 }
1358
1359 Ok(cmd)
1360}
1361
1362fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
1363 match status.code() {
1364 Some(code) => std::process::exit(code),
1365 None => bail!("deepseek-tui terminated by signal"),
1366 }
1367}
1368
1369fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
1370 let tui = locate_sibling_tui_binary()?;
1371 let status = Command::new(&tui)
1372 .args(args)
1373 .status()
1374 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1375 match status.code() {
1376 Some(code) => std::process::exit(code),
1377 None => bail!("deepseek-tui terminated by signal"),
1378 }
1379}
1380
1381fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
1382 format!(
1383 "failed to spawn companion TUI binary at {}: {err}\n\
1384\n\
1385The `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \
1386to execute it. Common fixes:\n\
1387 - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\n\
1388 - On Windows, run `where deepseek` and `where deepseek-tui`; both should \
1389come from the same install directory.\n\
1390 - If you downloaded release assets manually, keep both `deepseek` and \
1391`deepseek-tui` binaries together and make sure the TUI binary is executable.\n\
1392 - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \
1393binary.",
1394 tui.display()
1395 )
1396}
1397
1398fn locate_sibling_tui_binary() -> Result<PathBuf> {
1408 if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
1409 let candidate = PathBuf::from(override_path);
1410 if candidate.is_file() {
1411 return Ok(candidate);
1412 }
1413 bail!(
1414 "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
1415 candidate.display()
1416 );
1417 }
1418
1419 let current = std::env::current_exe().context("failed to locate current executable path")?;
1420 if let Some(found) = sibling_tui_candidate(¤t) {
1421 return Ok(found);
1422 }
1423
1424 let expected = current.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1427 bail!(
1428 "Companion `deepseek-tui` binary not found at {}.\n\
1429\n\
1430The `deepseek` dispatcher delegates interactive sessions to a sibling \
1431`deepseek-tui` binary. To fix this, install one of:\n\
1432 • npm: npm install -g deepseek-tui (downloads both binaries)\n\
1433 • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\n\
1434 • GitHub Releases: download BOTH `deepseek-<platform>` AND \
1435`deepseek-tui-<platform>` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \
1436and place them in the same directory.\n\
1437\n\
1438Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.",
1439 expected.display()
1440 );
1441}
1442
1443fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
1447 let primary =
1450 dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1451 if primary.is_file() {
1452 return Some(primary);
1453 }
1454 if cfg!(windows) {
1457 let suffixless = dispatcher.with_file_name("deepseek-tui");
1458 if suffixless.is_file() {
1459 return Some(suffixless);
1460 }
1461 }
1462 None
1463}
1464
1465fn run_metrics_command(args: MetricsArgs) -> Result<()> {
1466 let since = match args.since.as_deref() {
1467 Some(s) => {
1468 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
1469 }
1470 None => None,
1471 };
1472 metrics::run(metrics::MetricsArgs {
1473 json: args.json,
1474 since,
1475 })
1476}
1477
1478fn read_api_key_from_stdin() -> Result<String> {
1479 let mut input = String::new();
1480 io::stdin()
1481 .read_to_string(&mut input)
1482 .context("failed to read api key from stdin")?;
1483 let key = input.trim().to_string();
1484 if key.is_empty() {
1485 bail!("empty API key provided");
1486 }
1487 Ok(key)
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492 use super::*;
1493 use clap::error::ErrorKind;
1494
1495 fn parse_ok(argv: &[&str]) -> Cli {
1496 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
1497 }
1498
1499 fn help_for(argv: &[&str]) -> String {
1500 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
1501 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
1502 err.to_string()
1503 }
1504
1505 #[test]
1506 fn clap_command_definition_is_consistent() {
1507 Cli::command().debug_assert();
1508 }
1509
1510 #[test]
1517 fn anyhow_chain_surfaces_toml_parse_cause() {
1518 use anyhow::Context;
1519 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
1520 let err = Err::<(), _>(inner)
1521 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
1522 .unwrap_err();
1523
1524 assert_eq!(
1526 err.to_string(),
1527 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
1528 );
1529
1530 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
1532 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
1533 }
1534
1535 #[test]
1536 fn parses_config_command_matrix() {
1537 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
1538 assert!(matches!(
1539 cli.command,
1540 Some(Commands::Config(ConfigArgs {
1541 command: ConfigCommand::Get { ref key }
1542 })) if key == "provider"
1543 ));
1544
1545 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
1546 assert!(matches!(
1547 cli.command,
1548 Some(Commands::Config(ConfigArgs {
1549 command: ConfigCommand::Set { ref key, ref value }
1550 })) if key == "model" && value == "deepseek-v4-flash"
1551 ));
1552
1553 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
1554 assert!(matches!(
1555 cli.command,
1556 Some(Commands::Config(ConfigArgs {
1557 command: ConfigCommand::Unset { ref key }
1558 })) if key == "model"
1559 ));
1560
1561 assert!(matches!(
1562 parse_ok(&["deepseek", "config", "list"]).command,
1563 Some(Commands::Config(ConfigArgs {
1564 command: ConfigCommand::List
1565 }))
1566 ));
1567 assert!(matches!(
1568 parse_ok(&["deepseek", "config", "path"]).command,
1569 Some(Commands::Config(ConfigArgs {
1570 command: ConfigCommand::Path
1571 }))
1572 ));
1573 }
1574
1575 #[test]
1576 fn parses_model_command_matrix() {
1577 let cli = parse_ok(&["deepseek", "model", "list"]);
1578 assert!(matches!(
1579 cli.command,
1580 Some(Commands::Model(ModelArgs {
1581 command: ModelCommand::List { provider: None }
1582 }))
1583 ));
1584
1585 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
1586 assert!(matches!(
1587 cli.command,
1588 Some(Commands::Model(ModelArgs {
1589 command: ModelCommand::List {
1590 provider: Some(ProviderArg::Openai)
1591 }
1592 }))
1593 ));
1594
1595 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
1596 assert!(matches!(
1597 cli.command,
1598 Some(Commands::Model(ModelArgs {
1599 command: ModelCommand::Resolve {
1600 model: Some(ref model),
1601 provider: None
1602 }
1603 })) if model == "deepseek-v4-flash"
1604 ));
1605
1606 let cli = parse_ok(&[
1607 "deepseek",
1608 "model",
1609 "resolve",
1610 "--provider",
1611 "deepseek",
1612 "deepseek-v4-pro",
1613 ]);
1614 assert!(matches!(
1615 cli.command,
1616 Some(Commands::Model(ModelArgs {
1617 command: ModelCommand::Resolve {
1618 model: Some(ref model),
1619 provider: Some(ProviderArg::Deepseek)
1620 }
1621 })) if model == "deepseek-v4-pro"
1622 ));
1623 }
1624
1625 #[test]
1626 fn parses_thread_command_matrix() {
1627 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
1628 assert!(matches!(
1629 cli.command,
1630 Some(Commands::Thread(ThreadArgs {
1631 command: ThreadCommand::List {
1632 all: true,
1633 limit: Some(50)
1634 }
1635 }))
1636 ));
1637
1638 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
1639 assert!(matches!(
1640 cli.command,
1641 Some(Commands::Thread(ThreadArgs {
1642 command: ThreadCommand::Read { ref thread_id }
1643 })) if thread_id == "thread-1"
1644 ));
1645
1646 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
1647 assert!(matches!(
1648 cli.command,
1649 Some(Commands::Thread(ThreadArgs {
1650 command: ThreadCommand::Resume { ref thread_id }
1651 })) if thread_id == "thread-2"
1652 ));
1653
1654 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
1655 assert!(matches!(
1656 cli.command,
1657 Some(Commands::Thread(ThreadArgs {
1658 command: ThreadCommand::Fork { ref thread_id }
1659 })) if thread_id == "thread-3"
1660 ));
1661
1662 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
1663 assert!(matches!(
1664 cli.command,
1665 Some(Commands::Thread(ThreadArgs {
1666 command: ThreadCommand::Archive { ref thread_id }
1667 })) if thread_id == "thread-4"
1668 ));
1669
1670 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
1671 assert!(matches!(
1672 cli.command,
1673 Some(Commands::Thread(ThreadArgs {
1674 command: ThreadCommand::Unarchive { ref thread_id }
1675 })) if thread_id == "thread-5"
1676 ));
1677
1678 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
1679 assert!(matches!(
1680 cli.command,
1681 Some(Commands::Thread(ThreadArgs {
1682 command: ThreadCommand::SetName {
1683 ref thread_id,
1684 ref name
1685 }
1686 })) if thread_id == "thread-6" && name == "My Thread"
1687 ));
1688 }
1689
1690 #[test]
1691 fn parses_sandbox_app_server_and_completion_matrix() {
1692 let cli = parse_ok(&[
1693 "deepseek",
1694 "sandbox",
1695 "check",
1696 "echo hello",
1697 "--ask",
1698 "on-failure",
1699 ]);
1700 assert!(matches!(
1701 cli.command,
1702 Some(Commands::Sandbox(SandboxArgs {
1703 command: SandboxCommand::Check {
1704 ref command,
1705 ask: ApprovalModeArg::OnFailure
1706 }
1707 })) if command == "echo hello"
1708 ));
1709
1710 let cli = parse_ok(&[
1711 "deepseek",
1712 "app-server",
1713 "--host",
1714 "0.0.0.0",
1715 "--port",
1716 "9999",
1717 ]);
1718 assert!(matches!(
1719 cli.command,
1720 Some(Commands::AppServer(AppServerArgs {
1721 ref host,
1722 port: 9999,
1723 stdio: false,
1724 ..
1725 })) if host == "0.0.0.0"
1726 ));
1727
1728 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
1729 assert!(matches!(
1730 cli.command,
1731 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
1732 ));
1733
1734 let cli = parse_ok(&["deepseek", "completion", "bash"]);
1735 assert!(matches!(
1736 cli.command,
1737 Some(Commands::Completion { shell: Shell::Bash })
1738 ));
1739 }
1740
1741 #[test]
1742 fn parses_direct_tui_command_aliases() {
1743 let cli = parse_ok(&["deepseek", "doctor"]);
1744 assert!(matches!(
1745 cli.command,
1746 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
1747 ));
1748
1749 let cli = parse_ok(&["deepseek", "models", "--json"]);
1750 assert!(matches!(
1751 cli.command,
1752 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
1753 ));
1754
1755 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
1756 assert!(matches!(
1757 cli.command,
1758 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
1759 ));
1760
1761 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
1762 assert!(matches!(
1763 cli.command,
1764 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
1765 if args == &["--skills", "--local"]
1766 ));
1767 }
1768
1769 #[test]
1770 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
1771 assert!(should_pick_resume_in_dispatcher(
1772 &["resume".to_string()],
1773 true
1774 ));
1775 assert!(!should_pick_resume_in_dispatcher(
1776 &["resume".to_string(), "--last".to_string()],
1777 true
1778 ));
1779 assert!(!should_pick_resume_in_dispatcher(
1780 &["resume".to_string(), "abc123".to_string()],
1781 true
1782 ));
1783 assert!(!should_pick_resume_in_dispatcher(
1784 &["resume".to_string()],
1785 false
1786 ));
1787 }
1788
1789 #[test]
1790 fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
1791 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1792 let path = std::env::temp_dir().join(format!(
1793 "deepseek-cli-login-test-{}-{nanos}.toml",
1794 std::process::id()
1795 ));
1796 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1797 let secrets = no_keyring_secrets();
1798
1799 run_login_command_with_secrets(
1800 &mut store,
1801 LoginArgs {
1802 provider: ProviderArg::Deepseek,
1803 api_key: Some("sk-test".to_string()),
1804 chatgpt: false,
1805 device_code: false,
1806 token: None,
1807 },
1808 &secrets,
1809 )
1810 .expect("login should write config");
1811
1812 assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
1813 assert_eq!(
1814 store.config.providers.deepseek.api_key.as_deref(),
1815 Some("sk-test")
1816 );
1817 assert_eq!(
1818 store.config.default_text_model.as_deref(),
1819 Some("deepseek-v4-pro")
1820 );
1821 let saved = std::fs::read_to_string(&path).expect("config should be written");
1822 assert!(saved.contains("api_key = \"sk-test\""));
1823 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
1824
1825 let _ = std::fs::remove_file(path);
1826 }
1827
1828 #[test]
1829 fn parses_auth_subcommand_matrix() {
1830 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
1831 assert!(matches!(
1832 cli.command,
1833 Some(Commands::Auth(AuthArgs {
1834 command: AuthCommand::Set {
1835 provider: ProviderArg::Deepseek,
1836 api_key: None,
1837 api_key_stdin: false,
1838 }
1839 }))
1840 ));
1841
1842 let cli = parse_ok(&[
1843 "deepseek",
1844 "auth",
1845 "set",
1846 "--provider",
1847 "openrouter",
1848 "--api-key-stdin",
1849 ]);
1850 assert!(matches!(
1851 cli.command,
1852 Some(Commands::Auth(AuthArgs {
1853 command: AuthCommand::Set {
1854 provider: ProviderArg::Openrouter,
1855 api_key: None,
1856 api_key_stdin: true,
1857 }
1858 }))
1859 ));
1860
1861 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
1862 assert!(matches!(
1863 cli.command,
1864 Some(Commands::Auth(AuthArgs {
1865 command: AuthCommand::Get {
1866 provider: ProviderArg::Novita
1867 }
1868 }))
1869 ));
1870
1871 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
1872 assert!(matches!(
1873 cli.command,
1874 Some(Commands::Auth(AuthArgs {
1875 command: AuthCommand::Clear {
1876 provider: ProviderArg::NvidiaNim
1877 }
1878 }))
1879 ));
1880
1881 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
1882 assert!(matches!(
1883 cli.command,
1884 Some(Commands::Auth(AuthArgs {
1885 command: AuthCommand::Set {
1886 provider: ProviderArg::Fireworks,
1887 api_key: None,
1888 api_key_stdin: false,
1889 }
1890 }))
1891 ));
1892
1893 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
1894 assert!(matches!(
1895 cli.command,
1896 Some(Commands::Auth(AuthArgs {
1897 command: AuthCommand::Get {
1898 provider: ProviderArg::Sglang
1899 }
1900 }))
1901 ));
1902
1903 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
1904 assert!(matches!(
1905 cli.command,
1906 Some(Commands::Auth(AuthArgs {
1907 command: AuthCommand::Get {
1908 provider: ProviderArg::Vllm
1909 }
1910 }))
1911 ));
1912
1913 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
1914 assert!(matches!(
1915 cli.command,
1916 Some(Commands::Auth(AuthArgs {
1917 command: AuthCommand::Set {
1918 provider: ProviderArg::Ollama,
1919 api_key: None,
1920 api_key_stdin: false,
1921 }
1922 }))
1923 ));
1924
1925 let cli = parse_ok(&["deepseek", "auth", "list"]);
1926 assert!(matches!(
1927 cli.command,
1928 Some(Commands::Auth(AuthArgs {
1929 command: AuthCommand::List
1930 }))
1931 ));
1932
1933 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
1934 assert!(matches!(
1935 cli.command,
1936 Some(Commands::Auth(AuthArgs {
1937 command: AuthCommand::Migrate { dry_run: false }
1938 }))
1939 ));
1940
1941 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
1942 assert!(matches!(
1943 cli.command,
1944 Some(Commands::Auth(AuthArgs {
1945 command: AuthCommand::Migrate { dry_run: true }
1946 }))
1947 ));
1948 }
1949
1950 #[test]
1951 fn auth_set_writes_to_shared_config_file() {
1952 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
1953 use std::sync::Arc;
1954
1955 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1956 let path = std::env::temp_dir().join(format!(
1957 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
1958 std::process::id()
1959 ));
1960 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1961 let inner = Arc::new(InMemoryKeyringStore::new());
1962 let secrets = Secrets::new(inner.clone());
1963
1964 run_auth_command_with_secrets(
1965 &mut store,
1966 AuthCommand::Set {
1967 provider: ProviderArg::Deepseek,
1968 api_key: Some("sk-keyring".to_string()),
1969 api_key_stdin: false,
1970 },
1971 &secrets,
1972 )
1973 .expect("set should succeed");
1974
1975 assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
1976 assert_eq!(
1977 store.config.providers.deepseek.api_key.as_deref(),
1978 Some("sk-keyring")
1979 );
1980 let saved = std::fs::read_to_string(&path).unwrap_or_default();
1981 assert!(saved.contains("api_key = \"sk-keyring\""));
1982 assert_eq!(
1983 inner.get("deepseek").unwrap().as_deref(),
1984 Some("sk-keyring")
1985 );
1986
1987 let _ = std::fs::remove_file(path);
1988 }
1989
1990 #[test]
1991 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
1992 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1993 let path = std::env::temp_dir().join(format!(
1994 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
1995 std::process::id()
1996 ));
1997 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1998 let secrets = no_keyring_secrets();
1999
2000 run_auth_command_with_secrets(
2001 &mut store,
2002 AuthCommand::Set {
2003 provider: ProviderArg::Ollama,
2004 api_key: None,
2005 api_key_stdin: false,
2006 },
2007 &secrets,
2008 )
2009 .expect("ollama auth set should not require a key");
2010
2011 assert_eq!(store.config.provider, ProviderKind::Ollama);
2012 assert_eq!(
2013 store.config.providers.ollama.base_url.as_deref(),
2014 Some("http://localhost:11434/v1")
2015 );
2016 assert_eq!(store.config.providers.ollama.api_key, None);
2017
2018 let _ = std::fs::remove_file(path);
2019 }
2020
2021 #[test]
2022 fn auth_clear_removes_from_config() {
2023 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2024 use std::sync::Arc;
2025
2026 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2027 let path = std::env::temp_dir().join(format!(
2028 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
2029 std::process::id()
2030 ));
2031 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2032 store.config.api_key = Some("sk-stale".to_string());
2033 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2034 store.save().unwrap();
2035
2036 let inner = Arc::new(InMemoryKeyringStore::new());
2037 inner.set("deepseek", "sk-stale").unwrap();
2038 let secrets = Secrets::new(inner.clone());
2039
2040 run_auth_command_with_secrets(
2041 &mut store,
2042 AuthCommand::Clear {
2043 provider: ProviderArg::Deepseek,
2044 },
2045 &secrets,
2046 )
2047 .expect("clear should succeed");
2048
2049 assert!(store.config.api_key.is_none());
2050 assert!(store.config.providers.deepseek.api_key.is_none());
2051 assert_eq!(inner.get("deepseek").unwrap(), None);
2052
2053 let _ = std::fs::remove_file(path);
2054 }
2055
2056 #[test]
2057 fn auth_status_and_list_only_probe_active_provider_keyring() {
2058 use deepseek_secrets::{KeyringStore, SecretsError};
2059 use std::sync::{Arc, Mutex};
2060
2061 #[derive(Default)]
2062 struct RecordingStore {
2063 gets: Mutex<Vec<String>>,
2064 }
2065
2066 impl KeyringStore for RecordingStore {
2067 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
2068 self.gets.lock().unwrap().push(key.to_string());
2069 Ok(None)
2070 }
2071
2072 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
2073 Ok(())
2074 }
2075
2076 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
2077 Ok(())
2078 }
2079
2080 fn backend_name(&self) -> &'static str {
2081 "recording"
2082 }
2083 }
2084
2085 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2086 let path = std::env::temp_dir().join(format!(
2087 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
2088 std::process::id()
2089 ));
2090 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2091 store.config.provider = ProviderKind::Deepseek;
2092 let inner = Arc::new(RecordingStore::default());
2093 let secrets = Secrets::new(inner.clone());
2094
2095 run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)
2096 .expect("status should succeed");
2097 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
2098 .expect("list should succeed");
2099
2100 assert_eq!(
2101 inner.gets.lock().unwrap().as_slice(),
2102 ["deepseek", "deepseek"]
2103 );
2104
2105 let _ = std::fs::remove_file(path);
2106 }
2107
2108 #[test]
2109 fn dispatch_keyring_recovery_self_heals_into_config_file() {
2110 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2111 use std::sync::Arc;
2112
2113 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2114 let path = std::env::temp_dir().join(format!(
2115 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
2116 std::process::id()
2117 ));
2118 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2119 let inner = Arc::new(InMemoryKeyringStore::new());
2120 inner.set("deepseek", "ring-key").unwrap();
2121 let secrets = Secrets::new(inner);
2122
2123 let resolved = resolve_runtime_for_dispatch_with_secrets(
2124 &mut store,
2125 &CliRuntimeOverrides::default(),
2126 &secrets,
2127 );
2128
2129 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
2130 assert_eq!(
2131 resolved.api_key_source,
2132 Some(RuntimeApiKeySource::ConfigFile)
2133 );
2134 assert_eq!(store.config.api_key.as_deref(), Some("ring-key"));
2135 assert_eq!(
2136 store.config.providers.deepseek.api_key.as_deref(),
2137 Some("ring-key")
2138 );
2139
2140 let saved = std::fs::read_to_string(&path).expect("config should be written");
2141 assert!(saved.contains("api_key = \"ring-key\""));
2142
2143 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
2144 &mut store,
2145 &CliRuntimeOverrides::default(),
2146 &no_keyring_secrets(),
2147 );
2148 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
2149 assert_eq!(
2150 resolved_again.api_key_source,
2151 Some(RuntimeApiKeySource::ConfigFile)
2152 );
2153
2154 let _ = std::fs::remove_file(path);
2155 }
2156
2157 #[test]
2158 fn logout_removes_plaintext_provider_keys() {
2159 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2160 let path = std::env::temp_dir().join(format!(
2161 "deepseek-cli-logout-test-{}-{nanos}.toml",
2162 std::process::id()
2163 ));
2164 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2165 store.config.api_key = Some("sk-stale".to_string());
2166 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2167 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
2168 store.save().unwrap();
2169
2170 let secrets = no_keyring_secrets();
2171
2172 run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
2173
2174 assert!(store.config.api_key.is_none());
2175 assert!(store.config.providers.deepseek.api_key.is_none());
2176 assert!(store.config.providers.fireworks.api_key.is_none());
2177
2178 let _ = std::fs::remove_file(path);
2179 }
2180
2181 #[test]
2182 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
2183 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2184 use std::sync::Arc;
2185
2186 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2187 let path = std::env::temp_dir().join(format!(
2188 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
2189 std::process::id()
2190 ));
2191 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2192 store.config.api_key = Some("sk-deep".to_string());
2193 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
2194 store.config.providers.openrouter.api_key = Some("or-key".to_string());
2195 store.config.providers.novita.api_key = Some("nv-key".to_string());
2196 store.save().unwrap();
2197
2198 let inner = Arc::new(InMemoryKeyringStore::new());
2199 let secrets = Secrets::new(inner.clone());
2200
2201 run_auth_command_with_secrets(
2202 &mut store,
2203 AuthCommand::Migrate { dry_run: false },
2204 &secrets,
2205 )
2206 .expect("migrate should succeed");
2207
2208 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
2209 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
2210 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
2211
2212 assert!(store.config.api_key.is_none());
2214 assert!(store.config.providers.deepseek.api_key.is_none());
2215 assert!(store.config.providers.openrouter.api_key.is_none());
2216 assert!(store.config.providers.novita.api_key.is_none());
2217
2218 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
2219 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
2220 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
2221 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
2222
2223 let _ = std::fs::remove_file(path);
2224 }
2225
2226 #[test]
2227 fn auth_migrate_dry_run_does_not_modify_anything() {
2228 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2229 use std::sync::Arc;
2230
2231 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2232 let path = std::env::temp_dir().join(format!(
2233 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
2234 std::process::id()
2235 ));
2236 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2237 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
2238 store.save().unwrap();
2239
2240 let inner = Arc::new(InMemoryKeyringStore::new());
2241 let secrets = Secrets::new(inner.clone());
2242
2243 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
2244 .expect("dry-run should succeed");
2245
2246 assert_eq!(inner.get("openrouter").unwrap(), None);
2247 assert_eq!(
2248 store.config.providers.openrouter.api_key.as_deref(),
2249 Some("or-stay")
2250 );
2251
2252 let _ = std::fs::remove_file(path);
2253 }
2254
2255 #[test]
2256 fn parses_global_override_flags() {
2257 let cli = parse_ok(&[
2258 "deepseek",
2259 "--provider",
2260 "openai",
2261 "--config",
2262 "/tmp/deepseek.toml",
2263 "--profile",
2264 "work",
2265 "--model",
2266 "gpt-4.1",
2267 "--output-mode",
2268 "json",
2269 "--log-level",
2270 "debug",
2271 "--telemetry",
2272 "true",
2273 "--approval-policy",
2274 "on-request",
2275 "--sandbox-mode",
2276 "workspace-write",
2277 "--base-url",
2278 "https://api.openai.com/v1",
2279 "--api-key",
2280 "sk-test",
2281 "--no-alt-screen",
2282 "--no-mouse-capture",
2283 "--skip-onboarding",
2284 "model",
2285 "resolve",
2286 "gpt-4.1",
2287 ]);
2288
2289 assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
2290 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
2291 assert_eq!(cli.profile.as_deref(), Some("work"));
2292 assert_eq!(cli.model.as_deref(), Some("gpt-4.1"));
2293 assert_eq!(cli.output_mode.as_deref(), Some("json"));
2294 assert_eq!(cli.log_level.as_deref(), Some("debug"));
2295 assert_eq!(cli.telemetry, Some(true));
2296 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
2297 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
2298 assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1"));
2299 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
2300 assert!(cli.no_alt_screen);
2301 assert!(cli.no_mouse_capture);
2302 assert!(!cli.mouse_capture);
2303 assert!(cli.skip_onboarding);
2304 }
2305
2306 #[test]
2307 fn parses_top_level_prompt_flag_for_canonical_one_shot() {
2308 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
2309
2310 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
2311 assert_eq!(cli.prompt, None);
2312 }
2313
2314 #[test]
2315 fn root_help_surface_contains_expected_subcommands_and_globals() {
2316 let rendered = help_for(&["deepseek", "--help"]);
2317
2318 for token in [
2319 "run",
2320 "doctor",
2321 "models",
2322 "sessions",
2323 "resume",
2324 "setup",
2325 "login",
2326 "logout",
2327 "auth",
2328 "mcp-server",
2329 "config",
2330 "model",
2331 "thread",
2332 "sandbox",
2333 "app-server",
2334 "completion",
2335 "metrics",
2336 "--provider",
2337 "--model",
2338 "--config",
2339 "--profile",
2340 "--output-mode",
2341 "--log-level",
2342 "--telemetry",
2343 "--base-url",
2344 "--api-key",
2345 "--approval-policy",
2346 "--sandbox-mode",
2347 "--no-alt-screen",
2348 "--mouse-capture",
2349 "--no-mouse-capture",
2350 "--skip-onboarding",
2351 "--prompt",
2352 ] {
2353 assert!(
2354 rendered.contains(token),
2355 "expected help to contain token: {token}"
2356 );
2357 }
2358 }
2359
2360 #[test]
2361 fn subcommand_help_surfaces_are_stable() {
2362 let cases = [
2363 ("config", vec!["get", "set", "unset", "list", "path"]),
2364 ("model", vec!["list", "resolve"]),
2365 (
2366 "thread",
2367 vec![
2368 "list",
2369 "read",
2370 "resume",
2371 "fork",
2372 "archive",
2373 "unarchive",
2374 "set-name",
2375 ],
2376 ),
2377 ("sandbox", vec!["check"]),
2378 (
2379 "app-server",
2380 vec!["--host", "--port", "--config", "--stdio"],
2381 ),
2382 (
2383 "completion",
2384 vec![
2385 "<SHELL>",
2386 "bash",
2387 "source <(deepseek completion bash)",
2388 "~/.local/share/bash-completion/completions/deepseek",
2389 "fpath=(~/.zfunc $fpath)",
2390 "deepseek completion fish > ~/.config/fish/completions/deepseek.fish",
2391 "deepseek completion powershell | Out-String | Invoke-Expression",
2392 ],
2393 ),
2394 ("metrics", vec!["--json", "--since"]),
2395 ];
2396
2397 for (subcommand, expected_tokens) in cases {
2398 let argv = ["deepseek", subcommand, "--help"];
2399 let rendered = help_for(&argv);
2400 for token in expected_tokens {
2401 assert!(
2402 rendered.contains(token),
2403 "expected help for `{subcommand}` to include `{token}`"
2404 );
2405 }
2406 }
2407 }
2408
2409 #[test]
2415 fn sibling_tui_candidate_picks_platform_correct_name() {
2416 let dir = tempfile::TempDir::new().expect("tempdir");
2417 let dispatcher = dir
2418 .path()
2419 .join("deepseek")
2420 .with_extension(std::env::consts::EXE_EXTENSION);
2421 std::fs::write(&dispatcher, b"").unwrap();
2423
2424 assert!(sibling_tui_candidate(&dispatcher).is_none());
2426
2427 let target =
2428 dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
2429 std::fs::write(&target, b"").unwrap();
2430
2431 let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
2432 assert_eq!(found, target, "primary platform-correct name wins");
2433 }
2434
2435 #[test]
2436 fn dispatcher_spawn_error_names_path_and_recovery_checks() {
2437 let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
2438 let message = tui_spawn_error(Path::new("C:/tools/deepseek-tui.exe"), &err);
2439
2440 assert!(message.contains("C:/tools/deepseek-tui.exe"));
2441 assert!(message.contains("access is denied"));
2442 assert!(message.contains("where deepseek"));
2443 assert!(message.contains("DEEPSEEK_TUI_BIN"));
2444 }
2445
2446 #[cfg(windows)]
2451 #[test]
2452 fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
2453 let dir = tempfile::TempDir::new().expect("tempdir");
2454 let dispatcher = dir.path().join("deepseek.exe");
2455 std::fs::write(&dispatcher, b"").unwrap();
2456
2457 let suffixless = dispatcher.with_file_name("deepseek-tui");
2459 std::fs::write(&suffixless, b"").unwrap();
2460
2461 let found = sibling_tui_candidate(&dispatcher)
2462 .expect("Windows fallback must locate suffixless deepseek-tui");
2463 assert_eq!(found, suffixless);
2464 }
2465
2466 #[test]
2469 fn locate_sibling_tui_binary_honours_env_override() {
2470 let dir = tempfile::TempDir::new().expect("tempdir");
2471 let custom = dir
2472 .path()
2473 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2474 std::fs::write(&custom, b"").unwrap();
2475
2476 struct EnvGuard;
2478 impl Drop for EnvGuard {
2479 fn drop(&mut self) {
2480 unsafe { std::env::remove_var("DEEPSEEK_TUI_BIN") };
2484 }
2485 }
2486 let _g = EnvGuard;
2487 unsafe { std::env::set_var("DEEPSEEK_TUI_BIN", &custom) };
2489
2490 let resolved = locate_sibling_tui_binary().expect("override must resolve");
2491 assert_eq!(resolved, custom);
2492 }
2493}