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