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