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