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