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