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