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