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 = env!("DEEPSEEK_BUILD_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 provider_env_value(provider).is_some()
725}
726
727fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
728 match provider {
729 ProviderKind::Deepseek => &["DEEPSEEK_API_KEY"],
730 ProviderKind::Openrouter => &["OPENROUTER_API_KEY"],
731 ProviderKind::Novita => &["NOVITA_API_KEY"],
732 ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
733 ProviderKind::Fireworks => &["FIREWORKS_API_KEY"],
734 ProviderKind::Sglang => &["SGLANG_API_KEY"],
735 ProviderKind::Vllm => &["VLLM_API_KEY"],
736 ProviderKind::Ollama => &["OLLAMA_API_KEY"],
737 ProviderKind::Openai => &["OPENAI_API_KEY"],
738 }
739}
740
741fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
742 provider_env_vars(provider).iter().find_map(|var| {
743 std::env::var(var)
744 .ok()
745 .filter(|value| !value.trim().is_empty())
746 .map(|value| (*var, value))
747 })
748}
749
750fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
751 let slot = store
752 .config
753 .providers
754 .for_provider(provider)
755 .api_key
756 .as_deref();
757 let root = (provider == ProviderKind::Deepseek)
758 .then_some(store.config.api_key.as_deref())
759 .flatten();
760 slot.or(root).filter(|v| !v.trim().is_empty())
761}
762
763fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
764 provider_config_api_key(store, provider).is_some()
765}
766
767fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
768 secrets
769 .get(provider_slot(provider))
770 .ok()
771 .flatten()
772 .filter(|v| !v.trim().is_empty())
773}
774
775fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
776 provider_keyring_api_key(secrets, provider).is_some()
777}
778
779fn write_provider_api_key_to_keyring(
780 secrets: &Secrets,
781 provider: ProviderKind,
782 api_key: &str,
783) -> bool {
784 secrets.set(provider_slot(provider), api_key).is_ok()
785}
786
787fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
788 let _ = secrets.delete(provider_slot(provider));
789}
790
791fn auth_status_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
792 let provider = store.config.provider;
793 let config_key = provider_config_api_key(store, provider);
794 let keyring_key = provider_keyring_api_key(secrets, provider);
795 let env_key = provider_env_value(provider);
796
797 let active_source = if config_key.is_some() {
798 "config"
799 } else if keyring_key.is_some() {
800 "keyring"
801 } else if env_key.is_some() {
802 "env"
803 } else {
804 "missing"
805 };
806 let active_last4 = config_key
807 .map(last4_label)
808 .or_else(|| keyring_key.as_deref().map(last4_label))
809 .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)));
810 let active_label = active_last4
811 .map(|last4| format!("{active_source} (last4: {last4})"))
812 .unwrap_or_else(|| active_source.to_string());
813
814 let env_var_label = env_key
815 .as_ref()
816 .map(|(name, _)| (*name).to_string())
817 .unwrap_or_else(|| provider_env_vars(provider).join("/"));
818 let env_status = env_key
819 .as_ref()
820 .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
821 .unwrap_or_else(|| "unset".to_string());
822
823 vec![
824 format!("provider: {}", provider.as_str()),
825 format!("active source: {active_label}"),
826 "lookup order: config -> keyring -> env".to_string(),
827 format!(
828 "config file: {} ({})",
829 store.path().display(),
830 source_status(config_key, "missing")
831 ),
832 format!(
833 "keyring: {} ({})",
834 secrets.backend_name(),
835 source_status(keyring_key.as_deref(), "missing")
836 ),
837 format!("env var: {env_var_label} ({env_status})"),
838 ]
839}
840
841fn source_status(value: Option<&str>, missing_label: &str) -> String {
842 value
843 .map(|v| format!("set, last4: {}", last4_label(v)))
844 .unwrap_or_else(|| missing_label.to_string())
845}
846
847fn last4_label(value: &str) -> String {
848 let trimmed = value.trim();
849 let chars: Vec<char> = trimmed.chars().collect();
850 if chars.len() <= 4 {
851 return "<redacted>".to_string();
852 }
853 let last4: String = chars[chars.len() - 4..].iter().collect();
854 format!("...{last4}")
855}
856
857fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
858 run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
859}
860
861fn run_auth_command_with_secrets(
862 store: &mut ConfigStore,
863 command: AuthCommand,
864 secrets: &Secrets,
865) -> Result<()> {
866 match command {
867 AuthCommand::Status => {
868 for line in auth_status_lines(store, secrets) {
869 println!("{line}");
870 }
871 Ok(())
872 }
873 AuthCommand::Set {
874 provider,
875 api_key,
876 api_key_stdin,
877 } => {
878 let provider: ProviderKind = provider.into();
879 let slot = provider_slot(provider);
880 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
881 store.config.provider = provider;
882 let provider_cfg = store.config.providers.for_provider_mut(provider);
883 if provider_cfg.base_url.is_none() {
884 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
885 }
886 store.save()?;
887 println!(
888 "configured {slot} provider in {} (API key optional)",
889 store.path().display()
890 );
891 return Ok(());
892 }
893 let api_key = match (api_key, api_key_stdin) {
894 (Some(v), _) => v,
895 (None, true) => read_api_key_from_stdin()?,
896 (None, false) => prompt_api_key(slot)?,
897 };
898 write_provider_api_key_to_config(store, provider, &api_key);
899 let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
900 store.save()?;
901 if keyring_saved {
903 println!(
904 "saved API key for {slot} to {} and {}",
905 store.path().display(),
906 secrets.backend_name()
907 );
908 } else {
909 println!("saved API key for {slot} to {}", store.path().display());
910 }
911 Ok(())
912 }
913 AuthCommand::Get { provider } => {
914 let provider: ProviderKind = provider.into();
915 let slot = provider_slot(provider);
916 let in_file = provider_config_set(store, provider);
917 let in_keyring = !in_file && provider_keyring_set(secrets, provider);
918 let in_env = provider_env_set(provider);
919 let source = if in_file {
921 Some("config-file")
922 } else if in_keyring {
923 Some("keyring")
924 } else if in_env {
925 Some("env")
926 } else {
927 None
928 };
929 match source {
930 Some(source) => println!("{slot}: set (source: {source})"),
931 None => println!("{slot}: not set"),
932 }
933 Ok(())
934 }
935 AuthCommand::Clear { provider } => {
936 let provider: ProviderKind = provider.into();
937 let slot = provider_slot(provider);
938 clear_provider_api_key_from_config(store, provider);
939 clear_provider_api_key_from_keyring(secrets, provider);
940 store.save()?;
941 println!("cleared API key for {slot} from config and keyring");
942 Ok(())
943 }
944 AuthCommand::List => {
945 println!("provider config keyring env active");
946 let active_provider = store.config.provider;
947 for provider in PROVIDER_LIST {
948 let slot = provider_slot(provider);
949 let file = provider_config_set(store, provider);
950 let keyring = (provider == active_provider && !file)
951 .then(|| provider_keyring_set(secrets, provider));
952 let env = provider_env_set(provider);
953 let active = if file {
954 "config"
955 } else if keyring == Some(true) {
956 "keyring"
957 } else if env {
958 "env"
959 } else {
960 "missing"
961 };
962 println!(
963 "{slot:<12} {} {} {} {active}",
964 yes_no(file),
965 keyring_status_short(keyring),
966 yes_no(env)
967 );
968 }
969 Ok(())
970 }
971 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
972 }
973}
974
975fn yes_no(b: bool) -> &'static str {
976 if b { "yes" } else { "no " }
977}
978
979fn keyring_status_short(state: Option<bool>) -> &'static str {
980 match state {
981 Some(true) => "yes",
982 Some(false) => "no ",
983 None => "n/a",
984 }
985}
986
987fn prompt_api_key(slot: &str) -> Result<String> {
988 use std::io::{IsTerminal, Write};
989 eprint!("Enter API key for {slot}: ");
990 io::stderr().flush().ok();
991 if !io::stdin().is_terminal() {
992 return read_api_key_from_stdin();
994 }
995 let mut buf = String::new();
996 io::stdin()
997 .read_line(&mut buf)
998 .context("failed to read API key from stdin")?;
999 let key = buf.trim().to_string();
1000 if key.is_empty() {
1001 bail!("empty API key provided");
1002 }
1003 Ok(key)
1004}
1005
1006fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
1009 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
1010 let mut warnings: Vec<String> = Vec::new();
1011
1012 for provider in PROVIDER_LIST {
1013 let slot = provider_slot(provider);
1014 let from_provider_block = store
1015 .config
1016 .providers
1017 .for_provider(provider)
1018 .api_key
1019 .clone()
1020 .filter(|v| !v.trim().is_empty());
1021 let from_root = (provider == ProviderKind::Deepseek)
1022 .then(|| store.config.api_key.clone())
1023 .flatten()
1024 .filter(|v| !v.trim().is_empty());
1025 let value = from_provider_block.or(from_root);
1026 let Some(value) = value else { continue };
1027
1028 if let Ok(Some(existing)) = secrets.get(slot)
1029 && existing == value
1030 {
1031 } else if dry_run {
1033 migrated.push((provider, slot));
1034 continue;
1035 } else if let Err(err) = secrets.set(slot, &value) {
1036 warnings.push(format!("skipped {slot}: failed to write to keyring: {err}"));
1037 continue;
1038 }
1039 if !dry_run {
1040 store.config.providers.for_provider_mut(provider).api_key = None;
1041 if provider == ProviderKind::Deepseek {
1042 store.config.api_key = None;
1043 }
1044 }
1045 migrated.push((provider, slot));
1046 }
1047
1048 if !dry_run && !migrated.is_empty() {
1049 store
1050 .save()
1051 .context("failed to write updated config.toml")?;
1052 }
1053
1054 println!("keyring backend: {}", secrets.backend_name());
1055 if migrated.is_empty() {
1056 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
1057 } else {
1058 println!(
1059 "{} {} provider key(s):",
1060 if dry_run { "would migrate" } else { "migrated" },
1061 migrated.len()
1062 );
1063 for (_, slot) in &migrated {
1064 println!(" - {slot}");
1065 }
1066 if !dry_run {
1067 println!(
1068 "config.toml at {} no longer contains api_key entries for migrated providers.",
1069 store.path().display()
1070 );
1071 }
1072 }
1073 for w in warnings {
1074 eprintln!("warning: {w}");
1075 }
1076 Ok(())
1077}
1078
1079fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
1080 match command {
1081 ConfigCommand::Get { key } => {
1082 if let Some(value) = store.config.get_value(&key) {
1083 println!("{value}");
1084 return Ok(());
1085 }
1086 bail!("key not found: {key}");
1087 }
1088 ConfigCommand::Set { key, value } => {
1089 store.config.set_value(&key, &value)?;
1090 store.save()?;
1091 println!("set {key}");
1092 Ok(())
1093 }
1094 ConfigCommand::Unset { key } => {
1095 store.config.unset_value(&key)?;
1096 store.save()?;
1097 println!("unset {key}");
1098 Ok(())
1099 }
1100 ConfigCommand::List => {
1101 for (key, value) in store.config.list_values() {
1102 println!("{key} = {value}");
1103 }
1104 Ok(())
1105 }
1106 ConfigCommand::Path => {
1107 println!("{}", store.path().display());
1108 Ok(())
1109 }
1110 }
1111}
1112
1113fn run_model_command(command: ModelCommand) -> Result<()> {
1114 let registry = ModelRegistry::default();
1115 match command {
1116 ModelCommand::List { provider } => {
1117 let filter = provider.map(ProviderKind::from);
1118 for model in registry.list().into_iter().filter(|m| match filter {
1119 Some(p) => m.provider == p,
1120 None => true,
1121 }) {
1122 println!("{} ({})", model.id, model.provider.as_str());
1123 }
1124 Ok(())
1125 }
1126 ModelCommand::Resolve { model, provider } => {
1127 let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));
1128 println!("requested: {}", resolved.requested.unwrap_or_default());
1129 println!("resolved: {}", resolved.resolved.id);
1130 println!("provider: {}", resolved.resolved.provider.as_str());
1131 println!("used_fallback: {}", resolved.used_fallback);
1132 Ok(())
1133 }
1134 }
1135}
1136
1137fn run_thread_command(command: ThreadCommand) -> Result<()> {
1138 let state = StateStore::open(None)?;
1139 match command {
1140 ThreadCommand::List { all, limit } => {
1141 let threads = state.list_threads(ThreadListFilters {
1142 include_archived: all,
1143 limit,
1144 })?;
1145 for thread in threads {
1146 println!(
1147 "{} | {} | {} | {}",
1148 thread.id,
1149 thread
1150 .name
1151 .clone()
1152 .unwrap_or_else(|| "(unnamed)".to_string()),
1153 thread.model_provider,
1154 thread.cwd.display()
1155 );
1156 }
1157 Ok(())
1158 }
1159 ThreadCommand::Read { thread_id } => {
1160 let thread = state.get_thread(&thread_id)?;
1161 println!("{}", serde_json::to_string_pretty(&thread)?);
1162 Ok(())
1163 }
1164 ThreadCommand::Resume { thread_id } => {
1165 let args = vec!["resume".to_string(), thread_id];
1166 delegate_simple_tui(args)
1167 }
1168 ThreadCommand::Fork { thread_id } => {
1169 let args = vec!["fork".to_string(), thread_id];
1170 delegate_simple_tui(args)
1171 }
1172 ThreadCommand::Archive { thread_id } => {
1173 state.mark_archived(&thread_id)?;
1174 println!("archived {thread_id}");
1175 Ok(())
1176 }
1177 ThreadCommand::Unarchive { thread_id } => {
1178 state.mark_unarchived(&thread_id)?;
1179 println!("unarchived {thread_id}");
1180 Ok(())
1181 }
1182 ThreadCommand::SetName { thread_id, name } => {
1183 let mut thread = state
1184 .get_thread(&thread_id)?
1185 .with_context(|| format!("thread not found: {thread_id}"))?;
1186 thread.name = Some(name);
1187 thread.updated_at = chrono::Utc::now().timestamp();
1188 state.upsert_thread(&thread)?;
1189 println!("renamed {thread_id}");
1190 Ok(())
1191 }
1192 }
1193}
1194
1195fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
1196 match command {
1197 SandboxCommand::Check { command, ask } => {
1198 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
1199 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1200 let decision = engine.check(ExecPolicyContext {
1201 command: &command,
1202 cwd: &cwd.display().to_string(),
1203 ask_for_approval: ask.into(),
1204 sandbox_mode: Some("workspace-write"),
1205 })?;
1206 println!("{}", serde_json::to_string_pretty(&decision)?);
1207 Ok(())
1208 }
1209 }
1210}
1211
1212fn run_app_server_command(args: AppServerArgs) -> Result<()> {
1213 let runtime = tokio::runtime::Builder::new_multi_thread()
1214 .enable_all()
1215 .build()
1216 .context("failed to create tokio runtime")?;
1217 if args.stdio {
1218 return runtime.block_on(run_app_server_stdio(args.config));
1219 }
1220 let listen: SocketAddr = format!("{}:{}", args.host, args.port)
1221 .parse()
1222 .with_context(|| {
1223 format!(
1224 "invalid app-server listen address {}:{}",
1225 args.host, args.port
1226 )
1227 })?;
1228 runtime.block_on(run_app_server(AppServerOptions {
1229 listen,
1230 config_path: args.config,
1231 }))
1232}
1233
1234fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
1235 let persisted = load_mcp_server_definitions(store);
1236 let updated = run_stdio_server(persisted)?;
1237 persist_mcp_server_definitions(store, &updated)
1238}
1239
1240fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
1241 let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
1242 return Vec::new();
1243 };
1244
1245 match parse_mcp_server_definitions(&raw) {
1246 Ok(definitions) => definitions,
1247 Err(err) => {
1248 eprintln!(
1249 "warning: failed to parse persisted MCP server definitions ({}): {}",
1250 MCP_SERVER_DEFINITIONS_KEY, err
1251 );
1252 Vec::new()
1253 }
1254 }
1255}
1256
1257fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
1258 if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
1259 return Ok(parsed);
1260 }
1261
1262 let unwrapped: String = serde_json::from_str(raw)
1263 .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
1264 serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
1265 format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
1266 })
1267}
1268
1269fn persist_mcp_server_definitions(
1270 store: &mut ConfigStore,
1271 definitions: &[McpServerDefinition],
1272) -> Result<()> {
1273 let encoded =
1274 serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
1275 store
1276 .config
1277 .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
1278 store.save()
1279}
1280
1281fn delegate_to_tui(
1282 cli: &Cli,
1283 resolved_runtime: &ResolvedRuntimeOptions,
1284 passthrough: Vec<String>,
1285) -> Result<()> {
1286 let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
1287 let tui = PathBuf::from(cmd.get_program());
1288 let status = cmd
1289 .status()
1290 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1291 exit_with_tui_status(status)
1292}
1293
1294fn run_resume_command(
1295 cli: &Cli,
1296 resolved_runtime: &ResolvedRuntimeOptions,
1297 args: TuiPassthroughArgs,
1298) -> Result<()> {
1299 let passthrough = tui_args("resume", args);
1300 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
1301 return run_dispatcher_resume_picker(cli, resolved_runtime);
1302 }
1303 delegate_to_tui(cli, resolved_runtime, passthrough)
1304}
1305
1306fn run_dispatcher_resume_picker(
1307 cli: &Cli,
1308 resolved_runtime: &ResolvedRuntimeOptions,
1309) -> Result<()> {
1310 let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
1311 let tui = PathBuf::from(sessions_cmd.get_program());
1312 let status = sessions_cmd
1313 .status()
1314 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1315 if !status.success() {
1316 return exit_with_tui_status(status);
1317 }
1318
1319 println!();
1320 println!("Windows note: enter a session id or prefix from the list above.");
1321 println!("You can also run `deepseek resume --last` to skip this prompt.");
1322 print!("Session id/prefix (Enter to cancel): ");
1323 io::stdout().flush()?;
1324
1325 let mut input = String::new();
1326 io::stdin()
1327 .read_line(&mut input)
1328 .context("failed to read session selection")?;
1329 let session_id = input.trim();
1330 if session_id.is_empty() {
1331 bail!("No session selected.");
1332 }
1333
1334 delegate_to_tui(
1335 cli,
1336 resolved_runtime,
1337 vec!["resume".to_string(), session_id.to_string()],
1338 )
1339}
1340
1341fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
1342 is_windows && passthrough == ["resume"]
1343}
1344
1345fn build_tui_command(
1346 cli: &Cli,
1347 resolved_runtime: &ResolvedRuntimeOptions,
1348 passthrough: Vec<String>,
1349) -> Result<Command> {
1350 let tui = locate_sibling_tui_binary()?;
1351
1352 let mut cmd = Command::new(&tui);
1353 if let Some(config) = cli.config.as_ref() {
1354 cmd.arg("--config").arg(config);
1355 }
1356 if let Some(profile) = cli.profile.as_ref() {
1357 cmd.arg("--profile").arg(profile);
1358 }
1359 if cli.no_alt_screen {
1360 cmd.arg("--no-alt-screen");
1361 }
1362 if cli.mouse_capture {
1363 cmd.arg("--mouse-capture");
1364 }
1365 if cli.no_mouse_capture {
1366 cmd.arg("--no-mouse-capture");
1367 }
1368 if cli.skip_onboarding {
1369 cmd.arg("--skip-onboarding");
1370 }
1371 cmd.args(passthrough);
1372
1373 if !matches!(
1374 resolved_runtime.provider,
1375 ProviderKind::Deepseek
1376 | ProviderKind::NvidiaNim
1377 | ProviderKind::Openai
1378 | ProviderKind::Openrouter
1379 | ProviderKind::Novita
1380 | ProviderKind::Fireworks
1381 | ProviderKind::Sglang
1382 | ProviderKind::Vllm
1383 | ProviderKind::Ollama
1384 ) {
1385 bail!(
1386 "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, OpenRouter, Novita, Fireworks, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.",
1387 resolved_runtime.provider.as_str()
1388 );
1389 }
1390
1391 cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model);
1392 cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url);
1393 cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str());
1394 if !resolved_runtime.http_headers.is_empty() {
1395 let encoded = resolved_runtime
1396 .http_headers
1397 .iter()
1398 .map(|(name, value)| format!("{}={}", name.trim(), value.trim()))
1399 .collect::<Vec<_>>()
1400 .join(",");
1401 cmd.env("DEEPSEEK_HTTP_HEADERS", encoded);
1402 }
1403 if let Some(api_key) = resolved_runtime.api_key.as_ref() {
1404 cmd.env("DEEPSEEK_API_KEY", api_key);
1405 if resolved_runtime.provider == ProviderKind::Openai {
1406 cmd.env("OPENAI_API_KEY", api_key);
1407 }
1408 let source = resolved_runtime
1409 .api_key_source
1410 .unwrap_or(RuntimeApiKeySource::Env)
1411 .as_env_value();
1412 cmd.env("DEEPSEEK_API_KEY_SOURCE", source);
1413 }
1414
1415 if let Some(model) = cli.model.as_ref() {
1416 cmd.env("DEEPSEEK_MODEL", model);
1417 }
1418 if let Some(output_mode) = cli.output_mode.as_ref() {
1419 cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
1420 }
1421 if let Some(log_level) = cli.log_level.as_ref() {
1422 cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
1423 }
1424 if let Some(telemetry) = cli.telemetry {
1425 cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
1426 }
1427 if let Some(policy) = cli.approval_policy.as_ref() {
1428 cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
1429 }
1430 if let Some(mode) = cli.sandbox_mode.as_ref() {
1431 cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
1432 }
1433 if let Some(api_key) = cli.api_key.as_ref() {
1434 cmd.env("DEEPSEEK_API_KEY", api_key);
1435 if resolved_runtime.provider == ProviderKind::Openai {
1436 cmd.env("OPENAI_API_KEY", api_key);
1437 }
1438 cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
1439 }
1440 if let Some(base_url) = cli.base_url.as_ref() {
1441 cmd.env("DEEPSEEK_BASE_URL", base_url);
1442 }
1443
1444 Ok(cmd)
1445}
1446
1447fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
1448 match status.code() {
1449 Some(code) => std::process::exit(code),
1450 None => bail!("deepseek-tui terminated by signal"),
1451 }
1452}
1453
1454fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
1455 let tui = locate_sibling_tui_binary()?;
1456 let status = Command::new(&tui)
1457 .args(args)
1458 .status()
1459 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1460 match status.code() {
1461 Some(code) => std::process::exit(code),
1462 None => bail!("deepseek-tui terminated by signal"),
1463 }
1464}
1465
1466fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
1467 format!(
1468 "failed to spawn companion TUI binary at {}: {err}\n\
1469\n\
1470The `deepseek` dispatcher found a `deepseek-tui` file, but the OS refused \
1471to execute it. Common fixes:\n\
1472 - Reinstall with `npm install -g deepseek-tui`, or run `deepseek update`.\n\
1473 - On Windows, run `where deepseek` and `where deepseek-tui`; both should \
1474come from the same install directory.\n\
1475 - If you downloaded release assets manually, keep both `deepseek` and \
1476`deepseek-tui` binaries together and make sure the TUI binary is executable.\n\
1477 - Set DEEPSEEK_TUI_BIN to the absolute path of a working `deepseek-tui` \
1478binary.",
1479 tui.display()
1480 )
1481}
1482
1483fn locate_sibling_tui_binary() -> Result<PathBuf> {
1493 if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
1494 let candidate = PathBuf::from(override_path);
1495 if candidate.is_file() {
1496 return Ok(candidate);
1497 }
1498 bail!(
1499 "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
1500 candidate.display()
1501 );
1502 }
1503
1504 let current = std::env::current_exe().context("failed to locate current executable path")?;
1505 if let Some(found) = sibling_tui_candidate(¤t) {
1506 return Ok(found);
1507 }
1508
1509 let expected = current.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1512 bail!(
1513 "Companion `deepseek-tui` binary not found at {}.\n\
1514\n\
1515The `deepseek` dispatcher delegates interactive sessions to a sibling \
1516`deepseek-tui` binary. To fix this, install one of:\n\
1517 • npm: npm install -g deepseek-tui (downloads both binaries)\n\
1518 • cargo: cargo install deepseek-tui-cli deepseek-tui --locked\n\
1519 • GitHub Releases: download BOTH `deepseek-<platform>` AND \
1520`deepseek-tui-<platform>` from https://github.com/Hmbown/DeepSeek-TUI/releases/latest \
1521and place them in the same directory.\n\
1522\n\
1523Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `deepseek-tui` binary.",
1524 expected.display()
1525 );
1526}
1527
1528fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
1532 let primary =
1535 dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
1536 if primary.is_file() {
1537 return Some(primary);
1538 }
1539 if cfg!(windows) {
1542 let suffixless = dispatcher.with_file_name("deepseek-tui");
1543 if suffixless.is_file() {
1544 return Some(suffixless);
1545 }
1546 }
1547 None
1548}
1549
1550fn run_metrics_command(args: MetricsArgs) -> Result<()> {
1551 let since = match args.since.as_deref() {
1552 Some(s) => {
1553 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
1554 }
1555 None => None,
1556 };
1557 metrics::run(metrics::MetricsArgs {
1558 json: args.json,
1559 since,
1560 })
1561}
1562
1563fn read_api_key_from_stdin() -> Result<String> {
1564 let mut input = String::new();
1565 io::stdin()
1566 .read_to_string(&mut input)
1567 .context("failed to read api key from stdin")?;
1568 let key = input.trim().to_string();
1569 if key.is_empty() {
1570 bail!("empty API key provided");
1571 }
1572 Ok(key)
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577 use super::*;
1578 use clap::error::ErrorKind;
1579 use std::ffi::OsString;
1580 use std::sync::{Mutex, OnceLock};
1581
1582 fn parse_ok(argv: &[&str]) -> Cli {
1583 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
1584 }
1585
1586 fn help_for(argv: &[&str]) -> String {
1587 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
1588 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
1589 err.to_string()
1590 }
1591
1592 fn command_env(cmd: &Command, name: &str) -> Option<String> {
1593 let name = std::ffi::OsStr::new(name);
1594 cmd.get_envs().find_map(|(key, value)| {
1595 if key == name {
1596 value.map(|v| v.to_string_lossy().into_owned())
1597 } else {
1598 None
1599 }
1600 })
1601 }
1602
1603 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1604 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1605 LOCK.get_or_init(|| Mutex::new(()))
1606 .lock()
1607 .unwrap_or_else(|p| p.into_inner())
1608 }
1609
1610 struct ScopedEnvVar {
1611 name: &'static str,
1612 previous: Option<OsString>,
1613 }
1614
1615 impl ScopedEnvVar {
1616 fn set(name: &'static str, value: &str) -> Self {
1617 let previous = std::env::var_os(name);
1618 unsafe { std::env::set_var(name, value) };
1621 Self { name, previous }
1622 }
1623 }
1624
1625 impl Drop for ScopedEnvVar {
1626 fn drop(&mut self) {
1627 unsafe {
1629 if let Some(previous) = self.previous.take() {
1630 std::env::set_var(self.name, previous);
1631 } else {
1632 std::env::remove_var(self.name);
1633 }
1634 }
1635 }
1636 }
1637
1638 #[test]
1639 fn clap_command_definition_is_consistent() {
1640 Cli::command().debug_assert();
1641 }
1642
1643 #[test]
1650 fn anyhow_chain_surfaces_toml_parse_cause() {
1651 use anyhow::Context;
1652 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
1653 let err = Err::<(), _>(inner)
1654 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
1655 .unwrap_err();
1656
1657 assert_eq!(
1659 err.to_string(),
1660 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
1661 );
1662
1663 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
1665 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
1666 }
1667
1668 #[test]
1669 fn parses_config_command_matrix() {
1670 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
1671 assert!(matches!(
1672 cli.command,
1673 Some(Commands::Config(ConfigArgs {
1674 command: ConfigCommand::Get { ref key }
1675 })) if key == "provider"
1676 ));
1677
1678 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
1679 assert!(matches!(
1680 cli.command,
1681 Some(Commands::Config(ConfigArgs {
1682 command: ConfigCommand::Set { ref key, ref value }
1683 })) if key == "model" && value == "deepseek-v4-flash"
1684 ));
1685
1686 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
1687 assert!(matches!(
1688 cli.command,
1689 Some(Commands::Config(ConfigArgs {
1690 command: ConfigCommand::Unset { ref key }
1691 })) if key == "model"
1692 ));
1693
1694 assert!(matches!(
1695 parse_ok(&["deepseek", "config", "list"]).command,
1696 Some(Commands::Config(ConfigArgs {
1697 command: ConfigCommand::List
1698 }))
1699 ));
1700 assert!(matches!(
1701 parse_ok(&["deepseek", "config", "path"]).command,
1702 Some(Commands::Config(ConfigArgs {
1703 command: ConfigCommand::Path
1704 }))
1705 ));
1706 }
1707
1708 #[test]
1709 fn parses_model_command_matrix() {
1710 let cli = parse_ok(&["deepseek", "model", "list"]);
1711 assert!(matches!(
1712 cli.command,
1713 Some(Commands::Model(ModelArgs {
1714 command: ModelCommand::List { provider: None }
1715 }))
1716 ));
1717
1718 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
1719 assert!(matches!(
1720 cli.command,
1721 Some(Commands::Model(ModelArgs {
1722 command: ModelCommand::List {
1723 provider: Some(ProviderArg::Openai)
1724 }
1725 }))
1726 ));
1727
1728 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
1729 assert!(matches!(
1730 cli.command,
1731 Some(Commands::Model(ModelArgs {
1732 command: ModelCommand::Resolve {
1733 model: Some(ref model),
1734 provider: None
1735 }
1736 })) if model == "deepseek-v4-flash"
1737 ));
1738
1739 let cli = parse_ok(&[
1740 "deepseek",
1741 "model",
1742 "resolve",
1743 "--provider",
1744 "deepseek",
1745 "deepseek-v4-pro",
1746 ]);
1747 assert!(matches!(
1748 cli.command,
1749 Some(Commands::Model(ModelArgs {
1750 command: ModelCommand::Resolve {
1751 model: Some(ref model),
1752 provider: Some(ProviderArg::Deepseek)
1753 }
1754 })) if model == "deepseek-v4-pro"
1755 ));
1756 }
1757
1758 #[test]
1759 fn parses_thread_command_matrix() {
1760 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
1761 assert!(matches!(
1762 cli.command,
1763 Some(Commands::Thread(ThreadArgs {
1764 command: ThreadCommand::List {
1765 all: true,
1766 limit: Some(50)
1767 }
1768 }))
1769 ));
1770
1771 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
1772 assert!(matches!(
1773 cli.command,
1774 Some(Commands::Thread(ThreadArgs {
1775 command: ThreadCommand::Read { ref thread_id }
1776 })) if thread_id == "thread-1"
1777 ));
1778
1779 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
1780 assert!(matches!(
1781 cli.command,
1782 Some(Commands::Thread(ThreadArgs {
1783 command: ThreadCommand::Resume { ref thread_id }
1784 })) if thread_id == "thread-2"
1785 ));
1786
1787 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
1788 assert!(matches!(
1789 cli.command,
1790 Some(Commands::Thread(ThreadArgs {
1791 command: ThreadCommand::Fork { ref thread_id }
1792 })) if thread_id == "thread-3"
1793 ));
1794
1795 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
1796 assert!(matches!(
1797 cli.command,
1798 Some(Commands::Thread(ThreadArgs {
1799 command: ThreadCommand::Archive { ref thread_id }
1800 })) if thread_id == "thread-4"
1801 ));
1802
1803 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
1804 assert!(matches!(
1805 cli.command,
1806 Some(Commands::Thread(ThreadArgs {
1807 command: ThreadCommand::Unarchive { ref thread_id }
1808 })) if thread_id == "thread-5"
1809 ));
1810
1811 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
1812 assert!(matches!(
1813 cli.command,
1814 Some(Commands::Thread(ThreadArgs {
1815 command: ThreadCommand::SetName {
1816 ref thread_id,
1817 ref name
1818 }
1819 })) if thread_id == "thread-6" && name == "My Thread"
1820 ));
1821 }
1822
1823 #[test]
1824 fn parses_sandbox_app_server_and_completion_matrix() {
1825 let cli = parse_ok(&[
1826 "deepseek",
1827 "sandbox",
1828 "check",
1829 "echo hello",
1830 "--ask",
1831 "on-failure",
1832 ]);
1833 assert!(matches!(
1834 cli.command,
1835 Some(Commands::Sandbox(SandboxArgs {
1836 command: SandboxCommand::Check {
1837 ref command,
1838 ask: ApprovalModeArg::OnFailure
1839 }
1840 })) if command == "echo hello"
1841 ));
1842
1843 let cli = parse_ok(&[
1844 "deepseek",
1845 "app-server",
1846 "--host",
1847 "0.0.0.0",
1848 "--port",
1849 "9999",
1850 ]);
1851 assert!(matches!(
1852 cli.command,
1853 Some(Commands::AppServer(AppServerArgs {
1854 ref host,
1855 port: 9999,
1856 stdio: false,
1857 ..
1858 })) if host == "0.0.0.0"
1859 ));
1860
1861 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
1862 assert!(matches!(
1863 cli.command,
1864 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
1865 ));
1866
1867 let cli = parse_ok(&["deepseek", "completion", "bash"]);
1868 assert!(matches!(
1869 cli.command,
1870 Some(Commands::Completion { shell: Shell::Bash })
1871 ));
1872 }
1873
1874 #[test]
1875 fn parses_direct_tui_command_aliases() {
1876 let cli = parse_ok(&["deepseek", "doctor"]);
1877 assert!(matches!(
1878 cli.command,
1879 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
1880 ));
1881
1882 let cli = parse_ok(&["deepseek", "models", "--json"]);
1883 assert!(matches!(
1884 cli.command,
1885 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
1886 ));
1887
1888 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
1889 assert!(matches!(
1890 cli.command,
1891 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
1892 ));
1893
1894 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
1895 assert!(matches!(
1896 cli.command,
1897 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
1898 if args == &["--skills", "--local"]
1899 ));
1900 }
1901
1902 #[test]
1903 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
1904 assert!(should_pick_resume_in_dispatcher(
1905 &["resume".to_string()],
1906 true
1907 ));
1908 assert!(!should_pick_resume_in_dispatcher(
1909 &["resume".to_string(), "--last".to_string()],
1910 true
1911 ));
1912 assert!(!should_pick_resume_in_dispatcher(
1913 &["resume".to_string(), "abc123".to_string()],
1914 true
1915 ));
1916 assert!(!should_pick_resume_in_dispatcher(
1917 &["resume".to_string()],
1918 false
1919 ));
1920 }
1921
1922 #[test]
1923 fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
1924 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
1925 let path = std::env::temp_dir().join(format!(
1926 "deepseek-cli-login-test-{}-{nanos}.toml",
1927 std::process::id()
1928 ));
1929 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
1930 let secrets = no_keyring_secrets();
1931
1932 run_login_command_with_secrets(
1933 &mut store,
1934 LoginArgs {
1935 provider: ProviderArg::Deepseek,
1936 api_key: Some("sk-test".to_string()),
1937 chatgpt: false,
1938 device_code: false,
1939 token: None,
1940 },
1941 &secrets,
1942 )
1943 .expect("login should write config");
1944
1945 assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
1946 assert_eq!(
1947 store.config.providers.deepseek.api_key.as_deref(),
1948 Some("sk-test")
1949 );
1950 assert_eq!(
1951 store.config.default_text_model.as_deref(),
1952 Some("deepseek-v4-pro")
1953 );
1954 let saved = std::fs::read_to_string(&path).expect("config should be written");
1955 assert!(saved.contains("api_key = \"sk-test\""));
1956 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
1957
1958 let _ = std::fs::remove_file(path);
1959 }
1960
1961 #[test]
1962 fn parses_auth_subcommand_matrix() {
1963 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
1964 assert!(matches!(
1965 cli.command,
1966 Some(Commands::Auth(AuthArgs {
1967 command: AuthCommand::Set {
1968 provider: ProviderArg::Deepseek,
1969 api_key: None,
1970 api_key_stdin: false,
1971 }
1972 }))
1973 ));
1974
1975 let cli = parse_ok(&[
1976 "deepseek",
1977 "auth",
1978 "set",
1979 "--provider",
1980 "openrouter",
1981 "--api-key-stdin",
1982 ]);
1983 assert!(matches!(
1984 cli.command,
1985 Some(Commands::Auth(AuthArgs {
1986 command: AuthCommand::Set {
1987 provider: ProviderArg::Openrouter,
1988 api_key: None,
1989 api_key_stdin: true,
1990 }
1991 }))
1992 ));
1993
1994 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
1995 assert!(matches!(
1996 cli.command,
1997 Some(Commands::Auth(AuthArgs {
1998 command: AuthCommand::Get {
1999 provider: ProviderArg::Novita
2000 }
2001 }))
2002 ));
2003
2004 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
2005 assert!(matches!(
2006 cli.command,
2007 Some(Commands::Auth(AuthArgs {
2008 command: AuthCommand::Clear {
2009 provider: ProviderArg::NvidiaNim
2010 }
2011 }))
2012 ));
2013
2014 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
2015 assert!(matches!(
2016 cli.command,
2017 Some(Commands::Auth(AuthArgs {
2018 command: AuthCommand::Set {
2019 provider: ProviderArg::Fireworks,
2020 api_key: None,
2021 api_key_stdin: false,
2022 }
2023 }))
2024 ));
2025
2026 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
2027 assert!(matches!(
2028 cli.command,
2029 Some(Commands::Auth(AuthArgs {
2030 command: AuthCommand::Get {
2031 provider: ProviderArg::Sglang
2032 }
2033 }))
2034 ));
2035
2036 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
2037 assert!(matches!(
2038 cli.command,
2039 Some(Commands::Auth(AuthArgs {
2040 command: AuthCommand::Get {
2041 provider: ProviderArg::Vllm
2042 }
2043 }))
2044 ));
2045
2046 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
2047 assert!(matches!(
2048 cli.command,
2049 Some(Commands::Auth(AuthArgs {
2050 command: AuthCommand::Set {
2051 provider: ProviderArg::Ollama,
2052 api_key: None,
2053 api_key_stdin: false,
2054 }
2055 }))
2056 ));
2057
2058 let cli = parse_ok(&["deepseek", "auth", "list"]);
2059 assert!(matches!(
2060 cli.command,
2061 Some(Commands::Auth(AuthArgs {
2062 command: AuthCommand::List
2063 }))
2064 ));
2065
2066 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
2067 assert!(matches!(
2068 cli.command,
2069 Some(Commands::Auth(AuthArgs {
2070 command: AuthCommand::Migrate { dry_run: false }
2071 }))
2072 ));
2073
2074 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
2075 assert!(matches!(
2076 cli.command,
2077 Some(Commands::Auth(AuthArgs {
2078 command: AuthCommand::Migrate { dry_run: true }
2079 }))
2080 ));
2081 }
2082
2083 #[test]
2084 fn auth_set_writes_to_shared_config_file() {
2085 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2086 use std::sync::Arc;
2087
2088 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2089 let path = std::env::temp_dir().join(format!(
2090 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
2091 std::process::id()
2092 ));
2093 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2094 let inner = Arc::new(InMemoryKeyringStore::new());
2095 let secrets = Secrets::new(inner.clone());
2096
2097 run_auth_command_with_secrets(
2098 &mut store,
2099 AuthCommand::Set {
2100 provider: ProviderArg::Deepseek,
2101 api_key: Some("sk-keyring".to_string()),
2102 api_key_stdin: false,
2103 },
2104 &secrets,
2105 )
2106 .expect("set should succeed");
2107
2108 assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
2109 assert_eq!(
2110 store.config.providers.deepseek.api_key.as_deref(),
2111 Some("sk-keyring")
2112 );
2113 let saved = std::fs::read_to_string(&path).unwrap_or_default();
2114 assert!(saved.contains("api_key = \"sk-keyring\""));
2115 assert_eq!(
2116 inner.get("deepseek").unwrap().as_deref(),
2117 Some("sk-keyring")
2118 );
2119
2120 let _ = std::fs::remove_file(path);
2121 }
2122
2123 #[test]
2124 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
2125 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2126 let path = std::env::temp_dir().join(format!(
2127 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
2128 std::process::id()
2129 ));
2130 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2131 let secrets = no_keyring_secrets();
2132
2133 run_auth_command_with_secrets(
2134 &mut store,
2135 AuthCommand::Set {
2136 provider: ProviderArg::Ollama,
2137 api_key: None,
2138 api_key_stdin: false,
2139 },
2140 &secrets,
2141 )
2142 .expect("ollama auth set should not require a key");
2143
2144 assert_eq!(store.config.provider, ProviderKind::Ollama);
2145 assert_eq!(
2146 store.config.providers.ollama.base_url.as_deref(),
2147 Some("http://localhost:11434/v1")
2148 );
2149 assert_eq!(store.config.providers.ollama.api_key, None);
2150
2151 let _ = std::fs::remove_file(path);
2152 }
2153
2154 #[test]
2155 fn auth_clear_removes_from_config() {
2156 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2157 use std::sync::Arc;
2158
2159 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2160 let path = std::env::temp_dir().join(format!(
2161 "deepseek-cli-auth-clear-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.save().unwrap();
2168
2169 let inner = Arc::new(InMemoryKeyringStore::new());
2170 inner.set("deepseek", "sk-stale").unwrap();
2171 let secrets = Secrets::new(inner.clone());
2172
2173 run_auth_command_with_secrets(
2174 &mut store,
2175 AuthCommand::Clear {
2176 provider: ProviderArg::Deepseek,
2177 },
2178 &secrets,
2179 )
2180 .expect("clear should succeed");
2181
2182 assert!(store.config.api_key.is_none());
2183 assert!(store.config.providers.deepseek.api_key.is_none());
2184 assert_eq!(inner.get("deepseek").unwrap(), None);
2185
2186 let _ = std::fs::remove_file(path);
2187 }
2188
2189 #[test]
2190 fn auth_status_and_list_only_probe_active_provider_keyring() {
2191 use deepseek_secrets::{KeyringStore, SecretsError};
2192 use std::sync::{Arc, Mutex};
2193
2194 #[derive(Default)]
2195 struct RecordingStore {
2196 gets: Mutex<Vec<String>>,
2197 }
2198
2199 impl KeyringStore for RecordingStore {
2200 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
2201 self.gets.lock().unwrap().push(key.to_string());
2202 Ok(None)
2203 }
2204
2205 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
2206 Ok(())
2207 }
2208
2209 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
2210 Ok(())
2211 }
2212
2213 fn backend_name(&self) -> &'static str {
2214 "recording"
2215 }
2216 }
2217
2218 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2219 let path = std::env::temp_dir().join(format!(
2220 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
2221 std::process::id()
2222 ));
2223 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2224 store.config.provider = ProviderKind::Deepseek;
2225 let inner = Arc::new(RecordingStore::default());
2226 let secrets = Secrets::new(inner.clone());
2227
2228 run_auth_command_with_secrets(&mut store, AuthCommand::Status, &secrets)
2229 .expect("status should succeed");
2230 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
2231 .expect("list should succeed");
2232
2233 assert_eq!(
2234 inner.gets.lock().unwrap().as_slice(),
2235 ["deepseek", "deepseek"]
2236 );
2237
2238 let _ = std::fs::remove_file(path);
2239 }
2240
2241 #[test]
2242 fn auth_status_reports_all_active_provider_sources_with_last4() {
2243 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2244 use std::sync::Arc;
2245
2246 let _lock = env_lock();
2247 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
2248
2249 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2250 let path = std::env::temp_dir().join(format!(
2251 "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
2252 std::process::id()
2253 ));
2254 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2255 store.config.provider = ProviderKind::Deepseek;
2256 store.config.api_key = Some("sk-config-3333".to_string());
2257 store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
2258
2259 let inner = Arc::new(InMemoryKeyringStore::new());
2260 inner.set("deepseek", "sk-keyring-2222").unwrap();
2261 let secrets = Secrets::new(inner);
2262
2263 let output = auth_status_lines(&store, &secrets).join("\n");
2264
2265 assert!(output.contains("provider: deepseek"));
2266 assert!(output.contains("active source: config (last4: ...3333)"));
2267 assert!(output.contains("lookup order: config -> keyring -> env"));
2268 assert!(output.contains("config file: "));
2269 assert!(output.contains("set, last4: ...3333"));
2270 assert!(output.contains("keyring: in-memory (test) (set, last4: ...2222)"));
2271 assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
2272 assert!(!output.contains("sk-config-3333"));
2273 assert!(!output.contains("sk-keyring-2222"));
2274 assert!(!output.contains("sk-env-1111"));
2275
2276 let _ = std::fs::remove_file(path);
2277 }
2278
2279 #[test]
2280 fn dispatch_keyring_recovery_self_heals_into_config_file() {
2281 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2282 use std::sync::Arc;
2283
2284 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2285 let path = std::env::temp_dir().join(format!(
2286 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
2287 std::process::id()
2288 ));
2289 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2290 let inner = Arc::new(InMemoryKeyringStore::new());
2291 inner.set("deepseek", "ring-key").unwrap();
2292 let secrets = Secrets::new(inner);
2293
2294 let resolved = resolve_runtime_for_dispatch_with_secrets(
2295 &mut store,
2296 &CliRuntimeOverrides::default(),
2297 &secrets,
2298 );
2299
2300 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
2301 assert_eq!(
2302 resolved.api_key_source,
2303 Some(RuntimeApiKeySource::ConfigFile)
2304 );
2305 assert_eq!(store.config.api_key.as_deref(), Some("ring-key"));
2306 assert_eq!(
2307 store.config.providers.deepseek.api_key.as_deref(),
2308 Some("ring-key")
2309 );
2310
2311 let saved = std::fs::read_to_string(&path).expect("config should be written");
2312 assert!(saved.contains("api_key = \"ring-key\""));
2313
2314 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
2315 &mut store,
2316 &CliRuntimeOverrides::default(),
2317 &no_keyring_secrets(),
2318 );
2319 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
2320 assert_eq!(
2321 resolved_again.api_key_source,
2322 Some(RuntimeApiKeySource::ConfigFile)
2323 );
2324
2325 let _ = std::fs::remove_file(path);
2326 }
2327
2328 #[test]
2329 fn logout_removes_plaintext_provider_keys() {
2330 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2331 let path = std::env::temp_dir().join(format!(
2332 "deepseek-cli-logout-test-{}-{nanos}.toml",
2333 std::process::id()
2334 ));
2335 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2336 store.config.api_key = Some("sk-stale".to_string());
2337 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2338 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
2339 store.save().unwrap();
2340
2341 let secrets = no_keyring_secrets();
2342
2343 run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
2344
2345 assert!(store.config.api_key.is_none());
2346 assert!(store.config.providers.deepseek.api_key.is_none());
2347 assert!(store.config.providers.fireworks.api_key.is_none());
2348
2349 let _ = std::fs::remove_file(path);
2350 }
2351
2352 #[test]
2353 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
2354 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2355 use std::sync::Arc;
2356
2357 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2358 let path = std::env::temp_dir().join(format!(
2359 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
2360 std::process::id()
2361 ));
2362 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2363 store.config.api_key = Some("sk-deep".to_string());
2364 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
2365 store.config.providers.openrouter.api_key = Some("or-key".to_string());
2366 store.config.providers.novita.api_key = Some("nv-key".to_string());
2367 store.save().unwrap();
2368
2369 let inner = Arc::new(InMemoryKeyringStore::new());
2370 let secrets = Secrets::new(inner.clone());
2371
2372 run_auth_command_with_secrets(
2373 &mut store,
2374 AuthCommand::Migrate { dry_run: false },
2375 &secrets,
2376 )
2377 .expect("migrate should succeed");
2378
2379 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
2380 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
2381 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
2382
2383 assert!(store.config.api_key.is_none());
2385 assert!(store.config.providers.deepseek.api_key.is_none());
2386 assert!(store.config.providers.openrouter.api_key.is_none());
2387 assert!(store.config.providers.novita.api_key.is_none());
2388
2389 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
2390 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
2391 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
2392 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
2393
2394 let _ = std::fs::remove_file(path);
2395 }
2396
2397 #[test]
2398 fn auth_migrate_dry_run_does_not_modify_anything() {
2399 use deepseek_secrets::{InMemoryKeyringStore, KeyringStore};
2400 use std::sync::Arc;
2401
2402 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2403 let path = std::env::temp_dir().join(format!(
2404 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
2405 std::process::id()
2406 ));
2407 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2408 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
2409 store.save().unwrap();
2410
2411 let inner = Arc::new(InMemoryKeyringStore::new());
2412 let secrets = Secrets::new(inner.clone());
2413
2414 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
2415 .expect("dry-run should succeed");
2416
2417 assert_eq!(inner.get("openrouter").unwrap(), None);
2418 assert_eq!(
2419 store.config.providers.openrouter.api_key.as_deref(),
2420 Some("or-stay")
2421 );
2422
2423 let _ = std::fs::remove_file(path);
2424 }
2425
2426 #[test]
2427 fn parses_global_override_flags() {
2428 let cli = parse_ok(&[
2429 "deepseek",
2430 "--provider",
2431 "openai",
2432 "--config",
2433 "/tmp/deepseek.toml",
2434 "--profile",
2435 "work",
2436 "--model",
2437 "gpt-4.1",
2438 "--output-mode",
2439 "json",
2440 "--log-level",
2441 "debug",
2442 "--telemetry",
2443 "true",
2444 "--approval-policy",
2445 "on-request",
2446 "--sandbox-mode",
2447 "workspace-write",
2448 "--base-url",
2449 "https://api.openai.com/v1",
2450 "--api-key",
2451 "sk-test",
2452 "--no-alt-screen",
2453 "--no-mouse-capture",
2454 "--skip-onboarding",
2455 "model",
2456 "resolve",
2457 "gpt-4.1",
2458 ]);
2459
2460 assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
2461 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
2462 assert_eq!(cli.profile.as_deref(), Some("work"));
2463 assert_eq!(cli.model.as_deref(), Some("gpt-4.1"));
2464 assert_eq!(cli.output_mode.as_deref(), Some("json"));
2465 assert_eq!(cli.log_level.as_deref(), Some("debug"));
2466 assert_eq!(cli.telemetry, Some(true));
2467 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
2468 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
2469 assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1"));
2470 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
2471 assert!(cli.no_alt_screen);
2472 assert!(cli.no_mouse_capture);
2473 assert!(!cli.mouse_capture);
2474 assert!(cli.skip_onboarding);
2475 }
2476
2477 #[test]
2478 fn build_tui_command_allows_openai_and_forwards_provider_key() {
2479 let _lock = env_lock();
2480 let dir = tempfile::TempDir::new().expect("tempdir");
2481 let custom = dir
2482 .path()
2483 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2484 std::fs::write(&custom, b"").unwrap();
2485 let custom_str = custom.to_string_lossy().into_owned();
2486 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2487
2488 let cli = parse_ok(&["deepseek", "--provider", "openai"]);
2489 let resolved = ResolvedRuntimeOptions {
2490 provider: ProviderKind::Openai,
2491 model: "glm-5".to_string(),
2492 api_key: Some("resolved-openai-key".to_string()),
2493 api_key_source: Some(RuntimeApiKeySource::Keyring),
2494 base_url: "https://openai-compatible.example/v4".to_string(),
2495 auth_mode: Some("api_key".to_string()),
2496 output_mode: None,
2497 log_level: None,
2498 telemetry: false,
2499 approval_policy: None,
2500 sandbox_mode: None,
2501 http_headers: std::collections::BTreeMap::new(),
2502 };
2503
2504 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
2505 assert_eq!(
2506 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
2507 Some("openai")
2508 );
2509 assert_eq!(
2510 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
2511 Some("glm-5")
2512 );
2513 assert_eq!(
2514 command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
2515 Some("https://openai-compatible.example/v4")
2516 );
2517 assert_eq!(
2518 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
2519 Some("resolved-openai-key")
2520 );
2521 assert_eq!(
2522 command_env(&cmd, "OPENAI_API_KEY").as_deref(),
2523 Some("resolved-openai-key")
2524 );
2525 assert_eq!(
2526 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
2527 Some("keyring")
2528 );
2529 }
2530
2531 #[test]
2532 fn parses_top_level_prompt_flag_for_canonical_one_shot() {
2533 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
2534
2535 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
2536 assert_eq!(cli.prompt, None);
2537 }
2538
2539 #[test]
2540 fn root_help_surface_contains_expected_subcommands_and_globals() {
2541 let rendered = help_for(&["deepseek", "--help"]);
2542
2543 for token in [
2544 "run",
2545 "doctor",
2546 "models",
2547 "sessions",
2548 "resume",
2549 "setup",
2550 "login",
2551 "logout",
2552 "auth",
2553 "mcp-server",
2554 "config",
2555 "model",
2556 "thread",
2557 "sandbox",
2558 "app-server",
2559 "completion",
2560 "metrics",
2561 "--provider",
2562 "--model",
2563 "--config",
2564 "--profile",
2565 "--output-mode",
2566 "--log-level",
2567 "--telemetry",
2568 "--base-url",
2569 "--api-key",
2570 "--approval-policy",
2571 "--sandbox-mode",
2572 "--no-alt-screen",
2573 "--mouse-capture",
2574 "--no-mouse-capture",
2575 "--skip-onboarding",
2576 "--prompt",
2577 ] {
2578 assert!(
2579 rendered.contains(token),
2580 "expected help to contain token: {token}"
2581 );
2582 }
2583 }
2584
2585 #[test]
2586 fn subcommand_help_surfaces_are_stable() {
2587 let cases = [
2588 ("config", vec!["get", "set", "unset", "list", "path"]),
2589 ("model", vec!["list", "resolve"]),
2590 (
2591 "thread",
2592 vec![
2593 "list",
2594 "read",
2595 "resume",
2596 "fork",
2597 "archive",
2598 "unarchive",
2599 "set-name",
2600 ],
2601 ),
2602 ("sandbox", vec!["check"]),
2603 (
2604 "app-server",
2605 vec!["--host", "--port", "--config", "--stdio"],
2606 ),
2607 (
2608 "completion",
2609 vec![
2610 "<SHELL>",
2611 "bash",
2612 "source <(deepseek completion bash)",
2613 "~/.local/share/bash-completion/completions/deepseek",
2614 "fpath=(~/.zfunc $fpath)",
2615 "deepseek completion fish > ~/.config/fish/completions/deepseek.fish",
2616 "deepseek completion powershell | Out-String | Invoke-Expression",
2617 ],
2618 ),
2619 ("metrics", vec!["--json", "--since"]),
2620 ];
2621
2622 for (subcommand, expected_tokens) in cases {
2623 let argv = ["deepseek", subcommand, "--help"];
2624 let rendered = help_for(&argv);
2625 for token in expected_tokens {
2626 assert!(
2627 rendered.contains(token),
2628 "expected help for `{subcommand}` to include `{token}`"
2629 );
2630 }
2631 }
2632 }
2633
2634 #[test]
2640 fn sibling_tui_candidate_picks_platform_correct_name() {
2641 let dir = tempfile::TempDir::new().expect("tempdir");
2642 let dispatcher = dir
2643 .path()
2644 .join("deepseek")
2645 .with_extension(std::env::consts::EXE_EXTENSION);
2646 std::fs::write(&dispatcher, b"").unwrap();
2648
2649 assert!(sibling_tui_candidate(&dispatcher).is_none());
2651
2652 let target =
2653 dispatcher.with_file_name(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
2654 std::fs::write(&target, b"").unwrap();
2655
2656 let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
2657 assert_eq!(found, target, "primary platform-correct name wins");
2658 }
2659
2660 #[test]
2661 fn dispatcher_spawn_error_names_path_and_recovery_checks() {
2662 let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
2663 let message = tui_spawn_error(Path::new("C:/tools/deepseek-tui.exe"), &err);
2664
2665 assert!(message.contains("C:/tools/deepseek-tui.exe"));
2666 assert!(message.contains("access is denied"));
2667 assert!(message.contains("where deepseek"));
2668 assert!(message.contains("DEEPSEEK_TUI_BIN"));
2669 }
2670
2671 #[cfg(windows)]
2676 #[test]
2677 fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
2678 let dir = tempfile::TempDir::new().expect("tempdir");
2679 let dispatcher = dir.path().join("deepseek.exe");
2680 std::fs::write(&dispatcher, b"").unwrap();
2681
2682 let suffixless = dispatcher.with_file_name("deepseek-tui");
2684 std::fs::write(&suffixless, b"").unwrap();
2685
2686 let found = sibling_tui_candidate(&dispatcher)
2687 .expect("Windows fallback must locate suffixless deepseek-tui");
2688 assert_eq!(found, suffixless);
2689 }
2690
2691 #[test]
2694 fn locate_sibling_tui_binary_honours_env_override() {
2695 let _lock = env_lock();
2696 let dir = tempfile::TempDir::new().expect("tempdir");
2697 let custom = dir
2698 .path()
2699 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2700 std::fs::write(&custom, b"").unwrap();
2701 let custom_str = custom.to_string_lossy().into_owned();
2702 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2703
2704 let resolved = locate_sibling_tui_binary().expect("override must resolve");
2705 assert_eq!(resolved, custom);
2706 }
2707}