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, ProviderSource, ResolvedRuntimeOptions,
18 RuntimeApiKeySource,
19};
20use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
21use codewhale_mcp::{McpServerDefinition, run_stdio_server};
22use codewhale_secrets::Secrets;
23use codewhale_state::{StateStore, ThreadListFilters};
24
25#[derive(Debug, Clone, Copy, ValueEnum)]
26enum ProviderArg {
27 Deepseek,
28 NvidiaNim,
29 Openai,
30 Atlascloud,
31 WanjieArk,
32 Volcengine,
33 Openrouter,
34 XiaomiMimo,
35 Novita,
36 Fireworks,
37 Siliconflow,
38 Arcee,
39 Moonshot,
40 Sglang,
41 Vllm,
42 Ollama,
43 Huggingface,
44 Together,
45 OpenaiCodex,
46}
47
48impl From<ProviderArg> for ProviderKind {
49 fn from(value: ProviderArg) -> Self {
50 match value {
51 ProviderArg::Deepseek => ProviderKind::Deepseek,
52 ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
53 ProviderArg::Openai => ProviderKind::Openai,
54 ProviderArg::Atlascloud => ProviderKind::Atlascloud,
55 ProviderArg::WanjieArk => ProviderKind::WanjieArk,
56 ProviderArg::Volcengine => ProviderKind::Volcengine,
57 ProviderArg::Openrouter => ProviderKind::Openrouter,
58 ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
59 ProviderArg::Novita => ProviderKind::Novita,
60 ProviderArg::Fireworks => ProviderKind::Fireworks,
61 ProviderArg::Siliconflow => ProviderKind::Siliconflow,
62 ProviderArg::Arcee => ProviderKind::Arcee,
63 ProviderArg::Moonshot => ProviderKind::Moonshot,
64 ProviderArg::Sglang => ProviderKind::Sglang,
65 ProviderArg::Vllm => ProviderKind::Vllm,
66 ProviderArg::Ollama => ProviderKind::Ollama,
67 ProviderArg::Huggingface => ProviderKind::Huggingface,
68 ProviderArg::Together => ProviderKind::Together,
69 ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
70 }
71 }
72}
73
74#[derive(Debug, Parser)]
75#[command(
76 name = "codewhale",
77 version = env!("DEEPSEEK_BUILD_VERSION"),
78 bin_name = "codewhale",
79 override_usage = "codewhale [OPTIONS] [PROMPT]\n codewhale [OPTIONS] <COMMAND> [ARGS]"
80)]
81struct Cli {
82 #[arg(long)]
83 config: Option<PathBuf>,
84 #[arg(long)]
85 profile: Option<String>,
86 #[arg(
87 long,
88 value_enum,
89 help = "Advanced provider selector for non-TUI registry/config commands"
90 )]
91 provider: Option<ProviderArg>,
92 #[arg(long)]
93 model: Option<String>,
94 #[arg(long = "output-mode")]
95 output_mode: Option<String>,
96 #[arg(
97 long = "verbosity",
98 value_name = "LEVEL",
99 help = "Controls transcript and output verbosity (normal, concise)"
100 )]
101 verbosity: Option<String>,
102 #[arg(long = "log-level")]
103 log_level: Option<String>,
104 #[arg(long)]
105 telemetry: Option<bool>,
106 #[arg(long)]
107 approval_policy: Option<String>,
108 #[arg(long)]
109 sandbox_mode: Option<String>,
110 #[arg(long)]
111 api_key: Option<String>,
112 #[arg(long)]
113 base_url: Option<String>,
114 #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
116 workspace: Option<PathBuf>,
117 #[arg(long = "no-alt-screen", hide = true)]
118 no_alt_screen: bool,
119 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
120 mouse_capture: bool,
121 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
122 no_mouse_capture: bool,
123 #[arg(long = "skip-onboarding")]
124 skip_onboarding: bool,
125 #[arg(long)]
127 yolo: bool,
128 #[arg(short = 'c', long = "continue")]
130 continue_session: bool,
131 #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
132 prompt_flag: Option<String>,
133 #[arg(
134 value_name = "PROMPT",
135 trailing_var_arg = true,
136 allow_hyphen_values = true
137 )]
138 prompt: Vec<String>,
139 #[command(subcommand)]
140 command: Option<Commands>,
141}
142
143#[derive(Debug, Subcommand)]
144enum Commands {
145 Run(RunArgs),
147 Doctor(TuiPassthroughArgs),
149 Models(TuiPassthroughArgs),
151 #[command(visible_alias = "tts")]
153 Speech(TuiPassthroughArgs),
154 Sessions(TuiPassthroughArgs),
156 Resume(TuiPassthroughArgs),
158 Fork(TuiPassthroughArgs),
160 Init(TuiPassthroughArgs),
162 Setup(TuiPassthroughArgs),
164 #[command(after_help = "\
166Examples:
167 codewhale exec \"explain this function\"
168 codewhale exec --auto \"list crates/ with ls\"
169 codewhale exec --auto --output-format stream-json \"fix the failing test\"
170
171Common forwarded flags:
172 --auto Enable tool-backed agent mode with auto-approvals
173 --json Emit summary JSON
174 --resume <SESSION_ID> Resume a previous session by ID or prefix
175 --session-id <SESSION_ID> Resume a previous session by ID or prefix
176 --continue Continue the most recent session for this workspace
177 --output-format <FORMAT> Output format: text or stream-json
178
179Plain `codewhale exec` is a one-shot model response. Use `--auto` for
180non-interactive filesystem/shell tool use, matching the supported automation
181path used by stream-json wrappers.
182")]
183 Exec(TuiPassthroughArgs),
184 #[command(after_help = "\
186Examples:
187 codewhale swebench run --instance-id django__django-12345 --issue-file issue.md
188 codewhale swebench export --instance-id django__django-12345 --predictions-path all_preds.jsonl
189
190This command forwards to the TUI runtime. `run` invokes tool-backed agent mode
191and writes a SWE-bench-compatible JSONL prediction row from the resulting
192working-tree diff. `export` only writes the current diff.
193")]
194 Swebench(TuiPassthroughArgs),
195 Review(TuiPassthroughArgs),
197 Apply(TuiPassthroughArgs),
199 Eval(TuiPassthroughArgs),
201 Mcp(TuiPassthroughArgs),
203 Features(TuiPassthroughArgs),
205 Serve(TuiPassthroughArgs),
207 Completions(TuiPassthroughArgs),
209 Login(LoginArgs),
211 Logout,
213 Auth(AuthArgs),
215 McpServer,
217 Config(ConfigArgs),
219 Model(ModelArgs),
221 Thread(ThreadArgs),
223 Sandbox(SandboxArgs),
225 AppServer(AppServerArgs),
227 #[command(after_help = r#"Examples:
229 Bash (current shell only):
230 source <(codewhale completion bash)
231
232 Bash (persistent, Linux/bash-completion):
233 mkdir -p ~/.local/share/bash-completion/completions
234 codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
235 # Requires bash-completion to be installed and loaded by your shell.
236
237 Zsh:
238 mkdir -p ~/.zfunc
239 codewhale completion zsh > ~/.zfunc/_codewhale
240 # Add to ~/.zshrc if needed:
241 # fpath=(~/.zfunc $fpath)
242 # autoload -Uz compinit && compinit
243
244 Fish:
245 mkdir -p ~/.config/fish/completions
246 codewhale completion fish > ~/.config/fish/completions/codewhale.fish
247
248 PowerShell (current shell only):
249 codewhale completion powershell | Out-String | Invoke-Expression
250
251The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
252 Completion {
253 #[arg(value_enum)]
254 shell: Shell,
255 },
256 Metrics(MetricsArgs),
258 Update(UpdateArgs),
260}
261
262#[derive(Debug, Args)]
263struct UpdateArgs {
264 #[arg(long)]
266 beta: bool,
267 #[arg(long)]
269 check: bool,
270 #[arg(long, value_name = "URL")]
272 proxy: Option<String>,
273}
274
275#[derive(Debug, Args)]
276struct MetricsArgs {
277 #[arg(long)]
279 json: bool,
280 #[arg(long, value_name = "DURATION")]
282 since: Option<String>,
283}
284
285#[derive(Debug, Args)]
286struct RunArgs {
287 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
288 args: Vec<String>,
289}
290
291#[derive(Debug, Args, Clone)]
292struct TuiPassthroughArgs {
293 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
294 args: Vec<String>,
295}
296
297#[derive(Debug, Args)]
298struct LoginArgs {
299 #[arg(long, value_enum, hide = true)]
300 provider: Option<ProviderArg>,
301 #[arg(long)]
302 api_key: Option<String>,
303}
304
305#[derive(Debug, Args)]
306struct AuthArgs {
307 #[command(subcommand)]
308 command: AuthCommand,
309}
310
311#[derive(Debug, Subcommand)]
312enum AuthCommand {
313 Status {
317 #[arg(long, value_enum)]
319 provider: Option<ProviderArg>,
320 },
321 Set {
325 #[arg(long, value_enum)]
326 provider: ProviderArg,
327 #[arg(long)]
329 api_key: Option<String>,
330 #[arg(long = "api-key-stdin", default_value_t = false)]
332 api_key_stdin: bool,
333 },
334 Get {
337 #[arg(long, value_enum)]
338 provider: ProviderArg,
339 },
340 Clear {
342 #[arg(long, value_enum)]
343 provider: ProviderArg,
344 },
345 List,
348 #[command(hide = true)]
350 Migrate {
351 #[arg(long, default_value_t = false)]
353 dry_run: bool,
354 },
355}
356
357#[derive(Debug, Args)]
358struct ConfigArgs {
359 #[command(subcommand)]
360 command: ConfigCommand,
361}
362
363#[derive(Debug, Subcommand)]
364enum ConfigCommand {
365 Get { key: String },
366 Set { key: String, value: String },
367 Unset { key: String },
368 List,
369 Path,
370}
371
372#[derive(Debug, Args)]
373struct ModelArgs {
374 #[command(subcommand)]
375 command: ModelCommand,
376}
377
378#[derive(Debug, Subcommand)]
379enum ModelCommand {
380 List {
381 #[arg(long, value_enum)]
382 provider: Option<ProviderArg>,
383 },
384 Resolve {
385 model: Option<String>,
386 #[arg(long, value_enum)]
387 provider: Option<ProviderArg>,
388 },
389}
390
391#[derive(Debug, Args)]
392struct ThreadArgs {
393 #[command(subcommand)]
394 command: ThreadCommand,
395}
396
397#[derive(Debug, Subcommand)]
398enum ThreadCommand {
399 List {
400 #[arg(long, default_value_t = false)]
401 all: bool,
402 #[arg(long)]
403 limit: Option<usize>,
404 },
405 Read {
406 thread_id: String,
407 },
408 Resume {
409 thread_id: String,
410 },
411 Fork {
412 thread_id: String,
413 },
414 Archive {
415 thread_id: String,
416 },
417 Unarchive {
418 thread_id: String,
419 },
420 SetName {
421 thread_id: String,
422 name: String,
423 },
424 ClearName {
427 thread_id: String,
428 },
429}
430
431#[derive(Debug, Args)]
432struct SandboxArgs {
433 #[command(subcommand)]
434 command: SandboxCommand,
435}
436
437#[derive(Debug, Subcommand)]
438enum SandboxCommand {
439 Check {
440 command: String,
441 #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
442 ask: ApprovalModeArg,
443 },
444}
445
446#[derive(Debug, Clone, Copy, ValueEnum)]
447enum ApprovalModeArg {
448 UnlessTrusted,
449 OnFailure,
450 OnRequest,
451 Never,
452}
453
454impl From<ApprovalModeArg> for AskForApproval {
455 fn from(value: ApprovalModeArg) -> Self {
456 match value {
457 ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
458 ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
459 ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
460 ApprovalModeArg::Never => AskForApproval::Never,
461 }
462 }
463}
464
465#[derive(Debug, Args)]
466struct AppServerArgs {
467 #[arg(long, default_value = "127.0.0.1")]
468 host: String,
469 #[arg(long, default_value_t = 8787)]
470 port: u16,
471 #[arg(long)]
472 config: Option<PathBuf>,
473 #[arg(long = "auth-token")]
474 auth_token: Option<String>,
475 #[arg(long, default_value_t = false)]
476 insecure_no_auth: bool,
477 #[arg(long = "cors-origin")]
478 cors_origin: Vec<String>,
479 #[arg(long, default_value_t = false)]
480 stdio: bool,
481}
482
483const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
484
485fn install_rustls_crypto_provider() {
486 let _ = rustls::crypto::ring::default_provider().install_default();
487}
488
489pub fn run_cli() -> std::process::ExitCode {
490 install_rustls_crypto_provider();
491
492 match run() {
493 Ok(()) => std::process::ExitCode::SUCCESS,
494 Err(err) => {
495 eprintln!("error: {err}");
503 for cause in err.chain().skip(1) {
504 eprintln!(" caused by: {cause}");
505 }
506 std::process::ExitCode::FAILURE
507 }
508 }
509}
510
511fn run() -> Result<()> {
512 let mut cli = Cli::parse();
513
514 let mut store = ConfigStore::load(cli.config.clone())?;
515 let runtime_overrides = CliRuntimeOverrides {
516 provider: cli.provider.map(Into::into),
517 model: cli.model.clone(),
518 api_key: cli.api_key.clone(),
519 base_url: cli.base_url.clone(),
520 auth_mode: None,
521 output_mode: cli.output_mode.clone(),
522 log_level: cli.log_level.clone(),
523 telemetry: cli.telemetry,
524 approval_policy: cli.approval_policy.clone(),
525 sandbox_mode: cli.sandbox_mode.clone(),
526 yolo: Some(cli.yolo),
527 verbosity: cli.verbosity.clone(),
528 };
529 let command = cli.command.take();
530
531 match command {
532 Some(Commands::Run(args)) => {
533 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
534 delegate_to_tui(&cli, &resolved_runtime, args.args)
535 }
536 Some(Commands::Doctor(args)) => {
537 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
538 delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
539 }
540 Some(Commands::Models(args)) => {
541 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
542 delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
543 }
544 Some(Commands::Speech(args)) => {
545 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
546 delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
547 }
548 Some(Commands::Sessions(args)) => {
549 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
550 delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
551 }
552 Some(Commands::Resume(args)) => {
553 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
554 run_resume_command(&cli, &resolved_runtime, args)
555 }
556 Some(Commands::Fork(args)) => {
557 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
558 delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
559 }
560 Some(Commands::Init(args)) => {
561 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
562 delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
563 }
564 Some(Commands::Setup(args)) => {
565 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
566 delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
567 }
568 Some(Commands::Exec(args)) => {
569 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
570 delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
571 }
572 Some(Commands::Swebench(args)) => {
573 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
574 delegate_to_tui(&cli, &resolved_runtime, tui_args("swebench", args))
575 }
576 Some(Commands::Review(args)) => {
577 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
578 delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
579 }
580 Some(Commands::Apply(args)) => {
581 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
582 delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
583 }
584 Some(Commands::Eval(args)) => {
585 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
586 delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
587 }
588 Some(Commands::Mcp(args)) => {
589 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
590 delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
591 }
592 Some(Commands::Features(args)) => {
593 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
594 delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
595 }
596 Some(Commands::Serve(args)) => {
597 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
598 delegate_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
599 }
600 Some(Commands::Completions(args)) => {
601 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
602 delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
603 }
604 Some(Commands::Login(args)) => run_login_command(&mut store, args),
605 Some(Commands::Logout) => run_logout_command(&mut store),
606 Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
607 Some(Commands::McpServer) => run_mcp_server_command(&mut store),
608 Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
609 Some(Commands::Model(args)) => run_model_command(args.command),
610 Some(Commands::Thread(args)) => run_thread_command(args.command),
611 Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
612 Some(Commands::AppServer(args)) => run_app_server_command(args),
613 Some(Commands::Completion { shell }) => {
614 let mut cmd = Cli::command();
615 generate(shell, &mut cmd, "codewhale", &mut io::stdout());
616 Ok(())
617 }
618 Some(Commands::Metrics(args)) => run_metrics_command(args),
619 Some(Commands::Update(args)) => update::run_update(args.beta, args.check, args.proxy),
620 None => {
621 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
622 let forwarded = root_tui_passthrough(&cli)?;
623 delegate_to_tui(&cli, &resolved_runtime, forwarded)
624 }
625 }
626}
627
628fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
629 let mut forwarded = Vec::new();
630 if cli.continue_session {
631 forwarded.push("--continue".to_string());
632 }
633
634 let prompt =
635 cli.prompt_flag
636 .iter()
637 .chain(cli.prompt.iter())
638 .fold(String::new(), |mut acc, part| {
639 if !acc.is_empty() {
640 acc.push(' ');
641 }
642 acc.push_str(part);
643 acc
644 });
645 if !prompt.is_empty() {
646 if cli.continue_session {
647 bail!(
648 "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
649 );
650 }
651 forwarded.push("--prompt".to_string());
652 forwarded.push(prompt);
653 }
654
655 Ok(forwarded)
656}
657
658fn resolve_runtime_for_dispatch(
659 store: &mut ConfigStore,
660 runtime_overrides: &CliRuntimeOverrides,
661) -> ResolvedRuntimeOptions {
662 let runtime_secrets = Secrets::auto_detect();
663 resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
664}
665
666fn resolve_runtime_for_dispatch_with_secrets(
667 store: &mut ConfigStore,
668 runtime_overrides: &CliRuntimeOverrides,
669 secrets: &Secrets,
670) -> ResolvedRuntimeOptions {
671 let mut resolved = store
672 .config
673 .resolve_runtime_options_with_secrets(runtime_overrides, secrets);
674
675 if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)
676 && !provider_config_set(store, resolved.provider)
677 && let Some(api_key) = resolved.api_key.clone()
678 {
679 write_provider_api_key_to_config(store, resolved.provider, &api_key);
680 match store.save() {
681 Ok(()) => {
682 eprintln!(
683 "info: recovered API key from secret store and saved it to {}",
684 store.path().display()
685 );
686 resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);
687 }
688 Err(err) => {
689 eprintln!(
690 "warning: recovered API key from secret store but failed to save {}: {err}",
691 store.path().display()
692 );
693 }
694 }
695 }
696
697 resolved
698}
699
700fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
701 let mut forwarded = Vec::with_capacity(args.args.len() + 1);
702 forwarded.push(command.to_string());
703 forwarded.extend(args.args);
704 forwarded
705}
706
707fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
708 run_login_command_with_secrets(store, args, &Secrets::auto_detect())
709}
710
711fn run_login_command_with_secrets(
712 store: &mut ConfigStore,
713 args: LoginArgs,
714 secrets: &Secrets,
715) -> Result<()> {
716 let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
717 store.config.provider = provider;
718
719 let api_key = match args.api_key {
720 Some(v) => v,
721 None => read_api_key_from_stdin()?,
722 };
723 write_provider_api_key_to_config(store, provider, &api_key);
724 let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
725 store.save()?;
726 let destination = if keyring_saved {
727 format!("{} and {}", store.path().display(), secrets.backend_name())
728 } else {
729 store.path().display().to_string()
730 };
731 if provider == ProviderKind::Deepseek {
732 println!("logged in using API key mode (deepseek); saved key to {destination}");
733 } else {
734 println!(
735 "logged in using API key mode ({}); saved key to {destination}",
736 provider.as_str(),
737 );
738 }
739 Ok(())
740}
741
742fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
743 run_logout_command_with_secrets(store, &Secrets::auto_detect())
744}
745
746fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
747 let active_provider = store.config.provider;
748 store.config.api_key = None;
749 for provider in PROVIDER_LIST {
750 clear_provider_api_key_from_config(store, provider);
751 }
752 clear_provider_api_key_from_keyring(secrets, active_provider);
753 store.config.auth_mode = None;
754 store.save()?;
755 println!("logged out");
756 Ok(())
757}
758
759fn provider_slot(provider: ProviderKind) -> &'static str {
761 match provider {
762 ProviderKind::Deepseek => "deepseek",
763 ProviderKind::NvidiaNim => "nvidia-nim",
764 ProviderKind::Openai => "openai",
765 ProviderKind::Atlascloud => "atlascloud",
766 ProviderKind::WanjieArk => "wanjie-ark",
767 ProviderKind::Volcengine => "volcengine",
768 ProviderKind::Openrouter => "openrouter",
769 ProviderKind::XiaomiMimo => "xiaomi-mimo",
770 ProviderKind::Novita => "novita",
771 ProviderKind::Fireworks => "fireworks",
772 ProviderKind::Siliconflow => "siliconflow",
773 ProviderKind::SiliconflowCN => "siliconflow",
774 ProviderKind::Arcee => "arcee",
775 ProviderKind::Moonshot => "moonshot",
776 ProviderKind::Sglang => "sglang",
777 ProviderKind::Vllm => "vllm",
778 ProviderKind::Ollama => "ollama",
779 ProviderKind::Huggingface => "huggingface",
780 ProviderKind::Together => "together",
781 ProviderKind::OpenaiCodex => "openai-codex",
782 ProviderKind::Anthropic => "anthropic",
783 }
784}
785
786const PROVIDER_LIST: [ProviderKind; 20] = [
788 ProviderKind::Deepseek,
789 ProviderKind::NvidiaNim,
790 ProviderKind::Openai,
791 ProviderKind::Atlascloud,
792 ProviderKind::WanjieArk,
793 ProviderKind::Volcengine,
794 ProviderKind::Openrouter,
795 ProviderKind::XiaomiMimo,
796 ProviderKind::Novita,
797 ProviderKind::Fireworks,
798 ProviderKind::Siliconflow,
799 ProviderKind::SiliconflowCN,
800 ProviderKind::Arcee,
801 ProviderKind::Moonshot,
802 ProviderKind::Sglang,
803 ProviderKind::Vllm,
804 ProviderKind::Ollama,
805 ProviderKind::Huggingface,
806 ProviderKind::Together,
807 ProviderKind::OpenaiCodex,
808];
809
810fn provider_is_supported_by_tui(provider: ProviderKind) -> bool {
811 matches!(
812 provider,
813 ProviderKind::Deepseek
814 | ProviderKind::NvidiaNim
815 | ProviderKind::Openai
816 | ProviderKind::Atlascloud
817 | ProviderKind::WanjieArk
818 | ProviderKind::Volcengine
819 | ProviderKind::Openrouter
820 | ProviderKind::XiaomiMimo
821 | ProviderKind::Novita
822 | ProviderKind::Fireworks
823 | ProviderKind::Siliconflow
824 | ProviderKind::SiliconflowCN
825 | ProviderKind::Arcee
826 | ProviderKind::Moonshot
827 | ProviderKind::Sglang
828 | ProviderKind::Vllm
829 | ProviderKind::Ollama
830 | ProviderKind::Huggingface
831 | ProviderKind::Together
832 | ProviderKind::OpenaiCodex
833 )
834}
835
836fn supported_tui_providers_csv() -> String {
837 ProviderKind::ALL
838 .iter()
839 .copied()
840 .filter(|provider| provider_is_supported_by_tui(*provider))
841 .map(ProviderKind::as_str)
842 .collect::<Vec<_>>()
843 .join(", ")
844}
845
846#[cfg(test)]
847fn no_keyring_secrets() -> Secrets {
848 Secrets::new(std::sync::Arc::new(
849 codewhale_secrets::InMemoryKeyringStore::new(),
850 ))
851}
852
853fn write_provider_api_key_to_config(
854 store: &mut ConfigStore,
855 provider: ProviderKind,
856 api_key: &str,
857) {
858 store.config.auth_mode = Some("api_key".to_string());
859 store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
860 if provider == ProviderKind::Deepseek {
861 store.config.api_key = Some(api_key.to_string());
862 if store.config.default_text_model.is_none() {
863 store.config.default_text_model = Some(
864 store
865 .config
866 .providers
867 .deepseek
868 .model
869 .clone()
870 .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
871 );
872 }
873 }
874}
875
876fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
877 store.config.providers.for_provider_mut(provider).api_key = None;
878 if provider == ProviderKind::Deepseek {
879 store.config.api_key = None;
880 }
881}
882
883fn provider_env_set(provider: ProviderKind) -> bool {
884 provider_env_value(provider).is_some()
885}
886
887fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
888 match provider {
889 ProviderKind::Deepseek => &["DEEPSEEK_API_KEY"],
890 ProviderKind::Openrouter => &["OPENROUTER_API_KEY"],
891 ProviderKind::XiaomiMimo => &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"],
892 ProviderKind::Novita => &["NOVITA_API_KEY"],
893 ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
894 ProviderKind::Fireworks => &["FIREWORKS_API_KEY"],
895 ProviderKind::Siliconflow => &["SILICONFLOW_API_KEY"],
896 ProviderKind::SiliconflowCN => &["SILICONFLOW_API_KEY"],
897 ProviderKind::Arcee => &["ARCEE_API_KEY"],
898 ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
899 ProviderKind::Sglang => &["SGLANG_API_KEY"],
900 ProviderKind::Vllm => &["VLLM_API_KEY"],
901 ProviderKind::Ollama => &["OLLAMA_API_KEY"],
902 ProviderKind::Huggingface => &["HUGGINGFACE_API_KEY", "HF_TOKEN"],
903 ProviderKind::Openai => &["OPENAI_API_KEY"],
904 ProviderKind::Atlascloud => &["ATLASCLOUD_API_KEY"],
905 ProviderKind::Volcengine => &[
906 "VOLCENGINE_API_KEY",
907 "VOLCENGINE_ARK_API_KEY",
908 "ARK_API_KEY",
909 ],
910 ProviderKind::WanjieArk => &[
911 "WANJIE_ARK_API_KEY",
912 "WANJIE_API_KEY",
913 "WANJIE_MAAS_API_KEY",
914 ],
915 ProviderKind::Together => &["TOGETHER_API_KEY"],
916 ProviderKind::OpenaiCodex => &["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"],
917 ProviderKind::Anthropic => &["ANTHROPIC_API_KEY"],
918 }
919}
920
921fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
922 provider_env_vars(provider).iter().find_map(|var| {
923 std::env::var(var)
924 .ok()
925 .filter(|value| !value.trim().is_empty())
926 .map(|value| (*var, value))
927 })
928}
929
930fn openai_codex_auth_file_path() -> PathBuf {
931 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
932 let path = PathBuf::from(path);
933 if !path.as_os_str().is_empty() {
934 return path;
935 }
936 }
937
938 let codex_home = std::env::var("CODEX_HOME")
939 .map(PathBuf::from)
940 .unwrap_or_else(|_| {
941 dirs::home_dir()
942 .unwrap_or_else(|| PathBuf::from("."))
943 .join(".codex")
944 });
945 codex_home.join("auth.json")
946}
947
948fn provider_oauth_file_path(provider: ProviderKind) -> Option<PathBuf> {
949 (provider == ProviderKind::OpenaiCodex).then(openai_codex_auth_file_path)
950}
951
952fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
953 let slot = store
954 .config
955 .providers
956 .for_provider(provider)
957 .api_key
958 .as_deref();
959 let root = (provider == ProviderKind::Deepseek)
960 .then_some(store.config.api_key.as_deref())
961 .flatten();
962 slot.or(root).filter(|v| !v.trim().is_empty())
963}
964
965fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
966 provider_config_api_key(store, provider).is_some()
967}
968
969fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
970 secrets
971 .get(provider_slot(provider))
972 .ok()
973 .flatten()
974 .filter(|v| !v.trim().is_empty())
975}
976
977fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
978 provider_keyring_api_key(secrets, provider).is_some()
979}
980
981fn write_provider_api_key_to_keyring(
982 secrets: &Secrets,
983 provider: ProviderKind,
984 api_key: &str,
985) -> bool {
986 secrets.set(provider_slot(provider), api_key).is_ok()
987}
988
989fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
990 let _ = secrets.delete(provider_slot(provider));
991}
992
993fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
994 let active_provider = store.config.provider;
995 let mut lines = Vec::new();
996 lines.push(format!(
997 "active provider: {} (set via config or CODEWHALE_PROVIDER)",
998 active_provider.as_str()
999 ));
1000 lines.push(String::new());
1001 lines.push(format!(
1002 "{:<14} {:<8} {:<10} {:<8} {}",
1003 "provider", "config", "keyring", "env", "status"
1004 ));
1005 lines.push("-".repeat(70));
1006
1007 for provider in PROVIDER_LIST {
1008 let config_key = provider_config_api_key(store, provider);
1009 let keyring_key = provider_keyring_api_key(secrets, provider);
1010 let env_key = provider_env_value(provider);
1011 let oauth_file_present = provider_oauth_file_path(provider).is_some_and(|p| p.exists());
1012
1013 let config_status = config_key.map(|_| "set").unwrap_or("-");
1014 let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
1015 let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
1016
1017 let source = if provider == ProviderKind::OpenaiCodex {
1018 if env_key.is_some() {
1022 "env"
1023 } else if oauth_file_present {
1024 "oauth file"
1025 } else {
1026 "unset"
1027 }
1028 } else if config_key.is_some() {
1029 "config"
1030 } else if keyring_key.is_some() {
1031 "keyring"
1032 } else if env_key.is_some() {
1033 "env"
1034 } else if oauth_file_present {
1035 "oauth file"
1036 } else {
1037 "unset"
1038 };
1039
1040 let active_marker = if provider == active_provider {
1041 " *"
1042 } else {
1043 ""
1044 };
1045
1046 lines.push(format!(
1047 "{:<14} {:<8} {:<10} {:<8} {}{}",
1048 provider.as_str(),
1049 config_status,
1050 keyring_status,
1051 env_status,
1052 source,
1053 active_marker
1054 ));
1055 }
1056
1057 lines.push(String::new());
1058 lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
1059 lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
1060 lines
1061}
1062
1063fn auth_status_lines_for_provider(
1064 store: &ConfigStore,
1065 secrets: &Secrets,
1066 provider: ProviderKind,
1067) -> Vec<String> {
1068 let config_key = provider_config_api_key(store, provider);
1069 let keyring_key = provider_keyring_api_key(secrets, provider);
1070 let env_key = provider_env_value(provider);
1071 let oauth_file = provider_oauth_file_path(provider);
1072 let oauth_file_present = oauth_file.as_ref().is_some_and(|path| path.exists());
1073
1074 let active_source = if provider == ProviderKind::OpenaiCodex {
1075 if env_key.is_some() {
1076 "env"
1077 } else if oauth_file_present {
1078 "Codex OAuth file"
1079 } else {
1080 "missing"
1081 }
1082 } else if config_key.is_some() {
1083 "config"
1084 } else if keyring_key.is_some() {
1085 "secret store"
1086 } else if env_key.is_some() {
1087 "env"
1088 } else {
1089 "missing"
1090 };
1091 let active_last4 = if provider == ProviderKind::OpenaiCodex {
1092 env_key.as_ref().map(|(_, value)| last4_label(value))
1093 } else {
1094 config_key
1095 .map(last4_label)
1096 .or_else(|| keyring_key.as_deref().map(last4_label))
1097 .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
1098 };
1099 let active_label = active_last4
1100 .map(|last4| format!("{active_source} (last4: {last4})"))
1101 .unwrap_or_else(|| active_source.to_string());
1102
1103 let env_var_label = env_key
1104 .as_ref()
1105 .map(|(name, _)| (*name).to_string())
1106 .unwrap_or_else(|| provider_env_vars(provider).join("/"));
1107 let env_status = env_key
1108 .as_ref()
1109 .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
1110 .unwrap_or_else(|| "unset".to_string());
1111
1112 let is_active = provider == store.config.provider;
1113 let active_marker = if is_active { " (active provider)" } else { "" };
1114
1115 let provider_cfg = store.config.providers.for_provider(provider);
1116 let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
1117 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
1118
1119 let lookup_order = if provider == ProviderKind::OpenaiCodex {
1120 "lookup order: env -> Codex OAuth file".to_string()
1121 } else {
1122 "lookup order: config -> secret store -> env".to_string()
1123 };
1124 let auth_mode = if provider == ProviderKind::OpenaiCodex {
1125 "codex_oauth"
1126 } else {
1127 store.config.auth_mode.as_deref().unwrap_or("api_key")
1128 };
1129
1130 let mut lines = vec![
1131 format!("provider: {}{}", provider.as_str(), active_marker),
1132 format!("route: {}", base_url),
1133 format!("model: {}", model),
1134 format!("auth mode: {auth_mode}"),
1135 format!("active source: {active_label}"),
1136 lookup_order,
1137 format!(
1138 "config file: {} ({})",
1139 store.path().display(),
1140 source_status(config_key, "missing")
1141 ),
1142 format!(
1143 "secret store: {} ({})",
1144 secrets.backend_name(),
1145 source_status(keyring_key.as_deref(), "missing")
1146 ),
1147 format!("env var: {env_var_label} ({env_status})"),
1148 ];
1149 if let Some(path) = oauth_file {
1150 let status = if path.exists() { "present" } else { "missing" };
1151 lines.push(format!("Codex OAuth file: {} ({status})", path.display()));
1152 }
1153 lines
1154}
1155
1156fn source_status(value: Option<&str>, missing_label: &str) -> String {
1157 value
1158 .map(|v| format!("set, last4: {}", last4_label(v)))
1159 .unwrap_or_else(|| missing_label.to_string())
1160}
1161
1162fn last4_label(value: &str) -> String {
1163 let trimmed = value.trim();
1164 let chars: Vec<char> = trimmed.chars().collect();
1165 if chars.len() <= 4 {
1166 return "<redacted>".to_string();
1167 }
1168 let last4: String = chars[chars.len() - 4..].iter().collect();
1169 format!("...{last4}")
1170}
1171
1172fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
1173 run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
1174}
1175
1176fn run_auth_command_with_secrets(
1177 store: &mut ConfigStore,
1178 command: AuthCommand,
1179 secrets: &Secrets,
1180) -> Result<()> {
1181 match command {
1182 AuthCommand::Status { provider } => {
1183 match provider {
1184 Some(p) => {
1185 let provider: ProviderKind = p.into();
1186 for line in auth_status_lines_for_provider(store, secrets, provider) {
1187 println!("{line}");
1188 }
1189 }
1190 None => {
1191 for line in auth_status_all_providers(store, secrets) {
1192 println!("{line}");
1193 }
1194 }
1195 }
1196 Ok(())
1197 }
1198 AuthCommand::Set {
1199 provider,
1200 api_key,
1201 api_key_stdin,
1202 } => {
1203 let provider: ProviderKind = provider.into();
1204 let slot = provider_slot(provider);
1205 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
1206 let provider_cfg = store.config.providers.for_provider_mut(provider);
1207 if provider_cfg.base_url.is_none() {
1208 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
1209 }
1210 store.save()?;
1211 println!(
1212 "configured {slot} provider in {} (API key optional)",
1213 store.path().display()
1214 );
1215 return Ok(());
1216 }
1217 let api_key = match (api_key, api_key_stdin) {
1218 (Some(v), _) => v,
1219 (None, true) => read_api_key_from_stdin()?,
1220 (None, false) => prompt_api_key(slot)?,
1221 };
1222 write_provider_api_key_to_config(store, provider, &api_key);
1223 let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
1224 store.save()?;
1225 if keyring_saved {
1227 println!(
1228 "saved API key for {slot} to {} and {}",
1229 store.path().display(),
1230 secrets.backend_name()
1231 );
1232 } else {
1233 println!("saved API key for {slot} to {}", store.path().display());
1234 }
1235 Ok(())
1236 }
1237 AuthCommand::Get { provider } => {
1238 let provider: ProviderKind = provider.into();
1239 let slot = provider_slot(provider);
1240 let in_file = provider_config_set(store, provider);
1241 let in_keyring = !in_file && provider_keyring_set(secrets, provider);
1242 let in_env = provider_env_set(provider);
1243 let source = if in_file {
1245 Some("config-file")
1246 } else if in_keyring {
1247 Some("secret-store")
1248 } else if in_env {
1249 Some("env")
1250 } else {
1251 None
1252 };
1253 match source {
1254 Some(source) => println!("{slot}: set (source: {source})"),
1255 None => println!("{slot}: not set"),
1256 }
1257 Ok(())
1258 }
1259 AuthCommand::Clear { provider } => {
1260 let provider: ProviderKind = provider.into();
1261 let slot = provider_slot(provider);
1262 clear_provider_api_key_from_config(store, provider);
1263 clear_provider_api_key_from_keyring(secrets, provider);
1264 store.save()?;
1265 println!("cleared API key for {slot} from config and secret store");
1266 Ok(())
1267 }
1268 AuthCommand::List => {
1269 println!("provider config store env active");
1270 for provider in PROVIDER_LIST {
1271 let slot = provider_slot(provider);
1272 let file = provider_config_set(store, provider);
1273 let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
1274 let env = provider_env_set(provider);
1275 let active = if file {
1276 "config"
1277 } else if keyring == Some(true) {
1278 "store"
1279 } else if env {
1280 "env"
1281 } else {
1282 "missing"
1283 };
1284 println!(
1285 "{slot:<12} {} {} {} {active}",
1286 yes_no(file),
1287 keyring_status_short(keyring),
1288 yes_no(env)
1289 );
1290 }
1291 Ok(())
1292 }
1293 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
1294 }
1295}
1296
1297fn yes_no(b: bool) -> &'static str {
1298 if b { "yes" } else { "no " }
1299}
1300
1301fn keyring_status_short(state: Option<bool>) -> &'static str {
1302 match state {
1303 Some(true) => "yes",
1304 Some(false) => "no ",
1305 None => "n/a",
1306 }
1307}
1308
1309fn prompt_api_key(slot: &str) -> Result<String> {
1310 use std::io::{IsTerminal, Write};
1311 eprint!("Enter API key for {slot}: ");
1312 io::stderr().flush().ok();
1313 if !io::stdin().is_terminal() {
1314 return read_api_key_from_stdin();
1316 }
1317 let mut buf = String::new();
1318 io::stdin()
1319 .read_line(&mut buf)
1320 .context("failed to read API key from stdin")?;
1321 let key = buf.trim().to_string();
1322 if key.is_empty() {
1323 bail!("empty API key provided");
1324 }
1325 Ok(key)
1326}
1327
1328fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
1331 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
1332 let mut warnings: Vec<String> = Vec::new();
1333
1334 for provider in PROVIDER_LIST {
1335 let slot = provider_slot(provider);
1336 let from_provider_block = store
1337 .config
1338 .providers
1339 .for_provider(provider)
1340 .api_key
1341 .clone()
1342 .filter(|v| !v.trim().is_empty());
1343 let from_root = (provider == ProviderKind::Deepseek)
1344 .then(|| store.config.api_key.clone())
1345 .flatten()
1346 .filter(|v| !v.trim().is_empty());
1347 let value = from_provider_block.or(from_root);
1348 let Some(value) = value else { continue };
1349
1350 if let Ok(Some(existing)) = secrets.get(slot)
1351 && existing == value
1352 {
1353 } else if dry_run {
1355 migrated.push((provider, slot));
1356 continue;
1357 } else if let Err(err) = secrets.set(slot, &value) {
1358 warnings.push(format!(
1359 "skipped {slot}: failed to write to secret store: {err}"
1360 ));
1361 continue;
1362 }
1363 if !dry_run {
1364 store.config.providers.for_provider_mut(provider).api_key = None;
1365 if provider == ProviderKind::Deepseek {
1366 store.config.api_key = None;
1367 }
1368 }
1369 migrated.push((provider, slot));
1370 }
1371
1372 if !dry_run && !migrated.is_empty() {
1373 store
1374 .save()
1375 .context("failed to write updated config.toml")?;
1376 }
1377
1378 println!("secret store backend: {}", secrets.backend_name());
1379 if migrated.is_empty() {
1380 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
1381 } else {
1382 println!(
1383 "{} {} provider key(s):",
1384 if dry_run { "would migrate" } else { "migrated" },
1385 migrated.len()
1386 );
1387 for (_, slot) in &migrated {
1388 println!(" - {slot}");
1389 }
1390 if !dry_run {
1391 println!(
1392 "config.toml at {} no longer contains api_key entries for migrated providers.",
1393 store.path().display()
1394 );
1395 }
1396 }
1397 for w in warnings {
1398 eprintln!("warning: {w}");
1399 }
1400 Ok(())
1401}
1402
1403fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
1404 match command {
1405 ConfigCommand::Get { key } => {
1406 if let Some(value) = store.config.get_display_value(&key) {
1407 println!("{value}");
1408 return Ok(());
1409 }
1410 bail!("key not found: {key}");
1411 }
1412 ConfigCommand::Set { key, value } => {
1413 store.config.set_value(&key, &value)?;
1414 store.save()?;
1415 println!("set {key}");
1416 Ok(())
1417 }
1418 ConfigCommand::Unset { key } => {
1419 store.config.unset_value(&key)?;
1420 store.save()?;
1421 println!("unset {key}");
1422 Ok(())
1423 }
1424 ConfigCommand::List => {
1425 for (key, value) in store.config.list_values() {
1426 println!("{key} = {value}");
1427 }
1428 Ok(())
1429 }
1430 ConfigCommand::Path => {
1431 println!("{}", store.path().display());
1432 Ok(())
1433 }
1434 }
1435}
1436
1437fn run_model_command(command: ModelCommand) -> Result<()> {
1438 let registry = ModelRegistry::default();
1439 match command {
1440 ModelCommand::List { provider } => {
1441 let filter = provider.map(ProviderKind::from);
1442 for model in registry.list().into_iter().filter(|m| match filter {
1443 Some(p) => m.provider == p,
1444 None => true,
1445 }) {
1446 println!("{} ({})", model.id, model.provider.as_str());
1447 }
1448 Ok(())
1449 }
1450 ModelCommand::Resolve { model, provider } => {
1451 let resolved = registry.resolve(model.as_deref(), provider.map(ProviderKind::from));
1452 println!("requested: {}", resolved.requested.unwrap_or_default());
1453 println!("resolved: {}", resolved.resolved.id);
1454 println!("provider: {}", resolved.resolved.provider.as_str());
1455 println!("used_fallback: {}", resolved.used_fallback);
1456 Ok(())
1457 }
1458 }
1459}
1460
1461fn run_thread_command(command: ThreadCommand) -> Result<()> {
1462 let state = StateStore::open(None)?;
1463 match command {
1464 ThreadCommand::List { all, limit } => {
1465 let threads = state.list_threads(ThreadListFilters {
1466 include_archived: all,
1467 limit,
1468 })?;
1469 for thread in threads {
1470 println!(
1471 "{} | {} | {} | {}",
1472 thread.id,
1473 thread
1474 .name
1475 .clone()
1476 .unwrap_or_else(|| "(unnamed)".to_string()),
1477 thread.model_provider,
1478 thread.cwd.display()
1479 );
1480 }
1481 Ok(())
1482 }
1483 ThreadCommand::Read { thread_id } => {
1484 let thread = state.get_thread(&thread_id)?;
1485 println!("{}", serde_json::to_string_pretty(&thread)?);
1486 Ok(())
1487 }
1488 ThreadCommand::Resume { thread_id } => {
1489 let args = vec!["resume".to_string(), thread_id];
1490 delegate_simple_tui(args)
1491 }
1492 ThreadCommand::Fork { thread_id } => {
1493 let args = vec!["fork".to_string(), thread_id];
1494 delegate_simple_tui(args)
1495 }
1496 ThreadCommand::Archive { thread_id } => {
1497 state.mark_archived(&thread_id)?;
1498 println!("archived {thread_id}");
1499 Ok(())
1500 }
1501 ThreadCommand::Unarchive { thread_id } => {
1502 state.mark_unarchived(&thread_id)?;
1503 println!("unarchived {thread_id}");
1504 Ok(())
1505 }
1506 ThreadCommand::SetName { thread_id, name } => {
1507 let mut thread = state
1508 .get_thread(&thread_id)?
1509 .with_context(|| format!("thread not found: {thread_id}"))?;
1510 thread.name = Some(name);
1511 thread.updated_at = chrono::Utc::now().timestamp();
1512 state.upsert_thread(&thread)?;
1513 println!("renamed {thread_id}");
1514 Ok(())
1515 }
1516 ThreadCommand::ClearName { thread_id } => {
1517 let mut thread = state
1518 .get_thread(&thread_id)?
1519 .with_context(|| format!("thread not found: {thread_id}"))?;
1520 thread.name = None;
1521 thread.updated_at = chrono::Utc::now().timestamp();
1522 state.upsert_thread(&thread)?;
1523 println!("cleared name for {thread_id}");
1524 Ok(())
1525 }
1526 }
1527}
1528
1529fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
1530 match command {
1531 SandboxCommand::Check { command, ask } => {
1532 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
1533 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1534 let decision = engine.check(ExecPolicyContext {
1535 command: &command,
1536 cwd: &cwd.display().to_string(),
1537 tool: Some("exec_shell"),
1538 path: None,
1539 ask_for_approval: ask.into(),
1540 sandbox_mode: Some("workspace-write"),
1541 })?;
1542 println!("{}", serde_json::to_string_pretty(&decision)?);
1543 Ok(())
1544 }
1545 }
1546}
1547
1548fn run_app_server_command(args: AppServerArgs) -> Result<()> {
1549 let runtime = tokio::runtime::Builder::new_multi_thread()
1550 .enable_all()
1551 .build()
1552 .context("failed to create tokio runtime")?;
1553 if args.stdio {
1554 return runtime.block_on(run_app_server_stdio(args.config));
1555 }
1556 let listen: SocketAddr = format!("{}:{}", args.host, args.port)
1557 .parse()
1558 .with_context(|| {
1559 format!(
1560 "invalid app-server listen address {}:{}",
1561 args.host, args.port
1562 )
1563 })?;
1564 runtime.block_on(run_app_server(AppServerOptions {
1565 listen,
1566 config_path: args.config,
1567 auth_token: args.auth_token.or_else(app_server_token_from_env),
1568 insecure_no_auth: args.insecure_no_auth,
1569 cors_origins: args.cors_origin,
1570 }))
1571}
1572
1573fn app_server_token_from_env() -> Option<String> {
1574 std::env::var("CODEWHALE_APP_SERVER_TOKEN")
1575 .ok()
1576 .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
1577}
1578
1579fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
1580 let persisted = load_mcp_server_definitions(store);
1581 let updated = run_stdio_server(persisted)?;
1582 persist_mcp_server_definitions(store, &updated)
1583}
1584
1585fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
1586 let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
1587 return Vec::new();
1588 };
1589
1590 match parse_mcp_server_definitions(&raw) {
1591 Ok(definitions) => definitions,
1592 Err(err) => {
1593 eprintln!(
1594 "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
1595 );
1596 Vec::new()
1597 }
1598 }
1599}
1600
1601fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
1602 if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
1603 return Ok(parsed);
1604 }
1605
1606 let unwrapped: String = serde_json::from_str(raw)
1607 .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
1608 serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
1609 format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
1610 })
1611}
1612
1613fn persist_mcp_server_definitions(
1614 store: &mut ConfigStore,
1615 definitions: &[McpServerDefinition],
1616) -> Result<()> {
1617 let encoded =
1618 serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
1619 store
1620 .config
1621 .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
1622 store.save()
1623}
1624
1625fn delegate_to_tui(
1626 cli: &Cli,
1627 resolved_runtime: &ResolvedRuntimeOptions,
1628 passthrough: Vec<String>,
1629) -> Result<()> {
1630 let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
1631 let tui = PathBuf::from(cmd.get_program());
1632 let status = cmd
1633 .status()
1634 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1635 exit_with_tui_status(status)
1636}
1637
1638fn run_resume_command(
1639 cli: &Cli,
1640 resolved_runtime: &ResolvedRuntimeOptions,
1641 args: TuiPassthroughArgs,
1642) -> Result<()> {
1643 let passthrough = tui_args("resume", args);
1644 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
1645 return run_dispatcher_resume_picker(cli, resolved_runtime);
1646 }
1647 delegate_to_tui(cli, resolved_runtime, passthrough)
1648}
1649
1650fn run_dispatcher_resume_picker(
1651 cli: &Cli,
1652 resolved_runtime: &ResolvedRuntimeOptions,
1653) -> Result<()> {
1654 let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
1655 let tui = PathBuf::from(sessions_cmd.get_program());
1656 let status = sessions_cmd
1657 .status()
1658 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1659 if !status.success() {
1660 return exit_with_tui_status(status);
1661 }
1662
1663 println!();
1664 println!("Windows note: enter a session id or prefix from the list above.");
1665 println!("You can also run `codewhale resume --last` to skip this prompt.");
1666 print!("Session id/prefix (Enter to cancel): ");
1667 io::stdout().flush()?;
1668
1669 let mut input = String::new();
1670 io::stdin()
1671 .read_line(&mut input)
1672 .context("failed to read session selection")?;
1673 let session_id = input.trim();
1674 if session_id.is_empty() {
1675 bail!("No session selected.");
1676 }
1677
1678 delegate_to_tui(
1679 cli,
1680 resolved_runtime,
1681 vec!["resume".to_string(), session_id.to_string()],
1682 )
1683}
1684
1685fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
1686 is_windows && passthrough == ["resume"]
1687}
1688
1689fn build_tui_command(
1690 cli: &Cli,
1691 resolved_runtime: &ResolvedRuntimeOptions,
1692 passthrough: Vec<String>,
1693) -> Result<Command> {
1694 let tui = locate_sibling_tui_binary()?;
1695 let mut verbosity = resolved_runtime.verbosity.clone();
1696 if verbosity.is_none()
1697 && passthrough
1698 .iter()
1699 .any(|arg| matches!(arg.as_str(), "exec" | "swebench" | "eval"))
1700 {
1701 verbosity = Some("concise".to_string());
1702 }
1703
1704 let mut cmd = Command::new(&tui);
1705 if let Some(config) = cli.config.as_ref() {
1706 cmd.arg("--config").arg(config);
1707 }
1708 if let Some(profile) = cli.profile.as_ref() {
1709 cmd.arg("--profile").arg(profile);
1710 }
1711 if let Some(workspace) = cli.workspace.as_ref() {
1712 cmd.arg("--workspace").arg(workspace);
1713 }
1714 let _ = cli.no_alt_screen;
1717 if cli.mouse_capture {
1718 cmd.arg("--mouse-capture");
1719 }
1720 if cli.no_mouse_capture {
1721 cmd.arg("--no-mouse-capture");
1722 }
1723 if cli.skip_onboarding {
1724 cmd.arg("--skip-onboarding");
1725 }
1726 cmd.args(passthrough);
1727
1728 let mut launch_provider_override = cli.provider.map(ProviderKind::from);
1729 let mut keyring_bridge_provider = resolved_runtime.provider;
1730 let mut keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
1731 let mut keyring_bridge_source = resolved_runtime.api_key_source;
1732
1733 if !provider_is_supported_by_tui(resolved_runtime.provider) {
1734 let supported = supported_tui_providers_csv();
1735 match resolved_runtime.provider_source {
1736 ProviderSource::Cli => {
1737 bail!(
1738 "The interactive TUI does not support provider '{}' from --provider.\n\
1739 \n\
1740 Supported TUI providers: {supported}.\n\
1741 \n\
1742 To fix: remove `--provider {}` or pass a supported provider. \
1743 For this provider, use `codewhale exec --provider {} \"your prompt\"`.",
1744 resolved_runtime.provider.as_str(),
1745 resolved_runtime.provider.as_str(),
1746 resolved_runtime.provider.as_str(),
1747 );
1748 }
1749 ProviderSource::Env(var) => {
1750 bail!(
1751 "The interactive TUI does not support provider '{}' from {var}.\n\
1752 \n\
1753 Supported TUI providers: {supported}.\n\
1754 \n\
1755 To fix: unset {var} or set it to a supported provider. \
1756 For this provider, use `codewhale exec --provider {} \"your prompt\"`.",
1757 resolved_runtime.provider.as_str(),
1758 resolved_runtime.provider.as_str(),
1759 );
1760 }
1761 ProviderSource::Config => {
1762 let config_hint = cli
1763 .config
1764 .as_ref()
1765 .map(|path| path.display().to_string())
1766 .unwrap_or_else(|| "~/.codewhale/config.toml".to_string());
1767 eprintln!(
1768 "Warning: provider '{}' from config is not supported by the interactive TUI; \
1769 launching with deepseek instead. Edit {config_hint} and set \
1770 provider = \"deepseek\", or pass --provider <supported-id>.",
1771 resolved_runtime.provider.as_str(),
1772 );
1773 launch_provider_override = Some(ProviderKind::Deepseek);
1774 keyring_bridge_provider = ProviderKind::Deepseek;
1775 keyring_bridge_api_key = None;
1776 keyring_bridge_source = None;
1777 }
1778 }
1779 }
1780
1781 if let Some(provider) = launch_provider_override {
1782 cmd.env("DEEPSEEK_PROVIDER", provider.as_str());
1783 }
1784 if matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
1785 && let Some(api_key) = keyring_bridge_api_key
1786 {
1787 cmd.env("DEEPSEEK_API_KEY", api_key);
1791 for var in provider_env_vars(keyring_bridge_provider) {
1792 if *var != "DEEPSEEK_API_KEY" {
1793 cmd.env(var, api_key);
1794 }
1795 }
1796 cmd.env(
1797 "DEEPSEEK_API_KEY_SOURCE",
1798 RuntimeApiKeySource::Keyring.as_env_value(),
1799 );
1800 }
1801
1802 if let Some(model) = cli.model.as_ref() {
1803 cmd.env("DEEPSEEK_MODEL", model);
1804 }
1805 if let Some(output_mode) = cli.output_mode.as_ref() {
1806 cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
1807 }
1808 if let Some(v) = verbosity.as_ref() {
1809 cmd.env("CODEWHALE_VERBOSITY", v);
1810 cmd.env("DEEPSEEK_VERBOSITY", v);
1811 }
1812 if let Some(log_level) = cli.log_level.as_ref() {
1813 cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
1814 }
1815 if let Some(telemetry) = cli.telemetry {
1816 cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
1817 }
1818 if let Some(policy) = cli.approval_policy.as_ref() {
1819 cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
1820 }
1821 if let Some(mode) = cli.sandbox_mode.as_ref() {
1822 cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
1823 }
1824 if cli.yolo {
1825 cmd.env("DEEPSEEK_YOLO", "true");
1826 }
1827 if let Some(api_key) = cli.api_key.as_ref() {
1828 cmd.env("DEEPSEEK_API_KEY", api_key);
1829 if resolved_runtime.provider == ProviderKind::Openai {
1830 cmd.env("OPENAI_API_KEY", api_key);
1831 }
1832 if resolved_runtime.provider == ProviderKind::Atlascloud {
1833 cmd.env("ATLASCLOUD_API_KEY", api_key);
1834 }
1835 if resolved_runtime.provider == ProviderKind::WanjieArk {
1836 cmd.env("WANJIE_ARK_API_KEY", api_key);
1837 }
1838 if resolved_runtime.provider == ProviderKind::Volcengine {
1839 cmd.env("VOLCENGINE_API_KEY", api_key);
1840 }
1841 if resolved_runtime.provider == ProviderKind::Siliconflow {
1842 cmd.env("SILICONFLOW_API_KEY", api_key);
1843 }
1844 cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
1845 }
1846 if let Some(base_url) = cli.base_url.as_ref() {
1847 cmd.env("DEEPSEEK_BASE_URL", base_url);
1848 }
1849
1850 Ok(cmd)
1851}
1852
1853fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
1854 match status.code() {
1855 Some(code) => std::process::exit(code),
1856 None => bail!("codewhale-tui terminated by signal"),
1857 }
1858}
1859
1860fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
1861 let tui = locate_sibling_tui_binary()?;
1862 let status = Command::new(&tui)
1863 .args(args)
1864 .status()
1865 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1866 match status.code() {
1867 Some(code) => std::process::exit(code),
1868 None => bail!("codewhale-tui terminated by signal"),
1869 }
1870}
1871
1872fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
1873 format!(
1874 "failed to spawn companion TUI binary at {}: {err}\n\
1875\n\
1876The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
1877to execute it. Common fixes:\n\
1878 - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
1879 - On Windows, run `where codewhale` and `where codewhale-tui`; both should \
1880come from the same install directory.\n\
1881 - If you downloaded release assets manually, keep both `codewhale` and \
1882`codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
1883 - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \
1884binary.",
1885 tui.display()
1886 )
1887}
1888
1889fn locate_sibling_tui_binary() -> Result<PathBuf> {
1899 if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
1900 let candidate = PathBuf::from(override_path);
1901 if candidate.is_file() {
1902 return Ok(candidate);
1903 }
1904 bail!(
1905 "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
1906 candidate.display()
1907 );
1908 }
1909
1910 let current = std::env::current_exe().context("failed to locate current executable path")?;
1911 if let Some(found) = sibling_tui_candidate(¤t) {
1912 return Ok(found);
1913 }
1914
1915 let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
1918 bail!(
1919 "Companion `codewhale-tui` binary not found at {}.\n\
1920\n\
1921The `codewhale` dispatcher delegates interactive sessions to a sibling \
1922`codewhale-tui` binary. To fix this, install one of:\n\
1923 • npm: npm install -g codewhale (downloads both binaries)\n\
1924 • cargo: cargo install codewhale-cli codewhale-tui --locked\n\
1925 • GitHub Releases: download BOTH `codewhale-<platform>` AND \
1926`codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
1927and place them in the same directory.\n\
1928\n\
1929Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.",
1930 expected.display()
1931 );
1932}
1933
1934fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
1938 let primary =
1941 dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
1942 if primary.is_file() {
1943 return Some(primary);
1944 }
1945 if cfg!(windows) {
1948 let suffixless = dispatcher.with_file_name("codewhale-tui");
1949 if suffixless.is_file() {
1950 return Some(suffixless);
1951 }
1952 }
1953 None
1954}
1955
1956fn run_metrics_command(args: MetricsArgs) -> Result<()> {
1957 let since = match args.since.as_deref() {
1958 Some(s) => {
1959 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
1960 }
1961 None => None,
1962 };
1963 metrics::run(metrics::MetricsArgs {
1964 json: args.json,
1965 since,
1966 })
1967}
1968
1969fn read_api_key_from_stdin() -> Result<String> {
1970 let mut input = String::new();
1971 io::stdin()
1972 .read_to_string(&mut input)
1973 .context("failed to read api key from stdin")?;
1974 let key = input.trim().to_string();
1975 if key.is_empty() {
1976 bail!("empty API key provided");
1977 }
1978 Ok(key)
1979}
1980
1981#[cfg(test)]
1982mod tests {
1983 use super::*;
1984 use clap::error::ErrorKind;
1985 use std::ffi::OsString;
1986 use std::sync::{Mutex, OnceLock};
1987
1988 fn parse_ok(argv: &[&str]) -> Cli {
1989 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
1990 }
1991
1992 fn help_for(argv: &[&str]) -> String {
1993 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
1994 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
1995 err.to_string()
1996 }
1997
1998 fn command_env(cmd: &Command, name: &str) -> Option<String> {
1999 let name = std::ffi::OsStr::new(name);
2000 cmd.get_envs().find_map(|(key, value)| {
2001 if key == name {
2002 value.map(|v| v.to_string_lossy().into_owned())
2003 } else {
2004 None
2005 }
2006 })
2007 }
2008
2009 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2010 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2011 LOCK.get_or_init(|| Mutex::new(()))
2012 .lock()
2013 .unwrap_or_else(|p| p.into_inner())
2014 }
2015
2016 struct ScopedEnvVar {
2017 name: &'static str,
2018 previous: Option<OsString>,
2019 }
2020
2021 impl ScopedEnvVar {
2022 fn set(name: &'static str, value: &str) -> Self {
2023 let previous = std::env::var_os(name);
2024 unsafe { std::env::set_var(name, value) };
2027 Self { name, previous }
2028 }
2029 }
2030
2031 impl Drop for ScopedEnvVar {
2032 fn drop(&mut self) {
2033 unsafe {
2035 if let Some(previous) = self.previous.take() {
2036 std::env::set_var(self.name, previous);
2037 } else {
2038 std::env::remove_var(self.name);
2039 }
2040 }
2041 }
2042 }
2043
2044 fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
2045 let dir = tempfile::TempDir::new().expect("tempdir");
2046 let custom = dir
2047 .path()
2048 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2049 std::fs::write(&custom, b"").unwrap();
2050 let custom_str = custom.to_string_lossy().into_owned();
2051 let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2052 (dir, bin)
2053 }
2054
2055 fn resolved_runtime_for_test(
2056 provider: ProviderKind,
2057 provider_source: ProviderSource,
2058 ) -> ResolvedRuntimeOptions {
2059 ResolvedRuntimeOptions {
2060 provider,
2061 provider_source,
2062 model: "test-model".to_string(),
2063 api_key: None,
2064 api_key_source: None,
2065 base_url: "http://localhost:8000/v1".to_string(),
2066 auth_mode: None,
2067 insecure_skip_tls_verify: false,
2068 output_mode: None,
2069 log_level: None,
2070 telemetry: false,
2071 approval_policy: None,
2072 sandbox_mode: None,
2073 yolo: None,
2074 verbosity: None,
2075 http_headers: std::collections::BTreeMap::new(),
2076 }
2077 }
2078
2079 #[test]
2080 fn clap_command_definition_is_consistent() {
2081 Cli::command().debug_assert();
2082 }
2083
2084 #[test]
2091 fn anyhow_chain_surfaces_toml_parse_cause() {
2092 use anyhow::Context;
2093 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
2094 let err = Err::<(), _>(inner)
2095 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
2096 .unwrap_err();
2097
2098 assert_eq!(
2100 err.to_string(),
2101 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
2102 );
2103
2104 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
2106 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
2107 }
2108
2109 #[test]
2110 fn parses_config_command_matrix() {
2111 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
2112 assert!(matches!(
2113 cli.command,
2114 Some(Commands::Config(ConfigArgs {
2115 command: ConfigCommand::Get { ref key }
2116 })) if key == "provider"
2117 ));
2118
2119 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
2120 assert!(matches!(
2121 cli.command,
2122 Some(Commands::Config(ConfigArgs {
2123 command: ConfigCommand::Set { ref key, ref value }
2124 })) if key == "model" && value == "deepseek-v4-flash"
2125 ));
2126
2127 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
2128 assert!(matches!(
2129 cli.command,
2130 Some(Commands::Config(ConfigArgs {
2131 command: ConfigCommand::Unset { ref key }
2132 })) if key == "model"
2133 ));
2134
2135 assert!(matches!(
2136 parse_ok(&["deepseek", "config", "list"]).command,
2137 Some(Commands::Config(ConfigArgs {
2138 command: ConfigCommand::List
2139 }))
2140 ));
2141 assert!(matches!(
2142 parse_ok(&["deepseek", "config", "path"]).command,
2143 Some(Commands::Config(ConfigArgs {
2144 command: ConfigCommand::Path
2145 }))
2146 ));
2147 }
2148
2149 #[test]
2150 fn parses_update_beta_flag() {
2151 let cli = parse_ok(&["codewhale", "update"]);
2152 assert!(matches!(
2153 cli.command,
2154 Some(Commands::Update(UpdateArgs {
2155 beta: false,
2156 check: false,
2157 proxy: None
2158 }))
2159 ));
2160
2161 let cli = parse_ok(&["codewhale", "update", "--beta"]);
2162 assert!(matches!(
2163 cli.command,
2164 Some(Commands::Update(UpdateArgs {
2165 beta: true,
2166 check: false,
2167 proxy: None
2168 }))
2169 ));
2170
2171 let cli = parse_ok(&["codewhale", "update", "--check"]);
2172 assert!(matches!(
2173 cli.command,
2174 Some(Commands::Update(UpdateArgs {
2175 beta: false,
2176 check: true,
2177 proxy: None
2178 }))
2179 ));
2180
2181 let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
2182 let Some(Commands::Update(args)) = cli.command else {
2183 panic!("expected update command");
2184 };
2185 assert!(!args.beta);
2186 assert!(!args.check);
2187 assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
2188 }
2189
2190 #[test]
2191 fn parses_model_command_matrix() {
2192 let cli = parse_ok(&["deepseek", "model", "list"]);
2193 assert!(matches!(
2194 cli.command,
2195 Some(Commands::Model(ModelArgs {
2196 command: ModelCommand::List { provider: None }
2197 }))
2198 ));
2199
2200 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
2201 assert!(matches!(
2202 cli.command,
2203 Some(Commands::Model(ModelArgs {
2204 command: ModelCommand::List {
2205 provider: Some(ProviderArg::Openai)
2206 }
2207 }))
2208 ));
2209
2210 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
2211 assert!(matches!(
2212 cli.command,
2213 Some(Commands::Model(ModelArgs {
2214 command: ModelCommand::Resolve {
2215 model: Some(ref model),
2216 provider: None
2217 }
2218 })) if model == "deepseek-v4-flash"
2219 ));
2220
2221 let cli = parse_ok(&[
2222 "deepseek",
2223 "model",
2224 "resolve",
2225 "--provider",
2226 "deepseek",
2227 "deepseek-v4-pro",
2228 ]);
2229 assert!(matches!(
2230 cli.command,
2231 Some(Commands::Model(ModelArgs {
2232 command: ModelCommand::Resolve {
2233 model: Some(ref model),
2234 provider: Some(ProviderArg::Deepseek)
2235 }
2236 })) if model == "deepseek-v4-pro"
2237 ));
2238 }
2239
2240 #[test]
2241 fn parses_thread_command_matrix() {
2242 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
2243 assert!(matches!(
2244 cli.command,
2245 Some(Commands::Thread(ThreadArgs {
2246 command: ThreadCommand::List {
2247 all: true,
2248 limit: Some(50)
2249 }
2250 }))
2251 ));
2252
2253 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
2254 assert!(matches!(
2255 cli.command,
2256 Some(Commands::Thread(ThreadArgs {
2257 command: ThreadCommand::Read { ref thread_id }
2258 })) if thread_id == "thread-1"
2259 ));
2260
2261 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
2262 assert!(matches!(
2263 cli.command,
2264 Some(Commands::Thread(ThreadArgs {
2265 command: ThreadCommand::Resume { ref thread_id }
2266 })) if thread_id == "thread-2"
2267 ));
2268
2269 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
2270 assert!(matches!(
2271 cli.command,
2272 Some(Commands::Thread(ThreadArgs {
2273 command: ThreadCommand::Fork { ref thread_id }
2274 })) if thread_id == "thread-3"
2275 ));
2276
2277 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
2278 assert!(matches!(
2279 cli.command,
2280 Some(Commands::Thread(ThreadArgs {
2281 command: ThreadCommand::Archive { ref thread_id }
2282 })) if thread_id == "thread-4"
2283 ));
2284
2285 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
2286 assert!(matches!(
2287 cli.command,
2288 Some(Commands::Thread(ThreadArgs {
2289 command: ThreadCommand::Unarchive { ref thread_id }
2290 })) if thread_id == "thread-5"
2291 ));
2292
2293 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
2294 assert!(matches!(
2295 cli.command,
2296 Some(Commands::Thread(ThreadArgs {
2297 command: ThreadCommand::SetName {
2298 ref thread_id,
2299 ref name
2300 }
2301 })) if thread_id == "thread-6" && name == "My Thread"
2302 ));
2303
2304 let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
2305 assert!(matches!(
2306 cli.command,
2307 Some(Commands::Thread(ThreadArgs {
2308 command: ThreadCommand::ClearName { ref thread_id }
2309 })) if thread_id == "thread-7"
2310 ));
2311 }
2312
2313 #[test]
2314 fn parses_sandbox_app_server_and_completion_matrix() {
2315 let cli = parse_ok(&[
2316 "deepseek",
2317 "sandbox",
2318 "check",
2319 "echo hello",
2320 "--ask",
2321 "on-failure",
2322 ]);
2323 assert!(matches!(
2324 cli.command,
2325 Some(Commands::Sandbox(SandboxArgs {
2326 command: SandboxCommand::Check {
2327 ref command,
2328 ask: ApprovalModeArg::OnFailure
2329 }
2330 })) if command == "echo hello"
2331 ));
2332
2333 let cli = parse_ok(&[
2334 "deepseek",
2335 "app-server",
2336 "--host",
2337 "0.0.0.0",
2338 "--port",
2339 "9999",
2340 ]);
2341 assert!(matches!(
2342 cli.command,
2343 Some(Commands::AppServer(AppServerArgs {
2344 ref host,
2345 port: 9999,
2346 stdio: false,
2347 ..
2348 })) if host == "0.0.0.0"
2349 ));
2350
2351 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
2352 assert!(matches!(
2353 cli.command,
2354 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
2355 ));
2356
2357 let cli = parse_ok(&["deepseek", "completion", "bash"]);
2358 assert!(matches!(
2359 cli.command,
2360 Some(Commands::Completion { shell: Shell::Bash })
2361 ));
2362 }
2363
2364 #[test]
2365 fn parses_direct_tui_command_aliases() {
2366 let cli = parse_ok(&["deepseek", "doctor"]);
2367 assert!(matches!(
2368 cli.command,
2369 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
2370 ));
2371
2372 let cli = parse_ok(&["deepseek", "models", "--json"]);
2373 assert!(matches!(
2374 cli.command,
2375 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
2376 ));
2377
2378 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
2379 assert!(matches!(
2380 cli.command,
2381 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
2382 ));
2383
2384 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
2385 assert!(matches!(
2386 cli.command,
2387 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
2388 if args == &["--skills", "--local"]
2389 ));
2390 }
2391
2392 #[test]
2393 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
2394 assert!(should_pick_resume_in_dispatcher(
2395 &["resume".to_string()],
2396 true
2397 ));
2398 assert!(!should_pick_resume_in_dispatcher(
2399 &["resume".to_string(), "--last".to_string()],
2400 true
2401 ));
2402 assert!(!should_pick_resume_in_dispatcher(
2403 &["resume".to_string(), "abc123".to_string()],
2404 true
2405 ));
2406 assert!(!should_pick_resume_in_dispatcher(
2407 &["resume".to_string()],
2408 false
2409 ));
2410 }
2411
2412 #[test]
2413 fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
2414 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2415 let path = std::env::temp_dir().join(format!(
2416 "deepseek-cli-login-test-{}-{nanos}.toml",
2417 std::process::id()
2418 ));
2419 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2420 let secrets = no_keyring_secrets();
2421
2422 run_login_command_with_secrets(
2423 &mut store,
2424 LoginArgs {
2425 provider: Some(ProviderArg::Deepseek),
2426 api_key: Some("sk-test".to_string()),
2427 },
2428 &secrets,
2429 )
2430 .expect("login should write config");
2431
2432 assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
2433 assert_eq!(
2434 store.config.providers.deepseek.api_key.as_deref(),
2435 Some("sk-test")
2436 );
2437 assert_eq!(
2438 store.config.default_text_model.as_deref(),
2439 Some("deepseek-v4-pro")
2440 );
2441 let saved = std::fs::read_to_string(&path).expect("config should be written");
2442 assert!(saved.contains("api_key = \"sk-test\""));
2443 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
2444
2445 let _ = std::fs::remove_file(path);
2446 }
2447
2448 #[test]
2449 fn parses_auth_subcommand_matrix() {
2450 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
2451 assert!(matches!(
2452 cli.command,
2453 Some(Commands::Auth(AuthArgs {
2454 command: AuthCommand::Set {
2455 provider: ProviderArg::Deepseek,
2456 api_key: None,
2457 api_key_stdin: false,
2458 }
2459 }))
2460 ));
2461
2462 let cli = parse_ok(&[
2463 "deepseek",
2464 "auth",
2465 "set",
2466 "--provider",
2467 "openrouter",
2468 "--api-key-stdin",
2469 ]);
2470 assert!(matches!(
2471 cli.command,
2472 Some(Commands::Auth(AuthArgs {
2473 command: AuthCommand::Set {
2474 provider: ProviderArg::Openrouter,
2475 api_key: None,
2476 api_key_stdin: true,
2477 }
2478 }))
2479 ));
2480
2481 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
2482 assert!(matches!(
2483 cli.command,
2484 Some(Commands::Auth(AuthArgs {
2485 command: AuthCommand::Get {
2486 provider: ProviderArg::Novita
2487 }
2488 }))
2489 ));
2490
2491 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
2492 assert!(matches!(
2493 cli.command,
2494 Some(Commands::Auth(AuthArgs {
2495 command: AuthCommand::Clear {
2496 provider: ProviderArg::NvidiaNim
2497 }
2498 }))
2499 ));
2500
2501 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
2502 assert!(matches!(
2503 cli.command,
2504 Some(Commands::Auth(AuthArgs {
2505 command: AuthCommand::Set {
2506 provider: ProviderArg::Fireworks,
2507 api_key: None,
2508 api_key_stdin: false,
2509 }
2510 }))
2511 ));
2512
2513 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
2514 assert!(matches!(
2515 cli.command,
2516 Some(Commands::Auth(AuthArgs {
2517 command: AuthCommand::Set {
2518 provider: ProviderArg::Siliconflow,
2519 api_key: None,
2520 api_key_stdin: false,
2521 }
2522 }))
2523 ));
2524
2525 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
2526 assert!(matches!(
2527 cli.command,
2528 Some(Commands::Auth(AuthArgs {
2529 command: AuthCommand::Set {
2530 provider: ProviderArg::Arcee,
2531 api_key: None,
2532 api_key_stdin: false,
2533 }
2534 }))
2535 ));
2536
2537 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
2538 assert!(matches!(
2539 cli.command,
2540 Some(Commands::Auth(AuthArgs {
2541 command: AuthCommand::Set {
2542 provider: ProviderArg::Moonshot,
2543 api_key: None,
2544 api_key_stdin: false,
2545 }
2546 }))
2547 ));
2548
2549 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
2550 assert!(matches!(
2551 cli.command,
2552 Some(Commands::Auth(AuthArgs {
2553 command: AuthCommand::Set {
2554 provider: ProviderArg::WanjieArk,
2555 api_key: None,
2556 api_key_stdin: false,
2557 }
2558 }))
2559 ));
2560
2561 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
2562 assert!(matches!(
2563 cli.command,
2564 Some(Commands::Auth(AuthArgs {
2565 command: AuthCommand::Get {
2566 provider: ProviderArg::Sglang
2567 }
2568 }))
2569 ));
2570
2571 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
2572 assert!(matches!(
2573 cli.command,
2574 Some(Commands::Auth(AuthArgs {
2575 command: AuthCommand::Get {
2576 provider: ProviderArg::Vllm
2577 }
2578 }))
2579 ));
2580
2581 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
2582 assert!(matches!(
2583 cli.command,
2584 Some(Commands::Auth(AuthArgs {
2585 command: AuthCommand::Set {
2586 provider: ProviderArg::Ollama,
2587 api_key: None,
2588 api_key_stdin: false,
2589 }
2590 }))
2591 ));
2592
2593 let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
2594 assert!(matches!(
2595 cli.command,
2596 Some(Commands::Auth(AuthArgs {
2597 command: AuthCommand::Status {
2598 provider: Some(ProviderArg::OpenaiCodex)
2599 }
2600 }))
2601 ));
2602
2603 let cli = parse_ok(&["deepseek", "auth", "list"]);
2604 assert!(matches!(
2605 cli.command,
2606 Some(Commands::Auth(AuthArgs {
2607 command: AuthCommand::List
2608 }))
2609 ));
2610
2611 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
2612 assert!(matches!(
2613 cli.command,
2614 Some(Commands::Auth(AuthArgs {
2615 command: AuthCommand::Migrate { dry_run: false }
2616 }))
2617 ));
2618
2619 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
2620 assert!(matches!(
2621 cli.command,
2622 Some(Commands::Auth(AuthArgs {
2623 command: AuthCommand::Migrate { dry_run: true }
2624 }))
2625 ));
2626 }
2627
2628 #[test]
2629 fn auth_set_writes_to_shared_config_file() {
2630 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2631 use std::sync::Arc;
2632
2633 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2634 let path = std::env::temp_dir().join(format!(
2635 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
2636 std::process::id()
2637 ));
2638 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2639 let inner = Arc::new(InMemoryKeyringStore::new());
2640 let secrets = Secrets::new(inner.clone());
2641
2642 run_auth_command_with_secrets(
2643 &mut store,
2644 AuthCommand::Set {
2645 provider: ProviderArg::Deepseek,
2646 api_key: Some("sk-keyring".to_string()),
2647 api_key_stdin: false,
2648 },
2649 &secrets,
2650 )
2651 .expect("set should succeed");
2652
2653 assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
2654 assert_eq!(
2655 store.config.providers.deepseek.api_key.as_deref(),
2656 Some("sk-keyring")
2657 );
2658 let saved = std::fs::read_to_string(&path).unwrap_or_default();
2659 assert!(saved.contains("api_key = \"sk-keyring\""));
2660 assert_eq!(
2661 inner.get("deepseek").unwrap().as_deref(),
2662 Some("sk-keyring")
2663 );
2664
2665 let _ = std::fs::remove_file(path);
2666 }
2667
2668 #[test]
2669 fn auth_set_provider_key_does_not_switch_active_provider() {
2670 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2671 let path = std::env::temp_dir().join(format!(
2672 "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
2673 std::process::id()
2674 ));
2675 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2676 store.config.provider = ProviderKind::Deepseek;
2677 let secrets = no_keyring_secrets();
2678
2679 run_auth_command_with_secrets(
2680 &mut store,
2681 AuthCommand::Set {
2682 provider: ProviderArg::Arcee,
2683 api_key: Some("arcee-key".to_string()),
2684 api_key_stdin: false,
2685 },
2686 &secrets,
2687 )
2688 .expect("set should succeed");
2689
2690 assert_eq!(store.config.provider, ProviderKind::Deepseek);
2691 assert_eq!(
2692 store.config.providers.arcee.api_key.as_deref(),
2693 Some("arcee-key")
2694 );
2695
2696 let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
2697 assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
2698 assert_eq!(
2699 reloaded.config.providers.arcee.api_key.as_deref(),
2700 Some("arcee-key")
2701 );
2702
2703 let _ = std::fs::remove_file(path);
2704 }
2705
2706 #[test]
2707 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
2708 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2709 let path = std::env::temp_dir().join(format!(
2710 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
2711 std::process::id()
2712 ));
2713 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2714 store.config.provider = ProviderKind::Deepseek;
2715 let secrets = no_keyring_secrets();
2716
2717 run_auth_command_with_secrets(
2718 &mut store,
2719 AuthCommand::Set {
2720 provider: ProviderArg::Ollama,
2721 api_key: None,
2722 api_key_stdin: false,
2723 },
2724 &secrets,
2725 )
2726 .expect("ollama auth set should not require a key");
2727
2728 assert_eq!(store.config.provider, ProviderKind::Deepseek);
2729 assert_eq!(
2730 store.config.providers.ollama.base_url.as_deref(),
2731 Some("http://localhost:11434/v1")
2732 );
2733 assert_eq!(store.config.providers.ollama.api_key, None);
2734
2735 let _ = std::fs::remove_file(path);
2736 }
2737
2738 #[test]
2739 fn auth_clear_removes_from_config() {
2740 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2741 use std::sync::Arc;
2742
2743 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2744 let path = std::env::temp_dir().join(format!(
2745 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
2746 std::process::id()
2747 ));
2748 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2749 store.config.api_key = Some("sk-stale".to_string());
2750 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
2751 store.save().unwrap();
2752
2753 let inner = Arc::new(InMemoryKeyringStore::new());
2754 inner.set("deepseek", "sk-stale").unwrap();
2755 let secrets = Secrets::new(inner.clone());
2756
2757 run_auth_command_with_secrets(
2758 &mut store,
2759 AuthCommand::Clear {
2760 provider: ProviderArg::Deepseek,
2761 },
2762 &secrets,
2763 )
2764 .expect("clear should succeed");
2765
2766 assert!(store.config.api_key.is_none());
2767 assert!(store.config.providers.deepseek.api_key.is_none());
2768 assert_eq!(inner.get("deepseek").unwrap(), None);
2769
2770 let _ = std::fs::remove_file(path);
2771 }
2772
2773 #[test]
2774 fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
2775 use codewhale_secrets::{KeyringStore, SecretsError};
2776 use std::sync::{Arc, Mutex};
2777
2778 #[derive(Default)]
2779 struct RecordingStore {
2780 gets: Mutex<Vec<String>>,
2781 }
2782
2783 impl KeyringStore for RecordingStore {
2784 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
2785 self.gets.lock().unwrap().push(key.to_string());
2786 Ok(None)
2787 }
2788
2789 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
2790 Ok(())
2791 }
2792
2793 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
2794 Ok(())
2795 }
2796
2797 fn backend_name(&self) -> &'static str {
2798 "recording"
2799 }
2800 }
2801
2802 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2803 let path = std::env::temp_dir().join(format!(
2804 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
2805 std::process::id()
2806 ));
2807 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2808 store.config.provider = ProviderKind::Deepseek;
2809 let inner = Arc::new(RecordingStore::default());
2810 let secrets = Secrets::new(inner.clone());
2811
2812 run_auth_command_with_secrets(
2813 &mut store,
2814 AuthCommand::Status {
2815 provider: Some(ProviderArg::Deepseek),
2816 },
2817 &secrets,
2818 )
2819 .expect("status should succeed");
2820 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
2821 .expect("list should succeed");
2822
2823 let probed = inner.gets.lock().unwrap();
2824 assert_eq!(probed[0], "deepseek");
2826 assert!(probed.len() > 1, "list should probe all providers");
2829 assert!(
2830 PROVIDER_LIST
2831 .iter()
2832 .all(|p| probed.contains(&provider_slot(*p).to_string())),
2833 "every known provider should be probed by auth list: {:?}",
2834 *probed
2835 );
2836
2837 let _ = std::fs::remove_file(path);
2838 }
2839
2840 #[test]
2841 fn auth_status_reports_all_active_provider_sources_with_last4() {
2842 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2843 use std::sync::Arc;
2844
2845 let _lock = env_lock();
2846 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
2847
2848 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2849 let path = std::env::temp_dir().join(format!(
2850 "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
2851 std::process::id()
2852 ));
2853 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2854 store.config.provider = ProviderKind::Deepseek;
2855 store.config.api_key = Some("sk-config-3333".to_string());
2856 store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
2857
2858 let inner = Arc::new(InMemoryKeyringStore::new());
2859 inner.set("deepseek", "sk-keyring-2222").unwrap();
2860 let secrets = Secrets::new(inner);
2861
2862 let output =
2863 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
2864
2865 assert!(output.contains("provider: deepseek"));
2866 assert!(output.contains("active source: config (last4: ...3333)"));
2867 assert!(output.contains("lookup order: config -> secret store -> env"));
2868 assert!(output.contains("config file: "));
2869 assert!(output.contains("set, last4: ...3333"));
2870 assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
2871 assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
2872 assert!(!output.contains("sk-config-3333"));
2873 assert!(!output.contains("sk-keyring-2222"));
2874 assert!(!output.contains("sk-env-1111"));
2875
2876 let _ = std::fs::remove_file(path);
2877 }
2878
2879 #[test]
2880 fn auth_status_all_providers_lists_every_known_provider() {
2881 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2882 use std::sync::Arc;
2883
2884 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2885 let path = std::env::temp_dir().join(format!(
2886 "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
2887 std::process::id()
2888 ));
2889 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2890 store.config.provider = ProviderKind::Deepseek;
2891 store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
2892
2893 let inner = Arc::new(InMemoryKeyringStore::new());
2894 inner.set("openrouter", "sk-or-test5678").unwrap();
2895 let secrets = Secrets::new(inner);
2896
2897 let output = auth_status_all_providers(&store, &secrets).join("\n");
2898
2899 assert!(output.contains("deepseek"));
2901 assert!(output.contains("arcee"));
2902 assert!(output.contains("openrouter"));
2903 assert!(output.contains("huggingface"));
2904 assert!(output.contains("ollama"));
2905
2906 assert!(output.contains("deepseek") && output.contains("*"));
2908
2909 assert!(output.contains("config"));
2911
2912 assert!(!output.contains("sk-arcee-test1234"));
2914 assert!(!output.contains("sk-or-test5678"));
2915
2916 let _ = std::fs::remove_file(path);
2917 }
2918
2919 #[test]
2920 fn auth_status_openai_codex_reports_codex_oauth_file() {
2921 use codewhale_secrets::InMemoryKeyringStore;
2922 use std::sync::Arc;
2923
2924 let _lock = env_lock();
2925 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
2926 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
2927
2928 let dir = tempfile::TempDir::new().expect("tempdir");
2929 let config_path = dir.path().join("config.toml");
2930 let auth_path = dir.path().join("auth.json");
2931 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
2932 .expect("write auth file");
2933 let auth_path_str = auth_path.to_string_lossy().into_owned();
2934 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
2935
2936 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
2937 store.config.provider = ProviderKind::OpenaiCodex;
2938 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
2939
2940 let output =
2941 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
2942
2943 assert!(output.contains("provider: openai-codex"));
2944 assert!(output.contains("auth mode: codex_oauth"));
2945 assert!(output.contains("active source: Codex OAuth file"));
2946 assert!(output.contains("lookup order: env -> Codex OAuth file"));
2947 assert!(output.contains(&format!(
2948 "Codex OAuth file: {} (present)",
2949 auth_path.display()
2950 )));
2951 assert!(!output.contains("secret-token"));
2952 }
2953
2954 #[test]
2955 fn auth_status_scoped_provider_shows_detailed_info() {
2956 use codewhale_secrets::InMemoryKeyringStore;
2957 use std::sync::Arc;
2958
2959 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2960 let path = std::env::temp_dir().join(format!(
2961 "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
2962 std::process::id()
2963 ));
2964 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2965 store.config.provider = ProviderKind::Deepseek;
2966 store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
2967
2968 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
2969
2970 let output =
2971 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
2972
2973 assert!(output.contains("provider: arcee"));
2974 assert!(output.contains("active source: config (last4: ...9999)"));
2975 assert!(output.contains("route:"));
2976 assert!(output.contains("model:"));
2977 assert!(!output.contains("sk-arcee-9999"));
2978
2979 let _ = std::fs::remove_file(path);
2980 }
2981
2982 #[test]
2983 fn dispatch_keyring_recovery_self_heals_into_config_file() {
2984 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
2985 use std::sync::Arc;
2986
2987 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2988 let path = std::env::temp_dir().join(format!(
2989 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
2990 std::process::id()
2991 ));
2992 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2993 let inner = Arc::new(InMemoryKeyringStore::new());
2994 inner.set("deepseek", "ring-key").unwrap();
2995 let secrets = Secrets::new(inner);
2996
2997 let resolved = resolve_runtime_for_dispatch_with_secrets(
2998 &mut store,
2999 &CliRuntimeOverrides::default(),
3000 &secrets,
3001 );
3002
3003 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
3004 assert_eq!(
3005 resolved.api_key_source,
3006 Some(RuntimeApiKeySource::ConfigFile)
3007 );
3008 assert_eq!(store.config.api_key.as_deref(), Some("ring-key"));
3009 assert_eq!(
3010 store.config.providers.deepseek.api_key.as_deref(),
3011 Some("ring-key")
3012 );
3013
3014 let saved = std::fs::read_to_string(&path).expect("config should be written");
3015 assert!(saved.contains("api_key = \"ring-key\""));
3016
3017 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
3018 &mut store,
3019 &CliRuntimeOverrides::default(),
3020 &no_keyring_secrets(),
3021 );
3022 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
3023 assert_eq!(
3024 resolved_again.api_key_source,
3025 Some(RuntimeApiKeySource::ConfigFile)
3026 );
3027
3028 let _ = std::fs::remove_file(path);
3029 }
3030
3031 #[test]
3032 fn logout_removes_plaintext_provider_keys() {
3033 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3034 let path = std::env::temp_dir().join(format!(
3035 "deepseek-cli-logout-test-{}-{nanos}.toml",
3036 std::process::id()
3037 ));
3038 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3039 store.config.api_key = Some("sk-stale".to_string());
3040 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
3041 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
3042 store.save().unwrap();
3043
3044 let secrets = no_keyring_secrets();
3045
3046 run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
3047
3048 assert!(store.config.api_key.is_none());
3049 assert!(store.config.providers.deepseek.api_key.is_none());
3050 assert!(store.config.providers.fireworks.api_key.is_none());
3051
3052 let _ = std::fs::remove_file(path);
3053 }
3054
3055 #[test]
3056 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
3057 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3058 use std::sync::Arc;
3059
3060 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3061 let path = std::env::temp_dir().join(format!(
3062 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
3063 std::process::id()
3064 ));
3065 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3066 store.config.api_key = Some("sk-deep".to_string());
3067 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
3068 store.config.providers.openrouter.api_key = Some("or-key".to_string());
3069 store.config.providers.novita.api_key = Some("nv-key".to_string());
3070 store.save().unwrap();
3071
3072 let inner = Arc::new(InMemoryKeyringStore::new());
3073 let secrets = Secrets::new(inner.clone());
3074
3075 run_auth_command_with_secrets(
3076 &mut store,
3077 AuthCommand::Migrate { dry_run: false },
3078 &secrets,
3079 )
3080 .expect("migrate should succeed");
3081
3082 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
3083 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
3084 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
3085
3086 assert!(store.config.api_key.is_none());
3088 assert!(store.config.providers.deepseek.api_key.is_none());
3089 assert!(store.config.providers.openrouter.api_key.is_none());
3090 assert!(store.config.providers.novita.api_key.is_none());
3091
3092 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
3093 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
3094 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
3095 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
3096
3097 let _ = std::fs::remove_file(path);
3098 }
3099
3100 #[test]
3101 fn auth_migrate_dry_run_does_not_modify_anything() {
3102 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3103 use std::sync::Arc;
3104
3105 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3106 let path = std::env::temp_dir().join(format!(
3107 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
3108 std::process::id()
3109 ));
3110 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3111 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
3112 store.save().unwrap();
3113
3114 let inner = Arc::new(InMemoryKeyringStore::new());
3115 let secrets = Secrets::new(inner.clone());
3116
3117 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
3118 .expect("dry-run should succeed");
3119
3120 assert_eq!(inner.get("openrouter").unwrap(), None);
3121 assert_eq!(
3122 store.config.providers.openrouter.api_key.as_deref(),
3123 Some("or-stay")
3124 );
3125
3126 let _ = std::fs::remove_file(path);
3127 }
3128
3129 #[test]
3130 fn parses_global_override_flags() {
3131 let cli = parse_ok(&[
3132 "deepseek",
3133 "--provider",
3134 "openai",
3135 "--config",
3136 "/tmp/deepseek.toml",
3137 "--profile",
3138 "work",
3139 "--model",
3140 "deepseek-v4-pro",
3141 "--output-mode",
3142 "json",
3143 "--verbosity",
3144 "concise",
3145 "--log-level",
3146 "debug",
3147 "--telemetry",
3148 "true",
3149 "--approval-policy",
3150 "on-request",
3151 "--sandbox-mode",
3152 "workspace-write",
3153 "--base-url",
3154 "https://openai-compatible.example/v1",
3155 "--api-key",
3156 "sk-test",
3157 "--workspace",
3158 "/tmp/workspace",
3159 "--no-alt-screen",
3160 "--no-mouse-capture",
3161 "--skip-onboarding",
3162 "model",
3163 "resolve",
3164 "deepseek-v4-pro",
3165 ]);
3166
3167 assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
3168 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
3169 assert_eq!(cli.profile.as_deref(), Some("work"));
3170 assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
3171 assert_eq!(cli.output_mode.as_deref(), Some("json"));
3172 assert_eq!(cli.verbosity.as_deref(), Some("concise"));
3173 assert_eq!(cli.log_level.as_deref(), Some("debug"));
3174 assert_eq!(cli.telemetry, Some(true));
3175 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
3176 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
3177 assert_eq!(
3178 cli.base_url.as_deref(),
3179 Some("https://openai-compatible.example/v1")
3180 );
3181 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
3182 assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
3183 assert!(cli.no_alt_screen);
3184 assert!(cli.no_mouse_capture);
3185 assert!(!cli.mouse_capture);
3186 assert!(cli.skip_onboarding);
3187 }
3188
3189 #[test]
3190 fn build_tui_command_allows_openai_and_forwards_provider_key() {
3191 let _lock = env_lock();
3192 let dir = tempfile::TempDir::new().expect("tempdir");
3193 let custom = dir
3194 .path()
3195 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3196 std::fs::write(&custom, b"").unwrap();
3197 let custom_str = custom.to_string_lossy().into_owned();
3198 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3199
3200 let cli = parse_ok(&[
3201 "deepseek",
3202 "--provider",
3203 "openai",
3204 "--workspace",
3205 "/tmp/codewhale-workspace",
3206 ]);
3207 let resolved = ResolvedRuntimeOptions {
3208 provider: ProviderKind::Openai,
3209 provider_source: ProviderSource::Cli,
3210 model: "glm-5".to_string(),
3211 api_key: Some("resolved-openai-key".to_string()),
3212 api_key_source: Some(RuntimeApiKeySource::Keyring),
3213 base_url: "https://openai-compatible.example/v4".to_string(),
3214 auth_mode: Some("api_key".to_string()),
3215 insecure_skip_tls_verify: false,
3216 output_mode: None,
3217 log_level: None,
3218 telemetry: false,
3219 approval_policy: None,
3220 sandbox_mode: None,
3221 yolo: None,
3222 verbosity: None,
3223 http_headers: std::collections::BTreeMap::new(),
3224 };
3225
3226 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3227 assert_eq!(
3228 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3229 Some("openai")
3230 );
3231 assert_eq!(
3232 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3233 Some("resolved-openai-key")
3234 );
3235 assert_eq!(
3236 command_env(&cmd, "OPENAI_API_KEY").as_deref(),
3237 Some("resolved-openai-key")
3238 );
3239 assert_eq!(
3240 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
3241 Some("keyring")
3242 );
3243 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
3244 let args: Vec<String> = cmd
3245 .get_args()
3246 .map(|arg| arg.to_string_lossy().into_owned())
3247 .collect();
3248 assert!(
3249 args.windows(2)
3250 .any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
3251 "expected workspace forwarding in args: {args:?}"
3252 );
3253 }
3254
3255 #[test]
3256 fn build_tui_command_allows_openai_codex_from_resolved_runtime() {
3257 let _lock = env_lock();
3258 let dir = tempfile::TempDir::new().expect("tempdir");
3259 let custom = dir
3260 .path()
3261 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3262 std::fs::write(&custom, b"").unwrap();
3263 let custom_str = custom.to_string_lossy().into_owned();
3264 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3265
3266 let cli = parse_ok(&["codewhale", "doctor"]);
3267 let resolved = ResolvedRuntimeOptions {
3268 provider: ProviderKind::OpenaiCodex,
3269 provider_source: ProviderSource::Config,
3270 model: "gpt-5.5".to_string(),
3271 api_key: None,
3272 api_key_source: None,
3273 base_url: "https://chatgpt.com/backend-api".to_string(),
3274 auth_mode: Some("oauth".to_string()),
3275 insecure_skip_tls_verify: false,
3276 output_mode: None,
3277 log_level: None,
3278 telemetry: false,
3279 approval_policy: None,
3280 sandbox_mode: None,
3281 yolo: None,
3282 verbosity: None,
3283 http_headers: std::collections::BTreeMap::new(),
3284 };
3285
3286 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3287 .expect("openai-codex should be accepted by the facade");
3288 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
3289 let args: Vec<String> = cmd
3290 .get_args()
3291 .map(|arg| arg.to_string_lossy().into_owned())
3292 .collect();
3293 assert_eq!(args, vec!["doctor"]);
3294 }
3295
3296 #[test]
3297 fn build_tui_command_forwards_explicit_openai_codex_provider() {
3298 let _lock = env_lock();
3299 let dir = tempfile::TempDir::new().expect("tempdir");
3300 let custom = dir
3301 .path()
3302 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3303 std::fs::write(&custom, b"").unwrap();
3304 let custom_str = custom.to_string_lossy().into_owned();
3305 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3306
3307 let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
3308 let resolved = ResolvedRuntimeOptions {
3309 provider: ProviderKind::OpenaiCodex,
3310 provider_source: ProviderSource::Cli,
3311 model: "gpt-5.5".to_string(),
3312 api_key: None,
3313 api_key_source: None,
3314 base_url: "https://chatgpt.com/backend-api".to_string(),
3315 auth_mode: Some("oauth".to_string()),
3316 insecure_skip_tls_verify: false,
3317 output_mode: None,
3318 log_level: None,
3319 telemetry: false,
3320 approval_policy: None,
3321 sandbox_mode: None,
3322 yolo: None,
3323 verbosity: None,
3324 http_headers: std::collections::BTreeMap::new(),
3325 };
3326
3327 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3328 .expect("openai-codex should be accepted by the facade");
3329 assert_eq!(
3330 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3331 Some("openai-codex")
3332 );
3333 }
3334
3335 #[test]
3336 fn build_tui_command_rejects_unsupported_cli_provider_with_flag_hint() {
3337 let _lock = env_lock();
3338 let (_dir, _bin) = install_fake_tui_binary();
3339
3340 let cli = parse_ok(&["codewhale", "doctor"]);
3341 let resolved = resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Cli);
3342
3343 let err = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3344 .expect_err("unsupported provider should fail");
3345 let msg = err.to_string();
3346
3347 assert!(msg.contains("from --provider"), "{msg}");
3348 assert!(msg.contains("remove `--provider anthropic`"), "{msg}");
3349 assert!(msg.contains("Supported TUI providers:"), "{msg}");
3350 }
3351
3352 #[test]
3353 fn build_tui_command_rejects_unsupported_env_provider_with_env_hint() {
3354 let _lock = env_lock();
3355 let (_dir, _bin) = install_fake_tui_binary();
3356
3357 let cli = parse_ok(&["codewhale", "doctor"]);
3358 let resolved = resolved_runtime_for_test(
3359 ProviderKind::Anthropic,
3360 ProviderSource::Env("DEEPSEEK_PROVIDER"),
3361 );
3362
3363 let err = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3364 .expect_err("unsupported provider should fail");
3365 let msg = err.to_string();
3366
3367 assert!(msg.contains("from DEEPSEEK_PROVIDER"), "{msg}");
3368 assert!(msg.contains("unset DEEPSEEK_PROVIDER"), "{msg}");
3369 assert!(msg.contains("Supported TUI providers:"), "{msg}");
3370 }
3371
3372 #[test]
3373 fn build_tui_command_config_fallback_does_not_forward_stale_keyring_secret() {
3374 let _lock = env_lock();
3375 let (_dir, _bin) = install_fake_tui_binary();
3376
3377 let cli = parse_ok(&["codewhale", "doctor"]);
3378 let mut resolved =
3379 resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Config);
3380 resolved.api_key = Some("anthropic-keyring-secret".to_string());
3381 resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
3382
3383 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3384 .expect("config-sourced unsupported provider should fall back");
3385
3386 assert_eq!(
3387 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3388 Some("deepseek")
3389 );
3390 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
3391 assert_eq!(command_env(&cmd, "ANTHROPIC_API_KEY"), None);
3392 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
3393 }
3394
3395 #[test]
3396 fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
3397 let _lock = env_lock();
3398 let dir = tempfile::TempDir::new().expect("tempdir");
3399 let custom = dir
3400 .path()
3401 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3402 std::fs::write(&custom, b"").unwrap();
3403 let custom_str = custom.to_string_lossy().into_owned();
3404 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3405
3406 let cli = parse_ok(&["deepseek", "--profile", "google"]);
3407 let mut resolved_headers = std::collections::BTreeMap::new();
3408 resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
3409 let resolved = ResolvedRuntimeOptions {
3410 provider: ProviderKind::Deepseek,
3411 provider_source: ProviderSource::Config,
3412 model: "deepseek-v4-pro".to_string(),
3413 api_key: Some("config-file-key".to_string()),
3414 api_key_source: Some(RuntimeApiKeySource::ConfigFile),
3415 base_url: "https://api.deepseek.com/beta".to_string(),
3416 auth_mode: Some("api_key".to_string()),
3417 insecure_skip_tls_verify: false,
3418 output_mode: None,
3419 log_level: None,
3420 telemetry: false,
3421 approval_policy: None,
3422 sandbox_mode: None,
3423 yolo: None,
3424 verbosity: None,
3425 http_headers: resolved_headers,
3426 };
3427
3428 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3429
3430 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
3431 assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
3432 assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
3433 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
3434 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
3435 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
3436 assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
3437 let args: Vec<String> = cmd
3438 .get_args()
3439 .map(|arg| arg.to_string_lossy().into_owned())
3440 .collect();
3441 assert!(
3442 args.windows(2).any(|pair| pair == ["--profile", "google"]),
3443 "expected profile forwarding in args: {args:?}"
3444 );
3445 }
3446
3447 #[test]
3448 fn build_tui_command_defaults_noninteractive_to_concise_verbosity() {
3449 let _lock = env_lock();
3450 let (_dir, _bin) = install_fake_tui_binary();
3451
3452 let cli = parse_ok(&["codewhale"]);
3453 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
3454
3455 let cmd = build_tui_command(
3456 &cli,
3457 &resolved,
3458 vec!["exec".to_string(), "summarize".to_string()],
3459 )
3460 .expect("command");
3461
3462 assert_eq!(
3463 command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
3464 Some("concise")
3465 );
3466 assert_eq!(
3467 command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
3468 Some("concise")
3469 );
3470 }
3471
3472 #[test]
3473 fn build_tui_command_respects_resolved_verbosity_override() {
3474 let _lock = env_lock();
3475 let (_dir, _bin) = install_fake_tui_binary();
3476
3477 let cli = parse_ok(&["codewhale"]);
3478 let mut resolved =
3479 resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
3480 resolved.verbosity = Some("normal".to_string());
3481
3482 let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string()]).expect("command");
3483
3484 assert_eq!(
3485 command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
3486 Some("normal")
3487 );
3488 assert_eq!(
3489 command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
3490 Some("normal")
3491 );
3492 }
3493
3494 #[test]
3495 fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
3496 let _lock = env_lock();
3497 let dir = tempfile::TempDir::new().expect("tempdir");
3498 let custom = dir
3499 .path()
3500 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3501 std::fs::write(&custom, b"").unwrap();
3502 let custom_str = custom.to_string_lossy().into_owned();
3503 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3504
3505 let cli = parse_ok(&[
3506 "codewhale",
3507 "--provider",
3508 "moonshot",
3509 "--model",
3510 "kimi-k2.7-code",
3511 "--workspace",
3512 "/tmp/codewhale-workspace",
3513 ]);
3514 let resolved = ResolvedRuntimeOptions {
3515 provider: ProviderKind::Moonshot,
3516 provider_source: ProviderSource::Cli,
3517 model: "kimi-k2.7-code".to_string(),
3518 api_key: Some("resolved-kimi-key".to_string()),
3519 api_key_source: Some(RuntimeApiKeySource::Keyring),
3520 base_url: "https://api.moonshot.ai/v1".to_string(),
3521 auth_mode: Some("api_key".to_string()),
3522 insecure_skip_tls_verify: false,
3523 output_mode: None,
3524 log_level: None,
3525 telemetry: false,
3526 approval_policy: None,
3527 sandbox_mode: None,
3528 yolo: None,
3529 verbosity: None,
3530 http_headers: std::collections::BTreeMap::new(),
3531 };
3532
3533 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3534 assert_eq!(
3535 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3536 Some("moonshot")
3537 );
3538 assert_eq!(
3539 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
3540 Some("kimi-k2.7-code")
3541 );
3542 assert_eq!(
3543 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3544 Some("resolved-kimi-key")
3545 );
3546 assert_eq!(
3547 command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
3548 Some("resolved-kimi-key")
3549 );
3550 assert_eq!(
3551 command_env(&cmd, "KIMI_API_KEY").as_deref(),
3552 Some("resolved-kimi-key")
3553 );
3554 assert_eq!(
3555 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
3556 Some("keyring")
3557 );
3558 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
3559 }
3560
3561 #[test]
3562 fn build_tui_command_allows_volcengine_and_forwards_ark_keys() {
3563 let _lock = env_lock();
3564 let dir = tempfile::TempDir::new().expect("tempdir");
3565 let custom = dir
3566 .path()
3567 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3568 std::fs::write(&custom, b"").unwrap();
3569 let custom_str = custom.to_string_lossy().into_owned();
3570 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3571
3572 let cli = parse_ok(&[
3573 "codewhale",
3574 "--provider",
3575 "volcengine",
3576 "--model",
3577 "DeepSeek-V4-Pro",
3578 "--workspace",
3579 "/tmp/codewhale-workspace",
3580 ]);
3581 let resolved = ResolvedRuntimeOptions {
3582 provider: ProviderKind::Volcengine,
3583 provider_source: ProviderSource::Cli,
3584 model: "DeepSeek-V4-Pro".to_string(),
3585 api_key: Some("resolved-ark-key".to_string()),
3586 api_key_source: Some(RuntimeApiKeySource::Keyring),
3587 base_url: "https://ark.cn-beijing.volces.com/api/coding/v3".to_string(),
3588 auth_mode: Some("api_key".to_string()),
3589 insecure_skip_tls_verify: false,
3590 output_mode: None,
3591 log_level: None,
3592 telemetry: false,
3593 approval_policy: None,
3594 sandbox_mode: None,
3595 yolo: None,
3596 verbosity: None,
3597 http_headers: std::collections::BTreeMap::new(),
3598 };
3599
3600 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3601 assert_eq!(
3602 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3603 Some("volcengine")
3604 );
3605 assert_eq!(
3606 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
3607 Some("DeepSeek-V4-Pro")
3608 );
3609 assert_eq!(
3610 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3611 Some("resolved-ark-key")
3612 );
3613 assert_eq!(
3614 command_env(&cmd, "VOLCENGINE_API_KEY").as_deref(),
3615 Some("resolved-ark-key")
3616 );
3617 assert_eq!(
3618 command_env(&cmd, "VOLCENGINE_ARK_API_KEY").as_deref(),
3619 Some("resolved-ark-key")
3620 );
3621 assert_eq!(
3622 command_env(&cmd, "ARK_API_KEY").as_deref(),
3623 Some("resolved-ark-key")
3624 );
3625 }
3626
3627 #[test]
3628 fn build_tui_command_exports_explicit_provider_model_and_base_url() {
3629 let _lock = env_lock();
3630 let dir = tempfile::TempDir::new().expect("tempdir");
3631 let custom = dir
3632 .path()
3633 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3634 std::fs::write(&custom, b"").unwrap();
3635 let custom_str = custom.to_string_lossy().into_owned();
3636 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3637
3638 let cli = parse_ok(&[
3639 "deepseek",
3640 "--profile",
3641 "google",
3642 "--provider",
3643 "openai",
3644 "--model",
3645 "glm-5",
3646 "--base-url",
3647 "https://openai-compatible.example/v4",
3648 ]);
3649 let resolved = ResolvedRuntimeOptions {
3650 provider: ProviderKind::Openai,
3651 provider_source: ProviderSource::Cli,
3652 model: "glm-5".to_string(),
3653 api_key: None,
3654 api_key_source: None,
3655 base_url: "https://openai-compatible.example/v4".to_string(),
3656 auth_mode: None,
3657 insecure_skip_tls_verify: false,
3658 output_mode: None,
3659 log_level: None,
3660 telemetry: false,
3661 approval_policy: None,
3662 sandbox_mode: None,
3663 yolo: None,
3664 verbosity: None,
3665 http_headers: std::collections::BTreeMap::new(),
3666 };
3667
3668 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3669
3670 assert_eq!(
3671 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3672 Some("openai")
3673 );
3674 assert_eq!(
3675 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
3676 Some("glm-5")
3677 );
3678 assert_eq!(
3679 command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
3680 Some("https://openai-compatible.example/v4")
3681 );
3682 }
3683
3684 #[test]
3685 fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
3686 let _lock = env_lock();
3687 let dir = tempfile::TempDir::new().expect("tempdir");
3688 let custom = dir
3689 .path()
3690 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3691 std::fs::write(&custom, b"").unwrap();
3692 let custom_str = custom.to_string_lossy().into_owned();
3693 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3694
3695 let cases: &[(ProviderKind, &str, &[&str])] = &[
3697 (
3698 ProviderKind::Openrouter,
3699 "openrouter",
3700 &["OPENROUTER_API_KEY"],
3701 ),
3702 (
3703 ProviderKind::XiaomiMimo,
3704 "xiaomi-mimo",
3705 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"],
3706 ),
3707 (ProviderKind::Novita, "novita", &["NOVITA_API_KEY"]),
3708 (
3709 ProviderKind::NvidiaNim,
3710 "nvidia-nim",
3711 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"],
3712 ),
3713 (ProviderKind::Fireworks, "fireworks", &["FIREWORKS_API_KEY"]),
3714 (
3715 ProviderKind::Siliconflow,
3716 "siliconflow",
3717 &["SILICONFLOW_API_KEY"],
3718 ),
3719 (ProviderKind::Arcee, "arcee", &["ARCEE_API_KEY"]),
3720 (ProviderKind::Sglang, "sglang", &["SGLANG_API_KEY"]),
3721 (ProviderKind::Vllm, "vllm", &["VLLM_API_KEY"]),
3722 (ProviderKind::Ollama, "ollama", &["OLLAMA_API_KEY"]),
3723 (
3724 ProviderKind::Atlascloud,
3725 "atlascloud",
3726 &["ATLASCLOUD_API_KEY"],
3727 ),
3728 (
3729 ProviderKind::WanjieArk,
3730 "wanjie-ark",
3731 &[
3732 "WANJIE_ARK_API_KEY",
3733 "WANJIE_API_KEY",
3734 "WANJIE_MAAS_API_KEY",
3735 ],
3736 ),
3737 ];
3738
3739 for &(provider, flag, expected_vars) in cases {
3740 let cli = parse_ok(&[
3741 "codewhale",
3742 "--provider",
3743 flag,
3744 "--workspace",
3745 "/tmp/codewhale-workspace",
3746 ]);
3747 let resolved = ResolvedRuntimeOptions {
3748 provider,
3749 provider_source: ProviderSource::Cli,
3750 model: "test-model".to_string(),
3751 api_key: Some("test-key".to_string()),
3752 api_key_source: Some(RuntimeApiKeySource::Keyring),
3753 base_url: "http://localhost:8000/v1".to_string(),
3754 auth_mode: Some("api_key".to_string()),
3755 insecure_skip_tls_verify: false,
3756 output_mode: None,
3757 log_level: None,
3758 telemetry: false,
3759 approval_policy: None,
3760 sandbox_mode: None,
3761 yolo: None,
3762 verbosity: None,
3763 http_headers: std::collections::BTreeMap::new(),
3764 };
3765
3766 let cmd = build_tui_command(&cli, &resolved, Vec::new())
3767 .unwrap_or_else(|e| panic!("{flag}: {e}"));
3768
3769 assert_eq!(
3770 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3771 Some("test-key"),
3772 "{flag}: DEEPSEEK_API_KEY not forwarded"
3773 );
3774 for var in expected_vars {
3775 assert_eq!(
3776 command_env(&cmd, var).as_deref(),
3777 Some("test-key"),
3778 "{flag}: {var} not forwarded"
3779 );
3780 }
3781 assert_eq!(
3782 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
3783 Some("keyring"),
3784 "{flag}: expected keyring source bridge"
3785 );
3786 assert_eq!(
3787 command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
3788 None,
3789 "{flag}: auth mode should come from config/profile, not env handoff"
3790 );
3791 }
3792 }
3793
3794 #[test]
3795 fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
3796 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
3797
3798 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
3799 assert!(cli.prompt.is_empty());
3800 assert_eq!(
3801 root_tui_passthrough(&cli).unwrap(),
3802 vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
3803 );
3804 }
3805
3806 #[test]
3807 fn parses_top_level_continue_for_interactive_resume() {
3808 let cli = parse_ok(&["codewhale", "--continue"]);
3809
3810 assert!(cli.continue_session);
3811 assert!(cli.prompt_flag.is_none());
3812 assert!(cli.prompt.is_empty());
3813 assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
3814 }
3815
3816 #[test]
3817 fn top_level_continue_rejects_startup_prompt() {
3818 let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
3819
3820 let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
3821 assert!(
3822 err.to_string()
3823 .contains("codewhale exec --continue <PROMPT>")
3824 );
3825 }
3826
3827 #[test]
3828 fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
3829 let cli = parse_ok(&["deepseek", "hello", "world"]);
3830
3831 assert_eq!(cli.prompt, vec!["hello", "world"]);
3832 assert!(cli.command.is_none());
3833 assert_eq!(
3834 root_tui_passthrough(&cli).unwrap(),
3835 vec!["--prompt".to_string(), "hello world".to_string()]
3836 );
3837 }
3838
3839 #[test]
3840 fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
3841 let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
3842
3843 assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
3844 assert_eq!(cli.prompt, vec!["world"]);
3845 assert_eq!(
3846 root_tui_passthrough(&cli).unwrap(),
3847 vec!["--prompt".to_string(), "hello world".to_string()]
3848 );
3849 }
3850
3851 #[test]
3852 fn known_subcommands_still_parse_before_prompt_tail() {
3853 let cli = parse_ok(&["deepseek", "doctor"]);
3854
3855 assert!(cli.prompt.is_empty());
3856 assert!(matches!(cli.command, Some(Commands::Doctor(_))));
3857 }
3858
3859 #[test]
3860 fn root_help_surface_contains_expected_subcommands_and_globals() {
3861 let rendered = help_for(&["deepseek", "--help"]);
3862
3863 for token in [
3864 "run",
3865 "doctor",
3866 "models",
3867 "sessions",
3868 "resume",
3869 "setup",
3870 "login",
3871 "logout",
3872 "auth",
3873 "mcp-server",
3874 "config",
3875 "model",
3876 "thread",
3877 "sandbox",
3878 "app-server",
3879 "completion",
3880 "metrics",
3881 "--provider",
3882 "--model",
3883 "--config",
3884 "--profile",
3885 "--output-mode",
3886 "--log-level",
3887 "--telemetry",
3888 "--base-url",
3889 "--api-key",
3890 "--approval-policy",
3891 "--sandbox-mode",
3892 "--mouse-capture",
3893 "--no-mouse-capture",
3894 "--skip-onboarding",
3895 "--continue",
3896 "--prompt",
3897 ] {
3898 assert!(
3899 rendered.contains(token),
3900 "expected help to contain token: {token}"
3901 );
3902 }
3903 }
3904
3905 #[test]
3906 fn subcommand_help_surfaces_are_stable() {
3907 let cases = [
3908 ("config", vec!["get", "set", "unset", "list", "path"]),
3909 ("model", vec!["list", "resolve"]),
3910 (
3911 "thread",
3912 vec![
3913 "list",
3914 "read",
3915 "resume",
3916 "fork",
3917 "archive",
3918 "unarchive",
3919 "set-name",
3920 "clear-name",
3921 ],
3922 ),
3923 ("sandbox", vec!["check"]),
3924 (
3925 "exec",
3926 vec![
3927 "--auto",
3928 "--json",
3929 "--resume",
3930 "--session-id",
3931 "--continue",
3932 "--output-format",
3933 "stream-json",
3934 ],
3935 ),
3936 (
3937 "app-server",
3938 vec!["--host", "--port", "--config", "--stdio"],
3939 ),
3940 (
3941 "completion",
3942 vec![
3943 "<SHELL>",
3944 "bash",
3945 "source <(codewhale completion bash)",
3946 "~/.local/share/bash-completion/completions/codewhale",
3947 "fpath=(~/.zfunc $fpath)",
3948 "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
3949 "codewhale completion powershell | Out-String | Invoke-Expression",
3950 ],
3951 ),
3952 ("metrics", vec!["--json", "--since"]),
3953 ];
3954
3955 for (subcommand, expected_tokens) in cases {
3956 let argv = ["deepseek", subcommand, "--help"];
3957 let rendered = help_for(&argv);
3958 for token in expected_tokens {
3959 assert!(
3960 rendered.contains(token),
3961 "expected help for `{subcommand}` to include `{token}`"
3962 );
3963 }
3964 }
3965 }
3966
3967 #[test]
3973 fn sibling_tui_candidate_picks_platform_correct_name() {
3974 let dir = tempfile::TempDir::new().expect("tempdir");
3975 let dispatcher = dir
3976 .path()
3977 .join("codewhale")
3978 .with_extension(std::env::consts::EXE_EXTENSION);
3979 std::fs::write(&dispatcher, b"").unwrap();
3981
3982 assert!(sibling_tui_candidate(&dispatcher).is_none());
3984
3985 let target =
3986 dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
3987 std::fs::write(&target, b"").unwrap();
3988
3989 let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
3990 assert_eq!(found, target, "primary platform-correct name wins");
3991 }
3992
3993 #[test]
3994 fn dispatcher_spawn_error_names_path_and_recovery_checks() {
3995 let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
3996 let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
3997
3998 assert!(message.contains("C:/tools/codewhale-tui.exe"));
3999 assert!(message.contains("access is denied"));
4000 assert!(message.contains("where codewhale"));
4001 assert!(message.contains("DEEPSEEK_TUI_BIN"));
4002 }
4003
4004 #[cfg(windows)]
4009 #[test]
4010 fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
4011 let dir = tempfile::TempDir::new().expect("tempdir");
4012 let dispatcher = dir.path().join("codewhale.exe");
4013 std::fs::write(&dispatcher, b"").unwrap();
4014
4015 let suffixless = dispatcher.with_file_name("codewhale-tui");
4017 std::fs::write(&suffixless, b"").unwrap();
4018
4019 let found = sibling_tui_candidate(&dispatcher)
4020 .expect("Windows fallback must locate suffixless codewhale-tui");
4021 assert_eq!(found, suffixless);
4022 }
4023
4024 #[test]
4027 fn locate_sibling_tui_binary_honours_env_override() {
4028 let _lock = env_lock();
4029 let dir = tempfile::TempDir::new().expect("tempdir");
4030 let custom = dir
4031 .path()
4032 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
4033 std::fs::write(&custom, b"").unwrap();
4034 let custom_str = custom.to_string_lossy().into_owned();
4035 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
4036
4037 let resolved = locate_sibling_tui_binary().expect("override must resolve");
4038 assert_eq!(resolved, custom);
4039 }
4040}