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