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