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