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, ConfigToml, ProviderKind, ProviderSource,
21 ResolvedRuntimeOptions, RuntimeApiKeySource, provider_base_url_is_official,
22};
23use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
24use codewhale_mcp::{McpServerDefinition, run_stdio_server};
25use codewhale_secrets::Secrets;
26use codewhale_state::{StateStore, ThreadListFilters};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
29enum ProviderArg {
30 Deepseek,
31 NvidiaNim,
32 Openai,
33 Atlascloud,
34 WanjieArk,
35 Volcengine,
36 Openrouter,
37 XiaomiMimo,
38 Novita,
39 Fireworks,
40 Siliconflow,
41 #[value(
42 alias = "silicon-flow-cn",
43 alias = "siliconflow-CN",
44 alias = "silicon_flow_cn",
45 alias = "siliconflow_cn",
46 alias = "siliconflow-china",
47 alias = "siliconflow_china"
48 )]
49 SiliconflowCn,
50 Arcee,
51 Moonshot,
52 Sglang,
53 Vllm,
54 Ollama,
55 Huggingface,
56 Together,
57 OpenaiCodex,
58 Anthropic,
59 #[value(alias = "open-model", alias = "open_model")]
60 Openmodel,
61 Zai,
62 Stepfun,
63 Minimax,
64 #[value(
65 alias = "minimax_anthropic",
66 alias = "mini-max-anthropic",
67 alias = "mini_max_anthropic"
68 )]
69 MinimaxAnthropic,
70 #[value(alias = "deep-infra", alias = "deep_infra")]
71 Deepinfra,
72 #[value(alias = "fugu", alias = "sakana-ai", alias = "sakana_ai")]
73 Sakana,
74 #[value(alias = "long-cat", alias = "meituan-longcat", alias = "meituan")]
75 LongCat,
76 #[value(alias = "opencode_go", alias = "opencodego")]
77 OpencodeGo,
78 #[value(
79 alias = "opencode_zen",
80 alias = "opencodezen",
81 alias = "zen",
82 alias = "opencode"
83 )]
84 OpencodeZen,
85 #[value(
86 alias = "meta-ai",
87 alias = "meta_ai",
88 alias = "meta-model-api",
89 alias = "muse",
90 alias = "muse-spark"
91 )]
92 Meta,
93 #[value(alias = "x-ai", alias = "x_ai", alias = "grok")]
94 Xai,
95}
96
97impl From<ProviderArg> for ProviderKind {
98 fn from(value: ProviderArg) -> Self {
99 match value {
100 ProviderArg::Deepseek => ProviderKind::Deepseek,
101 ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
102 ProviderArg::Openai => ProviderKind::Openai,
103 ProviderArg::Atlascloud => ProviderKind::Atlascloud,
104 ProviderArg::WanjieArk => ProviderKind::WanjieArk,
105 ProviderArg::Volcengine => ProviderKind::Volcengine,
106 ProviderArg::Openrouter => ProviderKind::Openrouter,
107 ProviderArg::XiaomiMimo => ProviderKind::XiaomiMimo,
108 ProviderArg::Novita => ProviderKind::Novita,
109 ProviderArg::Fireworks => ProviderKind::Fireworks,
110 ProviderArg::Siliconflow => ProviderKind::Siliconflow,
111 ProviderArg::SiliconflowCn => ProviderKind::SiliconflowCN,
112 ProviderArg::Arcee => ProviderKind::Arcee,
113 ProviderArg::Moonshot => ProviderKind::Moonshot,
114 ProviderArg::Sglang => ProviderKind::Sglang,
115 ProviderArg::Vllm => ProviderKind::Vllm,
116 ProviderArg::Ollama => ProviderKind::Ollama,
117 ProviderArg::Huggingface => ProviderKind::Huggingface,
118 ProviderArg::Together => ProviderKind::Together,
119 ProviderArg::OpenaiCodex => ProviderKind::OpenaiCodex,
120 ProviderArg::Anthropic => ProviderKind::Anthropic,
121 ProviderArg::Openmodel => ProviderKind::Openmodel,
122 ProviderArg::Zai => ProviderKind::Zai,
123 ProviderArg::Stepfun => ProviderKind::Stepfun,
124 ProviderArg::Minimax => ProviderKind::Minimax,
125 ProviderArg::MinimaxAnthropic => ProviderKind::MinimaxAnthropic,
126 ProviderArg::Deepinfra => ProviderKind::Deepinfra,
127 ProviderArg::Sakana => ProviderKind::Sakana,
128 ProviderArg::LongCat => ProviderKind::LongCat,
129 ProviderArg::OpencodeGo => ProviderKind::OpencodeGo,
130 ProviderArg::OpencodeZen => ProviderKind::OpencodeZen,
131 ProviderArg::Meta => ProviderKind::Meta,
132 ProviderArg::Xai => ProviderKind::Xai,
133 }
134 }
135}
136
137fn builtin_provider_arg(value: &str) -> Option<ProviderArg> {
138 ProviderArg::from_str(value, false).ok()
139}
140
141fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
142 if value.is_empty()
143 || value == "__custom__"
144 || !value
145 .chars()
146 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
147 {
148 return Err(
149 "provider must be a simple identifier using letters, numbers, '-', '_', or '.'"
150 .to_string(),
151 );
152 }
153 Ok(value.to_string())
154}
155
156#[derive(Debug, Parser)]
157#[command(
158 name = "codewhale",
159 version = env!("DEEPSEEK_BUILD_VERSION"),
160 bin_name = "codewhale",
161 override_usage = "codewhale [OPTIONS] [PROMPT]\n codewhale [OPTIONS] <COMMAND> [ARGS]"
162)]
163struct Cli {
164 #[arg(long)]
165 config: Option<PathBuf>,
166 #[arg(long)]
167 profile: Option<String>,
168 #[arg(
169 long,
170 value_name = "PROVIDER",
171 value_parser = parse_provider_identifier,
172 help = "Provider selector; exec/fleet also accept configured custom provider identifiers"
173 )]
174 provider: Option<String>,
175 #[arg(long)]
176 model: Option<String>,
177 #[arg(long = "output-mode")]
178 output_mode: Option<String>,
179 #[arg(
180 long = "verbosity",
181 value_name = "LEVEL",
182 help = "Controls transcript and output verbosity (normal, concise)"
183 )]
184 verbosity: Option<String>,
185 #[arg(long = "log-level")]
186 log_level: Option<String>,
187 #[arg(long)]
188 telemetry: Option<bool>,
189 #[arg(long)]
190 approval_policy: Option<String>,
191 #[arg(long)]
192 sandbox_mode: Option<String>,
193 #[arg(long)]
194 api_key: Option<String>,
195 #[arg(long)]
196 base_url: Option<String>,
197 #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
199 workspace: Option<PathBuf>,
200 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
201 mouse_capture: bool,
202 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
203 no_mouse_capture: bool,
204 #[arg(long = "skip-onboarding")]
205 skip_onboarding: bool,
206 #[arg(long = "no-project-config")]
210 no_project_config: bool,
211 #[arg(long, hide = true)]
213 yolo: bool,
214 #[arg(short = 'c', long = "continue")]
216 continue_session: bool,
217 #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
218 prompt_flag: Option<String>,
219 #[arg(
220 value_name = "PROMPT",
221 trailing_var_arg = true,
222 allow_hyphen_values = true
223 )]
224 prompt: Vec<String>,
225 #[command(subcommand)]
226 command: Option<Commands>,
227}
228
229#[derive(Debug, Subcommand)]
230enum Commands {
231 Run(RunArgs),
233 Doctor(TuiPassthroughArgs),
235 Models(TuiPassthroughArgs),
237 #[command(visible_alias = "tts")]
239 Speech(TuiPassthroughArgs),
240 Sessions(TuiPassthroughArgs),
242 Resume(TuiPassthroughArgs),
244 Rc(TuiPassthroughArgs),
246 Fork(TuiPassthroughArgs),
248 Init(TuiPassthroughArgs),
250 Setup(TuiPassthroughArgs),
252 RemoteSetup(RemoteSetupArgs),
254 #[command(after_help = "\
256Examples:
257 codewhale exec \"explain this function\"
258 codewhale exec --auto \"list crates/ with ls\"
259 codewhale exec --auto --output-format stream-json \"fix the failing test\"
260
261Common forwarded flags:
262 --auto Enable tool-backed agent mode with auto-approvals
263 --json Emit summary JSON
264 --resume <SESSION_ID> Resume a previous session by ID or prefix
265 --session-id <SESSION_ID> Resume a previous session by ID or prefix
266 --continue Continue the most recent session for this workspace
267 --output-format <FORMAT> Output format: text or stream-json
268
269Plain `codewhale exec` is a one-shot model response. Use `--auto` for
270non-interactive filesystem/shell tool use, matching the supported automation
271path used by stream-json wrappers.
272")]
273 Exec(TuiPassthroughArgs),
274 Fleet(TuiPassthroughArgs),
276 #[command(name = "workflow-tool", hide = true)]
278 WorkflowTool(TuiPassthroughArgs),
279 #[command(name = "lane-log-proxy", hide = true)]
281 LaneLogProxy(LaneLogProxyArgs),
282 #[command(after_help = "\
284Examples:
285 codewhale workflow run stopship --fleet stopship --runtime tmux --goal verify-release-candidate
286 codewhale workflow run stopship --fleet stopship --runtime inline --verify
287
288`workflow run` validates the checked-in Workflow source and named Fleet roster,
289creates a Lane record, then dispatches the Workflow tool directly through the
290selected Runtime backend without an operator model turn.
291")]
292 Workflow(WorkflowArgs),
293 #[command(after_help = "\
295Examples:
296 codewhale lane list
297 codewhale lane status <lane-id>
298 codewhale lane attach <lane-id>
299 codewhale lane logs <lane-id>
300 codewhale lane interrupt <lane-id>
301 codewhale lane interrupt <lane-id>@<lifecycle-seq>
302 codewhale lane start --workflow stopship --fleet stopship --runtime tmux --goal verify-release-candidate -- echo hello
303
304Lane records persist under $CODEWHALE_HOME/lanes/. tmux durability belongs to
305Runtime, not Fleet.
306
307list/status/interrupt/restart/resume share one control-plane contract with the
308`/lane` slash command and its hotbar action: same verb ids, same availability,
309same read-vs-write authority, same exact-identity target selection, and the
310same receipt (`--json`). `lane stop` is a compatibility spelling of
311`lane interrupt`. Appending `@<lifecycle-seq>` fences a write to the exact
312lifecycle generation you observed.
313")]
314 Lane(LaneArgs),
315 Review(TuiPassthroughArgs),
317 Apply(TuiPassthroughArgs),
319 Eval(TuiPassthroughArgs),
321 Mcp(TuiPassthroughArgs),
323 Features(TuiPassthroughArgs),
325 #[command(after_help = "\
327Forwarded serve options:
328 --mcp Start MCP server over stdio
329 --http Start runtime HTTP/SSE API server
330 --mobile Start runtime HTTP/SSE API server with the mobile control page
331 --web Start the embedded loopback-only browser client
332 --qr Show a QR code for the mobile URL (requires --mobile)
333 --acp Start ACP server over stdio for editor clients
334 --host <HOST> Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
335 --port <PORT> Bind port [default: 7878]
336 --workers <WORKERS> Background task worker count (1-8)
337 --cors-origin <URL> Additional CORS origin to allow (repeatable)
338 --auth-token <TOKEN> Require this bearer token for /v1/* runtime API routes
339 --insecure Disable runtime API auth when no token is configured
340
341`codewhale serve --http` and `codewhale serve --mobile` remain compatibility
342aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
343New integrations should prefer `codewhale app-server`.")]
344 Serve(TuiPassthroughArgs),
345 #[command(
347 after_help = "The browser receives a one-time loopback bootstrap capability, never the Runtime token.\nThe capability is exchanged for a bounded, process-local HttpOnly, SameSite=Strict web session and then invalidated."
348 )]
349 Web(WebArgs),
350 Completions(TuiPassthroughArgs),
352 Login(LoginArgs),
354 Logout,
356 Auth(AuthArgs),
358 McpServer,
360 Config(ConfigArgs),
362 Model(ModelArgs),
364 Thread(ThreadArgs),
366 Sandbox(SandboxArgs),
368 #[command(after_help = "\
370Transports:
371 codewhale app-server --http Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
372 codewhale app-server --mobile Runtime API + phone control page (binds 0.0.0.0)
373 codewhale app-server --stdio JSON-RPC control transport over stdio (no listener)
374 codewhale app-server Legacy in-process app-server HTTP on 127.0.0.1:8787
375
376`--http` and `--mobile` serve the same mature runtime API as `codewhale serve
377--http`/`--mobile`, which remain as compatibility aliases. The runtime API token
378is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
379
380See docs/RUNTIME_API.md.")]
381 AppServer(AppServerArgs),
382 #[command(after_help = r#"Examples:
384 Bash (current shell only):
385 source <(codewhale completion bash)
386
387 Bash (persistent, Linux/bash-completion):
388 mkdir -p ~/.local/share/bash-completion/completions
389 codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
390 # Requires bash-completion to be installed and loaded by your shell.
391
392 Zsh:
393 mkdir -p ~/.zfunc
394 codewhale completion zsh > ~/.zfunc/_codewhale
395 # Add to ~/.zshrc if needed:
396 # fpath=(~/.zfunc $fpath)
397 # autoload -Uz compinit && compinit
398
399 Fish:
400 mkdir -p ~/.config/fish/completions
401 codewhale completion fish > ~/.config/fish/completions/codewhale.fish
402
403 PowerShell (current shell only):
404 codewhale completion powershell | Out-String | Invoke-Expression
405
406The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)]
407 Completion {
408 #[arg(value_enum)]
409 shell: Shell,
410 },
411 Metrics(MetricsArgs),
413 Update(UpdateArgs),
415}
416
417fn command_accepts_raw_provider(command: Option<&Commands>) -> bool {
418 matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_)))
419}
420
421fn top_level_provider_override(
422 provider: Option<&str>,
423 command: Option<&Commands>,
424) -> Result<Option<ProviderKind>> {
425 let Some(provider) = provider else {
426 return Ok(None);
427 };
428 if let Some(provider) = builtin_provider_arg(provider) {
429 return Ok(Some(provider.into()));
430 }
431 if command_accepts_raw_provider(command) {
432 return Ok(None);
433 }
434
435 let expected = ProviderArg::value_variants()
436 .iter()
437 .filter_map(ValueEnum::to_possible_value)
438 .map(|value| value.get_name().to_string())
439 .collect::<Vec<_>>()
440 .join(", ");
441 bail!(
442 "invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted only by exec and fleet"
443 )
444}
445
446fn prepare_raw_provider_tui_dispatch(
447 cli: &Cli,
448 command: Option<&Commands>,
449 runtime_overrides: &CliRuntimeOverrides,
450) -> Result<Option<(ResolvedRuntimeOptions, Vec<String>)>> {
451 let Some(provider) = cli.provider.as_deref() else {
452 return Ok(None);
453 };
454 if builtin_provider_arg(provider).is_some() || !command_accepts_raw_provider(command) {
455 return Ok(None);
456 }
457
458 let passthrough = match command {
459 Some(Commands::Exec(args)) => {
460 reject_exec_global_flags(&args.args)?;
461 tui_args("exec", args.clone())
462 }
463 Some(Commands::Fleet(args)) => tui_args("fleet", args.clone()),
464 _ => unreachable!("raw provider validation only permits Exec and Fleet"),
465 };
466
467 let resolved_runtime = ConfigToml::default().resolve_runtime_options(runtime_overrides);
471 Ok(Some((resolved_runtime, passthrough)))
472}
473
474#[derive(Debug, Args)]
475struct UpdateArgs {
476 #[arg(long)]
478 beta: bool,
479 #[arg(long)]
481 check: bool,
482 #[arg(long, value_name = "URL")]
484 proxy: Option<String>,
485}
486
487#[derive(Debug, Args)]
488struct MetricsArgs {
489 #[arg(long)]
491 json: bool,
492 #[arg(long, value_name = "DURATION")]
494 since: Option<String>,
495}
496
497#[derive(Debug, Args)]
498struct RunArgs {
499 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
500 args: Vec<String>,
501}
502
503#[derive(Debug, Args, Clone)]
504struct TuiPassthroughArgs {
505 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
506 args: Vec<String>,
507}
508
509#[derive(Debug, Args)]
510struct WebArgs {
511 #[arg(long, default_value_t = 7878)]
513 port: u16,
514}
515
516#[derive(Debug, Args)]
517struct LaneLogProxyArgs {
518 #[arg(long, value_name = "PATH")]
519 log_path: PathBuf,
520 #[arg(long, value_name = "PATH")]
521 receipt_path: PathBuf,
522 #[arg(long, value_name = "PATH")]
523 receipt_tmp_path: PathBuf,
524 #[arg(long, value_name = "PATH")]
525 environment_path: Option<PathBuf>,
526 #[arg(long)]
527 lane_id: String,
528 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
529 command: Vec<String>,
530}
531
532#[derive(Debug, Args)]
534struct LaneArgs {
535 #[command(subcommand)]
536 command: LaneCommand,
537}
538
539#[derive(Debug, Subcommand)]
540#[allow(clippy::large_enum_variant)]
544enum LaneCommand {
545 List {
547 #[arg(long, default_value_t = false)]
549 json: bool,
550 },
551 Status {
553 lane_id: String,
555 #[arg(long, default_value_t = false)]
556 json: bool,
557 },
558 Attach {
560 lane_id: String,
561 #[arg(long, default_value_t = false)]
563 print: bool,
564 },
565 Logs {
567 lane_id: String,
568 #[arg(long, short = 'f', default_value_t = false)]
570 follow: bool,
571 #[arg(long, default_value_t = 50)]
573 tail: usize,
574 },
575 Stop { lane_id: String },
580 Interrupt {
585 lane_id: String,
586 #[arg(long, default_value_t = false)]
587 json: bool,
588 },
589 Restart {
591 lane_id: String,
592 #[arg(long, default_value_t = false)]
593 json: bool,
594 },
595 Resume {
597 lane_id: String,
598 #[arg(long, default_value_t = false)]
599 json: bool,
600 },
601 Start {
603 #[arg(long)]
605 workflow: Option<String>,
606 #[arg(long)]
608 fleet: Option<String>,
609 #[arg(long)]
611 issue: Option<String>,
612 #[arg(long)]
614 goal: Option<String>,
615 #[arg(long, default_value = "tmux")]
617 runtime: String,
618 #[arg(long, value_name = "DIR")]
620 worktree_repo: Option<PathBuf>,
621 #[arg(long)]
623 branch: Option<String>,
624 #[arg(long, value_name = "DIR")]
626 worktree_path: Option<PathBuf>,
627 #[arg(long)]
629 worktree_ttl_secs: Option<u64>,
630 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
632 command: Vec<String>,
633 },
634}
635
636#[derive(Debug, Args)]
638struct WorkflowArgs {
639 #[command(subcommand)]
640 command: WorkflowCommand,
641}
642
643#[derive(Debug, Subcommand)]
644enum WorkflowCommand {
645 Run {
647 workflow: String,
649 #[arg(long)]
651 fleet: String,
652 #[arg(long)]
654 issue: Option<String>,
655 #[arg(long)]
657 goal: Option<String>,
658 #[arg(long, default_value = "tmux")]
660 runtime: String,
661 #[arg(long, value_name = "PATH")]
663 source_path: Option<PathBuf>,
664 #[arg(long)]
666 token_budget: Option<u64>,
667 #[arg(long, default_value_t = false)]
669 verify: bool,
670 #[arg(long, value_name = "DIR")]
672 worktree_repo: Option<PathBuf>,
673 #[arg(long)]
675 branch: Option<String>,
676 #[arg(long, value_name = "DIR")]
678 worktree_path: Option<PathBuf>,
679 #[arg(long)]
681 worktree_ttl_secs: Option<u64>,
682 },
683}
684
685struct LaneStartRequest {
686 workflow: Option<String>,
687 fleet: Option<String>,
688 issue: Option<String>,
689 goal: Option<String>,
690 runtime: String,
691 worktree_repo: Option<PathBuf>,
692 branch: Option<String>,
693 worktree_path: Option<PathBuf>,
694 worktree_ttl_secs: Option<u64>,
695 command: Vec<String>,
696 environment: Vec<(String, String)>,
697 cwd: Option<PathBuf>,
698}
699
700fn start_lane(request: LaneStartRequest) -> Result<()> {
701 use codewhale_lane::{
702 LaneRegistry, LaneStartSpec, RuntimeBackendKind, WorktreeProvision, resolve_backend,
703 };
704
705 let LaneStartRequest {
706 workflow,
707 fleet,
708 issue,
709 goal,
710 runtime,
711 worktree_repo,
712 branch,
713 worktree_path,
714 worktree_ttl_secs,
715 command,
716 environment,
717 cwd,
718 } = request;
719 let kind = RuntimeBackendKind::parse(&runtime)?;
720 let reg = LaneRegistry::open_default()?;
721 let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
722 let worktree = match (worktree_repo, branch) {
723 (Some(repo_root), Some(branch_name)) => {
724 let path = worktree_path
725 .unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
726 Some(WorktreeProvision {
727 repo_root,
728 branch: branch_name,
729 path,
730 base_ref: None,
731 })
732 }
733 (None, None) => None,
734 _ => bail!("--worktree-repo and --branch must be provided together"),
735 };
736 let cmd = if command.is_empty() {
737 vec![
738 "sh".into(),
739 "-c".into(),
740 format!("echo lane {} started", record.id),
741 ]
742 } else {
743 command
744 };
745 let spec = LaneStartSpec {
746 command: cmd,
747 cwd,
748 environment,
749 log_proxy: (kind == RuntimeBackendKind::Tmux)
750 .then(std::env::current_exe)
751 .transpose()
752 .context("resolve current Codewhale executable for tmux log proxy")?,
753 worktree,
754 };
755 let backend = resolve_backend(kind);
756 backend.start(®, &mut record, &spec)?;
757 println!("started {}", record.id);
758 println!("status: {}", record.status.as_str());
759 println!("runtime: {}", record.runtime.as_str());
760 println!("log: {}", record.log_path.display());
761 if let Some(attach) = backend.attach_command(&record) {
762 println!("attach: {attach}");
763 }
764 Ok(())
765}
766
767fn emit_control_receipt(receipt: &codewhale_lane::ControlReceipt, json: bool) -> Result<()> {
773 if json {
774 match receipt.operation {
780 codewhale_lane::ControlOperation::LaneList => {
781 println!("{}", serde_json::to_string_pretty(&receipt.lane_records)?);
782 }
783 codewhale_lane::ControlOperation::LaneStatus => match receipt.lane_records.first() {
784 Some(record) => println!("{}", serde_json::to_string_pretty(record)?),
785 None if receipt.is_error() => {}
791 None => println!("{}", serde_json::to_string_pretty(receipt)?),
792 },
793 _ => println!("{}", serde_json::to_string_pretty(receipt)?),
794 }
795 } else if receipt.is_error() {
796 eprintln!("{}", receipt.render());
797 } else {
798 println!("{}", receipt.render());
799 }
800 if receipt.is_error() {
801 let detail = receipt
802 .failure
803 .as_ref()
804 .map(ToString::to_string)
805 .unwrap_or_else(|| receipt.outcome.as_str().to_string());
806 bail!("{}: {detail}", receipt.operation_id);
807 }
808 Ok(())
809}
810
811fn run_lane_control(
812 operation: codewhale_lane::ControlOperation,
813 lane_id: Option<&str>,
814 json: bool,
815) -> Result<()> {
816 let receipt = codewhale_lane::control::execute_lane_control(
817 codewhale_lane::ControlSurface::Cli,
818 operation,
819 lane_id,
820 );
821 emit_control_receipt(&receipt, json)
822}
823
824fn run_lane_command(args: LaneArgs) -> Result<()> {
825 use codewhale_lane::{ControlOperation, LaneRegistry, backend_for};
826 use std::io::{BufRead, Seek, Write};
827 use std::process::Command;
828 use std::thread;
829 use std::time::Duration;
830
831 match args.command {
832 LaneCommand::List { json } => run_lane_control(ControlOperation::LaneList, None, json),
833 LaneCommand::Status { lane_id, json } => {
834 run_lane_control(ControlOperation::LaneStatus, Some(&lane_id), json)
835 }
836 LaneCommand::Interrupt { lane_id, json } => {
837 run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), json)
838 }
839 LaneCommand::Restart { lane_id, json } => {
840 run_lane_control(ControlOperation::LaneRestart, Some(&lane_id), json)
841 }
842 LaneCommand::Resume { lane_id, json } => {
843 run_lane_control(ControlOperation::LaneResume, Some(&lane_id), json)
844 }
845 LaneCommand::Attach { lane_id, print } => {
846 let reg = LaneRegistry::open_default()?;
847 let mut lane = reg.load(&lane_id)?;
848 let backend = backend_for(&lane);
849 backend.reconcile(®, &mut lane)?;
850 let Some(attach) = backend.attach_command(&lane) else {
851 if !lane.status.is_active() {
852 bail!(
853 "lane `{lane_id}` is {} and has no active attach target",
854 lane.status.as_str()
855 );
856 }
857 bail!(
858 "lane `{lane_id}` runtime `{}` has no attach target",
859 lane.runtime.as_str()
860 );
861 };
862 if print {
863 println!("{attach}");
864 return Ok(());
865 }
866 if let Some(session) = lane.tmux_session.as_deref() {
867 let socket = lane
868 .tmux_socket
869 .as_deref()
870 .context("tmux lane is missing its pinned server socket")?;
871 let status = Command::new("tmux")
872 .arg("-S")
873 .arg(socket)
874 .args(["attach", "-t", session])
875 .status();
876 match status {
877 Ok(s) if s.success() => Ok(()),
878 Ok(s) => bail!("tmux attach failed ({s}); command was: {attach}"),
879 Err(err) => {
880 eprintln!("could not exec tmux: {err}");
881 println!("{attach}");
882 bail!("tmux attach unavailable");
883 }
884 }
885 } else {
886 println!("{attach}");
887 Ok(())
888 }
889 }
890 LaneCommand::Logs {
891 lane_id,
892 follow,
893 tail,
894 } => {
895 let reg = LaneRegistry::open_default()?;
896 let lane = reg.load(&lane_id)?;
897 let path = lane.log_path;
898 if !path.exists() {
899 bail!("log file missing: {}", path.display());
900 }
901 let content = std::fs::read(&path)?;
902 let lines: Vec<&[u8]> = content
903 .split(|byte| *byte == b'\n')
904 .filter(|line| !line.is_empty())
905 .collect();
906 let start = lines.len().saturating_sub(tail);
907 let mut stdout = std::io::stdout().lock();
908 for line in &lines[start..] {
909 stdout.write_all(String::from_utf8_lossy(line).as_bytes())?;
910 stdout.write_all(b"\n")?;
911 }
912 stdout.flush()?;
913 if !follow {
914 return Ok(());
915 }
916 let mut file = std::fs::File::open(&path)?;
917 file.seek(std::io::SeekFrom::End(0))?;
918 let mut reader = std::io::BufReader::new(file);
919 loop {
920 let mut line = Vec::new();
921 match reader.read_until(b'\n', &mut line) {
922 Ok(0) => {
923 thread::sleep(Duration::from_millis(200));
924 continue;
925 }
926 Ok(_) => {
927 let mut stdout = std::io::stdout().lock();
928 stdout.write_all(String::from_utf8_lossy(&line).as_bytes())?;
929 stdout.flush()?;
930 }
931 Err(err) => return Err(err.into()),
932 }
933 }
934 }
935 LaneCommand::Stop { lane_id } => {
939 run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), false)
940 }
941 LaneCommand::Start {
942 workflow,
943 fleet,
944 issue,
945 goal,
946 runtime,
947 worktree_repo,
948 branch,
949 worktree_path,
950 worktree_ttl_secs,
951 command,
952 } => start_lane(LaneStartRequest {
953 workflow,
954 fleet,
955 issue,
956 goal,
957 runtime,
958 worktree_repo,
959 branch,
960 worktree_path,
961 worktree_ttl_secs,
962 command,
963 environment: Vec::new(),
964 cwd: None,
965 }),
966 }
967}
968
969fn run_lane_log_proxy_command(args: LaneLogProxyArgs) -> Result<()> {
970 let exit_code = codewhale_lane::run_lane_log_proxy(codewhale_lane::LaneLogProxySpec {
971 command: args.command,
972 log_path: args.log_path,
973 receipt_path: args.receipt_path,
974 receipt_tmp_path: args.receipt_tmp_path,
975 environment_path: args.environment_path,
976 lane_id: args.lane_id,
977 })?;
978 std::process::exit(exit_code);
979}
980
981fn run_workflow_command(
982 cli: &Cli,
983 resolved_runtime: &ResolvedRuntimeOptions,
984 config_path: &Path,
985 args: WorkflowArgs,
986) -> Result<()> {
987 match args.command {
988 WorkflowCommand::Run {
989 workflow,
990 fleet,
991 issue,
992 goal,
993 runtime,
994 source_path,
995 token_budget,
996 verify,
997 worktree_repo,
998 branch,
999 worktree_path,
1000 worktree_ttl_secs,
1001 } => {
1002 let workspace = workflow_workspace_root(cli.workspace.as_deref())?;
1003 let source_path =
1004 resolve_workflow_source_path(&workflow, source_path.as_ref(), &workspace)?;
1005 validate_workflow_source_file(&source_path)?;
1006
1007 let source_root = if let Some(repo) = worktree_repo.as_deref() {
1008 repo.canonicalize()
1009 .with_context(|| format!("resolve --worktree-repo {}", repo.display()))?
1010 } else {
1011 workspace.clone()
1012 };
1013
1014 let roots = named_fleet_search_roots(&workspace);
1015 let named_fleet = codewhale_workflow::load_named_fleet(&fleet, &roots)
1016 .with_context(|| format!("load fleet `{fleet}` from {}", display_roots(&roots)))?;
1017 if workflow == "stopship" || fleet == "stopship" || fleet == "v0868-stopship" {
1018 named_fleet
1019 .validate_stopship_roles()
1020 .with_context(|| format!("validate stopship roles in fleet `{fleet}`"))?;
1021 }
1022
1023 let process = workflow_exec_command(WorkflowExecSpec {
1024 cli,
1025 resolved_runtime,
1026 config_path,
1027 source_root: &source_root,
1028 source_path: &source_path,
1029 workflow: &workflow,
1030 fleet: &fleet,
1031 issue: issue.as_deref(),
1032 goal: goal.as_deref(),
1033 token_budget,
1034 verify,
1035 })?;
1036 start_lane(LaneStartRequest {
1037 workflow: Some(workflow),
1038 fleet: Some(fleet),
1039 issue,
1040 goal,
1041 runtime,
1042 worktree_repo,
1043 branch,
1044 worktree_path,
1045 worktree_ttl_secs,
1046 command: process.command,
1047 environment: process.environment,
1048 cwd: Some(workspace),
1049 })
1050 }
1051 }
1052}
1053
1054fn workflow_workspace_root(explicit: Option<&Path>) -> Result<PathBuf> {
1055 if let Some(path) = explicit {
1056 return path
1057 .canonicalize()
1058 .with_context(|| format!("resolve workflow workspace {}", path.display()));
1059 }
1060 let cwd = std::env::current_dir().context("resolve current directory")?;
1061 let output = Command::new("git")
1062 .args(["rev-parse", "--show-toplevel"])
1063 .current_dir(&cwd)
1064 .output();
1065 if let Ok(output) = output
1066 && output.status.success()
1067 {
1068 let text = String::from_utf8_lossy(&output.stdout);
1069 let root = text.trim();
1070 if !root.is_empty() {
1071 let root = PathBuf::from(root);
1072 return Ok(root.canonicalize().unwrap_or(root));
1073 }
1074 }
1075 Ok(cwd)
1076}
1077
1078fn resolve_workflow_source_path(
1079 workflow: &str,
1080 source_path: Option<&PathBuf>,
1081 workspace: &Path,
1082) -> Result<PathBuf> {
1083 let candidates = workflow_source_candidates(workflow, source_path, workspace);
1084 for candidate in &candidates {
1085 if candidate.is_file() {
1086 return Ok(candidate.clone());
1087 }
1088 }
1089 bail!(
1090 "workflow source for `{workflow}` not found; tried {}",
1091 candidates
1092 .iter()
1093 .map(|p| p.display().to_string())
1094 .collect::<Vec<_>>()
1095 .join(", ")
1096 )
1097}
1098
1099fn workflow_source_candidates(
1100 workflow: &str,
1101 source_path: Option<&PathBuf>,
1102 workspace: &Path,
1103) -> Vec<PathBuf> {
1104 let mut candidates = Vec::new();
1105 if let Some(path) = source_path {
1106 candidates.push(resolve_against_workspace(path, workspace));
1107 return candidates;
1108 }
1109
1110 let raw = workflow.trim();
1111 let workflow_path = PathBuf::from(raw);
1112 if raw.contains('/') || raw.contains('\\') || raw.ends_with(".js") || raw.ends_with(".ts") {
1113 candidates.push(resolve_against_workspace(&workflow_path, workspace));
1114 return candidates;
1115 }
1116
1117 let normalized = raw.replace('-', "_");
1118 for rel in [
1119 format!("workflows/{raw}.workflow.js"),
1120 format!("workflows/{normalized}.workflow.js"),
1121 ] {
1122 let path = workspace.join(rel);
1123 if !candidates.iter().any(|existing| existing == &path) {
1124 candidates.push(path);
1125 }
1126 }
1127 candidates
1128}
1129
1130fn resolve_against_workspace(path: &Path, workspace: &Path) -> PathBuf {
1131 if path.is_absolute() {
1132 path.to_path_buf()
1133 } else {
1134 workspace.join(path)
1135 }
1136}
1137
1138fn validate_workflow_source_file(path: &Path) -> Result<()> {
1139 let source =
1140 std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1141 if source.trim_start().starts_with("export default workflow(")
1142 || source.trim_start().starts_with("workflow(")
1143 || source.contains("\nworkflow(")
1144 {
1145 let identifier = path.display().to_string();
1146 if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
1147 codewhale_workflow::compile_typescript_workflow(&identifier, &source)
1148 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1149 } else {
1150 codewhale_workflow::compile_javascript_workflow(&identifier, &source)
1151 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1152 }
1153 }
1154 Ok(())
1155}
1156
1157fn named_fleet_search_roots(workspace: &Path) -> Vec<PathBuf> {
1158 let mut roots = Vec::new();
1159 if let Ok(home) = codewhale_config::codewhale_home() {
1160 roots.push(home);
1161 }
1162 roots.push(workspace.to_path_buf());
1163 roots
1164}
1165
1166fn display_roots(roots: &[PathBuf]) -> String {
1167 roots
1168 .iter()
1169 .map(|root| root.display().to_string())
1170 .collect::<Vec<_>>()
1171 .join(", ")
1172}
1173
1174struct WorkflowExecSpec<'a> {
1175 cli: &'a Cli,
1176 resolved_runtime: &'a ResolvedRuntimeOptions,
1177 config_path: &'a Path,
1178 source_root: &'a Path,
1179 source_path: &'a Path,
1180 workflow: &'a str,
1181 fleet: &'a str,
1182 issue: Option<&'a str>,
1183 goal: Option<&'a str>,
1184 token_budget: Option<u64>,
1185 verify: bool,
1186}
1187
1188struct WorkflowProcessSpec {
1189 command: Vec<String>,
1190 environment: Vec<(String, String)>,
1191}
1192
1193fn workflow_exec_command(spec: WorkflowExecSpec<'_>) -> Result<WorkflowProcessSpec> {
1194 let WorkflowExecSpec {
1195 cli,
1196 resolved_runtime,
1197 config_path,
1198 source_root,
1199 source_path,
1200 workflow,
1201 fleet,
1202 issue,
1203 goal,
1204 token_budget,
1205 verify,
1206 } = spec;
1207 let source_arg = source_path
1208 .strip_prefix(source_root)
1209 .with_context(|| {
1210 format!(
1211 "workflow source {} must be inside execution root {}",
1212 source_path.display(),
1213 source_root.display()
1214 )
1215 })?
1216 .display()
1217 .to_string();
1218 let mut payload = serde_json::json!({
1219 "action": "run",
1220 "source_path": source_arg,
1221 "fleet": fleet,
1222 "args": {
1223 "workflow": workflow,
1224 "fleet": fleet,
1225 "issue": issue,
1226 "goal": goal,
1227 },
1228 "verify": verify,
1229 });
1230 if let Some(token_budget) = token_budget {
1231 payload["token_budget"] = serde_json::json!(token_budget);
1232 }
1233 let input_json = serde_json::to_string(&payload)?;
1234 let passthrough = vec![
1235 "workflow-tool".to_string(),
1236 "--approval-source".to_string(),
1237 "explicit-workflow-command".to_string(),
1238 "--input-json".to_string(),
1239 input_json,
1240 ];
1241 let command =
1242 build_tui_command_with_paths(cli, resolved_runtime, passthrough, Some(config_path), None)?;
1243 lane_process_spec_from_command(&command)
1244}
1245
1246fn valid_lane_environment_key(key: &str) -> bool {
1247 let mut chars = key.chars();
1248 chars
1249 .next()
1250 .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
1251 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1252}
1253
1254fn shell_owned_lane_environment(key: &str) -> bool {
1255 matches!(
1256 key,
1257 "PWD" | "OLDPWD" | "SHLVL" | "_" | "TERM" | "TMUX" | "TMUX_PANE"
1258 )
1259}
1260
1261fn lane_process_spec_from_command(command: &Command) -> Result<WorkflowProcessSpec> {
1262 let mut argv = Vec::new();
1263 argv.push(command.get_program().to_string_lossy().into_owned());
1264 argv.extend(
1265 command
1266 .get_args()
1267 .map(|arg| arg.to_string_lossy().into_owned()),
1268 );
1269 let mut environment = std::collections::BTreeMap::new();
1270 for (key, value) in std::env::vars_os() {
1271 let (Some(key), Some(value)) = (key.to_str(), value.to_str()) else {
1272 continue;
1273 };
1274 if valid_lane_environment_key(key) && !shell_owned_lane_environment(key) {
1275 environment.insert(key.to_string(), value.to_string());
1276 }
1277 }
1278 for (key, value) in command.get_envs() {
1279 let key = key
1280 .to_str()
1281 .context("workflow runtime environment key is not UTF-8")?
1282 .to_string();
1283 if let Some(value) = value {
1284 environment.insert(
1285 key,
1286 value
1287 .to_str()
1288 .context("workflow runtime environment value is not UTF-8")?
1289 .to_string(),
1290 );
1291 } else {
1292 environment.remove(&key);
1293 }
1294 }
1295 Ok(WorkflowProcessSpec {
1296 command: argv,
1297 environment: environment.into_iter().collect(),
1298 })
1299}
1300
1301#[derive(Debug, Args, Clone, Default)]
1304struct RemoteSetupArgs {
1305 #[arg(long)]
1307 cloud: Option<String>,
1308 #[arg(long)]
1310 bridge: Option<String>,
1311 #[arg(long)]
1313 provider: Option<String>,
1314 #[arg(long, value_name = "DIR")]
1316 out: Option<PathBuf>,
1317 #[arg(long, default_value_t = false)]
1319 generate_only: bool,
1320 #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
1322 apply: bool,
1323 #[arg(long, default_value_t = false)]
1325 yes: bool,
1326 #[arg(long, default_value_t = false)]
1328 non_interactive: bool,
1329}
1330
1331fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
1335 let mut forwarded = vec!["remote-setup".to_string()];
1336 if let Some(cloud) = args.cloud {
1337 forwarded.push("--cloud".to_string());
1338 forwarded.push(cloud);
1339 }
1340 if let Some(bridge) = args.bridge {
1341 forwarded.push("--bridge".to_string());
1342 forwarded.push(bridge);
1343 }
1344 if let Some(provider) = args.provider {
1345 forwarded.push("--provider".to_string());
1346 forwarded.push(provider);
1347 }
1348 if let Some(out) = args.out {
1349 forwarded.push("--out".to_string());
1350 forwarded.push(out.to_string_lossy().into_owned());
1351 }
1352 if args.generate_only {
1353 forwarded.push("--generate-only".to_string());
1354 }
1355 if args.apply {
1356 forwarded.push("--apply".to_string());
1357 }
1358 if args.yes {
1359 forwarded.push("--yes".to_string());
1360 }
1361 if args.non_interactive {
1362 forwarded.push("--non-interactive".to_string());
1363 }
1364 forwarded
1365}
1366
1367#[derive(Debug, Args)]
1368struct LoginArgs {
1369 #[arg(long, value_enum, hide = true)]
1370 provider: Option<ProviderArg>,
1371 #[arg(long)]
1372 api_key: Option<String>,
1373}
1374
1375#[derive(Debug, Args)]
1376struct AuthArgs {
1377 #[command(subcommand)]
1378 command: AuthCommand,
1379}
1380
1381#[derive(Debug, Subcommand)]
1382enum AuthCommand {
1383 #[command(name = "xai-device")]
1385 XaiDevice,
1386 #[command(name = "external-consent")]
1389 ExternalConsent {
1390 #[arg(long, value_enum)]
1391 provider: ProviderArg,
1392 #[arg(long, value_enum)]
1393 mode: ExternalCredentialModeArg,
1394 #[arg(long, value_name = "PATH")]
1397 path: Option<PathBuf>,
1398 #[arg(long, default_value_t = false)]
1401 yes: bool,
1402 },
1403 #[command(name = "external-revoke")]
1405 ExternalRevoke {
1406 #[arg(long, value_enum)]
1407 provider: ProviderArg,
1408 },
1409 Status {
1413 #[arg(long, value_enum)]
1415 provider: Option<ProviderArg>,
1416 },
1417 Set {
1421 #[arg(long, value_enum)]
1422 provider: ProviderArg,
1423 #[arg(long)]
1425 api_key: Option<String>,
1426 #[arg(long = "api-key-stdin", default_value_t = false)]
1428 api_key_stdin: bool,
1429 },
1430 Get {
1433 #[arg(long, value_enum)]
1434 provider: ProviderArg,
1435 },
1436 Clear {
1438 #[arg(long, value_enum)]
1439 provider: ProviderArg,
1440 },
1441 List,
1444 #[command(hide = true)]
1446 Migrate {
1447 #[arg(long, default_value_t = false)]
1449 dry_run: bool,
1450 },
1451}
1452
1453#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1454enum ExternalCredentialModeArg {
1455 ReadOnly,
1456 Managed,
1457}
1458
1459#[derive(Debug, Args)]
1460struct ConfigArgs {
1461 #[command(subcommand)]
1462 command: ConfigCommand,
1463}
1464
1465#[derive(Debug, Subcommand)]
1466enum ConfigCommand {
1467 Get { key: String },
1468 Set { key: String, value: String },
1469 Unset { key: String },
1470 List,
1471 Path,
1472}
1473
1474#[derive(Debug, Args)]
1475struct ModelArgs {
1476 #[command(subcommand)]
1477 command: ModelCommand,
1478}
1479
1480#[derive(Debug, Subcommand)]
1481enum ModelCommand {
1482 List {
1483 #[arg(long, value_enum)]
1484 provider: Option<ProviderArg>,
1485 },
1486 Resolve {
1487 model: Option<String>,
1488 #[arg(long, value_enum)]
1489 provider: Option<ProviderArg>,
1490 },
1491 Set { model: String },
1493}
1494
1495#[derive(Debug, Args)]
1496struct ThreadArgs {
1497 #[command(subcommand)]
1498 command: ThreadCommand,
1499}
1500
1501#[derive(Debug, Subcommand)]
1502enum ThreadCommand {
1503 List {
1504 #[arg(long, default_value_t = false)]
1505 all: bool,
1506 #[arg(long)]
1507 limit: Option<usize>,
1508 },
1509 Read {
1510 thread_id: String,
1511 },
1512 Resume {
1513 thread_id: String,
1514 },
1515 Fork {
1516 thread_id: String,
1517 },
1518 Archive {
1519 thread_id: String,
1520 },
1521 Unarchive {
1522 thread_id: String,
1523 },
1524 SetName {
1525 thread_id: String,
1526 name: String,
1527 },
1528 ClearName {
1531 thread_id: String,
1532 },
1533}
1534
1535#[derive(Debug, Args)]
1536struct SandboxArgs {
1537 #[command(subcommand)]
1538 command: SandboxCommand,
1539}
1540
1541#[derive(Debug, Subcommand)]
1542enum SandboxCommand {
1543 Check {
1544 command: String,
1545 #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
1546 ask: ApprovalModeArg,
1547 },
1548}
1549
1550#[derive(Debug, Clone, Copy, ValueEnum)]
1551enum ApprovalModeArg {
1552 UnlessTrusted,
1553 OnFailure,
1554 OnRequest,
1555 Never,
1556}
1557
1558impl From<ApprovalModeArg> for AskForApproval {
1559 fn from(value: ApprovalModeArg) -> Self {
1560 match value {
1561 ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
1562 ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
1563 ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
1564 ApprovalModeArg::Never => AskForApproval::Never,
1565 }
1566 }
1567}
1568
1569#[derive(Debug, Args)]
1570struct AppServerArgs {
1571 #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
1575 http: bool,
1576 #[arg(long, conflicts_with = "stdio")]
1579 mobile: bool,
1580 #[arg(long, default_value_t = false)]
1583 stdio: bool,
1584 #[arg(long, requires = "mobile")]
1586 qr: bool,
1587 #[arg(long)]
1590 host: Option<String>,
1591 #[arg(long)]
1594 port: Option<u16>,
1595 #[arg(long)]
1597 workers: Option<usize>,
1598 #[arg(long)]
1599 config: Option<PathBuf>,
1600 #[arg(long = "auth-token")]
1601 auth_token: Option<String>,
1602 #[arg(long, default_value_t = false)]
1603 insecure_no_auth: bool,
1604 #[arg(long = "cors-origin")]
1605 cors_origin: Vec<String>,
1606}
1607
1608const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
1609
1610fn install_rustls_crypto_provider() {
1611 let _ = rustls::crypto::ring::default_provider().install_default();
1612}
1613
1614pub fn run_cli() -> std::process::ExitCode {
1615 install_rustls_crypto_provider();
1616
1617 match run() {
1618 Ok(()) => std::process::ExitCode::SUCCESS,
1619 Err(err) => {
1620 eprintln!("error: {err}");
1628 for cause in err.chain().skip(1) {
1629 eprintln!(" caused by: {cause}");
1630 }
1631 std::process::ExitCode::FAILURE
1632 }
1633 }
1634}
1635
1636fn split_lane_log_proxy_command(
1637 command: Option<Commands>,
1638) -> (Option<LaneLogProxyArgs>, Option<Commands>) {
1639 match command {
1640 Some(Commands::LaneLogProxy(args)) => (Some(args), None),
1641 command => (None, command),
1642 }
1643}
1644
1645fn run() -> Result<()> {
1646 let mut cli = Cli::parse();
1647
1648 let (proxy, command) = split_lane_log_proxy_command(cli.command.take());
1652 if let Some(args) = proxy {
1653 return run_lane_log_proxy_command(args);
1654 }
1655
1656 let runtime_provider = top_level_provider_override(cli.provider.as_deref(), command.as_ref())?;
1657 let uses_raw_tui_provider = cli.provider.is_some() && runtime_provider.is_none();
1658 let runtime_overrides = CliRuntimeOverrides {
1659 provider: runtime_provider,
1660 model: cli.model.clone(),
1661 api_key: cli.api_key.clone(),
1662 base_url: cli.base_url.clone(),
1663 auth_mode: None,
1664 output_mode: cli.output_mode.clone(),
1665 log_level: cli.log_level.clone(),
1666 telemetry: cli.telemetry,
1667 approval_policy: cli.approval_policy.clone(),
1668 sandbox_mode: cli.sandbox_mode.clone(),
1669 yolo: Some(cli.yolo),
1670 verbosity: cli.verbosity.clone(),
1671 };
1672 if uses_raw_tui_provider
1673 && let Some((resolved_runtime, passthrough)) =
1674 prepare_raw_provider_tui_dispatch(&cli, command.as_ref(), &runtime_overrides)?
1675 {
1676 return delegate_to_tui(&cli, &resolved_runtime, passthrough);
1677 }
1678
1679 let mut store = ConfigStore::load(cli.config.clone())?;
1680 match command {
1681 Some(Commands::Run(args)) => {
1682 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1683 delegate_to_tui(&cli, &resolved_runtime, args.args)
1684 }
1685 Some(Commands::Doctor(args)) => {
1686 let resolved_runtime =
1687 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1688 delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
1689 }
1690 Some(Commands::Models(args)) => {
1691 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1692 delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
1693 }
1694 Some(Commands::Speech(args)) => {
1695 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1696 delegate_to_tui(&cli, &resolved_runtime, tui_args("speech", args))
1697 }
1698 Some(Commands::Sessions(args)) => {
1699 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1700 delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
1701 }
1702 Some(Commands::Resume(args)) => {
1703 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1704 run_resume_command(&cli, &resolved_runtime, args)
1705 }
1706 Some(Commands::Rc(args)) => {
1707 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1708 let mut passthrough = vec!["--remote-control".to_string()];
1709 passthrough.extend(args.args);
1710 delegate_to_tui(&cli, &resolved_runtime, passthrough)
1711 }
1712 Some(Commands::Fork(args)) => {
1713 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1714 delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
1715 }
1716 Some(Commands::Init(args)) => {
1717 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1718 delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
1719 }
1720 Some(Commands::Setup(args)) => {
1721 let resolved_runtime = if setup_is_status_report(&args) {
1722 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides)
1723 } else {
1724 resolve_runtime_for_dispatch(&mut store, &runtime_overrides)
1725 };
1726 delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
1727 }
1728 Some(Commands::RemoteSetup(args)) => {
1729 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1730 delegate_to_tui(&cli, &resolved_runtime, remote_setup_tui_args(args))
1731 }
1732 Some(Commands::Exec(args)) => {
1733 reject_exec_global_flags(&args.args)?;
1734 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1735 delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
1736 }
1737 Some(Commands::Fleet(args)) => {
1738 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1739 delegate_to_tui(&cli, &resolved_runtime, tui_args("fleet", args))
1740 }
1741 Some(Commands::WorkflowTool(args)) => {
1742 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1743 delegate_to_tui(&cli, &resolved_runtime, tui_args("workflow-tool", args))
1744 }
1745 Some(Commands::LaneLogProxy(_)) => unreachable!("lane log proxy dispatched above"),
1746 Some(Commands::Workflow(args)) => {
1747 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1748 let config_path = store.path().to_path_buf();
1749 run_workflow_command(&cli, &resolved_runtime, &config_path, args)
1750 }
1751 Some(Commands::Lane(args)) => run_lane_command(args),
1752 Some(Commands::Review(args)) => {
1753 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1754 delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
1755 }
1756 Some(Commands::Apply(args)) => {
1757 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1758 delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
1759 }
1760 Some(Commands::Eval(args)) => {
1761 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1762 delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
1763 }
1764 Some(Commands::Mcp(args)) => {
1765 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1766 delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
1767 }
1768 Some(Commands::Features(args)) => {
1769 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1770 delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
1771 }
1772 Some(Commands::Serve(args)) => {
1773 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1774 delegate_server_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
1777 }
1778 Some(Commands::Web(args)) => {
1779 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1780 delegate_server_to_tui(&cli, &resolved_runtime, web_serve_passthrough(&args))
1781 }
1782 Some(Commands::Completions(args)) => {
1783 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1784 delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
1785 }
1786 Some(Commands::Login(args)) => run_login_command(&mut store, args),
1787 Some(Commands::Logout) => run_logout_command(&mut store),
1788 Some(Commands::Auth(args)) => match args.command {
1789 AuthCommand::XaiDevice => {
1790 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1791 delegate_to_tui(
1792 &cli,
1793 &resolved_runtime,
1794 vec!["auth".to_string(), "xai-device".to_string()],
1795 )
1796 }
1797 command => run_auth_command_with_runtime(&mut store, command, &runtime_overrides),
1798 },
1799 Some(Commands::McpServer) => run_mcp_server_command(&mut store),
1800 Some(Commands::Config(args)) => run_config_command(&mut store, args.command),
1801 Some(Commands::Model(args)) => {
1802 let resolved_runtime =
1806 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
1807 run_model_command(
1808 &mut store,
1809 args.command,
1810 runtime_overrides.provider,
1811 &resolved_runtime,
1812 )
1813 }
1814 Some(Commands::Thread(args)) => run_thread_command(args.command),
1815 Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
1816 Some(Commands::AppServer(args)) => {
1817 if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
1822 cli.config = args.config.clone();
1823 store = ConfigStore::load(cli.config.clone())?;
1824 }
1825 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1826 run_app_server_command(&cli, &resolved_runtime, args)
1827 }
1828 Some(Commands::Completion { shell }) => {
1829 let mut cmd = Cli::command();
1830 generate(shell, &mut cmd, "codewhale", &mut io::stdout());
1831 Ok(())
1832 }
1833 Some(Commands::Metrics(args)) => run_metrics_command(args),
1834 Some(Commands::Update(args)) => {
1835 #[cfg(not(target_env = "ohos"))]
1836 {
1837 update::run_update(args.beta, args.check, args.proxy)
1838 }
1839 #[cfg(target_env = "ohos")]
1840 {
1841 let _ = args;
1842 bail!("self-update is not supported on HarmonyOS/OpenHarmony yet");
1843 }
1844 }
1845 None => {
1846 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
1847 let forwarded = root_tui_passthrough(&cli)?;
1848 delegate_to_tui(&cli, &resolved_runtime, forwarded)
1849 }
1850 }
1851}
1852
1853fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
1854 let mut forwarded = Vec::new();
1855 if cli.continue_session {
1856 forwarded.push("--continue".to_string());
1857 }
1858
1859 let prompt =
1860 cli.prompt_flag
1861 .iter()
1862 .chain(cli.prompt.iter())
1863 .fold(String::new(), |mut acc, part| {
1864 if !acc.is_empty() {
1865 acc.push(' ');
1866 }
1867 acc.push_str(part);
1868 acc
1869 });
1870 if !prompt.is_empty() {
1871 if cli.continue_session {
1872 bail!(
1873 "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
1874 );
1875 }
1876 forwarded.push("--prompt".to_string());
1877 forwarded.push(prompt);
1878 }
1879
1880 Ok(forwarded)
1881}
1882
1883fn resolve_runtime_for_dispatch(
1884 store: &mut ConfigStore,
1885 runtime_overrides: &CliRuntimeOverrides,
1886) -> ResolvedRuntimeOptions {
1887 let runtime_secrets = Secrets::auto_detect();
1888 resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
1889}
1890
1891fn resolve_runtime_for_diagnostic_dispatch(
1899 store: &ConfigStore,
1900 runtime_overrides: &CliRuntimeOverrides,
1901) -> ResolvedRuntimeOptions {
1902 store.config.resolve_runtime_options(runtime_overrides)
1903}
1904
1905fn resolve_runtime_for_dispatch_with_secrets(
1906 store: &mut ConfigStore,
1907 runtime_overrides: &CliRuntimeOverrides,
1908 secrets: &Secrets,
1909) -> ResolvedRuntimeOptions {
1910 store
1911 .config
1912 .resolve_runtime_options_with_secrets(runtime_overrides, secrets)
1913}
1914
1915fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
1916 let mut forwarded = Vec::with_capacity(args.args.len() + 1);
1917 forwarded.push(command.to_string());
1918 forwarded.extend(args.args);
1919 forwarded
1920}
1921
1922fn setup_is_status_report(args: &TuiPassthroughArgs) -> bool {
1923 args.args.iter().any(|arg| arg == "--status")
1924}
1925
1926fn reject_exec_global_flags(args: &[String]) -> Result<()> {
1927 const GLOBAL_ONLY_FLAGS: &[&str] = &["--provider", "--model", "--api-key", "--base-url"];
1928
1929 for arg in args {
1930 if arg == "--" {
1931 break;
1932 }
1933 let flag = arg.split_once('=').map_or(arg.as_str(), |(flag, _)| flag);
1934 if GLOBAL_ONLY_FLAGS.contains(&flag) {
1935 bail!(
1936 "{flag} must be placed before `exec`.\n\nUse:\n codewhale {flag} <value> exec \"<prompt>\""
1937 );
1938 }
1939 }
1940
1941 Ok(())
1942}
1943
1944fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
1945 run_login_command_with_secrets(store, args, &Secrets::auto_detect())
1946}
1947
1948fn run_login_command_with_secrets(
1949 store: &mut ConfigStore,
1950 args: LoginArgs,
1951 secrets: &Secrets,
1952) -> Result<()> {
1953 let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into();
1954 store.config.provider = provider;
1955
1956 let api_key = match args.api_key {
1957 Some(v) => v,
1958 None => read_api_key_from_stdin()?,
1959 };
1960 let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
1961 let destination = if secret_store_saved {
1962 secrets.backend_name().to_string()
1963 } else {
1964 codewhale_config::quote_os_path(store.path())
1965 };
1966 if provider == ProviderKind::Deepseek {
1967 println!("logged in using API key mode (deepseek); saved key to {destination}");
1968 } else {
1969 println!(
1970 "logged in using API key mode ({}); saved key to {destination}",
1971 provider.as_str(),
1972 );
1973 }
1974 Ok(())
1975}
1976
1977fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
1978 run_logout_command_with_secrets(store, &Secrets::auto_detect())
1979}
1980
1981fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
1982 codewhale_config::with_xai_oauth_revocation_transaction(|| {
1983 run_logout_command_with_secrets_unlocked(store, secrets)
1984 })
1985}
1986
1987fn run_logout_command_with_secrets_unlocked(
1988 store: &mut ConfigStore,
1989 secrets: &Secrets,
1990) -> Result<()> {
1991 let original_config = store.config.clone();
1992 let active_provider = store.config.provider;
1993 store.config.api_key = None;
1994 for provider in ProviderKind::ALL {
1995 clear_provider_api_key_from_config(store, provider);
1996 store
1997 .config
1998 .providers
1999 .for_provider_mut(provider)
2000 .external_credentials = None;
2001 }
2002 let xai = store.config.providers.for_provider_mut(ProviderKind::Xai);
2003 xai.oauth_credential_generation = None;
2004 xai.auth_mode = None;
2005 store.config.auth_mode = None;
2006 if let Err(error) = store.save() {
2007 store.config = original_config;
2008 return Err(error);
2009 }
2010 clear_provider_api_key_from_keyring(secrets, active_provider);
2011 println!("logged out");
2012 Ok(())
2013}
2014
2015fn provider_slot(provider: ProviderKind) -> &'static str {
2017 match provider {
2018 ProviderKind::SiliconflowCN => "siliconflow",
2020 _ => provider.provider().id(),
2021 }
2022}
2023
2024#[cfg(test)]
2025fn no_keyring_secrets() -> Secrets {
2026 Secrets::new(std::sync::Arc::new(
2027 codewhale_secrets::InMemoryKeyringStore::new(),
2028 ))
2029}
2030
2031fn write_provider_api_key_to_config(
2032 store: &mut ConfigStore,
2033 provider: ProviderKind,
2034 api_key: &str,
2035) {
2036 prepare_provider_api_key_metadata(store, provider);
2037 store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
2038 if provider == ProviderKind::Deepseek {
2039 store.config.api_key = Some(api_key.to_string());
2040 }
2041}
2042
2043fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
2044 store.config.auth_mode = Some("api_key".to_string());
2045 let provider_config = store.config.providers.for_provider_mut(provider);
2046 provider_config.auth_mode = Some("api_key".to_string());
2047 provider_config.external_credentials = None;
2048 if provider == ProviderKind::Xai {
2049 provider_config.oauth_credential_generation = None;
2050 }
2051 if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() {
2052 store.config.default_text_model = Some(
2053 store
2054 .config
2055 .providers
2056 .deepseek
2057 .model
2058 .clone()
2059 .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
2060 );
2061 }
2062}
2063
2064fn persist_provider_api_key(
2067 store: &mut ConfigStore,
2068 secrets: &Secrets,
2069 provider: ProviderKind,
2070 api_key: &str,
2071) -> Result<bool> {
2072 if provider == ProviderKind::Xai {
2073 return codewhale_config::with_xai_oauth_revocation_transaction(|| {
2074 persist_provider_api_key_unlocked(store, secrets, provider, api_key)
2075 });
2076 }
2077 persist_provider_api_key_unlocked(store, secrets, provider, api_key)
2078}
2079
2080fn persist_provider_api_key_unlocked(
2081 store: &mut ConfigStore,
2082 secrets: &Secrets,
2083 provider: ProviderKind,
2084 api_key: &str,
2085) -> Result<bool> {
2086 let original_config = store.config.clone();
2087 prepare_provider_api_key_metadata(store, provider);
2088 let slot = provider_slot(provider);
2089 let prior_secret = secrets.get(slot);
2093 let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
2094 Ok(_) => match secrets.set(slot, api_key) {
2095 Ok(()) => {
2096 clear_provider_api_key_from_config(store, provider);
2097 true
2098 }
2099 Err(err) => {
2100 eprintln!(
2101 "warning: secret-store write failed for {}; using owner-only config fallback: {err}",
2102 provider_slot(provider)
2103 );
2104 write_provider_api_key_to_config(store, provider, api_key);
2105 false
2106 }
2107 },
2108 Err(error) => {
2109 eprintln!(
2110 "warning: secret-store snapshot failed for {slot}; using owner-only config fallback: {error}"
2111 );
2112 write_provider_api_key_to_config(store, provider, api_key);
2113 false
2114 }
2115 };
2116 if let Err(error) = store.save() {
2117 store.config = original_config;
2118 if secret_store_saved {
2119 let current = secrets
2120 .get(slot)
2121 .map_err(|rollback| anyhow::anyhow!(
2122 "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}"
2123 ))?;
2124 if current.as_deref() == Some(api_key) {
2125 match prior_secret.expect("snapshot succeeded before secret write") {
2126 Some(previous) => secrets.set(slot, &previous),
2127 None => secrets.delete(slot),
2128 }
2129 .map_err(|rollback| anyhow::anyhow!(
2130 "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}"
2131 ))?;
2132 }
2133 }
2134 return Err(error);
2135 }
2136 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
2137 Ok(secret_store_saved)
2138}
2139
2140fn clear_auth_provider(
2141 store: &mut ConfigStore,
2142 secrets: &Secrets,
2143 provider: ProviderKind,
2144) -> Result<()> {
2145 let slot = provider_slot(provider);
2146 let original_config = store.config.clone();
2147 clear_provider_api_key_from_config(store, provider);
2148 if provider == ProviderKind::Xai {
2149 let xai = store.config.providers.for_provider_mut(provider);
2150 xai.oauth_credential_generation = None;
2151 xai.auth_mode = None;
2152 xai.external_credentials = None;
2153 }
2154 if let Err(error) = store.save() {
2155 store.config = original_config;
2156 return Err(error);
2157 }
2158 clear_provider_api_key_from_keyring(secrets, provider);
2159 if provider == ProviderKind::Xai {
2160 println!("cleared xAI credentials from config, secret store, and owned OAuth storage");
2161 } else {
2162 println!("cleared API key for {slot} from config and secret store");
2163 }
2164 Ok(())
2165}
2166
2167fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
2168 store.config.providers.for_provider_mut(provider).api_key = None;
2169 if provider == ProviderKind::Deepseek {
2170 store.config.api_key = None;
2171 }
2172}
2173
2174fn provider_env_set(provider: ProviderKind) -> bool {
2175 provider_env_value(provider).is_some()
2176}
2177
2178fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
2179 provider.provider().env_vars()
2180}
2181
2182fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
2183 provider_env_vars(provider).iter().find_map(|var| {
2184 std::env::var(var)
2185 .ok()
2186 .filter(|value| !value.trim().is_empty())
2187 .map(|value| (*var, value))
2188 })
2189}
2190
2191fn openai_codex_auth_file_path() -> PathBuf {
2192 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
2193 let path = PathBuf::from(path);
2194 if !path.as_os_str().is_empty() {
2195 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2196 }
2197 }
2198
2199 let codex_home = std::env::var("CODEX_HOME")
2200 .map(PathBuf::from)
2201 .unwrap_or_else(|_| {
2202 dirs::home_dir()
2203 .unwrap_or_else(|| PathBuf::from("."))
2204 .join(".codex")
2205 });
2206 let path = codex_home.join("auth.json");
2207 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2208}
2209
2210fn grok_auth_file_path() -> PathBuf {
2211 for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
2212 if let Ok(path) = std::env::var(key) {
2213 let path = PathBuf::from(path.trim());
2214 if !path.as_os_str().is_empty() {
2215 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2216 }
2217 }
2218 }
2219 if let Ok(home) = std::env::var("GROK_HOME") {
2220 let home = PathBuf::from(home.trim());
2221 if !home.as_os_str().is_empty() {
2222 let path = home.join("auth.json");
2223 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2224 }
2225 }
2226 let path = dirs::home_dir()
2227 .unwrap_or_else(|| PathBuf::from("."))
2228 .join(".grok")
2229 .join("auth.json");
2230 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2231}
2232
2233fn external_credential_target(
2234 provider: ProviderKind,
2235 path_override: Option<PathBuf>,
2236) -> Result<(codewhale_config::ExternalCredentialSource, PathBuf)> {
2237 let (source, default_path) = match provider {
2238 ProviderKind::OpenaiCodex => (
2239 codewhale_config::ExternalCredentialSource::CodexCli,
2240 openai_codex_auth_file_path(),
2241 ),
2242 ProviderKind::Xai => (
2243 codewhale_config::ExternalCredentialSource::GrokCli,
2244 grok_auth_file_path(),
2245 ),
2246 ProviderKind::Moonshot => bail!(
2247 "Kimi is API-key-only in Codewhale. Create a key at https://platform.kimi.ai/console/api-keys; Kimi CLI OAuth import is unsupported."
2248 ),
2249 _ => bail!(
2250 "{} has no supported external CLI credential source",
2251 provider.as_str()
2252 ),
2253 };
2254 let path =
2255 codewhale_config::resolve_external_credential_path(path_override.unwrap_or(default_path))?;
2256 Ok((source, path))
2257}
2258
2259fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
2260 let slot = store
2261 .config
2262 .providers
2263 .for_provider(provider)
2264 .api_key
2265 .as_deref();
2266 let root = (provider == ProviderKind::Deepseek)
2267 .then_some(store.config.api_key.as_deref())
2268 .flatten();
2269 slot.or(root).filter(|v| !v.trim().is_empty())
2270}
2271
2272fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
2273 provider_config_api_key(store, provider).is_some()
2274}
2275
2276fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
2277 secrets
2278 .get(provider_slot(provider))
2279 .ok()
2280 .flatten()
2281 .filter(|v| !v.trim().is_empty())
2282}
2283
2284fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
2285 provider_keyring_api_key(secrets, provider).is_some()
2286}
2287
2288fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
2289 let _ = secrets.delete(provider_slot(provider));
2290}
2291
2292fn external_consent(
2293 store: &ConfigStore,
2294 provider: ProviderKind,
2295) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
2296 store
2297 .config
2298 .providers
2299 .for_provider(provider)
2300 .external_credentials
2301 .as_ref()
2302}
2303
2304fn external_read_consent(
2305 store: &ConfigStore,
2306 provider: ProviderKind,
2307) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
2308 let (source, expected_path) = external_credential_target(provider, None).ok()?;
2309 external_consent(store, provider)
2310 .filter(|consent| consent.read_grant(provider, source, &expected_path).is_ok())
2311}
2312
2313fn external_oauth_selected(store: &ConfigStore, provider: ProviderKind) -> bool {
2314 if external_read_consent(store, provider).is_none() {
2315 return false;
2316 }
2317 if provider == ProviderKind::OpenaiCodex {
2318 return true;
2319 }
2320 provider == ProviderKind::Xai
2321 && xai_oauth_mode_selected(store.config.providers.xai.auth_mode.as_deref())
2322}
2323
2324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2325enum XaiOAuthGenerationPointer {
2326 Absent,
2327 Valid,
2328 Invalid,
2329}
2330
2331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2332enum XaiAuthDiagnosticRoute {
2333 ApiKey,
2336 OwnedOAuth,
2339 NeedsRepair,
2342 ExternalConsent,
2345}
2346
2347#[derive(Debug, Clone)]
2348struct XaiAuthDiagnostics {
2349 base_url: String,
2350 official_endpoint: bool,
2351 auth_mode: Option<String>,
2352 oauth_selected: bool,
2353 generation: XaiOAuthGenerationPointer,
2354 route: XaiAuthDiagnosticRoute,
2355}
2356
2357impl XaiAuthDiagnostics {
2358 fn evaluates_runtime_api_key(&self) -> bool {
2362 matches!(
2363 self.route,
2364 XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::NeedsRepair
2365 )
2366 }
2367
2368 fn is_custom_endpoint(&self) -> bool {
2369 !self.official_endpoint
2370 }
2371}
2372
2373#[derive(Debug, Clone, Default)]
2377struct XaiRuntimeApiKey {
2378 source: Option<RuntimeApiKeySource>,
2379 last4: Option<String>,
2380}
2381
2382impl XaiRuntimeApiKey {
2383 fn source_name(&self) -> Option<&'static str> {
2384 match self.source {
2385 Some(RuntimeApiKeySource::Cli) => Some("cli"),
2386 Some(RuntimeApiKeySource::ConfigFile) => Some("config"),
2387 Some(RuntimeApiKeySource::Keyring) => Some("secret store"),
2388 Some(RuntimeApiKeySource::Env) => Some("env"),
2389 None => None,
2390 }
2391 }
2392
2393 fn source_with_last4(&self) -> Option<String> {
2394 self.source_name()
2395 .map(|source| match self.last4.as_deref() {
2396 Some(last4) => format!("{source} (last4: {last4})"),
2397 None => source.to_string(),
2398 })
2399 }
2400
2401 fn uses(&self, source: RuntimeApiKeySource) -> bool {
2402 self.source == Some(source)
2403 }
2404}
2405
2406fn runtime_overrides_for_provider(
2407 runtime_overrides: &CliRuntimeOverrides,
2408 provider: ProviderKind,
2409) -> CliRuntimeOverrides {
2410 let mut overrides = runtime_overrides.clone();
2411 overrides.provider = Some(provider);
2412 overrides
2413}
2414
2415fn xai_oauth_mode_selected(auth_mode: Option<&str>) -> bool {
2416 auth_mode.is_some_and(|mode| {
2417 matches!(
2418 mode.trim()
2419 .to_ascii_lowercase()
2420 .replace(['-', ' '], "_")
2421 .as_str(),
2422 "oauth"
2423 | "xai_oauth"
2424 | "xai"
2425 | "grok"
2426 | "grok_oauth"
2427 | "grok_cli"
2428 | "device"
2429 | "device_code"
2430 | "device_auth"
2431 )
2432 })
2433}
2434
2435fn xai_oauth_generation_pointer(store: &ConfigStore) -> XaiOAuthGenerationPointer {
2436 match store
2437 .config
2438 .providers
2439 .xai
2440 .oauth_credential_generation
2441 .as_deref()
2442 {
2443 None => XaiOAuthGenerationPointer::Absent,
2444 Some(generation) if codewhale_config::is_valid_xai_oauth_generation(generation) => {
2445 XaiOAuthGenerationPointer::Valid
2446 }
2447 Some(_) => XaiOAuthGenerationPointer::Invalid,
2448 }
2449}
2450
2451fn xai_auth_diagnostics(
2456 store: &ConfigStore,
2457 runtime_overrides: &CliRuntimeOverrides,
2458) -> XaiAuthDiagnostics {
2459 let mut route_overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai);
2463 route_overrides.api_key = None;
2464 route_overrides.auth_mode = Some("none".to_string());
2465 let resolved = store.config.resolve_runtime_options(&route_overrides);
2466 let official_endpoint =
2467 provider_base_url_is_official(ProviderKind::Xai, resolved.base_url.as_str());
2468 let auth_mode = store.config.providers.xai.auth_mode.clone();
2472 let generation = xai_oauth_generation_pointer(store);
2473 let oauth_selected = xai_oauth_mode_selected(auth_mode.as_deref());
2474 let route = if !official_endpoint || !oauth_selected {
2475 XaiAuthDiagnosticRoute::ApiKey
2476 } else {
2477 match generation {
2478 XaiOAuthGenerationPointer::Valid => XaiAuthDiagnosticRoute::OwnedOAuth,
2479 XaiOAuthGenerationPointer::Invalid => XaiAuthDiagnosticRoute::NeedsRepair,
2480 XaiOAuthGenerationPointer::Absent
2481 if external_read_consent(store, ProviderKind::Xai).is_some() =>
2482 {
2483 XaiAuthDiagnosticRoute::ExternalConsent
2484 }
2485 XaiOAuthGenerationPointer::Absent => XaiAuthDiagnosticRoute::ApiKey,
2486 }
2487 };
2488
2489 XaiAuthDiagnostics {
2490 base_url: resolved.base_url,
2491 official_endpoint,
2492 auth_mode,
2493 oauth_selected,
2494 generation,
2495 route,
2496 }
2497}
2498
2499fn xai_runtime_api_key(
2504 store: &ConfigStore,
2505 secrets: &Secrets,
2506 runtime_overrides: &CliRuntimeOverrides,
2507) -> XaiRuntimeApiKey {
2508 let resolved = store.config.resolve_runtime_options_with_secrets(
2509 &runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai),
2510 secrets,
2511 );
2512 debug_assert_eq!(resolved.provider, ProviderKind::Xai);
2513 XaiRuntimeApiKey {
2514 source: resolved.api_key_source,
2515 last4: resolved.api_key.as_deref().map(last4_label),
2516 }
2517}
2518
2519fn api_key_source_name(
2520 config_key: Option<&str>,
2521 keyring_key: Option<&str>,
2522 env_key: Option<&(&'static str, String)>,
2523) -> Option<&'static str> {
2524 if config_key.is_some() {
2525 Some("config")
2526 } else if keyring_key.is_some() {
2527 Some("secret store")
2528 } else if env_key.is_some() {
2529 Some("env")
2530 } else {
2531 None
2532 }
2533}
2534
2535fn xai_status_summary_source(
2536 diagnostics: &XaiAuthDiagnostics,
2537 api_key: Option<&XaiRuntimeApiKey>,
2538) -> String {
2539 match diagnostics.route {
2540 XaiAuthDiagnosticRoute::OwnedOAuth => {
2541 "Codewhale-owned OAuth configured/unprobed (valid generation pointer)".to_string()
2542 }
2543 XaiAuthDiagnosticRoute::NeedsRepair => {
2544 let api_key = api_key
2545 .and_then(XaiRuntimeApiKey::source_name)
2546 .unwrap_or("no runtime-effective API key");
2547 format!("needs repair (invalid OAuth generation pointer; API-key fallback: {api_key})")
2548 }
2549 XaiAuthDiagnosticRoute::ExternalConsent => {
2550 "external consent configured/unprobed".to_string()
2551 }
2552 XaiAuthDiagnosticRoute::ApiKey => api_key
2553 .and_then(XaiRuntimeApiKey::source_name)
2554 .unwrap_or("unset")
2555 .to_string(),
2556 }
2557}
2558
2559fn xai_credential_route_label(
2560 diagnostics: &XaiAuthDiagnostics,
2561 api_key: Option<&XaiRuntimeApiKey>,
2562) -> String {
2563 match diagnostics.route {
2564 XaiAuthDiagnosticRoute::OwnedOAuth => {
2565 "Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
2566 .to_string()
2567 }
2568 XaiAuthDiagnosticRoute::NeedsRepair => {
2569 let api_key = api_key
2570 .and_then(XaiRuntimeApiKey::source_with_last4)
2571 .unwrap_or_else(|| "no runtime-effective API key".to_string());
2572 format!(
2573 "xAI OAuth needs repair (invalid Codewhale-owned generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
2574 )
2575 }
2576 XaiAuthDiagnosticRoute::ExternalConsent => {
2577 "external read-only consent configured/unprobed".to_string()
2578 }
2579 XaiAuthDiagnosticRoute::ApiKey => api_key
2580 .and_then(XaiRuntimeApiKey::source_with_last4)
2581 .unwrap_or_else(|| "missing".to_string()),
2582 }
2583}
2584
2585fn xai_table_storage_status(
2586 api_key: Option<&XaiRuntimeApiKey>,
2587 source: RuntimeApiKeySource,
2588) -> &'static str {
2589 match api_key {
2590 Some(api_key) if api_key.uses(source) => "set",
2591 Some(_) => "-",
2592 None => "unprobed",
2595 }
2596}
2597
2598fn xai_list_storage_status(
2599 api_key: Option<&XaiRuntimeApiKey>,
2600 source: RuntimeApiKeySource,
2601) -> &'static str {
2602 match api_key {
2603 Some(api_key) if api_key.uses(source) => "yes",
2604 Some(_) => "no",
2605 None => "?",
2606 }
2607}
2608
2609fn xai_list_route(
2610 diagnostics: &XaiAuthDiagnostics,
2611 api_key: Option<&XaiRuntimeApiKey>,
2612) -> &'static str {
2613 match diagnostics.route {
2614 XaiAuthDiagnosticRoute::OwnedOAuth => "owned-oauth-configured",
2615 XaiAuthDiagnosticRoute::NeedsRepair => "needs-repair",
2616 XaiAuthDiagnosticRoute::ExternalConsent => "external-consent-configured",
2617 XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(|api_key| api_key.source) {
2618 Some(RuntimeApiKeySource::Cli) => "cli",
2619 Some(RuntimeApiKeySource::ConfigFile) => "config",
2620 Some(RuntimeApiKeySource::Keyring) => "store",
2621 Some(RuntimeApiKeySource::Env) => "env",
2622 None => "missing",
2623 },
2624 }
2625}
2626
2627fn xai_storage_detail(
2628 diagnostics: &XaiAuthDiagnostics,
2629 api_key: Option<&XaiRuntimeApiKey>,
2630 source: RuntimeApiKeySource,
2631) -> String {
2632 match api_key {
2633 Some(api_key) if api_key.uses(source) => api_key
2634 .last4
2635 .as_deref()
2636 .map(|last4| format!("runtime-effective, last4: {last4}"))
2637 .unwrap_or_else(|| "runtime-effective".to_string()),
2638 Some(_) if diagnostics.is_custom_endpoint() => {
2639 "not eligible for this custom xAI endpoint".to_string()
2640 }
2641 Some(_) => "not selected by the runtime resolver".to_string(),
2642 None if diagnostics.evaluates_runtime_api_key() && diagnostics.is_custom_endpoint() => {
2643 "not eligible for this custom xAI endpoint".to_string()
2644 }
2645 None if diagnostics.evaluates_runtime_api_key() => {
2646 "not set for this runtime route".to_string()
2647 }
2648 None => "unprobed (structural OAuth/consent route)".to_string(),
2649 }
2650}
2651
2652fn xai_lookup_order(diagnostics: &XaiAuthDiagnostics) -> String {
2653 match diagnostics.route {
2654 XaiAuthDiagnosticRoute::OwnedOAuth => {
2655 "lookup order: configured Codewhale-owned OAuth generation (storage unprobed); Grok CLI consent blocked".to_string()
2656 }
2657 XaiAuthDiagnosticRoute::NeedsRepair => {
2658 "lookup order: invalid Codewhale-owned OAuth generation blocks Grok CLI consent; runtime-effective API-key fallback: CLI -> config -> secret store -> env".to_string()
2659 }
2660 XaiAuthDiagnosticRoute::ExternalConsent => {
2661 "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)".to_string()
2662 }
2663 XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
2664 "lookup order: endpoint-bound API key only for this custom xAI endpoint (explicit CLI key or route-bound config key)".to_string()
2665 }
2666 XaiAuthDiagnosticRoute::ApiKey => {
2667 "lookup order: CLI -> config -> secret store -> env".to_string()
2668 }
2669 }
2670}
2671
2672fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApiKey>) -> String {
2673 match diagnostics.route {
2674 XaiAuthDiagnosticRoute::OwnedOAuth => {
2675 "xai: configured (source: Codewhale-owned OAuth generation; valid pointer; storage unprobed)".to_string()
2676 }
2677 XaiAuthDiagnosticRoute::NeedsRepair => {
2678 let api_key = match api_key.and_then(XaiRuntimeApiKey::source_name) {
2679 Some("config") => "config-file".to_string(),
2680 Some("secret store") => "secret-store".to_string(),
2681 Some("env") => "env".to_string(),
2682 Some("cli") => "cli".to_string(),
2683 Some(other) => other.to_string(),
2684 None => "no runtime-effective API key".to_string(),
2685 };
2686 format!(
2687 "xai: needs repair (invalid Codewhale-owned OAuth generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
2688 )
2689 }
2690 XaiAuthDiagnosticRoute::ExternalConsent => {
2691 "xai: configured (source: external read-only consent; availability unprobed)".to_string()
2692 }
2693 XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(XaiRuntimeApiKey::source_name) {
2694 Some("config") => "xai: set (source: config-file)".to_string(),
2695 Some("secret store") => "xai: set (source: secret-store)".to_string(),
2696 Some("env") => "xai: set (source: env)".to_string(),
2697 Some("cli") => "xai: set (source: cli)".to_string(),
2698 Some(other) => format!("xai: set (source: {other})"),
2699 None => "xai: not set".to_string(),
2700 },
2701 }
2702}
2703
2704fn auth_get_line_with_runtime(
2705 store: &ConfigStore,
2706 secrets: &Secrets,
2707 provider: ProviderKind,
2708 runtime_overrides: &CliRuntimeOverrides,
2709) -> String {
2710 let slot = provider_slot(provider);
2711 if provider == ProviderKind::Xai {
2712 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
2713 let api_key = diagnostics
2714 .evaluates_runtime_api_key()
2715 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
2716 return xai_get_line(&diagnostics, api_key.as_ref());
2717 }
2718
2719 let config_key = provider_config_api_key(store, provider);
2720 let keyring_key = config_key
2721 .is_none()
2722 .then(|| provider_keyring_api_key(secrets, provider))
2723 .flatten();
2724 let env_key = provider_env_value(provider);
2725
2726 match api_key_source_name(config_key, keyring_key.as_deref(), env_key.as_ref()) {
2727 Some("config") => format!("{slot}: set (source: config-file)"),
2728 Some("secret store") => format!("{slot}: set (source: secret-store)"),
2729 Some("env") => format!("{slot}: set (source: env)"),
2730 Some(other) => format!("{slot}: set (source: {other})"),
2731 None => format!("{slot}: not set"),
2732 }
2733}
2734
2735#[cfg(test)]
2736fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
2737 auth_status_all_providers_with_runtime(store, secrets, &CliRuntimeOverrides::default())
2738}
2739
2740fn auth_status_all_providers_with_runtime(
2741 store: &ConfigStore,
2742 secrets: &Secrets,
2743 runtime_overrides: &CliRuntimeOverrides,
2744) -> Vec<String> {
2745 let active_provider = store.config.provider;
2746 let mut lines = Vec::new();
2747 lines.push(format!(
2748 "active provider: {} (set via config or CODEWHALE_PROVIDER)",
2749 active_provider.as_str()
2750 ));
2751 lines.push(String::new());
2752 lines.push(format!(
2753 "{:<14} {:<8} {:<10} {:<8} {}",
2754 "provider", "config", "keyring", "env", "status"
2755 ));
2756 lines.push("-".repeat(70));
2757
2758 for provider in ProviderKind::ALL {
2759 if provider == ProviderKind::Xai {
2760 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
2761 let api_key = diagnostics
2762 .evaluates_runtime_api_key()
2763 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
2764 let active_marker = if provider == active_provider {
2765 " *"
2766 } else {
2767 ""
2768 };
2769 lines.push(format!(
2770 "{:<14} {:<8} {:<10} {:<8} {}{}",
2771 provider.as_str(),
2772 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
2773 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
2774 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
2775 xai_status_summary_source(&diagnostics, api_key.as_ref()),
2776 active_marker
2777 ));
2778 continue;
2779 }
2780
2781 let config_key = provider_config_api_key(store, provider);
2782 let keyring_key = provider_keyring_api_key(secrets, provider);
2783 let env_key = provider_env_value(provider);
2784 let external_selected = external_oauth_selected(store, provider);
2785
2786 let config_status = config_key.map(|_| "set").unwrap_or("-");
2787 let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
2788 let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
2789
2790 let source = if provider == ProviderKind::OpenaiCodex {
2791 if env_key.is_some() {
2795 "env".to_string()
2796 } else if external_selected {
2797 "external consent (not probed)".to_string()
2798 } else {
2799 "unset".to_string()
2800 }
2801 } else if external_selected {
2802 "external consent (not probed)".to_string()
2803 } else if config_key.is_some() {
2804 "config".to_string()
2805 } else if keyring_key.is_some() {
2806 "keyring".to_string()
2807 } else if env_key.is_some() {
2808 "env".to_string()
2809 } else {
2810 "unset".to_string()
2811 };
2812
2813 let active_marker = if provider == active_provider {
2814 " *"
2815 } else {
2816 ""
2817 };
2818
2819 lines.push(format!(
2820 "{:<14} {:<8} {:<10} {:<8} {}{}",
2821 provider.as_str(),
2822 config_status,
2823 keyring_status,
2824 env_status,
2825 source,
2826 active_marker
2827 ));
2828 }
2829
2830 lines.push(String::new());
2831 lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
2832 lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
2833 lines
2834}
2835
2836#[cfg(test)]
2837fn auth_list_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
2838 auth_list_lines_with_runtime(store, secrets, &CliRuntimeOverrides::default())
2839}
2840
2841fn auth_list_lines_with_runtime(
2842 store: &ConfigStore,
2843 secrets: &Secrets,
2844 runtime_overrides: &CliRuntimeOverrides,
2845) -> Vec<String> {
2846 let mut lines = Vec::new();
2847 lines.push("provider config store env route".to_string());
2848 for provider in ProviderKind::ALL {
2849 let slot = provider_slot(provider);
2850 if provider == ProviderKind::Xai {
2851 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
2852 let api_key = diagnostics
2853 .evaluates_runtime_api_key()
2854 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
2855 lines.push(format!(
2856 "{slot:<12} {} {} {} {}",
2857 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
2858 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
2859 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
2860 xai_list_route(&diagnostics, api_key.as_ref())
2861 ));
2862 continue;
2863 }
2864
2865 let file = provider_config_set(store, provider);
2866 let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
2867 let env = provider_env_set(provider);
2868 let external_selected = external_oauth_selected(store, provider);
2869 let active = if provider == ProviderKind::OpenaiCodex {
2870 if env {
2871 "env"
2872 } else if external_selected {
2873 "external-consent"
2874 } else {
2875 "missing"
2876 }
2877 } else if external_selected {
2878 "external-consent"
2879 } else if file {
2880 "config"
2881 } else if keyring == Some(true) {
2882 "store"
2883 } else if env {
2884 "env"
2885 } else {
2886 "missing"
2887 };
2888 lines.push(format!(
2889 "{slot:<12} {} {} {} {active}",
2890 yes_no(file),
2891 keyring_status_short(keyring),
2892 yes_no(env)
2893 ));
2894 }
2895 lines
2896}
2897
2898#[cfg(test)]
2899fn auth_status_lines_for_provider(
2900 store: &ConfigStore,
2901 secrets: &Secrets,
2902 provider: ProviderKind,
2903) -> Vec<String> {
2904 auth_status_lines_for_provider_with_runtime(
2905 store,
2906 secrets,
2907 provider,
2908 &CliRuntimeOverrides::default(),
2909 )
2910}
2911
2912fn auth_status_lines_for_provider_with_runtime(
2913 store: &ConfigStore,
2914 secrets: &Secrets,
2915 provider: ProviderKind,
2916 runtime_overrides: &CliRuntimeOverrides,
2917) -> Vec<String> {
2918 if provider == ProviderKind::Xai {
2919 return xai_auth_status_lines_for_provider(store, secrets, runtime_overrides);
2920 }
2921
2922 let config_key = provider_config_api_key(store, provider);
2923 let keyring_key = provider_keyring_api_key(secrets, provider);
2924 let env_key = provider_env_value(provider);
2925 let external = external_consent(store, provider);
2926 let external_selected = external_oauth_selected(store, provider);
2927
2928 let active_label = {
2929 let active_source = if provider == ProviderKind::OpenaiCodex {
2930 if env_key.is_some() {
2931 "env"
2932 } else if external_selected {
2933 "external read-only consent (availability not probed)"
2934 } else {
2935 "missing"
2936 }
2937 } else if external_selected {
2938 "external read-only consent (availability not probed)"
2939 } else if config_key.is_some() {
2940 "config"
2941 } else if keyring_key.is_some() {
2942 "secret store"
2943 } else if env_key.is_some() {
2944 "env"
2945 } else {
2946 "missing"
2947 };
2948 let active_last4 = if provider == ProviderKind::OpenaiCodex {
2949 env_key.as_ref().map(|(_, value)| last4_label(value))
2950 } else {
2951 config_key
2952 .map(last4_label)
2953 .or_else(|| keyring_key.as_deref().map(last4_label))
2954 .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
2955 };
2956 active_last4
2957 .map(|last4| format!("{active_source} (last4: {last4})"))
2958 .unwrap_or_else(|| active_source.to_string())
2959 };
2960
2961 let env_var_label = env_key
2962 .as_ref()
2963 .map(|(name, _)| (*name).to_string())
2964 .unwrap_or_else(|| provider_env_vars(provider).join("/"));
2965 let env_status = env_key
2966 .as_ref()
2967 .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
2968 .unwrap_or_else(|| "unset".to_string());
2969
2970 let is_active = provider == store.config.provider;
2971 let active_marker = if is_active { " (active provider)" } else { "" };
2972
2973 let provider_cfg = store.config.providers.for_provider(provider);
2974 let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
2975 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
2976
2977 let lookup_order = if provider == ProviderKind::OpenaiCodex {
2978 "lookup order: env -> consent-gated exact Codex CLI file".to_string()
2979 } else {
2980 "lookup order: config -> secret store -> env".to_string()
2981 };
2982 let auth_mode = if provider == ProviderKind::OpenaiCodex {
2983 "codex_oauth".to_string()
2984 } else {
2985 provider_cfg
2986 .auth_mode
2987 .as_deref()
2988 .or(store.config.auth_mode.as_deref())
2989 .unwrap_or("api_key")
2990 .to_string()
2991 };
2992
2993 let mut lines = vec![
2994 format!("provider: {}{}", provider.as_str(), active_marker),
2995 format!("route: {}", base_url),
2996 format!("model: {}", model),
2997 format!("auth mode: {auth_mode}"),
2998 format!("active source: {active_label}"),
2999 lookup_order,
3000 format!(
3001 "config file: {} ({})",
3002 codewhale_config::quote_os_path(store.path()),
3003 source_status(config_key, "missing")
3004 ),
3005 format!(
3006 "secret store: {} ({})",
3007 secrets.backend_name(),
3008 source_status(keyring_key.as_deref(), "missing")
3009 ),
3010 format!("env var: {env_var_label} ({env_status})"),
3011 ];
3012
3013 if let Ok((source, expected_path)) = external_credential_target(provider, None) {
3014 let status = codewhale_config::external_credential_consent_status(
3015 external,
3016 provider,
3017 source,
3018 &expected_path,
3019 store.config.provider,
3020 );
3021 lines.push(format!(
3022 "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
3023 status.access.as_str(),
3024 status.provider,
3025 status.source.as_str(),
3026 status.owner,
3027 codewhale_config::quote_os_path(&status.path),
3028 status.consent_version,
3029 status.route_state,
3030 status.scope_valid,
3031 status.ambient_path_changed,
3032 ));
3033 lines.push(format!("semantics: {}", status.semantics));
3034 lines.push(format!("revoke: {}", status.revoke_command));
3035 if let Some(warning) = status.ambient_path_warning() {
3036 lines.push(warning);
3037 }
3038 } else {
3039 lines.push("external credentials: disabled (no file was probed)".to_string());
3040 }
3041 lines
3042}
3043
3044fn xai_auth_status_lines_for_provider(
3045 store: &ConfigStore,
3046 secrets: &Secrets,
3047 runtime_overrides: &CliRuntimeOverrides,
3048) -> Vec<String> {
3049 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3050 let api_key = diagnostics
3051 .evaluates_runtime_api_key()
3052 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3053 let external = external_consent(store, ProviderKind::Xai);
3054 let selected_marker = if store.config.provider == ProviderKind::Xai {
3055 " (selected provider)"
3056 } else {
3057 ""
3058 };
3059 let provider_cfg = &store.config.providers.xai;
3060 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3061 let auth_mode = diagnostics.auth_mode.as_deref().unwrap_or("api_key");
3062
3063 let mut lines = vec![
3064 format!("provider: xai{selected_marker}"),
3065 format!("route: {}", diagnostics.base_url),
3066 format!("model: {model}"),
3067 format!("auth mode: {auth_mode}"),
3068 format!(
3069 "credential route: {}",
3070 xai_credential_route_label(&diagnostics, api_key.as_ref())
3071 ),
3072 xai_lookup_order(&diagnostics),
3073 format!(
3074 "config file: {} ({})",
3075 codewhale_config::quote_os_path(store.path()),
3076 xai_storage_detail(
3077 &diagnostics,
3078 api_key.as_ref(),
3079 RuntimeApiKeySource::ConfigFile
3080 )
3081 ),
3082 format!(
3083 "secret store: {} ({})",
3084 secrets.backend_name(),
3085 xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Keyring)
3086 ),
3087 format!(
3088 "env var: {} ({})",
3089 provider_env_vars(ProviderKind::Xai).join("/"),
3090 xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Env)
3091 ),
3092 format!(
3093 "endpoint policy: {}",
3094 if diagnostics.official_endpoint {
3095 "official xAI endpoint"
3096 } else {
3097 "custom xAI endpoint; API-key-only (owned and external OAuth are inactive)"
3098 }
3099 ),
3100 ];
3101
3102 lines.push(match diagnostics.generation {
3103 XaiOAuthGenerationPointer::Absent => "xAI OAuth generation: absent".to_string(),
3104 XaiOAuthGenerationPointer::Valid
3105 if diagnostics.route == XaiAuthDiagnosticRoute::OwnedOAuth =>
3106 {
3107 "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
3108 .to_string()
3109 }
3110 XaiOAuthGenerationPointer::Valid => {
3111 "xAI OAuth generation: valid but inactive for this route".to_string()
3112 }
3113 XaiOAuthGenerationPointer::Invalid => {
3114 "xAI OAuth generation: invalid Codewhale-owned pointer".to_string()
3115 }
3116 });
3117
3118 match diagnostics.route {
3119 XaiAuthDiagnosticRoute::OwnedOAuth => {
3120 lines.push(
3121 "external credentials: blocked by the configured Codewhale-owned xAI OAuth generation (file not probed)"
3122 .to_string(),
3123 );
3124 return lines;
3125 }
3126 XaiAuthDiagnosticRoute::NeedsRepair => {
3127 lines.push(
3128 "external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer (file not probed)"
3129 .to_string(),
3130 );
3131 lines.push(
3132 "repair: run `codewhale auth xai-device` to replace the owned generation, or switch [providers.xai] auth_mode to \"api_key\" and remove oauth_credential_generation. Grok CLI consent remains blocked until the pointer is absent."
3133 .to_string(),
3134 );
3135 return lines;
3136 }
3137 XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
3138 lines.push(
3139 "external credentials: unavailable on a custom xAI endpoint (API-key-only; file not probed)"
3140 .to_string(),
3141 );
3142 return lines;
3143 }
3144 XaiAuthDiagnosticRoute::ApiKey if !diagnostics.oauth_selected && external.is_some() => {
3145 lines.push(
3146 "external credentials: configured but inactive because xAI OAuth mode is not selected (file not probed)"
3147 .to_string(),
3148 );
3149 return lines;
3150 }
3151 XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::ExternalConsent => {}
3152 }
3153
3154 if let Ok((source, expected_path)) = external_credential_target(ProviderKind::Xai, None) {
3155 let status = codewhale_config::external_credential_consent_status(
3156 external,
3157 ProviderKind::Xai,
3158 source,
3159 &expected_path,
3160 store.config.provider,
3161 );
3162 lines.push(format!(
3163 "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
3164 status.access.as_str(),
3165 status.provider,
3166 status.source.as_str(),
3167 status.owner,
3168 codewhale_config::quote_os_path(&status.path),
3169 status.consent_version,
3170 status.route_state,
3171 status.scope_valid,
3172 status.ambient_path_changed,
3173 ));
3174 lines.push(format!("semantics: {}", status.semantics));
3175 lines.push(format!("revoke: {}", status.revoke_command));
3176 if let Some(warning) = status.ambient_path_warning() {
3177 lines.push(warning);
3178 }
3179 } else {
3180 lines.push("external credentials: disabled (no file was probed)".to_string());
3181 }
3182 lines
3183}
3184
3185fn source_status(value: Option<&str>, missing_label: &str) -> String {
3186 value
3187 .map(|v| format!("set, last4: {}", last4_label(v)))
3188 .unwrap_or_else(|| missing_label.to_string())
3189}
3190
3191fn last4_label(value: &str) -> String {
3192 let trimmed = value.trim();
3193 let chars: Vec<char> = trimmed.chars().collect();
3194 if chars.len() <= 4 {
3195 return "<redacted>".to_string();
3196 }
3197 let last4: String = chars[chars.len() - 4..].iter().collect();
3198 format!("...{last4}")
3199}
3200
3201fn run_auth_command_with_runtime(
3202 store: &mut ConfigStore,
3203 command: AuthCommand,
3204 runtime_overrides: &CliRuntimeOverrides,
3205) -> Result<()> {
3206 run_auth_command_with_secrets_and_runtime(
3207 store,
3208 command,
3209 &Secrets::auto_detect(),
3210 runtime_overrides,
3211 )
3212}
3213
3214#[cfg(test)]
3215fn run_auth_command_with_secrets(
3216 store: &mut ConfigStore,
3217 command: AuthCommand,
3218 secrets: &Secrets,
3219) -> Result<()> {
3220 run_auth_command_with_secrets_and_runtime(
3221 store,
3222 command,
3223 secrets,
3224 &CliRuntimeOverrides::default(),
3225 )
3226}
3227
3228fn run_auth_command_with_secrets_and_runtime(
3229 store: &mut ConfigStore,
3230 command: AuthCommand,
3231 secrets: &Secrets,
3232 runtime_overrides: &CliRuntimeOverrides,
3233) -> Result<()> {
3234 match command {
3235 AuthCommand::XaiDevice => {
3236 bail!("xAI device authentication must be delegated to codewhale-tui")
3237 }
3238 AuthCommand::ExternalConsent {
3239 provider,
3240 mode,
3241 path,
3242 yes,
3243 } => {
3244 let provider: ProviderKind = provider.into();
3245 let (source, path) = external_credential_target(provider, path)?;
3246 let preview = external_consent_preview_lines(provider, source, &path);
3247 for line in &preview {
3248 println!("{line}");
3249 }
3250 if mode == ExternalCredentialModeArg::Managed {
3251 bail!(
3252 "managed external credential access is unsupported in v0.9.1: no provider has a reviewed schema-safe preservation adapter. Use --mode read-only, or use Codewhale-owned login/API-key storage."
3253 );
3254 }
3255 confirm_external_consent(yes)?;
3256 let path_value = path.to_str().context(
3257 "external credential path cannot be persisted losslessly because it is not valid UTF-8",
3258 )?;
3259 let provider_key = provider.provider().provider_config_key();
3260 codewhale_config::mutate_config_document(store.path(), |document| {
3261 if matches!(provider, ProviderKind::OpenaiCodex | ProviderKind::Xai) {
3262 codewhale_config::set_config_document_value(
3263 document,
3264 &["providers", provider_key, "auth_mode"],
3265 "oauth",
3266 )?;
3267 }
3268 let prefix = &["providers", provider_key, "external_credentials"];
3269 codewhale_config::set_config_document_value(
3270 document,
3271 &[prefix[0], prefix[1], prefix[2], "access"],
3272 "read_only",
3273 )?;
3274 codewhale_config::set_config_document_value(
3275 document,
3276 &[prefix[0], prefix[1], prefix[2], "provider"],
3277 provider.as_str(),
3278 )?;
3279 codewhale_config::set_config_document_value(
3280 document,
3281 &[prefix[0], prefix[1], prefix[2], "source"],
3282 source.as_str(),
3283 )?;
3284 codewhale_config::set_config_document_value(
3285 document,
3286 &[prefix[0], prefix[1], prefix[2], "path"],
3287 path_value,
3288 )?;
3289 codewhale_config::set_config_document_value(
3290 document,
3291 &[prefix[0], prefix[1], prefix[2], "consent_version"],
3292 i64::from(codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION),
3293 )
3294 })?;
3295 store
3296 .reload()
3297 .context("external consent was saved, but config reload failed")?;
3298 println!(
3299 "saved read-only external credential consent: provider={}, owner={}, path={}, consent_version={} ({})",
3300 provider.as_str(),
3301 source.as_str(),
3302 codewhale_config::quote_os_path(&path),
3303 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION,
3304 codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
3305 );
3306 println!(
3307 "revoke with: codewhale auth external-revoke --provider {}",
3308 provider.as_str()
3309 );
3310 Ok(())
3311 }
3312 AuthCommand::ExternalRevoke { provider } => {
3313 let provider: ProviderKind = provider.into();
3314 let provider_key = provider.provider().provider_config_key();
3315 codewhale_config::mutate_config_document(store.path(), |document| {
3316 codewhale_config::unset_config_document_value(
3317 document,
3318 &["providers", provider_key, "external_credentials"],
3319 )?;
3320 Ok(())
3321 })?;
3322 store
3323 .reload()
3324 .context("external consent was revoked, but config reload failed")?;
3325 println!(
3326 "external credential access disabled for {}",
3327 provider.as_str()
3328 );
3329 Ok(())
3330 }
3331 AuthCommand::Status { provider } => {
3332 match provider {
3333 Some(p) => {
3334 let provider: ProviderKind = p.into();
3335 for line in auth_status_lines_for_provider_with_runtime(
3336 store,
3337 secrets,
3338 provider,
3339 runtime_overrides,
3340 ) {
3341 println!("{line}");
3342 }
3343 }
3344 None => {
3345 for line in
3346 auth_status_all_providers_with_runtime(store, secrets, runtime_overrides)
3347 {
3348 println!("{line}");
3349 }
3350 }
3351 }
3352 Ok(())
3353 }
3354 AuthCommand::Set {
3355 provider,
3356 api_key,
3357 api_key_stdin,
3358 } => {
3359 let provider: ProviderKind = provider.into();
3360 let slot = provider_slot(provider);
3361 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
3362 let provider_cfg = store.config.providers.for_provider_mut(provider);
3363 if provider_cfg.base_url.is_none() {
3364 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
3365 }
3366 store.save()?;
3367 println!(
3368 "configured {slot} provider in {} (API key optional)",
3369 store.path().display()
3370 );
3371 return Ok(());
3372 }
3373 let api_key = match (api_key, api_key_stdin) {
3374 (Some(v), _) => v,
3375 (None, true) => read_api_key_from_stdin()?,
3376 (None, false) => prompt_api_key(slot)?,
3377 };
3378 let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
3379 if secret_store_saved {
3381 println!(
3382 "saved API key for {slot} to {} (config contains metadata only)",
3383 secrets.backend_name(),
3384 );
3385 } else {
3386 println!("saved API key for {slot} to {}", store.path().display());
3387 }
3388 Ok(())
3389 }
3390 AuthCommand::Get { provider } => {
3391 let provider: ProviderKind = provider.into();
3392 println!(
3393 "{}",
3394 auth_get_line_with_runtime(store, secrets, provider, runtime_overrides)
3395 );
3396 Ok(())
3397 }
3398 AuthCommand::Clear { provider } => {
3399 let provider: ProviderKind = provider.into();
3400 if provider == ProviderKind::Xai {
3401 codewhale_config::with_xai_oauth_revocation_transaction(|| {
3402 clear_auth_provider(store, secrets, provider)
3403 })
3404 } else {
3405 clear_auth_provider(store, secrets, provider)
3406 }
3407 }
3408 AuthCommand::List => {
3409 for line in auth_list_lines_with_runtime(store, secrets, runtime_overrides) {
3410 println!("{line}");
3411 }
3412 Ok(())
3413 }
3414 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
3415 }
3416}
3417
3418fn external_consent_preview_lines(
3419 provider: ProviderKind,
3420 source: codewhale_config::ExternalCredentialSource,
3421 path: &Path,
3422) -> Vec<String> {
3423 vec![
3424 "External credential consent preview (nothing has been saved):".to_string(),
3425 format!(" provider: {}", provider.as_str()),
3426 format!(
3427 " owning CLI: {} ({})",
3428 source.owner_label(),
3429 source.as_str()
3430 ),
3431 format!(
3432 " exact resolved path: {}",
3433 codewhale_config::quote_os_path(path)
3434 ),
3435 format!(
3436 " access: read_only ({})",
3437 codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS
3438 ),
3439 " managed: unavailable (no reviewed schema-safe preservation adapter)".to_string(),
3440 format!(
3441 " revoke: codewhale auth external-revoke --provider {}",
3442 provider.as_str()
3443 ),
3444 ]
3445}
3446
3447fn confirm_external_consent(yes: bool) -> Result<()> {
3448 use std::io::IsTerminal;
3449
3450 if yes {
3451 return Ok(());
3452 }
3453 if !std::io::stdin().is_terminal() {
3454 bail!(
3455 "external credential consent was not saved: non-interactive use requires explicit --yes after reviewing the preview"
3456 );
3457 }
3458 confirm_external_consent_answer(&mut std::io::stdin().lock(), &mut std::io::stdout().lock())
3459}
3460
3461fn confirm_external_consent_answer(
3462 reader: &mut impl std::io::BufRead,
3463 writer: &mut impl std::io::Write,
3464) -> Result<()> {
3465 write!(writer, "Type 'yes' to grant this exact read-only access: ")?;
3466 writer.flush()?;
3467 let mut answer = String::new();
3468 reader
3469 .read_line(&mut answer)
3470 .context("reading external credential consent confirmation")?;
3471 if answer.trim() != "yes" {
3472 bail!("external credential consent cancelled; no configuration was changed");
3473 }
3474 Ok(())
3475}
3476
3477fn yes_no(b: bool) -> &'static str {
3478 if b { "yes" } else { "no " }
3479}
3480
3481fn keyring_status_short(state: Option<bool>) -> &'static str {
3482 match state {
3483 Some(true) => "yes",
3484 Some(false) => "no ",
3485 None => "n/a",
3486 }
3487}
3488
3489fn prompt_api_key(slot: &str) -> Result<String> {
3490 use std::io::{IsTerminal, Write};
3491 eprint!("Enter API key for {slot}: ");
3492 io::stderr().flush().ok();
3493 if !io::stdin().is_terminal() {
3494 return read_api_key_from_stdin();
3496 }
3497 let mut buf = String::new();
3498 io::stdin()
3499 .read_line(&mut buf)
3500 .context("failed to read API key from stdin")?;
3501 let key = buf.trim().to_string();
3502 if key.is_empty() {
3503 bail!("empty API key provided");
3504 }
3505 Ok(key)
3506}
3507
3508fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
3511 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
3512 let mut warnings: Vec<String> = Vec::new();
3513
3514 for provider in ProviderKind::ALL {
3515 let slot = provider_slot(provider);
3516 let from_provider_block = store
3517 .config
3518 .providers
3519 .for_provider(provider)
3520 .api_key
3521 .clone()
3522 .filter(|v| !v.trim().is_empty());
3523 let from_root = (provider == ProviderKind::Deepseek)
3524 .then(|| store.config.api_key.clone())
3525 .flatten()
3526 .filter(|v| !v.trim().is_empty());
3527 let value = from_provider_block.or(from_root);
3528 let Some(value) = value else { continue };
3529
3530 if let Ok(Some(existing)) = secrets.get(slot)
3531 && existing == value
3532 {
3533 } else if dry_run {
3535 migrated.push((provider, slot));
3536 continue;
3537 } else if let Err(err) = secrets.set(slot, &value) {
3538 warnings.push(format!(
3539 "skipped {slot}: failed to write to secret store: {err}"
3540 ));
3541 continue;
3542 }
3543 if !dry_run {
3544 store.config.providers.for_provider_mut(provider).api_key = None;
3545 if provider == ProviderKind::Deepseek {
3546 store.config.api_key = None;
3547 }
3548 }
3549 migrated.push((provider, slot));
3550 }
3551
3552 if !dry_run && !migrated.is_empty() {
3553 store
3554 .save()
3555 .context("failed to write updated config.toml")?;
3556 }
3557 if !dry_run {
3558 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())
3559 .context("failed to remove plaintext API keys from config backup")?;
3560 }
3561
3562 println!("secret store backend: {}", secrets.backend_name());
3563 if migrated.is_empty() {
3564 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
3565 } else {
3566 println!(
3567 "{} {} provider key(s):",
3568 if dry_run { "would migrate" } else { "migrated" },
3569 migrated.len()
3570 );
3571 for (_, slot) in &migrated {
3572 println!(" - {slot}");
3573 }
3574 if !dry_run {
3575 println!(
3576 "config.toml at {} no longer contains api_key entries for migrated providers.",
3577 store.path().display()
3578 );
3579 }
3580 }
3581 for w in warnings {
3582 eprintln!("warning: {w}");
3583 }
3584 Ok(())
3585}
3586
3587fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
3588 match command {
3589 ConfigCommand::Get { key } => {
3590 if let Some(value) = store.config.get_display_value(&key) {
3591 println!("{value}");
3592 return Ok(());
3593 }
3594 bail!("key not found: {key}");
3595 }
3596 ConfigCommand::Set { key, value } => {
3597 store.config.set_value(&key, &value)?;
3598 store.save()?;
3599 println!("set {key}");
3600 Ok(())
3601 }
3602 ConfigCommand::Unset { key } => {
3603 store.config.unset_value(&key)?;
3604 store.save()?;
3605 println!("unset {key}");
3606 Ok(())
3607 }
3608 ConfigCommand::List => {
3609 for (key, value) in store.config.list_values() {
3610 println!("{key} = {value}");
3611 }
3612 Ok(())
3613 }
3614 ConfigCommand::Path => {
3615 println!("{}", store.path().display());
3616 Ok(())
3617 }
3618 }
3619}
3620
3621fn model_command_provider_hint(
3622 command_provider: Option<ProviderArg>,
3623 top_level_provider: Option<ProviderKind>,
3624) -> Option<ProviderKind> {
3625 command_provider
3626 .map(ProviderKind::from)
3627 .or(top_level_provider)
3628}
3629
3630fn provider_source_label(source: ProviderSource) -> String {
3631 match source {
3632 ProviderSource::Cli => "--provider".to_string(),
3633 ProviderSource::Env(name) => format!("environment ({name})"),
3634 ProviderSource::Config => "config".to_string(),
3635 }
3636}
3637
3638fn run_model_command(
3639 store: &mut ConfigStore,
3640 command: ModelCommand,
3641 top_level_provider: Option<ProviderKind>,
3642 resolved_runtime: &ResolvedRuntimeOptions,
3643) -> Result<()> {
3644 let registry = ModelRegistry::default();
3645 match command {
3646 ModelCommand::List { provider } => {
3647 let filter = model_command_provider_hint(provider, top_level_provider);
3648 for model in registry.list().into_iter().filter(|m| match filter {
3649 Some(p) => m.provider == p,
3650 None => true,
3651 }) {
3652 println!("{} ({})", model.id, model.provider.as_str());
3653 }
3654 Ok(())
3655 }
3656 ModelCommand::Resolve { model, provider } => {
3657 let subcommand_provider = provider.map(ProviderKind::from);
3665 let queried = model.as_deref().map(str::trim).filter(|m| !m.is_empty());
3666
3667 if queried.is_none() && subcommand_provider.is_none() {
3672 let source = resolved_runtime.model_source;
3673 println!(
3674 "requested: {}",
3675 if source.is_explicit() {
3676 resolved_runtime.model.as_str()
3677 } else {
3678 ""
3679 }
3680 );
3681 println!("resolved: {}", resolved_runtime.model);
3682 println!("provider: {}", resolved_runtime.provider.as_str());
3683 println!("used_fallback: {}", !source.is_explicit());
3684 println!(
3685 "provider_source: {}",
3686 provider_source_label(resolved_runtime.provider_source)
3687 );
3688 println!("model_source: {}", source.as_str());
3689 return Ok(());
3690 }
3691
3692 let provider_hint = subcommand_provider.or(Some(resolved_runtime.provider));
3697 let mut resolved = registry.resolve(queried, provider_hint);
3698 let provider_named_by_user =
3705 subcommand_provider.is_some() || top_level_provider.is_some();
3706 if !provider_named_by_user && queried.is_some() && resolved.used_fallback {
3707 resolved = registry.resolve(queried, None);
3708 }
3709 println!("requested: {}", resolved.requested.unwrap_or_default());
3710 println!("resolved: {}", resolved.resolved.id);
3711 println!("provider: {}", resolved.resolved.provider.as_str());
3712 println!("used_fallback: {}", resolved.used_fallback);
3713 println!(
3714 "provider_source: {}",
3715 if subcommand_provider.is_some() {
3716 "--provider".to_string()
3717 } else {
3718 provider_source_label(resolved_runtime.provider_source)
3719 }
3720 );
3721 println!(
3722 "model_source: {}",
3723 if queried.is_some() {
3724 "argument"
3725 } else {
3726 resolved_runtime.model_source.as_str()
3727 }
3728 );
3729 Ok(())
3730 }
3731 ModelCommand::Set { model } => {
3732 let trimmed = model.trim();
3733 if trimmed.is_empty() {
3734 bail!("Model name cannot be empty");
3735 }
3736 let canonical = match trimmed.to_ascii_lowercase().as_str() {
3737 "pro" | "deepseek-v4pro" => "deepseek-v4-pro",
3738 "flash" | "deepseek-v4flash" => "deepseek-v4-flash",
3739 _ => trimmed,
3740 };
3741 store.config.default_text_model = Some(canonical.to_string());
3742 store.save()?;
3743 println!("Default model set to '{canonical}'");
3744 Ok(())
3745 }
3746 }
3747}
3748
3749fn run_thread_command(command: ThreadCommand) -> Result<()> {
3750 let state = StateStore::open(None)?;
3751 match command {
3752 ThreadCommand::List { all, limit } => {
3753 let threads = state.list_threads(ThreadListFilters {
3754 include_archived: all,
3755 limit,
3756 })?;
3757 for thread in threads {
3758 println!(
3759 "{} | {} | {} | {}",
3760 thread.id,
3761 thread
3762 .name
3763 .clone()
3764 .unwrap_or_else(|| "(unnamed)".to_string()),
3765 thread.model_provider,
3766 thread.cwd.display()
3767 );
3768 }
3769 Ok(())
3770 }
3771 ThreadCommand::Read { thread_id } => {
3772 let thread = state.get_thread(&thread_id)?;
3773 println!("{}", serde_json::to_string_pretty(&thread)?);
3774 Ok(())
3775 }
3776 ThreadCommand::Resume { thread_id } => {
3777 let args = vec!["resume".to_string(), thread_id];
3778 delegate_simple_tui(args)
3779 }
3780 ThreadCommand::Fork { thread_id } => {
3781 let args = vec!["fork".to_string(), thread_id];
3782 delegate_simple_tui(args)
3783 }
3784 ThreadCommand::Archive { thread_id } => {
3785 state.mark_archived(&thread_id)?;
3786 println!("archived {thread_id}");
3787 Ok(())
3788 }
3789 ThreadCommand::Unarchive { thread_id } => {
3790 state.mark_unarchived(&thread_id)?;
3791 println!("unarchived {thread_id}");
3792 Ok(())
3793 }
3794 ThreadCommand::SetName { thread_id, name } => {
3795 let mut thread = state
3796 .get_thread(&thread_id)?
3797 .with_context(|| format!("thread not found: {thread_id}"))?;
3798 thread.name = Some(name);
3799 thread.updated_at = chrono::Utc::now().timestamp();
3800 state.upsert_thread(&thread)?;
3801 println!("renamed {thread_id}");
3802 Ok(())
3803 }
3804 ThreadCommand::ClearName { thread_id } => {
3805 let mut thread = state
3806 .get_thread(&thread_id)?
3807 .with_context(|| format!("thread not found: {thread_id}"))?;
3808 thread.name = None;
3809 thread.updated_at = chrono::Utc::now().timestamp();
3810 state.upsert_thread(&thread)?;
3811 println!("cleared name for {thread_id}");
3812 Ok(())
3813 }
3814 }
3815}
3816
3817fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
3818 match command {
3819 SandboxCommand::Check { command, ask } => {
3820 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
3821 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3822 let decision = engine.check(ExecPolicyContext {
3823 command: &command,
3824 cwd: &cwd.display().to_string(),
3825 tool: Some("exec_shell"),
3826 path: None,
3827 ask_for_approval: ask.into(),
3828 sandbox_mode: Some("workspace-write"),
3829 })?;
3830 println!("{}", serde_json::to_string_pretty(&decision)?);
3831 Ok(())
3832 }
3833 }
3834}
3835
3836fn run_app_server_command(
3837 cli: &Cli,
3838 resolved_runtime: &ResolvedRuntimeOptions,
3839 args: AppServerArgs,
3840) -> Result<()> {
3841 if args.http || args.mobile {
3846 return delegate_server_to_tui(cli, resolved_runtime, app_server_serve_passthrough(&args));
3849 }
3850
3851 let runtime = tokio::runtime::Builder::new_multi_thread()
3852 .enable_all()
3853 .build()
3854 .context("failed to create tokio runtime")?;
3855 if args.stdio {
3856 return runtime.block_on(run_app_server_stdio(args.config));
3857 }
3858 let host = args.host.as_deref().unwrap_or("127.0.0.1");
3862 let port = args.port.unwrap_or(8787);
3863 let listen: SocketAddr = format!("{host}:{port}")
3864 .parse()
3865 .with_context(|| format!("invalid app-server listen address {host}:{port}"))?;
3866 runtime.block_on(run_app_server(AppServerOptions {
3867 listen,
3868 config_path: args.config,
3869 auth_token: args.auth_token.or_else(app_server_token_from_env),
3870 insecure_no_auth: args.insecure_no_auth,
3871 cors_origins: args.cors_origin,
3872 }))
3873}
3874
3875fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
3883 let mut forwarded = vec!["serve".to_string()];
3884 forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
3885 if let Some(host) = args.host.as_ref() {
3886 forwarded.push("--host".to_string());
3887 forwarded.push(host.clone());
3888 }
3889 if let Some(port) = args.port {
3890 forwarded.push("--port".to_string());
3891 forwarded.push(port.to_string());
3892 }
3893 if let Some(workers) = args.workers {
3894 forwarded.push("--workers".to_string());
3895 forwarded.push(workers.to_string());
3896 }
3897 for origin in &args.cors_origin {
3898 forwarded.push("--cors-origin".to_string());
3899 forwarded.push(origin.clone());
3900 }
3901 if let Some(token) = args.auth_token.as_ref() {
3902 forwarded.push("--auth-token".to_string());
3903 forwarded.push(token.clone());
3904 }
3905 if args.insecure_no_auth {
3906 forwarded.push("--insecure".to_string());
3907 }
3908 if args.qr {
3909 forwarded.push("--qr".to_string());
3910 }
3911 forwarded
3912}
3913
3914fn web_serve_passthrough(args: &WebArgs) -> Vec<String> {
3915 vec![
3916 "serve".to_string(),
3917 "--web".to_string(),
3918 "--port".to_string(),
3919 args.port.to_string(),
3920 ]
3921}
3922
3923fn app_server_token_from_env() -> Option<String> {
3924 std::env::var("CODEWHALE_APP_SERVER_TOKEN")
3925 .ok()
3926 .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
3927}
3928
3929fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
3930 let persisted = load_mcp_server_definitions(store);
3931 let updated = run_stdio_server(persisted)?;
3932 persist_mcp_server_definitions(store, &updated)
3933}
3934
3935fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
3936 let raw = store
3942 .config
3943 .get_raw_string(MCP_SERVER_DEFINITIONS_KEY)
3944 .map(ToOwned::to_owned)
3945 .or_else(|| store.config.get_value(MCP_SERVER_DEFINITIONS_KEY));
3946 let Some(raw) = raw else {
3947 return Vec::new();
3948 };
3949
3950 match parse_mcp_server_definitions(&raw) {
3951 Ok(definitions) => definitions,
3952 Err(err) => {
3953 eprintln!(
3954 "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
3955 );
3956 Vec::new()
3957 }
3958 }
3959}
3960
3961fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
3962 if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
3963 return Ok(parsed);
3964 }
3965
3966 let unwrapped: String = serde_json::from_str(raw).map_err(|_| {
3967 anyhow!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted")
3968 })?;
3969 serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).map_err(|_| {
3970 anyhow!(
3971 "invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted"
3972 )
3973 })
3974}
3975
3976fn persist_mcp_server_definitions(
3977 store: &mut ConfigStore,
3978 definitions: &[McpServerDefinition],
3979) -> Result<()> {
3980 let encoded =
3981 serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
3982 store
3983 .config
3984 .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
3985 store.save()
3986}
3987
3988fn delegate_to_tui(
3989 cli: &Cli,
3990 resolved_runtime: &ResolvedRuntimeOptions,
3991 passthrough: Vec<String>,
3992) -> Result<()> {
3993 let mut cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
3994 let tui = PathBuf::from(cmd.get_program());
3995 let status = cmd
3996 .status()
3997 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
3998 exit_with_tui_status(status)
3999}
4000
4001fn delegate_server_to_tui(
4021 cli: &Cli,
4022 resolved_runtime: &ResolvedRuntimeOptions,
4023 passthrough: Vec<String>,
4024) -> Result<()> {
4025 let mut std_cmd = build_tui_command(cli, resolved_runtime, passthrough)?;
4026 install_server_parent_death_signal(&mut std_cmd);
4027 let tui = PathBuf::from(std_cmd.get_program());
4028 let runtime = tokio::runtime::Builder::new_current_thread()
4029 .enable_all()
4030 .build()
4031 .context("failed to create server-teardown runtime")?;
4032 runtime.block_on(async move {
4033 let mut cmd = tokio::process::Command::from(std_cmd);
4034 cmd.kill_on_drop(true);
4035 let mut child = cmd
4036 .spawn()
4037 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
4038 #[cfg(windows)]
4043 let _child_job = attach_server_child_job(&child);
4044 match supervise_server_child(&mut child, server_shutdown_signal()).await? {
4045 ServerTeardown::Exited(status) => exit_with_tui_status(status),
4046 ServerTeardown::Signaled(code) => std::process::exit(code),
4049 }
4050 })
4051}
4052
4053#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
4057fn install_server_parent_death_signal(cmd: &mut Command) {
4058 use std::os::unix::process::CommandExt;
4059 unsafe {
4063 cmd.pre_exec(|| {
4064 let result = libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM, 0, 0, 0);
4065 if result == -1 {
4066 let _ = std::io::Error::last_os_error();
4068 }
4069 Ok(())
4070 });
4071 }
4072}
4073
4074#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
4075fn install_server_parent_death_signal(_cmd: &mut Command) {}
4076
4077#[derive(Debug)]
4079enum ServerTeardown {
4080 Exited(std::process::ExitStatus),
4082 Signaled(i32),
4085}
4086
4087async fn supervise_server_child<F>(
4090 child: &mut tokio::process::Child,
4091 shutdown: F,
4092) -> io::Result<ServerTeardown>
4093where
4094 F: std::future::Future<Output = i32>,
4095{
4096 tokio::select! {
4097 status = child.wait() => Ok(ServerTeardown::Exited(status?)),
4098 code = shutdown => {
4099 let _ = child.start_kill();
4102 let _ = child.wait().await;
4103 Ok(ServerTeardown::Signaled(code))
4104 }
4105 }
4106}
4107
4108#[cfg(unix)]
4112async fn server_shutdown_signal() -> i32 {
4113 use tokio::signal::unix::{SignalKind, signal};
4114 let mut terminate = signal(SignalKind::terminate()).ok();
4115 let mut hangup = signal(SignalKind::hangup()).ok();
4116 let term = async {
4117 match terminate.as_mut() {
4118 Some(s) => {
4119 s.recv().await;
4120 }
4121 None => std::future::pending::<()>().await,
4122 }
4123 };
4124 let hup = async {
4125 match hangup.as_mut() {
4126 Some(s) => {
4127 s.recv().await;
4128 }
4129 None => std::future::pending::<()>().await,
4130 }
4131 };
4132 tokio::select! {
4133 _ = tokio::signal::ctrl_c() => 130,
4134 _ = term => 143,
4135 _ = hup => 129,
4136 }
4137}
4138
4139#[cfg(not(unix))]
4140async fn server_shutdown_signal() -> i32 {
4141 let _ = tokio::signal::ctrl_c().await;
4142 130
4143}
4144
4145#[cfg(windows)]
4152fn attach_server_child_job(child: &tokio::process::Child) -> Option<ServerChildJob> {
4153 let Some(child_handle) = child.raw_handle() else {
4154 tracing::warn!("delegated server child exited before a job object could be attached");
4155 return None;
4156 };
4157
4158 match ServerChildJob::attach(child_handle) {
4159 Ok(job) => Some(job),
4160 Err(err) => {
4161 tracing::warn!("failed to place delegated server child in a job object: {err}");
4162 None
4163 }
4164 }
4165}
4166
4167#[cfg(windows)]
4168struct ServerChildJob {
4169 handle: windows::Win32::Foundation::HANDLE,
4170}
4171
4172#[cfg(windows)]
4175unsafe impl Send for ServerChildJob {}
4176
4177#[cfg(windows)]
4178impl ServerChildJob {
4179 fn attach(child_handle: std::os::windows::io::RawHandle) -> std::io::Result<Self> {
4180 use windows::Win32::Foundation::HANDLE;
4181 use windows::Win32::System::JobObjects::{
4182 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
4183 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
4184 SetInformationJobObject,
4185 };
4186 use windows::core::PCWSTR;
4187
4188 let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) }.map_err(win_io_error)?;
4191 let job = Self { handle };
4192
4193 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
4194 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
4195 unsafe {
4196 SetInformationJobObject(
4197 job.handle,
4198 JobObjectExtendedLimitInformation,
4199 &limits as *const _ as *const core::ffi::c_void,
4200 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
4201 )
4202 .map_err(win_io_error)?;
4203 AssignProcessToJobObject(job.handle, HANDLE(child_handle)).map_err(win_io_error)?;
4204 }
4205 Ok(job)
4206 }
4207}
4208
4209#[cfg(windows)]
4210impl Drop for ServerChildJob {
4211 fn drop(&mut self) {
4212 unsafe {
4216 let _ = windows::Win32::Foundation::CloseHandle(self.handle);
4217 }
4218 }
4219}
4220
4221#[cfg(windows)]
4222fn win_io_error(err: windows::core::Error) -> std::io::Error {
4223 std::io::Error::other(err)
4224}
4225
4226#[cfg(all(test, unix))]
4227mod server_teardown_tests {
4228 use super::*;
4229
4230 #[tokio::test]
4231 async fn supervisor_propagates_child_exit_when_no_shutdown() {
4232 let mut child = tokio::process::Command::new("true")
4235 .kill_on_drop(true)
4236 .spawn()
4237 .expect("spawn true");
4238 let outcome = supervise_server_child(&mut child, std::future::pending::<i32>())
4239 .await
4240 .expect("supervise");
4241 match outcome {
4242 ServerTeardown::Exited(status) => assert!(status.success()),
4243 other => panic!("expected Exited, got {other:?}"),
4244 }
4245 }
4246
4247 #[tokio::test]
4248 async fn shutdown_signal_kills_and_reaps_long_running_child() {
4249 let mut child = tokio::process::Command::new("sleep")
4252 .arg("30")
4253 .kill_on_drop(true)
4254 .spawn()
4255 .expect("spawn sleep");
4256 assert!(
4257 child.id().is_some(),
4258 "child should be running before shutdown"
4259 );
4260 let outcome = supervise_server_child(&mut child, async { 143 })
4263 .await
4264 .expect("supervise");
4265 assert!(matches!(outcome, ServerTeardown::Signaled(143)));
4266 assert!(
4269 child.id().is_none(),
4270 "delegated child must be reaped after dispatcher teardown"
4271 );
4272 }
4273
4274 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
4275 #[test]
4276 fn parent_death_signal_hook_does_not_break_spawn() {
4277 let mut cmd = Command::new("true");
4278 install_server_parent_death_signal(&mut cmd);
4279 let status = cmd.status().expect("spawn true with parent-death hook");
4280 assert!(status.success());
4281 }
4282}
4283
4284fn run_resume_command(
4285 cli: &Cli,
4286 resolved_runtime: &ResolvedRuntimeOptions,
4287 args: TuiPassthroughArgs,
4288) -> Result<()> {
4289 let passthrough = tui_args("resume", args);
4290 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
4291 return run_dispatcher_resume_picker(cli, resolved_runtime);
4292 }
4293 delegate_to_tui(cli, resolved_runtime, passthrough)
4294}
4295
4296fn run_dispatcher_resume_picker(
4297 cli: &Cli,
4298 resolved_runtime: &ResolvedRuntimeOptions,
4299) -> Result<()> {
4300 let mut sessions_cmd = build_tui_command(cli, resolved_runtime, vec!["sessions".to_string()])?;
4301 let tui = PathBuf::from(sessions_cmd.get_program());
4302 let status = sessions_cmd
4303 .status()
4304 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
4305 if !status.success() {
4306 return exit_with_tui_status(status);
4307 }
4308
4309 println!();
4310 println!("Windows note: enter a session id or prefix from the list above.");
4311 println!("You can also run `codewhale resume --last` to skip this prompt.");
4312 print!("Session id/prefix (Enter to cancel): ");
4313 io::stdout().flush()?;
4314
4315 let mut input = String::new();
4316 io::stdin()
4317 .read_line(&mut input)
4318 .context("failed to read session selection")?;
4319 let session_id = input.trim();
4320 if session_id.is_empty() {
4321 bail!("No session selected.");
4322 }
4323
4324 delegate_to_tui(
4325 cli,
4326 resolved_runtime,
4327 vec!["resume".to_string(), session_id.to_string()],
4328 )
4329}
4330
4331fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
4332 is_windows && passthrough == ["resume"]
4333}
4334
4335fn build_tui_command(
4336 cli: &Cli,
4337 resolved_runtime: &ResolvedRuntimeOptions,
4338 passthrough: Vec<String>,
4339) -> Result<Command> {
4340 build_tui_command_with_paths(
4341 cli,
4342 resolved_runtime,
4343 passthrough,
4344 cli.config.as_deref(),
4345 cli.workspace.as_deref(),
4346 )
4347}
4348
4349fn build_tui_command_with_paths(
4350 cli: &Cli,
4351 resolved_runtime: &ResolvedRuntimeOptions,
4352 passthrough: Vec<String>,
4353 config_path: Option<&Path>,
4354 workspace_path: Option<&Path>,
4355) -> Result<Command> {
4356 let tui = locate_sibling_tui_binary()?;
4357 let mut verbosity = if cli.profile.is_some() {
4358 cli.verbosity.clone()
4359 } else {
4360 resolved_runtime.verbosity.clone()
4361 };
4362 if verbosity.is_none()
4363 && passthrough
4364 .iter()
4365 .any(|arg| matches!(arg.as_str(), "exec" | "eval"))
4366 {
4367 verbosity = Some("concise".to_string());
4368 }
4369
4370 let mut cmd = Command::new(&tui);
4371 if let Some(config) = config_path {
4372 cmd.arg("--config").arg(config);
4373 }
4374 if let Some(profile) = cli.profile.as_ref() {
4375 cmd.arg("--profile").arg(profile);
4376 }
4377 if let Some(workspace) = workspace_path {
4378 cmd.arg("--workspace").arg(workspace);
4379 }
4380 if cli.mouse_capture {
4381 cmd.arg("--mouse-capture");
4382 }
4383 if cli.no_mouse_capture {
4384 cmd.arg("--no-mouse-capture");
4385 }
4386 if cli.skip_onboarding {
4387 cmd.arg("--skip-onboarding");
4388 }
4389 if cli.no_project_config {
4390 cmd.arg("--no-project-config");
4391 }
4392 cmd.args(passthrough);
4393
4394 let uses_raw_tui_provider = cli
4395 .provider
4396 .as_deref()
4397 .is_some_and(|provider| builtin_provider_arg(provider).is_none());
4398 let keyring_bridge_provider = resolved_runtime.provider;
4399 let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
4400 let keyring_bridge_source = resolved_runtime.api_key_source;
4401
4402 if let Some(provider) = cli.provider.as_deref() {
4403 let provider = builtin_provider_arg(provider)
4404 .map(ProviderKind::from)
4405 .map_or_else(
4406 || provider.to_string(),
4407 |provider| provider.as_str().to_string(),
4408 );
4409 cmd.env("CODEWHALE_PROVIDER", &provider);
4412 cmd.env("DEEPSEEK_PROVIDER", provider);
4413 }
4414 if !(uses_raw_tui_provider
4415 || (cli.profile.is_some()
4416 && matches!(resolved_runtime.provider_source, ProviderSource::Config)))
4417 && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
4418 && let Some(api_key) = keyring_bridge_api_key
4419 {
4420 cmd.env("DEEPSEEK_API_KEY", api_key);
4424 for var in provider_env_vars(keyring_bridge_provider) {
4425 if *var != "DEEPSEEK_API_KEY" {
4426 cmd.env(var, api_key);
4427 }
4428 }
4429 cmd.env(
4430 "DEEPSEEK_API_KEY_SOURCE",
4431 RuntimeApiKeySource::Keyring.as_env_value(),
4432 );
4433 }
4434
4435 if let Some(model) = cli.model.as_ref() {
4440 cmd.env("CODEWHALE_MODEL", model);
4441 cmd.env("DEEPSEEK_MODEL", model);
4442 }
4443 if let Some(output_mode) = cli.output_mode.as_ref() {
4444 cmd.env("CODEWHALE_OUTPUT_MODE", output_mode);
4445 cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode);
4446 }
4447 if let Some(v) = verbosity.as_ref() {
4448 cmd.env("CODEWHALE_VERBOSITY", v);
4449 cmd.env("DEEPSEEK_VERBOSITY", v);
4450 }
4451 if let Some(log_level) = cli.log_level.as_ref() {
4452 cmd.env("CODEWHALE_LOG_LEVEL", log_level);
4453 cmd.env("DEEPSEEK_LOG_LEVEL", log_level);
4454 }
4455 if let Some(telemetry) = cli.telemetry {
4456 cmd.env("CODEWHALE_TELEMETRY", telemetry.to_string());
4457 cmd.env("DEEPSEEK_TELEMETRY", telemetry.to_string());
4458 }
4459 if let Some(policy) = cli.approval_policy.as_ref() {
4460 cmd.env("CODEWHALE_APPROVAL_POLICY", policy);
4461 cmd.env("DEEPSEEK_APPROVAL_POLICY", policy);
4462 }
4463 if let Some(mode) = cli.sandbox_mode.as_ref() {
4464 cmd.env("CODEWHALE_SANDBOX_MODE", mode);
4465 cmd.env("DEEPSEEK_SANDBOX_MODE", mode);
4466 }
4467 if cli.yolo {
4468 cmd.env("CODEWHALE_YOLO", "true");
4469 cmd.env("DEEPSEEK_YOLO", "true");
4470 }
4471 if let Some(api_key) = cli.api_key.as_ref() {
4472 cmd.env("CODEWHALE_CLI_API_KEY", api_key);
4479 if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
4480 cmd.env("DEEPSEEK_API_KEY", api_key);
4481 for var in provider_env_vars(resolved_runtime.provider) {
4482 if *var != "DEEPSEEK_API_KEY" {
4483 cmd.env(var, api_key);
4484 }
4485 }
4486 }
4487 cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli");
4488 }
4489 if let Some(base_url) = cli.base_url.as_ref() {
4490 cmd.env("CODEWHALE_BASE_URL", base_url);
4491 cmd.env("DEEPSEEK_BASE_URL", base_url);
4492 }
4493
4494 Ok(cmd)
4495}
4496
4497fn tui_child_exit_code(status: std::process::ExitStatus) -> Option<i32> {
4498 if let Some(code) = status.code() {
4499 return Some(code);
4500 }
4501
4502 #[cfg(unix)]
4503 {
4504 use std::os::unix::process::ExitStatusExt;
4505
4506 status.signal().map(|signal| 128 + signal)
4507 }
4508
4509 #[cfg(not(unix))]
4510 {
4511 None
4512 }
4513}
4514
4515fn exit_with_tui_status(status: std::process::ExitStatus) -> Result<()> {
4516 if let Some(code) = tui_child_exit_code(status) {
4517 std::process::exit(code);
4518 }
4519 bail!("codewhale-tui terminated without an exit code")
4520}
4521
4522fn delegate_simple_tui(args: Vec<String>) -> Result<()> {
4523 let tui = locate_sibling_tui_binary()?;
4524 let status = Command::new(&tui)
4525 .args(args)
4526 .status()
4527 .map_err(|err| anyhow!("{}", tui_spawn_error(&tui, &err)))?;
4528 exit_with_tui_status(status)
4529}
4530
4531fn tui_spawn_error(tui: &Path, err: &io::Error) -> String {
4532 format!(
4533 "failed to spawn companion TUI binary at {}: {err}\n\
4534\n\
4535The `codewhale` dispatcher found a `codewhale-tui` file, but the OS refused \
4536to execute it. Common fixes:\n\
4537 - Reinstall with `npm install -g codewhale`, or run `codewhale update`.\n\
4538 - On Windows, run `where codewhale` and `where codewhale-tui`; both should \
4539come from the same install directory.\n\
4540 - If you downloaded release assets manually, keep both `codewhale` and \
4541`codewhale-tui` binaries together and make sure the TUI binary is executable.\n\
4542 - Set CODEWHALE_TUI_BIN (legacy alias: DEEPSEEK_TUI_BIN) to the absolute \
4543path of a working `codewhale-tui` binary.",
4544 tui.display()
4545 )
4546}
4547
4548fn locate_sibling_tui_binary() -> Result<PathBuf> {
4558 for var in ["CODEWHALE_TUI_BIN", "DEEPSEEK_TUI_BIN"] {
4559 if let Ok(override_path) = std::env::var(var) {
4560 let candidate = PathBuf::from(override_path);
4561 if candidate.is_file() {
4562 return Ok(candidate);
4563 }
4564 bail!(
4565 "{var} points at {}, which is not a regular file.",
4566 candidate.display()
4567 );
4568 }
4569 }
4570
4571 let current = std::env::current_exe().context("failed to locate current executable path")?;
4572 if let Some(found) = sibling_tui_candidate(¤t) {
4573 return Ok(found);
4574 }
4575
4576 let expected = current.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
4579 bail!(
4580 "Companion `codewhale-tui` binary not found at {}.\n\
4581\n\
4582The `codewhale` dispatcher delegates interactive sessions to a sibling \
4583`codewhale-tui` binary. To fix this, install one of:\n\
4584 • npm: npm install -g codewhale (downloads both binaries)\n\
4585 • cargo: cargo install codewhale-cli codewhale-tui --locked\n\
4586 • GitHub Releases: download BOTH `codewhale-<platform>` AND \
4587`codewhale-tui-<platform>` from https://github.com/Hmbown/CodeWhale/releases/latest \
4588and place them in the same directory.\n\
4589\n\
4590Or set CODEWHALE_TUI_BIN (legacy alias: DEEPSEEK_TUI_BIN) to the absolute path \
4591of an existing `codewhale-tui` binary.",
4592 expected.display()
4593 );
4594}
4595
4596fn sibling_tui_candidate(dispatcher: &Path) -> Option<PathBuf> {
4600 let primary =
4603 dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
4604 if primary.is_file() {
4605 return Some(primary);
4606 }
4607 if cfg!(windows) {
4610 let suffixless = dispatcher.with_file_name("codewhale-tui");
4611 if suffixless.is_file() {
4612 return Some(suffixless);
4613 }
4614 }
4615 None
4616}
4617
4618fn run_metrics_command(args: MetricsArgs) -> Result<()> {
4619 let since = match args.since.as_deref() {
4620 Some(s) => {
4621 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
4622 }
4623 None => None,
4624 };
4625 metrics::run(metrics::MetricsArgs {
4626 json: args.json,
4627 since,
4628 })
4629}
4630
4631fn read_api_key_from_stdin() -> Result<String> {
4632 let mut input = String::new();
4633 io::stdin()
4634 .read_to_string(&mut input)
4635 .context("failed to read api key from stdin")?;
4636 let key = input.trim().to_string();
4637 if key.is_empty() {
4638 bail!("empty API key provided");
4639 }
4640 Ok(key)
4641}
4642
4643#[cfg(test)]
4644mod tests {
4645 use super::*;
4646 use clap::error::ErrorKind;
4647 use codewhale_config::{ModelSource, ProviderSource};
4648 use std::ffi::OsString;
4649 use std::sync::{Mutex, OnceLock};
4650
4651 fn parse_ok(argv: &[&str]) -> Cli {
4652 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
4653 }
4654
4655 fn help_for(argv: &[&str]) -> String {
4656 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
4657 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
4658 err.to_string()
4659 }
4660
4661 fn command_env(cmd: &Command, name: &str) -> Option<String> {
4662 let name = std::ffi::OsStr::new(name);
4663 cmd.get_envs().find_map(|(key, value)| {
4664 if key == name {
4665 value.map(|v| v.to_string_lossy().into_owned())
4666 } else {
4667 None
4668 }
4669 })
4670 }
4671
4672 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
4673 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
4674 LOCK.get_or_init(|| Mutex::new(()))
4675 .lock()
4676 .unwrap_or_else(|p| p.into_inner())
4677 }
4678
4679 struct ScopedEnvVar {
4680 name: &'static str,
4681 previous: Option<OsString>,
4682 }
4683
4684 impl ScopedEnvVar {
4685 fn set(name: &'static str, value: &str) -> Self {
4686 let previous = std::env::var_os(name);
4687 unsafe { std::env::set_var(name, value) };
4690 Self { name, previous }
4691 }
4692
4693 fn remove(name: &'static str) -> Self {
4694 let previous = std::env::var_os(name);
4695 unsafe { std::env::remove_var(name) };
4698 Self { name, previous }
4699 }
4700 }
4701
4702 impl Drop for ScopedEnvVar {
4703 fn drop(&mut self) {
4704 unsafe {
4706 if let Some(previous) = self.previous.take() {
4707 std::env::set_var(self.name, previous);
4708 } else {
4709 std::env::remove_var(self.name);
4710 }
4711 }
4712 }
4713 }
4714
4715 #[derive(Default)]
4716 struct RecordingKeyringStore {
4717 gets: Mutex<Vec<String>>,
4718 values: Mutex<std::collections::BTreeMap<String, String>>,
4719 }
4720
4721 impl RecordingKeyringStore {
4722 fn set_value(&self, key: &str, value: &str) {
4723 self.values
4724 .lock()
4725 .expect("recording values lock")
4726 .insert(key.to_string(), value.to_string());
4727 }
4728
4729 fn queried(&self) -> Vec<String> {
4730 self.gets.lock().expect("recording gets lock").clone()
4731 }
4732 }
4733
4734 impl codewhale_secrets::KeyringStore for RecordingKeyringStore {
4735 fn get(
4736 &self,
4737 key: &str,
4738 ) -> std::result::Result<Option<String>, codewhale_secrets::SecretsError> {
4739 self.gets
4740 .lock()
4741 .expect("recording gets lock")
4742 .push(key.to_string());
4743 Ok(self
4744 .values
4745 .lock()
4746 .expect("recording values lock")
4747 .get(key)
4748 .cloned())
4749 }
4750
4751 fn set(
4752 &self,
4753 key: &str,
4754 value: &str,
4755 ) -> std::result::Result<(), codewhale_secrets::SecretsError> {
4756 self.set_value(key, value);
4757 Ok(())
4758 }
4759
4760 fn delete(&self, key: &str) -> std::result::Result<(), codewhale_secrets::SecretsError> {
4761 self.values
4762 .lock()
4763 .expect("recording values lock")
4764 .remove(key);
4765 Ok(())
4766 }
4767
4768 fn backend_name(&self) -> &'static str {
4769 "recording"
4770 }
4771 }
4772
4773 fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
4774 let dir = tempfile::TempDir::new().expect("tempdir");
4775 let custom = dir
4776 .path()
4777 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
4778 std::fs::write(&custom, b"").unwrap();
4779 let custom_str = custom.to_string_lossy();
4780 let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
4781 (dir, bin)
4782 }
4783
4784 fn resolved_runtime_for_test(
4785 provider: ProviderKind,
4786 provider_source: ProviderSource,
4787 ) -> ResolvedRuntimeOptions {
4788 ResolvedRuntimeOptions {
4789 provider,
4790 provider_source,
4791 model: "test-model".to_string(),
4792 model_source: ModelSource::ProviderDefault,
4793 api_key: None,
4794 api_key_source: None,
4795 base_url: "http://localhost:8000/v1".to_string(),
4796 auth_mode: None,
4797 insecure_skip_tls_verify: false,
4798 output_mode: None,
4799 log_level: None,
4800 telemetry: false,
4801 approval_policy: None,
4802 sandbox_mode: None,
4803 yolo: None,
4804 verbosity: None,
4805 http_headers: std::collections::BTreeMap::new(),
4806 }
4807 }
4808
4809 #[test]
4810 fn clap_command_definition_is_consistent() {
4811 Cli::command().debug_assert();
4812 }
4813
4814 #[test]
4821 fn anyhow_chain_surfaces_toml_parse_cause() {
4822 use anyhow::Context;
4823 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
4824 let err = Err::<(), _>(inner)
4825 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
4826 .unwrap_err();
4827
4828 assert_eq!(
4830 err.to_string(),
4831 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
4832 );
4833
4834 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
4836 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
4837 }
4838
4839 #[test]
4840 fn malformed_persisted_mcp_json_omits_secret_contents_and_keys() {
4841 let secret = "sentinel";
4842 let raw =
4843 format!(r#"[{{"name":"private","env":{{"PRIVATE_TOKEN":"{secret}"}} trailing-junk}}]"#);
4844 let error = parse_mcp_server_definitions(&raw).expect_err("malformed JSON must fail");
4845 let diagnostic = format!("{error:#}");
4846 assert!(!diagnostic.contains(secret), "{diagnostic}");
4847 assert!(!diagnostic.contains("PRIVATE_TOKEN"), "{diagnostic}");
4848 assert!(diagnostic.contains("contents were omitted"), "{diagnostic}");
4849 }
4850
4851 #[test]
4852 fn parses_config_command_matrix() {
4853 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
4854 assert!(matches!(
4855 cli.command,
4856 Some(Commands::Config(ConfigArgs {
4857 command: ConfigCommand::Get { ref key }
4858 })) if key == "provider"
4859 ));
4860
4861 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
4862 assert!(matches!(
4863 cli.command,
4864 Some(Commands::Config(ConfigArgs {
4865 command: ConfigCommand::Set { ref key, ref value }
4866 })) if key == "model" && value == "deepseek-v4-flash"
4867 ));
4868
4869 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
4870 assert!(matches!(
4871 cli.command,
4872 Some(Commands::Config(ConfigArgs {
4873 command: ConfigCommand::Unset { ref key }
4874 })) if key == "model"
4875 ));
4876
4877 assert!(matches!(
4878 parse_ok(&["deepseek", "config", "list"]).command,
4879 Some(Commands::Config(ConfigArgs {
4880 command: ConfigCommand::List
4881 }))
4882 ));
4883 assert!(matches!(
4884 parse_ok(&["deepseek", "config", "path"]).command,
4885 Some(Commands::Config(ConfigArgs {
4886 command: ConfigCommand::Path
4887 }))
4888 ));
4889 }
4890
4891 #[test]
4892 fn parses_update_beta_flag() {
4893 let cli = parse_ok(&["codewhale", "update"]);
4894 assert!(matches!(
4895 cli.command,
4896 Some(Commands::Update(UpdateArgs {
4897 beta: false,
4898 check: false,
4899 proxy: None
4900 }))
4901 ));
4902
4903 let cli = parse_ok(&["codewhale", "update", "--beta"]);
4904 assert!(matches!(
4905 cli.command,
4906 Some(Commands::Update(UpdateArgs {
4907 beta: true,
4908 check: false,
4909 proxy: None
4910 }))
4911 ));
4912
4913 let cli = parse_ok(&["codewhale", "update", "--check"]);
4914 assert!(matches!(
4915 cli.command,
4916 Some(Commands::Update(UpdateArgs {
4917 beta: false,
4918 check: true,
4919 proxy: None
4920 }))
4921 ));
4922
4923 let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
4924 let Some(Commands::Update(args)) = cli.command else {
4925 panic!("expected update command");
4926 };
4927 assert!(!args.beta);
4928 assert!(!args.check);
4929 assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
4930 }
4931
4932 #[test]
4933 fn parses_model_command_matrix() {
4934 let cli = parse_ok(&["deepseek", "model", "list"]);
4935 assert!(matches!(
4936 cli.command,
4937 Some(Commands::Model(ModelArgs {
4938 command: ModelCommand::List { provider: None }
4939 }))
4940 ));
4941
4942 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
4943 assert!(matches!(
4944 cli.command,
4945 Some(Commands::Model(ModelArgs {
4946 command: ModelCommand::List {
4947 provider: Some(ProviderArg::Openai)
4948 }
4949 }))
4950 ));
4951
4952 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
4953 assert!(matches!(
4954 cli.command,
4955 Some(Commands::Model(ModelArgs {
4956 command: ModelCommand::Resolve {
4957 model: Some(ref model),
4958 provider: None
4959 }
4960 })) if model == "deepseek-v4-flash"
4961 ));
4962
4963 let cli = parse_ok(&[
4964 "deepseek",
4965 "model",
4966 "resolve",
4967 "--provider",
4968 "deepseek",
4969 "deepseek-v4-pro",
4970 ]);
4971 assert!(matches!(
4972 cli.command,
4973 Some(Commands::Model(ModelArgs {
4974 command: ModelCommand::Resolve {
4975 model: Some(ref model),
4976 provider: Some(ProviderArg::Deepseek)
4977 }
4978 })) if model == "deepseek-v4-pro"
4979 ));
4980
4981 let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
4982 assert!(matches!(
4983 cli.command,
4984 Some(Commands::Model(ModelArgs {
4985 command: ModelCommand::Set { ref model }
4986 })) if model == "pro"
4987 ));
4988 }
4989
4990 #[test]
4991 fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
4992 assert_eq!(
4993 model_command_provider_hint(None, Some(ProviderKind::Zai)),
4994 Some(ProviderKind::Zai)
4995 );
4996 assert_eq!(
4997 model_command_provider_hint(Some(ProviderArg::Minimax), Some(ProviderKind::Zai)),
4998 Some(ProviderKind::Minimax)
4999 );
5000 assert_eq!(model_command_provider_hint(None, None), None);
5001
5002 let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
5003 assert_eq!(cli.provider.as_deref(), Some("zai"));
5004 assert!(matches!(
5005 cli.command,
5006 Some(Commands::Model(ModelArgs {
5007 command: ModelCommand::List { provider: None }
5008 }))
5009 ));
5010 }
5011
5012 #[test]
5013 fn parses_thread_command_matrix() {
5014 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
5015 assert!(matches!(
5016 cli.command,
5017 Some(Commands::Thread(ThreadArgs {
5018 command: ThreadCommand::List {
5019 all: true,
5020 limit: Some(50)
5021 }
5022 }))
5023 ));
5024
5025 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
5026 assert!(matches!(
5027 cli.command,
5028 Some(Commands::Thread(ThreadArgs {
5029 command: ThreadCommand::Read { ref thread_id }
5030 })) if thread_id == "thread-1"
5031 ));
5032
5033 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
5034 assert!(matches!(
5035 cli.command,
5036 Some(Commands::Thread(ThreadArgs {
5037 command: ThreadCommand::Resume { ref thread_id }
5038 })) if thread_id == "thread-2"
5039 ));
5040
5041 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
5042 assert!(matches!(
5043 cli.command,
5044 Some(Commands::Thread(ThreadArgs {
5045 command: ThreadCommand::Fork { ref thread_id }
5046 })) if thread_id == "thread-3"
5047 ));
5048
5049 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
5050 assert!(matches!(
5051 cli.command,
5052 Some(Commands::Thread(ThreadArgs {
5053 command: ThreadCommand::Archive { ref thread_id }
5054 })) if thread_id == "thread-4"
5055 ));
5056
5057 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
5058 assert!(matches!(
5059 cli.command,
5060 Some(Commands::Thread(ThreadArgs {
5061 command: ThreadCommand::Unarchive { ref thread_id }
5062 })) if thread_id == "thread-5"
5063 ));
5064
5065 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
5066 assert!(matches!(
5067 cli.command,
5068 Some(Commands::Thread(ThreadArgs {
5069 command: ThreadCommand::SetName {
5070 ref thread_id,
5071 ref name
5072 }
5073 })) if thread_id == "thread-6" && name == "My Thread"
5074 ));
5075
5076 let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
5077 assert!(matches!(
5078 cli.command,
5079 Some(Commands::Thread(ThreadArgs {
5080 command: ThreadCommand::ClearName { ref thread_id }
5081 })) if thread_id == "thread-7"
5082 ));
5083 }
5084
5085 #[test]
5086 fn parses_sandbox_app_server_and_completion_matrix() {
5087 let cli = parse_ok(&[
5088 "deepseek",
5089 "sandbox",
5090 "check",
5091 "echo hello",
5092 "--ask",
5093 "on-failure",
5094 ]);
5095 assert!(matches!(
5096 cli.command,
5097 Some(Commands::Sandbox(SandboxArgs {
5098 command: SandboxCommand::Check {
5099 ref command,
5100 ask: ApprovalModeArg::OnFailure
5101 }
5102 })) if command == "echo hello"
5103 ));
5104
5105 let cli = parse_ok(&[
5106 "deepseek",
5107 "app-server",
5108 "--host",
5109 "0.0.0.0",
5110 "--port",
5111 "9999",
5112 ]);
5113 assert!(matches!(
5114 cli.command,
5115 Some(Commands::AppServer(AppServerArgs {
5116 host: Some(ref host),
5117 port: Some(9999),
5118 stdio: false,
5119 http: false,
5120 mobile: false,
5121 ..
5122 })) if host == "0.0.0.0"
5123 ));
5124
5125 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
5126 assert!(matches!(
5127 cli.command,
5128 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
5129 ));
5130
5131 let cli = parse_ok(&["deepseek", "completion", "bash"]);
5132 assert!(matches!(
5133 cli.command,
5134 Some(Commands::Completion { shell: Shell::Bash })
5135 ));
5136 }
5137
5138 #[test]
5139 fn app_server_transports_are_mutually_exclusive() {
5140 assert!(matches!(
5141 parse_ok(&["deepseek", "app-server", "--http"]).command,
5142 Some(Commands::AppServer(AppServerArgs {
5143 http: true,
5144 mobile: false,
5145 stdio: false,
5146 ..
5147 }))
5148 ));
5149 assert!(matches!(
5150 parse_ok(&["deepseek", "app-server", "--mobile"]).command,
5151 Some(Commands::AppServer(AppServerArgs {
5152 mobile: true,
5153 http: false,
5154 stdio: false,
5155 ..
5156 }))
5157 ));
5158
5159 for argv in [
5160 ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
5161 ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
5162 ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
5163 ] {
5164 let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
5165 assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
5166 }
5167 }
5168
5169 #[test]
5170 fn app_server_qr_requires_mobile() {
5171 let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
5172 .expect_err("--qr without --mobile must fail");
5173 assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
5174 assert!(matches!(
5175 parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
5176 Some(Commands::AppServer(AppServerArgs {
5177 mobile: true,
5178 qr: true,
5179 ..
5180 }))
5181 ));
5182 }
5183
5184 #[test]
5185 fn app_server_serve_passthrough_maps_flags_to_serve() {
5186 let args = AppServerArgs {
5187 http: true,
5188 mobile: false,
5189 stdio: false,
5190 qr: false,
5191 host: Some("127.0.0.1".to_string()),
5192 port: Some(9000),
5193 workers: Some(4),
5194 config: None,
5195 auth_token: Some("tok".to_string()),
5196 insecure_no_auth: true,
5197 cors_origin: vec!["http://localhost:5173".to_string()],
5198 };
5199 let argv = app_server_serve_passthrough(&args);
5200 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
5201 assert_eq!(
5203 as_str,
5204 vec![
5205 "serve",
5206 "--http",
5207 "--host",
5208 "127.0.0.1",
5209 "--port",
5210 "9000",
5211 "--workers",
5212 "4",
5213 "--cors-origin",
5214 "http://localhost:5173",
5215 "--auth-token",
5216 "tok",
5217 "--insecure",
5218 ]
5219 );
5220 }
5221
5222 #[test]
5223 fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
5224 let args = AppServerArgs {
5225 http: false,
5226 mobile: true,
5227 stdio: false,
5228 qr: true,
5229 host: None,
5230 port: None,
5231 workers: None,
5232 config: None,
5233 auth_token: None,
5234 insecure_no_auth: false,
5235 cors_origin: vec![],
5236 };
5237 let argv = app_server_serve_passthrough(&args);
5238 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
5239 assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
5242 }
5243
5244 #[test]
5245 fn web_command_is_typed_and_delegates_without_auth_material() {
5246 let cli = parse_ok(&["codewhale", "web", "--port", "9091"]);
5247 let args = match cli.command {
5248 Some(Commands::Web(args)) => args,
5249 other => panic!("expected web command, got {other:?}"),
5250 };
5251 assert_eq!(args.port, 9091);
5252 let forwarded = web_serve_passthrough(&args);
5253 assert_eq!(forwarded, ["serve", "--web", "--port", "9091"]);
5254 assert!(!forwarded.iter().any(|arg| arg.contains("token")));
5255 }
5256
5257 #[test]
5258 fn web_command_defaults_to_runtime_port_and_documents_bootstrap_boundary() {
5259 let cli = parse_ok(&["codewhale", "web"]);
5260 assert!(matches!(
5261 cli.command,
5262 Some(Commands::Web(WebArgs { port: 7878 }))
5263 ));
5264 let help = help_for(&["codewhale", "web", "--help"]);
5265 assert!(help.contains("--port"));
5266 assert!(help.contains("one-time loopback bootstrap"));
5267 assert!(!help.contains("--auth-token"));
5268 }
5269
5270 #[test]
5271 fn serve_help_documents_forwarded_runtime_modes() {
5272 let help = help_for(&["codewhale", "serve", "--help"]);
5273 for flag in ["--http", "--mobile", "--web", "--mcp", "--acp"] {
5274 assert!(
5275 help.contains(flag),
5276 "serve help should document forwarded flag {flag}; help was:\n{help}"
5277 );
5278 }
5279 assert!(help.contains("compatibility"));
5280 }
5281
5282 #[test]
5283 fn parses_direct_tui_command_aliases() {
5284 let cli = parse_ok(&["deepseek", "doctor"]);
5285 assert!(matches!(
5286 cli.command,
5287 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
5288 ));
5289
5290 let cli = parse_ok(&["deepseek", "models", "--json"]);
5291 assert!(matches!(
5292 cli.command,
5293 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
5294 ));
5295
5296 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
5297 assert!(matches!(
5298 cli.command,
5299 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
5300 ));
5301
5302 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
5303 assert!(matches!(
5304 cli.command,
5305 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
5306 if args == &["--skills", "--local"]
5307 ));
5308
5309 let cli = parse_ok(&["codewhale", "fleet", "init"]);
5310 assert!(cli.prompt.is_empty());
5311 assert!(matches!(
5312 cli.command,
5313 Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
5314 ));
5315
5316 let cli = parse_ok(&[
5317 "codewhale",
5318 "fleet",
5319 "run",
5320 "tasks.json",
5321 "--max-workers",
5322 "2",
5323 ]);
5324 assert!(cli.prompt.is_empty());
5325 assert!(matches!(
5326 cli.command,
5327 Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
5328 if args == &["run", "tasks.json", "--max-workers", "2"]
5329 ));
5330
5331 let cli = parse_ok(&[
5332 "codewhale",
5333 "workflow",
5334 "run",
5335 "stopship",
5336 "--fleet",
5337 "stopship",
5338 "--runtime",
5339 "tmux",
5340 "--issue",
5341 "4375",
5342 ]);
5343 assert!(matches!(
5344 cli.command,
5345 Some(Commands::Workflow(WorkflowArgs {
5346 command: WorkflowCommand::Run {
5347 ref workflow,
5348 ref fleet,
5349 ref runtime,
5350 ref issue,
5351 ..
5352 }
5353 })) if workflow == "stopship"
5354 && fleet == "stopship"
5355 && runtime == "tmux"
5356 && issue.as_deref() == Some("4375")
5357 ));
5358 }
5359
5360 #[test]
5361 fn exec_and_fleet_accept_builtin_and_raw_provider_identifiers() {
5362 let builtin = parse_ok(&["codewhale", "--provider", "openrouter", "exec", "Reply OK"]);
5363 assert_eq!(builtin.provider.as_deref(), Some("openrouter"));
5364 assert_eq!(
5365 top_level_provider_override(builtin.provider.as_deref(), builtin.command.as_ref())
5366 .expect("built-in Exec provider"),
5367 Some(ProviderKind::Openrouter)
5368 );
5369
5370 for (provider, command) in [
5371 ("qianfan", vec!["exec", "Reply OK"]),
5372 ("lm-studio", vec!["exec", "Reply OK"]),
5373 ("lm-studio", vec!["fleet", "status"]),
5374 ] {
5375 let argv = std::iter::once("codewhale")
5376 .chain(["--provider", provider])
5377 .chain(command.iter().copied())
5378 .collect::<Vec<_>>();
5379 let cli = parse_ok(&argv);
5380 assert_eq!(cli.provider.as_deref(), Some(provider));
5381 assert_eq!(
5382 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
5383 .expect("raw TUI provider"),
5384 None,
5385 "{argv:?} should defer the raw provider id to the TUI"
5386 );
5387 }
5388 }
5389
5390 #[test]
5391 fn opencode_go_provider_aliases_parse_as_builtin() {
5392 for alias in ["opencode-go", "opencode_go", "opencodego"] {
5393 assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeGo));
5394 }
5395 }
5396
5397 #[test]
5398 fn opencode_zen_provider_aliases_parse_as_builtin() {
5399 for alias in [
5400 "opencode-zen",
5401 "opencode_zen",
5402 "opencodezen",
5403 "zen",
5404 "opencode",
5405 ] {
5406 assert_eq!(builtin_provider_arg(alias), Some(ProviderArg::OpencodeZen));
5407 }
5408 }
5409
5410 #[test]
5411 fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
5412 let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
5413 let err = top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
5414 .expect_err("model registry commands still require a built-in provider");
5415 assert!(
5416 err.to_string()
5417 .contains("configured custom providers are accepted only by exec and fleet")
5418 );
5419
5420 let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
5421 .expect_err("auth keeps enum-only provider validation");
5422 assert_eq!(err.kind(), ErrorKind::InvalidValue);
5423
5424 let err = Cli::try_parse_from([
5425 "codewhale",
5426 "--provider",
5427 "../../lm-studio",
5428 "exec",
5429 "Reply OK",
5430 ])
5431 .expect_err("provider ids must stay simple tokens");
5432 assert!(
5433 err.to_string()
5434 .contains("provider must be a simple identifier")
5435 );
5436 }
5437
5438 #[test]
5439 fn persisted_custom_provider_crosses_config_and_root_tui_launch_boundary() {
5440 let _lock = env_lock();
5441 let (_tui_dir, _tui_bin) = install_fake_tui_binary();
5442 let dir = tempfile::TempDir::new().expect("tempdir");
5443 let config_path = dir.path().join("config.toml");
5444 std::fs::write(
5445 &config_path,
5446 r#"provider = "lm-studio"
5447
5448[providers.lm-studio]
5449kind = "openai-compatible"
5450base_url = "http://127.0.0.1:1234/v1"
5451model = "qwen-2.5-7b"
5452"#,
5453 )
5454 .expect("custom provider config fixture");
5455 let store = ConfigStore::load(Some(config_path.clone()))
5456 .expect("a TUI-persisted custom provider must cross the dispatcher parser");
5457 assert_eq!(store.config.provider, ProviderKind::Custom);
5458 assert_eq!(store.config.provider_id(), "lm-studio");
5459
5460 let resolved = store
5461 .config
5462 .resolve_runtime_options(&CliRuntimeOverrides::default());
5463 assert_eq!(resolved.provider, ProviderKind::Custom);
5464 assert_eq!(resolved.base_url, "http://127.0.0.1:1234/v1");
5465 assert_eq!(resolved.model, "qwen-2.5-7b");
5466
5467 let config = config_path.to_string_lossy().into_owned();
5468 let root_cli = parse_ok(&["codewhale", "--config", &config]);
5469 let root_command = build_tui_command(&root_cli, &resolved, Vec::new())
5470 .expect("root launch should reach the TUI command boundary");
5471 let root_args = root_command
5472 .get_args()
5473 .map(|arg| arg.to_string_lossy().into_owned())
5474 .collect::<Vec<_>>();
5475 assert!(
5476 root_args
5477 .windows(2)
5478 .any(|args| args == ["--config", &config])
5479 );
5480 assert_eq!(command_env(&root_command, "CODEWHALE_PROVIDER"), None);
5481 assert_eq!(command_env(&root_command, "DEEPSEEK_PROVIDER"), None);
5482
5483 let cli = parse_ok(&[
5484 "codewhale",
5485 "--config",
5486 &config,
5487 "--provider",
5488 "lm-studio",
5489 "exec",
5490 "Reply OK",
5491 ]);
5492 let prepared = prepare_raw_provider_tui_dispatch(
5493 &cli,
5494 cli.command.as_ref(),
5495 &CliRuntimeOverrides::default(),
5496 )
5497 .expect("prepare raw provider dispatch")
5498 .expect("Exec with a raw provider should bypass dispatcher config resolution");
5499 assert_eq!(prepared.1, ["exec", "Reply OK"].map(str::to_string));
5500 }
5501
5502 #[test]
5503 fn hidden_lane_log_proxy_parses_child_argv_and_preserves_other_commands() {
5504 let cli = parse_ok(&[
5505 "codewhale",
5506 "lane-log-proxy",
5507 "--log-path",
5508 "/tmp/lane.ndjson",
5509 "--receipt-path",
5510 "/tmp/lane.exit.json",
5511 "--receipt-tmp-path",
5512 "/tmp/lane.exit.json.tmp",
5513 "--environment-path",
5514 "/tmp/lane.env.json",
5515 "--lane-id",
5516 "lane-proof",
5517 "--",
5518 "/bin/echo",
5519 "--child-flag",
5520 "hello",
5521 ]);
5522 let (proxy, command) = split_lane_log_proxy_command(cli.command);
5523 assert!(command.is_none());
5524 let proxy = proxy.expect("proxy args");
5525 assert_eq!(proxy.lane_id, "lane-proof");
5526 assert_eq!(
5527 proxy.command,
5528 ["/bin/echo", "--child-flag", "hello"].map(str::to_string)
5529 );
5530
5531 let cli = parse_ok(&["codewhale", "lane", "list", "--json"]);
5532 let (proxy, command) = split_lane_log_proxy_command(cli.command);
5533 assert!(proxy.is_none());
5534 assert!(matches!(
5535 command,
5536 Some(Commands::Lane(LaneArgs {
5537 command: LaneCommand::List { json: true }
5538 }))
5539 ));
5540 }
5541
5542 #[test]
5545 fn cli_lane_subcommands_cover_the_shared_control_contract() {
5546 use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface};
5547
5548 for descriptor in codewhale_lane::control::operations_for_domain(ControlDomain::Lane) {
5549 let argv = [
5550 "codewhale".to_string(),
5551 "lane".to_string(),
5552 descriptor.verb.to_string(),
5553 ];
5554 let mut argv: Vec<&str> = argv.iter().map(String::as_str).collect();
5555 if descriptor.target.requires_identity() {
5556 argv.push("lane-a1b2c3d4");
5557 }
5558 let cli = parse_ok(&argv);
5559 let Some(Commands::Lane(args)) = cli.command else {
5560 panic!("`{}` must parse as a lane subcommand", descriptor.verb);
5561 };
5562 let parsed = match args.command {
5563 LaneCommand::List { .. } => ControlOperation::LaneList,
5564 LaneCommand::Status { .. } => ControlOperation::LaneStatus,
5565 LaneCommand::Interrupt { .. } | LaneCommand::Stop { .. } => {
5566 ControlOperation::LaneInterrupt
5567 }
5568 LaneCommand::Restart { .. } => ControlOperation::LaneRestart,
5569 LaneCommand::Resume { .. } => ControlOperation::LaneResume,
5570 other => panic!(
5571 "unexpected lane subcommand for {}: {other:?}",
5572 descriptor.verb
5573 ),
5574 };
5575 assert_eq!(
5576 parsed, descriptor.operation,
5577 "`codewhale lane {}` must map to {}",
5578 descriptor.verb, descriptor.id
5579 );
5580 assert!(
5581 descriptor.offers(ControlSurface::Cli),
5582 "{} must be declared on the CLI surface",
5583 descriptor.id
5584 );
5585 }
5586 }
5587
5588 #[test]
5590 fn lane_stop_and_interrupt_resolve_to_one_verb() {
5591 use codewhale_lane::{ControlDomain, ControlOperation};
5592
5593 for spelling in ["stop", "interrupt", "cancel", "kill"] {
5594 assert_eq!(
5595 ControlOperation::parse_verb(ControlDomain::Lane, spelling),
5596 Some(ControlOperation::LaneInterrupt),
5597 "{spelling}"
5598 );
5599 }
5600 let stop = parse_ok(&["codewhale", "lane", "stop", "lane-a1b2c3d4"]);
5601 assert!(matches!(
5602 stop.command,
5603 Some(Commands::Lane(LaneArgs {
5604 command: LaneCommand::Stop { .. }
5605 }))
5606 ));
5607 }
5608
5609 #[test]
5610 fn short_workflow_names_do_not_resolve_version_pinned_files() {
5611 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5612 .join("..")
5613 .join("..");
5614 let candidates = workflow_source_candidates("issue-sweep", None, &workspace);
5618 assert!(candidates.iter().all(|path| {
5619 !path
5620 .file_name()
5621 .is_some_and(|name| name.to_string_lossy().starts_with("v0868_"))
5622 }));
5623 assert!(resolve_workflow_source_path("issue-sweep", None, &workspace).is_err());
5624
5625 let explicit =
5628 resolve_workflow_source_path("workflows/stopship.workflow.js", None, &workspace)
5629 .expect("explicit workflow path");
5630 assert!(explicit.ends_with("workflows/stopship.workflow.js"));
5631 }
5632
5633 #[test]
5634 fn workflow_run_resolves_stopship_alias_and_payload() {
5635 let _lock = env_lock();
5636 let (_dir, _tui) = install_fake_tui_binary();
5637 let _provider = ScopedEnvVar::remove("DEEPSEEK_PROVIDER");
5638 let _model = ScopedEnvVar::remove("DEEPSEEK_MODEL");
5639 let _base_url = ScopedEnvVar::remove("DEEPSEEK_BASE_URL");
5640 let _api_key = ScopedEnvVar::remove("DEEPSEEK_API_KEY");
5641 let _cli_api_key = ScopedEnvVar::remove("CODEWHALE_CLI_API_KEY");
5642 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
5643 .join("..")
5644 .join("..");
5645 let cli = parse_ok(&[
5646 "codewhale",
5647 "--profile",
5648 "workflow-profile",
5649 "--model",
5650 "explicit-workflow-model",
5651 "--api-key",
5652 "explicit-profile-key",
5653 "--workspace",
5654 workspace.to_str().expect("workspace UTF-8"),
5655 ]);
5656 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
5657 let source = resolve_workflow_source_path("stopship", None, &workspace)
5658 .expect("stopship workflow source");
5659 assert!(source.ends_with("workflows/stopship.workflow.js"));
5660
5661 let process = workflow_exec_command(WorkflowExecSpec {
5662 cli: &cli,
5663 resolved_runtime: &resolved,
5664 config_path: &workspace.join("config.toml"),
5665 source_root: &workspace,
5666 source_path: &source,
5667 workflow: "stopship",
5668 fleet: "stopship",
5669 issue: Some("4375"),
5670 goal: Some("fix stopship"),
5671 token_budget: Some(25_000),
5672 verify: true,
5673 })
5674 .expect("command");
5675 let joined = process.command.join("\n");
5676 assert!(joined.contains("workflow-tool"));
5677 assert!(joined.contains("explicit-workflow-command"));
5678 assert!(joined.contains("--input-json"));
5679 assert!(!process.command.iter().any(|arg| arg == "exec"));
5680 assert!(!process.command.iter().any(|arg| arg == "--workspace"));
5681 assert!(
5682 process
5683 .command
5684 .windows(2)
5685 .any(|pair| pair == ["--profile", "workflow-profile"])
5686 );
5687 assert!(!joined.contains("Run the CodeWhale"));
5688 assert!(joined.contains("\"source_path\":\"workflows/stopship.workflow.js\""));
5689 assert!(joined.contains("\"fleet\":\"stopship\""));
5690 assert!(joined.contains("\"issue\":\"4375\""));
5691 assert!(joined.contains("\"token_budget\":25000"));
5692 assert!(joined.contains("\"verify\":true"));
5693 assert!(
5694 process.environment.iter().any(|(key, value)| {
5695 key == "DEEPSEEK_MODEL" && value == "explicit-workflow-model"
5696 })
5697 );
5698 assert!(
5699 !process
5700 .environment
5701 .iter()
5702 .any(|(key, _)| key == "DEEPSEEK_PROVIDER")
5703 );
5704 assert!(
5705 !process
5706 .environment
5707 .iter()
5708 .any(|(key, _)| key == "DEEPSEEK_BASE_URL")
5709 );
5710 assert!(
5711 !process
5712 .environment
5713 .iter()
5714 .any(|(key, _)| key == "DEEPSEEK_API_KEY")
5715 );
5716 assert!(process.environment.iter().any(|(key, value)| {
5717 key == "CODEWHALE_CLI_API_KEY" && value == "explicit-profile-key"
5718 }));
5719 assert!(
5720 !process
5721 .command
5722 .iter()
5723 .any(|argument| argument.contains("explicit-profile-key"))
5724 );
5725 assert!(
5726 process
5727 .environment
5728 .iter()
5729 .all(|(_, value)| value != "test-model")
5730 );
5731 }
5732
5733 #[test]
5734 fn exec_keeps_global_looking_flags_as_passthrough_args() {
5735 let cli = parse_ok(&[
5736 "codewhale",
5737 "exec",
5738 "--provider",
5739 "definitely-not-a-provider",
5740 "Reply OK",
5741 ]);
5742
5743 let Some(Commands::Exec(args)) = cli.command else {
5744 panic!("expected exec command");
5745 };
5746
5747 assert_eq!(
5748 args.args,
5749 vec![
5750 "--provider".to_string(),
5751 "definitely-not-a-provider".to_string(),
5752 "Reply OK".to_string(),
5753 ]
5754 );
5755 }
5756
5757 #[test]
5758 fn exec_rejects_provider_after_subcommand() {
5759 let args = vec![
5760 "--provider".to_string(),
5761 "definitely-not-a-provider".to_string(),
5762 "Reply OK".to_string(),
5763 ];
5764
5765 let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
5766
5767 assert!(
5768 err.to_string()
5769 .contains("--provider must be placed before `exec`")
5770 );
5771 }
5772
5773 #[test]
5774 fn exec_rejects_equals_form_provider_after_subcommand() {
5775 let args = vec!["--provider=openmodel".to_string(), "Reply OK".to_string()];
5776
5777 let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
5778
5779 assert!(
5780 err.to_string()
5781 .contains("--provider must be placed before `exec`")
5782 );
5783 }
5784
5785 #[test]
5786 fn exec_allows_documented_forwarded_flags() {
5787 let args = vec![
5788 "--auto".to_string(),
5789 "--output-format".to_string(),
5790 "stream-json".to_string(),
5791 "fix tests".to_string(),
5792 ];
5793
5794 reject_exec_global_flags(&args).expect("documented exec flags should pass");
5795 }
5796
5797 #[test]
5798 fn exec_allows_literal_prompt_flags_after_separator() {
5799 let args = vec![
5800 "--".to_string(),
5801 "--provider".to_string(),
5802 "is literal prompt text".to_string(),
5803 ];
5804
5805 reject_exec_global_flags(&args).expect("separator should stop global flag validation");
5806 }
5807
5808 #[test]
5809 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
5810 assert!(should_pick_resume_in_dispatcher(
5811 &["resume".to_string()],
5812 true
5813 ));
5814 assert!(!should_pick_resume_in_dispatcher(
5815 &["resume".to_string(), "--last".to_string()],
5816 true
5817 ));
5818 assert!(!should_pick_resume_in_dispatcher(
5819 &["resume".to_string(), "abc123".to_string()],
5820 true
5821 ));
5822 assert!(!should_pick_resume_in_dispatcher(
5823 &["resume".to_string()],
5824 false
5825 ));
5826 }
5827
5828 #[test]
5829 fn deepseek_login_uses_isolated_file_store_and_preserves_tui_defaults() {
5830 let _lock = env_lock();
5831 let dir = tempfile::TempDir::new().expect("tempdir");
5832 let codewhale_home = dir.path().join("codewhale-home");
5833 let codewhale_home_value = codewhale_home.to_string_lossy().into_owned();
5834 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home_value);
5835 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
5836 let path = codewhale_home.join("config.toml");
5837 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
5838 let secrets = Secrets::auto_detect();
5839
5840 run_login_command_with_secrets(
5841 &mut store,
5842 LoginArgs {
5843 provider: Some(ProviderArg::Deepseek),
5844 api_key: Some("sk-test".to_string()),
5845 },
5846 &secrets,
5847 )
5848 .expect("login should persist credential");
5849
5850 assert!(store.config.api_key.is_none());
5851 assert!(store.config.providers.deepseek.api_key.is_none());
5852 assert_eq!(
5853 store.config.default_text_model.as_deref(),
5854 Some("deepseek-v4-pro")
5855 );
5856 let saved = std::fs::read_to_string(&path).expect("config should be written");
5857 assert!(!saved.contains("sk-test"), "{saved}");
5858 assert!(
5859 !saved
5860 .lines()
5861 .any(|line| line.trim_start().starts_with("api_key ="))
5862 );
5863 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
5864 assert_eq!(
5865 secrets.get("deepseek").expect("read secret").as_deref(),
5866 Some("sk-test")
5867 );
5868 }
5869
5870 #[test]
5871 fn parses_auth_subcommand_matrix() {
5872 let cli = parse_ok(&["deepseek", "auth", "xai-device"]);
5873 assert!(matches!(
5874 cli.command,
5875 Some(Commands::Auth(AuthArgs {
5876 command: AuthCommand::XaiDevice
5877 }))
5878 ));
5879
5880 let cli = parse_ok(&[
5881 "deepseek",
5882 "auth",
5883 "external-consent",
5884 "--provider",
5885 "openai-codex",
5886 "--mode",
5887 "read-only",
5888 "--path",
5889 "/tmp/codex-auth.json",
5890 "--yes",
5891 ]);
5892 assert!(matches!(
5893 cli.command,
5894 Some(Commands::Auth(AuthArgs {
5895 command: AuthCommand::ExternalConsent {
5896 provider: ProviderArg::OpenaiCodex,
5897 mode: ExternalCredentialModeArg::ReadOnly,
5898 path: Some(_),
5899 yes: true,
5900 }
5901 }))
5902 ));
5903
5904 let cli = parse_ok(&["deepseek", "auth", "external-revoke", "--provider", "xai"]);
5905 assert!(matches!(
5906 cli.command,
5907 Some(Commands::Auth(AuthArgs {
5908 command: AuthCommand::ExternalRevoke {
5909 provider: ProviderArg::Xai,
5910 }
5911 }))
5912 ));
5913
5914 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
5915 assert!(matches!(
5916 cli.command,
5917 Some(Commands::Auth(AuthArgs {
5918 command: AuthCommand::Set {
5919 provider: ProviderArg::Deepseek,
5920 api_key: None,
5921 api_key_stdin: false,
5922 }
5923 }))
5924 ));
5925
5926 let cli = parse_ok(&[
5927 "deepseek",
5928 "auth",
5929 "set",
5930 "--provider",
5931 "openrouter",
5932 "--api-key-stdin",
5933 ]);
5934 assert!(matches!(
5935 cli.command,
5936 Some(Commands::Auth(AuthArgs {
5937 command: AuthCommand::Set {
5938 provider: ProviderArg::Openrouter,
5939 api_key: None,
5940 api_key_stdin: true,
5941 }
5942 }))
5943 ));
5944
5945 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
5946 assert!(matches!(
5947 cli.command,
5948 Some(Commands::Auth(AuthArgs {
5949 command: AuthCommand::Get {
5950 provider: ProviderArg::Novita
5951 }
5952 }))
5953 ));
5954
5955 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
5956 assert!(matches!(
5957 cli.command,
5958 Some(Commands::Auth(AuthArgs {
5959 command: AuthCommand::Clear {
5960 provider: ProviderArg::NvidiaNim
5961 }
5962 }))
5963 ));
5964
5965 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
5966 assert!(matches!(
5967 cli.command,
5968 Some(Commands::Auth(AuthArgs {
5969 command: AuthCommand::Set {
5970 provider: ProviderArg::Fireworks,
5971 api_key: None,
5972 api_key_stdin: false,
5973 }
5974 }))
5975 ));
5976
5977 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
5978 assert!(matches!(
5979 cli.command,
5980 Some(Commands::Auth(AuthArgs {
5981 command: AuthCommand::Set {
5982 provider: ProviderArg::Siliconflow,
5983 api_key: None,
5984 api_key_stdin: false,
5985 }
5986 }))
5987 ));
5988
5989 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
5990 assert!(matches!(
5991 cli.command,
5992 Some(Commands::Auth(AuthArgs {
5993 command: AuthCommand::Set {
5994 provider: ProviderArg::Arcee,
5995 api_key: None,
5996 api_key_stdin: false,
5997 }
5998 }))
5999 ));
6000
6001 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
6002 assert!(matches!(
6003 cli.command,
6004 Some(Commands::Auth(AuthArgs {
6005 command: AuthCommand::Set {
6006 provider: ProviderArg::Moonshot,
6007 api_key: None,
6008 api_key_stdin: false,
6009 }
6010 }))
6011 ));
6012
6013 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
6014 assert!(matches!(
6015 cli.command,
6016 Some(Commands::Auth(AuthArgs {
6017 command: AuthCommand::Set {
6018 provider: ProviderArg::WanjieArk,
6019 api_key: None,
6020 api_key_stdin: false,
6021 }
6022 }))
6023 ));
6024
6025 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
6026 assert!(matches!(
6027 cli.command,
6028 Some(Commands::Auth(AuthArgs {
6029 command: AuthCommand::Get {
6030 provider: ProviderArg::Sglang
6031 }
6032 }))
6033 ));
6034
6035 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
6036 assert!(matches!(
6037 cli.command,
6038 Some(Commands::Auth(AuthArgs {
6039 command: AuthCommand::Get {
6040 provider: ProviderArg::Vllm
6041 }
6042 }))
6043 ));
6044
6045 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
6046 assert!(matches!(
6047 cli.command,
6048 Some(Commands::Auth(AuthArgs {
6049 command: AuthCommand::Set {
6050 provider: ProviderArg::Ollama,
6051 api_key: None,
6052 api_key_stdin: false,
6053 }
6054 }))
6055 ));
6056
6057 let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
6058 assert!(matches!(
6059 cli.command,
6060 Some(Commands::Auth(AuthArgs {
6061 command: AuthCommand::Status {
6062 provider: Some(ProviderArg::OpenaiCodex)
6063 }
6064 }))
6065 ));
6066
6067 for (provider, expected) in [
6068 ("anthropic", ProviderArg::Anthropic),
6069 ("openmodel", ProviderArg::Openmodel),
6070 ("open-model", ProviderArg::Openmodel),
6071 ("zai", ProviderArg::Zai),
6072 ("stepfun", ProviderArg::Stepfun),
6073 ("minimax", ProviderArg::Minimax),
6074 ("minimax-anthropic", ProviderArg::MinimaxAnthropic),
6075 ("minimax_anthropic", ProviderArg::MinimaxAnthropic),
6076 ("deepinfra", ProviderArg::Deepinfra),
6077 ("deep-infra", ProviderArg::Deepinfra),
6078 ("siliconflow-cn", ProviderArg::SiliconflowCn),
6079 ("siliconflow-CN", ProviderArg::SiliconflowCn),
6080 ("siliconflow_china", ProviderArg::SiliconflowCn),
6081 ] {
6082 let cli = parse_ok(&[
6083 "deepseek",
6084 "auth",
6085 "set",
6086 "--provider",
6087 provider,
6088 "--api-key-stdin",
6089 ]);
6090 assert!(matches!(
6091 cli.command,
6092 Some(Commands::Auth(AuthArgs {
6093 command: AuthCommand::Set {
6094 provider,
6095 api_key: None,
6096 api_key_stdin: true,
6097 }
6098 })) if provider == expected
6099 ));
6100 }
6101
6102 let cli = parse_ok(&["deepseek", "auth", "list"]);
6103 assert!(matches!(
6104 cli.command,
6105 Some(Commands::Auth(AuthArgs {
6106 command: AuthCommand::List
6107 }))
6108 ));
6109
6110 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
6111 assert!(matches!(
6112 cli.command,
6113 Some(Commands::Auth(AuthArgs {
6114 command: AuthCommand::Migrate { dry_run: false }
6115 }))
6116 ));
6117
6118 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
6119 assert!(matches!(
6120 cli.command,
6121 Some(Commands::Auth(AuthArgs {
6122 command: AuthCommand::Migrate { dry_run: true }
6123 }))
6124 ));
6125 }
6126
6127 #[test]
6128 fn auth_help_describes_runtime_effective_diagnostics() {
6129 let get = help_for(&["codewhale", "auth", "get", "--help"]);
6130 assert!(get.contains("effective credential route"), "{get}");
6131 assert!(get.contains("structural OAuth/repair state"), "{get}");
6132
6133 let status = help_for(&["codewhale", "auth", "status", "--help"]);
6134 assert!(
6135 status.contains("runtime-effective credential route state"),
6136 "{status}"
6137 );
6138
6139 let list = help_for(&["codewhale", "auth", "list", "--help"]);
6140 assert!(list.contains("runtime-effective auth state"), "{list}");
6141 }
6142
6143 #[test]
6144 fn auth_set_writes_secret_store_and_keeps_config_credential_free() {
6145 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6146 use std::sync::Arc;
6147
6148 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6149 let path = std::env::temp_dir().join(format!(
6150 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
6151 std::process::id()
6152 ));
6153 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6154 let inner = Arc::new(InMemoryKeyringStore::new());
6155 let secrets = Secrets::new(inner.clone());
6156
6157 run_auth_command_with_secrets(
6158 &mut store,
6159 AuthCommand::Set {
6160 provider: ProviderArg::Deepseek,
6161 api_key: Some("sk-keyring".to_string()),
6162 api_key_stdin: false,
6163 },
6164 &secrets,
6165 )
6166 .expect("set should succeed");
6167
6168 assert!(store.config.api_key.is_none());
6169 assert!(store.config.providers.deepseek.api_key.is_none());
6170 let saved = std::fs::read_to_string(&path).unwrap_or_default();
6171 assert!(!saved.contains("sk-keyring"), "{saved}");
6172 assert!(
6173 !saved
6174 .lines()
6175 .any(|line| line.trim_start().starts_with("api_key ="))
6176 );
6177 assert_eq!(
6178 inner.get("deepseek").unwrap().as_deref(),
6179 Some("sk-keyring")
6180 );
6181
6182 let _ = std::fs::remove_file(path);
6183 }
6184
6185 #[test]
6186 fn auth_set_uses_plaintext_config_only_when_secret_store_write_fails() {
6187 use codewhale_secrets::{KeyringStore, SecretsError};
6188 use std::sync::Arc;
6189
6190 struct FailingStore;
6191
6192 impl KeyringStore for FailingStore {
6193 fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
6194 Ok(None)
6195 }
6196
6197 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
6198 Err(SecretsError::Keyring("test write failure".to_string()))
6199 }
6200
6201 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
6202 Ok(())
6203 }
6204
6205 fn backend_name(&self) -> &'static str {
6206 "failing test store"
6207 }
6208 }
6209
6210 let dir = tempfile::TempDir::new().expect("tempdir");
6211 let path = dir.path().join("config.toml");
6212 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
6213 let secrets = Secrets::new(Arc::new(FailingStore));
6214
6215 run_auth_command_with_secrets(
6216 &mut store,
6217 AuthCommand::Set {
6218 provider: ProviderArg::Openrouter,
6219 api_key: Some("fallback-test-credential".to_string()),
6220 api_key_stdin: false,
6221 },
6222 &secrets,
6223 )
6224 .expect("config fallback");
6225
6226 assert_eq!(
6227 store.config.providers.openrouter.api_key.as_deref(),
6228 Some("fallback-test-credential")
6229 );
6230 let saved = std::fs::read_to_string(path).expect("config fallback file");
6231 assert!(saved.contains("fallback-test-credential"));
6232 }
6233
6234 #[test]
6235 fn auth_set_provider_key_does_not_switch_active_provider() {
6236 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6237 let path = std::env::temp_dir().join(format!(
6238 "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
6239 std::process::id()
6240 ));
6241 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6242 store.config.provider = ProviderKind::Deepseek;
6243 let secrets = no_keyring_secrets();
6244
6245 run_auth_command_with_secrets(
6246 &mut store,
6247 AuthCommand::Set {
6248 provider: ProviderArg::Arcee,
6249 api_key: Some("arcee-key".to_string()),
6250 api_key_stdin: false,
6251 },
6252 &secrets,
6253 )
6254 .expect("set should succeed");
6255
6256 assert_eq!(store.config.provider, ProviderKind::Deepseek);
6257 assert!(store.config.providers.arcee.api_key.is_none());
6258 assert_eq!(
6259 store.config.providers.arcee.auth_mode.as_deref(),
6260 Some("api_key")
6261 );
6262
6263 let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
6264 assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
6265 assert!(reloaded.config.providers.arcee.api_key.is_none());
6266 assert_eq!(
6267 reloaded.config.providers.arcee.auth_mode.as_deref(),
6268 Some("api_key")
6269 );
6270
6271 let _ = std::fs::remove_file(path);
6272 }
6273
6274 #[test]
6275 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
6276 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6277 let path = std::env::temp_dir().join(format!(
6278 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
6279 std::process::id()
6280 ));
6281 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6282 store.config.provider = ProviderKind::Deepseek;
6283 let secrets = no_keyring_secrets();
6284
6285 run_auth_command_with_secrets(
6286 &mut store,
6287 AuthCommand::Set {
6288 provider: ProviderArg::Ollama,
6289 api_key: None,
6290 api_key_stdin: false,
6291 },
6292 &secrets,
6293 )
6294 .expect("ollama auth set should not require a key");
6295
6296 assert_eq!(store.config.provider, ProviderKind::Deepseek);
6297 assert_eq!(
6298 store.config.providers.ollama.base_url.as_deref(),
6299 Some("http://localhost:11434/v1")
6300 );
6301 assert_eq!(store.config.providers.ollama.api_key, None);
6302
6303 let _ = std::fs::remove_file(path);
6304 }
6305
6306 #[test]
6307 fn auth_clear_removes_from_config() {
6308 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6309 use std::sync::Arc;
6310
6311 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6312 let path = std::env::temp_dir().join(format!(
6313 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
6314 std::process::id()
6315 ));
6316 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6317 store.config.api_key = Some("sk-stale".to_string());
6318 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
6319 store.save().unwrap();
6320
6321 let inner = Arc::new(InMemoryKeyringStore::new());
6322 inner.set("deepseek", "sk-stale").unwrap();
6323 let secrets = Secrets::new(inner.clone());
6324
6325 run_auth_command_with_secrets(
6326 &mut store,
6327 AuthCommand::Clear {
6328 provider: ProviderArg::Deepseek,
6329 },
6330 &secrets,
6331 )
6332 .expect("clear should succeed");
6333
6334 assert!(store.config.api_key.is_none());
6335 assert!(store.config.providers.deepseek.api_key.is_none());
6336 assert_eq!(inner.get("deepseek").unwrap(), None);
6337
6338 let _ = std::fs::remove_file(path);
6339 }
6340
6341 #[test]
6342 fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
6343 use codewhale_secrets::{KeyringStore, SecretsError};
6344 use std::sync::{Arc, Mutex};
6345
6346 #[derive(Default)]
6347 struct RecordingStore {
6348 gets: Mutex<Vec<String>>,
6349 }
6350
6351 impl KeyringStore for RecordingStore {
6352 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
6353 self.gets.lock().unwrap().push(key.to_string());
6354 Ok(None)
6355 }
6356
6357 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
6358 Ok(())
6359 }
6360
6361 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
6362 Ok(())
6363 }
6364
6365 fn backend_name(&self) -> &'static str {
6366 "recording"
6367 }
6368 }
6369
6370 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6371 let path = std::env::temp_dir().join(format!(
6372 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
6373 std::process::id()
6374 ));
6375 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6376 store.config.provider = ProviderKind::Deepseek;
6377 let inner = Arc::new(RecordingStore::default());
6378 let secrets = Secrets::new(inner.clone());
6379
6380 run_auth_command_with_secrets(
6381 &mut store,
6382 AuthCommand::Status {
6383 provider: Some(ProviderArg::Deepseek),
6384 },
6385 &secrets,
6386 )
6387 .expect("status should succeed");
6388 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
6389 .expect("list should succeed");
6390
6391 let probed = inner.gets.lock().unwrap();
6392 assert_eq!(probed[0], "deepseek");
6394 assert!(probed.len() > 1, "list should probe all providers");
6397 assert!(
6398 ProviderKind::ALL
6399 .iter()
6400 .all(|p| probed.contains(&provider_slot(*p).to_string())),
6401 "every known provider should be probed by auth list: {:?}",
6402 *probed
6403 );
6404
6405 let _ = std::fs::remove_file(path);
6406 }
6407
6408 #[test]
6409 fn auth_status_reports_all_active_provider_sources_with_last4() {
6410 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6411 use std::sync::Arc;
6412
6413 let _lock = env_lock();
6414 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
6415
6416 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6417 let path = std::env::temp_dir().join(format!(
6418 "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
6419 std::process::id()
6420 ));
6421 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6422 store.config.provider = ProviderKind::Deepseek;
6423 store.config.api_key = Some("sk-config-3333".to_string());
6424 store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
6425
6426 let inner = Arc::new(InMemoryKeyringStore::new());
6427 inner.set("deepseek", "sk-keyring-2222").unwrap();
6428 let secrets = Secrets::new(inner);
6429
6430 let output =
6431 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
6432
6433 assert!(output.contains("provider: deepseek"));
6434 assert!(output.contains("active source: config (last4: ...3333)"));
6435 assert!(output.contains("lookup order: config -> secret store -> env"));
6436 assert!(output.contains("config file: "));
6437 assert!(output.contains("set, last4: ...3333"));
6438 assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
6439 assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
6440 assert!(!output.contains("sk-config-3333"));
6441 assert!(!output.contains("sk-keyring-2222"));
6442 assert!(!output.contains("sk-env-1111"));
6443
6444 let _ = std::fs::remove_file(path);
6445 }
6446
6447 #[test]
6448 fn auth_status_all_providers_lists_every_known_provider() {
6449 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
6450 use std::sync::Arc;
6451
6452 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
6453 let path = std::env::temp_dir().join(format!(
6454 "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
6455 std::process::id()
6456 ));
6457 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
6458 store.config.provider = ProviderKind::Deepseek;
6459 store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
6460
6461 let inner = Arc::new(InMemoryKeyringStore::new());
6462 inner.set("openrouter", "sk-or-test5678").unwrap();
6463 let secrets = Secrets::new(inner);
6464
6465 let output = auth_status_all_providers(&store, &secrets).join("\n");
6466
6467 assert!(output.contains("deepseek"));
6469 assert!(output.contains("arcee"));
6470 assert!(output.contains("openrouter"));
6471 assert!(output.contains("huggingface"));
6472 assert!(output.contains("ollama"));
6473
6474 assert!(output.contains("deepseek") && output.contains("*"));
6476
6477 assert!(output.contains("config"));
6479
6480 assert!(!output.contains("sk-arcee-test1234"));
6482 assert!(!output.contains("sk-or-test5678"));
6483
6484 let _ = std::fs::remove_file(path);
6485 }
6486
6487 #[test]
6488 fn auth_status_never_probes_codex_file_and_reports_exact_consent() {
6489 use codewhale_secrets::InMemoryKeyringStore;
6490 use std::sync::Arc;
6491
6492 let _lock = env_lock();
6493 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
6494 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
6495
6496 let dir = tempfile::TempDir::new().expect("tempdir");
6497 let config_path = dir.path().join("config.toml");
6498 let auth_path = dir.path().join("auth.json");
6499 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
6500 .expect("write auth file");
6501 let auth_path_str = auth_path.to_string_lossy().into_owned();
6502 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
6503
6504 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
6505 store.config.provider = ProviderKind::OpenaiCodex;
6506 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
6507
6508 let output =
6509 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
6510
6511 assert!(output.contains("provider: openai-codex"));
6512 assert!(output.contains("auth mode: codex_oauth"));
6513 assert!(output.contains("active source: missing"));
6514 assert!(output.contains("lookup order: env -> consent-gated exact Codex CLI file"));
6515 assert!(output.contains("external credentials: disabled"));
6516 assert!(output.contains("scope_valid=false"));
6517 assert!(output.contains("disabled; no external-credential probing, reading"));
6518 assert!(output.contains("file not probed"));
6519 assert!(!output.contains("secret-token"));
6520
6521 store.config.providers.openai_codex.external_credentials =
6522 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
6523 ProviderKind::OpenaiCodex,
6524 codewhale_config::ExternalCredentialSource::CodexCli,
6525 auth_path.clone(),
6526 ));
6527 let output =
6528 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
6529 assert!(
6530 output.contains("active source: external read-only consent (availability not probed)")
6531 );
6532 assert!(output.contains("external credentials: read_only"));
6533 assert!(output.contains("provider=openai-codex"));
6534 assert!(output.contains("source=codex_cli"));
6535 assert!(output.contains(&format!(
6536 "path={}",
6537 codewhale_config::quote_os_path(&auth_path)
6538 )));
6539 assert!(output.contains(&format!(
6540 "consent_version={}",
6541 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
6542 )));
6543 assert!(output.contains("file not probed"));
6544 assert!(!output.contains("secret-token"));
6545
6546 let ambient_path = dir.path().join("new-ambient-auth.json");
6547 let ambient_path_str = ambient_path.to_string_lossy().into_owned();
6548 let _ambient_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &ambient_path_str);
6549 let changed =
6550 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
6551 assert!(changed.contains("state=active"), "{changed}");
6552 assert!(changed.contains("ambient_path_changed=true"), "{changed}");
6553 assert!(changed.contains("consent remains pinned"), "{changed}");
6554 assert!(
6555 changed.contains(&codewhale_config::quote_os_path(&auth_path)),
6556 "{changed}"
6557 );
6558 assert!(!changed.contains(&ambient_path_str), "{changed}");
6559 }
6560
6561 #[test]
6562 fn xai_valid_owned_generation_blocks_external_consent_without_storage_probes() {
6563 use std::sync::Arc;
6564
6565 let _lock = env_lock();
6566 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
6567 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
6568 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
6569 let dir = tempfile::TempDir::new().expect("tempdir");
6570 let config_path = dir.path().join("config.toml");
6571 let external_path = dir.path().join("grok-auth.json");
6572 let external_raw = "external owner bytes must not be read";
6573 std::fs::write(&external_path, external_raw).expect("external auth trap");
6574 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
6575
6576 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
6577 store.config.provider = ProviderKind::Xai;
6578 store.config.providers.xai.auth_mode = Some("oauth".to_string());
6579 store.config.providers.xai.oauth_credential_generation =
6580 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
6581 store.config.providers.xai.external_credentials =
6582 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
6583 ProviderKind::Xai,
6584 codewhale_config::ExternalCredentialSource::GrokCli,
6585 external_path.clone(),
6586 ));
6587 let keyring = Arc::new(RecordingKeyringStore::default());
6588 let secrets = Secrets::new(keyring.clone());
6589
6590 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
6591 assert!(
6592 scoped.contains(
6593 "credential route: Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
6594 ),
6595 "{scoped}"
6596 );
6597 assert!(scoped.contains("external credentials: blocked by the configured Codewhale-owned xAI OAuth generation"), "{scoped}");
6598 assert!(
6599 scoped.contains(
6600 "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
6601 ),
6602 "{scoped}"
6603 );
6604 assert!(
6605 !scoped.contains("active source: Codewhale-owned OAuth"),
6606 "a valid pointer is configured/unprobed, not an active credential: {scoped}"
6607 );
6608 assert!(
6609 !scoped.contains("fallback"),
6610 "an owned generation must never advertise Grok CLI fallback: {scoped}"
6611 );
6612
6613 let all = auth_status_all_providers(&store, &secrets).join("\n");
6614 let xai_row = all
6615 .lines()
6616 .find(|line| line.starts_with("xai"))
6617 .expect("xAI status row");
6618 assert!(
6619 xai_row.contains("Codewhale-owned OAuth configured/unprobed"),
6620 "{xai_row}"
6621 );
6622
6623 let list = auth_list_lines(&store, &secrets).join("\n");
6624 let xai_list_row = list
6625 .lines()
6626 .find(|line| line.starts_with("xai"))
6627 .expect("xAI list row");
6628 assert!(
6629 xai_list_row.ends_with("owned-oauth-configured"),
6630 "{xai_list_row}"
6631 );
6632
6633 let get = auth_get_line_with_runtime(
6634 &store,
6635 &secrets,
6636 ProviderKind::Xai,
6637 &CliRuntimeOverrides::default(),
6638 );
6639 assert!(
6640 get.starts_with("xai: configured (source: Codewhale-owned OAuth generation"),
6641 "{get}"
6642 );
6643 assert!(!get.starts_with("xai: set"), "{get}");
6644 assert!(!get.contains("fallback"), "{get}");
6645 assert!(
6646 !keyring.queried().iter().any(|slot| slot == "xai"),
6647 "owned OAuth diagnostics must not query the xAI API-key store: {:?}",
6648 keyring.queried()
6649 );
6650 assert_eq!(
6651 std::fs::read_to_string(external_path).expect("external trap unchanged"),
6652 external_raw
6653 );
6654
6655 store.config.providers.xai.auth_mode = None;
6656 store.config.auth_mode = Some("oauth".to_string());
6657 assert_eq!(
6658 xai_auth_diagnostics(&store, &CliRuntimeOverrides::default()).route,
6659 XaiAuthDiagnosticRoute::ApiKey,
6660 "a root auth mode must not select the xAI OAuth runtime route"
6661 );
6662 }
6663
6664 #[test]
6665 fn xai_invalid_generation_requires_repair_blocks_external_and_keeps_api_key_diagnostics() {
6666 use std::sync::Arc;
6667
6668 let _lock = env_lock();
6669 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
6670 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
6671 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
6672 let dir = tempfile::TempDir::new().expect("tempdir");
6673 let config_path = dir.path().join("config.toml");
6674 let external_path = dir.path().join("grok-auth.json");
6675 let external_raw = "external owner bytes must remain unread";
6676 std::fs::write(&external_path, external_raw).expect("external auth trap");
6677 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
6678
6679 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
6680 store.config.provider = ProviderKind::Xai;
6681 store.config.providers.xai.auth_mode = Some("oauth".to_string());
6682 store.config.providers.xai.api_key = Some("fake-cfg-key-1234".to_string());
6683 store.config.providers.xai.oauth_credential_generation = Some("../unsafe.json".to_string());
6684 store.config.providers.xai.external_credentials =
6685 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
6686 ProviderKind::Xai,
6687 codewhale_config::ExternalCredentialSource::GrokCli,
6688 external_path.clone(),
6689 ));
6690 let keyring = Arc::new(RecordingKeyringStore::default());
6691 let secrets = Secrets::new(keyring.clone());
6692
6693 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
6694 assert!(
6695 scoped.contains("credential route: xAI OAuth needs repair"),
6696 "{scoped}"
6697 );
6698 assert!(
6699 scoped.contains("API-key fallback: config (last4: ...1234)"),
6700 "{scoped}"
6701 );
6702 assert!(scoped.contains("external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer"), "{scoped}");
6703 assert!(
6704 scoped.contains("repair: run `codewhale auth xai-device`"),
6705 "{scoped}"
6706 );
6707 assert!(
6708 !scoped.contains("external read-only consent (availability not probed)"),
6709 "invalid owned pointers must not activate Grok CLI consent: {scoped}"
6710 );
6711
6712 let all = auth_status_all_providers(&store, &secrets).join("\n");
6713 let xai_row = all
6714 .lines()
6715 .find(|line| line.starts_with("xai"))
6716 .expect("xAI status row");
6717 assert!(xai_row.contains("needs repair"), "{xai_row}");
6718 assert!(xai_row.contains("API-key fallback: config"), "{xai_row}");
6719
6720 let list = auth_list_lines(&store, &secrets).join("\n");
6721 let xai_list_row = list
6722 .lines()
6723 .find(|line| line.starts_with("xai"))
6724 .expect("xAI list row");
6725 assert!(xai_list_row.ends_with("needs-repair"), "{xai_list_row}");
6726
6727 let get = auth_get_line_with_runtime(
6728 &store,
6729 &secrets,
6730 ProviderKind::Xai,
6731 &CliRuntimeOverrides::default(),
6732 );
6733 assert!(get.contains("xai: needs repair"), "{get}");
6734 assert!(get.contains("API-key fallback: config-file"), "{get}");
6735 assert!(
6736 !keyring.queried().iter().any(|slot| slot == "xai"),
6737 "an invalid owned pointer must not query the xAI API-key store: {:?}",
6738 keyring.queried()
6739 );
6740 assert_eq!(
6741 std::fs::read_to_string(external_path).expect("external trap unchanged"),
6742 external_raw
6743 );
6744 }
6745
6746 #[test]
6747 fn xai_cli_custom_endpoint_rejects_inherited_api_key_sources() {
6748 use std::sync::Arc;
6749
6750 let _lock = env_lock();
6751 let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-3333");
6752 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
6753 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
6754 let dir = tempfile::TempDir::new().expect("tempdir");
6755 let config_path = dir.path().join("config.toml");
6756 let external_path = dir.path().join("grok-auth.json");
6757 let external_raw = "external owner bytes must remain unprobed";
6758 std::fs::write(&external_path, external_raw).expect("external auth trap");
6759 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
6760
6761 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
6762 store.config.provider = ProviderKind::Xai;
6763 store.config.providers.xai.api_key = Some("fake-cfg-key-1111".to_string());
6764 store.config.providers.xai.auth_mode = Some("oauth".to_string());
6765 store.config.providers.xai.oauth_credential_generation =
6766 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
6767 store.config.providers.xai.external_credentials =
6768 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
6769 ProviderKind::Xai,
6770 codewhale_config::ExternalCredentialSource::GrokCli,
6771 external_path.clone(),
6772 ));
6773 let keyring = Arc::new(RecordingKeyringStore::default());
6774 keyring.set_value("xai", "fake-store-key-2222");
6775 let secrets = Secrets::new(keyring.clone());
6776 let runtime_overrides = CliRuntimeOverrides {
6777 base_url: Some("https://gateway.example.test/v1".to_string()),
6778 ..CliRuntimeOverrides::default()
6779 };
6780
6781 let scoped = auth_status_lines_for_provider_with_runtime(
6782 &store,
6783 &secrets,
6784 ProviderKind::Xai,
6785 &runtime_overrides,
6786 )
6787 .join("\n");
6788 assert!(
6789 scoped.contains("route: https://gateway.example.test/v1"),
6790 "{scoped}"
6791 );
6792 assert!(scoped.contains("credential route: missing"), "{scoped}");
6793 assert!(
6794 scoped.contains("custom xAI endpoint; API-key-only"),
6795 "{scoped}"
6796 );
6797 assert!(
6798 scoped.contains("not eligible for this custom xAI endpoint"),
6799 "{scoped}"
6800 );
6801 assert!(
6802 scoped.contains("external credentials: unavailable on a custom xAI endpoint"),
6803 "{scoped}"
6804 );
6805 for redacted_tail in ["...1111", "...2222", "...3333"] {
6806 assert!(
6807 !scoped.contains(redacted_tail),
6808 "custom CLI route must not advertise an inherited credential: {scoped}"
6809 );
6810 }
6811
6812 let all =
6813 auth_status_all_providers_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
6814 let xai_row = all
6815 .lines()
6816 .find(|line| line.starts_with("xai"))
6817 .expect("xAI status row");
6818 assert!(xai_row.contains("unset"), "{xai_row}");
6819 assert!(
6820 !xai_row.contains("config") && !xai_row.contains("keyring") && !xai_row.contains("env"),
6821 "xAI summary must show runtime-effective sources only: {xai_row}"
6822 );
6823
6824 let list = auth_list_lines_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
6825 let xai_list_row = list
6826 .lines()
6827 .find(|line| line.starts_with("xai"))
6828 .expect("xAI list row");
6829 assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
6830
6831 let get =
6832 auth_get_line_with_runtime(&store, &secrets, ProviderKind::Xai, &runtime_overrides);
6833 assert_eq!(get, "xai: not set");
6834 assert!(
6835 !keyring.queried().iter().any(|slot| slot == "xai"),
6836 "a global custom endpoint must not query xAI keyring state: {:?}",
6837 keyring.queried()
6838 );
6839 assert_eq!(
6840 std::fs::read_to_string(external_path).expect("external trap unchanged"),
6841 external_raw
6842 );
6843 }
6844
6845 #[test]
6846 fn xai_env_custom_endpoint_rejects_inherited_api_key_sources() {
6847 use std::sync::Arc;
6848
6849 let _lock = env_lock();
6850 let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-6666");
6851 let _xai_base = ScopedEnvVar::set("XAI_BASE_URL", "https://env-gateway.example.test/v1");
6852 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
6853 let dir = tempfile::TempDir::new().expect("tempdir");
6854 let config_path = dir.path().join("config.toml");
6855 let external_path = dir.path().join("grok-auth.json");
6856 let external_raw = "external owner bytes must remain unprobed";
6857 std::fs::write(&external_path, external_raw).expect("external auth trap");
6858 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
6859
6860 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
6861 store.config.provider = ProviderKind::Xai;
6862 store.config.providers.xai.api_key = Some("fake-cfg-key-4444".to_string());
6863 store.config.providers.xai.auth_mode = Some("oauth".to_string());
6864 store.config.providers.xai.oauth_credential_generation =
6865 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
6866 store.config.providers.xai.external_credentials =
6867 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
6868 ProviderKind::Xai,
6869 codewhale_config::ExternalCredentialSource::GrokCli,
6870 external_path.clone(),
6871 ));
6872 let keyring = Arc::new(RecordingKeyringStore::default());
6873 keyring.set_value("xai", "fake-store-key-5555");
6874 let secrets = Secrets::new(keyring.clone());
6875
6876 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
6877 assert!(
6878 scoped.contains("route: https://env-gateway.example.test/v1"),
6879 "{scoped}"
6880 );
6881 assert!(scoped.contains("credential route: missing"), "{scoped}");
6882 assert!(
6883 scoped.contains("custom xAI endpoint; API-key-only"),
6884 "{scoped}"
6885 );
6886 for redacted_tail in ["...4444", "...5555", "...6666"] {
6887 assert!(
6888 !scoped.contains(redacted_tail),
6889 "custom env route must not advertise an inherited credential: {scoped}"
6890 );
6891 }
6892
6893 let all = auth_status_all_providers(&store, &secrets).join("\n");
6894 let xai_row = all
6895 .lines()
6896 .find(|line| line.starts_with("xai"))
6897 .expect("xAI status row");
6898 assert!(xai_row.contains("unset"), "{xai_row}");
6899
6900 let list = auth_list_lines(&store, &secrets).join("\n");
6901 let xai_list_row = list
6902 .lines()
6903 .find(|line| line.starts_with("xai"))
6904 .expect("xAI list row");
6905 assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
6906
6907 assert_eq!(
6908 auth_get_line_with_runtime(
6909 &store,
6910 &secrets,
6911 ProviderKind::Xai,
6912 &CliRuntimeOverrides::default(),
6913 ),
6914 "xai: not set"
6915 );
6916 assert!(
6917 !keyring.queried().iter().any(|slot| slot == "xai"),
6918 "an XAI_BASE_URL custom route must not query xAI keyring state: {:?}",
6919 keyring.queried()
6920 );
6921 assert_eq!(
6922 std::fs::read_to_string(external_path).expect("external trap unchanged"),
6923 external_raw
6924 );
6925 }
6926
6927 #[test]
6928 fn xai_config_bound_custom_endpoint_uses_its_route_key() {
6929 use std::sync::Arc;
6930
6931 let _lock = env_lock();
6932 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
6933 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
6934 let dir = tempfile::TempDir::new().expect("tempdir");
6935 let config_path = dir.path().join("config.toml");
6936 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
6937 store.config.provider = ProviderKind::Xai;
6938 store.config.providers.xai.base_url =
6939 Some("https://bound-gateway.example.test/v1".to_string());
6940 store.config.providers.xai.api_key = Some("fake-bound-key-7777".to_string());
6941 let keyring = Arc::new(RecordingKeyringStore::default());
6942 keyring.set_value("xai", "fake-store-key-8888");
6943 let secrets = Secrets::new(keyring.clone());
6944
6945 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
6946 assert!(
6947 scoped.contains("credential route: config (last4: ...7777)"),
6948 "{scoped}"
6949 );
6950 assert!(
6951 scoped.contains("config file:") && scoped.contains("runtime-effective, last4: ...7777"),
6952 "{scoped}"
6953 );
6954 assert_eq!(
6955 auth_get_line_with_runtime(
6956 &store,
6957 &secrets,
6958 ProviderKind::Xai,
6959 &CliRuntimeOverrides::default(),
6960 ),
6961 "xai: set (source: config-file)"
6962 );
6963 assert!(
6964 !keyring.queried().iter().any(|slot| slot == "xai"),
6965 "an endpoint-bound config key should resolve before the xAI keyring: {:?}",
6966 keyring.queried()
6967 );
6968 }
6969
6970 #[test]
6971 fn xai_absent_generation_with_consent_is_external_configured_and_unprobed() {
6972 use std::sync::Arc;
6973
6974 let _lock = env_lock();
6975 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
6976 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
6977 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
6978 let dir = tempfile::TempDir::new().expect("tempdir");
6979 let config_path = dir.path().join("config.toml");
6980 let external_path = dir.path().join("grok-auth.json");
6981 let external_raw = "external owner bytes remain unprobed";
6982 std::fs::write(&external_path, external_raw).expect("external auth trap");
6983 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
6984
6985 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
6986 store.config.provider = ProviderKind::Xai;
6987 store.config.providers.xai.auth_mode = Some("oauth".to_string());
6988 store.config.providers.xai.external_credentials =
6989 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
6990 ProviderKind::Xai,
6991 codewhale_config::ExternalCredentialSource::GrokCli,
6992 external_path.clone(),
6993 ));
6994 let keyring = Arc::new(RecordingKeyringStore::default());
6995 let secrets = Secrets::new(keyring.clone());
6996
6997 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
6998 assert!(
6999 scoped.contains("credential route: external read-only consent configured/unprobed"),
7000 "{scoped}"
7001 );
7002 assert!(
7003 scoped.contains("external credentials: read_only"),
7004 "{scoped}"
7005 );
7006 assert!(
7007 scoped.contains(
7008 "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)"
7009 ),
7010 "{scoped}"
7011 );
7012
7013 let all = auth_status_all_providers(&store, &secrets).join("\n");
7014 let xai_row = all
7015 .lines()
7016 .find(|line| line.starts_with("xai"))
7017 .expect("xAI status row");
7018 assert!(
7019 xai_row.contains("external consent configured/unprobed"),
7020 "{xai_row}"
7021 );
7022
7023 let list = auth_list_lines(&store, &secrets).join("\n");
7024 let xai_list_row = list
7025 .lines()
7026 .find(|line| line.starts_with("xai"))
7027 .expect("xAI list row");
7028 assert!(
7029 xai_list_row.ends_with("external-consent-configured"),
7030 "{xai_list_row}"
7031 );
7032
7033 let get = auth_get_line_with_runtime(
7034 &store,
7035 &secrets,
7036 ProviderKind::Xai,
7037 &CliRuntimeOverrides::default(),
7038 );
7039 assert!(
7040 get.contains("source: external read-only consent; availability unprobed"),
7041 "{get}"
7042 );
7043 assert!(
7044 !keyring.queried().iter().any(|slot| slot == "xai"),
7045 "external-consent diagnostics must not query the xAI API-key store: {:?}",
7046 keyring.queried()
7047 );
7048 assert_eq!(
7049 std::fs::read_to_string(external_path).expect("external trap unchanged"),
7050 external_raw
7051 );
7052 }
7053
7054 #[test]
7055 fn auth_list_uses_persisted_consent_without_probing_codex_file() {
7056 use codewhale_secrets::InMemoryKeyringStore;
7057 use std::sync::Arc;
7058
7059 let _lock = env_lock();
7060 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
7061 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
7062
7063 let dir = tempfile::TempDir::new().expect("tempdir");
7064 let config_path = dir.path().join("config.toml");
7065 let auth_path = dir.path().join("auth.json");
7066 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
7067 .expect("write auth file");
7068 let auth_path_str = auth_path.to_string_lossy().into_owned();
7069 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
7070
7071 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
7072 store.config.provider = ProviderKind::OpenaiCodex;
7073 store.config.providers.openai_codex.external_credentials =
7074 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7075 ProviderKind::OpenaiCodex,
7076 codewhale_config::ExternalCredentialSource::CodexCli,
7077 auth_path,
7078 ));
7079 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
7080
7081 let output = auth_list_lines(&store, &secrets).join("\n");
7082 let row = output
7083 .lines()
7084 .find(|line| line.starts_with("openai-codex"))
7085 .unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
7086 assert!(row.ends_with("external-consent"), "{row}");
7087 assert!(!output.contains("secret-token"));
7088 }
7089
7090 #[test]
7091 fn external_consent_persists_exact_scope_and_api_key_or_revoke_disables_it() {
7092 let _lock = env_lock();
7093 let dir = tempfile::TempDir::new().expect("tempdir");
7094 let home = dir
7095 .path()
7096 .canonicalize()
7097 .expect("canonical temp root")
7098 .join("codewhale-home");
7099 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
7100 let config_path = dir.path().join("config.toml");
7101 let external_path = dir.path().join("grok-auth.json");
7102 let external_raw = r#"{"secret":"must-never-be-read-or-written"}"#;
7103 std::fs::write(&external_path, external_raw).expect("external auth trap");
7104 let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
7105 let secrets = no_keyring_secrets();
7106
7107 let preview = external_consent_preview_lines(
7108 ProviderKind::Xai,
7109 codewhale_config::ExternalCredentialSource::GrokCli,
7110 &external_path,
7111 )
7112 .join("\n");
7113 assert!(preview.contains("owning CLI: Grok CLI"), "{preview}");
7114 assert!(
7115 preview.contains(&format!(
7116 "exact resolved path: {}",
7117 codewhale_config::quote_os_path(&external_path)
7118 )),
7119 "{preview}"
7120 );
7121 assert!(preview.contains("no refresh, identity-provider or discovery requests"));
7122 assert!(preview.contains("normal requests to the explicitly selected provider"));
7123 assert!(preview.contains("managed: unavailable"));
7124
7125 let mut prompt = Vec::new();
7126 confirm_external_consent_answer(&mut "yes\n".as_bytes(), &mut prompt)
7127 .expect("exact yes confirms");
7128 assert!(
7129 String::from_utf8(prompt)
7130 .unwrap()
7131 .contains("exact read-only")
7132 );
7133 let cancelled = confirm_external_consent_answer(&mut "YES\n".as_bytes(), &mut Vec::new())
7134 .expect_err("confirmation is deliberate and case-sensitive");
7135 assert!(cancelled.to_string().contains("cancelled"));
7136
7137 let unconfirmed = run_auth_command_with_secrets(
7138 &mut store,
7139 AuthCommand::ExternalConsent {
7140 provider: ProviderArg::Xai,
7141 mode: ExternalCredentialModeArg::ReadOnly,
7142 path: Some(external_path.clone()),
7143 yes: false,
7144 },
7145 &secrets,
7146 )
7147 .expect_err("non-interactive consent requires --yes");
7148 assert!(unconfirmed.to_string().contains("requires explicit --yes"));
7149 assert!(store.config.providers.xai.external_credentials.is_none());
7150 assert!(
7151 !config_path.exists(),
7152 "unconfirmed consent must not persist"
7153 );
7154
7155 run_auth_command_with_secrets(
7156 &mut store,
7157 AuthCommand::ExternalConsent {
7158 provider: ProviderArg::Xai,
7159 mode: ExternalCredentialModeArg::ReadOnly,
7160 path: Some(external_path.clone()),
7161 yes: true,
7162 },
7163 &secrets,
7164 )
7165 .expect("read-only consent should persist");
7166
7167 let consent = store
7168 .config
7169 .providers
7170 .xai
7171 .external_credentials
7172 .as_ref()
7173 .expect("persisted consent");
7174 assert_eq!(
7175 consent.access,
7176 codewhale_config::ExternalCredentialAccess::ReadOnly
7177 );
7178 assert_eq!(consent.provider, ProviderKind::Xai.as_str());
7179 assert_eq!(
7180 consent.source,
7181 codewhale_config::ExternalCredentialSource::GrokCli
7182 );
7183 assert_eq!(consent.path, external_path);
7184 assert_eq!(
7185 consent.consent_version,
7186 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
7187 );
7188 assert_eq!(
7189 store.config.providers.xai.auth_mode.as_deref(),
7190 Some("oauth")
7191 );
7192 assert_eq!(
7193 std::fs::read_to_string(&consent.path).expect("external file unchanged"),
7194 external_raw
7195 );
7196
7197 let reloaded = ConfigStore::load(Some(config_path.clone())).expect("reload consent");
7198 let reloaded_consent = reloaded
7199 .config
7200 .providers
7201 .xai
7202 .external_credentials
7203 .as_ref()
7204 .expect("reloaded exact consent");
7205 assert_eq!(reloaded_consent.provider, ProviderKind::Xai.as_str());
7206 assert_eq!(
7207 reloaded_consent.source,
7208 codewhale_config::ExternalCredentialSource::GrokCli
7209 );
7210 assert_eq!(reloaded_consent.path, external_path);
7211 assert_eq!(
7212 reloaded_consent.consent_version,
7213 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
7214 );
7215
7216 run_auth_command_with_secrets(
7217 &mut store,
7218 AuthCommand::Set {
7219 provider: ProviderArg::Xai,
7220 api_key: Some("xai-codewhale-owned-key".to_string()),
7221 api_key_stdin: false,
7222 },
7223 &secrets,
7224 )
7225 .expect("Codewhale-owned API key should supersede external consent");
7226 assert!(store.config.providers.xai.external_credentials.is_none());
7227 assert_eq!(
7228 std::fs::read_to_string(&external_path).expect("external file still unchanged"),
7229 external_raw
7230 );
7231
7232 run_auth_command_with_secrets(
7233 &mut store,
7234 AuthCommand::ExternalConsent {
7235 provider: ProviderArg::Xai,
7236 mode: ExternalCredentialModeArg::ReadOnly,
7237 path: Some(external_path.clone()),
7238 yes: true,
7239 },
7240 &secrets,
7241 )
7242 .expect("consent can be granted again");
7243 run_auth_command_with_secrets(
7244 &mut store,
7245 AuthCommand::ExternalRevoke {
7246 provider: ProviderArg::Xai,
7247 },
7248 &secrets,
7249 )
7250 .expect("revoke should persist");
7251 assert!(store.config.providers.xai.external_credentials.is_none());
7252 assert_eq!(
7253 std::fs::read_to_string(&external_path).expect("revoke never touches external file"),
7254 external_raw
7255 );
7256 }
7257
7258 #[test]
7259 fn unsupported_managed_and_kimi_external_consent_fail_closed() {
7260 let dir = tempfile::TempDir::new().expect("tempdir");
7261 let config_path = dir.path().join("config.toml");
7262 let external_path = dir.path().join("external-auth.json");
7263 std::fs::write(&external_path, "must remain unchanged").expect("external fixture");
7264 let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
7265 let secrets = no_keyring_secrets();
7266
7267 let managed = run_auth_command_with_secrets(
7268 &mut store,
7269 AuthCommand::ExternalConsent {
7270 provider: ProviderArg::OpenaiCodex,
7271 mode: ExternalCredentialModeArg::Managed,
7272 path: Some(external_path.clone()),
7273 yes: true,
7274 },
7275 &secrets,
7276 )
7277 .expect_err("managed access must fail without a preservation adapter");
7278 assert!(
7279 managed
7280 .to_string()
7281 .contains("schema-safe preservation adapter")
7282 );
7283
7284 let kimi = run_auth_command_with_secrets(
7285 &mut store,
7286 AuthCommand::ExternalConsent {
7287 provider: ProviderArg::Moonshot,
7288 mode: ExternalCredentialModeArg::ReadOnly,
7289 path: Some(external_path.clone()),
7290 yes: true,
7291 },
7292 &secrets,
7293 )
7294 .expect_err("Kimi must remain API-key-only");
7295 assert!(kimi.to_string().contains("API-key-only"));
7296 assert!(
7297 kimi.to_string()
7298 .contains("https://platform.kimi.ai/console/api-keys")
7299 );
7300 assert!(
7301 store
7302 .config
7303 .providers
7304 .openai_codex
7305 .external_credentials
7306 .is_none()
7307 );
7308 assert!(
7309 store
7310 .config
7311 .providers
7312 .moonshot
7313 .external_credentials
7314 .is_none()
7315 );
7316 assert_eq!(
7317 std::fs::read_to_string(external_path).expect("external fixture unchanged"),
7318 "must remain unchanged"
7319 );
7320 assert!(
7321 !config_path.exists(),
7322 "rejected consent must not write config"
7323 );
7324 }
7325
7326 #[test]
7327 fn api_key_config_failure_restores_absent_and_existing_secret_state() {
7328 let _lock = env_lock();
7329 for prior in [None, Some("prior-xai-key")] {
7330 let dir = tempfile::TempDir::new().expect("tempdir");
7331 let home = dir
7332 .path()
7333 .canonicalize()
7334 .expect("canonical temp root")
7335 .join("codewhale-home");
7336 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
7337 let config_path = dir.path().join("config.toml");
7338 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load store");
7339 store.config.providers.xai.auth_mode = Some("oauth".to_string());
7340 store.config.providers.xai.external_credentials =
7341 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
7342 ProviderKind::Xai,
7343 codewhale_config::ExternalCredentialSource::GrokCli,
7344 dir.path().join("external.json"),
7345 ));
7346 std::fs::create_dir(&config_path).expect("turn config target into a directory");
7347 let secrets = no_keyring_secrets();
7348 if let Some(prior) = prior {
7349 secrets.set("xai", prior).expect("seed prior secret");
7350 }
7351
7352 let error = run_auth_command_with_secrets(
7353 &mut store,
7354 AuthCommand::Set {
7355 provider: ProviderArg::Xai,
7356 api_key: Some("new-xai-key".to_string()),
7357 api_key_stdin: false,
7358 },
7359 &secrets,
7360 )
7361 .expect_err("config write must fail");
7362 assert!(error.to_string().contains("config"), "{error:#}");
7363 assert_eq!(
7364 secrets.get("xai").expect("restored secret"),
7365 prior.map(str::to_string)
7366 );
7367 assert_eq!(
7368 store.config.providers.xai.auth_mode.as_deref(),
7369 Some("oauth")
7370 );
7371 assert!(store.config.providers.xai.external_credentials.is_some());
7372 assert!(store.config.providers.xai.api_key.is_none());
7373 assert!(config_path.is_dir());
7374 }
7375 }
7376
7377 #[test]
7378 fn auth_status_scoped_provider_shows_detailed_info() {
7379 use codewhale_secrets::InMemoryKeyringStore;
7380 use std::sync::Arc;
7381
7382 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
7383 let path = std::env::temp_dir().join(format!(
7384 "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
7385 std::process::id()
7386 ));
7387 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7388 store.config.provider = ProviderKind::Deepseek;
7389 store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
7390
7391 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
7392
7393 let output =
7394 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
7395
7396 assert!(output.contains("provider: arcee"));
7397 assert!(output.contains("active source: config (last4: ...9999)"));
7398 assert!(output.contains("route:"));
7399 assert!(output.contains("model:"));
7400 assert!(!output.contains("sk-arcee-9999"));
7401
7402 let _ = std::fs::remove_file(path);
7403 }
7404
7405 #[test]
7406 fn dispatch_uses_secret_store_without_rehydrating_plaintext_config() {
7407 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
7408 use std::sync::Arc;
7409
7410 let _lock = env_lock();
7414 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
7415 let path = std::env::temp_dir().join(format!(
7416 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
7417 std::process::id()
7418 ));
7419 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7420 let inner = Arc::new(InMemoryKeyringStore::new());
7421 inner.set("deepseek", "ring-key").unwrap();
7422 let secrets = Secrets::new(inner);
7423
7424 let resolved = resolve_runtime_for_dispatch_with_secrets(
7425 &mut store,
7426 &CliRuntimeOverrides::default(),
7427 &secrets,
7428 );
7429
7430 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
7431 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7432 assert!(store.config.api_key.is_none());
7433 assert!(store.config.providers.deepseek.api_key.is_none());
7434 assert!(
7435 !path.exists(),
7436 "dispatch must not create config from a stored key"
7437 );
7438
7439 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
7440 &mut store,
7441 &CliRuntimeOverrides::default(),
7442 &secrets,
7443 );
7444 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
7445 assert_eq!(
7446 resolved_again.api_key_source,
7447 Some(RuntimeApiKeySource::Keyring)
7448 );
7449 assert!(
7450 !path.exists(),
7451 "repeat dispatch must remain credential-file free"
7452 );
7453
7454 let _ = std::fs::remove_file(path);
7455 }
7456
7457 #[test]
7458 fn logout_removes_plaintext_provider_keys() {
7459 let _lock = env_lock();
7460 let dir = tempfile::TempDir::new().expect("tempdir");
7461 let home = dir
7462 .path()
7463 .canonicalize()
7464 .expect("canonical temp root")
7465 .join("codewhale-home");
7466 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
7467 let path = home.join("config.toml");
7468 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7469 store.config.api_key = Some("sk-stale".to_string());
7470 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
7471 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
7472 store.config.providers.xai.auth_mode = Some("oauth".to_string());
7473 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
7474 store.config.providers.xai.oauth_credential_generation = Some(generation.to_string());
7475 store.save().unwrap();
7476 let credentials = home.join("credentials");
7477 codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
7478 owned.write(generation, b"xai-generation", false)?;
7479 owned.write(
7480 codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
7481 b"legacy-xai",
7482 false,
7483 )?;
7484 Ok(())
7485 })
7486 .expect("seed Codewhale-owned xAI credentials");
7487 std::fs::write(credentials.join("other-provider.json"), "preserve").unwrap();
7488
7489 let secrets = no_keyring_secrets();
7490
7491 run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
7492
7493 assert!(store.config.api_key.is_none());
7494 assert!(store.config.providers.deepseek.api_key.is_none());
7495 assert!(store.config.providers.fireworks.api_key.is_none());
7496 assert!(store.config.providers.xai.auth_mode.is_none());
7497 assert!(
7498 store
7499 .config
7500 .providers
7501 .xai
7502 .oauth_credential_generation
7503 .is_none()
7504 );
7505 assert!(!credentials.join(generation).exists());
7506 assert!(!credentials.join("xai-auth.json").exists());
7507 assert!(credentials.join("other-provider.json").exists());
7508
7509 let _ = std::fs::remove_file(path);
7510 }
7511
7512 #[test]
7513 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
7514 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
7515 use std::sync::Arc;
7516
7517 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
7518 let path = std::env::temp_dir().join(format!(
7519 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
7520 std::process::id()
7521 ));
7522 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7523 store.config.api_key = Some("sk-deep".to_string());
7524 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
7525 store.config.providers.openrouter.api_key = Some("or-key".to_string());
7526 store.config.providers.novita.api_key = Some("nv-key".to_string());
7527 store.save().unwrap();
7528
7529 let inner = Arc::new(InMemoryKeyringStore::new());
7530 let secrets = Secrets::new(inner.clone());
7531
7532 run_auth_command_with_secrets(
7533 &mut store,
7534 AuthCommand::Migrate { dry_run: false },
7535 &secrets,
7536 )
7537 .expect("migrate should succeed");
7538
7539 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
7540 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
7541 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
7542
7543 assert!(store.config.api_key.is_none());
7545 assert!(store.config.providers.deepseek.api_key.is_none());
7546 assert!(store.config.providers.openrouter.api_key.is_none());
7547 assert!(store.config.providers.novita.api_key.is_none());
7548
7549 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
7550 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
7551 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
7552 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
7553
7554 let backup_path = path.with_file_name(format!(
7555 "{}.bak",
7556 path.file_name().unwrap_or_default().to_string_lossy()
7557 ));
7558 let backup = std::fs::read_to_string(&backup_path).expect("credential-free backup");
7559 assert!(
7560 !backup.contains("sk-deep"),
7561 "plaintext leaked in backup: {backup}"
7562 );
7563 assert!(
7564 !backup.contains("or-key"),
7565 "plaintext leaked in backup: {backup}"
7566 );
7567 assert!(
7568 !backup.contains("nv-key"),
7569 "plaintext leaked in backup: {backup}"
7570 );
7571
7572 let resolved = resolve_runtime_for_dispatch_with_secrets(
7573 &mut store,
7574 &CliRuntimeOverrides::default(),
7575 &secrets,
7576 );
7577 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
7578 let after_dispatch = std::fs::read_to_string(&path).expect("config after dispatch");
7579 assert!(!after_dispatch.contains("sk-deep"), "{after_dispatch}");
7580 assert!(
7581 !after_dispatch
7582 .lines()
7583 .any(|line| line.trim_start().starts_with("api_key ="))
7584 );
7585
7586 let _ = std::fs::remove_file(path);
7587 }
7588
7589 #[test]
7590 fn auth_migrate_dry_run_does_not_modify_anything() {
7591 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
7592 use std::sync::Arc;
7593
7594 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
7595 let path = std::env::temp_dir().join(format!(
7596 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
7597 std::process::id()
7598 ));
7599 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7600 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
7601 store.save().unwrap();
7602
7603 let inner = Arc::new(InMemoryKeyringStore::new());
7604 let secrets = Secrets::new(inner.clone());
7605
7606 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
7607 .expect("dry-run should succeed");
7608
7609 assert_eq!(inner.get("openrouter").unwrap(), None);
7610 assert_eq!(
7611 store.config.providers.openrouter.api_key.as_deref(),
7612 Some("or-stay")
7613 );
7614
7615 let _ = std::fs::remove_file(path);
7616 }
7617
7618 #[test]
7619 fn parses_global_override_flags() {
7620 let cli = parse_ok(&[
7621 "deepseek",
7622 "--provider",
7623 "openai",
7624 "--config",
7625 "/tmp/deepseek.toml",
7626 "--profile",
7627 "work",
7628 "--model",
7629 "deepseek-v4-pro",
7630 "--output-mode",
7631 "json",
7632 "--verbosity",
7633 "concise",
7634 "--log-level",
7635 "debug",
7636 "--telemetry",
7637 "true",
7638 "--approval-policy",
7639 "on-request",
7640 "--sandbox-mode",
7641 "workspace-write",
7642 "--base-url",
7643 "https://openai-compatible.example/v1",
7644 "--api-key",
7645 "sk-test",
7646 "--workspace",
7647 "/tmp/workspace",
7648 "--no-mouse-capture",
7649 "--skip-onboarding",
7650 "model",
7651 "resolve",
7652 "deepseek-v4-pro",
7653 ]);
7654
7655 assert_eq!(cli.provider.as_deref(), Some("openai"));
7656 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
7657 assert_eq!(cli.profile.as_deref(), Some("work"));
7658 assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
7659 assert_eq!(cli.output_mode.as_deref(), Some("json"));
7660 assert_eq!(cli.verbosity.as_deref(), Some("concise"));
7661 assert_eq!(cli.log_level.as_deref(), Some("debug"));
7662 assert_eq!(cli.telemetry, Some(true));
7663 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
7664 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
7665 assert_eq!(
7666 cli.base_url.as_deref(),
7667 Some("https://openai-compatible.example/v1")
7668 );
7669 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
7670 assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
7671 assert!(cli.no_mouse_capture);
7672 assert!(!cli.mouse_capture);
7673 assert!(cli.skip_onboarding);
7674 }
7675
7676 #[test]
7677 fn cli_provider_helpers_follow_config_metadata() {
7678 let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
7679 .iter()
7680 .map(|provider| provider.kind())
7681 .collect();
7682 assert_eq!(registry_kinds, ProviderKind::ALL);
7683
7684 for provider in ProviderKind::ALL {
7685 assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
7686 if provider == ProviderKind::SiliconflowCN {
7687 assert_eq!(
7688 provider_slot(provider),
7689 provider_slot(ProviderKind::Siliconflow)
7690 );
7691 } else {
7692 assert_eq!(provider_slot(provider), provider.provider().id());
7693 }
7694 }
7695 }
7696
7697 #[test]
7698 fn build_tui_command_forwards_raw_exec_and_fleet_provider_without_secret_bridge() {
7699 let _lock = env_lock();
7700 let (_dir, _bin) = install_fake_tui_binary();
7701 let _ambient_provider = ScopedEnvVar::set("CODEWHALE_PROVIDER", "openrouter");
7702
7703 let cases = [
7704 (
7705 parse_ok(&["codewhale", "--provider", "lm-studio", "exec", "Reply OK"]),
7706 vec!["exec".to_string(), "Reply OK".to_string()],
7707 ),
7708 (
7709 parse_ok(&["codewhale", "--provider", "lm-studio", "fleet", "status"]),
7710 vec!["fleet".to_string(), "status".to_string()],
7711 ),
7712 ];
7713
7714 for (cli, passthrough) in cases {
7715 let mut resolved =
7716 resolved_runtime_for_test(ProviderKind::Openrouter, ProviderSource::Config);
7717 resolved.api_key = Some("unrelated-keyring-secret".to_string());
7718 resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
7719
7720 let cmd = build_tui_command(&cli, &resolved, passthrough.clone())
7721 .expect("raw provider should dispatch to the TUI");
7722 assert_eq!(
7723 command_env(&cmd, "CODEWHALE_PROVIDER").as_deref(),
7724 Some("lm-studio")
7725 );
7726 assert_eq!(
7727 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
7728 Some("lm-studio")
7729 );
7730 for secret_var in [
7731 "CODEWHALE_CLI_API_KEY",
7732 "DEEPSEEK_API_KEY",
7733 "OPENROUTER_API_KEY",
7734 "DEEPSEEK_API_KEY_SOURCE",
7735 ] {
7736 assert_eq!(
7737 command_env(&cmd, secret_var),
7738 None,
7739 "raw provider dispatch must not bridge {secret_var}"
7740 );
7741 }
7742 assert_eq!(
7743 cmd.get_args()
7744 .map(|arg| arg.to_string_lossy().into_owned())
7745 .collect::<Vec<_>>(),
7746 passthrough
7747 );
7748 }
7749 }
7750
7751 #[test]
7752 fn build_tui_command_allows_openai_and_forwards_provider_key() {
7753 let _lock = env_lock();
7754 let dir = tempfile::TempDir::new().expect("tempdir");
7755 let custom = dir
7756 .path()
7757 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
7758 std::fs::write(&custom, b"").unwrap();
7759 let custom_str = custom.to_string_lossy().into_owned();
7760 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
7761
7762 let cli = parse_ok(&[
7763 "deepseek",
7764 "--provider",
7765 "openai",
7766 "--workspace",
7767 "/tmp/codewhale-workspace",
7768 ]);
7769 let resolved = ResolvedRuntimeOptions {
7770 provider: ProviderKind::Openai,
7771 provider_source: ProviderSource::Cli,
7772 model_source: ModelSource::ProviderDefault,
7773 model: "glm-5".to_string(),
7774 api_key: Some("resolved-openai-key".to_string()),
7775 api_key_source: Some(RuntimeApiKeySource::Keyring),
7776 base_url: "https://openai-compatible.example/v4".to_string(),
7777 auth_mode: Some("api_key".to_string()),
7778 insecure_skip_tls_verify: false,
7779 output_mode: None,
7780 log_level: None,
7781 telemetry: false,
7782 approval_policy: None,
7783 sandbox_mode: None,
7784 yolo: None,
7785 verbosity: None,
7786 http_headers: std::collections::BTreeMap::new(),
7787 };
7788
7789 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
7790 assert_eq!(
7791 command_env(&cmd, "CODEWHALE_PROVIDER").as_deref(),
7792 Some("openai")
7793 );
7794 assert_eq!(
7795 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
7796 Some("openai")
7797 );
7798 assert_eq!(
7799 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
7800 Some("resolved-openai-key")
7801 );
7802 assert_eq!(
7803 command_env(&cmd, "OPENAI_API_KEY").as_deref(),
7804 Some("resolved-openai-key")
7805 );
7806 assert_eq!(
7807 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
7808 Some("keyring")
7809 );
7810 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
7811 let args: Vec<String> = cmd
7812 .get_args()
7813 .map(|arg| arg.to_string_lossy().into_owned())
7814 .collect();
7815 assert!(
7816 args.windows(2)
7817 .any(|pair| pair == ["--workspace", "/tmp/codewhale-workspace"]),
7818 "expected workspace forwarding in args: {args:?}"
7819 );
7820 }
7821
7822 #[test]
7823 fn parses_no_project_config_before_subcommand() {
7824 let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "list the files"]);
7825 assert!(cli.no_project_config);
7826 match cli.command {
7827 Some(Commands::Exec(args)) => {
7828 assert_eq!(args.args, vec!["list the files".to_string()]);
7829 }
7830 other => panic!("expected exec subcommand, got {other:?}"),
7831 }
7832 }
7833
7834 #[test]
7835 fn no_project_config_after_passthrough_subcommand_is_not_the_dispatcher_flag() {
7836 let cli = parse_ok(&["codewhale", "exec", "--no-project-config", "hi"]);
7840 assert!(!cli.no_project_config);
7841 match cli.command {
7842 Some(Commands::Exec(args)) => {
7843 assert!(args.args.iter().any(|a| a == "--no-project-config"));
7844 }
7845 other => panic!("expected exec subcommand, got {other:?}"),
7846 }
7847 }
7848
7849 #[test]
7850 fn build_tui_command_forwards_no_project_config_before_subcommand() {
7851 let _lock = env_lock();
7852 let dir = tempfile::TempDir::new().expect("tempdir");
7853 let custom = dir
7854 .path()
7855 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
7856 std::fs::write(&custom, b"").unwrap();
7857 let custom_str = custom.to_string_lossy().into_owned();
7858 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
7859
7860 let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "hi"]);
7861 let resolved = ResolvedRuntimeOptions {
7862 provider: ProviderKind::Openai,
7863 provider_source: ProviderSource::Cli,
7864 model_source: ModelSource::ProviderDefault,
7865 model: "glm-5".to_string(),
7866 api_key: Some("resolved-openai-key".to_string()),
7867 api_key_source: Some(RuntimeApiKeySource::Keyring),
7868 base_url: "https://openai-compatible.example/v4".to_string(),
7869 auth_mode: Some("api_key".to_string()),
7870 insecure_skip_tls_verify: false,
7871 output_mode: None,
7872 log_level: None,
7873 telemetry: false,
7874 approval_policy: None,
7875 sandbox_mode: None,
7876 yolo: None,
7877 verbosity: None,
7878 http_headers: std::collections::BTreeMap::new(),
7879 };
7880
7881 let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string(), "hi".to_string()])
7882 .expect("command");
7883 let args: Vec<String> = cmd
7884 .get_args()
7885 .map(|arg| arg.to_string_lossy().into_owned())
7886 .collect();
7887 let flag = args
7888 .iter()
7889 .position(|a| a == "--no-project-config")
7890 .expect("--no-project-config forwarded");
7891 let subcommand = args
7892 .iter()
7893 .position(|a| a == "exec")
7894 .expect("exec forwarded");
7895 assert!(
7896 flag < subcommand,
7897 "--no-project-config must be forwarded before the subcommand: {args:?}"
7898 );
7899 }
7900
7901 #[test]
7902 fn build_tui_command_allows_openai_codex_from_resolved_runtime() {
7903 let _lock = env_lock();
7904 let dir = tempfile::TempDir::new().expect("tempdir");
7905 let custom = dir
7906 .path()
7907 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
7908 std::fs::write(&custom, b"").unwrap();
7909 let custom_str = custom.to_string_lossy().into_owned();
7910 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
7911
7912 let cli = parse_ok(&["codewhale", "doctor"]);
7913 let resolved = ResolvedRuntimeOptions {
7914 provider: ProviderKind::OpenaiCodex,
7915 provider_source: ProviderSource::Config,
7916 model_source: ModelSource::ProviderDefault,
7917 model: "gpt-5.5".to_string(),
7918 api_key: None,
7919 api_key_source: None,
7920 base_url: "https://chatgpt.com/backend-api".to_string(),
7921 auth_mode: Some("oauth".to_string()),
7922 insecure_skip_tls_verify: false,
7923 output_mode: None,
7924 log_level: None,
7925 telemetry: false,
7926 approval_policy: None,
7927 sandbox_mode: None,
7928 yolo: None,
7929 verbosity: None,
7930 http_headers: std::collections::BTreeMap::new(),
7931 };
7932
7933 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
7934 .expect("openai-codex should be accepted by the facade");
7935 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
7936 let args: Vec<String> = cmd
7937 .get_args()
7938 .map(|arg| arg.to_string_lossy().into_owned())
7939 .collect();
7940 assert_eq!(args, vec!["doctor"]);
7941 }
7942
7943 #[test]
7944 fn build_tui_command_forwards_explicit_openai_codex_provider() {
7945 let _lock = env_lock();
7946 let dir = tempfile::TempDir::new().expect("tempdir");
7947 let custom = dir
7948 .path()
7949 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
7950 std::fs::write(&custom, b"").unwrap();
7951 let custom_str = custom.to_string_lossy().into_owned();
7952 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
7953
7954 let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
7955 let resolved = ResolvedRuntimeOptions {
7956 provider: ProviderKind::OpenaiCodex,
7957 provider_source: ProviderSource::Cli,
7958 model_source: ModelSource::ProviderDefault,
7959 model: "gpt-5.5".to_string(),
7960 api_key: None,
7961 api_key_source: None,
7962 base_url: "https://chatgpt.com/backend-api".to_string(),
7963 auth_mode: Some("oauth".to_string()),
7964 insecure_skip_tls_verify: false,
7965 output_mode: None,
7966 log_level: None,
7967 telemetry: false,
7968 approval_policy: None,
7969 sandbox_mode: None,
7970 yolo: None,
7971 verbosity: None,
7972 http_headers: std::collections::BTreeMap::new(),
7973 };
7974
7975 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
7976 .expect("openai-codex should be accepted by the facade");
7977 assert_eq!(
7978 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
7979 Some("openai-codex")
7980 );
7981 }
7982
7983 #[test]
7984 fn build_tui_command_allows_anthropic_cli_provider() {
7985 let _lock = env_lock();
7986 let (_dir, _bin) = install_fake_tui_binary();
7987
7988 let cli = parse_ok(&["codewhale", "--provider", "anthropic", "doctor"]);
7989 let resolved = resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Cli);
7990
7991 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
7992 .expect("anthropic should be accepted by the facade");
7993 assert_eq!(
7994 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
7995 Some("anthropic")
7996 );
7997 }
7998
7999 #[test]
8000 fn build_tui_command_allows_anthropic_env_provider() {
8001 let _lock = env_lock();
8002 let (_dir, _bin) = install_fake_tui_binary();
8003
8004 let cli = parse_ok(&["codewhale", "doctor"]);
8005 let resolved = resolved_runtime_for_test(
8006 ProviderKind::Anthropic,
8007 ProviderSource::Env("DEEPSEEK_PROVIDER"),
8008 );
8009
8010 build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8011 .expect("anthropic from provider env should be accepted by the facade");
8012 }
8013
8014 #[test]
8015 fn build_tui_command_bridges_anthropic_keyring_secret() {
8016 let _lock = env_lock();
8017 let (_dir, _bin) = install_fake_tui_binary();
8018
8019 let cli = parse_ok(&["codewhale", "doctor"]);
8020 let mut resolved =
8021 resolved_runtime_for_test(ProviderKind::Anthropic, ProviderSource::Config);
8022 resolved.api_key = Some("anthropic-keyring-secret".to_string());
8023 resolved.api_key_source = Some(RuntimeApiKeySource::Keyring);
8024
8025 let cmd = build_tui_command(&cli, &resolved, vec!["doctor".to_string()])
8026 .expect("config-sourced anthropic provider should be accepted");
8027
8028 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
8029 assert_eq!(
8030 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
8031 Some("anthropic-keyring-secret")
8032 );
8033 assert_eq!(
8034 command_env(&cmd, "ANTHROPIC_API_KEY").as_deref(),
8035 Some("anthropic-keyring-secret")
8036 );
8037 assert_eq!(
8038 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
8039 Some("keyring")
8040 );
8041 }
8042
8043 #[test]
8044 fn build_tui_command_does_not_export_default_runtime_overrides_for_profiles() {
8045 let _lock = env_lock();
8046 let dir = tempfile::TempDir::new().expect("tempdir");
8047 let custom = dir
8048 .path()
8049 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8050 std::fs::write(&custom, b"").unwrap();
8051 let custom_str = custom.to_string_lossy().into_owned();
8052 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8053
8054 let cli = parse_ok(&["deepseek", "--profile", "google"]);
8055 let mut resolved_headers = std::collections::BTreeMap::new();
8056 resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
8057 let resolved = ResolvedRuntimeOptions {
8058 provider: ProviderKind::Deepseek,
8059 provider_source: ProviderSource::Config,
8060 model_source: ModelSource::ProviderDefault,
8061 model: "deepseek-v4-pro".to_string(),
8062 api_key: Some("config-file-key".to_string()),
8063 api_key_source: Some(RuntimeApiKeySource::ConfigFile),
8064 base_url: "https://api.deepseek.com/beta".to_string(),
8065 auth_mode: Some("api_key".to_string()),
8066 insecure_skip_tls_verify: false,
8067 output_mode: None,
8068 log_level: None,
8069 telemetry: false,
8070 approval_policy: None,
8071 sandbox_mode: None,
8072 yolo: None,
8073 verbosity: Some("normal".to_string()),
8074 http_headers: resolved_headers,
8075 };
8076
8077 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8078
8079 assert_eq!(command_env(&cmd, "DEEPSEEK_PROVIDER"), None);
8080 assert_eq!(command_env(&cmd, "DEEPSEEK_MODEL"), None);
8081 assert_eq!(command_env(&cmd, "DEEPSEEK_BASE_URL"), None);
8082 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY"), None);
8083 assert_eq!(command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE"), None);
8084 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
8085 assert_eq!(command_env(&cmd, "DEEPSEEK_HTTP_HEADERS"), None);
8086 assert_eq!(command_env(&cmd, "CODEWHALE_VERBOSITY"), None);
8087 assert_eq!(command_env(&cmd, "DEEPSEEK_VERBOSITY"), None);
8088 let args: Vec<String> = cmd
8089 .get_args()
8090 .map(|arg| arg.to_string_lossy().into_owned())
8091 .collect();
8092 assert!(
8093 args.windows(2).any(|pair| pair == ["--profile", "google"]),
8094 "expected profile forwarding in args: {args:?}"
8095 );
8096 }
8097
8098 #[test]
8099 fn build_tui_command_defaults_noninteractive_to_concise_verbosity() {
8100 let _lock = env_lock();
8101 let (_dir, _bin) = install_fake_tui_binary();
8102
8103 let cli = parse_ok(&["codewhale"]);
8104 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
8105
8106 let cmd = build_tui_command(
8107 &cli,
8108 &resolved,
8109 vec!["exec".to_string(), "summarize".to_string()],
8110 )
8111 .expect("command");
8112
8113 assert_eq!(
8114 command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
8115 Some("concise")
8116 );
8117 assert_eq!(
8118 command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
8119 Some("concise")
8120 );
8121 }
8122
8123 #[test]
8124 fn build_tui_command_respects_resolved_verbosity_override() {
8125 let _lock = env_lock();
8126 let (_dir, _bin) = install_fake_tui_binary();
8127
8128 let cli = parse_ok(&["codewhale"]);
8129 let mut resolved =
8130 resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
8131 resolved.verbosity = Some("normal".to_string());
8132
8133 let cmd = build_tui_command(&cli, &resolved, vec!["exec".to_string()]).expect("command");
8134
8135 assert_eq!(
8136 command_env(&cmd, "CODEWHALE_VERBOSITY").as_deref(),
8137 Some("normal")
8138 );
8139 assert_eq!(
8140 command_env(&cmd, "DEEPSEEK_VERBOSITY").as_deref(),
8141 Some("normal")
8142 );
8143 }
8144
8145 #[test]
8146 fn build_tui_command_allows_moonshot_and_forwards_kimi_key() {
8147 let _lock = env_lock();
8148 let dir = tempfile::TempDir::new().expect("tempdir");
8149 let custom = dir
8150 .path()
8151 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8152 std::fs::write(&custom, b"").unwrap();
8153 let custom_str = custom.to_string_lossy().into_owned();
8154 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8155
8156 let cli = parse_ok(&[
8157 "codewhale",
8158 "--provider",
8159 "moonshot",
8160 "--model",
8161 "kimi-k2.7-code",
8162 "--workspace",
8163 "/tmp/codewhale-workspace",
8164 ]);
8165 let resolved = ResolvedRuntimeOptions {
8166 provider: ProviderKind::Moonshot,
8167 provider_source: ProviderSource::Cli,
8168 model_source: ModelSource::ProviderDefault,
8169 model: "kimi-k2.7-code".to_string(),
8170 api_key: Some("resolved-kimi-key".to_string()),
8171 api_key_source: Some(RuntimeApiKeySource::Keyring),
8172 base_url: "https://api.moonshot.ai/v1".to_string(),
8173 auth_mode: Some("api_key".to_string()),
8174 insecure_skip_tls_verify: false,
8175 output_mode: None,
8176 log_level: None,
8177 telemetry: false,
8178 approval_policy: None,
8179 sandbox_mode: None,
8180 yolo: None,
8181 verbosity: None,
8182 http_headers: std::collections::BTreeMap::new(),
8183 };
8184
8185 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8186 assert_eq!(
8187 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8188 Some("moonshot")
8189 );
8190 assert_eq!(
8191 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
8192 Some("kimi-k2.7-code")
8193 );
8194 assert_eq!(
8195 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
8196 Some("resolved-kimi-key")
8197 );
8198 assert_eq!(
8199 command_env(&cmd, "MOONSHOT_API_KEY").as_deref(),
8200 Some("resolved-kimi-key")
8201 );
8202 assert_eq!(
8203 command_env(&cmd, "KIMI_API_KEY").as_deref(),
8204 Some("resolved-kimi-key")
8205 );
8206 assert_eq!(
8207 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
8208 Some("keyring")
8209 );
8210 assert_eq!(command_env(&cmd, "DEEPSEEK_AUTH_MODE"), None);
8211 }
8212
8213 #[test]
8214 fn build_tui_command_allows_volcengine_and_forwards_ark_keys() {
8215 let _lock = env_lock();
8216 let dir = tempfile::TempDir::new().expect("tempdir");
8217 let custom = dir
8218 .path()
8219 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8220 std::fs::write(&custom, b"").unwrap();
8221 let custom_str = custom.to_string_lossy().into_owned();
8222 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8223
8224 let cli = parse_ok(&[
8225 "codewhale",
8226 "--provider",
8227 "volcengine",
8228 "--model",
8229 "DeepSeek-V4-Pro",
8230 "--workspace",
8231 "/tmp/codewhale-workspace",
8232 ]);
8233 let resolved = ResolvedRuntimeOptions {
8234 provider: ProviderKind::Volcengine,
8235 provider_source: ProviderSource::Cli,
8236 model_source: ModelSource::ProviderDefault,
8237 model: "DeepSeek-V4-Pro".to_string(),
8238 api_key: Some("resolved-ark-key".to_string()),
8239 api_key_source: Some(RuntimeApiKeySource::Keyring),
8240 base_url: "https://ark.cn-beijing.volces.com/api/coding/v3".to_string(),
8241 auth_mode: Some("api_key".to_string()),
8242 insecure_skip_tls_verify: false,
8243 output_mode: None,
8244 log_level: None,
8245 telemetry: false,
8246 approval_policy: None,
8247 sandbox_mode: None,
8248 yolo: None,
8249 verbosity: None,
8250 http_headers: std::collections::BTreeMap::new(),
8251 };
8252
8253 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8254 assert_eq!(
8255 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8256 Some("volcengine")
8257 );
8258 assert_eq!(
8259 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
8260 Some("DeepSeek-V4-Pro")
8261 );
8262 assert_eq!(
8263 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
8264 Some("resolved-ark-key")
8265 );
8266 assert_eq!(
8267 command_env(&cmd, "VOLCENGINE_API_KEY").as_deref(),
8268 Some("resolved-ark-key")
8269 );
8270 assert_eq!(
8271 command_env(&cmd, "VOLCENGINE_ARK_API_KEY").as_deref(),
8272 Some("resolved-ark-key")
8273 );
8274 assert_eq!(
8275 command_env(&cmd, "ARK_API_KEY").as_deref(),
8276 Some("resolved-ark-key")
8277 );
8278 }
8279
8280 #[test]
8281 fn build_tui_command_exports_explicit_provider_model_and_base_url() {
8282 let _lock = env_lock();
8283 let dir = tempfile::TempDir::new().expect("tempdir");
8284 let custom = dir
8285 .path()
8286 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8287 std::fs::write(&custom, b"").unwrap();
8288 let custom_str = custom.to_string_lossy().into_owned();
8289 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8290
8291 let cli = parse_ok(&[
8292 "deepseek",
8293 "--profile",
8294 "google",
8295 "--provider",
8296 "openai",
8297 "--model",
8298 "glm-5",
8299 "--base-url",
8300 "https://openai-compatible.example/v4",
8301 ]);
8302 let resolved = ResolvedRuntimeOptions {
8303 provider: ProviderKind::Openai,
8304 provider_source: ProviderSource::Cli,
8305 model_source: ModelSource::ProviderDefault,
8306 model: "glm-5".to_string(),
8307 api_key: None,
8308 api_key_source: None,
8309 base_url: "https://openai-compatible.example/v4".to_string(),
8310 auth_mode: None,
8311 insecure_skip_tls_verify: false,
8312 output_mode: None,
8313 log_level: None,
8314 telemetry: false,
8315 approval_policy: None,
8316 sandbox_mode: None,
8317 yolo: None,
8318 verbosity: None,
8319 http_headers: std::collections::BTreeMap::new(),
8320 };
8321
8322 let cmd = build_tui_command(&cli, &resolved, Vec::new()).expect("command");
8323
8324 assert_eq!(
8325 command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
8326 Some("openai")
8327 );
8328 assert_eq!(
8329 command_env(&cmd, "DEEPSEEK_MODEL").as_deref(),
8330 Some("glm-5")
8331 );
8332 assert_eq!(
8333 command_env(&cmd, "DEEPSEEK_BASE_URL").as_deref(),
8334 Some("https://openai-compatible.example/v4")
8335 );
8336 }
8337
8338 #[test]
8339 fn build_tui_command_forwards_provider_keyring_env_vars_for_all_providers() {
8340 let _lock = env_lock();
8341 let dir = tempfile::TempDir::new().expect("tempdir");
8342 let custom = dir
8343 .path()
8344 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8345 std::fs::write(&custom, b"").unwrap();
8346 let custom_str = custom.to_string_lossy().into_owned();
8347 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8348
8349 for provider in ProviderKind::ALL {
8350 let cli = parse_ok(&["codewhale", "--workspace", "/tmp/codewhale-workspace"]);
8351 let resolved = ResolvedRuntimeOptions {
8352 provider,
8353 provider_source: ProviderSource::Config,
8354 model_source: ModelSource::ProviderDefault,
8355 model: "test-model".to_string(),
8356 api_key: Some("test-key".to_string()),
8357 api_key_source: Some(RuntimeApiKeySource::Keyring),
8358 base_url: "http://localhost:8000/v1".to_string(),
8359 auth_mode: Some("api_key".to_string()),
8360 insecure_skip_tls_verify: false,
8361 output_mode: None,
8362 log_level: None,
8363 telemetry: false,
8364 approval_policy: None,
8365 sandbox_mode: None,
8366 yolo: None,
8367 verbosity: None,
8368 http_headers: std::collections::BTreeMap::new(),
8369 };
8370
8371 let cmd = build_tui_command(&cli, &resolved, Vec::new())
8372 .unwrap_or_else(|e| panic!("{}: {e}", provider.as_str()));
8373
8374 assert_eq!(
8375 command_env(&cmd, "DEEPSEEK_API_KEY").as_deref(),
8376 Some("test-key"),
8377 "{}: DEEPSEEK_API_KEY not forwarded",
8378 provider.as_str()
8379 );
8380 for var in provider_env_vars(provider)
8381 .iter()
8382 .filter(|var| **var != "DEEPSEEK_API_KEY")
8383 {
8384 assert_eq!(
8385 command_env(&cmd, var).as_deref(),
8386 Some("test-key"),
8387 "{}: {var} not forwarded",
8388 provider.as_str()
8389 );
8390 }
8391 assert_eq!(
8392 command_env(&cmd, "DEEPSEEK_API_KEY_SOURCE").as_deref(),
8393 Some("keyring"),
8394 "{}: expected keyring source bridge",
8395 provider.as_str()
8396 );
8397 assert_eq!(
8398 command_env(&cmd, "DEEPSEEK_AUTH_MODE"),
8399 None,
8400 "{}: auth mode should come from config/profile, not env handoff",
8401 provider.as_str()
8402 );
8403 }
8404 }
8405
8406 #[test]
8407 fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
8408 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
8409
8410 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
8411 assert!(cli.prompt.is_empty());
8412 assert_eq!(
8413 root_tui_passthrough(&cli).unwrap(),
8414 vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
8415 );
8416 }
8417
8418 #[test]
8419 fn parses_top_level_continue_for_interactive_resume() {
8420 let cli = parse_ok(&["codewhale", "--continue"]);
8421
8422 assert!(cli.continue_session);
8423 assert!(cli.prompt_flag.is_none());
8424 assert!(cli.prompt.is_empty());
8425 assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
8426 }
8427
8428 #[test]
8429 fn parses_rc_as_the_account_owned_interactive_handoff() {
8430 let cli = parse_ok(&["codewhale", "rc"]);
8431
8432 let Some(Commands::Rc(args)) = cli.command else {
8433 panic!("rc should parse as the remote-control TUI handoff");
8434 };
8435 assert!(args.args.is_empty());
8436 }
8437
8438 #[test]
8439 fn top_level_continue_rejects_startup_prompt() {
8440 let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
8441
8442 let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
8443 assert!(
8444 err.to_string()
8445 .contains("codewhale exec --continue <PROMPT>")
8446 );
8447 }
8448
8449 #[test]
8450 fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
8451 let cli = parse_ok(&["deepseek", "hello", "world"]);
8452
8453 assert_eq!(cli.prompt, vec!["hello", "world"]);
8454 assert!(cli.command.is_none());
8455 assert_eq!(
8456 root_tui_passthrough(&cli).unwrap(),
8457 vec!["--prompt".to_string(), "hello world".to_string()]
8458 );
8459 }
8460
8461 #[test]
8462 fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
8463 let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
8464
8465 assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
8466 assert_eq!(cli.prompt, vec!["world"]);
8467 assert_eq!(
8468 root_tui_passthrough(&cli).unwrap(),
8469 vec!["--prompt".to_string(), "hello world".to_string()]
8470 );
8471 }
8472
8473 #[test]
8474 fn known_subcommands_still_parse_before_prompt_tail() {
8475 let cli = parse_ok(&["deepseek", "doctor"]);
8476
8477 assert!(cli.prompt.is_empty());
8478 assert!(matches!(cli.command, Some(Commands::Doctor(_))));
8479 }
8480
8481 #[test]
8482 fn root_help_surface_contains_expected_subcommands_and_globals() {
8483 let rendered = help_for(&["deepseek", "--help"]);
8484
8485 for token in [
8486 "run",
8487 "doctor",
8488 "models",
8489 "sessions",
8490 "resume",
8491 "setup",
8492 "login",
8493 "logout",
8494 "auth",
8495 "mcp-server",
8496 "config",
8497 "model",
8498 "thread",
8499 "sandbox",
8500 "app-server",
8501 "completion",
8502 "metrics",
8503 "--provider",
8504 "--model",
8505 "--config",
8506 "--profile",
8507 "--output-mode",
8508 "--log-level",
8509 "--telemetry",
8510 "--base-url",
8511 "--api-key",
8512 "--approval-policy",
8513 "--sandbox-mode",
8514 "--mouse-capture",
8515 "--no-mouse-capture",
8516 "--skip-onboarding",
8517 "--continue",
8518 "--prompt",
8519 ] {
8520 assert!(
8521 rendered.contains(token),
8522 "expected help to contain token: {token}"
8523 );
8524 }
8525 }
8526
8527 #[test]
8528 fn subcommand_help_surfaces_are_stable() {
8529 let cases = [
8530 ("config", vec!["get", "set", "unset", "list", "path"]),
8531 ("model", vec!["list", "resolve"]),
8532 (
8533 "thread",
8534 vec![
8535 "list",
8536 "read",
8537 "resume",
8538 "fork",
8539 "archive",
8540 "unarchive",
8541 "set-name",
8542 "clear-name",
8543 ],
8544 ),
8545 ("sandbox", vec!["check"]),
8546 (
8547 "exec",
8548 vec![
8549 "--auto",
8550 "--json",
8551 "--resume",
8552 "--session-id",
8553 "--continue",
8554 "--output-format",
8555 "stream-json",
8556 ],
8557 ),
8558 (
8559 "app-server",
8560 vec!["--host", "--port", "--config", "--stdio"],
8561 ),
8562 (
8563 "completion",
8564 vec![
8565 "<SHELL>",
8566 "bash",
8567 "source <(codewhale completion bash)",
8568 "~/.local/share/bash-completion/completions/codewhale",
8569 "fpath=(~/.zfunc $fpath)",
8570 "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
8571 "codewhale completion powershell | Out-String | Invoke-Expression",
8572 ],
8573 ),
8574 ("metrics", vec!["--json", "--since"]),
8575 ];
8576
8577 for (subcommand, expected_tokens) in cases {
8578 let argv = ["deepseek", subcommand, "--help"];
8579 let rendered = help_for(&argv);
8580 for token in expected_tokens {
8581 assert!(
8582 rendered.contains(token),
8583 "expected help for `{subcommand}` to include `{token}`"
8584 );
8585 }
8586 }
8587 }
8588
8589 #[test]
8595 fn sibling_tui_candidate_picks_platform_correct_name() {
8596 let dir = tempfile::TempDir::new().expect("tempdir");
8597 let dispatcher = dir
8598 .path()
8599 .join("codewhale")
8600 .with_extension(std::env::consts::EXE_EXTENSION);
8601 std::fs::write(&dispatcher, b"").unwrap();
8603
8604 assert!(sibling_tui_candidate(&dispatcher).is_none());
8606
8607 let target =
8608 dispatcher.with_file_name(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
8609 std::fs::write(&target, b"").unwrap();
8610
8611 let found = sibling_tui_candidate(&dispatcher).expect("must locate sibling");
8612 assert_eq!(found, target, "primary platform-correct name wins");
8613 }
8614
8615 #[test]
8616 fn dispatcher_spawn_error_names_path_and_recovery_checks() {
8617 let err = io::Error::new(io::ErrorKind::PermissionDenied, "access is denied");
8618 let message = tui_spawn_error(Path::new("C:/tools/codewhale-tui.exe"), &err);
8619
8620 assert!(message.contains("C:/tools/codewhale-tui.exe"));
8621 assert!(message.contains("access is denied"));
8622 assert!(message.contains("where codewhale"));
8623 assert!(message.contains("DEEPSEEK_TUI_BIN"));
8624 }
8625
8626 #[cfg(unix)]
8627 #[test]
8628 fn tui_child_exit_code_maps_unix_signal_to_shell_status() {
8629 use std::os::unix::process::ExitStatusExt;
8630
8631 let status = std::process::ExitStatus::from_raw(libc::SIGPIPE);
8632
8633 assert_eq!(tui_child_exit_code(status), Some(141));
8634 }
8635
8636 #[cfg(windows)]
8641 #[test]
8642 fn sibling_tui_candidate_windows_falls_back_to_suffixless() {
8643 let dir = tempfile::TempDir::new().expect("tempdir");
8644 let dispatcher = dir.path().join("codewhale.exe");
8645 std::fs::write(&dispatcher, b"").unwrap();
8646
8647 let suffixless = dispatcher.with_file_name("codewhale-tui");
8649 std::fs::write(&suffixless, b"").unwrap();
8650
8651 let found = sibling_tui_candidate(&dispatcher)
8652 .expect("Windows fallback must locate suffixless codewhale-tui");
8653 assert_eq!(found, suffixless);
8654 }
8655
8656 #[test]
8659 fn locate_sibling_tui_binary_honours_env_override() {
8660 let _lock = env_lock();
8661 let dir = tempfile::TempDir::new().expect("tempdir");
8662 let custom = dir
8663 .path()
8664 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
8665 std::fs::write(&custom, b"").unwrap();
8666 let custom_str = custom.to_string_lossy().into_owned();
8667 let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
8668
8669 let resolved = locate_sibling_tui_binary().expect("override must resolve");
8670 assert_eq!(resolved, custom);
8671 }
8672
8673 #[test]
8676 fn locate_sibling_tui_binary_prefers_codewhale_env_override() {
8677 let _lock = env_lock();
8678 let dir = tempfile::TempDir::new().expect("tempdir");
8679 let canonical = dir
8680 .path()
8681 .join(format!("canonical-tui{}", std::env::consts::EXE_SUFFIX));
8682 let legacy = dir
8683 .path()
8684 .join(format!("legacy-tui{}", std::env::consts::EXE_SUFFIX));
8685 std::fs::write(&canonical, b"").unwrap();
8686 std::fs::write(&legacy, b"").unwrap();
8687 let _canonical_bin = ScopedEnvVar::set("CODEWHALE_TUI_BIN", &canonical.to_string_lossy());
8688 let _legacy_bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &legacy.to_string_lossy());
8689
8690 let resolved = locate_sibling_tui_binary().expect("override must resolve");
8691 assert_eq!(resolved, canonical);
8692 }
8693}