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, ProviderSource, ResolvedRuntimeOptions,
18 RuntimeApiKeySource,
19};
20use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
21use codewhale_mcp::{McpServerDefinition, run_stdio_server};
22use codewhale_secrets::Secrets;
23use codewhale_state::{StateStore, ThreadListFilters};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
26enum ProviderArg {
27 Deepseek,
28 NvidiaNim,
29 Openai,
30 Atlascloud,
31 WanjieArk,
32 Volcengine,
33 Openrouter,
34 XiaomiMimo,
35 Novita,
36 Fireworks,
37 Siliconflow,
38 Arcee,
39 Moonshot,
40 Sglang,
41 Vllm,
42 Ollama,
43 Huggingface,
44 Together,
45 OpenaiCodex,
46 Anthropic,
47 Zai,
48 Stepfun,
49 Minimax,
50 #[value(alias = "deep-infra", alias = "deep_infra")]
51 Deepinfra,
52}
53
54impl From<ProviderArg> for ProviderKind {
55 fn from(value: ProviderArg) -> Self {
56 match value {
57 ProviderArg::Deepseek => ProviderKind::Deepseek,
58 ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
59 ProviderArg::Openai => ProviderKind::Openai,
60 ProviderArg::Atlascloud => ProviderKind::Atlascloud,
61 ProviderArg::WanjieArk => ProviderKind::WanjieArk,
62 ProviderArg::Volcengine => ProviderKind::Volcengine,
63 ProviderArg::Openrouter => ProviderKind::Openrouter,
64 ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
65 ProviderArg::Novita => ProviderKind::Novita,
66 ProviderArg::Fireworks => ProviderKind::Fireworks,
67 ProviderArg::Siliconflow => ProviderKind::Siliconflow,
68 ProviderArg::Arcee => ProviderKind::Arcee,
69 ProviderArg::Moonshot => ProviderKind::Moonshot,
70 ProviderArg::Sglang => ProviderKind::Sglang,
71 ProviderArg::Vllm => ProviderKind::Vllm,
72 ProviderArg::Ollama => ProviderKind::Ollama,
73 ProviderArg::Huggingface => ProviderKind::Huggingface,
74 ProviderArg::Together => ProviderKind::Together,
75 ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
76 ProviderArg::Anthropic => ProviderKind::Anthropic,
77 ProviderArg::Zai => ProviderKind::Zai,
78 ProviderArg::Stepfun => ProviderKind::Stepfun,
79 ProviderArg::Minimax => ProviderKind::Minimax,
80 ProviderArg::Deepinfra => ProviderKind::Deepinfra,
81 }
82 }
83}
84
85#[derive(Debug, Parser)]
86#[command(
87 name = "codewhale",
88 version = env!("DEEPSEEK_BUILD_VERSION"),
89 bin_name = "codewhale",
90 override_usage = "codewhale [OPTIONS] [PROMPT]\n codewhale [OPTIONS] <COMMAND> [ARGS]"
91)]
92struct Cli {
93 #[arg(long)]
94 config: Option<PathBuf>,
95 #[arg(long)]
96 profile: Option<String>,
97 #[arg(
98 long,
99 value_enum,
100 help = "Advanced provider selector for non-TUI registry/config commands"
101 )]
102 provider: Option<ProviderArg>,
103 #[arg(long)]
104 model: Option<String>,
105 #[arg(long = "output-mode")]
106 output_mode: Option<String>,
107 #[arg(
108 long = "verbosity",
109 value_name = "LEVEL",
110 help = "Controls transcript and output verbosity (normal, concise)"
111 )]
112 verbosity: Option<String>,
113 #[arg(long = "log-level")]
114 log_level: Option<String>,
115 #[arg(long)]
116 telemetry: Option<bool>,
117 #[arg(long)]
118 approval_policy: Option<String>,
119 #[arg(long)]
120 sandbox_mode: Option<String>,
121 #[arg(long)]
122 api_key: Option<String>,
123 #[arg(long)]
124 base_url: Option<String>,
125 #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
127 workspace: Option<PathBuf>,
128 #[arg(long = "no-alt-screen", hide = true)]
129 no_alt_screen: bool,
130 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
131 mouse_capture: bool,
132 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
133 no_mouse_capture: bool,
134 #[arg(long = "skip-onboarding")]
135 skip_onboarding: bool,
136 #[arg(long)]
138 yolo: bool,
139 #[arg(short = 'c', long = "continue")]
141 continue_session: bool,
142 #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
143 prompt_flag: Option<String>,
144 #[arg(
145 value_name = "PROMPT",
146 trailing_var_arg = true,
147 allow_hyphen_values = true
148 )]
149 prompt: Vec<String>,
150 #[command(subcommand)]
151 command: Option<Commands>,
152}
153
154#[derive(Debug, Subcommand)]
155enum Commands {
156 Run(RunArgs),
158 Doctor(TuiPassthroughArgs),
160 Models(TuiPassthroughArgs),
162 #[command(visible_alias = "tts")]
164 Speech(TuiPassthroughArgs),
165 Sessions(TuiPassthroughArgs),
167 Resume(TuiPassthroughArgs),
169 Fork(TuiPassthroughArgs),
171 Init(TuiPassthroughArgs),
173 Setup(TuiPassthroughArgs),
175 RemoteSetup(RemoteSetupArgs),
177 #[command(after_help = "\
179Examples:
180 codewhale exec \"explain this function\"
181 codewhale exec --auto \"list crates/ with ls\"
182 codewhale exec --auto --output-format stream-json \"fix the failing test\"
183
184Common forwarded flags:
185 --auto Enable tool-backed agent mode with auto-approvals
186 --json Emit summary JSON
187 --resume <SESSION_ID> Resume a previous session by ID or prefix
188 --session-id <SESSION_ID> Resume a previous session by ID or prefix
189 --continue Continue the most recent session for this workspace
190 --output-format <FORMAT> Output format: text or stream-json
191
192Plain `codewhale exec` is a one-shot model response. Use `--auto` for
193non-interactive filesystem/shell tool use, matching the supported automation
194path used by stream-json wrappers.
195")]
196 Exec(TuiPassthroughArgs),
197 #[command(after_help = "\
199Examples:
200 codewhale swebench run --instance-id django__django-12345 --issue-file issue.md
201 codewhale swebench export --instance-id django__django-12345 --predictions-path all_preds.jsonl
202
203This command forwards to the TUI runtime. `run` invokes tool-backed agent mode
204and writes a SWE-bench-compatible JSONL prediction row from the resulting
205working-tree diff. `export` only writes the current diff.
206")]
207 Swebench(TuiPassthroughArgs),
208 Fleet(TuiPassthroughArgs),
210 Review(TuiPassthroughArgs),
212 Apply(TuiPassthroughArgs),
214 Eval(TuiPassthroughArgs),
216 Mcp(TuiPassthroughArgs),
218 Features(TuiPassthroughArgs),
220 #[command(after_help = "\
222Forwarded serve options:
223 --mcp Start MCP server over stdio
224 --http Start runtime HTTP/SSE API server
225 --mobile Start runtime HTTP/SSE API server with the mobile control page
226 --qr Show a QR code for the mobile URL (requires --mobile)
227 --acp Start ACP server over stdio for editor clients
228 --host <HOST> Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
229 --port <PORT> Bind port [default: 7878]
230 --workers <WORKERS> Background task worker count (1-8)
231 --cors-origin <URL> Additional CORS origin to allow (repeatable)
232 --auth-token <TOKEN> Require this bearer token for /v1/* runtime API routes
233 --insecure Disable runtime API auth when no token is configured
234
235`codewhale serve --http` and `codewhale serve --mobile` remain compatibility
236aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
237New integrations should prefer `codewhale app-server`.")]
238 Serve(TuiPassthroughArgs),
239 Completions(TuiPassthroughArgs),
241 Login(LoginArgs),
243 Logout,
245 Auth(AuthArgs),
247 McpServer,
249 Config(ConfigArgs),
251 Model(ModelArgs),
253 Thread(ThreadArgs),
255 Sandbox(SandboxArgs),
257 #[command(after_help = "\
259Transports:
260 codewhale app-server --http Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
261 codewhale app-server --mobile Runtime API + phone control page (binds 0.0.0.0)
262 codewhale app-server --stdio JSON-RPC control transport over stdio (no listener)
263 codewhale app-server Legacy in-process app-server HTTP on 127.0.0.1:8787
264
265`--http` and `--mobile` serve the same mature runtime API as `codewhale serve
266--http`/`--mobile`, which remain as compatibility aliases. The runtime API token
267is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
268
269See docs/RUNTIME_API.md and scripts/release/app-server-smoke.sh.")]
270 AppServer(AppServerArgs),
271 #[command(after_help = r#"Examples:
273 Bash (current shell only):
274 source <(codewhale completion bash)
275
276 Bash (persistent, Linux/bash-completion):
277 mkdir -p ~/.local/share/bash-completion/completions
278 codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
279 # Requires bash-completion to be installed and loaded by your shell.
280
281 Zsh:
282 mkdir -p ~/.zfunc
283 codewhale completion zsh > ~/.zfunc/_codewhale
284 # Add to ~/.zshrc if needed:
285 # fpath=(~/.zfunc $fpath)
286 # autoload -Uz compinit && compinit
287
288 Fish:
289 mkdir -p ~/.config/fish/completions
290 codewhale completion fish > ~/.config/fish/completions/codewhale.fish
291
292 PowerShell (current shell only):
293 codewhale completion powershell | Out-String | Invoke-Expression
294
295The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
296 Completion {
297 #[arg(value_enum)]
298 shell: Shell,
299 },
300 Metrics(MetricsArgs),
302 Update(UpdateArgs),
304}
305
306#[derive(Debug, Args)]
307struct UpdateArgs {
308 #[arg(long)]
310 beta: bool,
311 #[arg(long)]
313 check: bool,
314 #[arg(long, value_name = "URL")]
316 proxy: Option<String>,
317}
318
319#[derive(Debug, Args)]
320struct MetricsArgs {
321 #[arg(long)]
323 json: bool,
324 #[arg(long, value_name = "DURATION")]
326 since: Option<String>,
327}
328
329#[derive(Debug, Args)]
330struct RunArgs {
331 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
332 args: Vec<String>,
333}
334
335#[derive(Debug, Args, Clone)]
336struct TuiPassthroughArgs {
337 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
338 args: Vec<String>,
339}
340
341#[derive(Debug, Args, Clone, Default)]
344struct RemoteSetupArgs {
345 #[arg(long)]
347 cloud: Option<String>,
348 #[arg(long)]
350 bridge: Option<String>,
351 #[arg(long)]
353 provider: Option<String>,
354 #[arg(long, value_name = "DIR")]
356 out: Option<PathBuf>,
357 #[arg(long, default_value_t = false)]
359 generate_only: bool,
360 #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
362 apply: bool,
363 #[arg(long, default_value_t = false)]
365 yes: bool,
366 #[arg(long, default_value_t = false)]
368 non_interactive: bool,
369}
370
371fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
375 let mut forwarded = vec!["remote-setup".to_string()];
376 if let Some(cloud) = args.cloud {
377 forwarded.push("--cloud".to_string());
378 forwarded.push(cloud);
379 }
380 if let Some(bridge) = args.bridge {
381 forwarded.push("--bridge".to_string());
382 forwarded.push(bridge);
383 }
384 if let Some(provider) = args.provider {
385 forwarded.push("--provider".to_string());
386 forwarded.push(provider);
387 }
388 if let Some(out) = args.out {
389 forwarded.push("--out".to_string());
390 forwarded.push(out.to_string_lossy().into_owned());
391 }
392 if args.generate_only {
393 forwarded.push("--generate-only".to_string());
394 }
395 if args.apply {
396 forwarded.push("--apply".to_string());
397 }
398 if args.yes {
399 forwarded.push("--yes".to_string());
400 }
401 if args.non_interactive {
402 forwarded.push("--non-interactive".to_string());
403 }
404 forwarded
405}
406
407#[derive(Debug, Args)]
408struct LoginArgs {
409 #[arg(long, value_enum, hide = true)]
410 provider: Option<ProviderArg>,
411 #[arg(long)]
412 api_key: Option<String>,
413}
414
415#[derive(Debug, Args)]
416struct AuthArgs {
417 #[command(subcommand)]
418 command: AuthCommand,
419}
420
421#[derive(Debug, Subcommand)]
422enum AuthCommand {
423 Status {
427 #[arg(long, value_enum)]
429 provider: Option<ProviderArg>,
430 },
431 Set {
435 #[arg(long, value_enum)]
436 provider: ProviderArg,
437 #[arg(long)]
439 api_key: Option<String>,
440 #[arg(long = "api-key-stdin", default_value_t = false)]
442 api_key_stdin: bool,
443 },
444 Get {
447 #[arg(long, value_enum)]
448 provider: ProviderArg,
449 },
450 Clear {
452 #[arg(long, value_enum)]
453 provider: ProviderArg,
454 },
455 List,
458 #[command(hide = true)]
460 Migrate {
461 #[arg(long, default_value_t = false)]
463 dry_run: bool,
464 },
465}
466
467#[derive(Debug, Args)]
468struct ConfigArgs {
469 #[command(subcommand)]
470 command: ConfigCommand,
471}
472
473#[derive(Debug, Subcommand)]
474enum ConfigCommand {
475 Get { key: String },
476 Set { key: String, value: String },
477 Unset { key: String },
478 List,
479 Path,
480}
481
482#[derive(Debug, Args)]
483struct ModelArgs {
484 #[command(subcommand)]
485 command: ModelCommand,
486}
487
488#[derive(Debug, Subcommand)]
489enum ModelCommand {
490 List {
491 #[arg(long, value_enum)]
492 provider: Option<ProviderArg>,
493 },
494 Resolve {
495 model: Option<String>,
496 #[arg(long, value_enum)]
497 provider: Option<ProviderArg>,
498 },
499}
500
501#[derive(Debug, Args)]
502struct ThreadArgs {
503 #[command(subcommand)]
504 command: ThreadCommand,
505}
506
507#[derive(Debug, Subcommand)]
508enum ThreadCommand {
509 List {
510 #[arg(long, default_value_t = false)]
511 all: bool,
512 #[arg(long)]
513 limit: Option<usize>,
514 },
515 Read {
516 thread_id: String,
517 },
518 Resume {
519 thread_id: String,
520 },
521 Fork {
522 thread_id: String,
523 },
524 Archive {
525 thread_id: String,
526 },
527 Unarchive {
528 thread_id: String,
529 },
530 SetName {
531 thread_id: String,
532 name: String,
533 },
534 ClearName {
537 thread_id: String,
538 },
539}
540
541#[derive(Debug, Args)]
542struct SandboxArgs {
543 #[command(subcommand)]
544 command: SandboxCommand,
545}
546
547#[derive(Debug, Subcommand)]
548enum SandboxCommand {
549 Check {
550 command: String,
551 #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
552 ask: ApprovalModeArg,
553 },
554}
555
556#[derive(Debug, Clone, Copy, ValueEnum)]
557enum ApprovalModeArg {
558 UnlessTrusted,
559 OnFailure,
560 OnRequest,
561 Never,
562}
563
564impl From<ApprovalModeArg> for AskForApproval {
565 fn from(value: ApprovalModeArg) -> Self {
566 match value {
567 ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
568 ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
569 ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
570 ApprovalModeArg::Never => AskForApproval::Never,
571 }
572 }
573}
574
575#[derive(Debug, Args)]
576struct AppServerArgs {
577 #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
581 http: bool,
582 #[arg(long, conflicts_with = "stdio")]
585 mobile: bool,
586 #[arg(long, default_value_t = false)]
589 stdio: bool,
590 #[arg(long, requires = "mobile")]
592 qr: bool,
593 #[arg(long)]
596 host: Option<String>,
597 #[arg(long)]
600 port: Option<u16>,
601 #[arg(long)]
603 workers: Option<usize>,
604 #[arg(long)]
605 config: Option<PathBuf>,
606 #[arg(long = "auth-token")]
607 auth_token: Option<String>,
608 #[arg(long, default_value_t = false)]
609 insecure_no_auth: bool,
610 #[arg(long = "cors-origin")]
611 cors_origin: Vec<String>,
612}
613
614const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
615
616fn install_rustls_crypto_provider() {
617 let _ = rustls::crypto::ring::default_provider().install_default();
618}
619
620pub fn run_cli() -> std::process::ExitCode {
621 install_rustls_crypto_provider();
622
623 match run() {
624 Ok(()) => std::process::ExitCode::SUCCESS,
625 Err(err) => {
626 eprintln!("error: {err}");
634 for cause in err.chain().skip(1) {
635 eprintln!(" caused by: {cause}");
636 }
637 std::process::ExitCode::FAILURE
638 }
639 }
640}
641
642fn run() -> Result<()> {
643 let mut cli = Cli::parse();
644
645 let mut store = ConfigStore::load(cli.config.clone())?;
646 let runtime_overrides = CliRuntimeOverrides {
647 provider: cli.provider.map(Into::into),
648 model: cli.model.clone(),
649 api_key: cli.api_key.clone(),
650 base_url: cli.base_url.clone(),
651 auth_mode: None,
652 output_mode: cli.output_mode.clone(),
653 log_level: cli.log_level.clone(),
654 telemetry: cli.telemetry,
655 approval_policy: cli.approval_policy.clone(),
656 sandbox_mode: cli.sandbox_mode.clone(),
657 yolo: Some(cli.yolo),
658 verbosity: cli.verbosity.clone(),
659 };
660 let command = cli.command.take();
661
662 match command {
663 Some(Commands::Run(args)) => {
664 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
665 delegate_to_tui(&cli, &resolved_runtime, args.args)
666 }
667 Some(Commands::Doctor(args)) => {
668 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
669 delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
670 }
671 Some(Commands::Models(args)) => {
672 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
673 delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
674 }
675 Some(Commands::Speech(args)) => {
676 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
677 delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
678 }
679 Some(Commands::Sessions(args)) => {
680 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
681 delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
682 }
683 Some(Commands::Resume(args)) => {
684 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
685 run_resume_command(&cli, &resolved_runtime, args)
686 }
687 Some(Commands::Fork(args)) => {
688 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
689 delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
690 }
691 Some(Commands::Init(args)) => {
692 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
693 delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
694 }
695 Some(Commands::Setup(args)) => {
696 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
697 delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
698 }
699 Some(Commands::RemoteSetup(args)) => {
700 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
701 delegate_to_tui(&cli, &resolved_runtime, remote_setup_tui_args(args))
702 }
703 Some(Commands::Exec(args)) => {
704 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
705 delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
706 }
707 Some(Commands::Swebench(args)) => {
708 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
709 delegate_to_tui(&cli, &resolved_runtime, tui_args("swebench", args))
710 }
711 Some(Commands::Fleet(args)) => {
712 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
713 delegate_to_tui(&cli, &resolved_runtime, tui_args("fleet", args))
714 }
715 Some(Commands::Review(args)) => {
716 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
717 delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
718 }
719 Some(Commands::Apply(args)) => {
720 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
721 delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
722 }
723 Some(Commands::Eval(args)) => {
724 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
725 delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
726 }
727 Some(Commands::Mcp(args)) => {
728 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
729 delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
730 }
731 Some(Commands::Features(args)) => {
732 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
733 delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
734 }
735 Some(Commands::Serve(args)) => {
736 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
737 delegate_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
738 }
739 Some(Commands::Completions(args)) => {
740 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
741 delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
742 }
743 Some(Commands::Login(args)) => run_login_command(&mut store, args),
744 Some(Commands::Logout) => run_logout_command(&mut store),
745 Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
746 Some(Commands::McpServer) => run_mcp_server_command(&mut store),
747 Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
748 Some(Commands::Model(args)) => run_model_command(args.command, runtime_overrides.provider),
749 Some(Commands::Thread(args)) => run_thread_command(args.command),
750 Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
751 Some(Commands::AppServer(args)) => {
752 if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
757 cli.config = args.config.clone();
758 store = ConfigStore::load(cli.config.clone())?;
759 }
760 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
761 run_app_server_command(&cli, &resolved_runtime, args)
762 }
763 Some(Commands::Completion { shell }) => {
764 let mut cmd = Cli::command();
765 generate(shell, &mut cmd, "codewhale", &mut io::stdout());
766 Ok(())
767 }
768 Some(Commands::Metrics(args)) => run_metrics_command(args),
769 Some(Commands::Update(args)) => update::run_update(args.beta, args.check, args.proxy),
770 None => {
771 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
772 let forwarded = root_tui_passthrough(&cli)?;
773 delegate_to_tui(&cli, &resolved_runtime, forwarded)
774 }
775 }
776}
777
778fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
779 let mut forwarded = Vec::new();
780 if cli.continue_session {
781 forwarded.push("--continue".to_string());
782 }
783
784 let prompt =
785 cli.prompt_flag
786 .iter()
787 .chain(cli.prompt.iter())
788 .fold(String::new(), |mut acc, part| {
789 if !acc.is_empty() {
790 acc.push(' ');
791 }
792 acc.push_str(part);
793 acc
794 });
795 if !prompt.is_empty() {
796 if cli.continue_session {
797 bail!(
798 "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
799 );
800 }
801 forwarded.push("--prompt".to_string());
802 forwarded.push(prompt);
803 }
804
805 Ok(forwarded)
806}
807
808fn resolve_runtime_for_dispatch(
809 store: &mut ConfigStore,
810 runtime_overrides: &CliRuntimeOverrides,
811) -> ResolvedRuntimeOptions {
812 let runtime_secrets = Secrets::auto_detect();
813 resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
814}
815
816fn resolve_runtime_for_dispatch_with_secrets(
817 store: &mut ConfigStore,
818 runtime_overrides: &CliRuntimeOverrides,
819 secrets: &Secrets,
820) -> ResolvedRuntimeOptions {
821 let mut resolved = store
822 .config
823 .resolve_runtime_options_with_secrets(runtime_overrides, secrets);
824
825 if resolved.api_key_source == Some(RuntimeApiKeySource::Keyring)
826 && !provider_config_set(store, resolved.provider)
827 && let Some(api_key) = resolved.api_key.clone()
828 {
829 write_provider_api_key_to_config(store, resolved.provider, &api_key);
830 match store.save() {
831 Ok(()) => {
832 eprintln!(
833 "info: recovered API key from secret store and saved it to {}",
834 store.path().display()
835 );
836 resolved.api_key_source = Some(RuntimeApiKeySource::ConfigFile);
837 }
838 Err(err) => {
839 eprintln!(
840 "warning: recovered API key from secret store but failed to save {}: {err}",
841 store.path().display()
842 );
843 }
844 }
845 }
846
847 resolved
848}
849
850fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
851 let mut forwarded = Vec::with_capacity(args.args.len() + 1);
852 forwarded.push(command.to_string());
853 forwarded.extend(args.args);
854 forwarded
855}
856
857fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
858 run_login_command_with_secrets(store, args, &Secrets::auto_detect())
859}
860
861fn run_login_command_with_secrets(
862 store: &mut ConfigStore,
863 args: LoginArgs,
864 secrets: &Secrets,
865) -> Result<()> {
866 let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
867 store.config.provider = provider;
868
869 let api_key = match args.api_key {
870 Some(v) => v,
871 None => read_api_key_from_stdin()?,
872 };
873 write_provider_api_key_to_config(store, provider, &api_key);
874 let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
875 store.save()?;
876 let destination = if keyring_saved {
877 format!("{} and {}", store.path().display(), secrets.backend_name())
878 } else {
879 store.path().display().to_string()
880 };
881 if provider == ProviderKind::Deepseek {
882 println!("logged in using API key mode (deepseek); saved key to {destination}");
883 } else {
884 println!(
885 "logged in using API key mode ({}); saved key to {destination}",
886 provider.as_str(),
887 );
888 }
889 Ok(())
890}
891
892fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
893 run_logout_command_with_secrets(store, &Secrets::auto_detect())
894}
895
896fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
897 let active_provider = store.config.provider;
898 store.config.api_key = None;
899 for provider in PROVIDER_LIST {
900 clear_provider_api_key_from_config(store, provider);
901 }
902 clear_provider_api_key_from_keyring(secrets, active_provider);
903 store.config.auth_mode = None;
904 store.save()?;
905 println!("logged out");
906 Ok(())
907}
908
909fn provider_slot(provider: ProviderKind) -> &'static str {
911 match provider {
912 ProviderKind::Deepseek => "deepseek",
913 ProviderKind::NvidiaNim => "nvidia-nim",
914 ProviderKind::Openai => "openai",
915 ProviderKind::Atlascloud => "atlascloud",
916 ProviderKind::WanjieArk => "wanjie-ark",
917 ProviderKind::Volcengine => "volcengine",
918 ProviderKind::Openrouter => "openrouter",
919 ProviderKind::XiaomiMimo => "xiaomi-mimo",
920 ProviderKind::Novita => "novita",
921 ProviderKind::Fireworks => "fireworks",
922 ProviderKind::Siliconflow => "siliconflow",
923 ProviderKind::SiliconflowCN => "siliconflow",
924 ProviderKind::Arcee => "arcee",
925 ProviderKind::Moonshot => "moonshot",
926 ProviderKind::Sglang => "sglang",
927 ProviderKind::Vllm => "vllm",
928 ProviderKind::Ollama => "ollama",
929 ProviderKind::Huggingface => "huggingface",
930 ProviderKind::Together => "together",
931 ProviderKind::OpenaiCodex => "openai-codex",
932 ProviderKind::Anthropic => "anthropic",
933 ProviderKind::Zai => "zai",
934 ProviderKind::Stepfun => "stepfun",
935 ProviderKind::Minimax => "minimax",
936 ProviderKind::Deepinfra => "deepinfra",
937 }
938}
939
940const PROVIDER_LIST: [ProviderKind; 25] = [
942 ProviderKind::Deepseek,
943 ProviderKind::NvidiaNim,
944 ProviderKind::Openai,
945 ProviderKind::Atlascloud,
946 ProviderKind::WanjieArk,
947 ProviderKind::Volcengine,
948 ProviderKind::Openrouter,
949 ProviderKind::XiaomiMimo,
950 ProviderKind::Novita,
951 ProviderKind::Fireworks,
952 ProviderKind::Siliconflow,
953 ProviderKind::SiliconflowCN,
954 ProviderKind::Arcee,
955 ProviderKind::Moonshot,
956 ProviderKind::Sglang,
957 ProviderKind::Vllm,
958 ProviderKind::Ollama,
959 ProviderKind::Huggingface,
960 ProviderKind::Together,
961 ProviderKind::OpenaiCodex,
962 ProviderKind::Anthropic,
963 ProviderKind::Zai,
964 ProviderKind::Stepfun,
965 ProviderKind::Minimax,
966 ProviderKind::Deepinfra,
967];
968
969fn provider_is_supported_by_tui(provider: ProviderKind) -> bool {
970 matches!(
971 provider,
972 ProviderKind::Deepseek
973 | ProviderKind::NvidiaNim
974 | ProviderKind::Openai
975 | ProviderKind::Atlascloud
976 | ProviderKind::WanjieArk
977 | ProviderKind::Volcengine
978 | ProviderKind::Openrouter
979 | ProviderKind::XiaomiMimo
980 | ProviderKind::Novita
981 | ProviderKind::Fireworks
982 | ProviderKind::Siliconflow
983 | ProviderKind::SiliconflowCN
984 | ProviderKind::Arcee
985 | ProviderKind::Moonshot
986 | ProviderKind::Sglang
987 | ProviderKind::Vllm
988 | ProviderKind::Ollama
989 | ProviderKind::Huggingface
990 | ProviderKind::Together
991 | ProviderKind::OpenaiCodex
992 | ProviderKind::Zai
993 | ProviderKind::Stepfun
994 | ProviderKind::Minimax
995 )
996 }
1003
1004fn supported_tui_providers_csv() -> String {
1005 ProviderKind::ALL
1006 .iter()
1007 .copied()
1008 .filter(|provider| provider_is_supported_by_tui(*provider))
1009 .map(ProviderKind::as_str)
1010 .collect::<Vec<_>>()
1011 .join(", ")
1012}
1013
1014#[cfg(test)]
1015fn no_keyring_secrets() -> Secrets {
1016 Secrets::new(std::sync::Arc::new(
1017 codewhale_secrets::InMemoryKeyringStore::new(),
1018 ))
1019}
1020
1021fn write_provider_api_key_to_config(
1022 store: &mut ConfigStore,
1023 provider: ProviderKind,
1024 api_key: &str,
1025) {
1026 store.config.auth_mode = Some("api_key".to_string());
1027 store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
1028 if provider == ProviderKind::Deepseek {
1029 store.config.api_key = Some(api_key.to_string());
1030 if store.config.default_text_model.is_none() {
1031 store.config.default_text_model = Some(
1032 store
1033 .config
1034 .providers
1035 .deepseek
1036 .model
1037 .clone()
1038 .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
1039 );
1040 }
1041 }
1042}
1043
1044fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
1045 store.config.providers.for_provider_mut(provider).api_key = None;
1046 if provider == ProviderKind::Deepseek {
1047 store.config.api_key = None;
1048 }
1049}
1050
1051fn provider_env_set(provider: ProviderKind) -> bool {
1052 provider_env_value(provider).is_some()
1053}
1054
1055fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
1056 match provider {
1057 ProviderKind::Deepseek => &["DEEPSEEK_API_KEY"],
1058 ProviderKind::Openrouter => &["OPENROUTER_API_KEY"],
1059 ProviderKind::XiaomiMimo => &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"],
1060 ProviderKind::Novita => &["NOVITA_API_KEY"],
1061 ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
1062 ProviderKind::Fireworks => &["FIREWORKS_API_KEY"],
1063 ProviderKind::Siliconflow => &["SILICONFLOW_API_KEY"],
1064 ProviderKind::SiliconflowCN => &["SILICONFLOW_API_KEY"],
1065 ProviderKind::Arcee => &["ARCEE_API_KEY"],
1066 ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"],
1067 ProviderKind::Sglang => &["SGLANG_API_KEY"],
1068 ProviderKind::Vllm => &["VLLM_API_KEY"],
1069 ProviderKind::Ollama => &["OLLAMA_API_KEY"],
1070 ProviderKind::Huggingface => &["HUGGINGFACE_API_KEY", "HF_TOKEN"],
1071 ProviderKind::Openai => &["OPENAI_API_KEY"],
1072 ProviderKind::Atlascloud => &["ATLASCLOUD_API_KEY"],
1073 ProviderKind::Volcengine => &[
1074 "VOLCENGINE_API_KEY",
1075 "VOLCENGINE_ARK_API_KEY",
1076 "ARK_API_KEY",
1077 ],
1078 ProviderKind::WanjieArk => &[
1079 "WANJIE_ARK_API_KEY",
1080 "WANJIE_API_KEY",
1081 "WANJIE_MAAS_API_KEY",
1082 ],
1083 ProviderKind::Together => &["TOGETHER_API_KEY"],
1084 ProviderKind::OpenaiCodex => &["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"],
1085 ProviderKind::Anthropic => &["ANTHROPIC_API_KEY"],
1086 ProviderKind::Zai => &["ZAI_API_KEY", "Z_AI_API_KEY"],
1087 ProviderKind::Stepfun => &["STEPFUN_API_KEY", "STEP_API_KEY"],
1088 ProviderKind::Minimax => &["MINIMAX_API_KEY"],
1089 ProviderKind::Deepinfra => &["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
1090 }
1091}
1092
1093fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
1094 provider_env_vars(provider).iter().find_map(|var| {
1095 std::env::var(var)
1096 .ok()
1097 .filter(|value| !value.trim().is_empty())
1098 .map(|value| (*var, value))
1099 })
1100}
1101
1102fn openai_codex_auth_file_path() -> PathBuf {
1103 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
1104 let path = PathBuf::from(path);
1105 if !path.as_os_str().is_empty() {
1106 return path;
1107 }
1108 }
1109
1110 let codex_home = std::env::var("CODEX_HOME")
1111 .map(PathBuf::from)
1112 .unwrap_or_else(|_| {
1113 dirs::home_dir()
1114 .unwrap_or_else(|| PathBuf::from("."))
1115 .join(".codex")
1116 });
1117 codex_home.join("auth.json")
1118}
1119
1120fn provider_oauth_file_path(provider: ProviderKind) -> Option<PathBuf> {
1121 (provider == ProviderKind::OpenaiCodex).then(openai_codex_auth_file_path)
1122}
1123
1124fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
1125 let slot = store
1126 .config
1127 .providers
1128 .for_provider(provider)
1129 .api_key
1130 .as_deref();
1131 let root = (provider == ProviderKind::Deepseek)
1132 .then_some(store.config.api_key.as_deref())
1133 .flatten();
1134 slot.or(root).filter(|v| !v.trim().is_empty())
1135}
1136
1137fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
1138 provider_config_api_key(store, provider).is_some()
1139}
1140
1141fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
1142 secrets
1143 .get(provider_slot(provider))
1144 .ok()
1145 .flatten()
1146 .filter(|v| !v.trim().is_empty())
1147}
1148
1149fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
1150 provider_keyring_api_key(secrets, provider).is_some()
1151}
1152
1153fn write_provider_api_key_to_keyring(
1154 secrets: &Secrets,
1155 provider: ProviderKind,
1156 api_key: &str,
1157) -> bool {
1158 secrets.set(provider_slot(provider), api_key).is_ok()
1159}
1160
1161fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
1162 let _ = secrets.delete(provider_slot(provider));
1163}
1164
1165fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
1166 let active_provider = store.config.provider;
1167 let mut lines = Vec::new();
1168 lines.push(format!(
1169 "active provider: {} (set via config or CODEWHALE_PROVIDER)",
1170 active_provider.as_str()
1171 ));
1172 lines.push(String::new());
1173 lines.push(format!(
1174 "{:<14} {:<8} {:<10} {:<8} {}",
1175 "provider", "config", "keyring", "env", "status"
1176 ));
1177 lines.push("-".repeat(70));
1178
1179 for provider in PROVIDER_LIST {
1180 let config_key = provider_config_api_key(store, provider);
1181 let keyring_key = provider_keyring_api_key(secrets, provider);
1182 let env_key = provider_env_value(provider);
1183 let oauth_file_present = provider_oauth_file_path(provider).is_some_and(|p| p.exists());
1184
1185 let config_status = config_key.map(|_| "set").unwrap_or("-");
1186 let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
1187 let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
1188
1189 let source = if provider == ProviderKind::OpenaiCodex {
1190 if env_key.is_some() {
1194 "env"
1195 } else if oauth_file_present {
1196 "oauth file"
1197 } else {
1198 "unset"
1199 }
1200 } else if config_key.is_some() {
1201 "config"
1202 } else if keyring_key.is_some() {
1203 "keyring"
1204 } else if env_key.is_some() {
1205 "env"
1206 } else if oauth_file_present {
1207 "oauth file"
1208 } else {
1209 "unset"
1210 };
1211
1212 let active_marker = if provider == active_provider {
1213 " *"
1214 } else {
1215 ""
1216 };
1217
1218 lines.push(format!(
1219 "{:<14} {:<8} {:<10} {:<8} {}{}",
1220 provider.as_str(),
1221 config_status,
1222 keyring_status,
1223 env_status,
1224 source,
1225 active_marker
1226 ));
1227 }
1228
1229 lines.push(String::new());
1230 lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
1231 lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
1232 lines
1233}
1234
1235fn auth_status_lines_for_provider(
1236 store: &ConfigStore,
1237 secrets: &Secrets,
1238 provider: ProviderKind,
1239) -> Vec<String> {
1240 let config_key = provider_config_api_key(store, provider);
1241 let keyring_key = provider_keyring_api_key(secrets, provider);
1242 let env_key = provider_env_value(provider);
1243 let oauth_file = provider_oauth_file_path(provider);
1244 let oauth_file_present = oauth_file.as_ref().is_some_and(|path| path.exists());
1245
1246 let active_source = if provider == ProviderKind::OpenaiCodex {
1247 if env_key.is_some() {
1248 "env"
1249 } else if oauth_file_present {
1250 "Codex OAuth file"
1251 } else {
1252 "missing"
1253 }
1254 } else if config_key.is_some() {
1255 "config"
1256 } else if keyring_key.is_some() {
1257 "secret store"
1258 } else if env_key.is_some() {
1259 "env"
1260 } else {
1261 "missing"
1262 };
1263 let active_last4 = if provider == ProviderKind::OpenaiCodex {
1264 env_key.as_ref().map(|(_, value)| last4_label(value))
1265 } else {
1266 config_key
1267 .map(last4_label)
1268 .or_else(|| keyring_key.as_deref().map(last4_label))
1269 .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
1270 };
1271 let active_label = active_last4
1272 .map(|last4| format!("{active_source} (last4: {last4})"))
1273 .unwrap_or_else(|| active_source.to_string());
1274
1275 let env_var_label = env_key
1276 .as_ref()
1277 .map(|(name, _)| (*name).to_string())
1278 .unwrap_or_else(|| provider_env_vars(provider).join("/"));
1279 let env_status = env_key
1280 .as_ref()
1281 .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
1282 .unwrap_or_else(|| "unset".to_string());
1283
1284 let is_active = provider == store.config.provider;
1285 let active_marker = if is_active { " (active provider)" } else { "" };
1286
1287 let provider_cfg = store.config.providers.for_provider(provider);
1288 let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
1289 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
1290
1291 let lookup_order = if provider == ProviderKind::OpenaiCodex {
1292 "lookup order: env -> Codex OAuth file".to_string()
1293 } else {
1294 "lookup order: config -> secret store -> env".to_string()
1295 };
1296 let auth_mode = if provider == ProviderKind::OpenaiCodex {
1297 "codex_oauth"
1298 } else {
1299 store.config.auth_mode.as_deref().unwrap_or("api_key")
1300 };
1301
1302 let mut lines = vec![
1303 format!("provider: {}{}", provider.as_str(), active_marker),
1304 format!("route: {}", base_url),
1305 format!("model: {}", model),
1306 format!("auth mode: {auth_mode}"),
1307 format!("active source: {active_label}"),
1308 lookup_order,
1309 format!(
1310 "config file: {} ({})",
1311 store.path().display(),
1312 source_status(config_key, "missing")
1313 ),
1314 format!(
1315 "secret store: {} ({})",
1316 secrets.backend_name(),
1317 source_status(keyring_key.as_deref(), "missing")
1318 ),
1319 format!("env var: {env_var_label} ({env_status})"),
1320 ];
1321 if let Some(path) = oauth_file {
1322 let status = if path.exists() { "present" } else { "missing" };
1323 lines.push(format!("Codex OAuth file: {} ({status})", path.display()));
1324 }
1325 lines
1326}
1327
1328fn source_status(value: Option<&str>, missing_label: &str) -> String {
1329 value
1330 .map(|v| format!("set, last4: {}", last4_label(v)))
1331 .unwrap_or_else(|| missing_label.to_string())
1332}
1333
1334fn last4_label(value: &str) -> String {
1335 let trimmed = value.trim();
1336 let chars: Vec<char> = trimmed.chars().collect();
1337 if chars.len() <= 4 {
1338 return "<redacted>".to_string();
1339 }
1340 let last4: String = chars[chars.len() - 4..].iter().collect();
1341 format!("...{last4}")
1342}
1343
1344fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()> {
1345 run_auth_command_with_secrets(store, command, &Secrets::auto_detect())
1346}
1347
1348fn run_auth_command_with_secrets(
1349 store: &mut ConfigStore,
1350 command: AuthCommand,
1351 secrets: &Secrets,
1352) -> Result<()> {
1353 match command {
1354 AuthCommand::Status { provider } => {
1355 match provider {
1356 Some(p) => {
1357 let provider: ProviderKind = p.into();
1358 for line in auth_status_lines_for_provider(store, secrets, provider) {
1359 println!("{line}");
1360 }
1361 }
1362 None => {
1363 for line in auth_status_all_providers(store, secrets) {
1364 println!("{line}");
1365 }
1366 }
1367 }
1368 Ok(())
1369 }
1370 AuthCommand::Set {
1371 provider,
1372 api_key,
1373 api_key_stdin,
1374 } => {
1375 let provider: ProviderKind = provider.into();
1376 let slot = provider_slot(provider);
1377 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
1378 let provider_cfg = store.config.providers.for_provider_mut(provider);
1379 if provider_cfg.base_url.is_none() {
1380 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
1381 }
1382 store.save()?;
1383 println!(
1384 "configured {slot} provider in {} (API key optional)",
1385 store.path().display()
1386 );
1387 return Ok(());
1388 }
1389 let api_key = match (api_key, api_key_stdin) {
1390 (Some(v), _) => v,
1391 (None, true) => read_api_key_from_stdin()?,
1392 (None, false) => prompt_api_key(slot)?,
1393 };
1394 write_provider_api_key_to_config(store, provider, &api_key);
1395 let keyring_saved = write_provider_api_key_to_keyring(secrets, provider, &api_key);
1396 store.save()?;
1397 if keyring_saved {
1399 println!(
1400 "saved API key for {slot} to {} and {}",
1401 store.path().display(),
1402 secrets.backend_name()
1403 );
1404 } else {
1405 println!("saved API key for {slot} to {}", store.path().display());
1406 }
1407 Ok(())
1408 }
1409 AuthCommand::Get { provider } => {
1410 let provider: ProviderKind = provider.into();
1411 let slot = provider_slot(provider);
1412 let in_file = provider_config_set(store, provider);
1413 let in_keyring = !in_file && provider_keyring_set(secrets, provider);
1414 let in_env = provider_env_set(provider);
1415 let source = if in_file {
1417 Some("config-file")
1418 } else if in_keyring {
1419 Some("secret-store")
1420 } else if in_env {
1421 Some("env")
1422 } else {
1423 None
1424 };
1425 match source {
1426 Some(source) => println!("{slot}: set (source: {source})"),
1427 None => println!("{slot}: not set"),
1428 }
1429 Ok(())
1430 }
1431 AuthCommand::Clear { provider } => {
1432 let provider: ProviderKind = provider.into();
1433 let slot = provider_slot(provider);
1434 clear_provider_api_key_from_config(store, provider);
1435 clear_provider_api_key_from_keyring(secrets, provider);
1436 store.save()?;
1437 println!("cleared API key for {slot} from config and secret store");
1438 Ok(())
1439 }
1440 AuthCommand::List => {
1441 println!("provider config store env active");
1442 for provider in PROVIDER_LIST {
1443 let slot = provider_slot(provider);
1444 let file = provider_config_set(store, provider);
1445 let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
1446 let env = provider_env_set(provider);
1447 let active = if file {
1448 "config"
1449 } else if keyring == Some(true) {
1450 "store"
1451 } else if env {
1452 "env"
1453 } else {
1454 "missing"
1455 };
1456 println!(
1457 "{slot:<12} {} {} {} {active}",
1458 yes_no(file),
1459 keyring_status_short(keyring),
1460 yes_no(env)
1461 );
1462 }
1463 Ok(())
1464 }
1465 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
1466 }
1467}
1468
1469fn yes_no(b: bool) -> &'static str {
1470 if b { "yes" } else { "no " }
1471}
1472
1473fn keyring_status_short(state: Option<bool>) -> &'static str {
1474 match state {
1475 Some(true) => "yes",
1476 Some(false) => "no ",
1477 None => "n/a",
1478 }
1479}
1480
1481fn prompt_api_key(slot: &str) -> Result<String> {
1482 use std::io::{IsTerminal, Write};
1483 eprint!("Enter API key for {slot}: ");
1484 io::stderr().flush().ok();
1485 if !io::stdin().is_terminal() {
1486 return read_api_key_from_stdin();
1488 }
1489 let mut buf = String::new();
1490 io::stdin()
1491 .read_line(&mut buf)
1492 .context("failed to read API key from stdin")?;
1493 let key = buf.trim().to_string();
1494 if key.is_empty() {
1495 bail!("empty API key provided");
1496 }
1497 Ok(key)
1498}
1499
1500fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
1503 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
1504 let mut warnings: Vec<String> = Vec::new();
1505
1506 for provider in PROVIDER_LIST {
1507 let slot = provider_slot(provider);
1508 let from_provider_block = store
1509 .config
1510 .providers
1511 .for_provider(provider)
1512 .api_key
1513 .clone()
1514 .filter(|v| !v.trim().is_empty());
1515 let from_root = (provider == ProviderKind::Deepseek)
1516 .then(|| store.config.api_key.clone())
1517 .flatten()
1518 .filter(|v| !v.trim().is_empty());
1519 let value = from_provider_block.or(from_root);
1520 let Some(value) = value else { continue };
1521
1522 if let Ok(Some(existing)) = secrets.get(slot)
1523 && existing == value
1524 {
1525 } else if dry_run {
1527 migrated.push((provider, slot));
1528 continue;
1529 } else if let Err(err) = secrets.set(slot, &value) {
1530 warnings.push(format!(
1531 "skipped {slot}: failed to write to secret store: {err}"
1532 ));
1533 continue;
1534 }
1535 if !dry_run {
1536 store.config.providers.for_provider_mut(provider).api_key = None;
1537 if provider == ProviderKind::Deepseek {
1538 store.config.api_key = None;
1539 }
1540 }
1541 migrated.push((provider, slot));
1542 }
1543
1544 if !dry_run && !migrated.is_empty() {
1545 store
1546 .save()
1547 .context("failed to write updated config.toml")?;
1548 }
1549
1550 println!("secret store backend: {}", secrets.backend_name());
1551 if migrated.is_empty() {
1552 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
1553 } else {
1554 println!(
1555 "{} {} provider key(s):",
1556 if dry_run { "would migrate" } else { "migrated" },
1557 migrated.len()
1558 );
1559 for (_, slot) in &migrated {
1560 println!(" - {slot}");
1561 }
1562 if !dry_run {
1563 println!(
1564 "config.toml at {} no longer contains api_key entries for migrated providers.",
1565 store.path().display()
1566 );
1567 }
1568 }
1569 for w in warnings {
1570 eprintln!("warning: {w}");
1571 }
1572 Ok(())
1573}
1574
1575fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
1576 match command {
1577 ConfigCommand::Get { key } => {
1578 if let Some(value) = store.config.get_display_value(&key) {
1579 println!("{value}");
1580 return Ok(());
1581 }
1582 bail!("key not found: {key}");
1583 }
1584 ConfigCommand::Set { key, value } => {
1585 store.config.set_value(&key, &value)?;
1586 store.save()?;
1587 println!("set {key}");
1588 Ok(())
1589 }
1590 ConfigCommand::Unset { key } => {
1591 store.config.unset_value(&key)?;
1592 store.save()?;
1593 println!("unset {key}");
1594 Ok(())
1595 }
1596 ConfigCommand::List => {
1597 for (key, value) in store.config.list_values() {
1598 println!("{key} = {value}");
1599 }
1600 Ok(())
1601 }
1602 ConfigCommand::Path => {
1603 println!("{}", store.path().display());
1604 Ok(())
1605 }
1606 }
1607}
1608
1609fn model_command_provider_hint(
1610 command_provider: Option<ProviderArg>,
1611 top_level_provider: Option<ProviderKind>,
1612) -> Option<ProviderKind> {
1613 command_provider
1614 .map(ProviderKind::from)
1615 .or(top_level_provider)
1616}
1617
1618fn run_model_command(
1619 command: ModelCommand,
1620 top_level_provider: Option<ProviderKind>,
1621) -> Result<()> {
1622 let registry = ModelRegistry::default();
1623 match command {
1624 ModelCommand::List { provider } => {
1625 let filter = model_command_provider_hint(provider, top_level_provider);
1626 for model in registry.list().into_iter().filter(|m| match filter {
1627 Some(p) => m.provider == p,
1628 None => true,
1629 }) {
1630 println!("{} ({})", model.id, model.provider.as_str());
1631 }
1632 Ok(())
1633 }
1634 ModelCommand::Resolve { model, provider } => {
1635 let provider = model_command_provider_hint(provider, top_level_provider);
1636 let resolved = registry.resolve(model.as_deref(), provider);
1637 println!("requested: {}", resolved.requested.unwrap_or_default());
1638 println!("resolved: {}", resolved.resolved.id);
1639 println!("provider: {}", resolved.resolved.provider.as_str());
1640 println!("used_fallback: {}", resolved.used_fallback);
1641 Ok(())
1642 }
1643 }
1644}
1645
1646fn run_thread_command(command: ThreadCommand) -> Result<()> {
1647 let state = StateStore::open(None)?;
1648 match command {
1649 ThreadCommand::List { all, limit } => {
1650 let threads = state.list_threads(ThreadListFilters {
1651 include_archived: all,
1652 limit,
1653 })?;
1654 for thread in threads {
1655 println!(
1656 "{} | {} | {} | {}",
1657 thread.id,
1658 thread
1659 .name
1660 .clone()
1661 .unwrap_or_else(|| "(unnamed)".to_string()),
1662 thread.model_provider,
1663 thread.cwd.display()
1664 );
1665 }
1666 Ok(())
1667 }
1668 ThreadCommand::Read { thread_id } => {
1669 let thread = state.get_thread(&thread_id)?;
1670 println!("{}", serde_json::to_string_pretty(&thread)?);
1671 Ok(())
1672 }
1673 ThreadCommand::Resume { thread_id } => {
1674 let args = vec!["resume".to_string(), thread_id];
1675 delegate_simple_tui(args)
1676 }
1677 ThreadCommand::Fork { thread_id } => {
1678 let args = vec!["fork".to_string(), thread_id];
1679 delegate_simple_tui(args)
1680 }
1681 ThreadCommand::Archive { thread_id } => {
1682 state.mark_archived(&thread_id)?;
1683 println!("archived {thread_id}");
1684 Ok(())
1685 }
1686 ThreadCommand::Unarchive { thread_id } => {
1687 state.mark_unarchived(&thread_id)?;
1688 println!("unarchived {thread_id}");
1689 Ok(())
1690 }
1691 ThreadCommand::SetName { thread_id, name } => {
1692 let mut thread = state
1693 .get_thread(&thread_id)?
1694 .with_context(|| format!("thread not found: {thread_id}"))?;
1695 thread.name = Some(name);
1696 thread.updated_at = chrono::Utc::now().timestamp();
1697 state.upsert_thread(&thread)?;
1698 println!("renamed {thread_id}");
1699 Ok(())
1700 }
1701 ThreadCommand::ClearName { thread_id } => {
1702 let mut thread = state
1703 .get_thread(&thread_id)?
1704 .with_context(|| format!("thread not found: {thread_id}"))?;
1705 thread.name = None;
1706 thread.updated_at = chrono::Utc::now().timestamp();
1707 state.upsert_thread(&thread)?;
1708 println!("cleared name for {thread_id}");
1709 Ok(())
1710 }
1711 }
1712}
1713
1714fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
1715 match command {
1716 SandboxCommand::Check { command, ask } => {
1717 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
1718 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1719 let decision = engine.check(ExecPolicyContext {
1720 command: &command,
1721 cwd: &cwd.display().to_string(),
1722 tool: Some("exec_shell"),
1723 path: None,
1724 ask_for_approval: ask.into(),
1725 sandbox_mode: Some("workspace-write"),
1726 })?;
1727 println!("{}", serde_json::to_string_pretty(&decision)?);
1728 Ok(())
1729 }
1730 }
1731}
1732
1733fn run_app_server_command(
1734 cli: &Cli,
1735 resolved_runtime: &ResolvedRuntimeOptions,
1736 args: AppServerArgs,
1737) -> Result<()> {
1738 if args.http || args.mobile {
1743 return delegate_to_tui(cli, resolved_runtime, app_server_serve_passthrough(&args));
1744 }
1745
1746 let runtime = tokio::runtime::Builder::new_multi_thread()
1747 .enable_all()
1748 .build()
1749 .context("failed to create tokio runtime")?;
1750 if args.stdio {
1751 return runtime.block_on(run_app_server_stdio(args.config));
1752 }
1753 let host = args.host.as_deref().unwrap_or("127.0.0.1");
1757 let port = args.port.unwrap_or(8787);
1758 let listen: SocketAddr = format!("{host}:{port}")
1759 .parse()
1760 .with_context(|| format!("invalid app-server listen address {host}:{port}"))?;
1761 runtime.block_on(run_app_server(AppServerOptions {
1762 listen,
1763 config_path: args.config,
1764 auth_token: args.auth_token.or_else(app_server_token_from_env),
1765 insecure_no_auth: args.insecure_no_auth,
1766 cors_origins: args.cors_origin,
1767 }))
1768}
1769
1770fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
1778 let mut forwarded = vec!["serve".to_string()];
1779 forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
1780 if let Some(host) = args.host.as_ref() {
1781 forwarded.push("--host".to_string());
1782 forwarded.push(host.clone());
1783 }
1784 if let Some(port) = args.port {
1785 forwarded.push("--port".to_string());
1786 forwarded.push(port.to_string());
1787 }
1788 if let Some(workers) = args.workers {
1789 forwarded.push("--workers".to_string());
1790 forwarded.push(workers.to_string());
1791 }
1792 for origin in &args.cors_origin {
1793 forwarded.push("--cors-origin".to_string());
1794 forwarded.push(origin.clone());
1795 }
1796 if let Some(token) = args.auth_token.as_ref() {
1797 forwarded.push("--auth-token".to_string());
1798 forwarded.push(token.clone());
1799 }
1800 if args.insecure_no_auth {
1801 forwarded.push("--insecure".to_string());
1802 }
1803 if args.qr {
1804 forwarded.push("--qr".to_string());
1805 }
1806 forwarded
1807}
1808
1809fn app_server_token_from_env() -> Option<String> {
1810 std::env::var("CODEWHALE_APP_SERVER_TOKEN")
1811 .ok()
1812 .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
1813}
1814
1815fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
1816 let persisted = load_mcp_server_definitions(store);
1817 let updated = run_stdio_server(persisted)?;
1818 persist_mcp_server_definitions(store, &updated)
1819}
1820
1821fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
1822 let Some(raw) = store.config.get_value(MCP_SERVER_DEFINITIONS_KEY) else {
1823 return Vec::new();
1824 };
1825
1826 match parse_mcp_server_definitions(&raw) {
1827 Ok(definitions) => definitions,
1828 Err(err) => {
1829 eprintln!(
1830 "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
1831 );
1832 Vec::new()
1833 }
1834 }
1835}
1836
1837fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
1838 if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
1839 return Ok(parsed);
1840 }
1841
1842 let unwrapped: String = serde_json::from_str(raw)
1843 .with_context(|| format!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}"))?;
1844 serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).with_context(|| {
1845 format!("invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}")
1846 })
1847}
1848
1849fn persist_mcp_server_definitions(
1850 store: &mut ConfigStore,
1851 definitions: &[McpServerDefinition],
1852) -> Result<()> {
1853 let encoded =
1854 serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
1855 store
1856 .config
1857 .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
1858 store.save()
1859}
1860
1861fn delegate_to_tui(
1862 cli: &Cli,
1863 resolved_runtime: &ResolvedRuntimeOptions,
1864 passthrough: Vec<String>,
1865) -> Result<()> {
1866 let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
1867 let tui = PathBuf::from(cmd.get_program());
1868 let status = cmd
1869 .status()
1870 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1871 exit_with_tui_status(status)
1872}
1873
1874fn run_resume_command(
1875 cli: &Cli,
1876 resolved_runtime: &ResolvedRuntimeOptions,
1877 args: TuiPassthroughArgs,
1878) -> Result<()> {
1879 let passthrough = tui_args("resume", args);
1880 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
1881 return run_dispatcher_resume_picker(cli, resolved_runtime);
1882 }
1883 delegate_to_tui(cli, resolved_runtime, passthrough)
1884}
1885
1886fn run_dispatcher_resume_picker(
1887 cli: &Cli,
1888 resolved_runtime: &ResolvedRuntimeOptions,
1889) -> Result<()> {
1890 let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
1891 let tui = PathBuf::from(sessions_cmd.get_program());
1892 let status = sessions_cmd
1893 .status()
1894 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
1895 if !status.success() {
1896 return exit_with_tui_status(status);
1897 }
1898
1899 println!();
1900 println!("Windows note: enter a session id or prefix from the list above.");
1901 println!("You can also run `codewhale resume --last` to skip this prompt.");
1902 print!("Session id/prefix (Enter to cancel): ");
1903 io::stdout().flush()?;
1904
1905 let mut input = String::new();
1906 io::stdin()
1907 .read_line(&mut input)
1908 .context("failed to read session selection")?;
1909 let session_id = input.trim();
1910 if session_id.is_empty() {
1911 bail!("No session selected.");
1912 }
1913
1914 delegate_to_tui(
1915 cli,
1916 resolved_runtime,
1917 vec!["resume".to_string(), session_id.to_string()],
1918 )
1919}
1920
1921fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
1922 is_windows && passthrough == ["resume"]
1923}
1924
1925fn build_tui_command(
1926 cli: &Cli,
1927 resolved_runtime: &ResolvedRuntimeOptions,
1928 passthrough: Vec<String>,
1929) -> Result<Command> {
1930 let tui = locate_sibling_tui_binary()?;
1931 let mut verbosity = resolved_runtime.verbosity.clone();
1932 if verbosity.is_none()
1933 && passthrough
1934 .iter()
1935 .any(|arg| matches!(arg.as_str(), "exec" | "swebench" | "eval"))
1936 {
1937 verbosity = Some("concise".to_string());
1938 }
1939
1940 let mut cmd = Command::new(&tui);
1941 if let Some(config) = cli.config.as_ref() {
1942 cmd.arg("--config").arg(config);
1943 }
1944 if let Some(profile) = cli.profile.as_ref() {
1945 cmd.arg("--profile").arg(profile);
1946 }
1947 if let Some(workspace) = cli.workspace.as_ref() {
1948 cmd.arg("--workspace").arg(workspace);
1949 }
1950 let _ = cli.no_alt_screen;
1953 if cli.mouse_capture {
1954 cmd.arg("--mouse-capture");
1955 }
1956 if cli.no_mouse_capture {
1957 cmd.arg("--no-mouse-capture");
1958 }
1959 if cli.skip_onboarding {
1960 cmd.arg("--skip-onboarding");
1961 }
1962 cmd.args(passthrough);
1963
1964 let mut launch_provider_override = cli.provider.map(ProviderKind::from);
1965 let mut keyring_bridge_provider = resolved_runtime.provider;
1966 let mut keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
1967 let mut keyring_bridge_source = resolved_runtime.api_key_source;
1968
1969 if !provider_is_supported_by_tui(resolved_runtime.provider) {
1970 let supported = supported_tui_providers_csv();
1971 match resolved_runtime.provider_source {
1972 ProviderSource::Cli => {
1973 bail!(
1974 "The interactive TUI does not support provider '{}' from --provider.\n\
1975 \n\
1976 Supported TUI providers: {supported}.\n\
1977 \n\
1978 To fix: remove `--provider {}` or pass a supported provider. \
1979 For this provider, use `codewhale exec --provider {} \"your prompt\"`.",
1980 resolved_runtime.provider.as_str(),
1981 resolved_runtime.provider.as_str(),
1982 resolved_runtime.provider.as_str(),
1983 );
1984 }
1985 ProviderSource::Env(var) => {
1986 bail!(
1987 "The interactive TUI does not support provider '{}' from {var}.\n\
1988 \n\
1989 Supported TUI providers: {supported}.\n\
1990 \n\
1991 To fix: unset {var} or set it to a supported provider. \
1992 For this provider, use `codewhale exec --provider {} \"your prompt\"`.",
1993 resolved_runtime.provider.as_str(),
1994 resolved_runtime.provider.as_str(),
1995 );
1996 }
1997 ProviderSource::Config => {
1998 let config_hint = cli
1999 .config
2000 .as_ref()
2001 .map(|path| path.display().to_string())
2002 .unwrap_or_else(|| "~/.codewhale/config.toml".to_string());
2003 eprintln!(
2004 "Warning: provider '{}' from config is not supported by the interactive TUI; \
2005 launching with deepseek instead. Edit {config_hint} and set \
2006 provider = \"deepseek\", or pass --provider <supported-id>.",
2007 resolved_runtime.provider.as_str(),
2008 );
2009 launch_provider_override = Some(ProviderKind::Deepseek);
2010 keyring_bridge_provider = ProviderKind::Deepseek;
2011 keyring_bridge_api_key = None;
2012 keyring_bridge_source = None;
2013 }
2014 }
2015 }
2016
2017 if let Some(provider) = launch_provider_override {
2018 cmd.env("DEEPSEEK_PROVIDER", provider.as_str());
2019 }
2020 if matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
2021 && let Some(api_key) = keyring_bridge_api_key
2022 {
2023 cmd.env("DEEPSEEK_API_KEY", api_key);
2027 for var in provider_env_vars(keyring_bridge_provider) {
2028 if *var != "DEEPSEEK_API_KEY" {
2029 cmd.env(var, api_key);
2030 }
2031 }
2032 cmd.env(
2033 "DEEPSEEK_API_KEY_SOURCE",
2034 RuntimeApiKeySource::Keyring.as_env_value(),
2035 );
2036 }
2037
2038 if let Some(model) = cli.model.as_ref() {
2039 cmd.env("DEEPSEEK_MODEL", model);
2040 }
2041 if let Some(output_mode) = cli.output_mode.as_ref() {
2042 cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
2043 }
2044 if let Some(v) = verbosity.as_ref() {
2045 cmd.env("CODEWHALE_VERBOSITY", v);
2046 cmd.env("DEEPSEEK_VERBOSITY", v);
2047 }
2048 if let Some(log_level) = cli.log_level.as_ref() {
2049 cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
2050 }
2051 if let Some(telemetry) = cli.telemetry {
2052 cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
2053 }
2054 if let Some(policy) = cli.approval_policy.as_ref() {
2055 cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
2056 }
2057 if let Some(mode) = cli.sandbox_mode.as_ref() {
2058 cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
2059 }
2060 if cli.yolo {
2061 cmd.env("DEEPSEEK_YOLO", "true");
2062 }
2063 if let Some(api_key) = cli.api_key.as_ref() {
2064 cmd.env("DEEPSEEK_API_KEY", api_key);
2065 if resolved_runtime.provider == ProviderKind::Openai {
2066 cmd.env("OPENAI_API_KEY", api_key);
2067 }
2068 if resolved_runtime.provider == ProviderKind::Atlascloud {
2069 cmd.env("ATLASCLOUD_API_KEY", api_key);
2070 }
2071 if resolved_runtime.provider == ProviderKind::WanjieArk {
2072 cmd.env("WANJIE_ARK_API_KEY", api_key);
2073 }
2074 if resolved_runtime.provider == ProviderKind::Volcengine {
2075 cmd.env("VOLCENGINE_API_KEY", api_key);
2076 }
2077 if resolved_runtime.provider == ProviderKind::Siliconflow {
2078 cmd.env("SILICONFLOW_API_KEY", api_key);
2079 }
2080 cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
2081 }
2082 if let Some(base_url) = cli.base_url.as_ref() {
2083 cmd.env("DEEPSEEK_BASE_URL", base_url);
2084 }
2085
2086 Ok(cmd)
2087}
2088
2089fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
2090 match status.code() {
2091 Some(code) => std::process::exit(code),
2092 None => bail!("codewhale-tui terminated by signal"),
2093 }
2094}
2095
2096fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
2097 let tui = locate_sibling_tui_binary()?;
2098 let status = Command::new(&tui)
2099 .args(args)
2100 .status()
2101 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
2102 match status.code() {
2103 Some(code) => std::process::exit(code),
2104 None => bail!("codewhale-tui terminated by signal"),
2105 }
2106}
2107
2108fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
2109 format!(
2110 "failed to spawn companion TUI binary at {}: {err}\n\
2111\n\
2112The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
2113to execute it. Common fixes:\n\
2114 - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
2115 - On Windows, run `where codewhale` and `where codewhale-tui`; both should \
2116come from the same install directory.\n\
2117 - If you downloaded release assets manually, keep both `codewhale` and \
2118`codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
2119 - Set DEEPSEEK_TUI_BIN to the absolute path of a working `codewhale-tui` \
2120binary.",
2121 tui.display()
2122 )
2123}
2124
2125fn locate_sibling_tui_binary() -> Result<PathBuf> {
2135 if let Ok(override_path) = std::env::var("DEEPSEEK_TUI_BIN") {
2136 let candidate = PathBuf::from(override_path);
2137 if candidate.is_file() {
2138 return Ok(candidate);
2139 }
2140 bail!(
2141 "DEEPSEEK_TUI_BIN points at {}, which is not a regular file.",
2142 candidate.display()
2143 );
2144 }
2145
2146 let current = std::env::current_exe().context("failed to locate current executable path")?;
2147 if let Some(found) = sibling_tui_candidate(¤t) {
2148 return Ok(found);
2149 }
2150
2151 let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
2154 bail!(
2155 "Companion `codewhale-tui` binary not found at {}.\n\
2156\n\
2157The `codewhale` dispatcher delegates interactive sessions to a sibling \
2158`codewhale-tui` binary. To fix this, install one of:\n\
2159 • npm: npm install -g codewhale (downloads both binaries)\n\
2160 • cargo: cargo install codewhale-cli codewhale-tui --locked\n\
2161 • GitHub Releases: download BOTH `codewhale-<platform>` AND \
2162`codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
2163and place them in the same directory.\n\
2164\n\
2165Or set DEEPSEEK_TUI_BIN to the absolute path of an existing `codewhale-tui` binary.",
2166 expected.display()
2167 );
2168}
2169
2170fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
2174 let primary =
2177 dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
2178 if primary.is_file() {
2179 return Some(primary);
2180 }
2181 if cfg!(windows) {
2184 let suffixless = dispatcher.with_file_name("codewhale-tui");
2185 if suffixless.is_file() {
2186 return Some(suffixless);
2187 }
2188 }
2189 None
2190}
2191
2192fn run_metrics_command(args: MetricsArgs) -> Result<()> {
2193 let since = match args.since.as_deref() {
2194 Some(s) => {
2195 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
2196 }
2197 None => None,
2198 };
2199 metrics::run(metrics::MetricsArgs {
2200 json: args.json,
2201 since,
2202 })
2203}
2204
2205fn read_api_key_from_stdin() -> Result<String> {
2206 let mut input = String::new();
2207 io::stdin()
2208 .read_to_string(&mut input)
2209 .context("failed to read api key from stdin")?;
2210 let key = input.trim().to_string();
2211 if key.is_empty() {
2212 bail!("empty API key provided");
2213 }
2214 Ok(key)
2215}
2216
2217#[cfg(test)]
2218mod tests {
2219 use super::*;
2220 use clap::error::ErrorKind;
2221 use std::ffi::OsString;
2222 use std::sync::{Mutex, OnceLock};
2223
2224 fn parse_ok(argv: &[&str]) -> Cli {
2225 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
2226 }
2227
2228 fn help_for(argv: &[&str]) -> String {
2229 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
2230 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
2231 err.to_string()
2232 }
2233
2234 fn command_env(cmd: &Command, name: &str) -> Option<String> {
2235 let name = std::ffi::OsStr::new(name);
2236 cmd.get_envs().find_map(|(key, value)| {
2237 if key == name {
2238 value.map(|v| v.to_string_lossy().into_owned())
2239 } else {
2240 None
2241 }
2242 })
2243 }
2244
2245 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2246 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2247 LOCK.get_or_init(|| Mutex::new(()))
2248 .lock()
2249 .unwrap_or_else(|p| p.into_inner())
2250 }
2251
2252 struct ScopedEnvVar {
2253 name: &'static str,
2254 previous: Option<OsString>,
2255 }
2256
2257 impl ScopedEnvVar {
2258 fn set(name: &'static str, value: &str) -> Self {
2259 let previous = std::env::var_os(name);
2260 unsafe { std::env::set_var(name, value) };
2263 Self { name, previous }
2264 }
2265 }
2266
2267 impl Drop for ScopedEnvVar {
2268 fn drop(&mut self) {
2269 unsafe {
2271 if let Some(previous) = self.previous.take() {
2272 std::env::set_var(self.name, previous);
2273 } else {
2274 std::env::remove_var(self.name);
2275 }
2276 }
2277 }
2278 }
2279
2280 fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
2281 let dir = tempfile::TempDir::new().expect("tempdir");
2282 let custom = dir
2283 .path()
2284 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
2285 std::fs::write(&custom, b"").unwrap();
2286 let custom_str = custom.to_string_lossy().into_owned();
2287 let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
2288 (dir, bin)
2289 }
2290
2291 fn resolved_runtime_for_test(
2292 provider: ProviderKind,
2293 provider_source: ProviderSource,
2294 ) -> ResolvedRuntimeOptions {
2295 ResolvedRuntimeOptions {
2296 provider,
2297 provider_source,
2298 model: "test-model".to_string(),
2299 api_key: None,
2300 api_key_source: None,
2301 base_url: "http://localhost:8000/v1".to_string(),
2302 auth_mode: None,
2303 insecure_skip_tls_verify: false,
2304 output_mode: None,
2305 log_level: None,
2306 telemetry: false,
2307 approval_policy: None,
2308 sandbox_mode: None,
2309 yolo: None,
2310 verbosity: None,
2311 http_headers: std::collections::BTreeMap::new(),
2312 }
2313 }
2314
2315 #[test]
2316 fn clap_command_definition_is_consistent() {
2317 Cli::command().debug_assert();
2318 }
2319
2320 #[test]
2327 fn anyhow_chain_surfaces_toml_parse_cause() {
2328 use anyhow::Context;
2329 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
2330 let err = Err::<(), _>(inner)
2331 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
2332 .unwrap_err();
2333
2334 assert_eq!(
2336 err.to_string(),
2337 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
2338 );
2339
2340 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
2342 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
2343 }
2344
2345 #[test]
2346 fn parses_config_command_matrix() {
2347 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
2348 assert!(matches!(
2349 cli.command,
2350 Some(Commands::Config(ConfigArgs {
2351 command: ConfigCommand::Get { ref key }
2352 })) if key == "provider"
2353 ));
2354
2355 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
2356 assert!(matches!(
2357 cli.command,
2358 Some(Commands::Config(ConfigArgs {
2359 command: ConfigCommand::Set { ref key, ref value }
2360 })) if key == "model" && value == "deepseek-v4-flash"
2361 ));
2362
2363 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
2364 assert!(matches!(
2365 cli.command,
2366 Some(Commands::Config(ConfigArgs {
2367 command: ConfigCommand::Unset { ref key }
2368 })) if key == "model"
2369 ));
2370
2371 assert!(matches!(
2372 parse_ok(&["deepseek", "config", "list"]).command,
2373 Some(Commands::Config(ConfigArgs {
2374 command: ConfigCommand::List
2375 }))
2376 ));
2377 assert!(matches!(
2378 parse_ok(&["deepseek", "config", "path"]).command,
2379 Some(Commands::Config(ConfigArgs {
2380 command: ConfigCommand::Path
2381 }))
2382 ));
2383 }
2384
2385 #[test]
2386 fn parses_update_beta_flag() {
2387 let cli = parse_ok(&["codewhale", "update"]);
2388 assert!(matches!(
2389 cli.command,
2390 Some(Commands::Update(UpdateArgs {
2391 beta: false,
2392 check: false,
2393 proxy: None
2394 }))
2395 ));
2396
2397 let cli = parse_ok(&["codewhale", "update", "--beta"]);
2398 assert!(matches!(
2399 cli.command,
2400 Some(Commands::Update(UpdateArgs {
2401 beta: true,
2402 check: false,
2403 proxy: None
2404 }))
2405 ));
2406
2407 let cli = parse_ok(&["codewhale", "update", "--check"]);
2408 assert!(matches!(
2409 cli.command,
2410 Some(Commands::Update(UpdateArgs {
2411 beta: false,
2412 check: true,
2413 proxy: None
2414 }))
2415 ));
2416
2417 let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
2418 let Some(Commands::Update(args)) = cli.command else {
2419 panic!("expected update command");
2420 };
2421 assert!(!args.beta);
2422 assert!(!args.check);
2423 assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
2424 }
2425
2426 #[test]
2427 fn parses_model_command_matrix() {
2428 let cli = parse_ok(&["deepseek", "model", "list"]);
2429 assert!(matches!(
2430 cli.command,
2431 Some(Commands::Model(ModelArgs {
2432 command: ModelCommand::List { provider: None }
2433 }))
2434 ));
2435
2436 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
2437 assert!(matches!(
2438 cli.command,
2439 Some(Commands::Model(ModelArgs {
2440 command: ModelCommand::List {
2441 provider: Some(ProviderArg::Openai)
2442 }
2443 }))
2444 ));
2445
2446 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
2447 assert!(matches!(
2448 cli.command,
2449 Some(Commands::Model(ModelArgs {
2450 command: ModelCommand::Resolve {
2451 model: Some(ref model),
2452 provider: None
2453 }
2454 })) if model == "deepseek-v4-flash"
2455 ));
2456
2457 let cli = parse_ok(&[
2458 "deepseek",
2459 "model",
2460 "resolve",
2461 "--provider",
2462 "deepseek",
2463 "deepseek-v4-pro",
2464 ]);
2465 assert!(matches!(
2466 cli.command,
2467 Some(Commands::Model(ModelArgs {
2468 command: ModelCommand::Resolve {
2469 model: Some(ref model),
2470 provider: Some(ProviderArg::Deepseek)
2471 }
2472 })) if model == "deepseek-v4-pro"
2473 ));
2474 }
2475
2476 #[test]
2477 fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
2478 assert_eq!(
2479 model_command_provider_hint(None, Some(ProviderKind::Zai)),
2480 Some(ProviderKind::Zai)
2481 );
2482 assert_eq!(
2483 model_command_provider_hint(Some(ProviderArg::Minimax), Some(ProviderKind::Zai)),
2484 Some(ProviderKind::Minimax)
2485 );
2486 assert_eq!(model_command_provider_hint(None, None), None);
2487
2488 let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
2489 assert_eq!(cli.provider, Some(ProviderArg::Zai));
2490 assert!(matches!(
2491 cli.command,
2492 Some(Commands::Model(ModelArgs {
2493 command: ModelCommand::List { provider: None }
2494 }))
2495 ));
2496 }
2497
2498 #[test]
2499 fn parses_thread_command_matrix() {
2500 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
2501 assert!(matches!(
2502 cli.command,
2503 Some(Commands::Thread(ThreadArgs {
2504 command: ThreadCommand::List {
2505 all: true,
2506 limit: Some(50)
2507 }
2508 }))
2509 ));
2510
2511 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
2512 assert!(matches!(
2513 cli.command,
2514 Some(Commands::Thread(ThreadArgs {
2515 command: ThreadCommand::Read { ref thread_id }
2516 })) if thread_id == "thread-1"
2517 ));
2518
2519 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
2520 assert!(matches!(
2521 cli.command,
2522 Some(Commands::Thread(ThreadArgs {
2523 command: ThreadCommand::Resume { ref thread_id }
2524 })) if thread_id == "thread-2"
2525 ));
2526
2527 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
2528 assert!(matches!(
2529 cli.command,
2530 Some(Commands::Thread(ThreadArgs {
2531 command: ThreadCommand::Fork { ref thread_id }
2532 })) if thread_id == "thread-3"
2533 ));
2534
2535 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
2536 assert!(matches!(
2537 cli.command,
2538 Some(Commands::Thread(ThreadArgs {
2539 command: ThreadCommand::Archive { ref thread_id }
2540 })) if thread_id == "thread-4"
2541 ));
2542
2543 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
2544 assert!(matches!(
2545 cli.command,
2546 Some(Commands::Thread(ThreadArgs {
2547 command: ThreadCommand::Unarchive { ref thread_id }
2548 })) if thread_id == "thread-5"
2549 ));
2550
2551 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
2552 assert!(matches!(
2553 cli.command,
2554 Some(Commands::Thread(ThreadArgs {
2555 command: ThreadCommand::SetName {
2556 ref thread_id,
2557 ref name
2558 }
2559 })) if thread_id == "thread-6" && name == "My Thread"
2560 ));
2561
2562 let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
2563 assert!(matches!(
2564 cli.command,
2565 Some(Commands::Thread(ThreadArgs {
2566 command: ThreadCommand::ClearName { ref thread_id }
2567 })) if thread_id == "thread-7"
2568 ));
2569 }
2570
2571 #[test]
2572 fn parses_sandbox_app_server_and_completion_matrix() {
2573 let cli = parse_ok(&[
2574 "deepseek",
2575 "sandbox",
2576 "check",
2577 "echo hello",
2578 "--ask",
2579 "on-failure",
2580 ]);
2581 assert!(matches!(
2582 cli.command,
2583 Some(Commands::Sandbox(SandboxArgs {
2584 command: SandboxCommand::Check {
2585 ref command,
2586 ask: ApprovalModeArg::OnFailure
2587 }
2588 })) if command == "echo hello"
2589 ));
2590
2591 let cli = parse_ok(&[
2592 "deepseek",
2593 "app-server",
2594 "--host",
2595 "0.0.0.0",
2596 "--port",
2597 "9999",
2598 ]);
2599 assert!(matches!(
2600 cli.command,
2601 Some(Commands::AppServer(AppServerArgs {
2602 host: Some(ref host),
2603 port: Some(9999),
2604 stdio: false,
2605 http: false,
2606 mobile: false,
2607 ..
2608 })) if host == "0.0.0.0"
2609 ));
2610
2611 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
2612 assert!(matches!(
2613 cli.command,
2614 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
2615 ));
2616
2617 let cli = parse_ok(&["deepseek", "completion", "bash"]);
2618 assert!(matches!(
2619 cli.command,
2620 Some(Commands::Completion { shell: Shell::Bash })
2621 ));
2622 }
2623
2624 #[test]
2625 fn app_server_transports_are_mutually_exclusive() {
2626 assert!(matches!(
2627 parse_ok(&["deepseek", "app-server", "--http"]).command,
2628 Some(Commands::AppServer(AppServerArgs {
2629 http: true,
2630 mobile: false,
2631 stdio: false,
2632 ..
2633 }))
2634 ));
2635 assert!(matches!(
2636 parse_ok(&["deepseek", "app-server", "--mobile"]).command,
2637 Some(Commands::AppServer(AppServerArgs {
2638 mobile: true,
2639 http: false,
2640 stdio: false,
2641 ..
2642 }))
2643 ));
2644
2645 for argv in [
2646 ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
2647 ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
2648 ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
2649 ] {
2650 let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
2651 assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
2652 }
2653 }
2654
2655 #[test]
2656 fn app_server_qr_requires_mobile() {
2657 let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
2658 .expect_err("--qr without --mobile must fail");
2659 assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
2660 assert!(matches!(
2661 parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
2662 Some(Commands::AppServer(AppServerArgs {
2663 mobile: true,
2664 qr: true,
2665 ..
2666 }))
2667 ));
2668 }
2669
2670 #[test]
2671 fn app_server_serve_passthrough_maps_flags_to_serve() {
2672 let args = AppServerArgs {
2673 http: true,
2674 mobile: false,
2675 stdio: false,
2676 qr: false,
2677 host: Some("127.0.0.1".to_string()),
2678 port: Some(9000),
2679 workers: Some(4),
2680 config: None,
2681 auth_token: Some("tok".to_string()),
2682 insecure_no_auth: true,
2683 cors_origin: vec!["http://localhost:5173".to_string()],
2684 };
2685 let argv = app_server_serve_passthrough(&args);
2686 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
2687 assert_eq!(
2689 as_str,
2690 vec![
2691 "serve",
2692 "--http",
2693 "--host",
2694 "127.0.0.1",
2695 "--port",
2696 "9000",
2697 "--workers",
2698 "4",
2699 "--cors-origin",
2700 "http://localhost:5173",
2701 "--auth-token",
2702 "tok",
2703 "--insecure",
2704 ]
2705 );
2706 }
2707
2708 #[test]
2709 fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
2710 let args = AppServerArgs {
2711 http: false,
2712 mobile: true,
2713 stdio: false,
2714 qr: true,
2715 host: None,
2716 port: None,
2717 workers: None,
2718 config: None,
2719 auth_token: None,
2720 insecure_no_auth: false,
2721 cors_origin: vec![],
2722 };
2723 let argv = app_server_serve_passthrough(&args);
2724 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
2725 assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
2728 }
2729
2730 #[test]
2731 fn serve_help_documents_forwarded_runtime_modes() {
2732 let help = help_for(&["codewhale", "serve", "--help"]);
2733 for flag in ["--http", "--mobile", "--mcp", "--acp"] {
2734 assert!(
2735 help.contains(flag),
2736 "serve help should document forwarded flag {flag}; help was:\n{help}"
2737 );
2738 }
2739 assert!(help.contains("compatibility"));
2740 }
2741
2742 #[test]
2743 fn parses_direct_tui_command_aliases() {
2744 let cli = parse_ok(&["deepseek", "doctor"]);
2745 assert!(matches!(
2746 cli.command,
2747 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
2748 ));
2749
2750 let cli = parse_ok(&["deepseek", "models", "--json"]);
2751 assert!(matches!(
2752 cli.command,
2753 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
2754 ));
2755
2756 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
2757 assert!(matches!(
2758 cli.command,
2759 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
2760 ));
2761
2762 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
2763 assert!(matches!(
2764 cli.command,
2765 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
2766 if args == &["--skills", "--local"]
2767 ));
2768
2769 let cli = parse_ok(&["codewhale", "fleet", "init"]);
2770 assert!(cli.prompt.is_empty());
2771 assert!(matches!(
2772 cli.command,
2773 Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
2774 ));
2775
2776 let cli = parse_ok(&[
2777 "codewhale",
2778 "fleet",
2779 "run",
2780 "tasks.json",
2781 "--max-workers",
2782 "2",
2783 ]);
2784 assert!(cli.prompt.is_empty());
2785 assert!(matches!(
2786 cli.command,
2787 Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
2788 if args == &["run", "tasks.json", "--max-workers", "2"]
2789 ));
2790 }
2791
2792 #[test]
2793 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
2794 assert!(should_pick_resume_in_dispatcher(
2795 &["resume".to_string()],
2796 true
2797 ));
2798 assert!(!should_pick_resume_in_dispatcher(
2799 &["resume".to_string(), "--last".to_string()],
2800 true
2801 ));
2802 assert!(!should_pick_resume_in_dispatcher(
2803 &["resume".to_string(), "abc123".to_string()],
2804 true
2805 ));
2806 assert!(!should_pick_resume_in_dispatcher(
2807 &["resume".to_string()],
2808 false
2809 ));
2810 }
2811
2812 #[test]
2813 fn deepseek_login_writes_shared_config_and_preserves_tui_defaults() {
2814 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
2815 let path = std::env::temp_dir().join(format!(
2816 "deepseek-cli-login-test-{}-{nanos}.toml",
2817 std::process::id()
2818 ));
2819 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
2820 let secrets = no_keyring_secrets();
2821
2822 run_login_command_with_secrets(
2823 &mut store,
2824 LoginArgs {
2825 provider: Some(ProviderArg::Deepseek),
2826 api_key: Some("sk-test".to_string()),
2827 },
2828 &secrets,
2829 )
2830 .expect("login should write config");
2831
2832 assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
2833 assert_eq!(
2834 store.config.providers.deepseek.api_key.as_deref(),
2835 Some("sk-test")
2836 );
2837 assert_eq!(
2838 store.config.default_text_model.as_deref(),
2839 Some("deepseek-v4-pro")
2840 );
2841 let saved = std::fs::read_to_string(&path).expect("config should be written");
2842 assert!(saved.contains("api_key = \"sk-test\""));
2843 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
2844
2845 let _ = std::fs::remove_file(path);
2846 }
2847
2848 #[test]
2849 fn parses_auth_subcommand_matrix() {
2850 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
2851 assert!(matches!(
2852 cli.command,
2853 Some(Commands::Auth(AuthArgs {
2854 command: AuthCommand::Set {
2855 provider: ProviderArg::Deepseek,
2856 api_key: None,
2857 api_key_stdin: false,
2858 }
2859 }))
2860 ));
2861
2862 let cli = parse_ok(&[
2863 "deepseek",
2864 "auth",
2865 "set",
2866 "--provider",
2867 "openrouter",
2868 "--api-key-stdin",
2869 ]);
2870 assert!(matches!(
2871 cli.command,
2872 Some(Commands::Auth(AuthArgs {
2873 command: AuthCommand::Set {
2874 provider: ProviderArg::Openrouter,
2875 api_key: None,
2876 api_key_stdin: true,
2877 }
2878 }))
2879 ));
2880
2881 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
2882 assert!(matches!(
2883 cli.command,
2884 Some(Commands::Auth(AuthArgs {
2885 command: AuthCommand::Get {
2886 provider: ProviderArg::Novita
2887 }
2888 }))
2889 ));
2890
2891 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
2892 assert!(matches!(
2893 cli.command,
2894 Some(Commands::Auth(AuthArgs {
2895 command: AuthCommand::Clear {
2896 provider: ProviderArg::NvidiaNim
2897 }
2898 }))
2899 ));
2900
2901 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
2902 assert!(matches!(
2903 cli.command,
2904 Some(Commands::Auth(AuthArgs {
2905 command: AuthCommand::Set {
2906 provider: ProviderArg::Fireworks,
2907 api_key: None,
2908 api_key_stdin: false,
2909 }
2910 }))
2911 ));
2912
2913 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
2914 assert!(matches!(
2915 cli.command,
2916 Some(Commands::Auth(AuthArgs {
2917 command: AuthCommand::Set {
2918 provider: ProviderArg::Siliconflow,
2919 api_key: None,
2920 api_key_stdin: false,
2921 }
2922 }))
2923 ));
2924
2925 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
2926 assert!(matches!(
2927 cli.command,
2928 Some(Commands::Auth(AuthArgs {
2929 command: AuthCommand::Set {
2930 provider: ProviderArg::Arcee,
2931 api_key: None,
2932 api_key_stdin: false,
2933 }
2934 }))
2935 ));
2936
2937 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
2938 assert!(matches!(
2939 cli.command,
2940 Some(Commands::Auth(AuthArgs {
2941 command: AuthCommand::Set {
2942 provider: ProviderArg::Moonshot,
2943 api_key: None,
2944 api_key_stdin: false,
2945 }
2946 }))
2947 ));
2948
2949 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
2950 assert!(matches!(
2951 cli.command,
2952 Some(Commands::Auth(AuthArgs {
2953 command: AuthCommand::Set {
2954 provider: ProviderArg::WanjieArk,
2955 api_key: None,
2956 api_key_stdin: false,
2957 }
2958 }))
2959 ));
2960
2961 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
2962 assert!(matches!(
2963 cli.command,
2964 Some(Commands::Auth(AuthArgs {
2965 command: AuthCommand::Get {
2966 provider: ProviderArg::Sglang
2967 }
2968 }))
2969 ));
2970
2971 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
2972 assert!(matches!(
2973 cli.command,
2974 Some(Commands::Auth(AuthArgs {
2975 command: AuthCommand::Get {
2976 provider: ProviderArg::Vllm
2977 }
2978 }))
2979 ));
2980
2981 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
2982 assert!(matches!(
2983 cli.command,
2984 Some(Commands::Auth(AuthArgs {
2985 command: AuthCommand::Set {
2986 provider: ProviderArg::Ollama,
2987 api_key: None,
2988 api_key_stdin: false,
2989 }
2990 }))
2991 ));
2992
2993 let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
2994 assert!(matches!(
2995 cli.command,
2996 Some(Commands::Auth(AuthArgs {
2997 command: AuthCommand::Status {
2998 provider: Some(ProviderArg::OpenaiCodex)
2999 }
3000 }))
3001 ));
3002
3003 for (provider, expected) in [
3004 ("anthropic", ProviderArg::Anthropic),
3005 ("zai", ProviderArg::Zai),
3006 ("stepfun", ProviderArg::Stepfun),
3007 ("minimax", ProviderArg::Minimax),
3008 ("deepinfra", ProviderArg::Deepinfra),
3009 ("deep-infra", ProviderArg::Deepinfra),
3010 ] {
3011 let cli = parse_ok(&[
3012 "deepseek",
3013 "auth",
3014 "set",
3015 "--provider",
3016 provider,
3017 "--api-key-stdin",
3018 ]);
3019 assert!(matches!(
3020 cli.command,
3021 Some(Commands::Auth(AuthArgs {
3022 command: AuthCommand::Set {
3023 provider,
3024 api_key: None,
3025 api_key_stdin: true,
3026 }
3027 })) if provider == expected
3028 ));
3029 }
3030
3031 let cli = parse_ok(&["deepseek", "auth", "list"]);
3032 assert!(matches!(
3033 cli.command,
3034 Some(Commands::Auth(AuthArgs {
3035 command: AuthCommand::List
3036 }))
3037 ));
3038
3039 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
3040 assert!(matches!(
3041 cli.command,
3042 Some(Commands::Auth(AuthArgs {
3043 command: AuthCommand::Migrate { dry_run: false }
3044 }))
3045 ));
3046
3047 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
3048 assert!(matches!(
3049 cli.command,
3050 Some(Commands::Auth(AuthArgs {
3051 command: AuthCommand::Migrate { dry_run: true }
3052 }))
3053 ));
3054 }
3055
3056 #[test]
3057 fn auth_set_writes_to_shared_config_file() {
3058 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3059 use std::sync::Arc;
3060
3061 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3062 let path = std::env::temp_dir().join(format!(
3063 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
3064 std::process::id()
3065 ));
3066 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3067 let inner = Arc::new(InMemoryKeyringStore::new());
3068 let secrets = Secrets::new(inner.clone());
3069
3070 run_auth_command_with_secrets(
3071 &mut store,
3072 AuthCommand::Set {
3073 provider: ProviderArg::Deepseek,
3074 api_key: Some("sk-keyring".to_string()),
3075 api_key_stdin: false,
3076 },
3077 &secrets,
3078 )
3079 .expect("set should succeed");
3080
3081 assert_eq!(store.config.api_key.as_deref(), Some("sk-keyring"));
3082 assert_eq!(
3083 store.config.providers.deepseek.api_key.as_deref(),
3084 Some("sk-keyring")
3085 );
3086 let saved = std::fs::read_to_string(&path).unwrap_or_default();
3087 assert!(saved.contains("api_key = \"sk-keyring\""));
3088 assert_eq!(
3089 inner.get("deepseek").unwrap().as_deref(),
3090 Some("sk-keyring")
3091 );
3092
3093 let _ = std::fs::remove_file(path);
3094 }
3095
3096 #[test]
3097 fn auth_set_provider_key_does_not_switch_active_provider() {
3098 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3099 let path = std::env::temp_dir().join(format!(
3100 "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
3101 std::process::id()
3102 ));
3103 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3104 store.config.provider = ProviderKind::Deepseek;
3105 let secrets = no_keyring_secrets();
3106
3107 run_auth_command_with_secrets(
3108 &mut store,
3109 AuthCommand::Set {
3110 provider: ProviderArg::Arcee,
3111 api_key: Some("arcee-key".to_string()),
3112 api_key_stdin: false,
3113 },
3114 &secrets,
3115 )
3116 .expect("set should succeed");
3117
3118 assert_eq!(store.config.provider, ProviderKind::Deepseek);
3119 assert_eq!(
3120 store.config.providers.arcee.api_key.as_deref(),
3121 Some("arcee-key")
3122 );
3123
3124 let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
3125 assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
3126 assert_eq!(
3127 reloaded.config.providers.arcee.api_key.as_deref(),
3128 Some("arcee-key")
3129 );
3130
3131 let _ = std::fs::remove_file(path);
3132 }
3133
3134 #[test]
3135 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
3136 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3137 let path = std::env::temp_dir().join(format!(
3138 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
3139 std::process::id()
3140 ));
3141 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3142 store.config.provider = ProviderKind::Deepseek;
3143 let secrets = no_keyring_secrets();
3144
3145 run_auth_command_with_secrets(
3146 &mut store,
3147 AuthCommand::Set {
3148 provider: ProviderArg::Ollama,
3149 api_key: None,
3150 api_key_stdin: false,
3151 },
3152 &secrets,
3153 )
3154 .expect("ollama auth set should not require a key");
3155
3156 assert_eq!(store.config.provider, ProviderKind::Deepseek);
3157 assert_eq!(
3158 store.config.providers.ollama.base_url.as_deref(),
3159 Some("http://localhost:11434/v1")
3160 );
3161 assert_eq!(store.config.providers.ollama.api_key, None);
3162
3163 let _ = std::fs::remove_file(path);
3164 }
3165
3166 #[test]
3167 fn auth_clear_removes_from_config() {
3168 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3169 use std::sync::Arc;
3170
3171 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3172 let path = std::env::temp_dir().join(format!(
3173 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
3174 std::process::id()
3175 ));
3176 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3177 store.config.api_key = Some("sk-stale".to_string());
3178 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
3179 store.save().unwrap();
3180
3181 let inner = Arc::new(InMemoryKeyringStore::new());
3182 inner.set("deepseek", "sk-stale").unwrap();
3183 let secrets = Secrets::new(inner.clone());
3184
3185 run_auth_command_with_secrets(
3186 &mut store,
3187 AuthCommand::Clear {
3188 provider: ProviderArg::Deepseek,
3189 },
3190 &secrets,
3191 )
3192 .expect("clear should succeed");
3193
3194 assert!(store.config.api_key.is_none());
3195 assert!(store.config.providers.deepseek.api_key.is_none());
3196 assert_eq!(inner.get("deepseek").unwrap(), None);
3197
3198 let _ = std::fs::remove_file(path);
3199 }
3200
3201 #[test]
3202 fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
3203 use codewhale_secrets::{KeyringStore, SecretsError};
3204 use std::sync::{Arc, Mutex};
3205
3206 #[derive(Default)]
3207 struct RecordingStore {
3208 gets: Mutex<Vec<String>>,
3209 }
3210
3211 impl KeyringStore for RecordingStore {
3212 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
3213 self.gets.lock().unwrap().push(key.to_string());
3214 Ok(None)
3215 }
3216
3217 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
3218 Ok(())
3219 }
3220
3221 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
3222 Ok(())
3223 }
3224
3225 fn backend_name(&self) -> &'static str {
3226 "recording"
3227 }
3228 }
3229
3230 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3231 let path = std::env::temp_dir().join(format!(
3232 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
3233 std::process::id()
3234 ));
3235 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3236 store.config.provider = ProviderKind::Deepseek;
3237 let inner = Arc::new(RecordingStore::default());
3238 let secrets = Secrets::new(inner.clone());
3239
3240 run_auth_command_with_secrets(
3241 &mut store,
3242 AuthCommand::Status {
3243 provider: Some(ProviderArg::Deepseek),
3244 },
3245 &secrets,
3246 )
3247 .expect("status should succeed");
3248 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
3249 .expect("list should succeed");
3250
3251 let probed = inner.gets.lock().unwrap();
3252 assert_eq!(probed[0], "deepseek");
3254 assert!(probed.len() > 1, "list should probe all providers");
3257 assert!(
3258 PROVIDER_LIST
3259 .iter()
3260 .all(|p| probed.contains(&provider_slot(*p).to_string())),
3261 "every known provider should be probed by auth list: {:?}",
3262 *probed
3263 );
3264
3265 let _ = std::fs::remove_file(path);
3266 }
3267
3268 #[test]
3269 fn auth_status_reports_all_active_provider_sources_with_last4() {
3270 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3271 use std::sync::Arc;
3272
3273 let _lock = env_lock();
3274 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
3275
3276 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3277 let path = std::env::temp_dir().join(format!(
3278 "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
3279 std::process::id()
3280 ));
3281 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3282 store.config.provider = ProviderKind::Deepseek;
3283 store.config.api_key = Some("sk-config-3333".to_string());
3284 store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
3285
3286 let inner = Arc::new(InMemoryKeyringStore::new());
3287 inner.set("deepseek", "sk-keyring-2222").unwrap();
3288 let secrets = Secrets::new(inner);
3289
3290 let output =
3291 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
3292
3293 assert!(output.contains("provider: deepseek"));
3294 assert!(output.contains("active source: config (last4: ...3333)"));
3295 assert!(output.contains("lookup order: config -> secret store -> env"));
3296 assert!(output.contains("config file: "));
3297 assert!(output.contains("set, last4: ...3333"));
3298 assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
3299 assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
3300 assert!(!output.contains("sk-config-3333"));
3301 assert!(!output.contains("sk-keyring-2222"));
3302 assert!(!output.contains("sk-env-1111"));
3303
3304 let _ = std::fs::remove_file(path);
3305 }
3306
3307 #[test]
3308 fn auth_status_all_providers_lists_every_known_provider() {
3309 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3310 use std::sync::Arc;
3311
3312 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3313 let path = std::env::temp_dir().join(format!(
3314 "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
3315 std::process::id()
3316 ));
3317 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3318 store.config.provider = ProviderKind::Deepseek;
3319 store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
3320
3321 let inner = Arc::new(InMemoryKeyringStore::new());
3322 inner.set("openrouter", "sk-or-test5678").unwrap();
3323 let secrets = Secrets::new(inner);
3324
3325 let output = auth_status_all_providers(&store, &secrets).join("\n");
3326
3327 assert!(output.contains("deepseek"));
3329 assert!(output.contains("arcee"));
3330 assert!(output.contains("openrouter"));
3331 assert!(output.contains("huggingface"));
3332 assert!(output.contains("ollama"));
3333
3334 assert!(output.contains("deepseek") && output.contains("*"));
3336
3337 assert!(output.contains("config"));
3339
3340 assert!(!output.contains("sk-arcee-test1234"));
3342 assert!(!output.contains("sk-or-test5678"));
3343
3344 let _ = std::fs::remove_file(path);
3345 }
3346
3347 #[test]
3348 fn auth_status_openai_codex_reports_codex_oauth_file() {
3349 use codewhale_secrets::InMemoryKeyringStore;
3350 use std::sync::Arc;
3351
3352 let _lock = env_lock();
3353 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
3354 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
3355
3356 let dir = tempfile::TempDir::new().expect("tempdir");
3357 let config_path = dir.path().join("config.toml");
3358 let auth_path = dir.path().join("auth.json");
3359 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
3360 .expect("write auth file");
3361 let auth_path_str = auth_path.to_string_lossy().into_owned();
3362 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
3363
3364 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
3365 store.config.provider = ProviderKind::OpenaiCodex;
3366 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
3367
3368 let output =
3369 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
3370
3371 assert!(output.contains("provider: openai-codex"));
3372 assert!(output.contains("auth mode: codex_oauth"));
3373 assert!(output.contains("active source: Codex OAuth file"));
3374 assert!(output.contains("lookup order: env -> Codex OAuth file"));
3375 assert!(output.contains(&format!(
3376 "Codex OAuth file: {} (present)",
3377 auth_path.display()
3378 )));
3379 assert!(!output.contains("secret-token"));
3380 }
3381
3382 #[test]
3383 fn auth_status_scoped_provider_shows_detailed_info() {
3384 use codewhale_secrets::InMemoryKeyringStore;
3385 use std::sync::Arc;
3386
3387 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3388 let path = std::env::temp_dir().join(format!(
3389 "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
3390 std::process::id()
3391 ));
3392 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3393 store.config.provider = ProviderKind::Deepseek;
3394 store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
3395
3396 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
3397
3398 let output =
3399 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
3400
3401 assert!(output.contains("provider: arcee"));
3402 assert!(output.contains("active source: config (last4: ...9999)"));
3403 assert!(output.contains("route:"));
3404 assert!(output.contains("model:"));
3405 assert!(!output.contains("sk-arcee-9999"));
3406
3407 let _ = std::fs::remove_file(path);
3408 }
3409
3410 #[test]
3411 fn dispatch_keyring_recovery_self_heals_into_config_file() {
3412 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3413 use std::sync::Arc;
3414
3415 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3416 let path = std::env::temp_dir().join(format!(
3417 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
3418 std::process::id()
3419 ));
3420 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3421 let inner = Arc::new(InMemoryKeyringStore::new());
3422 inner.set("deepseek", "ring-key").unwrap();
3423 let secrets = Secrets::new(inner);
3424
3425 let resolved = resolve_runtime_for_dispatch_with_secrets(
3426 &mut store,
3427 &CliRuntimeOverrides::default(),
3428 &secrets,
3429 );
3430
3431 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
3432 assert_eq!(
3433 resolved.api_key_source,
3434 Some(RuntimeApiKeySource::ConfigFile)
3435 );
3436 assert_eq!(store.config.api_key.as_deref(), Some("ring-key"));
3437 assert_eq!(
3438 store.config.providers.deepseek.api_key.as_deref(),
3439 Some("ring-key")
3440 );
3441
3442 let saved = std::fs::read_to_string(&path).expect("config should be written");
3443 assert!(saved.contains("api_key = \"ring-key\""));
3444
3445 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
3446 &mut store,
3447 &CliRuntimeOverrides::default(),
3448 &no_keyring_secrets(),
3449 );
3450 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
3451 assert_eq!(
3452 resolved_again.api_key_source,
3453 Some(RuntimeApiKeySource::ConfigFile)
3454 );
3455
3456 let _ = std::fs::remove_file(path);
3457 }
3458
3459 #[test]
3460 fn logout_removes_plaintext_provider_keys() {
3461 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3462 let path = std::env::temp_dir().join(format!(
3463 "deepseek-cli-logout-test-{}-{nanos}.toml",
3464 std::process::id()
3465 ));
3466 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3467 store.config.api_key = Some("sk-stale".to_string());
3468 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
3469 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
3470 store.save().unwrap();
3471
3472 let secrets = no_keyring_secrets();
3473
3474 run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
3475
3476 assert!(store.config.api_key.is_none());
3477 assert!(store.config.providers.deepseek.api_key.is_none());
3478 assert!(store.config.providers.fireworks.api_key.is_none());
3479
3480 let _ = std::fs::remove_file(path);
3481 }
3482
3483 #[test]
3484 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
3485 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3486 use std::sync::Arc;
3487
3488 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3489 let path = std::env::temp_dir().join(format!(
3490 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
3491 std::process::id()
3492 ));
3493 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3494 store.config.api_key = Some("sk-deep".to_string());
3495 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
3496 store.config.providers.openrouter.api_key = Some("or-key".to_string());
3497 store.config.providers.novita.api_key = Some("nv-key".to_string());
3498 store.save().unwrap();
3499
3500 let inner = Arc::new(InMemoryKeyringStore::new());
3501 let secrets = Secrets::new(inner.clone());
3502
3503 run_auth_command_with_secrets(
3504 &mut store,
3505 AuthCommand::Migrate { dry_run: false },
3506 &secrets,
3507 )
3508 .expect("migrate should succeed");
3509
3510 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
3511 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
3512 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
3513
3514 assert!(store.config.api_key.is_none());
3516 assert!(store.config.providers.deepseek.api_key.is_none());
3517 assert!(store.config.providers.openrouter.api_key.is_none());
3518 assert!(store.config.providers.novita.api_key.is_none());
3519
3520 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
3521 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
3522 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
3523 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
3524
3525 let _ = std::fs::remove_file(path);
3526 }
3527
3528 #[test]
3529 fn auth_migrate_dry_run_does_not_modify_anything() {
3530 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
3531 use std::sync::Arc;
3532
3533 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
3534 let path = std::env::temp_dir().join(format!(
3535 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
3536 std::process::id()
3537 ));
3538 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
3539 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
3540 store.save().unwrap();
3541
3542 let inner = Arc::new(InMemoryKeyringStore::new());
3543 let secrets = Secrets::new(inner.clone());
3544
3545 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
3546 .expect("dry-run should succeed");
3547
3548 assert_eq!(inner.get("openrouter").unwrap(), None);
3549 assert_eq!(
3550 store.config.providers.openrouter.api_key.as_deref(),
3551 Some("or-stay")
3552 );
3553
3554 let _ = std::fs::remove_file(path);
3555 }
3556
3557 #[test]
3558 fn parses_global_override_flags() {
3559 let cli = parse_ok(&[
3560 "deepseek",
3561 "--provider",
3562 "openai",
3563 "--config",
3564 "/tmp/deepseek.toml",
3565 "--profile",
3566 "work",
3567 "--model",
3568 "deepseek-v4-pro",
3569 "--output-mode",
3570 "json",
3571 "--verbosity",
3572 "concise",
3573 "--log-level",
3574 "debug",
3575 "--telemetry",
3576 "true",
3577 "--approval-policy",
3578 "on-request",
3579 "--sandbox-mode",
3580 "workspace-write",
3581 "--base-url",
3582 "https://openai-compatible.example/v1",
3583 "--api-key",
3584 "sk-test",
3585 "--workspace",
3586 "/tmp/workspace",
3587 "--no-alt-screen",
3588 "--no-mouse-capture",
3589 "--skip-onboarding",
3590 "model",
3591 "resolve",
3592 "deepseek-v4-pro",
3593 ]);
3594
3595 assert!(matches!(cli.provider, Some(ProviderArg::Openai)));
3596 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
3597 assert_eq!(cli.profile.as_deref(), Some("work"));
3598 assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
3599 assert_eq!(cli.output_mode.as_deref(), Some("json"));
3600 assert_eq!(cli.verbosity.as_deref(), Some("concise"));
3601 assert_eq!(cli.log_level.as_deref(), Some("debug"));
3602 assert_eq!(cli.telemetry, Some(true));
3603 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
3604 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
3605 assert_eq!(
3606 cli.base_url.as_deref(),
3607 Some("https://openai-compatible.example/v1")
3608 );
3609 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
3610 assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
3611 assert!(cli.no_alt_screen);
3612 assert!(cli.no_mouse_capture);
3613 assert!(!cli.mouse_capture);
3614 assert!(cli.skip_onboarding);
3615 }
3616
3617 #[test]
3618 fn build_tui_command_allows_openai_and_forwards_provider_key() {
3619 let _lock = env_lock();
3620 let dir = tempfile::TempDir::new().expect("tempdir");
3621 let custom = dir
3622 .path()
3623 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3624 std::fs::write(&custom, b"").unwrap();
3625 let custom_str = custom.to_string_lossy().into_owned();
3626 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3627
3628 let cli = parse_ok(&[
3629 "deepseek",
3630 "--provider",
3631 "openai",
3632 "--workspace",
3633 "/tmp/codewhale-workspace",
3634 ]);
3635 let resolved = ResolvedRuntimeOptions {
3636 provider: ProviderKind::Openai,
3637 provider_source: ProviderSource::Cli,
3638 model: "glm-5".to_string(),
3639 api_key: Some("resolved-openai-key".to_string()),
3640 api_key_source: Some(RuntimeApiKeySource::Keyring),
3641 base_url: "https://openai-compatible.example/v4".to_string(),
3642 auth_mode: Some("api_key".to_string()),
3643 insecure_skip_tls_verify: false,
3644 output_mode: None,
3645 log_level: None,
3646 telemetry: false,
3647 approval_policy: None,
3648 sandbox_mode: None,
3649 yolo: None,
3650 verbosity: None,
3651 http_headers: std::collections::BTreeMap::new(),
3652 };
3653
3654 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3655 assert_eq!(
3656 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3657 Some("openai")
3658 );
3659 assert_eq!(
3660 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3661 Some("resolved-openai-key")
3662 );
3663 assert_eq!(
3664 command_env(&cmd, "OPENAI_API_KEY").as_deref(),
3665 Some("resolved-openai-key")
3666 );
3667 assert_eq!(
3668 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
3669 Some("keyring")
3670 );
3671 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
3672 let args: Vec<String> = cmd
3673 .get_args()
3674 .map(|arg| arg.to_string_lossy().into_owned())
3675 .collect();
3676 assert!(
3677 args.windows(2)
3678 .any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
3679 "expected workspace forwarding in args: {args:?}"
3680 );
3681 }
3682
3683 #[test]
3684 fn build_tui_command_allows_openai_codex_from_resolved_runtime() {
3685 let _lock = env_lock();
3686 let dir = tempfile::TempDir::new().expect("tempdir");
3687 let custom = dir
3688 .path()
3689 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3690 std::fs::write(&custom, b"").unwrap();
3691 let custom_str = custom.to_string_lossy().into_owned();
3692 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3693
3694 let cli = parse_ok(&["codewhale", "doctor"]);
3695 let resolved = ResolvedRuntimeOptions {
3696 provider: ProviderKind::OpenaiCodex,
3697 provider_source: ProviderSource::Config,
3698 model: "gpt-5.5".to_string(),
3699 api_key: None,
3700 api_key_source: None,
3701 base_url: "https://chatgpt.com/backend-api".to_string(),
3702 auth_mode: Some("oauth".to_string()),
3703 insecure_skip_tls_verify: false,
3704 output_mode: None,
3705 log_level: None,
3706 telemetry: false,
3707 approval_policy: None,
3708 sandbox_mode: None,
3709 yolo: None,
3710 verbosity: None,
3711 http_headers: std::collections::BTreeMap::new(),
3712 };
3713
3714 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3715 .expect("openai-codex should be accepted by the facade");
3716 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
3717 let args: Vec<String> = cmd
3718 .get_args()
3719 .map(|arg| arg.to_string_lossy().into_owned())
3720 .collect();
3721 assert_eq!(args, vec!["doctor"]);
3722 }
3723
3724 #[test]
3725 fn build_tui_command_forwards_explicit_openai_codex_provider() {
3726 let _lock = env_lock();
3727 let dir = tempfile::TempDir::new().expect("tempdir");
3728 let custom = dir
3729 .path()
3730 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3731 std::fs::write(&custom, b"").unwrap();
3732 let custom_str = custom.to_string_lossy().into_owned();
3733 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3734
3735 let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
3736 let resolved = ResolvedRuntimeOptions {
3737 provider: ProviderKind::OpenaiCodex,
3738 provider_source: ProviderSource::Cli,
3739 model: "gpt-5.5".to_string(),
3740 api_key: None,
3741 api_key_source: None,
3742 base_url: "https://chatgpt.com/backend-api".to_string(),
3743 auth_mode: Some("oauth".to_string()),
3744 insecure_skip_tls_verify: false,
3745 output_mode: None,
3746 log_level: None,
3747 telemetry: false,
3748 approval_policy: None,
3749 sandbox_mode: None,
3750 yolo: None,
3751 verbosity: None,
3752 http_headers: std::collections::BTreeMap::new(),
3753 };
3754
3755 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3756 .expect("openai-codex should be accepted by the facade");
3757 assert_eq!(
3758 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3759 Some("openai-codex")
3760 );
3761 }
3762
3763 #[test]
3764 fn build_tui_command_rejects_unsupported_cli_provider_with_flag_hint() {
3765 let _lock = env_lock();
3766 let (_dir, _bin) = install_fake_tui_binary();
3767
3768 let cli = parse_ok(&["codewhale", "doctor"]);
3769 let resolved = resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Cli);
3770
3771 let err = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3772 .expect_err("unsupported provider should fail");
3773 let msg = err.to_string();
3774
3775 assert!(msg.contains("from --provider"), "{msg}");
3776 assert!(msg.contains("remove `--provider anthropic`"), "{msg}");
3777 assert!(msg.contains("Supported TUI providers:"), "{msg}");
3778 }
3779
3780 #[test]
3781 fn build_tui_command_rejects_unsupported_env_provider_with_env_hint() {
3782 let _lock = env_lock();
3783 let (_dir, _bin) = install_fake_tui_binary();
3784
3785 let cli = parse_ok(&["codewhale", "doctor"]);
3786 let resolved = resolved_runtime_for_test(
3787 ProviderKind::Anthropic,
3788 ProviderSource::Env("DEEPSEEK_PROVIDER"),
3789 );
3790
3791 let err = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3792 .expect_err("unsupported provider should fail");
3793 let msg = err.to_string();
3794
3795 assert!(msg.contains("from DEEPSEEK_PROVIDER"), "{msg}");
3796 assert!(msg.contains("unset DEEPSEEK_PROVIDER"), "{msg}");
3797 assert!(msg.contains("Supported TUI providers:"), "{msg}");
3798 }
3799
3800 #[test]
3801 fn build_tui_command_config_fallback_does_not_forward_stale_keyring_secret() {
3802 let _lock = env_lock();
3803 let (_dir, _bin) = install_fake_tui_binary();
3804
3805 let cli = parse_ok(&["codewhale", "doctor"]);
3806 let mut resolved =
3807 resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Config);
3808 resolved.api_key = Some("anthropic-keyring-secret".to_string());
3809 resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
3810
3811 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
3812 .expect("config-sourced unsupported provider should fall back");
3813
3814 assert_eq!(
3815 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3816 Some("deepseek")
3817 );
3818 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
3819 assert_eq!(command_env(&cmd, "ANTHROPIC_API_KEY"), None);
3820 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
3821 }
3822
3823 #[test]
3824 fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
3825 let _lock = env_lock();
3826 let dir = tempfile::TempDir::new().expect("tempdir");
3827 let custom = dir
3828 .path()
3829 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3830 std::fs::write(&custom, b"").unwrap();
3831 let custom_str = custom.to_string_lossy().into_owned();
3832 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3833
3834 let cli = parse_ok(&["deepseek", "--profile", "google"]);
3835 let mut resolved_headers = std::collections::BTreeMap::new();
3836 resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
3837 let resolved = ResolvedRuntimeOptions {
3838 provider: ProviderKind::Deepseek,
3839 provider_source: ProviderSource::Config,
3840 model: "deepseek-v4-pro".to_string(),
3841 api_key: Some("config-file-key".to_string()),
3842 api_key_source: Some(RuntimeApiKeySource::ConfigFile),
3843 base_url: "https://api.deepseek.com/beta".to_string(),
3844 auth_mode: Some("api_key".to_string()),
3845 insecure_skip_tls_verify: false,
3846 output_mode: None,
3847 log_level: None,
3848 telemetry: false,
3849 approval_policy: None,
3850 sandbox_mode: None,
3851 yolo: None,
3852 verbosity: None,
3853 http_headers: resolved_headers,
3854 };
3855
3856 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3857
3858 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
3859 assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
3860 assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
3861 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
3862 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
3863 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
3864 assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
3865 let args: Vec<String> = cmd
3866 .get_args()
3867 .map(|arg| arg.to_string_lossy().into_owned())
3868 .collect();
3869 assert!(
3870 args.windows(2).any(|pair| pair == ["--profile", "google"]),
3871 "expected profile forwarding in args: {args:?}"
3872 );
3873 }
3874
3875 #[test]
3876 fn build_tui_command_defaults_noninteractive_to_concise_verbosity() {
3877 let _lock = env_lock();
3878 let (_dir, _bin) = install_fake_tui_binary();
3879
3880 let cli = parse_ok(&["codewhale"]);
3881 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
3882
3883 let cmd = build_tui_command(
3884 &cli,
3885 &resolved,
3886 vec!["exec".to_string(), "summarize".to_string()],
3887 )
3888 .expect("command");
3889
3890 assert_eq!(
3891 command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
3892 Some("concise")
3893 );
3894 assert_eq!(
3895 command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
3896 Some("concise")
3897 );
3898 }
3899
3900 #[test]
3901 fn build_tui_command_respects_resolved_verbosity_override() {
3902 let _lock = env_lock();
3903 let (_dir, _bin) = install_fake_tui_binary();
3904
3905 let cli = parse_ok(&["codewhale"]);
3906 let mut resolved =
3907 resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
3908 resolved.verbosity = Some("normal".to_string());
3909
3910 let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string()]).expect("command");
3911
3912 assert_eq!(
3913 command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
3914 Some("normal")
3915 );
3916 assert_eq!(
3917 command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
3918 Some("normal")
3919 );
3920 }
3921
3922 #[test]
3923 fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
3924 let _lock = env_lock();
3925 let dir = tempfile::TempDir::new().expect("tempdir");
3926 let custom = dir
3927 .path()
3928 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3929 std::fs::write(&custom, b"").unwrap();
3930 let custom_str = custom.to_string_lossy().into_owned();
3931 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3932
3933 let cli = parse_ok(&[
3934 "codewhale",
3935 "--provider",
3936 "moonshot",
3937 "--model",
3938 "kimi-k2.7-code",
3939 "--workspace",
3940 "/tmp/codewhale-workspace",
3941 ]);
3942 let resolved = ResolvedRuntimeOptions {
3943 provider: ProviderKind::Moonshot,
3944 provider_source: ProviderSource::Cli,
3945 model: "kimi-k2.7-code".to_string(),
3946 api_key: Some("resolved-kimi-key".to_string()),
3947 api_key_source: Some(RuntimeApiKeySource::Keyring),
3948 base_url: "https://api.moonshot.ai/v1".to_string(),
3949 auth_mode: Some("api_key".to_string()),
3950 insecure_skip_tls_verify: false,
3951 output_mode: None,
3952 log_level: None,
3953 telemetry: false,
3954 approval_policy: None,
3955 sandbox_mode: None,
3956 yolo: None,
3957 verbosity: None,
3958 http_headers: std::collections::BTreeMap::new(),
3959 };
3960
3961 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
3962 assert_eq!(
3963 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
3964 Some("moonshot")
3965 );
3966 assert_eq!(
3967 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
3968 Some("kimi-k2.7-code")
3969 );
3970 assert_eq!(
3971 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
3972 Some("resolved-kimi-key")
3973 );
3974 assert_eq!(
3975 command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
3976 Some("resolved-kimi-key")
3977 );
3978 assert_eq!(
3979 command_env(&cmd, "KIMI_API_KEY").as_deref(),
3980 Some("resolved-kimi-key")
3981 );
3982 assert_eq!(
3983 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
3984 Some("keyring")
3985 );
3986 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
3987 }
3988
3989 #[test]
3990 fn build_tui_command_allows_volcengine_and_forwards_ark_keys() {
3991 let _lock = env_lock();
3992 let dir = tempfile::TempDir::new().expect("tempdir");
3993 let custom = dir
3994 .path()
3995 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
3996 std::fs::write(&custom, b"").unwrap();
3997 let custom_str = custom.to_string_lossy().into_owned();
3998 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
3999
4000 let cli = parse_ok(&[
4001 "codewhale",
4002 "--provider",
4003 "volcengine",
4004 "--model",
4005 "DeepSeek-V4-Pro",
4006 "--workspace",
4007 "/tmp/codewhale-workspace",
4008 ]);
4009 let resolved = ResolvedRuntimeOptions {
4010 provider: ProviderKind::Volcengine,
4011 provider_source: ProviderSource::Cli,
4012 model: "DeepSeek-V4-Pro".to_string(),
4013 api_key: Some("resolved-ark-key".to_string()),
4014 api_key_source: Some(RuntimeApiKeySource::Keyring),
4015 base_url: "https://ark.cn-beijing.volces.com/api/coding/v3".to_string(),
4016 auth_mode: Some("api_key".to_string()),
4017 insecure_skip_tls_verify: false,
4018 output_mode: None,
4019 log_level: None,
4020 telemetry: false,
4021 approval_policy: None,
4022 sandbox_mode: None,
4023 yolo: None,
4024 verbosity: None,
4025 http_headers: std::collections::BTreeMap::new(),
4026 };
4027
4028 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
4029 assert_eq!(
4030 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
4031 Some("volcengine")
4032 );
4033 assert_eq!(
4034 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
4035 Some("DeepSeek-V4-Pro")
4036 );
4037 assert_eq!(
4038 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
4039 Some("resolved-ark-key")
4040 );
4041 assert_eq!(
4042 command_env(&cmd, "VOLCENGINE_API_KEY").as_deref(),
4043 Some("resolved-ark-key")
4044 );
4045 assert_eq!(
4046 command_env(&cmd, "VOLCENGINE_ARK_API_KEY").as_deref(),
4047 Some("resolved-ark-key")
4048 );
4049 assert_eq!(
4050 command_env(&cmd, "ARK_API_KEY").as_deref(),
4051 Some("resolved-ark-key")
4052 );
4053 }
4054
4055 #[test]
4056 fn build_tui_command_exports_explicit_provider_model_and_base_url() {
4057 let _lock = env_lock();
4058 let dir = tempfile::TempDir::new().expect("tempdir");
4059 let custom = dir
4060 .path()
4061 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
4062 std::fs::write(&custom, b"").unwrap();
4063 let custom_str = custom.to_string_lossy().into_owned();
4064 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
4065
4066 let cli = parse_ok(&[
4067 "deepseek",
4068 "--profile",
4069 "google",
4070 "--provider",
4071 "openai",
4072 "--model",
4073 "glm-5",
4074 "--base-url",
4075 "https://openai-compatible.example/v4",
4076 ]);
4077 let resolved = ResolvedRuntimeOptions {
4078 provider: ProviderKind::Openai,
4079 provider_source: ProviderSource::Cli,
4080 model: "glm-5".to_string(),
4081 api_key: None,
4082 api_key_source: None,
4083 base_url: "https://openai-compatible.example/v4".to_string(),
4084 auth_mode: None,
4085 insecure_skip_tls_verify: false,
4086 output_mode: None,
4087 log_level: None,
4088 telemetry: false,
4089 approval_policy: None,
4090 sandbox_mode: None,
4091 yolo: None,
4092 verbosity: None,
4093 http_headers: std::collections::BTreeMap::new(),
4094 };
4095
4096 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
4097
4098 assert_eq!(
4099 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
4100 Some("openai")
4101 );
4102 assert_eq!(
4103 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
4104 Some("glm-5")
4105 );
4106 assert_eq!(
4107 command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
4108 Some("https://openai-compatible.example/v4")
4109 );
4110 }
4111
4112 #[test]
4113 fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
4114 let _lock = env_lock();
4115 let dir = tempfile::TempDir::new().expect("tempdir");
4116 let custom = dir
4117 .path()
4118 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
4119 std::fs::write(&custom, b"").unwrap();
4120 let custom_str = custom.to_string_lossy().into_owned();
4121 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
4122
4123 let cases: &[(ProviderKind, &str, &[&str])] = &[
4125 (
4126 ProviderKind::Openrouter,
4127 "openrouter",
4128 &["OPENROUTER_API_KEY"],
4129 ),
4130 (
4131 ProviderKind::XiaomiMimo,
4132 "xiaomi-mimo",
4133 &["XIAOMI_MIMO_API_KEY", "XIAOMI_API_KEY", "MIMO_API_KEY"],
4134 ),
4135 (ProviderKind::Novita, "novita", &["NOVITA_API_KEY"]),
4136 (
4137 ProviderKind::NvidiaNim,
4138 "nvidia-nim",
4139 &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"],
4140 ),
4141 (ProviderKind::Fireworks, "fireworks", &["FIREWORKS_API_KEY"]),
4142 (
4143 ProviderKind::Siliconflow,
4144 "siliconflow",
4145 &["SILICONFLOW_API_KEY"],
4146 ),
4147 (ProviderKind::Arcee, "arcee", &["ARCEE_API_KEY"]),
4148 (ProviderKind::Sglang, "sglang", &["SGLANG_API_KEY"]),
4149 (ProviderKind::Vllm, "vllm", &["VLLM_API_KEY"]),
4150 (ProviderKind::Ollama, "ollama", &["OLLAMA_API_KEY"]),
4151 (
4152 ProviderKind::Atlascloud,
4153 "atlascloud",
4154 &["ATLASCLOUD_API_KEY"],
4155 ),
4156 (
4157 ProviderKind::WanjieArk,
4158 "wanjie-ark",
4159 &[
4160 "WANJIE_ARK_API_KEY",
4161 "WANJIE_API_KEY",
4162 "WANJIE_MAAS_API_KEY",
4163 ],
4164 ),
4165 ];
4166
4167 for &(provider, flag, expected_vars) in cases {
4168 let cli = parse_ok(&[
4169 "codewhale",
4170 "--provider",
4171 flag,
4172 "--workspace",
4173 "/tmp/codewhale-workspace",
4174 ]);
4175 let resolved = ResolvedRuntimeOptions {
4176 provider,
4177 provider_source: ProviderSource::Cli,
4178 model: "test-model".to_string(),
4179 api_key: Some("test-key".to_string()),
4180 api_key_source: Some(RuntimeApiKeySource::Keyring),
4181 base_url: "http://localhost:8000/v1".to_string(),
4182 auth_mode: Some("api_key".to_string()),
4183 insecure_skip_tls_verify: false,
4184 output_mode: None,
4185 log_level: None,
4186 telemetry: false,
4187 approval_policy: None,
4188 sandbox_mode: None,
4189 yolo: None,
4190 verbosity: None,
4191 http_headers: std::collections::BTreeMap::new(),
4192 };
4193
4194 let cmd = build_tui_command(&cli, &resolved, Vec::new())
4195 .unwrap_or_else(|e| panic!("{flag}: {e}"));
4196
4197 assert_eq!(
4198 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
4199 Some("test-key"),
4200 "{flag}: DEEPSEEK_API_KEY not forwarded"
4201 );
4202 for var in expected_vars {
4203 assert_eq!(
4204 command_env(&cmd, var).as_deref(),
4205 Some("test-key"),
4206 "{flag}: {var} not forwarded"
4207 );
4208 }
4209 assert_eq!(
4210 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
4211 Some("keyring"),
4212 "{flag}: expected keyring source bridge"
4213 );
4214 assert_eq!(
4215 command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
4216 None,
4217 "{flag}: auth mode should come from config/profile, not env handoff"
4218 );
4219 }
4220 }
4221
4222 #[test]
4223 fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
4224 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
4225
4226 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
4227 assert!(cli.prompt.is_empty());
4228 assert_eq!(
4229 root_tui_passthrough(&cli).unwrap(),
4230 vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
4231 );
4232 }
4233
4234 #[test]
4235 fn parses_top_level_continue_for_interactive_resume() {
4236 let cli = parse_ok(&["codewhale", "--continue"]);
4237
4238 assert!(cli.continue_session);
4239 assert!(cli.prompt_flag.is_none());
4240 assert!(cli.prompt.is_empty());
4241 assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
4242 }
4243
4244 #[test]
4245 fn top_level_continue_rejects_startup_prompt() {
4246 let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
4247
4248 let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
4249 assert!(
4250 err.to_string()
4251 .contains("codewhale exec --continue <PROMPT>")
4252 );
4253 }
4254
4255 #[test]
4256 fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
4257 let cli = parse_ok(&["deepseek", "hello", "world"]);
4258
4259 assert_eq!(cli.prompt, vec!["hello", "world"]);
4260 assert!(cli.command.is_none());
4261 assert_eq!(
4262 root_tui_passthrough(&cli).unwrap(),
4263 vec!["--prompt".to_string(), "hello world".to_string()]
4264 );
4265 }
4266
4267 #[test]
4268 fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
4269 let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
4270
4271 assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
4272 assert_eq!(cli.prompt, vec!["world"]);
4273 assert_eq!(
4274 root_tui_passthrough(&cli).unwrap(),
4275 vec!["--prompt".to_string(), "hello world".to_string()]
4276 );
4277 }
4278
4279 #[test]
4280 fn known_subcommands_still_parse_before_prompt_tail() {
4281 let cli = parse_ok(&["deepseek", "doctor"]);
4282
4283 assert!(cli.prompt.is_empty());
4284 assert!(matches!(cli.command, Some(Commands::Doctor(_))));
4285 }
4286
4287 #[test]
4288 fn root_help_surface_contains_expected_subcommands_and_globals() {
4289 let rendered = help_for(&["deepseek", "--help"]);
4290
4291 for token in [
4292 "run",
4293 "doctor",
4294 "models",
4295 "sessions",
4296 "resume",
4297 "setup",
4298 "login",
4299 "logout",
4300 "auth",
4301 "mcp-server",
4302 "config",
4303 "model",
4304 "thread",
4305 "sandbox",
4306 "app-server",
4307 "completion",
4308 "metrics",
4309 "--provider",
4310 "--model",
4311 "--config",
4312 "--profile",
4313 "--output-mode",
4314 "--log-level",
4315 "--telemetry",
4316 "--base-url",
4317 "--api-key",
4318 "--approval-policy",
4319 "--sandbox-mode",
4320 "--mouse-capture",
4321 "--no-mouse-capture",
4322 "--skip-onboarding",
4323 "--continue",
4324 "--prompt",
4325 ] {
4326 assert!(
4327 rendered.contains(token),
4328 "expected help to contain token: {token}"
4329 );
4330 }
4331 }
4332
4333 #[test]
4334 fn subcommand_help_surfaces_are_stable() {
4335 let cases = [
4336 ("config", vec!["get", "set", "unset", "list", "path"]),
4337 ("model", vec!["list", "resolve"]),
4338 (
4339 "thread",
4340 vec![
4341 "list",
4342 "read",
4343 "resume",
4344 "fork",
4345 "archive",
4346 "unarchive",
4347 "set-name",
4348 "clear-name",
4349 ],
4350 ),
4351 ("sandbox", vec!["check"]),
4352 (
4353 "exec",
4354 vec![
4355 "--auto",
4356 "--json",
4357 "--resume",
4358 "--session-id",
4359 "--continue",
4360 "--output-format",
4361 "stream-json",
4362 ],
4363 ),
4364 (
4365 "app-server",
4366 vec!["--host", "--port", "--config", "--stdio"],
4367 ),
4368 (
4369 "completion",
4370 vec![
4371 "<SHELL>",
4372 "bash",
4373 "source <(codewhale completion bash)",
4374 "~/.local/share/bash-completion/completions/codewhale",
4375 "fpath=(~/.zfunc $fpath)",
4376 "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
4377 "codewhale completion powershell | Out-String | Invoke-Expression",
4378 ],
4379 ),
4380 ("metrics", vec!["--json", "--since"]),
4381 ];
4382
4383 for (subcommand, expected_tokens) in cases {
4384 let argv = ["deepseek", subcommand, "--help"];
4385 let rendered = help_for(&argv);
4386 for token in expected_tokens {
4387 assert!(
4388 rendered.contains(token),
4389 "expected help for `{subcommand}` to include `{token}`"
4390 );
4391 }
4392 }
4393 }
4394
4395 #[test]
4401 fn sibling_tui_candidate_picks_platform_correct_name() {
4402 let dir = tempfile::TempDir::new().expect("tempdir");
4403 let dispatcher = dir
4404 .path()
4405 .join("codewhale")
4406 .with_extension(std::env::consts::EXE_EXTENSION);
4407 std::fs::write(&dispatcher, b"").unwrap();
4409
4410 assert!(sibling_tui_candidate(&dispatcher).is_none());
4412
4413 let target =
4414 dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
4415 std::fs::write(&target, b"").unwrap();
4416
4417 let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
4418 assert_eq!(found, target, "primary platform-correct name wins");
4419 }
4420
4421 #[test]
4422 fn dispatcher_spawn_error_names_path_and_recovery_checks() {
4423 let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
4424 let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
4425
4426 assert!(message.contains("C:/tools/codewhale-tui.exe"));
4427 assert!(message.contains("access is denied"));
4428 assert!(message.contains("where codewhale"));
4429 assert!(message.contains("DEEPSEEK_TUI_BIN"));
4430 }
4431
4432 #[cfg(windows)]
4437 #[test]
4438 fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
4439 let dir = tempfile::TempDir::new().expect("tempdir");
4440 let dispatcher = dir.path().join("codewhale.exe");
4441 std::fs::write(&dispatcher, b"").unwrap();
4442
4443 let suffixless = dispatcher.with_file_name("codewhale-tui");
4445 std::fs::write(&suffixless, b"").unwrap();
4446
4447 let found = sibling_tui_candidate(&dispatcher)
4448 .expect("Windows fallback must locate suffixless codewhale-tui");
4449 assert_eq!(found, suffixless);
4450 }
4451
4452 #[test]
4455 fn locate_sibling_tui_binary_honours_env_override() {
4456 let _lock = env_lock();
4457 let dir = tempfile::TempDir::new().expect("tempdir");
4458 let custom = dir
4459 .path()
4460 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
4461 std::fs::write(&custom, b"").unwrap();
4462 let custom_str = custom.to_string_lossy().into_owned();
4463 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
4464
4465 let resolved = locate_sibling_tui_binary().expect("override must resolve");
4466 assert_eq!(resolved, custom);
4467 }
4468}