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