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