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