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