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