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