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