返回 CodeWhale
lib.rs
根目录 / crates / cli / src / lib.rs
1 #![allow(clippy::uninlined_format_args)]
2
3 mod cloud;
4 mod config_bundles;
5 mod credential_handoff;
6 mod dispatch;
7 mod metrics;
8 #[cfg(not(target_env = "ohos"))]
9 mod update;
10
11 use std::io::{self, IsTerminal, Read, Write};
12 use std::net::SocketAddr;
13 use std::path::{Path, PathBuf};
14 use std::process::Command;
15
16 use anyhow::{Context, Result, anyhow, bail};
17 use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
18 use clap_complete::{Shell, generate};
19 use codewhale_agent::ModelRegistry;
20 use codewhale_app_server::daemon_socket::{DaemonSocketOptions, run_daemon_socket};
21 use codewhale_app_server::{
22 AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
23 };
24 use codewhale_config::credentials::{
25 clear_provider_api_key_from_config, provider_slot, set_provider_api_key,
26 };
27 use codewhale_config::route::{ProvidersExport, parse_route_kind};
28 use codewhale_config::{
29 CliRuntimeOverrides, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind,
30 ProviderSource, ResolvedRuntimeOptions, RuntimeApiKeySource, SetupState,
31 classify_config_api_key_value, provider_base_url_is_official,
32 };
33 use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
34 use codewhale_mcp::{McpServerDefinition, run_stdio_server};
35 use codewhale_secrets::Secrets;
36 use codewhale_state::{StateStore, ThreadListFilters};
37 use codewhale_telemetry::{
38 self as telemetry, Counters, DurationBucket, Errors, Event, ExitClass, SessionSource, Surface,
39 TelemetryDecision, TurnWall,
40 };
41
42 fn is_antigravity_legacy_selector(value: &str) -> bool {
43 matches!(
44 value.trim().to_ascii_lowercase().as_str(),
45 "antigravity" | "agy"
46 )
47 }
48
49 /// Catalog-backed `--provider` parser. Replaces the closed 47-arm `ProviderArg` enum.
50 fn parse_catalog_route(value: &str) -> std::result::Result<ProviderKind, String> {
51 if is_antigravity_legacy_selector(value) {
52 return Err(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string());
53 }
54 parse_route_kind(value).ok_or_else(|| {
55 format!(
56 "unknown route '{value}'; expected a catalog route id (see `codewhale providers export --json`)"
57 )
58 })
59 }
60
61 fn builtin_provider_arg(value: &str) -> Option<ProviderKind> {
62 parse_route_kind(value).filter(|provider| *provider != ProviderKind::Antigravity)
63 }
64
65 /// The legacy tombstone is accepted only by the local Codewhale-state clear
66 /// command. Every selectable/auth-consuming parser continues through
67 /// [`parse_catalog_route`], which rejects it.
68 fn parse_auth_clear_provider(value: &str) -> std::result::Result<ProviderKind, String> {
69 if is_antigravity_legacy_selector(value) {
70 return Ok(ProviderKind::Antigravity);
71 }
72 parse_catalog_route(value)
73 }
74
75 fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
76 if value.is_empty()
77 || value == "__custom__"
78 || !value
79 .chars()
80 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
81 {
82 return Err(
83 "provider must be a simple identifier using letters, numbers, '-', '_', or '.'"
84 .to_string(),
85 );
86 }
87 Ok(value.to_string())
88 }
89
90 #[derive(Debug, Parser)]
91 #[command(
92 name = "codewhale",
93 version = env!("CODEWHALE_BUILD_VERSION"),
94 bin_name = "codewhale",
95 override_usage = "codewhale [OPTIONS] [PROMPT]\n codewhale [OPTIONS] <COMMAND> [ARGS]"
96 )]
97 struct Cli {
98 #[arg(long)]
99 config: Option<PathBuf>,
100 #[arg(long)]
101 profile: Option<String>,
102 #[arg(
103 long,
104 value_name = "PROVIDER",
105 value_parser = parse_provider_identifier,
106 help = "Provider selector; exec/fleet also accept configured custom provider identifiers"
107 )]
108 provider: Option<String>,
109 #[arg(long)]
110 model: Option<String>,
111 #[arg(long = "output-mode")]
112 output_mode: Option<String>,
113 #[arg(
114 long = "verbosity",
115 value_name = "LEVEL",
116 help = "Controls transcript and output verbosity (normal, concise)"
117 )]
118 verbosity: Option<String>,
119 #[arg(long = "log-level")]
120 log_level: Option<String>,
121 #[arg(
122 long,
123 value_name = "BOOL",
124 help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
125 durable off: config set telemetry false; CODEWHALE_TELEMETRY=0 always wins)"
126 )]
127 telemetry: Option<bool>,
128 #[arg(long)]
129 approval_policy: Option<String>,
130 #[arg(long)]
131 sandbox_mode: Option<String>,
132 #[arg(long)]
133 api_key: Option<String>,
134 #[arg(long)]
135 base_url: Option<String>,
136 /// Workspace directory for Codewhale file tools.
137 #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
138 workspace: Option<PathBuf>,
139 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
140 mouse_capture: bool,
141 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
142 no_mouse_capture: bool,
143 #[arg(long = "skip-onboarding")]
144 skip_onboarding: bool,
145 /// Start a fresh session without automatic resume or crash recovery.
146 #[arg(long)]
147 fresh: bool,
148 /// Skip loading project-level config, including the workspace-specific
149 /// `[workspace]`/`[projects]` overlay from user config. Must appear before
150 /// the subcommand; it is applied before subcommand dispatch.
151 #[arg(long = "no-project-config")]
152 no_project_config: bool,
153 /// Legacy compatibility alias for Act + Full Access.
154 #[arg(long, hide = true)]
155 yolo: bool,
156 /// Continue the most recent interactive session for this workspace.
157 #[arg(short = 'c', long = "continue")]
158 continue_session: bool,
159 /// Resume a saved interactive session by id or unique id prefix.
160 #[arg(
161 short = 'r',
162 long = "resume",
163 value_name = "SESSION_ID",
164 conflicts_with_all = ["continue_session", "session_id"]
165 )]
166 resume: Option<String>,
167 /// Alias of `--resume` matching `codewhale exec --session-id`.
168 #[arg(
169 long = "session-id",
170 value_name = "SESSION_ID",
171 conflicts_with_all = ["continue_session", "resume"]
172 )]
173 session_id: Option<String>,
174 #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
175 prompt_flag: Option<String>,
176 /// Per-run config override (`KEY=VALUE`), repeatable, never saved.
177 /// Runtime keys: provider, model/default_text_model, verbosity,
178 /// approval_policy, sandbox_mode, telemetry. Dedicated flags win;
179 /// managed policy still applies. `config set` persists instead. Long-only:
180 /// short `-c` is already `--continue`.
181 #[arg(long = "set", value_name = "KEY=VALUE")]
182 overrides: Vec<String>,
183 #[arg(
184 value_name = "PROMPT",
185 trailing_var_arg = true,
186 allow_hyphen_values = true
187 )]
188 prompt: Vec<String>,
189 #[command(subcommand)]
190 command: Option<Commands>,
191 }
192
193 #[derive(Debug, Subcommand)]
194 enum Commands {
195 /// Run an interactive or non-interactive task.
196 Run(RunArgs),
197 /// Run Codewhale diagnostics.
198 Doctor(TuiPassthroughArgs),
199 /// List cached models; use --update to refresh configured provider catalogs.
200 #[command(
201 after_help = "Examples:\n codewhale models --update\n codewhale models --update --provider openai\n codewhale models --provider openai-codex --json\n\n--update (alias: --refresh) refreshes configured provider catalogs. --provider ID limits the scope."
202 )]
203 Models(TuiPassthroughArgs),
204 /// Generate speech audio with Xiaomi MiMo TTS models.
205 #[command(visible_alias = "tts")]
206 Speech(TuiPassthroughArgs),
207 /// List saved sessions.
208 Sessions(TuiPassthroughArgs),
209 /// Resume a saved session.
210 Resume(TuiPassthroughArgs),
211 /// Launch an interactive session and hand it to the Codewhale web app.
212 Rc(TuiPassthroughArgs),
213 /// Fork a saved session.
214 Fork(TuiPassthroughArgs),
215 /// Create a default AGENTS.md in the current directory.
216 Init(TuiPassthroughArgs),
217 /// Bootstrap MCP config and/or skills directories.
218 Setup(TuiPassthroughArgs),
219 /// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge).
220 RemoteSetup(RemoteSetupArgs),
221 /// Run a non-interactive prompt.
222 #[command(after_help = "\
223 Examples:
224 codewhale exec \"explain this function\"
225 codewhale exec --auto \"list crates/ with ls\"
226 codewhale exec --auto --output-format stream-json \"fix the failing test\"
227
228 Common forwarded flags:
229 --auto Enable tool-backed agent mode with auto-approvals
230 --json Emit summary JSON
231 --resume <SESSION_ID> Resume a previous session by ID or prefix
232 --session-id <SESSION_ID> Resume a previous session by ID or prefix
233 --continue Continue the most recent session for this workspace
234 --output-format <FORMAT> Output format: text or stream-json
235 --hooks Opt in to configured hooks (tool_call_before, shell_env)
236
237 Plain `codewhale exec` is a one-shot model response. Use `--auto` for
238 non-interactive filesystem/shell tool use, matching the supported automation
239 path used by stream-json wrappers.
240 ")]
241 Exec(TuiPassthroughArgs),
242 /// Manage durable Agent fleet runs.
243 #[command(
244 name = "fleet",
245 after_help = "\
246 Examples:
247 codewhale fleet init
248 codewhale fleet run tasks.json --max-workers 4
249 codewhale fleet status
250
251 The durable ledger `.codewhale/fleet.jsonl`, saved rosters `fleets/<name>.toml`,
252 the `[fleet]` and `[fleets.*]` config tables, and `workflow run --fleet` keep
253 the Fleet name across versions."
254 )]
255 Fleet(TuiPassthroughArgs),
256 /// Internal model-free Workflow tool dispatcher used by Lane Runtime.
257 #[command(name = "workflow-tool", hide = true)]
258 WorkflowTool(TuiPassthroughArgs),
259 /// Internal detached-runtime output/receipt supervisor.
260 #[command(name = "lane-log-proxy", hide = true)]
261 LaneLogProxy(LaneLogProxyArgs),
262 /// Run checked-in Workflows through a Lane Runtime backend.
263 #[command(after_help = "\
264 Examples:
265 codewhale workflow run stopship --fleet stopship --runtime tmux --goal verify-release-candidate
266 codewhale workflow run stopship --fleet stopship --runtime inline --verify
267
268 `workflow run` validates the checked-in Workflow source and named Fleet roster,
269 creates a Lane record, then dispatches the Workflow tool directly through the
270 selected Runtime backend without an operator model turn.
271 ")]
272 Workflow(WorkflowArgs),
273 /// Manage running workflow instances (Lanes) and Runtime backends (#4176).
274 #[command(after_help = "\
275 Examples:
276 codewhale lane list
277 codewhale lane status <lane-id>
278 codewhale lane attach <lane-id>
279 codewhale lane logs <lane-id>
280 codewhale lane interrupt <lane-id>
281 codewhale lane interrupt <lane-id>@<lifecycle-seq>
282 codewhale lane start --workflow stopship --fleet stopship --runtime tmux --goal verify-release-candidate -- echo hello
283
284 Lane records persist under $CODEWHALE_HOME/lanes/. tmux durability belongs to
285 Runtime, not Fleet.
286
287 list/status/interrupt/restart/resume share one control-plane contract with the
288 `/lane` slash command and its hotbar action: same verb ids, same availability,
289 same read-vs-write authority, same exact-identity target selection, and the
290 same receipt (`--json`). `lane stop` is a compatibility spelling of
291 `lane interrupt`. Appending `@<lifecycle-seq>` fences a write to the exact
292 lifecycle generation you observed.
293 ")]
294 Lane(LaneArgs),
295 /// Run a Codewhale-powered code review over a git diff.
296 Review(TuiPassthroughArgs),
297 /// Apply a patch file or stdin to the working tree.
298 Apply(TuiPassthroughArgs),
299 /// Run the offline evaluation harness.
300 Eval(TuiPassthroughArgs),
301 /// Manage MCP servers.
302 Mcp(TuiPassthroughArgs),
303 /// Run the shared ambient pet owner (`pet serve`). Internal: spawned
304 /// lazily by clients when no owner is running.
305 #[command(name = "pet", hide = true)]
306 Pet(TuiPassthroughArgs),
307 /// Inspect feature flags.
308 Features(TuiPassthroughArgs),
309 /// Connect third-party harnesses through Codewhale (e.g. `integrations dsh status`).
310 Integrations(TuiPassthroughArgs),
311 /// Run a local Codewhale server.
312 #[command(after_help = "\
313 Forwarded serve options:
314 --mcp Start MCP server over stdio
315 --http Start runtime HTTP/SSE API server
316 --mobile Start runtime HTTP/SSE API server with the mobile control page
317 --web Start the embedded loopback-only browser client
318 --qr Show a QR code for the mobile URL (requires --mobile)
319 --acp Start ACP server over stdio for editor clients
320 --host <HOST> Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
321 --port <PORT> Bind port [default: 7878]
322 --workers <WORKERS> Background task worker count (1-8)
323 --cors-origin <URL> Additional CORS origin to allow (repeatable)
324 --auth-token <TOKEN> Require this bearer token for /v1/* runtime API routes
325 --insecure Disable runtime API auth when no token is configured
326
327 `codewhale serve --http` and `codewhale serve --mobile` remain compatibility
328 aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
329 New integrations should prefer `codewhale app-server`.")]
330 Serve(TuiPassthroughArgs),
331 /// Open the first-class local browser client over the canonical Runtime API.
332 #[command(
333 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."
334 )]
335 Web(WebArgs),
336 /// Sign in to your Codewhale account (browser device flow).
337 Login(LoginArgs),
338 /// Remove saved authentication state.
339 Logout,
340 /// Manage authentication credentials and provider mode.
341 Auth(AuthArgs),
342 /// Sign in to your Codewhale account and manage account-scoped provider keys.
343 #[command(visible_alias = "cloud")]
344 Account(cloud::CloudArgs),
345 /// Offload a coding agent to the Codewhale cloud. Never spends or pushes without --confirm.
346 #[command(visible_alias = "cloud-agent")]
347 Dispatch(dispatch::DispatchArgs),
348 /// Run MCP server mode over stdio.
349 McpServer,
350 /// Read/write/list config values.
351 Config(ConfigArgs),
352 /// Resolve or list available models across providers.
353 Model(ModelArgs),
354 /// Manage thread/session metadata and resume/fork flows.
355 Thread(ThreadArgs),
356 /// Evaluate sandbox/approval policy decisions.
357 Sandbox(SandboxArgs),
358 /// Run the canonical runtime API / control plane (HTTP/SSE, mobile, stdio).
359 #[command(after_help = "\
360 Transports:
361 codewhale app-server --http Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
362 codewhale app-server --mobile Runtime API + phone control page (binds 0.0.0.0)
363 codewhale app-server --stdio JSON-RPC control transport over stdio (no listener)
364 codewhale app-server Legacy in-process app-server HTTP on 127.0.0.1:8787
365
366 `--http` and `--mobile` serve the same mature runtime API as `codewhale serve
367 --http`/`--mobile`, which remain as compatibility aliases. The runtime API token
368 is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
369
370 See docs/RUNTIME_API.md.")]
371 AppServer(AppServerArgs),
372 /// Generate shell completions.
373 #[command(
374 visible_alias = "completions",
375 after_help = r#"Every script completes both `codewhale` and the `codew` shorthand.
376
377 Examples:
378 Bash (current shell only):
379 source <(codewhale completion bash)
380
381 Bash (persistent, Linux/bash-completion):
382 mkdir -p ~/.local/share/bash-completion/completions
383 codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
384 # Requires bash-completion to be installed and loaded by your shell.
385
386 Zsh:
387 mkdir -p ~/.zfunc
388 codewhale completion zsh > ~/.zfunc/_codewhale
389 # Add to ~/.zshrc if needed:
390 # fpath=(~/.zfunc $fpath)
391 # autoload -Uz compinit && compinit
392
393 Fish:
394 mkdir -p ~/.config/fish/completions
395 codewhale completion fish > ~/.config/fish/completions/codewhale.fish
396
397 PowerShell (current shell only):
398 codewhale completion powershell | Out-String | Invoke-Expression
399
400 PowerShell (persistent):
401 New-Item -ItemType Directory -Force -Path (Split-Path -Parent $PROFILE)
402 codewhale completion powershell >> $PROFILE
403
404 Elvish:
405 codewhale completion elvish >> ~/.config/elvish/rc.elv
406
407 The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#
408 )]
409 Completion {
410 #[arg(value_enum)]
411 shell: Shell,
412 },
413 /// Print a usage rollup from the audit log and session store.
414 Metrics(MetricsArgs),
415 /// Update this release binary from GitHub (package-managed installs get migration instructions).
416 #[command(
417 after_help = "GitHub Releases is the default source. Supported mirrors are explicit overrides or manifest-failure fallbacks. Checksums are required; older releases never replace a newer build.\n\nThe command prints the executable it will update. If you have multiple installs, run the intended binary by its full path.\n\nNew macOS/Linux install: curl -fsSL https://codewhale.net/install.sh | sh\nInstallation and PATH help: https://github.com/Hmbown/CodeWhale/blob/main/docs/INSTALL.md"
418 )]
419 Update(UpdateArgs),
420 /// Export the route catalog (`providers export --json`).
421 Providers(ProvidersArgs),
422 }
423
424 #[derive(Debug, Args)]
425 struct ProvidersArgs {
426 #[command(subcommand)]
427 command: ProvidersCommand,
428 }
429
430 #[derive(Debug, Subcommand)]
431 enum ProvidersCommand {
432 /// Write the owned route catalog as JSON (cwc contract).
433 Export {
434 /// Required. The export is the generated cwc catalog source of truth.
435 #[arg(long)]
436 json: bool,
437 },
438 }
439
440 /// The name of this crate's `[[bin]]` target, and the command users actually
441 /// type. Completion scripts must register *this*, not the in-tree
442 /// `codewhale-tui` binary that used to render them (#5526).
443 ///
444 /// GitHub releases do not ship a separately compiled TUI: `release-artifacts.yml`
445 /// builds `-p codewhale-cli` and publishes `codewhale` plus a byte-identical
446 /// `codew` copy. The `codewhale-tui-*` filenames still attached to the release
447 /// are that same binary (a v0.9.4 updater bridge), not a third runtime.
448 const COMPLETION_BIN_NAME: &str = "codewhale";
449
450 /// Releases publish `codew` as a byte-identical copy of `codewhale`
451 /// (`release-artifacts.yml` copies the binary and `cmp`s it), so a completion
452 /// script that fires only for `codewhale` is half-installed for anyone who
453 /// types the short name.
454 const COMPLETION_ALIAS_NAME: &str = "codew";
455
456 /// Render the completion script for `shell` from this binary's own clap tree,
457 /// registered for both published command names.
458 fn render_completion_script(shell: Shell) -> String {
459 let mut cmd = Cli::command();
460 let mut buf = Vec::new();
461 generate(shell, &mut cmd, COMPLETION_BIN_NAME, &mut buf);
462 let script = String::from_utf8_lossy(&buf).into_owned();
463 register_completion_alias(shell, script)
464 }
465
466 /// Extend a clap_complete script so the `codew` shorthand completes too.
467 ///
468 /// Each shell gets its own idiomatic hook rather than a second copy of the
469 /// script: bash re-binds the generated function, zsh widens the `#compdef`
470 /// tag line, fish wraps the primary command, PowerShell registers an array
471 /// of command names, and Elvish aliases the completer map entry. `Shell` is
472 /// non-exhaustive, so any future variant falls through unchanged.
473 fn register_completion_alias(shell: Shell, script: String) -> String {
474 let bin = COMPLETION_BIN_NAME;
475 let alias = COMPLETION_ALIAS_NAME;
476 match shell {
477 Shell::Bash => format!(
478 "{script}\n\
479 if [[ \"${{BASH_VERSINFO[0]}}\" -eq 4 && \"${{BASH_VERSINFO[1]}}\" -ge 4 || \"${{BASH_VERSINFO[0]}}\" -gt 4 ]]; then\n \
480 complete -F _{bin} -o nosort -o bashdefault -o default {alias}\n\
481 else\n \
482 complete -F _{bin} -o bashdefault -o default {alias}\n\
483 fi\n"
484 ),
485 // Two install paths, two hooks. Autoloaded from `fpath` the tag line
486 // on the first line is what binds the names; sourced directly, the
487 // `compdef` call clap emits at the bottom is. Cover both, and reuse
488 // clap's own `funcstack` guard so the appended call is skipped when
489 // the body runs as the completion function itself.
490 Shell::Zsh => {
491 let tagged = match script.strip_prefix(&format!("#compdef {bin}\n")) {
492 Some(rest) => format!("#compdef {bin} {alias}\n{rest}"),
493 None => script,
494 };
495 format!(
496 "{tagged}\nif [ \"$funcstack[1]\" != \"_{bin}\" ]; then\n \
497 compdef _{bin} {alias}\n\
498 fi\n"
499 )
500 }
501 Shell::Fish => format!("{script}\ncomplete -c {alias} -w {bin}\n"),
502 Shell::PowerShell => script.replacen(
503 &format!("-CommandName '{bin}'"),
504 &format!("-CommandName '{bin}','{alias}'"),
505 1,
506 ),
507 Shell::Elvish => format!(
508 "{script}\n\
509 set edit:completion:arg-completer[{alias}] = $edit:completion:arg-completer[{bin}]\n"
510 ),
511 _ => script,
512 }
513 }
514
515 fn command_accepts_raw_provider(command: Option<&Commands>) -> bool {
516 matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_)))
517 }
518
519 fn top_level_provider_override(
520 provider: Option<&str>,
521 command: Option<&Commands>,
522 ) -> Result<Option<ProviderKind>> {
523 let Some(provider) = provider else {
524 return Ok(None);
525 };
526 if is_antigravity_legacy_selector(provider) {
527 bail!(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE);
528 }
529 if let Some(provider) = builtin_provider_arg(provider) {
530 return Ok(Some(provider));
531 }
532 if command_accepts_raw_provider(command) {
533 return Ok(None);
534 }
535
536 let expected = ProviderKind::names_hint();
537 bail!(
538 "invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted only by exec and fleet"
539 )
540 }
541
542 fn prepare_raw_provider_tui_dispatch(
543 cli: &Cli,
544 command: Option<&Commands>,
545 runtime_overrides: &CliRuntimeOverrides,
546 ) -> Result<Option<(ResolvedRuntimeOptions, Vec<String>)>> {
547 let Some(provider) = cli.provider.as_deref() else {
548 return Ok(None);
549 };
550 if builtin_provider_arg(provider).is_some() || !command_accepts_raw_provider(command) {
551 return Ok(None);
552 }
553
554 let passthrough = match command {
555 Some(Commands::Exec(args)) => {
556 reject_exec_global_flags(&args.args)?;
557 tui_args("exec", args.clone())
558 }
559 Some(Commands::Fleet(args)) => tui_args("fleet", args.clone()),
560 _ => unreachable!("raw provider validation only permits Exec and Fleet"),
561 };
562
563 // Dynamic provider config belongs to the TUI schema. Do not parse it
564 // through the dispatcher's enum-backed ConfigStore or recover credentials
565 // for an unrelated fallback provider before the TUI sees the raw id.
566 let resolved_runtime = ConfigToml::default().resolve_runtime_options(runtime_overrides);
567 Ok(Some((resolved_runtime, passthrough)))
568 }
569
570 #[derive(Debug, Args)]
571 struct UpdateArgs {
572 /// Update to the latest beta release instead of the latest stable release.
573 #[arg(long)]
574 beta: bool,
575 /// Only check the latest release; do not download or replace binaries.
576 #[arg(long)]
577 check: bool,
578 /// Proxy URL to use for update HTTP requests.
579 #[arg(long, value_name = "URL")]
580 proxy: Option<String>,
581 }
582
583 #[derive(Debug, Args)]
584 struct MetricsArgs {
585 /// Emit machine-readable JSON.
586 #[arg(long)]
587 json: bool,
588 /// Restrict to events newer than this duration (e.g. 7d, 24h, 30m, now-2h).
589 #[arg(long, value_name = "DURATION")]
590 since: Option<String>,
591 }
592
593 #[derive(Debug, Args)]
594 struct RunArgs {
595 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
596 args: Vec<String>,
597 }
598
599 #[derive(Debug, Args, Clone)]
600 struct TuiPassthroughArgs {
601 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
602 args: Vec<String>,
603 }
604
605 #[derive(Debug, Args)]
606 struct WebArgs {
607 /// Loopback port for the local Runtime API and embedded client.
608 #[arg(long, default_value_t = 7878)]
609 port: u16,
610 }
611
612 #[derive(Debug, Args)]
613 struct LaneLogProxyArgs {
614 #[arg(long, value_name = "PATH")]
615 log_path: PathBuf,
616 #[arg(long, value_name = "PATH")]
617 receipt_path: PathBuf,
618 #[arg(long, value_name = "PATH")]
619 receipt_tmp_path: PathBuf,
620 #[arg(long, value_name = "PATH")]
621 environment_path: Option<PathBuf>,
622 #[arg(long)]
623 lane_id: String,
624 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
625 command: Vec<String>,
626 }
627
628 /// `codewhale lane …` — running workflow instances (#4176).
629 #[derive(Debug, Args)]
630 struct LaneArgs {
631 #[command(subcommand)]
632 command: LaneCommand,
633 }
634
635 #[derive(Debug, Subcommand)]
636 // Clap constructs this command enum once at process startup. Keeping the
637 // fields inline makes the generated CLI shape explicit; boxing them only to
638 // reduce this transient value would add indirection without runtime benefit.
639 #[allow(clippy::large_enum_variant)]
640 enum LaneCommand {
641 /// List known lanes (newest first).
642 List {
643 /// Emit JSON.
644 #[arg(long, default_value_t = false)]
645 json: bool,
646 },
647 /// Show one lane's status and attach metadata.
648 Status {
649 /// Lane id (e.g. `lane-a1b2c3d4`).
650 lane_id: String,
651 #[arg(long, default_value_t = false)]
652 json: bool,
653 },
654 /// Attach to a tmux-backed lane (prints attach command; execs when possible).
655 Attach {
656 lane_id: String,
657 /// Only print the attach command; do not exec.
658 #[arg(long, default_value_t = false)]
659 print: bool,
660 },
661 /// Tail the lane stream-json / NDJSON journal.
662 Logs {
663 lane_id: String,
664 /// Follow the log file (like `tail -f`).
665 #[arg(long, short = 'f', default_value_t = false)]
666 follow: bool,
667 /// Number of trailing lines when not following (default 50).
668 #[arg(long, default_value_t = 50)]
669 tail: usize,
670 },
671 /// Stop a running lane and run worktree TTL cleanup.
672 ///
673 /// Compatibility spelling for `lane interrupt`; both resolve to the
674 /// `lane.interrupt` control-plane verb (#1888).
675 Stop { lane_id: String },
676 /// Interrupt a running lane (durable `lane.interrupt`).
677 ///
678 /// Accepts an exact lane id, optionally fenced as `<lane-id>@<seq>` so the
679 /// stop only applies to the lifecycle generation you observed.
680 Interrupt {
681 lane_id: String,
682 #[arg(long, default_value_t = false)]
683 json: bool,
684 },
685 /// Restart a lane in place (declared, no backend — reports why).
686 Restart {
687 lane_id: String,
688 #[arg(long, default_value_t = false)]
689 json: bool,
690 },
691 /// Resume a stopped lane (declared, no backend — reports why).
692 Resume {
693 lane_id: String,
694 #[arg(long, default_value_t = false)]
695 json: bool,
696 },
697 /// Start a lane under a Runtime backend (tmux|inline|vm|ci).
698 Start {
699 /// Workflow name (e.g. `stopship`).
700 #[arg(long)]
701 workflow: Option<String>,
702 /// Fleet roster name (e.g. `stopship`); the flag keeps its compatibility spelling.
703 #[arg(long)]
704 fleet: Option<String>,
705 /// Issue id binding.
706 #[arg(long)]
707 issue: Option<String>,
708 /// Free-form goal text.
709 #[arg(long)]
710 goal: Option<String>,
711 /// Runtime backend: tmux, inline, vm, or ci.
712 #[arg(long, default_value = "tmux")]
713 runtime: String,
714 /// Create an isolated worktree under this repo root.
715 #[arg(long, value_name = "DIR")]
716 worktree_repo: Option<PathBuf>,
717 /// Branch name for the worktree (requires `--worktree-repo`).
718 #[arg(long)]
719 branch: Option<String>,
720 /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
721 #[arg(long, value_name = "DIR")]
722 worktree_path: Option<PathBuf>,
723 /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
724 #[arg(long)]
725 worktree_ttl_secs: Option<u64>,
726 /// Command to run in the runtime (after `--`).
727 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
728 command: Vec<String>,
729 },
730 }
731
732 /// `codewhale workflow …` — Workflow entrypoints backed by Lanes (#4177/#4178).
733 #[derive(Debug, Args)]
734 struct WorkflowArgs {
735 #[command(subcommand)]
736 command: WorkflowCommand,
737 }
738
739 #[derive(Debug, Subcommand)]
740 enum WorkflowCommand {
741 /// Run a checked-in Workflow through a Runtime-backed Lane.
742 Run {
743 /// Workflow name or path. `stopship` maps to workflows/stopship.workflow.js.
744 workflow: String,
745 /// Named Fleet roster (e.g. stopship). The flag keeps its compatibility
746 /// spelling. Without one, roles resolve against the built-in roster
747 /// and the session route.
748 #[arg(long)]
749 fleet: Option<String>,
750 /// Issue id binding recorded on the Lane and passed into workflow args.
751 #[arg(long)]
752 issue: Option<String>,
753 /// Free-form goal text recorded on the Lane and passed into workflow args.
754 #[arg(long)]
755 goal: Option<String>,
756 /// Runtime backend: tmux, inline, vm, or ci.
757 #[arg(long, default_value = "tmux")]
758 runtime: String,
759 /// Explicit Workflow source path, overriding name-based resolution.
760 #[arg(long, value_name = "PATH")]
761 source_path: Option<PathBuf>,
762 /// Optional shared Workflow token budget.
763 #[arg(long)]
764 token_budget: Option<u64>,
765 /// Run verifier gates after a successful Workflow completion.
766 #[arg(long, default_value_t = false)]
767 verify: bool,
768 /// Create an isolated worktree under this repo root.
769 #[arg(long, value_name = "DIR")]
770 worktree_repo: Option<PathBuf>,
771 /// Branch name for the worktree (requires `--worktree-repo`).
772 #[arg(long)]
773 branch: Option<String>,
774 /// Worktree path (defaults to `<repo>/.codewhale/lanes/<lane-id>`).
775 #[arg(long, value_name = "DIR")]
776 worktree_path: Option<PathBuf>,
777 /// Worktree cleanup TTL seconds after stop (0 = immediate on stop).
778 #[arg(long)]
779 worktree_ttl_secs: Option<u64>,
780 },
781 }
782
783 struct LaneStartRequest {
784 workflow: Option<String>,
785 fleet: Option<String>,
786 issue: Option<String>,
787 goal: Option<String>,
788 runtime: String,
789 worktree_repo: Option<PathBuf>,
790 branch: Option<String>,
791 worktree_path: Option<PathBuf>,
792 worktree_ttl_secs: Option<u64>,
793 command: Vec<String>,
794 environment: Vec<(String, String)>,
795 cwd: Option<PathBuf>,
796 }
797
798 fn start_lane(request: LaneStartRequest) -> Result<()> {
799 use codewhale_lane::{
800 LaneRegistry, LaneStartSpec, RuntimeBackendKind, WorktreeProvision, resolve_backend,
801 };
802
803 let LaneStartRequest {
804 workflow,
805 fleet,
806 issue,
807 goal,
808 runtime,
809 worktree_repo,
810 branch,
811 worktree_path,
812 worktree_ttl_secs,
813 command,
814 environment,
815 cwd,
816 } = request;
817 let kind = RuntimeBackendKind::parse(&runtime)?;
818 let reg = LaneRegistry::open_default()?;
819 let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
820 let worktree = match (worktree_repo, branch) {
821 (Some(repo_root), Some(branch_name)) => {
822 let path = worktree_path
823 .unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
824 Some(WorktreeProvision {
825 repo_root,
826 branch: branch_name,
827 path,
828 base_ref: None,
829 })
830 }
831 (None, None) => None,
832 _ => bail!("--worktree-repo and --branch must be provided together"),
833 };
834 let cmd = if command.is_empty() {
835 vec![
836 "sh".into(),
837 "-c".into(),
838 format!("echo lane {} started", record.id),
839 ]
840 } else {
841 command
842 };
843 let spec = LaneStartSpec {
844 command: cmd,
845 cwd,
846 environment,
847 log_proxy: (kind == RuntimeBackendKind::Tmux)
848 .then(std::env::current_exe)
849 .transpose()
850 .context("resolve current Codewhale executable for tmux log proxy")?,
851 worktree,
852 };
853 let backend = resolve_backend(kind);
854 backend.start(&reg, &mut record, &spec)?;
855 println!("started {}", record.id);
856 println!("status: {}", record.status.as_str());
857 println!("runtime: {}", record.runtime.as_str());
858 println!("log: {}", record.log_path.display());
859 if let Some(attach) = backend.attach_command(&record) {
860 println!("attach: {attach}");
861 }
862 Ok(())
863 }
864
865 /// Print one shared control receipt on the CLI surface.
866 ///
867 /// The CLI does not format Lane control results itself: it renders the same
868 /// [`codewhale_lane::ControlReceipt`] the slash command and hotbar render, so
869 /// the three surfaces cannot drift in what they report (#1888).
870 fn emit_control_receipt(receipt: &codewhale_lane::ControlReceipt, json: bool) -> Result<()> {
871 if json {
872 // v0.9.2 compatibility: `lane list --json` has always emitted an array
873 // of `LaneRecord`, and `lane status --json` a single one. Scripts
874 // select `.[].id`, `.worktree_path`, `.log_path` off that shape, so the
875 // receipt does not replace it. The receipt is what every other verb
876 // emits, and what the human renderer shows for these two.
877 match receipt.operation {
878 codewhale_lane::ControlOperation::LaneList => {
879 println!("{}", serde_json::to_string_pretty(&receipt.lane_records)?);
880 }
881 codewhale_lane::ControlOperation::LaneStatus => match receipt.lane_records.first() {
882 Some(record) => println!("{}", serde_json::to_string_pretty(record)?),
883 // Legacy behaviour for an unknown id: `reg.load()` failed, so
884 // the command errored on stderr and printed *nothing* on
885 // stdout. Emitting a receipt (or a bare `null`) here would make
886 // `lane status --json <bad-id> | jq` succeed where it used to
887 // fail. Stay silent and let the bail! below set the exit code.
888 None if receipt.is_error() => {}
889 None => println!("{}", serde_json::to_string_pretty(receipt)?),
890 },
891 _ => println!("{}", serde_json::to_string_pretty(receipt)?),
892 }
893 } else if receipt.is_error() {
894 eprintln!("{}", receipt.render());
895 } else {
896 println!("{}", receipt.render());
897 }
898 if receipt.is_error() {
899 let detail = receipt
900 .failure
901 .as_ref()
902 .map(ToString::to_string)
903 .unwrap_or_else(|| receipt.outcome.as_str().to_string());
904 bail!("{}: {detail}", receipt.operation_id);
905 }
906 Ok(())
907 }
908
909 fn run_lane_control(
910 operation: codewhale_lane::ControlOperation,
911 lane_id: Option<&str>,
912 json: bool,
913 ) -> Result<()> {
914 let receipt = codewhale_lane::control::execute_lane_control(
915 codewhale_lane::ControlSurface::Cli,
916 operation,
917 lane_id,
918 );
919 emit_control_receipt(&receipt, json)
920 }
921
922 fn run_lane_command(args: LaneArgs) -> Result<()> {
923 use codewhale_lane::{ControlOperation, LaneRegistry, backend_for};
924 use std::io::{BufRead, Seek, Write};
925 use std::process::Command;
926 use std::thread;
927 use std::time::Duration;
928
929 match args.command {
930 LaneCommand::List { json } => run_lane_control(ControlOperation::LaneList, None, json),
931 LaneCommand::Status { lane_id, json } => {
932 run_lane_control(ControlOperation::LaneStatus, Some(&lane_id), json)
933 }
934 LaneCommand::Interrupt { lane_id, json } => {
935 run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), json)
936 }
937 LaneCommand::Restart { lane_id, json } => {
938 run_lane_control(ControlOperation::LaneRestart, Some(&lane_id), json)
939 }
940 LaneCommand::Resume { lane_id, json } => {
941 run_lane_control(ControlOperation::LaneResume, Some(&lane_id), json)
942 }
943 LaneCommand::Attach { lane_id, print } => {
944 let reg = LaneRegistry::open_default()?;
945 let mut lane = reg.load(&lane_id)?;
946 let backend = backend_for(&lane);
947 backend.reconcile(&reg, &mut lane)?;
948 let Some(attach) = backend.attach_command(&lane) else {
949 if !lane.status.is_active() {
950 bail!(
951 "lane `{lane_id}` is {} and has no active attach target",
952 lane.status.as_str()
953 );
954 }
955 bail!(
956 "lane `{lane_id}` runtime `{}` has no attach target",
957 lane.runtime.as_str()
958 );
959 };
960 if print {
961 println!("{attach}");
962 return Ok(());
963 }
964 if let Some(session) = lane.tmux_session.as_deref() {
965 let socket = lane
966 .tmux_socket
967 .as_deref()
968 .context("tmux lane is missing its pinned server socket")?;
969 let status = Command::new("tmux")
970 .arg("-S")
971 .arg(socket)
972 .args(["attach", "-t", session])
973 .status();
974 match status {
975 Ok(s) if s.success() => Ok(()),
976 Ok(s) => bail!("tmux attach failed ({s}); command was: {attach}"),
977 Err(err) => {
978 eprintln!("could not exec tmux: {err}");
979 println!("{attach}");
980 bail!("tmux attach unavailable");
981 }
982 }
983 } else {
984 println!("{attach}");
985 Ok(())
986 }
987 }
988 LaneCommand::Logs {
989 lane_id,
990 follow,
991 tail,
992 } => {
993 let reg = LaneRegistry::open_default()?;
994 let lane = reg.load(&lane_id)?;
995 let path = lane.log_path;
996 if !path.exists() {
997 bail!("log file missing: {}", path.display());
998 }
999 let content = std::fs::read(&path)?;
1000 let lines: Vec<&[u8]> = content
1001 .split(|byte| *byte == b'\n')
1002 .filter(|line| !line.is_empty())
1003 .collect();
1004 let start = lines.len().saturating_sub(tail);
1005 let mut stdout = std::io::stdout().lock();
1006 for line in &lines[start..] {
1007 stdout.write_all(String::from_utf8_lossy(line).as_bytes())?;
1008 stdout.write_all(b"\n")?;
1009 }
1010 stdout.flush()?;
1011 if !follow {
1012 return Ok(());
1013 }
1014 let mut file = std::fs::File::open(&path)?;
1015 file.seek(std::io::SeekFrom::End(0))?;
1016 let mut reader = std::io::BufReader::new(file);
1017 loop {
1018 let mut line = Vec::new();
1019 match reader.read_until(b'\n', &mut line) {
1020 Ok(0) => {
1021 thread::sleep(Duration::from_millis(200));
1022 continue;
1023 }
1024 Ok(_) => {
1025 let mut stdout = std::io::stdout().lock();
1026 stdout.write_all(String::from_utf8_lossy(&line).as_bytes())?;
1027 stdout.flush()?;
1028 }
1029 Err(err) => return Err(err.into()),
1030 }
1031 }
1032 }
1033 // `stop` is the historical spelling of `interrupt`. Both go through
1034 // the same verb so the durable transition, the lifecycle fence, and
1035 // the receipt are identical.
1036 LaneCommand::Stop { lane_id } => {
1037 run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), false)
1038 }
1039 LaneCommand::Start {
1040 workflow,
1041 fleet,
1042 issue,
1043 goal,
1044 runtime,
1045 worktree_repo,
1046 branch,
1047 worktree_path,
1048 worktree_ttl_secs,
1049 command,
1050 } => start_lane(LaneStartRequest {
1051 workflow,
1052 fleet,
1053 issue,
1054 goal,
1055 runtime,
1056 worktree_repo,
1057 branch,
1058 worktree_path,
1059 worktree_ttl_secs,
1060 command,
1061 environment: Vec::new(),
1062 cwd: None,
1063 }),
1064 }
1065 }
1066
1067 fn run_lane_log_proxy_command(args: LaneLogProxyArgs) -> Result<()> {
1068 let exit_code = codewhale_lane::run_lane_log_proxy(codewhale_lane::LaneLogProxySpec {
1069 command: args.command,
1070 log_path: args.log_path,
1071 receipt_path: args.receipt_path,
1072 receipt_tmp_path: args.receipt_tmp_path,
1073 environment_path: args.environment_path,
1074 lane_id: args.lane_id,
1075 })?;
1076 std::process::exit(exit_code);
1077 }
1078
1079 fn run_workflow_command(
1080 cli: &Cli,
1081 resolved_runtime: &ResolvedRuntimeOptions,
1082 config_path: &Path,
1083 args: WorkflowArgs,
1084 ) -> Result<()> {
1085 match args.command {
1086 WorkflowCommand::Run {
1087 workflow,
1088 fleet,
1089 issue,
1090 goal,
1091 runtime,
1092 source_path,
1093 token_budget,
1094 verify,
1095 worktree_repo,
1096 branch,
1097 worktree_path,
1098 worktree_ttl_secs,
1099 } => {
1100 let workspace = workflow_workspace_root(cli.workspace.as_deref())?;
1101 let source_path =
1102 resolve_workflow_source_path(&workflow, source_path.as_ref(), &workspace)?;
1103 validate_workflow_source_file(&source_path)?;
1104
1105 let source_root = if let Some(repo) = worktree_repo.as_deref() {
1106 repo.canonicalize()
1107 .with_context(|| format!("resolve --worktree-repo {}", repo.display()))?
1108 } else {
1109 workspace.clone()
1110 };
1111
1112 // A fleet is an optional pin layer, not a requirement: role-only
1113 // tasks resolve against the built-in roster and the session route
1114 // (matching the TUI tool path). When a fleet IS given, it is
1115 // loaded and validated before the run starts.
1116 if let Some(name) = fleet.as_deref() {
1117 let roots = named_fleet_search_roots(&workspace);
1118 let loaded =
1119 codewhale_workflow::load_named_fleet(name, &roots).with_context(|| {
1120 format!("load Fleet `{name}` from {}", display_roots(&roots))
1121 })?;
1122 if workflow == "stopship" || name == "stopship" {
1123 loaded
1124 .validate_stopship_roles()
1125 .with_context(|| format!("validate stopship roles in Fleet `{name}`"))?;
1126 }
1127 }
1128
1129 let process = workflow_exec_command(WorkflowExecSpec {
1130 cli,
1131 resolved_runtime,
1132 config_path,
1133 source_root: &source_root,
1134 source_path: &source_path,
1135 workflow: &workflow,
1136 fleet: fleet.as_deref(),
1137 issue: issue.as_deref(),
1138 goal: goal.as_deref(),
1139 token_budget,
1140 verify,
1141 })?;
1142 start_lane(LaneStartRequest {
1143 workflow: Some(workflow),
1144 fleet,
1145 issue,
1146 goal,
1147 runtime,
1148 worktree_repo,
1149 branch,
1150 worktree_path,
1151 worktree_ttl_secs,
1152 command: process.command,
1153 environment: process.environment,
1154 cwd: Some(workspace),
1155 })
1156 }
1157 }
1158 }
1159
1160 fn workflow_workspace_root(explicit: Option<&Path>) -> Result<PathBuf> {
1161 if let Some(path) = explicit {
1162 return path
1163 .canonicalize()
1164 .with_context(|| format!("resolve workflow workspace {}", path.display()));
1165 }
1166 let cwd = std::env::current_dir().context("resolve current directory")?;
1167 let output = Command::new("git")
1168 .args(["rev-parse", "--show-toplevel"])
1169 .current_dir(&cwd)
1170 .output();
1171 if let Ok(output) = output
1172 && output.status.success()
1173 {
1174 let text = String::from_utf8_lossy(&output.stdout);
1175 let root = text.trim();
1176 if !root.is_empty() {
1177 let root = PathBuf::from(root);
1178 return Ok(root.canonicalize().unwrap_or(root));
1179 }
1180 }
1181 Ok(cwd)
1182 }
1183
1184 fn resolve_workflow_source_path(
1185 workflow: &str,
1186 source_path: Option<&PathBuf>,
1187 workspace: &Path,
1188 ) -> Result<PathBuf> {
1189 let candidates = workflow_source_candidates(workflow, source_path, workspace);
1190 for candidate in &candidates {
1191 if candidate.is_file() {
1192 return Ok(candidate.clone());
1193 }
1194 }
1195 bail!(
1196 "workflow source for `{workflow}` not found; tried {}",
1197 candidates
1198 .iter()
1199 .map(|p| p.display().to_string())
1200 .collect::<Vec<_>>()
1201 .join(", ")
1202 )
1203 }
1204
1205 fn workflow_source_candidates(
1206 workflow: &str,
1207 source_path: Option<&PathBuf>,
1208 workspace: &Path,
1209 ) -> Vec<PathBuf> {
1210 let mut candidates = Vec::new();
1211 if let Some(path) = source_path {
1212 candidates.push(resolve_against_workspace(path, workspace));
1213 return candidates;
1214 }
1215
1216 let raw = workflow.trim();
1217 let workflow_path = PathBuf::from(raw);
1218 if raw.contains('/') || raw.contains('\\') || raw.ends_with(".js") || raw.ends_with(".ts") {
1219 candidates.push(resolve_against_workspace(&workflow_path, workspace));
1220 return candidates;
1221 }
1222
1223 let normalized = raw.replace('-', "_");
1224 for rel in [
1225 format!("workflows/{raw}.workflow.js"),
1226 format!("workflows/{normalized}.workflow.js"),
1227 ] {
1228 let path = workspace.join(rel);
1229 if !candidates.iter().any(|existing| existing == &path) {
1230 candidates.push(path);
1231 }
1232 }
1233 candidates
1234 }
1235
1236 fn resolve_against_workspace(path: &Path, workspace: &Path) -> PathBuf {
1237 if path.is_absolute() {
1238 path.to_path_buf()
1239 } else {
1240 workspace.join(path)
1241 }
1242 }
1243
1244 fn validate_workflow_source_file(path: &Path) -> Result<()> {
1245 let source =
1246 std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1247 if source.trim_start().starts_with("export default workflow(")
1248 || source.trim_start().starts_with("workflow(")
1249 || source.contains("\nworkflow(")
1250 {
1251 let identifier = path.display().to_string();
1252 if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
1253 codewhale_workflow::compile_typescript_workflow(&identifier, &source)
1254 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1255 } else {
1256 codewhale_workflow::compile_javascript_workflow(&identifier, &source)
1257 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1258 }
1259 }
1260 Ok(())
1261 }
1262
1263 fn named_fleet_search_roots(workspace: &Path) -> Vec<PathBuf> {
1264 let mut roots = Vec::new();
1265 if let Ok(home) = codewhale_config::codewhale_home() {
1266 roots.push(home);
1267 }
1268 roots.push(workspace.to_path_buf());
1269 roots
1270 }
1271
1272 fn display_roots(roots: &[PathBuf]) -> String {
1273 roots
1274 .iter()
1275 .map(|root| root.display().to_string())
1276 .collect::<Vec<_>>()
1277 .join(", ")
1278 }
1279
1280 struct WorkflowExecSpec<'a> {
1281 cli: &'a Cli,
1282 resolved_runtime: &'a ResolvedRuntimeOptions,
1283 config_path: &'a Path,
1284 source_root: &'a Path,
1285 source_path: &'a Path,
1286 workflow: &'a str,
1287 fleet: Option<&'a str>,
1288 issue: Option<&'a str>,
1289 goal: Option<&'a str>,
1290 token_budget: Option<u64>,
1291 verify: bool,
1292 }
1293
1294 struct WorkflowProcessSpec {
1295 command: Vec<String>,
1296 environment: Vec<(String, String)>,
1297 }
1298
1299 fn workflow_exec_command(spec: WorkflowExecSpec<'_>) -> Result<WorkflowProcessSpec> {
1300 let WorkflowExecSpec {
1301 cli,
1302 resolved_runtime,
1303 config_path,
1304 source_root,
1305 source_path,
1306 workflow,
1307 fleet,
1308 issue,
1309 goal,
1310 token_budget,
1311 verify,
1312 } = spec;
1313 let source_arg = source_path
1314 .strip_prefix(source_root)
1315 .with_context(|| {
1316 format!(
1317 "workflow source {} must be inside execution root {}",
1318 source_path.display(),
1319 source_root.display()
1320 )
1321 })?
1322 .display()
1323 .to_string();
1324 let mut payload = serde_json::json!({
1325 "action": "run",
1326 "source_path": source_arg,
1327 "fleet": fleet,
1328 "args": {
1329 "workflow": workflow,
1330 "fleet": fleet,
1331 "issue": issue,
1332 "goal": goal,
1333 },
1334 "verify": verify,
1335 });
1336 if let Some(token_budget) = token_budget {
1337 payload["token_budget"] = serde_json::json!(token_budget);
1338 }
1339 let input_json = serde_json::to_string(&payload)?;
1340 let passthrough = vec![
1341 "workflow-tool".to_string(),
1342 "--approval-source".to_string(),
1343 "explicit-workflow-command".to_string(),
1344 "--input-json".to_string(),
1345 input_json,
1346 ];
1347 let argv = {
1348 // Build argv with explicit config path like the previous dispatcher did.
1349 let mut args = Vec::new();
1350 let executable = std::env::current_exe()
1351 .context("resolve current Codewhale executable for workflow lane")?;
1352 let executable = executable.into_os_string().into_string().map_err(|path| {
1353 anyhow!(
1354 "current Codewhale executable path is not valid UTF-8: {}",
1355 PathBuf::from(path).display()
1356 )
1357 })?;
1358 args.push(executable);
1359 // config_path is the explicit workflow config path; prefer it over cli.config
1360 let cfg = Some(config_path);
1361 if let Some(cp) = cfg {
1362 args.push("--config".to_string());
1363 args.push(cp.display().to_string());
1364 } else if let Some(cp) = cli.config.as_deref() {
1365 args.push("--config".to_string());
1366 args.push(cp.display().to_string());
1367 }
1368 if let Some(profile) = cli.profile.as_ref() {
1369 args.push("--profile".to_string());
1370 args.push(profile.clone());
1371 }
1372
1373 if cli.mouse_capture {
1374 args.push("--mouse-capture".to_string());
1375 }
1376 if cli.no_mouse_capture {
1377 args.push("--no-mouse-capture".to_string());
1378 }
1379 if cli.skip_onboarding {
1380 args.push("--skip-onboarding".to_string());
1381 }
1382 if cli.no_project_config {
1383 args.push("--no-project-config".to_string());
1384 }
1385 args.extend(passthrough.clone());
1386 args
1387 };
1388 apply_tui_env(cli, resolved_runtime, &passthrough);
1389 lane_process_spec_from_argv(&argv)
1390 }
1391
1392 fn valid_lane_environment_key(key: &str) -> bool {
1393 let mut chars = key.chars();
1394 chars
1395 .next()
1396 .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
1397 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1398 }
1399
1400 fn shell_owned_lane_environment(key: &str) -> bool {
1401 matches!(
1402 key,
1403 "PWD" | "OLDPWD" | "SHLVL" | "_" | "TERM" | "TMUX" | "TMUX_PANE"
1404 )
1405 }
1406
1407 fn lane_process_spec_from_argv(argv: &[String]) -> Result<WorkflowProcessSpec> {
1408 let mut environment = std::collections::BTreeMap::new();
1409 for (key, value) in std::env::vars_os() {
1410 let (Some(key), Some(value)) = (key.to_str(), value.to_str()) else {
1411 continue;
1412 };
1413 if valid_lane_environment_key(key) && !shell_owned_lane_environment(key) {
1414 environment.insert(key.to_string(), value.to_string());
1415 }
1416 }
1417 Ok(WorkflowProcessSpec {
1418 command: argv.to_vec(),
1419 environment: environment.into_iter().collect(),
1420 })
1421 }
1422
1423 /// Flags for `codewhale remote-setup`. Forwarded to the TUI binary, which owns
1424 /// the interactive wizard and bundle generation.
1425 #[derive(Debug, Args, Clone, Default)]
1426 struct RemoteSetupArgs {
1427 /// Cloud target slug (lighthouse, azure, digitalocean). Skips the prompt.
1428 #[arg(long)]
1429 cloud: Option<String>,
1430 /// Chat bridge slug (feishu, telegram). Skips the prompt.
1431 #[arg(long)]
1432 bridge: Option<String>,
1433 /// Provider slug; validated against the provider registry. Skips the prompt.
1434 #[arg(long)]
1435 provider: Option<String>,
1436 /// Bundle output directory (default `./codewhale-deploy/<cloud>-<bridge>`).
1437 #[arg(long, value_name = "DIR")]
1438 out: Option<PathBuf>,
1439 /// Emit the bundle, do not provision (default).
1440 #[arg(long, default_value_t = false)]
1441 generate_only: bool,
1442 /// Run the cloud CLI to auto-provision (not yet implemented).
1443 #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
1444 apply: bool,
1445 /// Skip the final confirmation gate (CI / non-interactive).
1446 #[arg(long, default_value_t = false)]
1447 yes: bool,
1448 /// Fail instead of prompting if any required value is missing.
1449 #[arg(long, default_value_t = false)]
1450 non_interactive: bool,
1451 }
1452
1453 /// Build the forwarded argv for the TUI `remote-setup` subcommand from the
1454 /// structured CLI flags. Mirrors the named flags exactly so the TUI clap parser
1455 /// re-derives the same `RemoteSetupArgs`.
1456 fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
1457 let mut forwarded = vec!["remote-setup".to_string()];
1458 if let Some(cloud) = args.cloud {
1459 forwarded.push("--cloud".to_string());
1460 forwarded.push(cloud);
1461 }
1462 if let Some(bridge) = args.bridge {
1463 forwarded.push("--bridge".to_string());
1464 forwarded.push(bridge);
1465 }
1466 if let Some(provider) = args.provider {
1467 forwarded.push("--provider".to_string());
1468 forwarded.push(provider);
1469 }
1470 if let Some(out) = args.out {
1471 forwarded.push("--out".to_string());
1472 forwarded.push(out.to_string_lossy().into_owned());
1473 }
1474 if args.generate_only {
1475 forwarded.push("--generate-only".to_string());
1476 }
1477 if args.apply {
1478 forwarded.push("--apply".to_string());
1479 }
1480 if args.yes {
1481 forwarded.push("--yes".to_string());
1482 }
1483 if args.non_interactive {
1484 forwarded.push("--non-interactive".to_string());
1485 }
1486 forwarded
1487 }
1488
1489 #[derive(Debug, Args)]
1490 struct LoginArgs {
1491 /// Print the verification URL without trying to open a browser.
1492 #[arg(long, default_value_t = false)]
1493 no_open: bool,
1494 /// Maximum time to wait for browser authorization.
1495 #[arg(
1496 long = "timeout-seconds",
1497 default_value_t = cloud::DEFAULT_LOGIN_TIMEOUT_SECONDS,
1498 value_parser = clap::value_parser!(u64).range(1..=cloud::MAX_LOGIN_TIMEOUT_SECONDS)
1499 )]
1500 timeout_seconds: u64,
1501 /// Legacy provider-key flag: rejected with a redirect to `auth set`.
1502 #[arg(long, hide = true)]
1503 api_key: Option<String>,
1504 /// Legacy provider flag: rejected with a redirect to `auth set`.
1505 #[arg(long, value_parser = parse_catalog_route, hide = true)]
1506 provider: Option<ProviderKind>,
1507 }
1508
1509 #[derive(Debug, Args)]
1510 struct AuthArgs {
1511 #[command(subcommand)]
1512 command: AuthCommand,
1513 }
1514
1515 #[derive(Debug, Subcommand)]
1516 enum AuthCommand {
1517 /// Sign in to xAI/Grok with an SSH-friendly device code.
1518 #[command(name = "xai-device")]
1519 XaiDevice,
1520 /// Sign in with ChatGPT for Codex subscription access (PKCE loopback).
1521 #[command(name = "chatgpt")]
1522 Chatgpt,
1523 /// Revoke Codewhale-owned ChatGPT tokens. Codex CLI consent is unchanged.
1524 #[command(name = "chatgpt-revoke")]
1525 ChatgptRevoke,
1526 /// Explicitly allow read-only access to one credential file owned by
1527 /// another CLI. Managed mutation is currently unsupported and fails closed.
1528 #[command(name = "external-consent")]
1529 ExternalConsent {
1530 #[arg(long, value_parser = parse_catalog_route)]
1531 provider: ProviderKind,
1532 #[arg(long, value_enum)]
1533 mode: ExternalCredentialModeArg,
1534 /// Exact credential file path. Defaults to the selected CLI's resolved
1535 /// path without probing whether the file exists.
1536 #[arg(long, value_name = "PATH")]
1537 path: Option<PathBuf>,
1538 /// Confirm the disclosed exact read-only grant without an interactive
1539 /// prompt. Required when stdin is not a terminal.
1540 #[arg(long, default_value_t = false)]
1541 yes: bool,
1542 },
1543 /// Revoke access to another CLI's credential file for one provider.
1544 #[command(name = "external-revoke")]
1545 ExternalRevoke {
1546 #[arg(long, value_parser = parse_catalog_route)]
1547 provider: ProviderKind,
1548 },
1549 /// Show current provider and runtime-effective credential route state.
1550 /// Without `--provider`, shows all known providers.
1551 /// With `--provider`, shows detailed status for that provider.
1552 Status {
1553 /// Show status for a specific provider only.
1554 #[arg(long, value_parser = parse_catalog_route)]
1555 provider: Option<ProviderKind>,
1556 /// Report resolved home/config/settings/backend paths and structural
1557 /// credential-source presence without printing credential values or
1558 /// probing provider credential stores.
1559 #[arg(long, default_value_t = false)]
1560 diagnostic: bool,
1561 },
1562 /// Save an API key to the shared user config file. Reads from
1563 /// `--api-key`, `--api-key-stdin`, or prompts on stdin when
1564 /// neither is given. Does not echo the key.
1565 Set {
1566 #[arg(long, value_parser = parse_catalog_route)]
1567 provider: ProviderKind,
1568 /// Inline value (discouraged — appears in shell history).
1569 #[arg(long)]
1570 api_key: Option<String>,
1571 /// Read the key from stdin instead of prompting.
1572 #[arg(long = "api-key-stdin", default_value_t = false)]
1573 api_key_stdin: bool,
1574 },
1575 /// Report the effective credential route for a provider. Never prints a
1576 /// credential; reports the source layer or structural OAuth/repair state.
1577 Get {
1578 #[arg(long, value_parser = parse_catalog_route)]
1579 provider: ProviderKind,
1580 },
1581 /// Pipe the runtime-effective API key to a local client; refuses terminals.
1582 PrintApiKey {
1583 #[arg(long, value_parser = parse_catalog_route)]
1584 provider: ProviderKind,
1585 },
1586 /// Delete a provider's key from config and secret-store storage.
1587 Clear {
1588 #[arg(long, value_parser = parse_auth_clear_provider)]
1589 provider: ProviderKind,
1590 },
1591 /// List all known providers with their runtime-effective auth state,
1592 /// without revealing credentials.
1593 List,
1594 /// Advanced: migrate config-file keys into a platform credential store.
1595 #[command(hide = true)]
1596 Migrate {
1597 /// Don't actually write anything; print what would change.
1598 #[arg(long, default_value_t = false)]
1599 dry_run: bool,
1600 },
1601 }
1602
1603 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1604 enum ExternalCredentialModeArg {
1605 ReadOnly,
1606 Managed,
1607 }
1608
1609 #[derive(Debug, Args)]
1610 struct ConfigArgs {
1611 #[command(subcommand)]
1612 command: ConfigCommand,
1613 }
1614
1615 #[derive(Debug, Subcommand)]
1616 enum ConfigCommand {
1617 Get {
1618 key: String,
1619 },
1620 Set {
1621 key: String,
1622 value: String,
1623 },
1624 Unset {
1625 key: String,
1626 },
1627 /// Review aggregate usage counting by Codewhale and PostHog (default on).
1628 Telemetry {
1629 /// Optional compatibility form: enable future sessions under this policy version.
1630 #[arg(long, value_name = "VERSION")]
1631 accept_notice: Option<u32>,
1632 },
1633 List,
1634 Path,
1635 /// Open the config file in `$VISUAL`/`$EDITOR` (else `vi`).
1636 Edit,
1637 /// Check the loaded config: unknown keys, empty secrets, malformed
1638 /// URLs. Read-only; prints warnings, fails on errors, never prints a
1639 /// credential.
1640 Doctor,
1641 /// Print the effective config (including `--set` overlays) as TOML with
1642 /// secrets redacted by key name.
1643 Dump,
1644 /// Import a portable config bundle from a file, HTTPS URL, or stdin (-).
1645 Import(config_bundles::ImportArgs),
1646 /// Export a portable, secret-free config bundle.
1647 Export(config_bundles::ExportArgs),
1648 }
1649
1650 #[derive(Debug, Args)]
1651 struct ModelArgs {
1652 #[command(subcommand)]
1653 command: ModelCommand,
1654 }
1655
1656 #[derive(Debug, Subcommand)]
1657 enum ModelCommand {
1658 List {
1659 #[arg(long, value_parser = parse_catalog_route)]
1660 provider: Option<ProviderKind>,
1661 },
1662 Resolve {
1663 model: Option<String>,
1664 #[arg(long, value_parser = parse_catalog_route)]
1665 provider: Option<ProviderKind>,
1666 },
1667 /// Set the default model (e.g. "pro", "flash", "deepseek-v4-pro").
1668 Set { model: String },
1669 }
1670
1671 #[derive(Debug, Args)]
1672 struct ThreadArgs {
1673 #[command(subcommand)]
1674 command: ThreadCommand,
1675 }
1676
1677 #[derive(Debug, Subcommand)]
1678 enum ThreadCommand {
1679 List {
1680 #[arg(long, default_value_t = false)]
1681 all: bool,
1682 #[arg(long)]
1683 limit: Option<usize>,
1684 },
1685 Read {
1686 thread_id: String,
1687 },
1688 Resume {
1689 thread_id: String,
1690 },
1691 Fork {
1692 thread_id: String,
1693 },
1694 Archive {
1695 thread_id: String,
1696 },
1697 Unarchive {
1698 thread_id: String,
1699 },
1700 SetName {
1701 thread_id: String,
1702 name: String,
1703 },
1704 /// Remove the custom name from a thread, restoring the default
1705 /// `(unnamed)` rendering in `thread list`.
1706 ClearName {
1707 thread_id: String,
1708 },
1709 }
1710
1711 #[derive(Debug, Args)]
1712 struct SandboxArgs {
1713 #[command(subcommand)]
1714 command: SandboxCommand,
1715 }
1716
1717 #[derive(Debug, Subcommand)]
1718 enum SandboxCommand {
1719 Check {
1720 command: String,
1721 #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
1722 ask: ApprovalModeArg,
1723 },
1724 }
1725
1726 #[derive(Debug, Clone, Copy, ValueEnum)]
1727 enum ApprovalModeArg {
1728 UnlessTrusted,
1729 OnFailure,
1730 OnRequest,
1731 Never,
1732 }
1733
1734 impl From<ApprovalModeArg> for AskForApproval {
1735 fn from(value: ApprovalModeArg) -> Self {
1736 match value {
1737 ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
1738 ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
1739 ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
1740 ApprovalModeArg::Never => AskForApproval::Never,
1741 }
1742 }
1743 }
1744
1745 #[derive(Debug, Args)]
1746 struct AppServerArgs {
1747 /// Serve the full HTTP/SSE runtime API (`/v1/*`: sessions, threads, turns,
1748 /// approvals, events, usage, fleet, tasks). This is the canonical runtime
1749 /// API surface; it delegates to the same server as `codewhale serve --http`.
1750 #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
1751 http: bool,
1752 /// Serve the runtime API plus the phone-friendly mobile control page.
1753 /// Equivalent to the legacy `codewhale serve --mobile`.
1754 #[arg(long, conflicts_with = "stdio")]
1755 mobile: bool,
1756 /// Run the app-server JSON-RPC control transport over stdio (no listener).
1757 /// Used by local SDKs and JSON-RPC integrations.
1758 #[arg(long, default_value_t = false)]
1759 stdio: bool,
1760 /// Run as the desktop daemon: the same JSON-RPC control transport as
1761 /// `--stdio`, served on a user-private unix domain socket under the
1762 /// Codewhale runtime directory. Clients must `daemon/attach` first.
1763 /// Not yet supported on Windows (fails with a typed error).
1764 #[arg(long, default_value_t = false, conflicts_with_all = ["stdio", "http", "mobile"])]
1765 socket: bool,
1766 /// Socket path override for --socket. Defaults to
1767 /// `$CODEWHALE_HOME/run/daemon.sock`, else `$XDG_RUNTIME_DIR/codewhale/daemon.sock`,
1768 /// else `~/Library/Application Support/codewhale/daemon.sock` (macOS) or
1769 /// `~/.codewhale/run/daemon.sock`.
1770 #[arg(long = "socket-path", requires = "socket")]
1771 socket_path: Option<PathBuf>,
1772 /// Show a QR code for the mobile URL in the terminal (requires --mobile).
1773 #[arg(long, requires = "mobile")]
1774 qr: bool,
1775 /// Bind host. Defaults to 127.0.0.1; with --mobile and no host, binds
1776 /// 0.0.0.0 so LAN devices can reach the mobile page.
1777 #[arg(long)]
1778 host: Option<String>,
1779 /// Bind port. Defaults to 7878 for --http/--mobile (the runtime API) and
1780 /// 8787 for the legacy in-process app-server HTTP transport.
1781 #[arg(long)]
1782 port: Option<u16>,
1783 /// Background task worker count (1-8). Only used with --http/--mobile.
1784 #[arg(long)]
1785 workers: Option<usize>,
1786 #[arg(long)]
1787 config: Option<PathBuf>,
1788 #[arg(long = "auth-token")]
1789 auth_token: Option<String>,
1790 #[arg(long, default_value_t = false)]
1791 insecure_no_auth: bool,
1792 #[arg(long = "cors-origin")]
1793 cors_origin: Vec<String>,
1794 }
1795
1796 const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
1797
1798 fn install_rustls_crypto_provider() {
1799 let _ = rustls::crypto::ring::default_provider().install_default();
1800 }
1801
1802 pub fn run_cli() -> std::process::ExitCode {
1803 install_rustls_crypto_provider();
1804
1805 match run() {
1806 Ok(()) => std::process::ExitCode::SUCCESS,
1807 Err(err) => {
1808 // Use the full anyhow chain so callers see the underlying
1809 // cause (e.g. the actual TOML parse error with line/column)
1810 // instead of just the top-level context message. The bare
1811 // `{err}` Display impl drops the chain — see #767, where
1812 // users hit "failed to parse config at <path>" with no
1813 // hint that the real error was a stray BOM or unbalanced
1814 // quote a few lines down.
1815 eprintln!("error: {err}");
1816 for cause in err.chain().skip(1) {
1817 eprintln!(" caused by: {cause}");
1818 }
1819 // A Codewhale account failure carries a class: CI logs must be
1820 // able to tell a bad credential from an unconfigured agent model
1821 // without parsing English, and the machine-readable code beside
1822 // it names the control-plane branch that was taken.
1823 if let Some(machine) = err.downcast_ref::<cloud::machine::MachineError>() {
1824 eprintln!(
1825 " codewhale: code={} status={}",
1826 machine.code, machine.status
1827 );
1828 if let Ok(code) = u8::try_from(machine.exit_code) {
1829 return std::process::ExitCode::from(code);
1830 }
1831 }
1832 std::process::ExitCode::FAILURE
1833 }
1834 }
1835 }
1836
1837 fn split_lane_log_proxy_command(
1838 command: Option<Commands>,
1839 ) -> (Option<LaneLogProxyArgs>, Option<Commands>) {
1840 match command {
1841 Some(Commands::LaneLogProxy(args)) => (Some(args), None),
1842 command => (None, command),
1843 }
1844 }
1845
1846 fn config_command_targets_project(matches: &clap::ArgMatches) -> bool {
1847 let Some(config_matches) = matches.subcommand_matches("config") else {
1848 return false;
1849 };
1850 let Some((command, command_matches)) = config_matches.subcommand() else {
1851 return false;
1852 };
1853 if !matches!(command, "import" | "export") {
1854 return false;
1855 }
1856 command_matches
1857 .try_get_one::<bool>("project")
1858 .ok()
1859 .flatten()
1860 .copied()
1861 .unwrap_or(false)
1862 }
1863
1864 fn config_store_path_for_dispatch(
1865 explicit_path: Option<PathBuf>,
1866 project_bundle_scope: bool,
1867 cwd: &Path,
1868 ) -> Option<PathBuf> {
1869 if explicit_path.is_none() && project_bundle_scope {
1870 // Mirror the project-config loader: the current app dir wins, but a
1871 // workspace that still keeps its document under the legacy app dir
1872 // must be read and updated in place rather than shadowed by a new
1873 // empty document.
1874 let current = cwd
1875 .join(codewhale_config::CODEWHALE_APP_DIR)
1876 .join(codewhale_config::CONFIG_FILE_NAME);
1877 let legacy = cwd
1878 .join(codewhale_config::LEGACY_APP_DIR)
1879 .join(codewhale_config::CONFIG_FILE_NAME);
1880 if !current.is_file() && legacy.is_file() {
1881 return Some(legacy);
1882 }
1883 return Some(current);
1884 }
1885 explicit_path
1886 }
1887
1888 /// Runtime `--set` uses the dedicated flag handoff, so the existing loader
1889 /// owns profile, provider, managed-policy and requirements precedence. Keep
1890 /// config read/write commands on their separate, never-saved store overlay.
1891 fn apply_runtime_set_overrides(cli: &mut Cli) -> Result<()> {
1892 let mut values = CliRuntimeOverrides::default();
1893 let mut provider = None;
1894 for spec in &cli.overrides {
1895 let (key, value) = spec
1896 .split_once('=')
1897 .context("invalid --set: expected KEY=VALUE (value omitted)")?;
1898 match key.trim() {
1899 "provider" => {
1900 provider = Some(
1901 parse_provider_identifier(value)
1902 .map_err(|_| anyhow!("invalid --set provider (value omitted)"))?,
1903 );
1904 }
1905 "model" | "default_text_model" => values.model = Some(value.to_string()),
1906 "verbosity" => values.verbosity = Some(value.to_string()),
1907 "approval_policy" => values.approval_policy = Some(value.to_string()),
1908 "sandbox_mode" => values.sandbox_mode = Some(value.to_string()),
1909 "telemetry" => {
1910 let mut config = ConfigToml::default();
1911 config
1912 .set_value("telemetry", value)
1913 .map_err(|_| anyhow!("invalid --set telemetry: expected a boolean"))?;
1914 values.telemetry = config.telemetry;
1915 }
1916 _ => bail!(
1917 "unsupported runtime --set key (value omitted): supported keys are provider, \
1918 model, default_text_model, verbosity, approval_policy, sandbox_mode and \
1919 telemetry; use the dedicated option or config set for other keys"
1920 ),
1921 }
1922 if value.trim().is_empty() {
1923 bail!("invalid runtime --set: value must not be empty");
1924 }
1925 }
1926 // A dedicated flag is more specific than a generic --set for the same
1927 // field. Repeated --set keys otherwise keep their last value.
1928 cli.provider = cli.provider.take().or(provider);
1929 cli.model = cli.model.take().or(values.model);
1930 cli.verbosity = cli.verbosity.take().or(values.verbosity);
1931 cli.approval_policy = cli.approval_policy.take().or(values.approval_policy);
1932 cli.sandbox_mode = cli.sandbox_mode.take().or(values.sandbox_mode);
1933 cli.telemetry = cli.telemetry.or(values.telemetry);
1934 Ok(())
1935 }
1936
1937 fn run() -> Result<()> {
1938 let matches = Cli::command().get_matches();
1939 let project_bundle_scope = config_command_targets_project(&matches);
1940 let mut cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit());
1941
1942 // The detached log proxy must not depend on user config parsing: its job
1943 // is to frame child output and publish a terminal receipt even when the
1944 // delegated command's own config is malformed.
1945 let (proxy, command) = split_lane_log_proxy_command(cli.command.take());
1946 if let Some(args) = proxy {
1947 return run_lane_log_proxy_command(args);
1948 }
1949
1950 if !cli.overrides.is_empty() && matches!(command, Some(Commands::Auth(_))) {
1951 bail!("--set is not supported by auth commands; use a saved config");
1952 }
1953 if !cli.overrides.is_empty()
1954 && matches!(&command, Some(Commands::AppServer(args)) if !args.http && !args.mobile)
1955 {
1956 bail!(
1957 "--set is not supported by the legacy app-server transport; use app-server --http or a saved config"
1958 );
1959 }
1960 if !matches!(command, Some(Commands::Config(_))) {
1961 apply_runtime_set_overrides(&mut cli)?;
1962 }
1963
1964 let pipe_api_key_handoff = matches!(
1965 &command,
1966 Some(Commands::Auth(AuthArgs {
1967 command: AuthCommand::PrintApiKey { .. }
1968 }))
1969 );
1970 if pipe_api_key_handoff {
1971 credential_handoff::prepare_stdout(io::stdout().is_terminal())?;
1972 }
1973 let runtime_provider = top_level_provider_override(cli.provider.as_deref(), command.as_ref())?;
1974 let uses_raw_tui_provider = cli.provider.is_some() && runtime_provider.is_none();
1975 let runtime_overrides = CliRuntimeOverrides {
1976 provider: runtime_provider,
1977 model: cli.model.clone(),
1978 api_key: cli.api_key.clone(),
1979 base_url: cli.base_url.clone(),
1980 auth_mode: None,
1981 output_mode: cli.output_mode.clone(),
1982 log_level: cli.log_level.clone(),
1983 telemetry: cli.telemetry,
1984 approval_policy: cli.approval_policy.clone(),
1985 sandbox_mode: cli.sandbox_mode.clone(),
1986 yolo: Some(cli.yolo),
1987 verbosity: cli.verbosity.clone(),
1988 };
1989 if uses_raw_tui_provider
1990 && let Some((resolved_runtime, passthrough)) =
1991 prepare_raw_provider_tui_dispatch(&cli, command.as_ref(), &runtime_overrides)?
1992 {
1993 return run_tui_in_process(&cli, &resolved_runtime, passthrough);
1994 }
1995
1996 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1997 let config_path =
1998 config_store_path_for_dispatch(cli.config.clone(), project_bundle_scope, &cwd);
1999 let mut store = ConfigStore::load(config_path).map_err(|error| {
2000 if pipe_api_key_handoff {
2001 anyhow!("unavailable credential")
2002 } else {
2003 error
2004 }
2005 })?;
2006 // Root session flags only reach the TUI through the `None` branch below;
2007 // no subcommand handler reads them. Accepting them silently resumes
2008 // nothing -- `codewhale --resume abc exec "..."` would start a fresh
2009 // session while looking like it continued one.
2010 if command.is_some()
2011 && (cli.continue_session || cli.resume.is_some() || cli.session_id.is_some())
2012 {
2013 anyhow::bail!(
2014 "--continue/--resume/--session-id apply to the interactive session and \
2015 cannot be combined with a subcommand. Run them without a subcommand, or \
2016 use the subcommand's own flag (for example `codewhale exec --session-id <id>`)."
2017 );
2018 }
2019 // Only config inspection needs the store overlay. Runtime overrides use
2020 // the dedicated flags above and must never enter a store that another
2021 // command (or legacy credential migration) can save.
2022 if matches!(command, Some(Commands::Config(_))) {
2023 apply_per_run_overrides(&mut store, &cli.overrides)?;
2024 }
2025
2026 match command {
2027 Some(Commands::Run(args)) => {
2028 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2029 run_tui_in_process(&cli, &resolved_runtime, args.args)
2030 }
2031 Some(Commands::Doctor(args)) => {
2032 let resolved_runtime =
2033 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2034 run_tui_in_process(&cli, &resolved_runtime, tui_args("doctor", args))
2035 }
2036 Some(Commands::Models(args)) => {
2037 let resolved_runtime =
2038 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2039 run_tui_in_process(&cli, &resolved_runtime, tui_args("models", args))
2040 }
2041 Some(Commands::Speech(args)) => {
2042 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2043 run_tui_in_process(&cli, &resolved_runtime, tui_args("speech", args))
2044 }
2045 Some(Commands::Sessions(args)) => {
2046 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2047 run_tui_in_process(&cli, &resolved_runtime, tui_args("sessions", args))
2048 }
2049 Some(Commands::Resume(args)) => {
2050 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2051 run_resume_command(&cli, &resolved_runtime, args)
2052 }
2053 Some(Commands::Rc(args)) => {
2054 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2055 let mut passthrough = vec!["--remote-control".to_string()];
2056 passthrough.extend(args.args);
2057 run_tui_in_process(&cli, &resolved_runtime, passthrough)
2058 }
2059 Some(Commands::Fork(args)) => {
2060 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2061 run_tui_in_process(&cli, &resolved_runtime, tui_args("fork", args))
2062 }
2063 Some(Commands::Init(args)) => {
2064 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2065 run_tui_in_process(&cli, &resolved_runtime, tui_args("init", args))
2066 }
2067 Some(Commands::Setup(args)) => {
2068 let resolved_runtime = if setup_is_status_report(&args) {
2069 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides)
2070 } else {
2071 resolve_runtime_for_dispatch(&mut store, &runtime_overrides)
2072 };
2073 run_tui_in_process(&cli, &resolved_runtime, tui_args("setup", args))
2074 }
2075 Some(Commands::RemoteSetup(args)) => {
2076 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2077 run_tui_in_process(&cli, &resolved_runtime, remote_setup_tui_args(args))
2078 }
2079 Some(Commands::Exec(args)) => {
2080 reject_exec_global_flags(&args.args)?;
2081 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2082 run_tui_in_process(&cli, &resolved_runtime, tui_args("exec", args))
2083 }
2084 Some(Commands::Fleet(args)) => {
2085 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2086 run_tui_in_process(&cli, &resolved_runtime, tui_args("fleet", args))
2087 }
2088 Some(Commands::WorkflowTool(args)) => {
2089 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2090 run_tui_in_process(&cli, &resolved_runtime, tui_args("workflow-tool", args))
2091 }
2092 Some(Commands::LaneLogProxy(_)) => unreachable!("lane log proxy dispatched above"),
2093 Some(Commands::Workflow(args)) => {
2094 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2095 let config_path = store.path().to_path_buf();
2096 run_workflow_command(&cli, &resolved_runtime, &config_path, args)
2097 }
2098 Some(Commands::Lane(args)) => run_lane_command(args),
2099 Some(Commands::Review(args)) => {
2100 // CI path: a machine token authenticates as the account with no
2101 // local session and no browser. The account's own configured
2102 // provider then disambiguates a model that maps to several
2103 // configured routes, which review otherwise hard-errors on.
2104 let mut overrides = runtime_overrides.clone();
2105 if overrides.provider.is_none()
2106 && let Some(provider) = cloud::machine_review_provider()?
2107 {
2108 overrides.provider = Some(provider);
2109 }
2110 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &overrides);
2111 run_tui_in_process(&cli, &resolved_runtime, tui_args("review", args))
2112 }
2113 Some(Commands::Apply(args)) => {
2114 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2115 run_tui_in_process(&cli, &resolved_runtime, tui_args("apply", args))
2116 }
2117 Some(Commands::Eval(args)) => {
2118 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2119 run_tui_in_process(&cli, &resolved_runtime, tui_args("eval", args))
2120 }
2121 Some(Commands::Mcp(args)) => {
2122 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2123 run_tui_in_process(&cli, &resolved_runtime, tui_args("mcp", args))
2124 }
2125 Some(Commands::Pet(args)) => {
2126 // `pet` must reach run_with_args at argv[1]; the TUI passthrough
2127 // builder would inject global flags ahead of it and the trailing
2128 // PROMPT positional would otherwise swallow `pet serve`.
2129 let mut argv = vec!["codewhale".to_string(), "pet".to_string()];
2130 argv.extend(args.args);
2131 let code = codewhale_tui::run(argv);
2132 std::process::exit(if code == std::process::ExitCode::SUCCESS {
2133 0
2134 } else {
2135 1
2136 });
2137 }
2138 Some(Commands::Integrations(args)) => {
2139 // Integrations only need route *identity*. Do not recover or
2140 // export a stored credential just to plan/launch a third-party
2141 // harness: it resolves its own keys from its own environment.
2142 let resolved_runtime =
2143 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2144 run_tui_in_process(&cli, &resolved_runtime, tui_args("integrations", args))
2145 }
2146 Some(Commands::Features(args)) => {
2147 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2148 run_tui_in_process(&cli, &resolved_runtime, tui_args("features", args))
2149 }
2150 Some(Commands::Serve(args)) => {
2151 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2152 // `serve` starts a long-running runtime API listener; supervise the
2153 // delegated child so it is torn down with the dispatcher (#3259).
2154 run_tui_server_in_process(&cli, &resolved_runtime, tui_args("serve", args))
2155 }
2156 Some(Commands::Web(args)) => {
2157 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2158 run_tui_server_in_process(&cli, &resolved_runtime, web_serve_passthrough(&args))
2159 }
2160 Some(Commands::Login(args)) => {
2161 reject_legacy_login_provider_args(&args)?;
2162 cloud::reject_inline_api_key(cli.api_key.as_deref())?;
2163 cloud::run_account_login(
2164 args.no_open,
2165 args.timeout_seconds,
2166 cli.profile.as_deref(),
2167 &store,
2168 )
2169 }
2170 Some(Commands::Logout) => run_logout_command(&mut store, cli.profile.as_deref()),
2171 Some(Commands::Auth(args)) => match args.command {
2172 AuthCommand::XaiDevice => {
2173 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2174 run_tui_in_process(
2175 &cli,
2176 &resolved_runtime,
2177 vec!["auth".to_string(), "xai-device".to_string()],
2178 )
2179 }
2180 AuthCommand::Chatgpt => {
2181 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2182 run_tui_in_process(
2183 &cli,
2184 &resolved_runtime,
2185 vec!["auth".to_string(), "chatgpt".to_string()],
2186 )
2187 }
2188 AuthCommand::ChatgptRevoke => {
2189 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2190 run_tui_in_process(
2191 &cli,
2192 &resolved_runtime,
2193 vec!["auth".to_string(), "chatgpt-revoke".to_string()],
2194 )
2195 }
2196 command @ AuthCommand::Status {
2197 diagnostic: true, ..
2198 } => {
2199 // Like `doctor`, this is a read-only diagnostic. Starting a
2200 // telemetry session here would create
2201 // `$CODEWHALE_HOME/telemetry` before the report could truthfully
2202 // say the isolated home is missing.
2203 run_auth_command_with_runtime(&mut store, command, &runtime_overrides)
2204 }
2205 command => {
2206 let resolved_runtime =
2207 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2208 let session = start_cli_telemetry(
2209 &resolved_runtime,
2210 Some(store.path().to_path_buf()),
2211 Surface::Cli,
2212 );
2213 let outcome =
2214 run_auth_command_with_runtime(&mut store, command, &runtime_overrides);
2215 finish_cli_telemetry(session, &outcome);
2216 outcome
2217 }
2218 },
2219 Some(Commands::Account(args)) => {
2220 cloud::reject_inline_api_key(cli.api_key.as_deref())?;
2221 cloud::run(args, cli.profile.as_deref(), &store)
2222 }
2223 Some(Commands::Dispatch(args)) => dispatch::run(args),
2224 Some(Commands::McpServer) => {
2225 // `codewhale serve --mcp` delegates to the TUI and arms there, so
2226 // without this the same user action reported differently depending
2227 // on which spelling they typed — and `mcp-server`, a surface the
2228 // schema documents as emitting, could only ever read zero. A
2229 // structural zero a maintainer mistakes for an adoption zero is
2230 // the thing the "which surfaces emit" section exists to prevent.
2231 let resolved_runtime =
2232 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2233 let session = start_cli_telemetry(
2234 &resolved_runtime,
2235 Some(store.path().to_path_buf()),
2236 Surface::McpServer,
2237 );
2238 let outcome = run_mcp_server_command(&mut store);
2239 finish_cli_telemetry(session, &outcome);
2240 outcome
2241 }
2242 Some(Commands::Config(args)) => {
2243 let resolved_runtime =
2244 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2245 let session = start_cli_telemetry(
2246 &resolved_runtime,
2247 Some(store.path().to_path_buf()),
2248 Surface::Cli,
2249 );
2250 let outcome = run_config_command(
2251 &mut store,
2252 args.command,
2253 project_bundle_scope,
2254 &cli.overrides,
2255 );
2256 finish_cli_telemetry(session, &outcome);
2257 outcome
2258 }
2259 Some(Commands::Model(args)) => {
2260 // `model resolve` is a diagnostic: it must report the same route
2261 // the runtime would take, so it resolves through the same
2262 // read-only path `doctor` uses rather than looking only at flags.
2263 let resolved_runtime =
2264 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2265 run_model_command(
2266 &mut store,
2267 args.command,
2268 runtime_overrides.provider,
2269 &resolved_runtime,
2270 )
2271 }
2272 Some(Commands::Thread(args)) => {
2273 run_thread_command(&cli, &mut store, &runtime_overrides, args.command)
2274 }
2275 Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
2276 Some(Commands::AppServer(args)) => {
2277 // The HTTP/mobile runtime API is delegated to the mature `serve` path
2278 // in the TUI binary, which reads the *global* --config. app-server has
2279 // historically taken a subcommand-level --config, so bridge it before
2280 // resolving runtime options (provider/keyring) for the delegated run.
2281 if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
2282 cli.config = args.config.clone();
2283 store = ConfigStore::load(cli.config.clone())?;
2284 }
2285 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2286 run_app_server_command(&cli, &resolved_runtime, args)
2287 }
2288 Some(Commands::Completion { shell }) => {
2289 let mut stdout = io::stdout();
2290 stdout.write_all(render_completion_script(shell).as_bytes())?;
2291 stdout.flush()?;
2292 Ok(())
2293 }
2294 Some(Commands::Metrics(args)) => run_metrics_command(args),
2295 Some(Commands::Update(args)) => {
2296 let resolved_runtime =
2297 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2298 let session = start_cli_telemetry(
2299 &resolved_runtime,
2300 Some(store.path().to_path_buf()),
2301 Surface::Cli,
2302 );
2303 #[cfg(not(target_env = "ohos"))]
2304 let outcome = update::run_update(args.beta, args.check, args.proxy);
2305 #[cfg(target_env = "ohos")]
2306 let outcome = {
2307 let _ = args;
2308 Err(anyhow!(
2309 "self-update is not supported on HarmonyOS/OpenHarmony yet"
2310 ))
2311 };
2312 finish_cli_telemetry(session, &outcome);
2313 outcome
2314 }
2315 Some(Commands::Providers(args)) => run_providers_command(args),
2316 None => {
2317 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2318 let forwarded = root_tui_passthrough(&cli)?;
2319 run_tui_in_process(&cli, &resolved_runtime, forwarded)
2320 }
2321 }
2322 }
2323
2324 fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
2325 let mut forwarded = Vec::new();
2326 if cli.continue_session {
2327 forwarded.push("--continue".to_string());
2328 }
2329 let resume_session_id = cli
2330 .resume
2331 .as_deref()
2332 .or(cli.session_id.as_deref())
2333 .map(str::trim);
2334 if resume_session_id.is_some_and(str::is_empty) {
2335 // A shell expanding an unset variable -- `codewhale --resume
2336 // "$SESSION_ID"` -- must not quietly become a fresh session. The user
2337 // asked to resume; starting new loses the session they meant, and the
2338 // mistake is invisible until the history is gone.
2339 bail!(
2340 "--resume/--session-id needs a session id, but got an empty value \
2341 (an unset shell variable?). Use `codewhale --continue` to resume \
2342 the most recent session."
2343 );
2344 }
2345 if let Some(session_id) = resume_session_id {
2346 forwarded.push("--resume".to_string());
2347 forwarded.push(session_id.to_string());
2348 }
2349
2350 let prompt =
2351 cli.prompt_flag
2352 .iter()
2353 .chain(cli.prompt.iter())
2354 .fold(String::new(), |mut acc, part| {
2355 if !acc.is_empty() {
2356 acc.push(' ');
2357 }
2358 acc.push_str(part);
2359 acc
2360 });
2361 if !prompt.is_empty() {
2362 if cli.continue_session {
2363 bail!(
2364 "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
2365 );
2366 }
2367 if let Some(session_id) = resume_session_id {
2368 bail!(
2369 "`codewhale --resume {session_id}` resumes the interactive TUI. Use `codewhale exec --resume {session_id} <PROMPT>` to continue a session non-interactively."
2370 );
2371 }
2372 forwarded.push("--prompt".to_string());
2373 forwarded.push(prompt);
2374 }
2375
2376 Ok(forwarded)
2377 }
2378
2379 fn resolve_runtime_for_dispatch(
2380 store: &mut ConfigStore,
2381 runtime_overrides: &CliRuntimeOverrides,
2382 ) -> ResolvedRuntimeOptions {
2383 let runtime_secrets = Secrets::auto_detect();
2384 resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
2385 }
2386
2387 /// Resolve enough routing state to delegate a static diagnostic without
2388 /// reading or migrating the durable secret store.
2389 ///
2390 /// The TUI's doctor/setup-status path performs its own read-only source check,
2391 /// so this dispatcher must not recover and export a credential merely to start
2392 /// that report. Regular runtime and authentication commands keep using
2393 /// [`resolve_runtime_for_dispatch`].
2394 fn resolve_runtime_for_diagnostic_dispatch(
2395 store: &ConfigStore,
2396 runtime_overrides: &CliRuntimeOverrides,
2397 ) -> ResolvedRuntimeOptions {
2398 store.config.resolve_runtime_options(runtime_overrides)
2399 }
2400
2401 /// An armed telemetry session belonging to a subcommand that runs *in this
2402 /// process*.
2403 ///
2404 /// Existing at all is the permission: it is only ever constructed behind
2405 /// [`TelemetryDecision::Enabled`] after persistent and run-scoped opt-outs are
2406 /// applied.
2407 struct CliTelemetrySession {
2408 started: std::time::Instant,
2409 }
2410
2411 /// Arm telemetry for a subcommand the dispatcher executes itself.
2412 ///
2413 /// Only the terminal branches take this path. Everything that delegates to the
2414 /// TUI binary is armed over there, under its own surface, from the environment
2415 /// this dispatcher forwards — naming a surface here for a delegated command
2416 /// would report one run twice under two identities.
2417 ///
2418 /// Persistent config and setup-state opt-outs are applied inside
2419 /// [`telemetry::decide`].
2420 fn start_cli_telemetry(
2421 resolved: &ResolvedRuntimeOptions,
2422 config_path: Option<PathBuf>,
2423 surface: Surface,
2424 ) -> Option<CliTelemetrySession> {
2425 let consent = resolve_cli_telemetry_consent(
2426 resolved,
2427 config_path,
2428 surface,
2429 telemetry::load_setup_state_for_decision(),
2430 )?;
2431 telemetry::init(consent);
2432 telemetry::record(Event::SessionStart {
2433 source: SessionSource::Unknown,
2434 });
2435 Some(CliTelemetrySession {
2436 started: std::time::Instant::now(),
2437 })
2438 }
2439
2440 fn resolve_cli_telemetry_consent(
2441 resolved: &ResolvedRuntimeOptions,
2442 config_path: Option<PathBuf>,
2443 surface: Surface,
2444 setup: Option<SetupState>,
2445 ) -> Option<telemetry::TelemetryConsent> {
2446 let setup = setup?;
2447 let TelemetryDecision::Enabled(consent) = telemetry::decide(resolved, &setup, surface) else {
2448 return None;
2449 };
2450 Some(consent.with_config_path(config_path))
2451 }
2452
2453 /// Close the session opened by [`start_cli_telemetry`] and flush, bounded.
2454 ///
2455 /// The exit class comes from what actually happened, never from an exit code:
2456 /// a cancelled run and a SIGINT both exit 130, so a code-derived class would
2457 /// mislabel every cancel as a signal.
2458 ///
2459 /// The flush re-resolves telemetry from disk before it sends anything, which is
2460 /// what makes `codewhale config set telemetry false` take effect on the very run
2461 /// that wrote it rather than on the next one.
2462 fn finish_cli_telemetry(session: Option<CliTelemetrySession>, outcome: &Result<()>) {
2463 let Some(session) = session else {
2464 return;
2465 };
2466 telemetry::set_exit_class(if outcome.is_ok() {
2467 ExitClass::Clean
2468 } else {
2469 ExitClass::Error
2470 });
2471 telemetry::record(Event::SessionEnd {
2472 duration_bucket: DurationBucket::from_secs(session.started.elapsed().as_secs()),
2473 exit_class: telemetry::exit_class(),
2474 // Cold start is measured by the TUI's startup trace. This surface has
2475 // no equivalent, and inventing one from process start would be a
2476 // different measurement wearing the same name.
2477 cold_start_bucket: None,
2478 providers: Vec::new(),
2479 counters: Counters::default(),
2480 errors: Errors::default(),
2481 turn_wall: TurnWall::default(),
2482 });
2483 let _ = telemetry::shutdown_blocking(telemetry::SHUTDOWN_FLUSH_TIMEOUT);
2484 }
2485
2486 fn resolve_runtime_for_dispatch_with_secrets(
2487 store: &mut ConfigStore,
2488 runtime_overrides: &CliRuntimeOverrides,
2489 secrets: &Secrets,
2490 ) -> ResolvedRuntimeOptions {
2491 store
2492 .config
2493 .resolve_runtime_options_with_secrets(runtime_overrides, secrets)
2494 }
2495
2496 fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
2497 let mut forwarded = Vec::with_capacity(args.args.len() + 1);
2498 forwarded.push(command.to_string());
2499 forwarded.extend(args.args);
2500 forwarded
2501 }
2502
2503 fn setup_is_status_report(args: &TuiPassthroughArgs) -> bool {
2504 args.args.iter().any(|arg| arg == "--status")
2505 }
2506
2507 fn reject_exec_global_flags(args: &[String]) -> Result<()> {
2508 const GLOBAL_ONLY_FLAGS: &[&str] = &["--provider", "--model", "--api-key", "--base-url"];
2509
2510 for arg in args {
2511 if arg == "--" {
2512 break;
2513 }
2514 let flag = arg.split_once('=').map_or(arg.as_str(), |(flag, _)| flag);
2515 if GLOBAL_ONLY_FLAGS.contains(&flag) {
2516 bail!(
2517 "{flag} must be placed before `exec`.\n\nUse:\n codewhale {flag} <value> exec \"<prompt>\""
2518 );
2519 }
2520 }
2521
2522 Ok(())
2523 }
2524
2525 /// `codewhale login` used to configure provider API keys; that surface moved
2526 /// to `auth set --provider`. The hidden legacy flags stay parseable so the
2527 /// redirect below can name the replacement instead of an unknown-flag error.
2528 fn reject_legacy_login_provider_args(args: &LoginArgs) -> Result<()> {
2529 if args.api_key.is_none() && args.provider.is_none() {
2530 return Ok(());
2531 }
2532 bail!(
2533 "`codewhale login` now signs in to your Codewhale account via the browser device flow. \
2534 To configure a provider key, run `codewhale auth set --provider <provider>` (hidden prompt) \
2535 or `codewhale auth set --provider <provider> --api-key-stdin`."
2536 )
2537 }
2538
2539 fn run_logout_command(store: &mut ConfigStore, profile: Option<&str>) -> Result<()> {
2540 run_logout_command_with_secrets(store, &Secrets::auto_detect(), profile)
2541 }
2542
2543 fn run_logout_command_with_secrets(
2544 store: &mut ConfigStore,
2545 secrets: &Secrets,
2546 profile: Option<&str>,
2547 ) -> Result<()> {
2548 codewhale_config::with_xai_oauth_revocation_transaction(|| {
2549 run_logout_command_with_secrets_unlocked(store, secrets, profile)
2550 })
2551 }
2552
2553 fn run_logout_command_with_secrets_unlocked(
2554 store: &mut ConfigStore,
2555 secrets: &Secrets,
2556 profile: Option<&str>,
2557 ) -> Result<()> {
2558 let original_config = store.config.clone();
2559 store.config.api_key = None;
2560 for provider in ProviderKind::ALL {
2561 clear_provider_api_key_from_config(store, provider);
2562 store
2563 .config
2564 .providers
2565 .for_provider_mut(provider)
2566 .external_credentials = None;
2567 }
2568 let xai = store.config.providers.for_provider_mut(ProviderKind::Xai);
2569 xai.oauth_credential_generation = None;
2570 xai.auth_mode = None;
2571 let openai_codex = store
2572 .config
2573 .providers
2574 .for_provider_mut(ProviderKind::OpenaiCodex);
2575 if openai_codex
2576 .oauth_credential_generation
2577 .as_deref()
2578 .is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation)
2579 {
2580 openai_codex.oauth_credential_generation = None;
2581 if openai_codex.auth_mode.as_deref() == Some("oauth") {
2582 openai_codex.auth_mode = None;
2583 }
2584 }
2585 store.config.auth_mode = None;
2586 if let Err(error) = store.save() {
2587 store.config = original_config;
2588 return Err(error);
2589 }
2590 let mut keyring_failures = clear_all_provider_api_keys_from_keyring(secrets);
2591 // Already inside with_xai_oauth_revocation_transaction: the locked
2592 // variant must not re-enter the non-reentrant lifecycle mutex.
2593 if let Err(error) = codewhale_config::clear_all_chatgpt_oauth_credentials_locked() {
2594 keyring_failures.push(format!("chatgpt oauth: {error}"));
2595 }
2596 if let Err(error) = clear_daytona_slot(secrets) {
2597 keyring_failures.push(format!(
2598 "{}: {error}",
2599 codewhale_secrets::DAYTONA_TOKEN_SLOT
2600 ));
2601 }
2602 if let Err(error) = clear_account_session(profile) {
2603 keyring_failures.push(format!("account session: {error}"));
2604 }
2605 if keyring_failures.is_empty() {
2606 println!("logged out");
2607 } else {
2608 eprintln!(
2609 "failed to delete stored credentials for: {}",
2610 keyring_failures.join(", ")
2611 );
2612 println!("logged out (some stored credentials could not be deleted)");
2613 }
2614 Ok(())
2615 }
2616
2617 fn clear_daytona_slot(secrets: &Secrets) -> Result<(), codewhale_secrets::SecretsError> {
2618 if secrets
2619 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)?
2620 .is_some_and(|value| !value.trim().is_empty())
2621 {
2622 secrets.delete(codewhale_secrets::DAYTONA_TOKEN_SLOT)?;
2623 }
2624 Ok(())
2625 }
2626
2627 fn clear_account_session(profile: Option<&str>) -> Result<(), String> {
2628 use codewhale_secrets::account::{
2629 ACCOUNT_API_BASE_ENV, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
2630 secure_account_session_secrets,
2631 };
2632 let secrets = secure_account_session_secrets().map_err(|error| error.to_string())?;
2633 let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
2634 .ok()
2635 .map(|value| value.trim().trim_end_matches('/').to_string())
2636 .filter(|value| !value.is_empty())
2637 .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
2638 AccountSessionStore::new(secrets, profile, &api_base)
2639 .clear()
2640 .map_err(|error| error.to_string())
2641 }
2642
2643 #[cfg(test)]
2644 fn no_keyring_secrets() -> Secrets {
2645 Secrets::new(std::sync::Arc::new(
2646 codewhale_secrets::InMemoryKeyringStore::new(),
2647 ))
2648 }
2649
2650 fn clear_auth_provider(
2651 store: &mut ConfigStore,
2652 secrets: &Secrets,
2653 provider: ProviderKind,
2654 ) -> Result<()> {
2655 if provider == ProviderKind::Antigravity {
2656 return clear_legacy_antigravity_config(store, secrets);
2657 }
2658 let outcome = codewhale_config::credentials::clear_provider_api_key(store, secrets, provider)?;
2659 let slot = outcome.slot;
2660 // The secret-store leg used to fail silently here, which meant `auth clear`
2661 // could print success while the key was still in the keyring. Say so
2662 // instead; the config no longer advertises a key the backend may hold.
2663 if let Some(error) = &outcome.secret_store_error {
2664 println!(
2665 "cleared API key for {slot} from config, but the secret store refused the delete: {error}"
2666 );
2667 return Ok(());
2668 }
2669 if provider == ProviderKind::Xai {
2670 println!("cleared xAI credentials from config, secret store, and owned OAuth storage");
2671 } else {
2672 println!("cleared API key for {slot} from config and secret store");
2673 }
2674 Ok(())
2675 }
2676
2677 /// Remove only Codewhale-owned state for the retired Antigravity route.
2678 ///
2679 /// This deliberately operates on the already-loaded Codewhale config and its
2680 /// own secret slot. It never resolves an external credential path, reads an
2681 /// environment credential, or invokes a Google/Antigravity logout or revoke
2682 /// flow.
2683 fn clear_legacy_antigravity_config(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
2684 let provider = ProviderKind::Antigravity;
2685 let slot = provider_slot(provider);
2686 let original_config = store.config.clone();
2687 let prior_secret = secrets.get(slot).map_err(|error| {
2688 anyhow!(
2689 "could not snapshot the Codewhale-owned legacy {slot} secret slot before clearing it: {error}; config was not changed"
2690 )
2691 })?;
2692
2693 store.config.providers.antigravity = Default::default();
2694 store
2695 .config
2696 .fallback_providers
2697 .retain(|fallback| *fallback != provider);
2698 if store.config.provider == provider {
2699 store.config.provider = ProviderKind::default();
2700 store.config.selected_provider_id = None;
2701 }
2702
2703 if let Err(error) = secrets.delete(slot) {
2704 store.config = original_config;
2705 return Err(anyhow!(
2706 "could not clear the Codewhale-owned legacy {slot} secret slot: {error}; config was not changed"
2707 ));
2708 }
2709
2710 if let Err(error) = store.save() {
2711 store.config = original_config;
2712 if let Some(previous) = prior_secret {
2713 let current = secrets.get(slot).map_err(|rollback| {
2714 anyhow!(
2715 "{error}; additionally could not verify rollback of the Codewhale-owned legacy {slot} secret slot: {rollback}"
2716 )
2717 })?;
2718 match current {
2719 None => secrets.set(slot, &previous).map_err(|rollback| {
2720 anyhow!(
2721 "{error}; additionally failed to restore the Codewhale-owned legacy {slot} secret slot: {rollback}"
2722 )
2723 })?,
2724 Some(current) if current == previous => {}
2725 Some(_) => {
2726 return Err(anyhow!(
2727 "{error}; additionally the Codewhale-owned legacy {slot} secret slot changed concurrently and was not overwritten during rollback"
2728 ));
2729 }
2730 }
2731 }
2732 return Err(error);
2733 }
2734
2735 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
2736 codewhale_config::scrub_legacy_antigravity_from_config_backup(store.path())?;
2737 println!(
2738 "cleared Codewhale-owned legacy Antigravity config, consent, selection, fallback entries, and secret-store slot; Google and Antigravity sessions were not read, revoked, or changed. For Gemini, configure provider google and set GEMINI_API_KEY"
2739 );
2740 Ok(())
2741 }
2742
2743 fn provider_env_set(provider: ProviderKind) -> bool {
2744 provider_env_value(provider).is_some()
2745 }
2746
2747 fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
2748 provider.provider().env_vars()
2749 }
2750
2751 fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
2752 provider_env_vars(provider).iter().find_map(|var| {
2753 std::env::var(var)
2754 .ok()
2755 .filter(|value| !value.trim().is_empty())
2756 .map(|value| (*var, value))
2757 })
2758 }
2759
2760 fn openai_codex_auth_file_path() -> PathBuf {
2761 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
2762 let path = PathBuf::from(path);
2763 if !path.as_os_str().is_empty() {
2764 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2765 }
2766 }
2767
2768 let codex_home = std::env::var("CODEX_HOME")
2769 .map(PathBuf::from)
2770 .unwrap_or_else(|_| {
2771 dirs::home_dir()
2772 .unwrap_or_else(|| PathBuf::from("."))
2773 .join(".codex")
2774 });
2775 let path = codex_home.join("auth.json");
2776 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2777 }
2778
2779 fn grok_auth_file_path() -> PathBuf {
2780 for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
2781 if let Ok(path) = std::env::var(key) {
2782 let path = PathBuf::from(path.trim());
2783 if !path.as_os_str().is_empty() {
2784 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2785 }
2786 }
2787 }
2788 if let Ok(home) = std::env::var("GROK_HOME") {
2789 let home = PathBuf::from(home.trim());
2790 if !home.as_os_str().is_empty() {
2791 let path = home.join("auth.json");
2792 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2793 }
2794 }
2795 let path = dirs::home_dir()
2796 .unwrap_or_else(|| PathBuf::from("."))
2797 .join(".grok")
2798 .join("auth.json");
2799 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2800 }
2801
2802 fn external_credential_target(
2803 provider: ProviderKind,
2804 path_override: Option<PathBuf>,
2805 ) -> Result<(codewhale_config::ExternalCredentialSource, PathBuf)> {
2806 let (source, default_path) = match provider {
2807 ProviderKind::OpenaiCodex => (
2808 codewhale_config::ExternalCredentialSource::CodexCli,
2809 openai_codex_auth_file_path(),
2810 ),
2811 ProviderKind::Xai => (
2812 codewhale_config::ExternalCredentialSource::GrokCli,
2813 grok_auth_file_path(),
2814 ),
2815 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => (
2816 codewhale_config::ExternalCredentialSource::DshCli,
2817 codewhale_config::default_dsh_credentials_path(),
2818 ),
2819 ProviderKind::Moonshot => bail!(
2820 "Kimi is API-key-only in Codewhale. Create a key at https://platform.kimi.ai/console/api-keys; Kimi CLI OAuth import is unsupported."
2821 ),
2822 _ => bail!(
2823 "{} has no supported external CLI credential source",
2824 provider.as_str()
2825 ),
2826 };
2827 let path =
2828 codewhale_config::resolve_external_credential_path(path_override.unwrap_or(default_path))?;
2829 Ok((source, path))
2830 }
2831
2832 fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
2833 let slot = store
2834 .config
2835 .providers
2836 .for_provider(provider)
2837 .api_key
2838 .as_deref();
2839 let root = (provider == ProviderKind::Deepseek)
2840 .then_some(store.config.api_key.as_deref())
2841 .flatten();
2842 slot.or(root)
2843 .filter(|value| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal)
2844 }
2845
2846 fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
2847 provider_config_api_key(store, provider).is_some()
2848 }
2849
2850 fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
2851 secrets
2852 .get(provider_slot(provider))
2853 .ok()
2854 .flatten()
2855 .filter(|v| !v.trim().is_empty())
2856 }
2857
2858 fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
2859 provider_keyring_api_key(secrets, provider).is_some()
2860 }
2861
2862 /// Delete the keyring credential of every provider that has one stored.
2863 ///
2864 /// Returns a human-readable entry per slot whose deletion failed, so the
2865 /// caller can report the failure instead of claiming a clean logout while
2866 /// credentials linger in the keyring. Slots shared by several providers
2867 /// (e.g. the historical `siliconflow` slot) are deleted once.
2868 fn clear_all_provider_api_keys_from_keyring(secrets: &Secrets) -> Vec<String> {
2869 let mut failures = Vec::new();
2870 let mut cleared_slots = std::collections::HashSet::new();
2871 for provider in ProviderKind::ALL {
2872 let slot = provider_slot(provider);
2873 if !cleared_slots.insert(slot) {
2874 continue;
2875 }
2876 if !provider_keyring_set(secrets, provider) {
2877 continue;
2878 }
2879 if let Err(error) = secrets.delete(slot) {
2880 failures.push(format!("{slot}: {error}"));
2881 }
2882 }
2883 failures
2884 }
2885
2886 fn external_consent(
2887 store: &ConfigStore,
2888 provider: ProviderKind,
2889 ) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
2890 store
2891 .config
2892 .providers
2893 .for_provider(provider)
2894 .external_credentials
2895 .as_ref()
2896 }
2897
2898 fn external_read_consent(
2899 store: &ConfigStore,
2900 provider: ProviderKind,
2901 ) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
2902 let (source, expected_path) = external_credential_target(provider, None).ok()?;
2903 external_consent(store, provider)
2904 .filter(|consent| consent.read_grant(provider, source, &expected_path).is_ok())
2905 }
2906
2907 fn external_oauth_selected(store: &ConfigStore, provider: ProviderKind) -> bool {
2908 if external_read_consent(store, provider).is_none() {
2909 return false;
2910 }
2911 if provider == ProviderKind::OpenaiCodex {
2912 return true;
2913 }
2914 provider == ProviderKind::Xai
2915 && xai_oauth_mode_selected(store.config.providers.xai.auth_mode.as_deref())
2916 }
2917
2918 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2919 enum XaiOAuthGenerationPointer {
2920 Absent,
2921 Valid,
2922 Invalid,
2923 }
2924
2925 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2926 enum XaiAuthDiagnosticRoute {
2927 /// Normal API-key diagnostics apply. This includes custom endpoints, where
2928 /// xAI OAuth is intentionally inactive.
2929 ApiKey,
2930 /// A syntactically valid Codewhale-owned generation pointer selects the
2931 /// owned OAuth route. Diagnostics deliberately do not inspect the file.
2932 OwnedOAuth,
2933 /// A configured but unsafe/malformed generation pointer blocks external
2934 /// Grok CLI access. The runtime can still fall back to API-key sources.
2935 NeedsRepair,
2936 /// With no configured generation, an exact read-only Grok CLI consent can
2937 /// be selected structurally. The external file is never probed here.
2938 ExternalConsent,
2939 }
2940
2941 #[derive(Debug, Clone)]
2942 struct XaiAuthDiagnostics {
2943 base_url: String,
2944 official_endpoint: bool,
2945 auth_mode: Option<String>,
2946 oauth_selected: bool,
2947 generation: XaiOAuthGenerationPointer,
2948 route: XaiAuthDiagnosticRoute,
2949 }
2950
2951 impl XaiAuthDiagnostics {
2952 /// API-key routes are reported from the same endpoint-bound resolver that
2953 /// dispatch uses. Owned OAuth and consent-only routes remain structural so
2954 /// diagnostics cannot turn into a credential-store probe.
2955 fn evaluates_runtime_api_key(&self) -> bool {
2956 matches!(
2957 self.route,
2958 XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::NeedsRepair
2959 )
2960 }
2961
2962 fn is_custom_endpoint(&self) -> bool {
2963 !self.official_endpoint
2964 }
2965 }
2966
2967 /// Source and redacted tail from the shared runtime resolver. Keeping only a
2968 /// redacted tail prevents the presentation layer from accidentally retaining a
2969 /// plaintext credential after it has derived the effective route.
2970 #[derive(Debug, Clone, Default)]
2971 struct XaiRuntimeApiKey {
2972 source: Option<RuntimeApiKeySource>,
2973 last4: Option<String>,
2974 }
2975
2976 impl XaiRuntimeApiKey {
2977 fn source_name(&self) -> Option<&'static str> {
2978 match self.source {
2979 Some(RuntimeApiKeySource::Cli) => Some("cli"),
2980 Some(RuntimeApiKeySource::ConfigFile) => Some("config"),
2981 Some(RuntimeApiKeySource::Keyring) => Some("secret store"),
2982 Some(RuntimeApiKeySource::Env) => Some("env"),
2983 None => None,
2984 }
2985 }
2986
2987 fn source_with_last4(&self) -> Option<String> {
2988 self.source_name()
2989 .map(|source| match self.last4.as_deref() {
2990 Some(last4) => format!("{source} (last4: {last4})"),
2991 None => source.to_string(),
2992 })
2993 }
2994
2995 fn uses(&self, source: RuntimeApiKeySource) -> bool {
2996 self.source == Some(source)
2997 }
2998 }
2999
3000 fn runtime_overrides_for_provider(
3001 runtime_overrides: &CliRuntimeOverrides,
3002 provider: ProviderKind,
3003 ) -> CliRuntimeOverrides {
3004 let mut overrides = runtime_overrides.clone();
3005 overrides.provider = Some(provider);
3006 overrides
3007 }
3008
3009 fn xai_oauth_mode_selected(auth_mode: Option<&str>) -> bool {
3010 auth_mode.is_some_and(|mode| {
3011 matches!(
3012 mode.trim()
3013 .to_ascii_lowercase()
3014 .replace(['-', ' '], "_")
3015 .as_str(),
3016 "oauth"
3017 | "xai_oauth"
3018 | "xai"
3019 | "grok"
3020 | "grok_oauth"
3021 | "grok_cli"
3022 | "device"
3023 | "device_code"
3024 | "device_auth"
3025 )
3026 })
3027 }
3028
3029 fn xai_oauth_generation_pointer(store: &ConfigStore) -> XaiOAuthGenerationPointer {
3030 match store
3031 .config
3032 .providers
3033 .xai
3034 .oauth_credential_generation
3035 .as_deref()
3036 {
3037 None => XaiOAuthGenerationPointer::Absent,
3038 Some(generation) if codewhale_config::is_valid_xai_oauth_generation(generation) => {
3039 XaiOAuthGenerationPointer::Valid
3040 }
3041 Some(_) => XaiOAuthGenerationPointer::Invalid,
3042 }
3043 }
3044
3045 /// Resolve the same xAI route facts the runtime uses, without asking the
3046 /// durable credential store for a secret. `ConfigToml::resolve_runtime_options`
3047 /// deliberately uses an in-memory store, so this is safe for diagnostic output
3048 /// that must remain structural/non-probing.
3049 fn xai_auth_diagnostics(
3050 store: &ConfigStore,
3051 runtime_overrides: &CliRuntimeOverrides,
3052 ) -> XaiAuthDiagnostics {
3053 // We only need the effective endpoint here. Suppressing API-key
3054 // resolution keeps valid-owned and consent-only diagnostics structural:
3055 // they must not read ambient credential state merely to describe a route.
3056 let mut route_overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai);
3057 route_overrides.api_key = None;
3058 route_overrides.auth_mode = Some("none".to_string());
3059 let resolved = store.config.resolve_runtime_options(&route_overrides);
3060 let official_endpoint =
3061 provider_base_url_is_official(ProviderKind::Xai, resolved.base_url.as_str());
3062 // The TUI activates xAI OAuth only from `[providers.xai] auth_mode`; a
3063 // root-level auth mode may influence generic API-key policy but must never
3064 // turn an inert xAI generation pointer into an OAuth route.
3065 let auth_mode = store.config.providers.xai.auth_mode.clone();
3066 let generation = xai_oauth_generation_pointer(store);
3067 let oauth_selected = xai_oauth_mode_selected(auth_mode.as_deref());
3068 let route = if !official_endpoint || !oauth_selected {
3069 XaiAuthDiagnosticRoute::ApiKey
3070 } else {
3071 match generation {
3072 XaiOAuthGenerationPointer::Valid => XaiAuthDiagnosticRoute::OwnedOAuth,
3073 XaiOAuthGenerationPointer::Invalid => XaiAuthDiagnosticRoute::NeedsRepair,
3074 XaiOAuthGenerationPointer::Absent
3075 if external_read_consent(store, ProviderKind::Xai).is_some() =>
3076 {
3077 XaiAuthDiagnosticRoute::ExternalConsent
3078 }
3079 XaiOAuthGenerationPointer::Absent => XaiAuthDiagnosticRoute::ApiKey,
3080 }
3081 };
3082
3083 XaiAuthDiagnostics {
3084 base_url: resolved.base_url,
3085 official_endpoint,
3086 auth_mode,
3087 oauth_selected,
3088 generation,
3089 route,
3090 }
3091 }
3092
3093 /// Return the API-key route exactly as the dispatcher would resolve it. This
3094 /// is the critical distinction for a global `--base-url` or `XAI_BASE_URL`:
3095 /// official-provider config, keyring, and ambient keys must not cross onto an
3096 /// unrelated custom endpoint.
3097 fn xai_runtime_api_key(
3098 store: &ConfigStore,
3099 secrets: &Secrets,
3100 runtime_overrides: &CliRuntimeOverrides,
3101 ) -> XaiRuntimeApiKey {
3102 let resolved = store.config.resolve_runtime_options_with_secrets(
3103 &runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai),
3104 secrets,
3105 );
3106 debug_assert_eq!(resolved.provider, ProviderKind::Xai);
3107 XaiRuntimeApiKey {
3108 source: resolved.api_key_source,
3109 last4: resolved.api_key.as_deref().map(last4_label),
3110 }
3111 }
3112
3113 fn api_key_source_name(
3114 config_key: Option<&str>,
3115 keyring_key: Option<&str>,
3116 env_key: Option<&(&'static str, String)>,
3117 ) -> Option<&'static str> {
3118 if config_key.is_some() {
3119 Some("config")
3120 } else if keyring_key.is_some() {
3121 Some("secret store")
3122 } else if env_key.is_some() {
3123 Some("env")
3124 } else {
3125 None
3126 }
3127 }
3128
3129 fn xai_status_summary_source(
3130 diagnostics: &XaiAuthDiagnostics,
3131 api_key: Option<&XaiRuntimeApiKey>,
3132 ) -> String {
3133 match diagnostics.route {
3134 XaiAuthDiagnosticRoute::OwnedOAuth => {
3135 "Codewhale-owned OAuth configured/unprobed (valid generation pointer)".to_string()
3136 }
3137 XaiAuthDiagnosticRoute::NeedsRepair => {
3138 let api_key = api_key
3139 .and_then(XaiRuntimeApiKey::source_name)
3140 .unwrap_or("no runtime-effective API key");
3141 format!("needs repair (invalid OAuth generation pointer; API-key fallback: {api_key})")
3142 }
3143 XaiAuthDiagnosticRoute::ExternalConsent => {
3144 "external consent configured/unprobed".to_string()
3145 }
3146 XaiAuthDiagnosticRoute::ApiKey => api_key
3147 .and_then(XaiRuntimeApiKey::source_name)
3148 .unwrap_or("unset")
3149 .to_string(),
3150 }
3151 }
3152
3153 fn xai_credential_route_label(
3154 diagnostics: &XaiAuthDiagnostics,
3155 api_key: Option<&XaiRuntimeApiKey>,
3156 ) -> String {
3157 match diagnostics.route {
3158 XaiAuthDiagnosticRoute::OwnedOAuth => {
3159 "Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
3160 .to_string()
3161 }
3162 XaiAuthDiagnosticRoute::NeedsRepair => {
3163 let api_key = api_key
3164 .and_then(XaiRuntimeApiKey::source_with_last4)
3165 .unwrap_or_else(|| "no runtime-effective API key".to_string());
3166 format!(
3167 "xAI OAuth needs repair (invalid Codewhale-owned generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
3168 )
3169 }
3170 XaiAuthDiagnosticRoute::ExternalConsent => {
3171 "external read-only consent configured/unprobed".to_string()
3172 }
3173 XaiAuthDiagnosticRoute::ApiKey => api_key
3174 .and_then(XaiRuntimeApiKey::source_with_last4)
3175 .unwrap_or_else(|| "missing".to_string()),
3176 }
3177 }
3178
3179 fn xai_table_storage_status(
3180 api_key: Option<&XaiRuntimeApiKey>,
3181 source: RuntimeApiKeySource,
3182 ) -> &'static str {
3183 match api_key {
3184 Some(api_key) if api_key.uses(source) => "set",
3185 Some(_) => "-",
3186 // The selected structural OAuth/consent route intentionally does not
3187 // establish whether any API-key storage is populated.
3188 None => "unprobed",
3189 }
3190 }
3191
3192 fn xai_list_storage_status(
3193 api_key: Option<&XaiRuntimeApiKey>,
3194 source: RuntimeApiKeySource,
3195 ) -> &'static str {
3196 match api_key {
3197 Some(api_key) if api_key.uses(source) => "yes",
3198 Some(_) => "no",
3199 None => "?",
3200 }
3201 }
3202
3203 fn xai_list_route(
3204 diagnostics: &XaiAuthDiagnostics,
3205 api_key: Option<&XaiRuntimeApiKey>,
3206 ) -> &'static str {
3207 match diagnostics.route {
3208 XaiAuthDiagnosticRoute::OwnedOAuth => "owned-oauth-configured",
3209 XaiAuthDiagnosticRoute::NeedsRepair => "needs-repair",
3210 XaiAuthDiagnosticRoute::ExternalConsent => "external-consent-configured",
3211 XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(|api_key| api_key.source) {
3212 Some(RuntimeApiKeySource::Cli) => "cli",
3213 Some(RuntimeApiKeySource::ConfigFile) => "config",
3214 Some(RuntimeApiKeySource::Keyring) => "store",
3215 Some(RuntimeApiKeySource::Env) => "env",
3216 None => "missing",
3217 },
3218 }
3219 }
3220
3221 fn xai_storage_detail(
3222 diagnostics: &XaiAuthDiagnostics,
3223 api_key: Option<&XaiRuntimeApiKey>,
3224 source: RuntimeApiKeySource,
3225 ) -> String {
3226 match api_key {
3227 Some(api_key) if api_key.uses(source) => api_key
3228 .last4
3229 .as_deref()
3230 .map(|last4| format!("runtime-effective, last4: {last4}"))
3231 .unwrap_or_else(|| "runtime-effective".to_string()),
3232 Some(_) if diagnostics.is_custom_endpoint() => {
3233 "not eligible for this custom xAI endpoint".to_string()
3234 }
3235 Some(_) => "not selected by the runtime resolver".to_string(),
3236 None if diagnostics.evaluates_runtime_api_key() && diagnostics.is_custom_endpoint() => {
3237 "not eligible for this custom xAI endpoint".to_string()
3238 }
3239 None if diagnostics.evaluates_runtime_api_key() => {
3240 "not set for this runtime route".to_string()
3241 }
3242 None => "unprobed (structural OAuth/consent route)".to_string(),
3243 }
3244 }
3245
3246 fn xai_lookup_order(diagnostics: &XaiAuthDiagnostics) -> String {
3247 match diagnostics.route {
3248 XaiAuthDiagnosticRoute::OwnedOAuth => {
3249 "lookup order: configured Codewhale-owned OAuth generation (storage unprobed); Grok CLI consent blocked".to_string()
3250 }
3251 XaiAuthDiagnosticRoute::NeedsRepair => {
3252 "lookup order: invalid Codewhale-owned OAuth generation blocks Grok CLI consent; runtime-effective API-key fallback: CLI -> config -> secret store -> env".to_string()
3253 }
3254 XaiAuthDiagnosticRoute::ExternalConsent => {
3255 "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)".to_string()
3256 }
3257 XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
3258 "lookup order: endpoint-bound API key only for this custom xAI endpoint (explicit CLI key or route-bound config key)".to_string()
3259 }
3260 XaiAuthDiagnosticRoute::ApiKey => {
3261 "lookup order: CLI -> config -> secret store -> env".to_string()
3262 }
3263 }
3264 }
3265
3266 fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApiKey>) -> String {
3267 match diagnostics.route {
3268 XaiAuthDiagnosticRoute::OwnedOAuth => {
3269 "xai: configured (source: Codewhale-owned OAuth generation; valid pointer; storage unprobed)".to_string()
3270 }
3271 XaiAuthDiagnosticRoute::NeedsRepair => {
3272 let api_key = match api_key.and_then(XaiRuntimeApiKey::source_name) {
3273 Some("config") => "config-file".to_string(),
3274 Some("secret store") => "secret-store".to_string(),
3275 Some("env") => "env".to_string(),
3276 Some("cli") => "cli".to_string(),
3277 Some(other) => other.to_string(),
3278 None => "no runtime-effective API key".to_string(),
3279 };
3280 format!(
3281 "xai: needs repair (invalid Codewhale-owned OAuth generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
3282 )
3283 }
3284 XaiAuthDiagnosticRoute::ExternalConsent => {
3285 "xai: configured (source: external read-only consent; availability unprobed)".to_string()
3286 }
3287 XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(XaiRuntimeApiKey::source_name) {
3288 Some("config") => "xai: set (source: config-file)".to_string(),
3289 Some("secret store") => "xai: set (source: secret-store)".to_string(),
3290 Some("env") => "xai: set (source: env)".to_string(),
3291 Some("cli") => "xai: set (source: cli)".to_string(),
3292 Some(other) => format!("xai: set (source: {other})"),
3293 None => "xai: not set".to_string(),
3294 },
3295 }
3296 }
3297
3298 fn auth_get_line_with_runtime(
3299 store: &ConfigStore,
3300 secrets: &Secrets,
3301 provider: ProviderKind,
3302 runtime_overrides: &CliRuntimeOverrides,
3303 ) -> String {
3304 let slot = provider_slot(provider);
3305 if provider == ProviderKind::Xai {
3306 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3307 let api_key = diagnostics
3308 .evaluates_runtime_api_key()
3309 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3310 return xai_get_line(&diagnostics, api_key.as_ref());
3311 }
3312
3313 let config_key = provider_config_api_key(store, provider);
3314 let keyring_key = config_key
3315 .is_none()
3316 .then(|| provider_keyring_api_key(secrets, provider))
3317 .flatten();
3318 let env_key = provider_env_value(provider);
3319
3320 match api_key_source_name(config_key, keyring_key.as_deref(), env_key.as_ref()) {
3321 Some("config") => format!("{slot}: set (source: config-file)"),
3322 Some("secret store") => format!("{slot}: set (source: secret-store)"),
3323 Some("env") => format!("{slot}: set (source: env)"),
3324 Some(other) => format!("{slot}: set (source: {other})"),
3325 None => format!("{slot}: not set"),
3326 }
3327 }
3328
3329 #[cfg(test)]
3330 fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
3331 auth_status_all_providers_with_runtime(store, secrets, &CliRuntimeOverrides::default())
3332 }
3333
3334 fn auth_status_all_providers_with_runtime(
3335 store: &ConfigStore,
3336 secrets: &Secrets,
3337 runtime_overrides: &CliRuntimeOverrides,
3338 ) -> Vec<String> {
3339 let active_provider = store.config.provider;
3340 let mut lines = Vec::new();
3341 lines.push(account_status_line());
3342 lines.push(String::new());
3343 lines.push(format!(
3344 "active provider: {} (set via config or CODEWHALE_PROVIDER)",
3345 active_provider.as_str()
3346 ));
3347 lines.push(String::new());
3348 lines.push(format!(
3349 "{:<14} {:<8} {:<10} {:<8} {}",
3350 "provider", "config", "keyring", "env", "status"
3351 ));
3352 lines.push("-".repeat(70));
3353
3354 for provider in ProviderKind::ALL {
3355 if provider == ProviderKind::Xai {
3356 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3357 let api_key = diagnostics
3358 .evaluates_runtime_api_key()
3359 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3360 let active_marker = if provider == active_provider {
3361 " *"
3362 } else {
3363 ""
3364 };
3365 lines.push(format!(
3366 "{:<14} {:<8} {:<10} {:<8} {}{}",
3367 provider.as_str(),
3368 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
3369 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
3370 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
3371 xai_status_summary_source(&diagnostics, api_key.as_ref()),
3372 active_marker
3373 ));
3374 continue;
3375 }
3376
3377 let config_key = provider_config_api_key(store, provider);
3378 let keyring_key = provider_keyring_api_key(secrets, provider);
3379 let env_key = provider_env_value(provider);
3380 let external_selected = external_oauth_selected(store, provider);
3381
3382 let config_status = config_key.map(|_| "set").unwrap_or("-");
3383 let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
3384 let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
3385
3386 let source = if provider == ProviderKind::OpenaiCodex {
3387 // Keep the summary consistent with `auth status`: Codex auth is
3388 // OAuth-file (or env token) based — config/keyring keys are not
3389 // consulted for it.
3390 if env_key.is_some() {
3391 "env".to_string()
3392 } else if external_selected {
3393 "external consent (not probed)".to_string()
3394 } else {
3395 "unset".to_string()
3396 }
3397 } else if external_selected {
3398 "external consent (not probed)".to_string()
3399 } else if config_key.is_some() {
3400 "config".to_string()
3401 } else if keyring_key.is_some() {
3402 "keyring".to_string()
3403 } else if env_key.is_some() {
3404 "env".to_string()
3405 } else {
3406 "unset".to_string()
3407 };
3408
3409 let active_marker = if provider == active_provider {
3410 " *"
3411 } else {
3412 ""
3413 };
3414
3415 lines.push(format!(
3416 "{:<14} {:<8} {:<10} {:<8} {}{}",
3417 provider.as_str(),
3418 config_status,
3419 keyring_status,
3420 env_status,
3421 source,
3422 active_marker
3423 ));
3424 }
3425
3426 lines.push(String::new());
3427 lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
3428 lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
3429 lines.push("Account sign-in is `codewhale login`.".to_string());
3430 lines
3431 }
3432
3433 fn account_status_line() -> String {
3434 use codewhale_secrets::account::{
3435 ACCOUNT_API_BASE_ENV, AccountSessionState, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
3436 secure_account_session_secrets,
3437 };
3438 let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
3439 .ok()
3440 .map(|value| value.trim().trim_end_matches('/').to_string())
3441 .filter(|value| !value.is_empty())
3442 .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
3443 match secure_account_session_secrets() {
3444 Ok(secrets) => {
3445 match AccountSessionStore::new(secrets, None, &api_base)
3446 .runtime_info_at(chrono::Utc::now())
3447 {
3448 Ok(info) => {
3449 let state = match info.state {
3450 AccountSessionState::SignedOut => "not signed in",
3451 AccountSessionState::Authenticated => "signed in",
3452 AccountSessionState::OfflineCached => "offline (cached)",
3453 AccountSessionState::Expired => "expired",
3454 AccountSessionState::Revoked => "revoked",
3455 };
3456 format!("account: {state} (api {api_base})")
3457 }
3458 Err(error) => format!("account: unavailable ({error})"),
3459 }
3460 }
3461 Err(error) => format!("account: unavailable ({error})"),
3462 }
3463 }
3464
3465 fn diagnostic_path_state(path: &Path, directory: bool) -> &'static str {
3466 match std::fs::symlink_metadata(path) {
3467 Ok(metadata) if metadata.file_type().is_symlink() => "present (symlink; not followed)",
3468 Ok(metadata) if directory && metadata.is_dir() => "present",
3469 Ok(metadata) if !directory && metadata.is_file() => "present",
3470 Ok(_) => "present (unexpected type)",
3471 Err(error) if error.kind() == std::io::ErrorKind::NotFound => "missing",
3472 Err(_) => "unknown",
3473 }
3474 }
3475
3476 const fn secret_backend_kind_label(
3477 kind: codewhale_secrets::SecretBackendDiagnosticKind,
3478 ) -> &'static str {
3479 match kind {
3480 codewhale_secrets::SecretBackendDiagnosticKind::File => "file",
3481 codewhale_secrets::SecretBackendDiagnosticKind::System => "system",
3482 codewhale_secrets::SecretBackendDiagnosticKind::Unknown => "unknown",
3483 }
3484 }
3485
3486 const fn secret_backend_inspection_label(
3487 inspection: codewhale_secrets::SecretBackendInspection,
3488 ) -> &'static str {
3489 match inspection {
3490 codewhale_secrets::SecretBackendInspection::MetadataOnly => "metadata_only",
3491 codewhale_secrets::SecretBackendInspection::NotProbed => "not_probed",
3492 }
3493 }
3494
3495 const fn secret_backend_presence_label(
3496 presence: codewhale_secrets::SecretBackendPresence,
3497 ) -> &'static str {
3498 match presence {
3499 codewhale_secrets::SecretBackendPresence::Present => "present",
3500 codewhale_secrets::SecretBackendPresence::Absent => "missing",
3501 codewhale_secrets::SecretBackendPresence::Unknown => "unknown",
3502 }
3503 }
3504
3505 /// Value-free home and credential-source report for `auth status --diagnostic`.
3506 ///
3507 /// Unlike ordinary `auth status`, this path never constructs [`Secrets`] and
3508 /// never asks a provider keyring for a value. File presence comes from metadata
3509 /// only; provider environment variables are checked with the runtime's
3510 /// non-empty-string semantics and their contents are never formatted.
3511 fn auth_diagnostic_lines(store: &ConfigStore, provider: Option<ProviderKind>) -> Vec<String> {
3512 let explicit_home = codewhale_paths::codewhale_home_is_explicit();
3513 let resolved_home = codewhale_paths::codewhale_home();
3514 let mut lines = vec![
3515 "auth diagnostic (structural only; credential values are never printed and provider credential stores were not opened)".to_string(),
3516 String::new(),
3517 ];
3518
3519 let home = match resolved_home {
3520 Ok(Some(path)) => {
3521 lines.push(format!(
3522 "codewhale home: {} (source: {}; state: {})",
3523 codewhale_config::quote_os_path(&path),
3524 if explicit_home {
3525 "CODEWHALE_HOME (isolated)"
3526 } else {
3527 "platform home"
3528 },
3529 diagnostic_path_state(&path, true),
3530 ));
3531 Some(path)
3532 }
3533 Ok(None) => {
3534 lines.push("codewhale home: unavailable (no user home resolved)".to_string());
3535 None
3536 }
3537 Err(error) => {
3538 lines.push(format!("codewhale home: unavailable ({error})"));
3539 None
3540 }
3541 };
3542
3543 lines.push(format!(
3544 "config: {} ({})",
3545 codewhale_config::quote_os_path(store.path()),
3546 diagnostic_path_state(store.path(), false),
3547 ));
3548 if let Some(home) = home.as_ref() {
3549 let settings = home.join("settings.toml");
3550 lines.push(format!(
3551 "settings: {} ({})",
3552 codewhale_config::quote_os_path(&settings),
3553 diagnostic_path_state(&settings, false),
3554 ));
3555 } else {
3556 lines.push("settings: unavailable (Codewhale home unresolved)".to_string());
3557 }
3558
3559 let backend = codewhale_secrets::diagnose_secret_backend();
3560 lines.push(format!(
3561 "secret backend: {} (inspection: {})",
3562 secret_backend_kind_label(backend.backend),
3563 secret_backend_inspection_label(backend.inspection),
3564 ));
3565 if let Some(path) = backend.path.as_ref() {
3566 lines.push(format!(
3567 "secret store: {} ({})",
3568 codewhale_config::quote_os_path(path),
3569 secret_backend_presence_label(backend.presence),
3570 ));
3571 } else {
3572 lines.push(format!(
3573 "secret store: unavailable ({})",
3574 secret_backend_presence_label(backend.presence),
3575 ));
3576 }
3577 if let Some(path) = backend.legacy_path.as_ref() {
3578 lines.push(format!(
3579 "legacy secret store: {} ({})",
3580 codewhale_config::quote_os_path(path),
3581 secret_backend_presence_label(backend.legacy_presence),
3582 ));
3583 } else if explicit_home {
3584 lines.push(
3585 "legacy secret store: suppressed by explicit CODEWHALE_HOME isolation".to_string(),
3586 );
3587 } else {
3588 lines.push("legacy secret store: unavailable (not probed)".to_string());
3589 }
3590
3591 lines.push(String::new());
3592 // Diagnostic mode answers "which sources will this shell use?" for one
3593 // route. Ordinary `auth status` remains the all-provider inventory; a
3594 // different provider can be inspected explicitly with `--provider`.
3595 let providers = [provider.unwrap_or(store.config.provider)];
3596 for provider in providers {
3597 let config_present = provider_config_api_key(store, provider).is_some();
3598 let environment_present = provider_env_vars(provider)
3599 .iter()
3600 .any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()));
3601 let environment_names = match provider_env_vars(provider) {
3602 [] => "none configured".to_string(),
3603 names => names.join("/"),
3604 };
3605 let external_configured = external_consent(store, provider).is_some();
3606 lines.push(format!(
3607 "provider {} sources: config_literal={}, secret_backend={} (provider entry unprobed), environment={} ({}), external_consent={}",
3608 provider.as_str(),
3609 if config_present { "present" } else { "missing" },
3610 secret_backend_presence_label(backend.presence),
3611 if environment_present { "present" } else { "missing" },
3612 environment_names,
3613 if external_configured {
3614 "configured"
3615 } else {
3616 "missing"
3617 },
3618 ));
3619 }
3620 lines
3621 }
3622
3623 fn run_auth_diagnostic(store: &ConfigStore, provider: Option<ProviderKind>) -> Result<()> {
3624 for line in auth_diagnostic_lines(store, provider) {
3625 println!("{line}");
3626 }
3627 Ok(())
3628 }
3629
3630 #[cfg(test)]
3631 fn auth_list_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
3632 auth_list_lines_with_runtime(store, secrets, &CliRuntimeOverrides::default())
3633 }
3634
3635 fn auth_list_lines_with_runtime(
3636 store: &ConfigStore,
3637 secrets: &Secrets,
3638 runtime_overrides: &CliRuntimeOverrides,
3639 ) -> Vec<String> {
3640 let mut lines = Vec::new();
3641 lines.push("provider config store env route".to_string());
3642 for provider in ProviderKind::ALL {
3643 // Label the row by the provider, not by its credential slot. This
3644 // table has one row per ProviderKind, but several kinds share a slot
3645 // (ProviderKind::secret_store_slot): SiliconflowCN shares
3646 // `siliconflow`, and the four Model Studio variants share
3647 // `modelstudio-token-plan`. Labelling by slot printed `siliconflow`
3648 // twice and `modelstudio-token-plan` four times, so the reader could
3649 // not tell which row was which provider. The status columns still
3650 // read the shared slot, which is what makes one saved key light up
3651 // the whole family.
3652 let label = provider.as_str();
3653 if provider == ProviderKind::Xai {
3654 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3655 let api_key = diagnostics
3656 .evaluates_runtime_api_key()
3657 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3658 lines.push(format!(
3659 "{label:<12} {} {} {} {}",
3660 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
3661 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
3662 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
3663 xai_list_route(&diagnostics, api_key.as_ref())
3664 ));
3665 continue;
3666 }
3667
3668 let file = provider_config_set(store, provider);
3669 let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
3670 let env = provider_env_set(provider);
3671 let external_selected = external_oauth_selected(store, provider);
3672 let active = if provider == ProviderKind::OpenaiCodex {
3673 if env {
3674 "env"
3675 } else if external_selected {
3676 "external-consent"
3677 } else {
3678 "missing"
3679 }
3680 } else if external_selected {
3681 "external-consent"
3682 } else if file {
3683 "config"
3684 } else if keyring == Some(true) {
3685 "store"
3686 } else if env {
3687 "env"
3688 } else {
3689 "missing"
3690 };
3691 lines.push(format!(
3692 "{label:<12} {} {} {} {active}",
3693 yes_no(file),
3694 keyring_status_short(keyring),
3695 yes_no(env)
3696 ));
3697 }
3698 lines
3699 }
3700
3701 #[cfg(test)]
3702 fn auth_status_lines_for_provider(
3703 store: &ConfigStore,
3704 secrets: &Secrets,
3705 provider: ProviderKind,
3706 ) -> Vec<String> {
3707 auth_status_lines_for_provider_with_runtime(
3708 store,
3709 secrets,
3710 provider,
3711 &CliRuntimeOverrides::default(),
3712 )
3713 }
3714
3715 fn auth_status_lines_for_provider_with_runtime(
3716 store: &ConfigStore,
3717 secrets: &Secrets,
3718 provider: ProviderKind,
3719 runtime_overrides: &CliRuntimeOverrides,
3720 ) -> Vec<String> {
3721 if provider == ProviderKind::Xai {
3722 return xai_auth_status_lines_for_provider(store, secrets, runtime_overrides);
3723 }
3724
3725 let config_key = provider_config_api_key(store, provider);
3726 let keyring_key = provider_keyring_api_key(secrets, provider);
3727 let env_key = provider_env_value(provider);
3728 let external = external_consent(store, provider);
3729 let external_selected = external_oauth_selected(store, provider);
3730
3731 let active_label = {
3732 let active_source = if provider == ProviderKind::OpenaiCodex {
3733 if env_key.is_some() {
3734 "env"
3735 } else if store
3736 .config
3737 .providers
3738 .openai_codex
3739 .oauth_credential_generation
3740 .as_deref()
3741 .is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation)
3742 {
3743 "codewhale-owned ChatGPT sign-in (availability not probed)"
3744 } else if external_selected {
3745 "external read-only consent (availability not probed)"
3746 } else {
3747 "missing"
3748 }
3749 } else if external_selected {
3750 "external read-only consent (availability not probed)"
3751 } else if config_key.is_some() {
3752 "config"
3753 } else if keyring_key.is_some() {
3754 "secret store"
3755 } else if env_key.is_some() {
3756 "env"
3757 } else {
3758 "missing"
3759 };
3760 let active_last4 = if provider == ProviderKind::OpenaiCodex {
3761 env_key.as_ref().map(|(_, value)| last4_label(value))
3762 } else {
3763 config_key
3764 .map(last4_label)
3765 .or_else(|| keyring_key.as_deref().map(last4_label))
3766 .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
3767 };
3768 active_last4
3769 .map(|last4| format!("{active_source} (last4: {last4})"))
3770 .unwrap_or_else(|| active_source.to_string())
3771 };
3772
3773 let env_var_label = env_key
3774 .as_ref()
3775 .map(|(name, _)| (*name).to_string())
3776 .unwrap_or_else(|| provider_env_vars(provider).join("/"));
3777 let env_status = env_key
3778 .as_ref()
3779 .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
3780 .unwrap_or_else(|| "unset".to_string());
3781
3782 let is_active = provider == store.config.provider;
3783 let active_marker = if is_active { " (active provider)" } else { "" };
3784
3785 let provider_cfg = store.config.providers.for_provider(provider);
3786 let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
3787 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3788
3789 let lookup_order = if provider == ProviderKind::OpenaiCodex {
3790 "lookup order: env -> Codewhale-owned ChatGPT sign-in -> consent-gated exact Codex CLI file"
3791 .to_string()
3792 } else {
3793 "lookup order: config -> secret store -> env".to_string()
3794 };
3795 let auth_mode = if provider == ProviderKind::OpenaiCodex {
3796 "codex_oauth".to_string()
3797 } else {
3798 provider_cfg
3799 .auth_mode
3800 .as_deref()
3801 .or(store.config.auth_mode.as_deref())
3802 .unwrap_or("api_key")
3803 .to_string()
3804 };
3805
3806 let mut lines = vec![
3807 format!("provider: {}{}", provider.as_str(), active_marker),
3808 format!("route: {}", base_url),
3809 format!("model: {}", model),
3810 format!("auth mode: {auth_mode}"),
3811 format!("active source: {active_label}"),
3812 lookup_order,
3813 format!(
3814 "config file: {} ({})",
3815 codewhale_config::quote_os_path(store.path()),
3816 source_status(config_key, "missing")
3817 ),
3818 format!(
3819 "secret store: {} ({})",
3820 secrets.backend_name(),
3821 source_status(keyring_key.as_deref(), "missing")
3822 ),
3823 format!("env var: {env_var_label} ({env_status})"),
3824 ];
3825
3826 if let Ok((source, expected_path)) = external_credential_target(provider, None) {
3827 let status = codewhale_config::external_credential_consent_status(
3828 external,
3829 provider,
3830 source,
3831 &expected_path,
3832 store.config.provider,
3833 );
3834 lines.push(format!(
3835 "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
3836 status.access.as_str(),
3837 status.provider,
3838 status.source.as_str(),
3839 status.owner,
3840 codewhale_config::quote_os_path(&status.path),
3841 status.consent_version,
3842 status.route_state,
3843 status.scope_valid,
3844 status.ambient_path_changed,
3845 ));
3846 lines.push(format!("semantics: {}", status.semantics));
3847 lines.push(format!("revoke: {}", status.revoke_command));
3848 if let Some(warning) = status.ambient_path_warning() {
3849 lines.push(warning);
3850 }
3851 } else {
3852 lines.push("external credentials: disabled (no file was probed)".to_string());
3853 }
3854 lines
3855 }
3856
3857 fn xai_auth_status_lines_for_provider(
3858 store: &ConfigStore,
3859 secrets: &Secrets,
3860 runtime_overrides: &CliRuntimeOverrides,
3861 ) -> Vec<String> {
3862 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3863 let api_key = diagnostics
3864 .evaluates_runtime_api_key()
3865 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3866 let external = external_consent(store, ProviderKind::Xai);
3867 let selected_marker = if store.config.provider == ProviderKind::Xai {
3868 " (selected provider)"
3869 } else {
3870 ""
3871 };
3872 let provider_cfg = &store.config.providers.xai;
3873 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3874 let auth_mode = diagnostics.auth_mode.as_deref().unwrap_or("api_key");
3875
3876 let mut lines = vec![
3877 format!("provider: xai{selected_marker}"),
3878 format!("route: {}", diagnostics.base_url),
3879 format!("model: {model}"),
3880 format!("auth mode: {auth_mode}"),
3881 format!(
3882 "credential route: {}",
3883 xai_credential_route_label(&diagnostics, api_key.as_ref())
3884 ),
3885 xai_lookup_order(&diagnostics),
3886 format!(
3887 "config file: {} ({})",
3888 codewhale_config::quote_os_path(store.path()),
3889 xai_storage_detail(
3890 &diagnostics,
3891 api_key.as_ref(),
3892 RuntimeApiKeySource::ConfigFile
3893 )
3894 ),
3895 format!(
3896 "secret store: {} ({})",
3897 secrets.backend_name(),
3898 xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Keyring)
3899 ),
3900 format!(
3901 "env var: {} ({})",
3902 provider_env_vars(ProviderKind::Xai).join("/"),
3903 xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Env)
3904 ),
3905 format!(
3906 "endpoint policy: {}",
3907 if diagnostics.official_endpoint {
3908 "official xAI endpoint"
3909 } else {
3910 "custom xAI endpoint; API-key-only (owned and external OAuth are inactive)"
3911 }
3912 ),
3913 ];
3914
3915 lines.push(match diagnostics.generation {
3916 XaiOAuthGenerationPointer::Absent => "xAI OAuth generation: absent".to_string(),
3917 XaiOAuthGenerationPointer::Valid
3918 if diagnostics.route == XaiAuthDiagnosticRoute::OwnedOAuth =>
3919 {
3920 "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
3921 .to_string()
3922 }
3923 XaiOAuthGenerationPointer::Valid => {
3924 "xAI OAuth generation: valid but inactive for this route".to_string()
3925 }
3926 XaiOAuthGenerationPointer::Invalid => {
3927 "xAI OAuth generation: invalid Codewhale-owned pointer".to_string()
3928 }
3929 });
3930
3931 match diagnostics.route {
3932 XaiAuthDiagnosticRoute::OwnedOAuth => {
3933 lines.push(
3934 "external credentials: blocked by the configured Codewhale-owned xAI OAuth generation (file not probed)"
3935 .to_string(),
3936 );
3937 return lines;
3938 }
3939 XaiAuthDiagnosticRoute::NeedsRepair => {
3940 lines.push(
3941 "external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer (file not probed)"
3942 .to_string(),
3943 );
3944 lines.push(
3945 "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."
3946 .to_string(),
3947 );
3948 return lines;
3949 }
3950 XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
3951 lines.push(
3952 "external credentials: unavailable on a custom xAI endpoint (API-key-only; file not probed)"
3953 .to_string(),
3954 );
3955 return lines;
3956 }
3957 XaiAuthDiagnosticRoute::ApiKey if !diagnostics.oauth_selected && external.is_some() => {
3958 lines.push(
3959 "external credentials: configured but inactive because xAI OAuth mode is not selected (file not probed)"
3960 .to_string(),
3961 );
3962 return lines;
3963 }
3964 XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::ExternalConsent => {}
3965 }
3966
3967 if let Ok((source, expected_path)) = external_credential_target(ProviderKind::Xai, None) {
3968 let status = codewhale_config::external_credential_consent_status(
3969 external,
3970 ProviderKind::Xai,
3971 source,
3972 &expected_path,
3973 store.config.provider,
3974 );
3975 lines.push(format!(
3976 "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
3977 status.access.as_str(),
3978 status.provider,
3979 status.source.as_str(),
3980 status.owner,
3981 codewhale_config::quote_os_path(&status.path),
3982 status.consent_version,
3983 status.route_state,
3984 status.scope_valid,
3985 status.ambient_path_changed,
3986 ));
3987 lines.push(format!("semantics: {}", status.semantics));
3988 lines.push(format!("revoke: {}", status.revoke_command));
3989 if let Some(warning) = status.ambient_path_warning() {
3990 lines.push(warning);
3991 }
3992 } else {
3993 lines.push("external credentials: disabled (no file was probed)".to_string());
3994 }
3995 lines
3996 }
3997
3998 fn source_status(value: Option<&str>, missing_label: &str) -> String {
3999 value
4000 .map(|v| format!("set, last4: {}", last4_label(v)))
4001 .unwrap_or_else(|| missing_label.to_string())
4002 }
4003
4004 fn last4_label(value: &str) -> String {
4005 let trimmed = value.trim();
4006 let chars: Vec<char> = trimmed.chars().collect();
4007 if chars.len() <= 4 {
4008 return "<redacted>".to_string();
4009 }
4010 let last4: String = chars[chars.len() - 4..].iter().collect();
4011 format!("...{last4}")
4012 }
4013
4014 fn run_auth_command_with_runtime(
4015 store: &mut ConfigStore,
4016 command: AuthCommand,
4017 runtime_overrides: &CliRuntimeOverrides,
4018 ) -> Result<()> {
4019 let command = match command {
4020 AuthCommand::Status {
4021 provider,
4022 diagnostic: true,
4023 } => {
4024 // Keep the structural diagnostic structurally read-only: ordinary
4025 // status constructs the configured credential facade so it can report
4026 // runtime-effective sources, but diagnostic mode must not even create
4027 // a system-keyring handle or inspect a file-backed store.
4028 return run_auth_diagnostic(store, provider);
4029 }
4030 command => command,
4031 };
4032 run_auth_command_with_secrets_and_runtime(
4033 store,
4034 command,
4035 &Secrets::auto_detect(),
4036 runtime_overrides,
4037 )
4038 }
4039
4040 #[cfg(test)]
4041 fn run_auth_command_with_secrets(
4042 store: &mut ConfigStore,
4043 command: AuthCommand,
4044 secrets: &Secrets,
4045 ) -> Result<()> {
4046 run_auth_command_with_secrets_and_runtime(
4047 store,
4048 command,
4049 secrets,
4050 &CliRuntimeOverrides::default(),
4051 )
4052 }
4053
4054 fn run_auth_command_with_secrets_and_runtime(
4055 store: &mut ConfigStore,
4056 command: AuthCommand,
4057 secrets: &Secrets,
4058 runtime_overrides: &CliRuntimeOverrides,
4059 ) -> Result<()> {
4060 match command {
4061 AuthCommand::XaiDevice => {
4062 let argv = vec![
4063 "codewhale".to_string(),
4064 "auth".to_string(),
4065 "xai-device".to_string(),
4066 ];
4067 let code = codewhale_tui::run(argv);
4068 std::process::exit(if code == std::process::ExitCode::SUCCESS {
4069 0
4070 } else {
4071 1
4072 })
4073 }
4074 AuthCommand::Chatgpt => {
4075 let argv = vec![
4076 "codewhale".to_string(),
4077 "auth".to_string(),
4078 "chatgpt".to_string(),
4079 ];
4080 let code = codewhale_tui::run(argv);
4081 std::process::exit(if code == std::process::ExitCode::SUCCESS {
4082 0
4083 } else {
4084 1
4085 })
4086 }
4087 AuthCommand::ChatgptRevoke => {
4088 let argv = vec![
4089 "codewhale".to_string(),
4090 "auth".to_string(),
4091 "chatgpt-revoke".to_string(),
4092 ];
4093 let code = codewhale_tui::run(argv);
4094 std::process::exit(if code == std::process::ExitCode::SUCCESS {
4095 0
4096 } else {
4097 1
4098 })
4099 }
4100 AuthCommand::ExternalConsent {
4101 provider,
4102 mode,
4103 path,
4104 yes,
4105 } => {
4106 let (source, path) = external_credential_target(provider, path)?;
4107 let preview = external_consent_preview_lines(provider, source, &path);
4108 for line in &preview {
4109 println!("{line}");
4110 }
4111 if mode == ExternalCredentialModeArg::Managed {
4112 bail!(
4113 "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."
4114 );
4115 }
4116 confirm_external_consent(yes)?;
4117 let path_value = path.to_str().context(
4118 "external credential path cannot be persisted losslessly because it is not valid UTF-8",
4119 )?;
4120 let provider_key = provider.provider().provider_config_key();
4121 codewhale_config::mutate_config_document(store.path(), |document| {
4122 if matches!(provider, ProviderKind::OpenaiCodex | ProviderKind::Xai) {
4123 codewhale_config::set_config_document_value(
4124 document,
4125 &["providers", provider_key, "auth_mode"],
4126 "oauth",
4127 )?;
4128 }
4129 let prefix = &["providers", provider_key, "external_credentials"];
4130 codewhale_config::set_config_document_value(
4131 document,
4132 &[prefix[0], prefix[1], prefix[2], "access"],
4133 "read_only",
4134 )?;
4135 codewhale_config::set_config_document_value(
4136 document,
4137 &[prefix[0], prefix[1], prefix[2], "provider"],
4138 provider.as_str(),
4139 )?;
4140 codewhale_config::set_config_document_value(
4141 document,
4142 &[prefix[0], prefix[1], prefix[2], "source"],
4143 source.as_str(),
4144 )?;
4145 codewhale_config::set_config_document_value(
4146 document,
4147 &[prefix[0], prefix[1], prefix[2], "path"],
4148 path_value,
4149 )?;
4150 codewhale_config::set_config_document_value(
4151 document,
4152 &[prefix[0], prefix[1], prefix[2], "consent_version"],
4153 i64::from(codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION),
4154 )
4155 })?;
4156 store
4157 .reload()
4158 .context("external consent was saved, but config reload failed")?;
4159 println!(
4160 "saved read-only external credential consent: provider={}, owner={}, path={}, consent_version={} ({})",
4161 provider.as_str(),
4162 source.as_str(),
4163 codewhale_config::quote_os_path(&path),
4164 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION,
4165 codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
4166 );
4167 println!(
4168 "revoke with: codewhale auth external-revoke --provider {}",
4169 provider.as_str()
4170 );
4171 Ok(())
4172 }
4173 AuthCommand::ExternalRevoke { provider } => {
4174 let provider_key = provider.provider().provider_config_key();
4175 codewhale_config::mutate_config_document(store.path(), |document| {
4176 codewhale_config::unset_config_document_value(
4177 document,
4178 &["providers", provider_key, "external_credentials"],
4179 )?;
4180 Ok(())
4181 })?;
4182 store
4183 .reload()
4184 .context("external consent was revoked, but config reload failed")?;
4185 println!(
4186 "external credential access disabled for {}",
4187 provider.as_str()
4188 );
4189 Ok(())
4190 }
4191 AuthCommand::Status {
4192 provider,
4193 diagnostic,
4194 } => {
4195 if diagnostic {
4196 return run_auth_diagnostic(store, provider);
4197 }
4198 match provider {
4199 Some(provider) => {
4200 for line in auth_status_lines_for_provider_with_runtime(
4201 store,
4202 secrets,
4203 provider,
4204 runtime_overrides,
4205 ) {
4206 println!("{line}");
4207 }
4208 }
4209 None => {
4210 for line in
4211 auth_status_all_providers_with_runtime(store, secrets, runtime_overrides)
4212 {
4213 println!("{line}");
4214 }
4215 }
4216 }
4217 Ok(())
4218 }
4219 AuthCommand::Set {
4220 provider,
4221 api_key,
4222 api_key_stdin,
4223 } => {
4224 let slot = provider_slot(provider);
4225 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
4226 let provider_cfg = store.config.providers.for_provider_mut(provider);
4227 if provider_cfg.base_url.is_none() {
4228 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
4229 }
4230 store.save()?;
4231 println!(
4232 "configured {slot} provider in {} (API key optional)",
4233 store.path().display()
4234 );
4235 return Ok(());
4236 }
4237 let api_key = match (api_key, api_key_stdin) {
4238 (Some(v), _) => v,
4239 (None, true) => read_api_key_from_stdin()?,
4240 (None, false) => prompt_api_key(slot)?,
4241 };
4242 let mut credential_store =
4243 codewhale_config::credentials::credential_metadata_store(store)?;
4244 if let Some(redirected) = credential_store.as_ref() {
4245 eprintln!(
4246 "ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead",
4247 codewhale_config::quote_os_path(store.path()),
4248 codewhale_config::quote_os_path(redirected.path()),
4249 );
4250 }
4251 let store = credential_store.as_mut().unwrap_or(store);
4252 let secret_store_saved = set_provider_api_key(store, secrets, provider, &api_key)?;
4253 // Don't print the key. Don't echo length.
4254 if secret_store_saved {
4255 println!(
4256 "saved API key for {slot} to {} (config contains metadata only)",
4257 secrets.backend_name(),
4258 );
4259 } else {
4260 println!("saved API key for {slot} to {}", store.path().display());
4261 }
4262 Ok(())
4263 }
4264 AuthCommand::Get { provider } => {
4265 println!(
4266 "{}",
4267 auth_get_line_with_runtime(store, secrets, provider, runtime_overrides)
4268 );
4269 Ok(())
4270 }
4271 AuthCommand::PrintApiKey { provider } => {
4272 let mut stdout = io::stdout().lock();
4273 credential_handoff::handoff_secret_line(&mut stdout, io::stdout().is_terminal(), || {
4274 credential_handoff::resolve_api_key(store, secrets, provider, runtime_overrides)
4275 })
4276 }
4277 AuthCommand::Clear { provider } => {
4278 if provider == ProviderKind::Xai {
4279 codewhale_config::with_xai_oauth_revocation_transaction(|| {
4280 clear_auth_provider(store, secrets, provider)
4281 })
4282 } else {
4283 clear_auth_provider(store, secrets, provider)
4284 }
4285 }
4286 AuthCommand::List => {
4287 for line in auth_list_lines_with_runtime(store, secrets, runtime_overrides) {
4288 println!("{line}");
4289 }
4290 Ok(())
4291 }
4292 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
4293 }
4294 }
4295
4296 fn external_consent_preview_lines(
4297 provider: ProviderKind,
4298 source: codewhale_config::ExternalCredentialSource,
4299 path: &Path,
4300 ) -> Vec<String> {
4301 vec![
4302 "External credential consent preview (nothing has been saved):".to_string(),
4303 format!(" provider: {}", provider.as_str()),
4304 format!(
4305 " owning CLI: {} ({})",
4306 source.owner_label(),
4307 source.as_str()
4308 ),
4309 format!(
4310 " exact resolved path: {}",
4311 codewhale_config::quote_os_path(path)
4312 ),
4313 format!(
4314 " access: read_only ({})",
4315 codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS
4316 ),
4317 " managed: unavailable (no reviewed schema-safe preservation adapter)".to_string(),
4318 format!(
4319 " revoke: codewhale auth external-revoke --provider {}",
4320 provider.as_str()
4321 ),
4322 ]
4323 }
4324
4325 fn confirm_external_consent(yes: bool) -> Result<()> {
4326 use std::io::IsTerminal;
4327
4328 if yes {
4329 return Ok(());
4330 }
4331 if !std::io::stdin().is_terminal() {
4332 bail!(
4333 "external credential consent was not saved: non-interactive use requires explicit --yes after reviewing the preview"
4334 );
4335 }
4336 confirm_external_consent_answer(&mut std::io::stdin().lock(), &mut std::io::stdout().lock())
4337 }
4338
4339 fn confirm_external_consent_answer(
4340 reader: &mut impl std::io::BufRead,
4341 writer: &mut impl std::io::Write,
4342 ) -> Result<()> {
4343 write!(writer, "Type 'yes' to grant this exact read-only access: ")?;
4344 writer.flush()?;
4345 let mut answer = String::new();
4346 reader
4347 .read_line(&mut answer)
4348 .context("reading external credential consent confirmation")?;
4349 if answer.trim() != "yes" {
4350 bail!("external credential consent cancelled; no configuration was changed");
4351 }
4352 Ok(())
4353 }
4354
4355 fn yes_no(b: bool) -> &'static str {
4356 if b { "yes" } else { "no " }
4357 }
4358
4359 fn keyring_status_short(state: Option<bool>) -> &'static str {
4360 match state {
4361 Some(true) => "yes",
4362 Some(false) => "no ",
4363 None => "n/a",
4364 }
4365 }
4366
4367 fn prompt_api_key(slot: &str) -> Result<String> {
4368 use std::io::{IsTerminal, Write};
4369 eprint!("Enter API key for {slot}: ");
4370 io::stderr().flush().ok();
4371 if !io::stdin().is_terminal() {
4372 // Non-interactive: read directly without prompting twice.
4373 return read_api_key_from_stdin();
4374 }
4375 let mut buf = String::new();
4376 io::stdin()
4377 .read_line(&mut buf)
4378 .context("failed to read API key from stdin")?;
4379 let key = buf.trim().to_string();
4380 if key.is_empty() {
4381 bail!("empty API key provided");
4382 }
4383 Ok(key)
4384 }
4385
4386 /// Move plaintext keys from config.toml into the configured secret store.
4387 /// Hidden in v0.8.8 because the normal setup path is config/env only.
4388 fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
4389 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
4390 let mut warnings: Vec<String> = Vec::new();
4391 let literal =
4392 |value: &String| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal;
4393
4394 for provider in ProviderKind::ALL {
4395 let slot = provider_slot(provider);
4396 let from_provider_block = store
4397 .config
4398 .providers
4399 .for_provider(provider)
4400 .api_key
4401 .clone()
4402 .filter(literal);
4403 let from_root = (provider == ProviderKind::Deepseek)
4404 .then(|| store.config.api_key.clone())
4405 .flatten()
4406 .filter(literal);
4407 let value = from_provider_block.or(from_root);
4408 let Some(value) = value else { continue };
4409
4410 if let Ok(Some(existing)) = secrets.get(slot)
4411 && existing == value
4412 {
4413 // Already migrated; safe to strip the file slot.
4414 } else if dry_run {
4415 migrated.push((provider, slot));
4416 continue;
4417 } else if let Err(err) = secrets.set(slot, &value) {
4418 warnings.push(format!(
4419 "skipped {slot}: failed to write to secret store: {err}"
4420 ));
4421 continue;
4422 }
4423 if !dry_run {
4424 store.config.providers.for_provider_mut(provider).api_key = None;
4425 if provider == ProviderKind::Deepseek {
4426 store.config.api_key = None;
4427 }
4428 }
4429 migrated.push((provider, slot));
4430 }
4431
4432 if !dry_run && !migrated.is_empty() {
4433 store
4434 .save()
4435 .context("failed to write updated config.toml")?;
4436 }
4437 if !dry_run {
4438 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())
4439 .context("failed to remove plaintext API keys from config backup")?;
4440 }
4441
4442 println!("secret store backend: {}", secrets.backend_name());
4443 if migrated.is_empty() {
4444 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
4445 } else {
4446 println!(
4447 "{} {} provider key(s):",
4448 if dry_run { "would migrate" } else { "migrated" },
4449 migrated.len()
4450 );
4451 for (_, slot) in &migrated {
4452 println!(" - {slot}");
4453 }
4454 if !dry_run {
4455 println!(
4456 "config.toml at {} no longer contains api_key entries for migrated providers.",
4457 store.path().display()
4458 );
4459 }
4460 }
4461 for w in warnings {
4462 eprintln!("warning: {w}");
4463 }
4464 Ok(())
4465 }
4466
4467 fn run_config_command(
4468 store: &mut ConfigStore,
4469 command: ConfigCommand,
4470 project_bundle_scope: bool,
4471 per_run_overrides: &[String],
4472 ) -> Result<()> {
4473 if project_bundle_scope && !codewhale_config::config_path_is_workspace_scoped(store.path()) {
4474 bail!(
4475 "--project requires a workspace config ({} is the user-global document)",
4476 store.path().display()
4477 );
4478 }
4479 // A per-run overlay must never leak into the file: commands that write
4480 // the store refuse it outright instead of saving a merged document.
4481 if !per_run_overrides.is_empty()
4482 && matches!(
4483 command,
4484 ConfigCommand::Set { .. }
4485 | ConfigCommand::Unset { .. }
4486 | ConfigCommand::Import(_)
4487 | ConfigCommand::Telemetry {
4488 accept_notice: Some(_)
4489 }
4490 )
4491 {
4492 bail!(
4493 "--set is per-run and never saved; it cannot be combined with `config set`, \
4494 `config unset`, or `config import`. Drop --set, or use a read command."
4495 );
4496 }
4497 match command {
4498 ConfigCommand::Get { key } => {
4499 if per_run_overrides.is_empty() && codewhale_tui::route_preferences::is_route_key(&key)
4500 {
4501 if let Some(value) = codewhale_tui::route_preferences::get(store.path(), &key)? {
4502 println!("{value}");
4503 return Ok(());
4504 }
4505 bail!("key not found: {key}");
4506 }
4507 if codewhale_config::notifications::in_namespace(&key) {
4508 let config = codewhale_config::notifications::from_extras(&store.config.extras)?;
4509 let keys = if key.eq_ignore_ascii_case("notifications") {
4510 codewhale_config::notifications::NotificationSetting::ALL.to_vec()
4511 } else {
4512 vec![codewhale_config::notifications::NotificationSetting::required(&key)?]
4513 };
4514 for setting in keys {
4515 if key.eq_ignore_ascii_case("notifications") {
4516 println!(
4517 "notifications.{} = {}",
4518 setting.key(),
4519 config.display(setting)
4520 );
4521 } else {
4522 println!("{}", config.display(setting));
4523 }
4524 }
4525 return Ok(());
4526 }
4527 if let Some(value) = store.config.get_display_value(&key) {
4528 if key == "telemetry" {
4529 println!(
4530 "Usage reporting: {}",
4531 telemetry_preference_status(store.config.telemetry)
4532 );
4533 println!("Details: codewhale config telemetry");
4534 } else {
4535 println!("{value}");
4536 }
4537 return Ok(());
4538 }
4539 bail!("key not found: {key}");
4540 }
4541 ConfigCommand::Set { key, value } => {
4542 if codewhale_tui::route_preferences::is_route_key(&key) {
4543 codewhale_tui::route_preferences::set(store.path(), &key, &value)?;
4544 store.reload()?;
4545 println!("set {key}");
4546 return Ok(());
4547 }
4548 if codewhale_config::notifications::in_namespace(&key) {
4549 let setting = codewhale_config::notifications::NotificationSetting::required(&key)?;
4550 codewhale_config::notifications::NotificationConfigUpdate::parse(setting, &value)?
4551 .persist(store.path())?;
4552 store.reload()?;
4553 println!("set notifications.{}", setting.key());
4554 return Ok(());
4555 }
4556 store.config.set_value(&key, &value)?;
4557 if key == "telemetry" {
4558 let enabled = store
4559 .config
4560 .telemetry
4561 .context("telemetry must be true or false")?;
4562 let receipt = codewhale_tui::set_telemetry_preference(
4563 Some(store.path().to_path_buf()),
4564 enabled,
4565 )?;
4566 println!("{receipt}");
4567 if enabled {
4568 println!("{}", telemetry::notice::STARTUP_DISCLOSURE);
4569 }
4570 } else {
4571 store.save()?;
4572 println!("set {key}");
4573 }
4574 Ok(())
4575 }
4576 ConfigCommand::Telemetry { accept_notice } => {
4577 println!("{}\n", telemetry::notice::NOTICE_BODY);
4578 if let Some(version) = accept_notice {
4579 let receipt = codewhale_tui::accept_telemetry_notice(
4580 Some(store.path().to_path_buf()),
4581 version,
4582 )?;
4583 println!("{receipt}");
4584 } else {
4585 println!(
4586 "Usage reporting: {}",
4587 telemetry_preference_status(store.config.telemetry)
4588 );
4589 println!("To enable: codewhale config set telemetry true");
4590 println!("To opt out: codewhale config set telemetry false");
4591 }
4592 Ok(())
4593 }
4594 ConfigCommand::Unset { key } => {
4595 if codewhale_tui::route_preferences::is_route_key(&key) {
4596 codewhale_tui::route_preferences::unset(store.path(), &key)?;
4597 store.reload()?;
4598 println!("unset {key}");
4599 return Ok(());
4600 }
4601 if codewhale_config::notifications::in_namespace(&key) {
4602 let setting = codewhale_config::notifications::NotificationSetting::required(&key)?;
4603 setting.unset(store.path())?;
4604 store.reload()?;
4605 println!("unset notifications.{}", setting.key());
4606 return Ok(());
4607 }
4608 store.config.unset_value(&key)?;
4609 store.save()?;
4610 println!("unset {key}");
4611 Ok(())
4612 }
4613 ConfigCommand::List => {
4614 // Configured truth, not live-session truth (DGF-01): a running
4615 // session keeps the route it resolved at launch, so these values
4616 // must not be read as "what the current session is serving".
4617 // `#` keeps the header safe for `key = value` line parsers.
4618 println!("# configured values ({})", store.path().display());
4619 println!(
4620 "# a running session keeps the route it resolved at launch; `codewhale model resolve` reports the route a new session would take"
4621 );
4622 for (key, value) in store.config.list_values() {
4623 println!("{key} = {value}");
4624 }
4625 Ok(())
4626 }
4627 ConfigCommand::Path => {
4628 println!("{}", store.path().display());
4629 Ok(())
4630 }
4631 ConfigCommand::Edit => {
4632 let path = store.path().to_path_buf();
4633 println!("{}", path.display());
4634 let editor = std::env::var("VISUAL")
4635 .or_else(|_| std::env::var("EDITOR"))
4636 .unwrap_or_else(|_| "vi".to_string());
4637 let status = Command::new(&editor)
4638 .arg(&path)
4639 .status()
4640 .with_context(|| format!("failed to launch editor {editor:?}"))?;
4641 if !status.success() {
4642 bail!("editor {editor:?} exited with {status}");
4643 }
4644 Ok(())
4645 }
4646 ConfigCommand::Doctor => run_config_doctor(store),
4647 ConfigCommand::Dump => {
4648 if !per_run_overrides.is_empty() {
4649 println!(
4650 "# {} per-run --set override(s), not saved",
4651 per_run_overrides.len()
4652 );
4653 }
4654 println!("# {}", store.path().display());
4655 print!(
4656 "{}",
4657 toml::to_string_pretty(&store.config.redacted_toml_value())?
4658 );
4659 Ok(())
4660 }
4661 ConfigCommand::Import(args) => {
4662 let workspace = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
4663 config_bundles::run_import(&args, store, &workspace)
4664 }
4665 ConfigCommand::Export(args) => config_bundles::run_export(&args, store),
4666 }
4667 }
4668
4669 /// Apply per-run `--set KEY=VALUE` overlays to the loaded store in memory.
4670 /// Nothing is saved; callers that persist must refuse overrides first
4671 /// (see `run_config_command`).
4672 fn apply_per_run_overrides(store: &mut ConfigStore, specs: &[String]) -> Result<()> {
4673 for spec in specs {
4674 let (key, value) = spec
4675 .split_once('=')
4676 .with_context(|| format!("invalid --set {spec:?}: expected KEY=VALUE"))?;
4677 store
4678 .config
4679 .set_value(key.trim(), value)
4680 .with_context(|| format!("invalid --set {spec:?}"))?;
4681 }
4682 Ok(())
4683 }
4684
4685 /// Read-only credential and endpoint check. The dispatcher's extras also
4686 /// contain settings owned by runtime readers; they are not unknown keys.
4687 /// Never prints a credential — presence and shape only.
4688 fn run_config_doctor(store: &ConfigStore) -> Result<()> {
4689 println!("# {}", store.path().display());
4690 let mut errors: Vec<String> = Vec::new();
4691 if !store.config.extras.is_empty() {
4692 println!(
4693 "note: additional settings are preserved for runtime readers; this check does not classify their support"
4694 );
4695 }
4696
4697 let mut secrets: Vec<(String, Option<String>)> =
4698 vec![("api_key".to_string(), store.config.api_key.clone())];
4699 let mut endpoints: Vec<(String, Option<String>)> =
4700 vec![("base_url".to_string(), store.config.base_url.clone())];
4701 for provider in ProviderKind::ALL {
4702 let table = store.config.providers.for_provider(provider);
4703 secrets.push((format!("{provider:?}.api_key"), table.api_key.clone()));
4704 endpoints.push((format!("{provider:?}.base_url"), table.base_url.clone()));
4705 }
4706 for (name, secret) in secrets {
4707 if secret
4708 .as_deref()
4709 .is_some_and(|value| value.trim().is_empty())
4710 {
4711 errors.push(format!("`{name}` is set but empty"));
4712 }
4713 }
4714 for (name, endpoint) in endpoints {
4715 if let Some(url) = endpoint.as_deref()
4716 && !url.starts_with("http://")
4717 && !url.starts_with("https://")
4718 {
4719 errors.push(format!("`{name}` is not an http(s) URL: {url}"));
4720 }
4721 }
4722
4723 if !errors.is_empty() {
4724 for error in &errors {
4725 println!("error: {error}");
4726 }
4727 bail!("doctor: {} error(s): {}", errors.len(), errors.join("; "));
4728 }
4729 println!("doctor: credentials and endpoints clean");
4730 Ok(())
4731 }
4732
4733 fn telemetry_preference_status(preference: Option<bool>) -> &'static str {
4734 let (enabled, source) = codewhale_config::resolved_telemetry_consent(preference);
4735 if !enabled {
4736 return match source {
4737 codewhale_config::TelemetrySource::Env => "Off (environment or run kill switch)",
4738 _ => "Off (saved preference)",
4739 };
4740 }
4741 match telemetry::load_setup_state_for_decision() {
4742 Some(state) if state.telemetry_opted_out() => "Off (saved opt-out)",
4743 None => "Off (privacy state unreadable)",
4744 Some(_) => match source {
4745 codewhale_config::TelemetrySource::Default => "On (default)",
4746 codewhale_config::TelemetrySource::Env | codewhale_config::TelemetrySource::Cli => {
4747 "On (environment or run preference)"
4748 }
4749 codewhale_config::TelemetrySource::Config => "On (saved preference)",
4750 },
4751 }
4752 }
4753
4754 fn model_command_provider_hint(
4755 command_provider: Option<ProviderKind>,
4756 top_level_provider: Option<ProviderKind>,
4757 ) -> Option<ProviderKind> {
4758 command_provider.or(top_level_provider)
4759 }
4760
4761 fn provider_source_label(source: ProviderSource) -> String {
4762 match source {
4763 ProviderSource::Cli => "--provider".to_string(),
4764 ProviderSource::Env(name) => format!("environment ({name})"),
4765 ProviderSource::Config => "config".to_string(),
4766 }
4767 }
4768
4769 fn canonical_model_for_set(model: &str) -> &str {
4770 match model.to_ascii_lowercase().as_str() {
4771 "pro" | "deepseek-v4pro" => "deepseek-v4-pro",
4772 "flash" | "deepseek-v4flash" => "deepseek-v4-flash",
4773 "flash-vision" | "deepseek-v4flashvisionexp" => "deepseek-v4-flash-vision-exp",
4774 "auto" => "auto",
4775 _ => model,
4776 }
4777 }
4778
4779 fn run_model_command(
4780 store: &mut ConfigStore,
4781 command: ModelCommand,
4782 top_level_provider: Option<ProviderKind>,
4783 resolved_runtime: &ResolvedRuntimeOptions,
4784 ) -> Result<()> {
4785 let registry = ModelRegistry::default();
4786 match command {
4787 ModelCommand::List { provider } => {
4788 let filter = model_command_provider_hint(provider, top_level_provider);
4789 for model in registry.list().into_iter().filter(|m| match filter {
4790 Some(p) => m.provider == p,
4791 None => true,
4792 }) {
4793 println!("{} ({})", model.id, model.provider.as_str());
4794 }
4795 Ok(())
4796 }
4797 ModelCommand::Resolve { model, provider } => {
4798 // Only `model resolve --provider X` is a hypothetical. The
4799 // top-level `--provider` is the route this process is actually on,
4800 // and it is already folded into `resolved_runtime` — treating it as
4801 // a hypothetical made `codewhale --provider moonshot --model
4802 // kimi-k3 model resolve` re-derive a registry default and report
4803 // `kimi-k2.7-code` while the runtime used `kimi-k3` (v0.9.1 kimi-k3 dogfood report). The
4804 // top-level `--model` was not consulted at all on that path.
4805 let subcommand_provider = provider;
4806 let queried = model.as_deref().map(str::trim).filter(|m| !m.is_empty());
4807
4808 // With no explicit query, this reports the route the runtime would
4809 // actually take — the same answer `doctor` gives — rather than
4810 // re-deriving one from an empty flag set. Re-deriving is what made
4811 // a Z.ai config report `provider: deepseek` (#4832).
4812 if queried.is_none() && subcommand_provider.is_none() {
4813 let saved = if matches!(resolved_runtime.provider_source, ProviderSource::Config)
4814 && !matches!(
4815 resolved_runtime.model_source,
4816 codewhale_config::ModelSource::Cli | codewhale_config::ModelSource::Env
4817 ) {
4818 Some(codewhale_tui::route_preferences::selected_route(
4819 store.path(),
4820 )?)
4821 } else {
4822 None
4823 };
4824 let provider = saved
4825 .as_ref()
4826 .map_or(resolved_runtime.provider.as_str(), |(provider, _, _)| {
4827 provider.as_str()
4828 });
4829 let model = saved
4830 .as_ref()
4831 .map_or(resolved_runtime.model.as_str(), |(_, model, _)| {
4832 model.as_str()
4833 });
4834 let source = saved
4835 .as_ref()
4836 .map_or(resolved_runtime.model_source, |(_, _, source)| *source);
4837 println!(
4838 "requested: {}",
4839 if source.is_explicit() { model } else { "" }
4840 );
4841 println!("resolved: {model}");
4842 println!("provider: {provider}");
4843 println!("used_fallback: {}", !source.is_explicit());
4844 println!(
4845 "provider_source: {}",
4846 provider_source_label(resolved_runtime.provider_source)
4847 );
4848 println!("model_source: {}", source.as_str());
4849 return Ok(());
4850 }
4851
4852 // An explicit model or provider makes this a hypothetical query
4853 // inside a named route. The subcommand provider wins; otherwise
4854 // the configured runtime provider remains authoritative. Model
4855 // text never authorizes switching providers or credential slots.
4856 let provider_hint = subcommand_provider.or(Some(resolved_runtime.provider));
4857 let resolved = registry.resolve(queried, provider_hint)?;
4858 println!("requested: {}", resolved.requested.unwrap_or_default());
4859 println!("resolved: {}", resolved.resolved.id);
4860 println!("provider: {}", resolved.resolved.provider.as_str());
4861 println!("used_fallback: {}", resolved.used_fallback);
4862 println!(
4863 "provider_source: {}",
4864 if subcommand_provider.is_some() {
4865 "--provider".to_string()
4866 } else {
4867 provider_source_label(resolved_runtime.provider_source)
4868 }
4869 );
4870 println!(
4871 "model_source: {}",
4872 if queried.is_some() {
4873 "argument"
4874 } else {
4875 // This branch is reachable only for an explicit
4876 // subcommand provider with no requested model. The model
4877 // therefore came from that provider's default, not from
4878 // the configured runtime route we deliberately overrode.
4879 "provider default"
4880 }
4881 );
4882 Ok(())
4883 }
4884 ModelCommand::Set { model } => {
4885 let trimmed = model.trim();
4886 if trimmed.is_empty() {
4887 bail!("Model name cannot be empty");
4888 }
4889 let canonical = canonical_model_for_set(trimmed);
4890 codewhale_tui::route_preferences::set(store.path(), "model", canonical)?;
4891 store.reload()?;
4892 println!("Default model set to '{canonical}'");
4893 Ok(())
4894 }
4895 }
4896 }
4897
4898 /// The TUI passthrough a thread subcommand delegates as, if it delegates.
4899 ///
4900 /// Exhaustive on purpose: a future `ThreadCommand` variant that starts a
4901 /// session has to state its passthrough here, where the caller below routes it
4902 /// through the one command builder that applies the telemetry floor.
4903 fn thread_delegation(command: &ThreadCommand) -> Option<Vec<String>> {
4904 match command {
4905 ThreadCommand::Resume { thread_id } => Some(vec!["resume".to_string(), thread_id.clone()]),
4906 ThreadCommand::Fork { thread_id } => Some(vec!["fork".to_string(), thread_id.clone()]),
4907 ThreadCommand::List { .. }
4908 | ThreadCommand::Read { .. }
4909 | ThreadCommand::Archive { .. }
4910 | ThreadCommand::Unarchive { .. }
4911 | ThreadCommand::SetName { .. }
4912 | ThreadCommand::ClearName { .. } => None,
4913 }
4914 }
4915
4916 fn run_thread_command(
4917 cli: &Cli,
4918 store: &mut ConfigStore,
4919 runtime_overrides: &CliRuntimeOverrides,
4920 command: ThreadCommand,
4921 ) -> Result<()> {
4922 // `thread resume`/`thread fork` start a full interactive session in the TUI
4923 // binary, so they delegate exactly like the top-level `resume` does —
4924 // through dispatcher, which forwards `--config` and states the
4925 // resolved telemetry value in the child's environment. They used to take a
4926 // bare command invocation that forwarded neither, so a session
4927 // launched this way re-resolved from `$CODEWHALE_HOME/config.toml` with no
4928 // overrides and armed telemetry even when the user had passed
4929 // `--telemetry false` or pointed `--config` at a file that said
4930 // `telemetry = false`.
4931 if let Some(passthrough) = thread_delegation(&command) {
4932 let resolved_runtime = resolve_runtime_for_dispatch(store, runtime_overrides);
4933 return run_tui_in_process(cli, &resolved_runtime, passthrough);
4934 }
4935 let state = StateStore::open(None)?;
4936 match command {
4937 ThreadCommand::List { all, limit } => {
4938 let threads = state.list_threads(ThreadListFilters {
4939 include_archived: all,
4940 limit,
4941 })?;
4942 for thread in threads {
4943 println!(
4944 "{} | {} | {} | {}",
4945 thread.id,
4946 thread
4947 .name
4948 .clone()
4949 .unwrap_or_else(|| "(unnamed)".to_string()),
4950 thread.model_provider,
4951 thread.cwd.display()
4952 );
4953 }
4954 Ok(())
4955 }
4956 ThreadCommand::Read { thread_id } => {
4957 let thread = state.get_thread(&thread_id)?;
4958 println!("{}", serde_json::to_string_pretty(&thread)?);
4959 Ok(())
4960 }
4961 ThreadCommand::Resume { .. } | ThreadCommand::Fork { .. } => {
4962 unreachable!("thread_delegation routes resume and fork before this match")
4963 }
4964 ThreadCommand::Archive { thread_id } => {
4965 state.mark_archived(&thread_id)?;
4966 println!("archived {thread_id}");
4967 Ok(())
4968 }
4969 ThreadCommand::Unarchive { thread_id } => {
4970 state.mark_unarchived(&thread_id)?;
4971 println!("unarchived {thread_id}");
4972 Ok(())
4973 }
4974 ThreadCommand::SetName { thread_id, name } => {
4975 let mut thread = state
4976 .get_thread(&thread_id)?
4977 .with_context(|| format!("thread not found: {thread_id}"))?;
4978 thread.name = Some(name);
4979 thread.updated_at = chrono::Utc::now().timestamp();
4980 state.upsert_thread(&thread)?;
4981 println!("renamed {thread_id}");
4982 Ok(())
4983 }
4984 ThreadCommand::ClearName { thread_id } => {
4985 let mut thread = state
4986 .get_thread(&thread_id)?
4987 .with_context(|| format!("thread not found: {thread_id}"))?;
4988 thread.name = None;
4989 thread.updated_at = chrono::Utc::now().timestamp();
4990 state.upsert_thread(&thread)?;
4991 println!("cleared name for {thread_id}");
4992 Ok(())
4993 }
4994 }
4995 }
4996
4997 fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
4998 match command {
4999 SandboxCommand::Check { command, ask } => {
5000 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
5001 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
5002 let decision = engine.check(ExecPolicyContext {
5003 command: &command,
5004 cwd: &cwd.display().to_string(),
5005 tool: Some("exec_shell"),
5006 path: None,
5007 ask_for_approval: ask.into(),
5008 sandbox_mode: Some("workspace-write"),
5009 })?;
5010 println!("{}", serde_json::to_string_pretty(&decision)?);
5011 Ok(())
5012 }
5013 }
5014 }
5015
5016 fn run_app_server_command(
5017 cli: &Cli,
5018 resolved_runtime: &ResolvedRuntimeOptions,
5019 args: AppServerArgs,
5020 ) -> Result<()> {
5021 // The full runtime API lives in the TUI crate behind `serve --http`/`--mobile`.
5022 // Rather than duplicate ~6.5k lines or add a CLI→TUI crate dependency, the
5023 // canonical `app-server --http`/`--mobile` entrypoint reuses that mature server
5024 // by delegating to the sibling TUI binary (the same mechanism `serve` uses).
5025 if args.http || args.mobile {
5026 // Delegated runtime API listener — supervise it so the child does not
5027 // outlive the dispatcher (#3259).
5028 return run_tui_server_in_process(
5029 cli,
5030 resolved_runtime,
5031 app_server_serve_passthrough(&args),
5032 );
5033 }
5034
5035 // Everything below runs the app-server *in this process*, which is why the
5036 // surface cannot be derived from the executable: `current_exe()` would
5037 // report every one of these sessions as `cli`.
5038 let session = start_cli_telemetry(
5039 resolved_runtime,
5040 args.config.clone().or_else(|| cli.config.clone()),
5041 Surface::AppServer,
5042 );
5043
5044 let runtime = match tokio::runtime::Builder::new_multi_thread()
5045 .enable_all()
5046 .build()
5047 .context("failed to create tokio runtime")
5048 {
5049 Ok(runtime) => runtime,
5050 Err(error) => {
5051 let outcome = Err(error);
5052 finish_cli_telemetry(session, &outcome);
5053 return outcome;
5054 }
5055 };
5056 if args.stdio {
5057 let outcome = runtime.block_on(run_app_server_stdio(args.config));
5058 finish_cli_telemetry(session, &outcome);
5059 return outcome;
5060 }
5061 if args.socket {
5062 let outcome = runtime.block_on(run_daemon_socket(DaemonSocketOptions {
5063 socket_path: args.socket_path,
5064 config_path: args.config,
5065 }));
5066 finish_cli_telemetry(session, &outcome);
5067 return outcome;
5068 }
5069 // Legacy in-process app-server HTTP transport (`/healthz`, `/thread`, `/app`,
5070 // `/prompt`, `/tool`, `/jobs`). Kept for backward compatibility; defaults to
5071 // 127.0.0.1:8787 to avoid colliding with the runtime API default of :7878.
5072 // `/prompt` and `/thread` messages are not served locally: they run a real
5073 // turn by bridging to a runtime API child, and fail with an explicit
5074 // `runtime_unavailable` when one cannot be started.
5075 let host = args.host.as_deref().unwrap_or("127.0.0.1");
5076 let port = args.port.unwrap_or(8787);
5077 let outcome = format!("{host}:{port}")
5078 .parse::<SocketAddr>()
5079 .with_context(|| format!("invalid app-server listen address {host}:{port}"))
5080 .and_then(|listen| {
5081 runtime.block_on(run_app_server(AppServerOptions {
5082 listen,
5083 config_path: args.config,
5084 auth_token: args.auth_token.or_else(app_server_token_from_env),
5085 insecure_no_auth: args.insecure_no_auth,
5086 cors_origins: args.cors_origin,
5087 }))
5088 });
5089 finish_cli_telemetry(session, &outcome);
5090 outcome
5091 }
5092
5093 /// Build the `serve` argv forwarded to the TUI binary for
5094 /// `codewhale app-server --http`/`--mobile`. Maps app-server flags onto the
5095 /// matching `serve` flags (note `--insecure-no-auth` → `--insecure`). The
5096 /// subcommand-level `--config` is bridged through the global `--config` in the
5097 /// dispatcher, so it is intentionally not part of this passthrough. An auth
5098 /// token from the environment is deliberately *not* forwarded into child argv;
5099 /// the runtime API reads CODEWHALE_RUNTIME_TOKEN/DEEPSEEK_RUNTIME_TOKEN itself.
5100 fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
5101 let mut forwarded = vec!["serve".to_string()];
5102 forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
5103 if let Some(host) = args.host.as_ref() {
5104 forwarded.push("--host".to_string());
5105 forwarded.push(host.clone());
5106 }
5107 if let Some(port) = args.port {
5108 forwarded.push("--port".to_string());
5109 forwarded.push(port.to_string());
5110 }
5111 if let Some(workers) = args.workers {
5112 forwarded.push("--workers".to_string());
5113 forwarded.push(workers.to_string());
5114 }
5115 for origin in &args.cors_origin {
5116 forwarded.push("--cors-origin".to_string());
5117 forwarded.push(origin.clone());
5118 }
5119 if let Some(token) = args.auth_token.as_ref() {
5120 forwarded.push("--auth-token".to_string());
5121 forwarded.push(token.clone());
5122 }
5123 if args.insecure_no_auth {
5124 forwarded.push("--insecure".to_string());
5125 }
5126 if args.qr {
5127 forwarded.push("--qr".to_string());
5128 }
5129 forwarded
5130 }
5131
5132 fn web_serve_passthrough(args: &WebArgs) -> Vec<String> {
5133 vec![
5134 "serve".to_string(),
5135 "--web".to_string(),
5136 "--port".to_string(),
5137 args.port.to_string(),
5138 ]
5139 }
5140
5141 fn app_server_token_from_env() -> Option<String> {
5142 std::env::var("CODEWHALE_APP_SERVER_TOKEN")
5143 .ok()
5144 .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
5145 }
5146
5147 fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
5148 let persisted = load_mcp_server_definitions(store);
5149 let updated = run_stdio_server(persisted)?;
5150 persist_mcp_server_definitions(store, &updated)
5151 }
5152
5153 fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
5154 // `get_raw_string` first: `get_value` re-renders the extras entry as TOML,
5155 // which quotes a JSON payload into `'[{"config":…}]'` and makes it
5156 // unparseable — so every persisted definition was silently dropped and
5157 // `mcp-server` started with an empty server list (#4727). `get_value`
5158 // remains as the fallback for keys that are not plain extras strings.
5159 let raw = store
5160 .config
5161 .get_raw_string(MCP_SERVER_DEFINITIONS_KEY)
5162 .map(ToOwned::to_owned)
5163 .or_else(|| store.config.get_value(MCP_SERVER_DEFINITIONS_KEY));
5164 let Some(raw) = raw else {
5165 return Vec::new();
5166 };
5167
5168 match parse_mcp_server_definitions(&raw) {
5169 Ok(definitions) => definitions,
5170 Err(err) => {
5171 eprintln!(
5172 "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
5173 );
5174 Vec::new()
5175 }
5176 }
5177 }
5178
5179 fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
5180 if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
5181 return Ok(parsed);
5182 }
5183
5184 let unwrapped: String = serde_json::from_str(raw).map_err(|_| {
5185 anyhow!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted")
5186 })?;
5187 serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).map_err(|_| {
5188 anyhow!(
5189 "invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted"
5190 )
5191 })
5192 }
5193
5194 fn persist_mcp_server_definitions(
5195 store: &mut ConfigStore,
5196 definitions: &[McpServerDefinition],
5197 ) -> Result<()> {
5198 let encoded =
5199 serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
5200 store
5201 .config
5202 .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
5203 store.save()
5204 }
5205
5206 /// Delegate a long-running server command (`serve --http`/`--mobile`,
5207 /// `app-server --http`/`--mobile`) to the sibling TUI binary, supervising the
5208 /// child so its listener does not outlive the dispatcher (#3259).
5209 ///
5210 /// Plain [`run_tui_in_process`] blocks on `Command::status()`, which reaps the
5211 /// child only on the child's own exit. If the dispatcher is terminated while
5212 /// the delegated server is still running, the child can be reparented and keep
5213 /// its listener bound. Here the child runs under a Tokio supervisor that
5214 /// forwards termination (Ctrl+C / SIGTERM / SIGHUP) by killing and reaping the
5215 /// child before the dispatcher exits, and `kill_on_drop` tears the child down
5216 /// if the dispatcher unwinds.
5217 ///
5218 /// For an *uncatchable* dispatcher death (SIGKILL, a hard crash) the Tokio
5219 /// supervisor above can't run, so two OS-level safety nets are installed as
5220 /// well (#3259): on Linux the child sets `PR_SET_PDEATHSIG` so the kernel
5221 /// signals it when the dispatcher dies; on Windows the child is placed in a
5222 /// kill-on-job-close Job Object so closing the dispatcher's handle (which the
5223 /// OS does on process death) terminates it. macOS has no equivalent primitive,
5224 /// so an uncatchable dispatcher death there can still orphan the child.
5225
5226 /// On Linux, ask the kernel to terminate the delegated server if the dispatcher
5227 /// dies before it can run the graceful shutdown supervisor. This covers the
5228 /// hard parent-death edge of #3259 for `SIGKILL`, OOM, or abrupt process exit.
5229 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
5230 #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
5231
5232 /// Outcome of supervising a delegated server child.
5233 #[derive(Debug)]
5234
5235 /// Wait for the server `child` to exit, or for `shutdown` to fire first. On
5236 /// shutdown, kill the child and reap it so no listener is left reparented.
5237
5238 /// Resolve when the dispatcher should tear down a delegated server child, and
5239 /// the conventional `128 + signal` exit code to propagate: Ctrl+C on every
5240 /// platform (130), plus SIGTERM (143) and SIGHUP (129) on Unix.
5241 #[cfg(unix)]
5242 #[cfg(not(unix))]
5243
5244 /// Assign the delegated server `child` to a kill-on-job-close Job Object so the
5245 /// OS terminates it when the dispatcher's handle to the job closes — which it
5246 /// does on any dispatcher exit, including an uncatchable kill (#3259). The
5247 /// returned guard must be held for the dispatcher's lifetime. Best-effort:
5248 /// returns `None` if the job cannot be created or assigned. Mirrors the Job
5249 /// Object idiom in `crates/tui/src/tools/shell.rs`.
5250 #[cfg(windows)]
5251 #[cfg(windows)]
5252 // SAFETY: the wrapped value is a process-wide kernel handle; moving it across
5253 // threads does not invalidate it, and it is only ever closed once, on drop.
5254 #[cfg(windows)]
5255 unsafe impl Send for ServerChildJob {}
5256
5257 fn run_resume_command(
5258 cli: &Cli,
5259 resolved_runtime: &ResolvedRuntimeOptions,
5260 args: TuiPassthroughArgs,
5261 ) -> Result<()> {
5262 let passthrough = tui_args("resume", args);
5263 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
5264 return run_dispatcher_resume_picker(cli, resolved_runtime);
5265 }
5266 run_tui_in_process(cli, resolved_runtime, passthrough)
5267 }
5268
5269 fn run_dispatcher_resume_picker(
5270 cli: &Cli,
5271 resolved_runtime: &ResolvedRuntimeOptions,
5272 ) -> Result<()> {
5273 let argv = tui_argv(cli, vec!["sessions".to_string()]);
5274 apply_tui_env(cli, resolved_runtime, &argv);
5275 let code = codewhale_tui::run(argv);
5276 if code != std::process::ExitCode::SUCCESS {
5277 std::process::exit(if code == std::process::ExitCode::SUCCESS {
5278 0
5279 } else {
5280 1
5281 })
5282 }
5283
5284 println!();
5285 println!("Windows note: enter a session id or prefix from the list above.");
5286 println!("You can also run `codewhale resume --last` to skip this prompt.");
5287 print!("Session id/prefix (Enter to cancel): ");
5288 io::stdout().flush()?;
5289
5290 let mut input = String::new();
5291 io::stdin()
5292 .read_line(&mut input)
5293 .context("failed to read session selection")?;
5294 let session_id = input.trim();
5295 if session_id.is_empty() {
5296 bail!("No session selected.");
5297 }
5298
5299 run_tui_in_process(
5300 cli,
5301 resolved_runtime,
5302 vec!["resume".to_string(), session_id.to_string()],
5303 )
5304 }
5305
5306 fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
5307 is_windows && passthrough == ["resume"]
5308 }
5309
5310 fn run_tui_in_process(
5311 cli: &Cli,
5312 resolved_runtime: &ResolvedRuntimeOptions,
5313 passthrough: Vec<String>,
5314 ) -> Result<()> {
5315 let argv = tui_argv(cli, passthrough.clone());
5316 apply_tui_env(cli, resolved_runtime, &passthrough);
5317 let code = codewhale_tui::run(argv);
5318 std::process::exit(if code == std::process::ExitCode::SUCCESS {
5319 0
5320 } else {
5321 1
5322 })
5323 }
5324
5325 fn run_tui_server_in_process(
5326 cli: &Cli,
5327 resolved_runtime: &ResolvedRuntimeOptions,
5328 passthrough: Vec<String>,
5329 ) -> Result<()> {
5330 let argv = tui_argv(cli, passthrough.clone());
5331 apply_tui_env(cli, resolved_runtime, &passthrough);
5332 let code = codewhale_tui::run(argv);
5333 std::process::exit(if code == std::process::ExitCode::SUCCESS {
5334 0
5335 } else {
5336 1
5337 })
5338 }
5339
5340 fn tui_argv(cli: &Cli, passthrough: Vec<String>) -> Vec<String> {
5341 let mut args = Vec::new();
5342 args.push("codewhale".to_string());
5343 if let Some(config) = cli.config.as_deref() {
5344 args.push("--config".to_string());
5345 args.push(config.display().to_string());
5346 }
5347 if let Some(profile) = cli.profile.as_ref() {
5348 args.push("--profile".to_string());
5349 args.push(profile.clone());
5350 }
5351 if let Some(workspace) = cli.workspace.as_deref() {
5352 args.push("--workspace".to_string());
5353 args.push(workspace.display().to_string());
5354 }
5355 if cli.mouse_capture {
5356 args.push("--mouse-capture".to_string());
5357 }
5358 if cli.no_mouse_capture {
5359 args.push("--no-mouse-capture".to_string());
5360 }
5361 if cli.skip_onboarding {
5362 args.push("--skip-onboarding".to_string());
5363 }
5364 if cli.fresh {
5365 args.push("--fresh".to_string());
5366 }
5367 if cli.no_project_config {
5368 args.push("--no-project-config".to_string());
5369 }
5370 args.extend(passthrough);
5371 args
5372 }
5373
5374 /// Set one process environment variable for the CLI-to-TUI bridge.
5375 ///
5376 /// Callers must guarantee no concurrent environment access: production
5377 /// callers run pre-runtime on the main thread, and tests serialize on the
5378 /// shared env lock. All current callers are inside [`apply_tui_env`].
5379 fn set_tui_env(key: impl AsRef<std::ffi::OsStr>, value: impl AsRef<std::ffi::OsStr>) {
5380 // SAFETY: no concurrent environment access. Production setters run on
5381 // the main thread before the TUI runtime starts, and the only other
5382 // thread that may be alive is the detached telemetry writer, which
5383 // never reads or writes the process environment. Tests serialize on
5384 // the shared env lock instead.
5385 unsafe {
5386 std::env::set_var(key, value);
5387 }
5388 }
5389
5390 fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrough: &[String]) {
5391 let mut verbosity = if cli.profile.is_some() {
5392 cli.verbosity.clone()
5393 } else {
5394 resolved_runtime.verbosity.clone()
5395 };
5396 if verbosity.is_none()
5397 && passthrough
5398 .iter()
5399 .any(|arg| matches!(arg.as_str(), "exec" | "eval"))
5400 {
5401 verbosity = Some("concise".to_string());
5402 }
5403 let uses_raw_tui_provider = cli
5404 .provider
5405 .as_deref()
5406 .is_some_and(|provider| builtin_provider_arg(provider).is_none());
5407 let keyring_bridge_provider = resolved_runtime.provider;
5408 let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
5409 let keyring_bridge_source = resolved_runtime.api_key_source;
5410 if let Some(provider) = cli.provider.as_deref() {
5411 let provider = builtin_provider_arg(provider).map_or_else(
5412 || provider.to_string(),
5413 |provider| provider.as_str().to_string(),
5414 );
5415 set_tui_env("CODEWHALE_PROVIDER", &provider);
5416 set_tui_env("DEEPSEEK_PROVIDER", provider);
5417 }
5418 if !(uses_raw_tui_provider
5419 || (cli.profile.is_some()
5420 && matches!(resolved_runtime.provider_source, ProviderSource::Config)))
5421 && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
5422 && let Some(api_key) = keyring_bridge_api_key
5423 {
5424 for var in provider_env_vars(keyring_bridge_provider) {
5425 set_tui_env(var, api_key);
5426 }
5427 set_tui_env(
5428 codewhale_config::CLI_API_KEY_SOURCE_ENV,
5429 RuntimeApiKeySource::Keyring.as_env_value(),
5430 );
5431 }
5432 if let Some(model) = cli.model.as_ref() {
5433 set_tui_env("CODEWHALE_MODEL", model);
5434 set_tui_env("DEEPSEEK_MODEL", model);
5435 }
5436 if let Some(output_mode) = cli.output_mode.as_ref() {
5437 set_tui_env("CODEWHALE_OUTPUT_MODE", output_mode);
5438 set_tui_env("DEEPSEEK_OUTPUT_MODE", output_mode);
5439 }
5440 if let Some(v) = verbosity.as_ref() {
5441 set_tui_env("CODEWHALE_VERBOSITY", v);
5442 set_tui_env("DEEPSEEK_VERBOSITY", v);
5443 }
5444 if let Some(log_level) = cli.log_level.as_ref() {
5445 set_tui_env("CODEWHALE_LOG_LEVEL", log_level);
5446 set_tui_env("DEEPSEEK_LOG_LEVEL", log_level);
5447 }
5448 let telemetry = resolved_runtime.telemetry.to_string();
5449 set_tui_env("CODEWHALE_TELEMETRY", &telemetry);
5450 set_tui_env("DEEPSEEK_TELEMETRY", &telemetry);
5451 let floor = cli.telemetry == Some(false) || codewhale_config::telemetry_floor_in_force();
5452 set_tui_env(
5453 codewhale_config::TELEMETRY_FLOOR_ENV,
5454 if floor { "1" } else { "0" },
5455 );
5456 if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
5457 set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
5458 set_tui_env("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint);
5459 }
5460 if let Some(policy) = cli.approval_policy.as_ref() {
5461 set_tui_env("CODEWHALE_APPROVAL_POLICY", policy);
5462 set_tui_env("DEEPSEEK_APPROVAL_POLICY", policy);
5463 }
5464 if let Some(mode) = cli.sandbox_mode.as_ref() {
5465 set_tui_env("CODEWHALE_SANDBOX_MODE", mode);
5466 set_tui_env("DEEPSEEK_SANDBOX_MODE", mode);
5467 }
5468 if cli.yolo {
5469 set_tui_env("CODEWHALE_YOLO", "true");
5470 }
5471 if let Some(api_key) = cli.api_key.as_ref() {
5472 set_tui_env(codewhale_config::CLI_API_KEY_ENV, api_key);
5473 if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
5474 for var in provider_env_vars(resolved_runtime.provider) {
5475 set_tui_env(var, api_key);
5476 }
5477 }
5478 set_tui_env(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli");
5479 }
5480 if let Some(base_url) = cli.base_url.as_ref() {
5481 set_tui_env("CODEWHALE_BASE_URL", base_url);
5482 set_tui_env("DEEPSEEK_BASE_URL", base_url);
5483 }
5484 }
5485
5486 // There is deliberately no "just run the TUI with these args" helper here. One
5487 // existed, `thread resume`/`thread fork` used it, and it forwarded neither
5488 // `--config` nor the resolved telemetry value — so the kill switch the
5489 // dispatcher had already applied never reached the process that emits. Every
5490 // delegation is now in-process, and
5491 // `only_one_function_may_locate_and_spawn_the_tui` pins that.
5492
5493 fn run_providers_command(args: ProvidersArgs) -> Result<()> {
5494 match args.command {
5495 ProvidersCommand::Export { json } => {
5496 if !json {
5497 bail!("`codewhale providers export` requires `--json`");
5498 }
5499 let export = ProvidersExport::from_registry(env!("CODEWHALE_BUILD_VERSION"));
5500 serde_json::to_writer_pretty(io::stdout(), &export)
5501 .context("failed to write providers export")?;
5502 println!();
5503 Ok(())
5504 }
5505 }
5506 }
5507
5508 fn run_metrics_command(args: MetricsArgs) -> Result<()> {
5509 let since = match args.since.as_deref() {
5510 Some(s) => {
5511 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
5512 }
5513 None => None,
5514 };
5515 metrics::run(metrics::MetricsArgs {
5516 json: args.json,
5517 since,
5518 })
5519 }
5520
5521 /// Maximum bytes read for an API key on stdin. Keys are short; anything
5522 /// larger is a piped file, not a key.
5523 const MAX_STDIN_API_KEY_BYTES: u64 = 8 * 1024;
5524
5525 fn read_api_key_from_stdin() -> Result<String> {
5526 let mut input = String::new();
5527 io::stdin()
5528 .take(MAX_STDIN_API_KEY_BYTES + 1)
5529 .read_to_string(&mut input)
5530 .context("failed to read api key from stdin")?;
5531 if input.len() as u64 > MAX_STDIN_API_KEY_BYTES {
5532 bail!("API key on stdin exceeds the 8 KiB limit");
5533 }
5534 let key = input.trim().to_string();
5535 if key.is_empty() {
5536 bail!("empty API key provided");
5537 }
5538 Ok(key)
5539 }
5540
5541 #[cfg(test)]
5542 mod tests {
5543 use super::*;
5544 use clap::error::ErrorKind;
5545 use codewhale_config::{ModelSource, ProviderSource};
5546 use std::ffi::OsString;
5547 use std::sync::{Mutex, OnceLock};
5548
5549 fn parse_ok(argv: &[&str]) -> Cli {
5550 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
5551 }
5552
5553 fn help_for(argv: &[&str]) -> String {
5554 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
5555 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
5556 err.to_string()
5557 }
5558
5559 pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
5560 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5561 LOCK.get_or_init(|| Mutex::new(()))
5562 .lock()
5563 .unwrap_or_else(|p| p.into_inner())
5564 }
5565
5566 pub(crate) struct ScopedEnvVar {
5567 name: &'static str,
5568 previous: Option<OsString>,
5569 }
5570
5571 impl ScopedEnvVar {
5572 pub(crate) fn set(name: &'static str, value: &str) -> Self {
5573 let previous = std::env::var_os(name);
5574 // Safety: tests using this helper serialize with env_lock() and
5575 // restore the original value in Drop.
5576 unsafe { std::env::set_var(name, value) };
5577 Self { name, previous }
5578 }
5579
5580 pub(crate) fn remove(name: &'static str) -> Self {
5581 let previous = std::env::var_os(name);
5582 // Safety: tests using this helper serialize with env_lock() and
5583 // restore the original value in Drop.
5584 unsafe { std::env::remove_var(name) };
5585 Self { name, previous }
5586 }
5587 }
5588
5589 impl Drop for ScopedEnvVar {
5590 fn drop(&mut self) {
5591 // Safety: tests using this helper serialize with env_lock().
5592 unsafe {
5593 if let Some(previous) = self.previous.take() {
5594 std::env::set_var(self.name, previous.clone());
5595 } else {
5596 std::env::remove_var(self.name);
5597 }
5598 }
5599 }
5600 }
5601
5602 #[derive(Default)]
5603 struct RecordingKeyringStore {
5604 gets: Mutex<Vec<String>>,
5605 values: Mutex<std::collections::BTreeMap<String, String>>,
5606 }
5607
5608 impl RecordingKeyringStore {
5609 fn set_value(&self, key: &str, value: &str) {
5610 self.values
5611 .lock()
5612 .expect("recording values lock")
5613 .insert(key.to_string(), value.to_string());
5614 }
5615
5616 fn queried(&self) -> Vec<String> {
5617 self.gets.lock().expect("recording gets lock").clone()
5618 }
5619 }
5620
5621 impl codewhale_secrets::KeyringStore for RecordingKeyringStore {
5622 fn get(
5623 &self,
5624 key: &str,
5625 ) -> std::result::Result<Option<String>, codewhale_secrets::SecretsError> {
5626 self.gets
5627 .lock()
5628 .expect("recording gets lock")
5629 .push(key.to_string());
5630 Ok(self
5631 .values
5632 .lock()
5633 .expect("recording values lock")
5634 .get(key)
5635 .cloned())
5636 }
5637
5638 fn set(
5639 &self,
5640 key: &str,
5641 value: &str,
5642 ) -> std::result::Result<(), codewhale_secrets::SecretsError> {
5643 self.set_value(key, value);
5644 Ok(())
5645 }
5646
5647 fn delete(&self, key: &str) -> std::result::Result<(), codewhale_secrets::SecretsError> {
5648 self.values
5649 .lock()
5650 .expect("recording values lock")
5651 .remove(key);
5652 Ok(())
5653 }
5654
5655 fn backend_name(&self) -> &'static str {
5656 "recording"
5657 }
5658 }
5659
5660 fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
5661 let dir = tempfile::TempDir::new().expect("tempdir");
5662 let custom = dir
5663 .path()
5664 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5665 std::fs::write(&custom, b"").unwrap();
5666 let custom_str = custom.to_string_lossy();
5667 let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5668 (dir, bin)
5669 }
5670
5671 fn resolved_runtime_for_test(
5672 provider: ProviderKind,
5673 provider_source: ProviderSource,
5674 ) -> ResolvedRuntimeOptions {
5675 ResolvedRuntimeOptions {
5676 provider,
5677 provider_source,
5678 model: "test-model".to_string(),
5679 model_source: ModelSource::ProviderDefault,
5680 api_key: None,
5681 api_key_source: None,
5682 base_url: "http://localhost:8000/v1".to_string(),
5683 auth_mode: None,
5684 insecure_skip_tls_verify: false,
5685 output_mode: None,
5686 log_level: None,
5687 telemetry: false,
5688 telemetry_source: codewhale_config::TelemetrySource::Default,
5689 telemetry_explicit_off: false,
5690 telemetry_endpoint: None,
5691 approval_policy: None,
5692 sandbox_mode: None,
5693 yolo: None,
5694 verbosity: None,
5695 http_headers: std::collections::BTreeMap::new(),
5696 route: None,
5697 }
5698 }
5699
5700 #[test]
5701 fn tui_credential_handoff_stays_with_the_selected_provider() {
5702 let _lock = env_lock();
5703 let mut names = ProviderKind::ALL
5704 .into_iter()
5705 .flat_map(provider_env_vars)
5706 .copied()
5707 .collect::<Vec<_>>();
5708 names.extend([
5709 codewhale_config::CLI_API_KEY_ENV,
5710 codewhale_config::CLI_API_KEY_SOURCE_ENV,
5711 codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV,
5712 "CODEWHALE_PROVIDER",
5713 "DEEPSEEK_PROVIDER",
5714 "CODEWHALE_TELEMETRY",
5715 "DEEPSEEK_TELEMETRY",
5716 codewhale_config::TELEMETRY_FLOOR_ENV,
5717 ]);
5718 names.sort_unstable();
5719 names.dedup();
5720 let _clean_env = names
5721 .into_iter()
5722 .map(ScopedEnvVar::remove)
5723 .collect::<Vec<_>>();
5724 let _deepseek_key = ScopedEnvVar::set("DEEPSEEK_API_KEY", "existing-deepseek-key");
5725
5726 let clear_bridge = || {
5727 // Safety: this test holds env_lock() and the guards above restore
5728 // every touched variable.
5729 unsafe {
5730 for var in ProviderKind::ALL
5731 .into_iter()
5732 .flat_map(provider_env_vars)
5733 .filter(|var| **var != "DEEPSEEK_API_KEY")
5734 {
5735 std::env::remove_var(var);
5736 }
5737 std::env::remove_var(codewhale_config::CLI_API_KEY_ENV);
5738 std::env::remove_var(codewhale_config::CLI_API_KEY_SOURCE_ENV);
5739 std::env::remove_var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV);
5740 }
5741 };
5742
5743 for (provider_arg, provider) in [
5744 ("nvidia-nim", ProviderKind::NvidiaNim),
5745 ("openrouter", ProviderKind::Openrouter),
5746 ("anthropic", ProviderKind::Anthropic),
5747 ] {
5748 clear_bridge();
5749 let keyring_key = format!("{provider_arg}-keyring-key");
5750 let mut keyring_runtime = resolved_runtime_for_test(provider, ProviderSource::Cli);
5751 keyring_runtime.api_key = Some(keyring_key.clone());
5752 keyring_runtime.api_key_source = Some(RuntimeApiKeySource::Keyring);
5753 let keyring_cli = parse_ok(&["codewhale", "--provider", provider_arg]);
5754
5755 apply_tui_env(&keyring_cli, &keyring_runtime, &[]);
5756
5757 assert_eq!(
5758 std::env::var("DEEPSEEK_API_KEY").as_deref(),
5759 Ok("existing-deepseek-key"),
5760 "{provider_arg} keyring handoff replaced DeepSeek's credential"
5761 );
5762 for var in provider_env_vars(provider) {
5763 assert_eq!(
5764 std::env::var(var).as_deref(),
5765 Ok(keyring_key.as_str()),
5766 "{provider_arg} keyring handoff missed {var}"
5767 );
5768 }
5769 assert_eq!(
5770 std::env::var(codewhale_config::CLI_API_KEY_SOURCE_ENV).as_deref(),
5771 Ok("keyring")
5772 );
5773 assert!(std::env::var(codewhale_config::CLI_API_KEY_ENV).is_err());
5774 assert!(
5775 std::env::var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV).is_err(),
5776 "new dispatchers must not write the retired vendor-named marker"
5777 );
5778
5779 clear_bridge();
5780 let explicit_key = format!("{provider_arg}-explicit-key");
5781 let explicit_cli = parse_ok(&[
5782 "codewhale",
5783 "--provider",
5784 provider_arg,
5785 "--api-key",
5786 explicit_key.as_str(),
5787 ]);
5788 let explicit_runtime = resolved_runtime_for_test(provider, ProviderSource::Cli);
5789
5790 apply_tui_env(&explicit_cli, &explicit_runtime, &[]);
5791
5792 assert_eq!(
5793 std::env::var("DEEPSEEK_API_KEY").as_deref(),
5794 Ok("existing-deepseek-key"),
5795 "{provider_arg} explicit CLI credential handoff replaced DeepSeek's credential"
5796 );
5797 for var in provider_env_vars(provider) {
5798 assert_eq!(
5799 std::env::var(var).as_deref(),
5800 Ok(explicit_key.as_str()),
5801 "{provider_arg} explicit CLI credential handoff missed {var}"
5802 );
5803 }
5804 assert_eq!(
5805 std::env::var(codewhale_config::CLI_API_KEY_ENV).as_deref(),
5806 Ok(explicit_key.as_str())
5807 );
5808 assert_eq!(
5809 std::env::var(codewhale_config::CLI_API_KEY_SOURCE_ENV).as_deref(),
5810 Ok("cli")
5811 );
5812 assert!(std::env::var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV).is_err());
5813 }
5814 }
5815
5816 #[test]
5817 fn yolo_flag_writes_only_the_codewhale_env_var() {
5818 let _lock = env_lock();
5819 let _guards = [
5820 ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
5821 ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
5822 ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
5823 ScopedEnvVar::remove("CODEWHALE_YOLO"),
5824 ScopedEnvVar::remove("DEEPSEEK_YOLO"),
5825 ];
5826
5827 let cli = parse_ok(&["codewhale", "--yolo"]);
5828 let runtime = resolved_runtime_for_test(ProviderKind::NvidiaNim, ProviderSource::Cli);
5829 apply_tui_env(&cli, &runtime, &[]);
5830
5831 assert_eq!(
5832 std::env::var("CODEWHALE_YOLO").as_deref(),
5833 Ok("true"),
5834 "--yolo must still enable the posture via CODEWHALE_YOLO"
5835 );
5836 assert!(
5837 std::env::var("DEEPSEEK_YOLO").is_err(),
5838 "--yolo must not write the retired DEEPSEEK_YOLO alias (#5443)"
5839 );
5840 }
5841
5842 #[test]
5843 fn clap_command_definition_is_consistent() {
5844 Cli::command().debug_assert();
5845 }
5846
5847 // Regression for #767: `run_cli` prints the full anyhow chain so users
5848 // see the underlying TOML parser error (line/column, expected token)
5849 // instead of just the top-level "failed to parse config at <path>"
5850 // wrapper. anyhow's bare `Display` impl drops the chain — pin both
5851 // pieces here so a future refactor of the printing path doesn't
5852 // silently regress.
5853 #[test]
5854 fn anyhow_chain_surfaces_toml_parse_cause() {
5855 use anyhow::Context;
5856 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
5857 let err = Err::<(), _>(inner)
5858 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
5859 .unwrap_err();
5860
5861 // What `eprintln!("error: {err}")` prints (top context only).
5862 assert_eq!(
5863 err.to_string(),
5864 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
5865 );
5866
5867 // What the `for cause in err.chain().skip(1)` loop iterates over.
5868 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
5869 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
5870 }
5871
5872 #[test]
5873 fn malformed_persisted_mcp_json_omits_secret_contents_and_keys() {
5874 let secret = "sentinel";
5875 let raw =
5876 format!(r#"[{{"name":"private","env":{{"PRIVATE_TOKEN":"{secret}"}} trailing-junk}}]"#);
5877 let error = parse_mcp_server_definitions(&raw).expect_err("malformed JSON must fail");
5878 let diagnostic = format!("{error:#}");
5879 assert!(!diagnostic.contains(secret), "{diagnostic}");
5880 assert!(!diagnostic.contains("PRIVATE_TOKEN"), "{diagnostic}");
5881 assert!(diagnostic.contains("contents were omitted"), "{diagnostic}");
5882 }
5883
5884 #[test]
5885 fn parses_config_command_matrix() {
5886 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
5887 assert!(matches!(
5888 cli.command,
5889 Some(Commands::Config(ConfigArgs {
5890 command: ConfigCommand::Get { ref key }
5891 })) if key == "provider"
5892 ));
5893
5894 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
5895 assert!(matches!(
5896 cli.command,
5897 Some(Commands::Config(ConfigArgs {
5898 command: ConfigCommand::Set { ref key, ref value }
5899 })) if key == "model" && value == "deepseek-v4-flash"
5900 ));
5901
5902 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
5903 assert!(matches!(
5904 cli.command,
5905 Some(Commands::Config(ConfigArgs {
5906 command: ConfigCommand::Unset { ref key }
5907 })) if key == "model"
5908 ));
5909
5910 assert!(matches!(
5911 parse_ok(&["deepseek", "config", "list"]).command,
5912 Some(Commands::Config(ConfigArgs {
5913 command: ConfigCommand::List
5914 }))
5915 ));
5916 assert!(matches!(
5917 parse_ok(&["deepseek", "config", "path"]).command,
5918 Some(Commands::Config(ConfigArgs {
5919 command: ConfigCommand::Path
5920 }))
5921 ));
5922 assert!(matches!(
5923 parse_ok(&["codewhale", "config", "edit"]).command,
5924 Some(Commands::Config(ConfigArgs {
5925 command: ConfigCommand::Edit
5926 }))
5927 ));
5928 assert!(matches!(
5929 parse_ok(&["codewhale", "config", "doctor"]).command,
5930 Some(Commands::Config(ConfigArgs {
5931 command: ConfigCommand::Doctor
5932 }))
5933 ));
5934 assert!(matches!(
5935 parse_ok(&["codewhale", "config", "dump"]).command,
5936 Some(Commands::Config(ConfigArgs {
5937 command: ConfigCommand::Dump
5938 }))
5939 ));
5940 }
5941
5942 #[test]
5943 fn parses_repeatable_global_set_overrides() {
5944 let cli = parse_ok(&[
5945 "codewhale",
5946 "--set",
5947 "verbosity=concise",
5948 "--set",
5949 "model=deepseek-v4-flash",
5950 "config",
5951 "get",
5952 "verbosity",
5953 ]);
5954 assert_eq!(
5955 cli.overrides,
5956 vec![
5957 "verbosity=concise".to_string(),
5958 "model=deepseek-v4-flash".to_string()
5959 ]
5960 );
5961 }
5962
5963 #[test]
5964 fn config_doctor_is_clean_on_minimal_config() {
5965 let temp = tempfile::tempdir().expect("tempdir");
5966 let path = temp.path().join("config.toml");
5967 write_config_fixture(&path, "verbosity = \"concise\"\n");
5968 let store = ConfigStore::load(Some(path)).expect("load fixture");
5969 run_config_doctor(&store).expect("clean doctor");
5970 }
5971
5972 #[test]
5973 fn config_doctor_preserves_keys_owned_by_other_readers() {
5974 let temp = tempfile::tempdir().expect("tempdir");
5975 let path = temp.path().join("config.toml");
5976 write_config_fixture(&path, "zzz_unknown = 1\n");
5977 let store = ConfigStore::load(Some(path)).expect("load fixture");
5978 assert!(!store.config.extras.is_empty());
5979 run_config_doctor(&store).expect("extras do not establish unsupported settings");
5980 }
5981
5982 #[test]
5983 fn config_doctor_fails_on_empty_secret_and_bad_url() {
5984 let temp = tempfile::tempdir().expect("tempdir");
5985 let path = temp.path().join("config.toml");
5986 write_config_fixture(&path, "api_key = \"\"\nbase_url = \"gopher://x\"\n");
5987 let store = ConfigStore::load(Some(path)).expect("load fixture");
5988 let error = run_config_doctor(&store).expect_err("doctor must fail");
5989 let message = format!("{error:#}");
5990 assert!(
5991 message.contains("api_key") && message.contains("empty"),
5992 "{message}"
5993 );
5994 assert!(
5995 message.contains("base_url") && message.contains("http"),
5996 "{message}"
5997 );
5998 }
5999
6000 #[test]
6001 fn per_run_overrides_apply_in_memory_and_never_save() {
6002 let temp = tempfile::tempdir().expect("tempdir");
6003 let path = temp.path().join("config.toml");
6004 write_config_fixture(&path, "verbosity = \"normal\"\n");
6005 let mut store = ConfigStore::load(Some(path.clone())).expect("load fixture");
6006 apply_per_run_overrides(&mut store, &["verbosity=concise".to_string()])
6007 .expect("overlay applies");
6008 assert_eq!(store.config.verbosity.as_deref(), Some("concise"));
6009 let error = apply_per_run_overrides(&mut store, &["no-equals-here".to_string()])
6010 .expect_err("missing = must fail");
6011 assert!(format!("{error:#}").contains("KEY=VALUE"));
6012 // Nothing was saved: a reload sees the file, not the overlay.
6013 let reloaded = ConfigStore::load(Some(path)).expect("reload");
6014 assert_eq!(reloaded.config.verbosity.as_deref(), Some("normal"));
6015 }
6016
6017 #[test]
6018 fn unsupported_nested_config_set_preserves_original_file_bytes() {
6019 let temp = tempfile::tempdir().unwrap();
6020 let path = temp.path().join("config.toml");
6021 let original =
6022 "# Keep this comment and spacing\n[tools]\nuser_input_timeout_seconds = 7 # fixture\n";
6023 write_config_fixture(&path, original);
6024 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
6025 let err = run_config_command(
6026 &mut store,
6027 ConfigCommand::Set {
6028 key: "tools.user_input_timeout_seconds".into(),
6029 value: "0".into(),
6030 },
6031 false,
6032 &[],
6033 )
6034 .unwrap_err();
6035 assert!(err.to_string().contains("[tools]"));
6036 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6037 }
6038
6039 #[test]
6040 fn mutating_config_commands_refuse_per_run_overrides() {
6041 let temp = tempfile::tempdir().expect("tempdir");
6042 let path = temp.path().join("config.toml");
6043 write_config_fixture(&path, "verbosity = \"normal\"\n");
6044 let mut store = ConfigStore::load(Some(path)).expect("load fixture");
6045 let overrides = vec!["verbosity=concise".to_string()];
6046 let error = run_config_command(
6047 &mut store,
6048 ConfigCommand::Set {
6049 key: "verbosity".to_string(),
6050 value: "concise".to_string(),
6051 },
6052 false,
6053 &overrides,
6054 )
6055 .expect_err("set with --set must refuse");
6056 assert!(format!("{error:#}").contains("--set"), "{error:#}");
6057 // Reads still work under an overlay.
6058 run_config_command(&mut store, ConfigCommand::List, false, &overrides)
6059 .expect("list with --set");
6060 }
6061
6062 fn config_dispatch_from(
6063 argv: &[OsString],
6064 cwd: &Path,
6065 ) -> (Option<PathBuf>, ConfigCommand, bool) {
6066 let matches = Cli::command()
6067 .try_get_matches_from(argv.iter().cloned())
6068 .unwrap_or_else(|error| panic!("config command should parse: {error}"));
6069 let project_bundle_scope = config_command_targets_project(&matches);
6070 let cli = Cli::from_arg_matches(&matches)
6071 .unwrap_or_else(|error| panic!("config command should decode: {error}"));
6072 let selected_path = config_store_path_for_dispatch(cli.config, project_bundle_scope, cwd);
6073 let Some(Commands::Config(ConfigArgs { command })) = cli.command else {
6074 panic!("expected config command");
6075 };
6076 (selected_path, command, project_bundle_scope)
6077 }
6078
6079 fn write_config_fixture(path: &Path, body: &str) {
6080 std::fs::create_dir_all(path.parent().expect("config should have a parent"))
6081 .expect("create config parent");
6082 std::fs::write(path, body).expect("write config fixture");
6083 }
6084
6085 #[test]
6086 fn project_config_dispatch_prefers_current_app_dir_and_falls_back_to_legacy() {
6087 let temp = tempfile::tempdir().expect("tempdir");
6088 let workspace = temp.path().join("workspace");
6089 let current = workspace.join(".codewhale/config.toml");
6090 let legacy = workspace.join(".deepseek/config.toml");
6091
6092 // Fresh workspace: create under the current app dir.
6093 std::fs::create_dir_all(&workspace).expect("workspace");
6094 assert_eq!(
6095 config_store_path_for_dispatch(None, true, &workspace),
6096 Some(current.clone())
6097 );
6098
6099 // Legacy-only workspace: operate on the legacy document in place.
6100 write_config_fixture(&legacy, "verbosity = \"legacy\"\n");
6101 assert_eq!(
6102 config_store_path_for_dispatch(None, true, &workspace),
6103 Some(legacy.clone())
6104 );
6105
6106 // Both present: the current app dir wins, matching the loader.
6107 write_config_fixture(&current, "verbosity = \"current\"\n");
6108 assert_eq!(
6109 config_store_path_for_dispatch(None, true, &workspace),
6110 Some(current.clone())
6111 );
6112
6113 // An explicit --config path always wins; without --project nothing is selected.
6114 let explicit = temp.path().join("explicit.toml");
6115 assert_eq!(
6116 config_store_path_for_dispatch(Some(explicit.clone()), true, &workspace),
6117 Some(explicit)
6118 );
6119 assert_eq!(
6120 config_store_path_for_dispatch(None, false, &workspace),
6121 None
6122 );
6123 }
6124
6125 #[test]
6126 fn project_config_import_dispatches_to_the_cwd_document() {
6127 let temp = tempfile::tempdir().expect("tempdir");
6128 let workspace = temp.path().join("workspace");
6129 std::fs::create_dir_all(workspace.join(".git")).expect("create checkout marker");
6130 let project_path = workspace.join(".codewhale/config.toml");
6131 let global_path = temp.path().join("global-config.toml");
6132 write_config_fixture(&project_path, "verbosity = \"project-before\"\n");
6133 write_config_fixture(&global_path, "verbosity = \"global-only\"\n");
6134
6135 let bundle_path = temp.path().join("project-bundle.toml");
6136 std::fs::write(
6137 &bundle_path,
6138 r#"schema_version = 1
6139 kind = "codewhale.portable-config"
6140
6141 [project]
6142 verbosity = "project-imported"
6143 "#,
6144 )
6145 .expect("write project bundle");
6146 let argv = [
6147 OsString::from("codewhale"),
6148 OsString::from("config"),
6149 OsString::from("import"),
6150 bundle_path.as_os_str().to_owned(),
6151 OsString::from("--yes"),
6152 OsString::from("--project"),
6153 ];
6154 let (selected_path, command, project_bundle_scope) =
6155 config_dispatch_from(&argv, &workspace);
6156 assert_eq!(selected_path.as_deref(), Some(project_path.as_path()));
6157
6158 let mut store = ConfigStore::load(selected_path).expect("load selected project config");
6159 run_config_command(&mut store, command, project_bundle_scope, &[])
6160 .expect("import project bundle");
6161 let project = ConfigStore::load(Some(project_path.clone())).expect("reload project");
6162 let global = ConfigStore::load(Some(global_path.clone())).expect("reload global");
6163 assert_eq!(
6164 project.config.verbosity.as_deref(),
6165 Some("project-imported")
6166 );
6167 assert_eq!(global.config.verbosity.as_deref(), Some("global-only"));
6168
6169 let explicit_argv = [
6170 OsString::from("codewhale"),
6171 OsString::from("--config"),
6172 global_path.as_os_str().to_owned(),
6173 OsString::from("config"),
6174 OsString::from("import"),
6175 bundle_path.as_os_str().to_owned(),
6176 OsString::from("--yes"),
6177 OsString::from("--project"),
6178 ];
6179 let global_before = std::fs::read(&global_path).expect("read global before refusal");
6180 let (selected_path, command, project_bundle_scope) =
6181 config_dispatch_from(&explicit_argv, &workspace);
6182 assert_eq!(selected_path.as_deref(), Some(global_path.as_path()));
6183 let mut explicit_store =
6184 ConfigStore::load(selected_path).expect("load explicit global config");
6185 let error = run_config_command(&mut explicit_store, command, project_bundle_scope, &[])
6186 .expect_err("project import must reject an explicit non-workspace config");
6187 assert!(
6188 error
6189 .to_string()
6190 .contains("--project requires a workspace config"),
6191 "{error:#}"
6192 );
6193 assert_eq!(
6194 std::fs::read(&global_path).expect("read global after refusal"),
6195 global_before
6196 );
6197 }
6198
6199 #[test]
6200 fn project_config_export_reads_the_cwd_document() {
6201 let temp = tempfile::tempdir().expect("tempdir");
6202 let workspace = temp.path().join("workspace");
6203 std::fs::create_dir_all(workspace.join(".git")).expect("create checkout marker");
6204 let project_path = workspace.join(".codewhale/config.toml");
6205 let global_path = temp.path().join("global-config.toml");
6206 let output_path = temp.path().join("portable.toml");
6207 write_config_fixture(&project_path, "verbosity = \"project-only\"\n");
6208 write_config_fixture(&global_path, "verbosity = \"global-only\"\n");
6209
6210 let argv = [
6211 OsString::from("codewhale"),
6212 OsString::from("config"),
6213 OsString::from("export"),
6214 OsString::from("--portable"),
6215 OsString::from("--project"),
6216 OsString::from("--out"),
6217 output_path.as_os_str().to_owned(),
6218 ];
6219 let (selected_path, command, project_bundle_scope) =
6220 config_dispatch_from(&argv, &workspace);
6221 assert_eq!(selected_path.as_deref(), Some(project_path.as_path()));
6222
6223 let mut store = ConfigStore::load(selected_path).expect("load selected project config");
6224 run_config_command(&mut store, command, project_bundle_scope, &[])
6225 .expect("export project bundle");
6226 let body = std::fs::read_to_string(&output_path).expect("read portable export");
6227 let bundle = config_bundles::parse_bundle_str(&body, "portable.toml")
6228 .expect("parse portable export");
6229 assert_eq!(
6230 bundle
6231 .project
6232 .entries
6233 .get("verbosity")
6234 .and_then(toml::Value::as_str),
6235 Some("project-only")
6236 );
6237 assert!(bundle.global.entries.is_empty());
6238
6239 let explicit_output_path = temp.path().join("explicit-portable.toml");
6240 let explicit_argv = [
6241 OsString::from("codewhale"),
6242 OsString::from("--config"),
6243 global_path.as_os_str().to_owned(),
6244 OsString::from("config"),
6245 OsString::from("export"),
6246 OsString::from("--portable"),
6247 OsString::from("--project"),
6248 OsString::from("--out"),
6249 explicit_output_path.as_os_str().to_owned(),
6250 ];
6251 let global_before = std::fs::read(&global_path).expect("read global before refusal");
6252 let (selected_path, command, project_bundle_scope) =
6253 config_dispatch_from(&explicit_argv, &workspace);
6254 assert_eq!(selected_path.as_deref(), Some(global_path.as_path()));
6255 let mut explicit_store =
6256 ConfigStore::load(selected_path).expect("load explicit global config");
6257 let error = run_config_command(&mut explicit_store, command, project_bundle_scope, &[])
6258 .expect_err("project export must reject an explicit non-workspace config");
6259 assert!(
6260 error
6261 .to_string()
6262 .contains("--project requires a workspace config"),
6263 "{error:#}"
6264 );
6265 assert!(!explicit_output_path.exists());
6266 assert_eq!(
6267 std::fs::read(&global_path).expect("read global after refusal"),
6268 global_before
6269 );
6270 }
6271
6272 #[test]
6273 fn parses_update_beta_flag() {
6274 let cli = parse_ok(&["codewhale", "update"]);
6275 assert!(matches!(
6276 cli.command,
6277 Some(Commands::Update(UpdateArgs {
6278 beta: false,
6279 check: false,
6280 proxy: None
6281 }))
6282 ));
6283
6284 let cli = parse_ok(&["codewhale", "update", "--beta"]);
6285 assert!(matches!(
6286 cli.command,
6287 Some(Commands::Update(UpdateArgs {
6288 beta: true,
6289 check: false,
6290 proxy: None
6291 }))
6292 ));
6293
6294 let cli = parse_ok(&["codewhale", "update", "--check"]);
6295 assert!(matches!(
6296 cli.command,
6297 Some(Commands::Update(UpdateArgs {
6298 beta: false,
6299 check: true,
6300 proxy: None
6301 }))
6302 ));
6303
6304 let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
6305 let Some(Commands::Update(args)) = cli.command else {
6306 panic!("expected update command");
6307 };
6308 assert!(!args.beta);
6309 assert!(!args.check);
6310 assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
6311 }
6312
6313 #[test]
6314 fn parses_model_command_matrix() {
6315 let cli = parse_ok(&["deepseek", "model", "list"]);
6316 assert!(matches!(
6317 cli.command,
6318 Some(Commands::Model(ModelArgs {
6319 command: ModelCommand::List { provider: None }
6320 }))
6321 ));
6322
6323 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
6324 assert!(matches!(
6325 cli.command,
6326 Some(Commands::Model(ModelArgs {
6327 command: ModelCommand::List {
6328 provider: Some(ProviderKind::Openai)
6329 }
6330 }))
6331 ));
6332
6333 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
6334 assert!(matches!(
6335 cli.command,
6336 Some(Commands::Model(ModelArgs {
6337 command: ModelCommand::Resolve {
6338 model: Some(ref model),
6339 provider: None
6340 }
6341 })) if model == "deepseek-v4-flash"
6342 ));
6343
6344 let cli = parse_ok(&[
6345 "deepseek",
6346 "model",
6347 "resolve",
6348 "--provider",
6349 "deepseek",
6350 "deepseek-v4-pro",
6351 ]);
6352 assert!(matches!(
6353 cli.command,
6354 Some(Commands::Model(ModelArgs {
6355 command: ModelCommand::Resolve {
6356 model: Some(ref model),
6357 provider: Some(ProviderKind::Deepseek)
6358 }
6359 })) if model == "deepseek-v4-pro"
6360 ));
6361
6362 let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
6363 assert!(matches!(
6364 cli.command,
6365 Some(Commands::Model(ModelArgs {
6366 command: ModelCommand::Set { ref model }
6367 })) if model == "pro"
6368 ));
6369 }
6370
6371 #[test]
6372 fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
6373 assert_eq!(
6374 model_command_provider_hint(None, Some(ProviderKind::Zai)),
6375 Some(ProviderKind::Zai)
6376 );
6377 assert_eq!(
6378 model_command_provider_hint(Some(ProviderKind::Minimax), Some(ProviderKind::Zai)),
6379 Some(ProviderKind::Minimax)
6380 );
6381 assert_eq!(model_command_provider_hint(None, None), None);
6382
6383 let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
6384 assert_eq!(cli.provider.as_deref(), Some("zai"));
6385 assert!(matches!(
6386 cli.command,
6387 Some(Commands::Model(ModelArgs {
6388 command: ModelCommand::List { provider: None }
6389 }))
6390 ));
6391 }
6392
6393 #[test]
6394 fn durable_cli_route_edits_use_canonical_config_and_keep_temporary_overrides_unsaved() {
6395 let _env = env_lock();
6396 let home = tempfile::tempdir().expect("isolated home");
6397 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
6398 let _config_path = ScopedEnvVar::remove("CODEWHALE_CONFIG_PATH");
6399 let _legacy_config_path = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
6400 let path = home.path().join("config.toml");
6401 std::fs::write(&path, "provider = \"deepseek\"\ndefault_text_model = \"deepseek-v4-pro\"\n[providers.zai]\nmodel = \"GLM-5.2\"\n").unwrap();
6402 let settings_path = home.path().join("settings.toml");
6403 let settings = "default_provider = \"zai\"\n[provider_models]\nzai = \"GLM-5.3\"\n";
6404 std::fs::write(&settings_path, settings).unwrap();
6405 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
6406 let runtime = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
6407 run_model_command(
6408 &mut store,
6409 ModelCommand::Set {
6410 model: "GLM-5.2".into(),
6411 },
6412 None,
6413 &runtime,
6414 )
6415 .unwrap();
6416 assert_eq!(store.config.provider, ProviderKind::Zai);
6417 assert_eq!(store.config.providers.zai.model.as_deref(), Some("GLM-5.2"));
6418 assert_eq!(
6419 store.config.extras["route_preferences_version"].as_integer(),
6420 Some(1)
6421 );
6422
6423 run_config_command(
6424 &mut store,
6425 ConfigCommand::Set {
6426 key: "default_text_model".into(),
6427 value: "GLM-5.1".into(),
6428 },
6429 false,
6430 &[],
6431 )
6432 .unwrap();
6433 assert_eq!(store.config.providers.zai.model.as_deref(), Some("GLM-5.1"));
6434 assert_eq!(
6435 codewhale_tui::route_preferences::get(&path, "model")
6436 .unwrap()
6437 .as_deref(),
6438 Some("GLM-5.1")
6439 );
6440 run_config_command(
6441 &mut store,
6442 ConfigCommand::Unset {
6443 key: "providers.zai.model".into(),
6444 },
6445 false,
6446 &[],
6447 )
6448 .unwrap();
6449 assert!(store.config.providers.zai.model.is_none());
6450 assert_eq!(std::fs::read_to_string(settings_path).unwrap(), settings);
6451
6452 let before = std::fs::read(&path).unwrap();
6453 let overrides = vec!["model=temporary-model".to_string()];
6454 assert!(
6455 run_config_command(
6456 &mut store,
6457 ConfigCommand::Set {
6458 key: "model".into(),
6459 value: "GLM-5.2".into(),
6460 },
6461 false,
6462 &overrides
6463 )
6464 .is_err()
6465 );
6466 apply_per_run_overrides(&mut store, &overrides).unwrap();
6467 assert_eq!(store.config.model.as_deref(), Some("temporary-model"));
6468 assert_eq!(std::fs::read(&path).unwrap(), before);
6469 }
6470
6471 #[test]
6472 fn model_set_canonicalizes_deepseek_vision_aliases() {
6473 for alias in ["flash-vision", "deepseek-v4flashvisionexp"] {
6474 assert_eq!(
6475 canonical_model_for_set(alias),
6476 "deepseek-v4-flash-vision-exp"
6477 );
6478 }
6479 assert_eq!(
6480 canonical_model_for_set("deepseek-v4-flash-vision-exp"),
6481 "deepseek-v4-flash-vision-exp"
6482 );
6483 }
6484
6485 #[test]
6486 fn parses_thread_command_matrix() {
6487 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
6488 assert!(matches!(
6489 cli.command,
6490 Some(Commands::Thread(ThreadArgs {
6491 command: ThreadCommand::List {
6492 all: true,
6493 limit: Some(50)
6494 }
6495 }))
6496 ));
6497
6498 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
6499 assert!(matches!(
6500 cli.command,
6501 Some(Commands::Thread(ThreadArgs {
6502 command: ThreadCommand::Read { ref thread_id }
6503 })) if thread_id == "thread-1"
6504 ));
6505
6506 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
6507 assert!(matches!(
6508 cli.command,
6509 Some(Commands::Thread(ThreadArgs {
6510 command: ThreadCommand::Resume { ref thread_id }
6511 })) if thread_id == "thread-2"
6512 ));
6513
6514 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
6515 assert!(matches!(
6516 cli.command,
6517 Some(Commands::Thread(ThreadArgs {
6518 command: ThreadCommand::Fork { ref thread_id }
6519 })) if thread_id == "thread-3"
6520 ));
6521
6522 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
6523 assert!(matches!(
6524 cli.command,
6525 Some(Commands::Thread(ThreadArgs {
6526 command: ThreadCommand::Archive { ref thread_id }
6527 })) if thread_id == "thread-4"
6528 ));
6529
6530 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
6531 assert!(matches!(
6532 cli.command,
6533 Some(Commands::Thread(ThreadArgs {
6534 command: ThreadCommand::Unarchive { ref thread_id }
6535 })) if thread_id == "thread-5"
6536 ));
6537
6538 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
6539 assert!(matches!(
6540 cli.command,
6541 Some(Commands::Thread(ThreadArgs {
6542 command: ThreadCommand::SetName {
6543 ref thread_id,
6544 ref name
6545 }
6546 })) if thread_id == "thread-6" && name == "My Thread"
6547 ));
6548
6549 let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
6550 assert!(matches!(
6551 cli.command,
6552 Some(Commands::Thread(ThreadArgs {
6553 command: ThreadCommand::ClearName { ref thread_id }
6554 })) if thread_id == "thread-7"
6555 ));
6556 }
6557
6558 #[test]
6559 fn parses_sandbox_app_server_and_completion_matrix() {
6560 let cli = parse_ok(&[
6561 "deepseek",
6562 "sandbox",
6563 "check",
6564 "echo hello",
6565 "--ask",
6566 "on-failure",
6567 ]);
6568 assert!(matches!(
6569 cli.command,
6570 Some(Commands::Sandbox(SandboxArgs {
6571 command: SandboxCommand::Check {
6572 ref command,
6573 ask: ApprovalModeArg::OnFailure
6574 }
6575 })) if command == "echo hello"
6576 ));
6577
6578 let cli = parse_ok(&[
6579 "deepseek",
6580 "app-server",
6581 "--host",
6582 "0.0.0.0",
6583 "--port",
6584 "9999",
6585 ]);
6586 assert!(matches!(
6587 cli.command,
6588 Some(Commands::AppServer(AppServerArgs {
6589 host: Some(ref host),
6590 port: Some(9999),
6591 stdio: false,
6592 http: false,
6593 mobile: false,
6594 ..
6595 })) if host == "0.0.0.0"
6596 ));
6597
6598 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
6599 assert!(matches!(
6600 cli.command,
6601 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
6602 ));
6603
6604 let cli = parse_ok(&["deepseek", "completion", "bash"]);
6605 assert!(matches!(
6606 cli.command,
6607 Some(Commands::Completion { shell: Shell::Bash })
6608 ));
6609 }
6610
6611 /// The `[[bin]] name` declared in this crate's manifest is the only thing a
6612 /// user ever types. Read it from disk rather than restating it, so renaming
6613 /// the binary without re-pointing the completion generator fails here
6614 /// instead of silently shipping a script nobody's shell loads (#5526).
6615 fn declared_bin_name() -> String {
6616 let manifest = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"))
6617 .expect("read crates/cli/Cargo.toml");
6618 let bin_section = manifest
6619 .split("[[bin]]")
6620 .nth(1)
6621 .expect("crates/cli/Cargo.toml declares a [[bin]] target");
6622 for line in bin_section.lines() {
6623 let line = line.trim();
6624 if let Some(rest) = line.strip_prefix("name") {
6625 let value = rest.trim_start().trim_start_matches('=').trim();
6626 return value.trim_matches('"').to_string();
6627 }
6628 }
6629 panic!("[[bin]] section has no name key");
6630 }
6631
6632 #[test]
6633 fn completion_bin_name_matches_the_declared_bin_target() {
6634 assert_eq!(
6635 COMPLETION_BIN_NAME,
6636 declared_bin_name(),
6637 "completion scripts must register the binary this crate actually builds"
6638 );
6639 }
6640
6641 /// Issue #5526: `codewhale completions <shell>` used to forward to the
6642 /// in-tree `codewhale-tui` binary, so every generated script registered
6643 /// `codewhale-tui` — not a GitHub-release command — and exposed the TUI's
6644 /// smaller subcommand tree. Pin the registered names per shell.
6645 #[test]
6646 fn generated_completion_scripts_register_the_published_command_names() {
6647 let bin = declared_bin_name();
6648 let alias = COMPLETION_ALIAS_NAME;
6649
6650 // Match whole lines throughout: `codew` is a prefix of `codewhale`,
6651 // so a substring check for the alias is satisfied by the primary
6652 // binding and would pass on an unfixed build.
6653 let has_line =
6654 |script: &str, wanted: &str| script.lines().any(|line| line.trim() == wanted);
6655
6656 let bash = render_completion_script(Shell::Bash);
6657 assert!(
6658 has_line(
6659 &bash,
6660 &format!("complete -F _{bin} -o bashdefault -o default {bin}")
6661 ),
6662 "bash script must bind the real binary name:\n{bash}"
6663 );
6664 assert!(
6665 has_line(
6666 &bash,
6667 &format!("complete -F _{bin} -o bashdefault -o default {alias}")
6668 ),
6669 "bash script must bind the {alias} shorthand too"
6670 );
6671
6672 let zsh = render_completion_script(Shell::Zsh);
6673 assert_eq!(
6674 zsh.lines().next(),
6675 Some(format!("#compdef {bin} {alias}").as_str()),
6676 "zsh compdef tag line must list both published command names"
6677 );
6678 assert!(
6679 has_line(&zsh, &format!("compdef _{bin} {bin}")),
6680 "zsh script must bind {bin} on the sourced path"
6681 );
6682 assert!(
6683 has_line(&zsh, &format!("compdef _{bin} {alias}")),
6684 "zsh script must bind {alias} on the sourced path too"
6685 );
6686
6687 let fish = render_completion_script(Shell::Fish);
6688 assert!(
6689 fish.contains(&format!("complete -c {bin} ")),
6690 "fish script must complete the real binary name"
6691 );
6692 assert!(
6693 has_line(&fish, &format!("complete -c {alias} -w {bin}")),
6694 "fish script must wrap the {alias} shorthand onto {bin}"
6695 );
6696
6697 let powershell = render_completion_script(Shell::PowerShell);
6698 assert!(
6699 powershell.contains(&format!(
6700 "Register-ArgumentCompleter -Native -CommandName '{bin}','{alias}'"
6701 )),
6702 "PowerShell script must register both published command names"
6703 );
6704
6705 let elvish = render_completion_script(Shell::Elvish);
6706 assert!(
6707 has_line(
6708 &elvish,
6709 &format!("set edit:completion:arg-completer[{bin}] = {{|@words|")
6710 ),
6711 "elvish script must bind the real binary name:\n{elvish}"
6712 );
6713 assert!(
6714 has_line(
6715 &elvish,
6716 &format!(
6717 "set edit:completion:arg-completer[{alias}] = $edit:completion:arg-completer[{bin}]"
6718 )
6719 ),
6720 "elvish script must alias the {alias} shorthand onto {bin}"
6721 );
6722
6723 for (shell, script) in [
6724 ("bash", &bash),
6725 ("zsh", &zsh),
6726 ("fish", &fish),
6727 ("powershell", &powershell),
6728 ("elvish", &elvish),
6729 ] {
6730 assert!(
6731 !script.contains("codewhale-tui"),
6732 "{shell} completions leaked the in-tree codewhale-tui name (#5526)"
6733 );
6734 }
6735 }
6736
6737 /// The other half of #5526: the script has to describe *this* CLI's
6738 /// commands. Rendering from a different clap tree would drop or invent
6739 /// subcommands, which is exactly how the forwarded script went stale.
6740 #[test]
6741 fn generated_completion_scripts_cover_the_real_subcommand_surface() {
6742 let bash = render_completion_script(Shell::Bash);
6743 for sub in Cli::command().get_subcommands() {
6744 if sub.is_hide_set() {
6745 continue;
6746 }
6747 let name = sub.get_name();
6748 assert!(
6749 bash.contains(name),
6750 "bash completions omit the `{name}` subcommand"
6751 );
6752 }
6753 }
6754
6755 /// `completions` is what the issue reporter typed and what the TUI called
6756 /// it; keep it working, now as an alias that renders in-process.
6757 #[test]
6758 fn completions_is_an_alias_for_completion() {
6759 assert!(matches!(
6760 parse_ok(&["codewhale", "completions", "powershell"]).command,
6761 Some(Commands::Completion {
6762 shell: Shell::PowerShell
6763 })
6764 ));
6765 }
6766
6767 #[test]
6768 fn app_server_transports_are_mutually_exclusive() {
6769 assert!(matches!(
6770 parse_ok(&["deepseek", "app-server", "--http"]).command,
6771 Some(Commands::AppServer(AppServerArgs {
6772 http: true,
6773 mobile: false,
6774 stdio: false,
6775 ..
6776 }))
6777 ));
6778 assert!(matches!(
6779 parse_ok(&["deepseek", "app-server", "--mobile"]).command,
6780 Some(Commands::AppServer(AppServerArgs {
6781 mobile: true,
6782 http: false,
6783 stdio: false,
6784 ..
6785 }))
6786 ));
6787
6788 assert!(matches!(
6789 parse_ok(&["deepseek", "app-server", "--socket"]).command,
6790 Some(Commands::AppServer(AppServerArgs {
6791 socket: true,
6792 socket_path: None,
6793 http: false,
6794 mobile: false,
6795 stdio: false,
6796 ..
6797 }))
6798 ));
6799
6800 for argv in [
6801 ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
6802 ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
6803 ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
6804 ["deepseek", "app-server", "--socket", "--stdio"].as_slice(),
6805 ["deepseek", "app-server", "--socket", "--http"].as_slice(),
6806 ["deepseek", "app-server", "--socket", "--mobile"].as_slice(),
6807 ] {
6808 let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
6809 assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
6810 }
6811 }
6812
6813 #[test]
6814 fn app_server_socket_path_requires_socket() {
6815 let err = Cli::try_parse_from(["deepseek", "app-server", "--socket-path", "/tmp/d.sock"])
6816 .expect_err("--socket-path without --socket must fail");
6817 assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
6818 match parse_ok(&[
6819 "deepseek",
6820 "app-server",
6821 "--socket",
6822 "--socket-path",
6823 "/tmp/d.sock",
6824 ])
6825 .command
6826 {
6827 Some(Commands::AppServer(AppServerArgs {
6828 socket: true,
6829 socket_path: Some(path),
6830 ..
6831 })) => assert_eq!(path, PathBuf::from("/tmp/d.sock")),
6832 other => panic!("unexpected parse: {other:?}"),
6833 }
6834 }
6835
6836 #[test]
6837 fn app_server_qr_requires_mobile() {
6838 let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
6839 .expect_err("--qr without --mobile must fail");
6840 assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
6841 assert!(matches!(
6842 parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
6843 Some(Commands::AppServer(AppServerArgs {
6844 mobile: true,
6845 qr: true,
6846 ..
6847 }))
6848 ));
6849 }
6850
6851 #[test]
6852 fn app_server_serve_passthrough_maps_flags_to_serve() {
6853 let args = AppServerArgs {
6854 http: true,
6855 mobile: false,
6856 stdio: false,
6857 socket: false,
6858 socket_path: None,
6859 qr: false,
6860 host: Some("127.0.0.1".to_string()),
6861 port: Some(9000),
6862 workers: Some(4),
6863 config: None,
6864 auth_token: Some("tok".to_string()),
6865 insecure_no_auth: true,
6866 cors_origin: vec!["http://localhost:5173".to_string()],
6867 };
6868 let argv = app_server_serve_passthrough(&args);
6869 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
6870 // app-server's --insecure-no-auth maps onto serve's --insecure.
6871 assert_eq!(
6872 as_str,
6873 vec![
6874 "serve",
6875 "--http",
6876 "--host",
6877 "127.0.0.1",
6878 "--port",
6879 "9000",
6880 "--workers",
6881 "4",
6882 "--cors-origin",
6883 "http://localhost:5173",
6884 "--auth-token",
6885 "tok",
6886 "--insecure",
6887 ]
6888 );
6889 }
6890
6891 #[test]
6892 fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
6893 let args = AppServerArgs {
6894 http: false,
6895 mobile: true,
6896 stdio: false,
6897 socket: false,
6898 socket_path: None,
6899 qr: true,
6900 host: None,
6901 port: None,
6902 workers: None,
6903 config: None,
6904 auth_token: None,
6905 insecure_no_auth: false,
6906 cors_origin: vec![],
6907 };
6908 let argv = app_server_serve_passthrough(&args);
6909 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
6910 // No host/port forwarded → serve applies its own --mobile 0.0.0.0 default.
6911 // No auth token is injected from the environment into child argv.
6912 assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
6913 }
6914
6915 #[test]
6916 fn web_command_is_typed_and_delegates_without_auth_material() {
6917 let cli = parse_ok(&["codewhale", "web", "--port", "9091"]);
6918 let args = match cli.command {
6919 Some(Commands::Web(args)) => args,
6920 other => panic!("expected web command, got {other:?}"),
6921 };
6922 assert_eq!(args.port, 9091);
6923 let forwarded = web_serve_passthrough(&args);
6924 assert_eq!(forwarded, ["serve", "--web", "--port", "9091"]);
6925 assert!(!forwarded.iter().any(|arg| arg.contains("token")));
6926 }
6927
6928 #[test]
6929 fn web_command_defaults_to_runtime_port_and_documents_bootstrap_boundary() {
6930 let cli = parse_ok(&["codewhale", "web"]);
6931 assert!(matches!(
6932 cli.command,
6933 Some(Commands::Web(WebArgs { port: 7878 }))
6934 ));
6935 let help = help_for(&["codewhale", "web", "--help"]);
6936 assert!(help.contains("--port"));
6937 assert!(help.contains("one-time loopback bootstrap"));
6938 assert!(!help.contains("--auth-token"));
6939 }
6940
6941 #[test]
6942 fn serve_help_documents_forwarded_runtime_modes() {
6943 let help = help_for(&["codewhale", "serve", "--help"]);
6944 for flag in ["--http", "--mobile", "--web", "--mcp", "--acp"] {
6945 assert!(
6946 help.contains(flag),
6947 "serve help should document forwarded flag {flag}; help was:\n{help}"
6948 );
6949 }
6950 assert!(help.contains("compatibility"));
6951 }
6952
6953 #[test]
6954 fn parses_direct_tui_command_aliases() {
6955 let cli = parse_ok(&["deepseek", "doctor"]);
6956 assert!(matches!(
6957 cli.command,
6958 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
6959 ));
6960
6961 let cli = parse_ok(&["deepseek", "models", "--json"]);
6962 assert!(matches!(
6963 cli.command,
6964 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
6965 ));
6966
6967 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
6968 assert!(matches!(
6969 cli.command,
6970 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
6971 ));
6972
6973 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
6974 assert!(matches!(
6975 cli.command,
6976 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
6977 if args == &["--skills", "--local"]
6978 ));
6979
6980 let cli = parse_ok(&["codewhale", "fleet", "init"]);
6981 assert!(cli.prompt.is_empty());
6982 assert!(matches!(
6983 cli.command,
6984 Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
6985 ));
6986
6987 let cli = parse_ok(&[
6988 "codewhale",
6989 "fleet",
6990 "run",
6991 "tasks.json",
6992 "--max-workers",
6993 "2",
6994 ]);
6995 assert!(cli.prompt.is_empty());
6996 assert!(matches!(
6997 cli.command,
6998 Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
6999 if args == &["run", "tasks.json", "--max-workers", "2"]
7000 ));
7001
7002 let cli = parse_ok(&[
7003 "codewhale",
7004 "workflow",
7005 "run",
7006 "stopship",
7007 "--fleet",
7008 "stopship",
7009 "--runtime",
7010 "tmux",
7011 "--issue",
7012 "4375",
7013 ]);
7014 assert!(matches!(
7015 cli.command,
7016 Some(Commands::Workflow(WorkflowArgs {
7017 command: WorkflowCommand::Run {
7018 ref workflow,
7019 ref fleet,
7020 ref runtime,
7021 ref issue,
7022 ..
7023 }
7024 })) if workflow == "stopship"
7025 && fleet.as_deref() == Some("stopship")
7026 && runtime == "tmux"
7027 && issue.as_deref() == Some("4375")
7028 ));
7029 }
7030
7031 /// Fleet is the only top-level spelling for durable runs. The retired
7032 /// `pod` spelling must fail to parse instead of dispatching.
7033 #[test]
7034 fn fleet_is_the_only_top_level_command_and_pod_is_rejected() {
7035 for tail in [
7036 vec!["init"],
7037 vec!["status"],
7038 vec!["run", "tasks.json", "--max-workers", "2"],
7039 ] {
7040 let fleet = parse_ok(
7041 &std::iter::once("codewhale")
7042 .chain(["fleet"])
7043 .chain(tail.iter().copied())
7044 .collect::<Vec<_>>(),
7045 );
7046 let Some(Commands::Fleet(fleet_args)) = &fleet.command else {
7047 panic!("fleet must parse into the fleet command: {tail:?}");
7048 };
7049 assert_eq!(fleet_args.args, tail, "{tail:?}");
7050 assert!(fleet.prompt.is_empty(), "{tail:?}");
7051
7052 let retired = parse_ok(
7053 &std::iter::once("codewhale")
7054 .chain(["pod"])
7055 .chain(tail.iter().copied())
7056 .collect::<Vec<_>>(),
7057 );
7058 assert!(
7059 retired.command.is_none(),
7060 "retired pod must not dispatch to any command: {tail:?}"
7061 );
7062 assert_eq!(
7063 retired.prompt.first().map(String::as_str),
7064 Some("pod"),
7065 "retired pod words fall through to prompt text: {tail:?}"
7066 );
7067 }
7068
7069 // Help advertises fleet only.
7070 let help = help_for(&["codewhale", "--help"]);
7071 let commands = help
7072 .lines()
7073 .map(str::trim_start)
7074 .filter(|line| line.starts_with("fleet"))
7075 .collect::<Vec<_>>();
7076 assert_eq!(
7077 commands.len(),
7078 1,
7079 "expected exactly one entry: {commands:?}"
7080 );
7081 assert!(commands[0].starts_with("fleet"), "{commands:?}");
7082 assert!(
7083 commands[0].contains("fleet"),
7084 "help summary should name fleet: {commands:?}"
7085 );
7086 assert!(
7087 !help.contains("Manage durable Agent Fleet runs"),
7088 "the retired Fleet-led summary must be gone from top-level help"
7089 );
7090
7091 let fleet_help = help_for(&["codewhale", "fleet", "--help"]);
7092 assert!(fleet_help.contains("Manage durable Agent fleet runs"));
7093 assert!(fleet_help.contains("codewhale fleet run tasks.json --max-workers 4"));
7094
7095 // The inner command token matches the canonical name so receipts
7096 // and any echoed invocation never regress to the retired name.
7097 let args = TuiPassthroughArgs {
7098 args: vec!["status".into()],
7099 };
7100 assert_eq!(
7101 tui_args("fleet", args.clone()),
7102 vec!["fleet".to_string(), "status".to_string()]
7103 );
7104 assert!(command_accepts_raw_provider(Some(&Commands::Fleet(args))));
7105 }
7106
7107 #[test]
7108 fn exec_and_fleet_accept_builtin_and_raw_provider_identifiers() {
7109 let builtin = parse_ok(&["codewhale", "--provider", "openrouter", "exec", "Reply OK"]);
7110 assert_eq!(builtin.provider.as_deref(), Some("openrouter"));
7111 assert_eq!(
7112 top_level_provider_override(builtin.provider.as_deref(), builtin.command.as_ref())
7113 .expect("built-in Exec provider"),
7114 Some(ProviderKind::Openrouter)
7115 );
7116
7117 assert_eq!(
7118 top_level_provider_override(
7119 Some("qianfan"),
7120 Some(&Commands::Exec(TuiPassthroughArgs {
7121 args: vec!["Reply OK".into()]
7122 }))
7123 )
7124 .expect("qianfan is a catalog route"),
7125 Some(ProviderKind::Qianfan)
7126 );
7127
7128 for (provider, command) in [
7129 ("lm-studio", vec!["exec", "Reply OK"]),
7130 ("lm-studio", vec!["fleet", "status"]),
7131 ] {
7132 let argv = std::iter::once("codewhale")
7133 .chain(["--provider", provider])
7134 .chain(command.iter().copied())
7135 .collect::<Vec<_>>();
7136 let cli = parse_ok(&argv);
7137 assert_eq!(cli.provider.as_deref(), Some(provider));
7138 assert_eq!(
7139 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
7140 .expect("raw TUI provider"),
7141 None,
7142 "{argv:?} should defer the raw provider id to the TUI"
7143 );
7144 }
7145 }
7146
7147 #[test]
7148 fn opencode_go_provider_aliases_parse_as_builtin() {
7149 for alias in ["opencode-go", "opencode_go", "opencodego"] {
7150 assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OpencodeGo));
7151 }
7152 }
7153
7154 #[test]
7155 fn ollama_cloud_provider_aliases_parse_as_builtin() {
7156 for alias in ["ollama-cloud", "ollama_cloud"] {
7157 assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OllamaCloud));
7158 }
7159 }
7160
7161 #[test]
7162 fn antigravity_provider_aliases_are_clear_only_and_never_raw_custom() {
7163 for alias in ["antigravity", "agy"] {
7164 assert_eq!(builtin_provider_arg(alias), None, "{alias}");
7165 assert_eq!(
7166 parse_auth_clear_provider(alias),
7167 Ok(ProviderKind::Antigravity),
7168 "{alias}"
7169 );
7170 let error = parse_catalog_route(alias).expect_err("legacy route is not selectable");
7171 assert!(error.contains("non-runnable legacy provider"), "{error}");
7172 assert!(error.contains("--provider antigravity"), "{error}");
7173 assert!(error.contains("google"), "{error}");
7174 assert!(error.contains("GEMINI_API_KEY"), "{error}");
7175
7176 let clear = parse_ok(&["codewhale", "auth", "clear", "--provider", alias]);
7177 assert!(matches!(
7178 clear.command,
7179 Some(Commands::Auth(AuthArgs {
7180 command: AuthCommand::Clear {
7181 provider: ProviderKind::Antigravity,
7182 }
7183 }))
7184 ));
7185
7186 for argv in [
7187 vec!["codewhale", "auth", "set", "--provider", alias],
7188 vec!["codewhale", "auth", "get", "--provider", alias],
7189 vec!["codewhale", "auth", "print-api-key", "--provider", alias],
7190 vec!["codewhale", "auth", "status", "--provider", alias],
7191 vec!["codewhale", "auth", "external-revoke", "--provider", alias],
7192 vec![
7193 "codewhale",
7194 "auth",
7195 "external-consent",
7196 "--provider",
7197 alias,
7198 "--mode",
7199 "read-only",
7200 "--yes",
7201 ],
7202 vec!["codewhale", "model", "list", "--provider", alias],
7203 vec!["codewhale", "model", "resolve", "--provider", alias],
7204 ] {
7205 let error = Cli::try_parse_from(argv)
7206 .expect_err("legacy Antigravity route must be rejected outside auth clear");
7207 assert_eq!(error.kind(), ErrorKind::ValueValidation);
7208 assert!(
7209 error.to_string().contains("non-runnable legacy provider"),
7210 "{error}"
7211 );
7212 }
7213
7214 for command in [
7215 Commands::Exec(TuiPassthroughArgs {
7216 args: vec!["Reply OK".into()],
7217 }),
7218 Commands::Fleet(TuiPassthroughArgs {
7219 args: vec!["status".into()],
7220 }),
7221 ] {
7222 let error = top_level_provider_override(Some(alias), Some(&command))
7223 .expect_err("legacy alias must not fall through as a raw custom provider");
7224 assert!(
7225 error.to_string().contains("non-runnable legacy provider"),
7226 "{error}"
7227 );
7228 }
7229 }
7230 }
7231
7232 #[test]
7233 fn legacy_dual_wire_provider_flag_keeps_named_table_kind() {
7234 // The CLI flag must resolve legacy spellings to the table-owning
7235 // dialect kind (mirroring TOML serde), never to the collapsed catalog
7236 // primary, or the user's own [providers.*] table is orphaned.
7237 for alias in [
7238 "minimax-anthropic",
7239 "minimax_anthropic",
7240 "mini-max-anthropic",
7241 "mini_max_anthropic",
7242 ] {
7243 assert_eq!(
7244 builtin_provider_arg(alias),
7245 Some(ProviderKind::MinimaxAnthropic),
7246 "{alias}"
7247 );
7248 }
7249 let cli = parse_ok(&[
7250 "codewhale",
7251 "--provider",
7252 "minimax-anthropic",
7253 "exec",
7254 "Reply OK",
7255 ]);
7256 assert_eq!(
7257 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
7258 .expect("legacy dual-wire provider"),
7259 Some(ProviderKind::MinimaxAnthropic)
7260 );
7261 }
7262
7263 #[test]
7264 fn opencode_zen_provider_aliases_parse_as_builtin() {
7265 for alias in [
7266 "opencode-zen",
7267 "opencode_zen",
7268 "opencodezen",
7269 "zen",
7270 "opencode",
7271 ] {
7272 assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OpencodeZen));
7273 }
7274 }
7275
7276 #[test]
7277 fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
7278 let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
7279 let err = top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
7280 .expect_err("model registry commands still require a built-in provider");
7281 assert!(
7282 err.to_string()
7283 .contains("configured custom providers are accepted only by exec and fleet")
7284 );
7285
7286 let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
7287 .expect_err("auth keeps enum-only provider validation");
7288 assert_eq!(err.kind(), ErrorKind::ValueValidation);
7289
7290 let err = Cli::try_parse_from([
7291 "codewhale",
7292 "--provider",
7293 "../../lm-studio",
7294 "exec",
7295 "Reply OK",
7296 ])
7297 .expect_err("provider ids must stay simple tokens");
7298 assert!(
7299 err.to_string()
7300 .contains("provider must be a simple identifier")
7301 );
7302 }
7303
7304 #[test]
7305 fn hidden_lane_log_proxy_parses_child_argv_and_preserves_other_commands() {
7306 let cli = parse_ok(&[
7307 "codewhale",
7308 "lane-log-proxy",
7309 "--log-path",
7310 "/tmp/lane.ndjson",
7311 "--receipt-path",
7312 "/tmp/lane.exit.json",
7313 "--receipt-tmp-path",
7314 "/tmp/lane.exit.json.tmp",
7315 "--environment-path",
7316 "/tmp/lane.env.json",
7317 "--lane-id",
7318 "lane-proof",
7319 "--",
7320 "/bin/echo",
7321 "--child-flag",
7322 "hello",
7323 ]);
7324 let (proxy, command) = split_lane_log_proxy_command(cli.command);
7325 assert!(command.is_none());
7326 let proxy = proxy.expect("proxy args");
7327 assert_eq!(proxy.lane_id, "lane-proof");
7328 assert_eq!(
7329 proxy.command,
7330 ["/bin/echo", "--child-flag", "hello"].map(str::to_string)
7331 );
7332
7333 let cli = parse_ok(&["codewhale", "lane", "list", "--json"]);
7334 let (proxy, command) = split_lane_log_proxy_command(cli.command);
7335 assert!(proxy.is_none());
7336 assert!(matches!(
7337 command,
7338 Some(Commands::Lane(LaneArgs {
7339 command: LaneCommand::List { json: true }
7340 }))
7341 ));
7342 }
7343
7344 /// #1888: the CLI must expose exactly the Lane verbs the shared contract
7345 /// declares, under the same ids — no CLI-only verb, no missing verb.
7346 #[test]
7347 fn cli_lane_subcommands_cover_the_shared_control_contract() {
7348 use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface};
7349
7350 for descriptor in codewhale_lane::control::operations_for_domain(ControlDomain::Lane) {
7351 let argv = [
7352 "codewhale".to_string(),
7353 "lane".to_string(),
7354 descriptor.verb.to_string(),
7355 ];
7356 let mut argv: Vec<&str> = argv.iter().map(String::as_str).collect();
7357 if descriptor.target.requires_identity() {
7358 argv.push("lane-a1b2c3d4");
7359 }
7360 let cli = parse_ok(&argv);
7361 let Some(Commands::Lane(args)) = cli.command else {
7362 panic!("`{}` must parse as a lane subcommand", descriptor.verb);
7363 };
7364 let parsed = match args.command {
7365 LaneCommand::List { .. } => ControlOperation::LaneList,
7366 LaneCommand::Status { .. } => ControlOperation::LaneStatus,
7367 LaneCommand::Interrupt { .. } | LaneCommand::Stop { .. } => {
7368 ControlOperation::LaneInterrupt
7369 }
7370 LaneCommand::Restart { .. } => ControlOperation::LaneRestart,
7371 LaneCommand::Resume { .. } => ControlOperation::LaneResume,
7372 other => panic!(
7373 "unexpected lane subcommand for {}: {other:?}",
7374 descriptor.verb
7375 ),
7376 };
7377 assert_eq!(
7378 parsed, descriptor.operation,
7379 "`codewhale lane {}` must map to {}",
7380 descriptor.verb, descriptor.id
7381 );
7382 assert!(
7383 descriptor.offers(ControlSurface::Cli),
7384 "{} must be declared on the CLI surface",
7385 descriptor.id
7386 );
7387 }
7388 }
7389
7390 /// `lane stop` is a compatibility spelling, not a second verb.
7391 #[test]
7392 fn lane_stop_and_interrupt_resolve_to_one_verb() {
7393 use codewhale_lane::{ControlDomain, ControlOperation};
7394
7395 for spelling in ["stop", "interrupt", "cancel", "kill"] {
7396 assert_eq!(
7397 ControlOperation::parse_verb(ControlDomain::Lane, spelling),
7398 Some(ControlOperation::LaneInterrupt),
7399 "{spelling}"
7400 );
7401 }
7402 let stop = parse_ok(&["codewhale", "lane", "stop", "lane-a1b2c3d4"]);
7403 assert!(matches!(
7404 stop.command,
7405 Some(Commands::Lane(LaneArgs {
7406 command: LaneCommand::Stop { .. }
7407 }))
7408 ));
7409 }
7410
7411 #[test]
7412 fn short_workflow_names_do_not_resolve_version_pinned_files() {
7413 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
7414 .join("..")
7415 .join("..");
7416 // A bare short name must never expand to a version-pinned script.
7417 // The v0868_* lane scripts are gone, but the guard stays so a future
7418 // vXXXX_ naming habit cannot silently become resolvable.
7419 let candidates = workflow_source_candidates("issue-sweep", None, &workspace);
7420 assert!(candidates.iter().all(|path| {
7421 !path
7422 .file_name()
7423 .is_some_and(|name| name.to_string_lossy().starts_with("v0868_"))
7424 }));
7425 assert!(resolve_workflow_source_path("issue-sweep", None, &workspace).is_err());
7426
7427 // An explicit repo-relative path still resolves — checked against a
7428 // workflow that actually ships.
7429 let explicit =
7430 resolve_workflow_source_path("workflows/stopship.workflow.js", None, &workspace)
7431 .expect("explicit workflow path");
7432 assert!(explicit.ends_with("workflows/stopship.workflow.js"));
7433 }
7434
7435 #[test]
7436 fn workflow_run_resolves_stopship_alias_and_payload() {
7437 let _lock = env_lock();
7438 let (_dir, _tui) = install_fake_tui_binary();
7439 let _provider = ScopedEnvVar::remove("DEEPSEEK_PROVIDER");
7440 let _model = ScopedEnvVar::remove("DEEPSEEK_MODEL");
7441 let _base_url = ScopedEnvVar::remove("DEEPSEEK_BASE_URL");
7442 let _api_key = ScopedEnvVar::remove("DEEPSEEK_API_KEY");
7443 let _cli_api_key = ScopedEnvVar::remove("CODEWHALE_CLI_API_KEY");
7444 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
7445 .join("..")
7446 .join("..");
7447 let cli = parse_ok(&[
7448 "codewhale",
7449 "--profile",
7450 "workflow-profile",
7451 "--model",
7452 "explicit-workflow-model",
7453 "--api-key",
7454 "explicit-profile-key",
7455 "--workspace",
7456 workspace.to_str().expect("workspace UTF-8"),
7457 ]);
7458 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
7459 let source = resolve_workflow_source_path("stopship", None, &workspace)
7460 .expect("stopship workflow source");
7461 assert!(source.ends_with("workflows/stopship.workflow.js"));
7462
7463 let process = workflow_exec_command(WorkflowExecSpec {
7464 cli: &cli,
7465 resolved_runtime: &resolved,
7466 config_path: &workspace.join("config.toml"),
7467 source_root: &workspace,
7468 source_path: &source,
7469 workflow: "stopship",
7470 fleet: Some("stopship"),
7471 issue: Some("4375"),
7472 goal: Some("fix stopship"),
7473 token_budget: Some(25_000),
7474 verify: true,
7475 })
7476 .expect("command");
7477 let current_executable = std::env::current_exe().expect("current executable");
7478 assert_eq!(
7479 process.command.first().map(String::as_str),
7480 current_executable.to_str(),
7481 "workflow lanes must launch the exact runtime that built their process spec"
7482 );
7483 let joined = process.command.join("\n");
7484 assert!(joined.contains("workflow-tool"));
7485 assert!(joined.contains("explicit-workflow-command"));
7486 assert!(joined.contains("--input-json"));
7487 assert!(!process.command.iter().any(|arg| arg == "exec"));
7488 assert!(!process.command.iter().any(|arg| arg == "--workspace"));
7489 assert!(
7490 process
7491 .command
7492 .windows(2)
7493 .any(|pair| pair == ["--profile", "workflow-profile"])
7494 );
7495 assert!(!joined.contains("Run the CodeWhale"));
7496 assert!(joined.contains("\"source_path\":\"workflows/stopship.workflow.js\""));
7497 assert!(joined.contains("\"fleet\":\"stopship\""));
7498 assert!(joined.contains("\"issue\":\"4375\""));
7499 assert!(joined.contains("\"token_budget\":25000"));
7500 assert!(joined.contains("\"verify\":true"));
7501 assert!(
7502 process.environment.iter().any(|(key, value)| {
7503 key == "DEEPSEEK_MODEL" && value == "explicit-workflow-model"
7504 })
7505 );
7506 assert!(
7507 !process
7508 .environment
7509 .iter()
7510 .any(|(key, _)| key == "DEEPSEEK_PROVIDER")
7511 );
7512 assert!(
7513 !process
7514 .environment
7515 .iter()
7516 .any(|(key, _)| key == "DEEPSEEK_BASE_URL")
7517 );
7518 assert!(
7519 !process
7520 .environment
7521 .iter()
7522 .any(|(key, _)| key == "DEEPSEEK_API_KEY")
7523 );
7524 assert!(process.environment.iter().any(|(key, value)| {
7525 key == "CODEWHALE_CLI_API_KEY" && value == "explicit-profile-key"
7526 }));
7527 assert!(
7528 !process
7529 .command
7530 .iter()
7531 .any(|argument| argument.contains("explicit-profile-key"))
7532 );
7533 assert!(
7534 process
7535 .environment
7536 .iter()
7537 .all(|(_, value)| value != "test-model")
7538 );
7539 }
7540
7541 #[test]
7542 fn exec_keeps_global_looking_flags_as_passthrough_args() {
7543 let cli = parse_ok(&[
7544 "codewhale",
7545 "exec",
7546 "--provider",
7547 "definitely-not-a-provider",
7548 "Reply OK",
7549 ]);
7550
7551 let Some(Commands::Exec(args)) = cli.command else {
7552 panic!("expected exec command");
7553 };
7554
7555 assert_eq!(
7556 args.args,
7557 vec![
7558 "--provider".to_string(),
7559 "definitely-not-a-provider".to_string(),
7560 "Reply OK".to_string(),
7561 ]
7562 );
7563 }
7564
7565 #[test]
7566 fn exec_rejects_provider_after_subcommand() {
7567 let args = vec![
7568 "--provider".to_string(),
7569 "definitely-not-a-provider".to_string(),
7570 "Reply OK".to_string(),
7571 ];
7572
7573 let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
7574
7575 assert!(
7576 err.to_string()
7577 .contains("--provider must be placed before `exec`")
7578 );
7579 }
7580
7581 #[test]
7582 fn exec_rejects_equals_form_provider_after_subcommand() {
7583 let args = vec!["--provider=openmodel".to_string(), "Reply OK".to_string()];
7584
7585 let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
7586
7587 assert!(
7588 err.to_string()
7589 .contains("--provider must be placed before `exec`")
7590 );
7591 }
7592
7593 #[test]
7594 fn exec_allows_documented_forwarded_flags() {
7595 let args = vec![
7596 "--auto".to_string(),
7597 "--output-format".to_string(),
7598 "stream-json".to_string(),
7599 "fix tests".to_string(),
7600 ];
7601
7602 reject_exec_global_flags(&args).expect("documented exec flags should pass");
7603 }
7604
7605 #[test]
7606 fn exec_allows_literal_prompt_flags_after_separator() {
7607 let args = vec![
7608 "--".to_string(),
7609 "--provider".to_string(),
7610 "is literal prompt text".to_string(),
7611 ];
7612
7613 reject_exec_global_flags(&args).expect("separator should stop global flag validation");
7614 }
7615
7616 #[test]
7617 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
7618 assert!(should_pick_resume_in_dispatcher(
7619 &["resume".to_string()],
7620 true
7621 ));
7622 assert!(!should_pick_resume_in_dispatcher(
7623 &["resume".to_string(), "--last".to_string()],
7624 true
7625 ));
7626 assert!(!should_pick_resume_in_dispatcher(
7627 &["resume".to_string(), "abc123".to_string()],
7628 true
7629 ));
7630 assert!(!should_pick_resume_in_dispatcher(
7631 &["resume".to_string()],
7632 false
7633 ));
7634 }
7635
7636 #[test]
7637 fn auth_set_uses_isolated_file_store_and_preserves_tui_defaults() {
7638 let _lock = env_lock();
7639 let dir = tempfile::TempDir::new().expect("tempdir");
7640 let codewhale_home = dir.path().join("codewhale-home");
7641 let codewhale_home_value = codewhale_home.to_string_lossy().into_owned();
7642 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home_value);
7643 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
7644 let path = codewhale_home.join("config.toml");
7645 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7646 let secrets = Secrets::auto_detect();
7647
7648 run_auth_command_with_secrets(
7649 &mut store,
7650 AuthCommand::Set {
7651 provider: ProviderKind::Deepseek,
7652 api_key: Some("sk-test".to_string()),
7653 api_key_stdin: false,
7654 },
7655 &secrets,
7656 )
7657 .expect("auth set should persist credential");
7658
7659 assert!(store.config.api_key.is_none());
7660 assert!(store.config.providers.deepseek.api_key.is_none());
7661 assert_eq!(
7662 store.config.default_text_model.as_deref(),
7663 Some("deepseek-v4-pro")
7664 );
7665 let saved = std::fs::read_to_string(&path).expect("config should be written");
7666 assert!(!saved.contains("sk-test"), "{saved}");
7667 assert!(
7668 !saved
7669 .lines()
7670 .any(|line| line.trim_start().starts_with("api_key="))
7671 );
7672 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
7673 assert_eq!(
7674 secrets.get("deepseek").expect("read secret").as_deref(),
7675 Some("sk-test")
7676 );
7677 }
7678
7679 /// `codewhale login` now means the Codewhale account device flow: the
7680 /// account-login flags parse through and reach the cloud path.
7681 #[test]
7682 fn login_parses_account_device_flow_flags() {
7683 let cli = parse_ok(&["codewhale", "login", "--no-open", "--timeout-seconds", "5"]);
7684 let Some(Commands::Login(args)) = cli.command else {
7685 panic!("expected Login");
7686 };
7687 assert!(args.no_open);
7688 assert_eq!(args.timeout_seconds, 5);
7689 assert!(args.api_key.is_none());
7690 assert!(args.provider.is_none());
7691
7692 let cli = parse_ok(&["codewhale", "login"]);
7693 let Some(Commands::Login(args)) = cli.command else {
7694 panic!("expected Login");
7695 };
7696 assert!(!args.no_open);
7697 assert_eq!(args.timeout_seconds, 600);
7698 }
7699
7700 /// The provider-key surface moved to `auth set --provider`; the hidden
7701 /// legacy flags must redirect loudly instead of silently configuring a key.
7702 #[test]
7703 fn login_rejects_legacy_provider_flags_with_redirect() {
7704 let err = reject_legacy_login_provider_args(&LoginArgs {
7705 no_open: false,
7706 timeout_seconds: 600,
7707 api_key: Some("sk-x".to_string()),
7708 provider: None,
7709 })
7710 .expect_err("legacy --api-key must be rejected");
7711 let rendered = err.to_string();
7712 assert!(
7713 rendered.contains("auth set --provider"),
7714 "redirect must name `auth set --provider`: {rendered}"
7715 );
7716
7717 let err = reject_legacy_login_provider_args(&LoginArgs {
7718 no_open: false,
7719 timeout_seconds: 600,
7720 api_key: None,
7721 provider: Some(ProviderKind::Deepseek),
7722 })
7723 .expect_err("legacy --provider must be rejected");
7724 assert!(
7725 err.to_string().contains("auth set --provider"),
7726 "redirect must name `auth set --provider`"
7727 );
7728
7729 reject_legacy_login_provider_args(&LoginArgs {
7730 no_open: false,
7731 timeout_seconds: 600,
7732 api_key: None,
7733 provider: None,
7734 })
7735 .expect("plain account login carries no legacy flags");
7736 }
7737
7738 /// Root help keeps the `login` token, but its meaning is now the account
7739 /// sign-in; the subcommand help must say so.
7740 #[test]
7741 fn login_help_describes_account_signin() {
7742 let help = help_for(&["codewhale", "login", "--help"]);
7743 assert!(
7744 help.contains("Codewhale account"),
7745 "login help must describe account sign-in: {help}"
7746 );
7747 assert!(
7748 !help.to_lowercase().contains("api key"),
7749 "login help must not advertise provider API keys: {help}"
7750 );
7751 }
7752
7753 #[test]
7754 fn auth_parses_daytona_slot_commands_as_unknown() {
7755 // The internal cloud-agent slot must not be a user command: parsing
7756 // rejects it and `auth --help` never teaches it.
7757 for argv in [
7758 vec![
7759 "codewhale",
7760 "auth",
7761 "set-slot",
7762 "daytona",
7763 "--api-key-stdin",
7764 ],
7765 vec!["codewhale", "auth", "clear-slot", "daytona"],
7766 ] {
7767 let error = Cli::try_parse_from(argv).expect_err("slot commands must not parse");
7768 assert_eq!(error.kind(), ErrorKind::InvalidSubcommand, "{error}");
7769 }
7770 let help = help_for(&["codewhale", "auth", "--help"]);
7771 assert!(!help.contains("set-slot"), "{help}");
7772 assert!(!help.contains("clear-slot"), "{help}");
7773 assert!(!help.to_lowercase().contains("daytona"), "{help}");
7774 }
7775
7776 /// #5198: `auth set` shares the login resolver — provider auth markers go
7777 /// user-global even when the ambient config is workspace-scoped.
7778 #[test]
7779 fn auth_set_with_repo_scoped_ambient_config_writes_user_global_metadata() {
7780 let _lock = env_lock();
7781 let dir = tempfile::TempDir::new().expect("tempdir");
7782 let repo = dir.path().join("repo");
7783 std::fs::create_dir_all(repo.join(".git")).expect("git marker");
7784 let repo_config_dir = repo.join(".codewhale");
7785 std::fs::create_dir_all(&repo_config_dir).expect("repo config dir");
7786 let repo_config = repo_config_dir.join("config.toml");
7787 std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config");
7788
7789 let codewhale_home = dir.path().join("codewhale-home");
7790 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy());
7791 let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy());
7792 let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
7793 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
7794 let mut store = ConfigStore::load(None).expect("ambient store should load");
7795 let secrets = Secrets::auto_detect();
7796
7797 run_auth_command_with_secrets(
7798 &mut store,
7799 AuthCommand::Set {
7800 provider: ProviderKind::Openrouter,
7801 api_key: Some("sk-or-repo-scoped".to_string()),
7802 api_key_stdin: false,
7803 },
7804 &secrets,
7805 )
7806 .expect("auth set should persist credential");
7807
7808 assert_eq!(
7809 secrets.get("openrouter").expect("read secret").as_deref(),
7810 Some("sk-or-repo-scoped")
7811 );
7812 let global = std::fs::read_to_string(codewhale_home.join("config.toml"))
7813 .expect("user-global config");
7814 assert!(
7815 global.contains("auth_mode = \"api_key\""),
7816 "user-global config must carry the auth markers: {global}"
7817 );
7818 assert!(
7819 global.contains("openrouter"),
7820 "user-global config must name the provider table: {global}"
7821 );
7822 assert!(!global.contains("sk-or-repo-scoped"), "{global}");
7823 let repo_after = std::fs::read_to_string(&repo_config).expect("repo config");
7824 assert_eq!(
7825 repo_after, "approval_policy = \"never\"\n",
7826 "workspace config must stay untouched by credential metadata: {repo_after}"
7827 );
7828 }
7829
7830 #[test]
7831 fn parses_auth_subcommand_matrix() {
7832 let cli = parse_ok(&["deepseek", "auth", "xai-device"]);
7833 assert!(matches!(
7834 cli.command,
7835 Some(Commands::Auth(AuthArgs {
7836 command: AuthCommand::XaiDevice
7837 }))
7838 ));
7839
7840 let cli = parse_ok(&["deepseek", "auth", "chatgpt"]);
7841 assert!(matches!(
7842 cli.command,
7843 Some(Commands::Auth(AuthArgs {
7844 command: AuthCommand::Chatgpt
7845 }))
7846 ));
7847
7848 let cli = parse_ok(&["deepseek", "auth", "chatgpt-revoke"]);
7849 assert!(matches!(
7850 cli.command,
7851 Some(Commands::Auth(AuthArgs {
7852 command: AuthCommand::ChatgptRevoke
7853 }))
7854 ));
7855
7856 let cli = parse_ok(&[
7857 "deepseek",
7858 "auth",
7859 "external-consent",
7860 "--provider",
7861 "openai-codex",
7862 "--mode",
7863 "read-only",
7864 "--path",
7865 "/tmp/codex-auth.json",
7866 "--yes",
7867 ]);
7868 assert!(matches!(
7869 cli.command,
7870 Some(Commands::Auth(AuthArgs {
7871 command: AuthCommand::ExternalConsent {
7872 provider: ProviderKind::OpenaiCodex,
7873 mode: ExternalCredentialModeArg::ReadOnly,
7874 path: Some(_),
7875 yes: true,
7876 }
7877 }))
7878 ));
7879
7880 let cli = parse_ok(&["deepseek", "auth", "external-revoke", "--provider", "xai"]);
7881 assert!(matches!(
7882 cli.command,
7883 Some(Commands::Auth(AuthArgs {
7884 command: AuthCommand::ExternalRevoke {
7885 provider: ProviderKind::Xai,
7886 }
7887 }))
7888 ));
7889
7890 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
7891 assert!(matches!(
7892 cli.command,
7893 Some(Commands::Auth(AuthArgs {
7894 command: AuthCommand::Set {
7895 provider: ProviderKind::Deepseek,
7896 api_key: None,
7897 api_key_stdin: false,
7898 }
7899 }))
7900 ));
7901
7902 let cli = parse_ok(&[
7903 "deepseek",
7904 "auth",
7905 "set",
7906 "--provider",
7907 "openrouter",
7908 "--api-key-stdin",
7909 ]);
7910 assert!(matches!(
7911 cli.command,
7912 Some(Commands::Auth(AuthArgs {
7913 command: AuthCommand::Set {
7914 provider: ProviderKind::Openrouter,
7915 api_key: None,
7916 api_key_stdin: true,
7917 }
7918 }))
7919 ));
7920
7921 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
7922 assert!(matches!(
7923 cli.command,
7924 Some(Commands::Auth(AuthArgs {
7925 command: AuthCommand::Get {
7926 provider: ProviderKind::Novita
7927 }
7928 }))
7929 ));
7930
7931 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
7932 assert!(matches!(
7933 cli.command,
7934 Some(Commands::Auth(AuthArgs {
7935 command: AuthCommand::Clear {
7936 provider: ProviderKind::NvidiaNim
7937 }
7938 }))
7939 ));
7940
7941 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
7942 assert!(matches!(
7943 cli.command,
7944 Some(Commands::Auth(AuthArgs {
7945 command: AuthCommand::Set {
7946 provider: ProviderKind::Fireworks,
7947 api_key: None,
7948 api_key_stdin: false,
7949 }
7950 }))
7951 ));
7952
7953 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
7954 assert!(matches!(
7955 cli.command,
7956 Some(Commands::Auth(AuthArgs {
7957 command: AuthCommand::Set {
7958 provider: ProviderKind::Siliconflow,
7959 api_key: None,
7960 api_key_stdin: false,
7961 }
7962 }))
7963 ));
7964
7965 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
7966 assert!(matches!(
7967 cli.command,
7968 Some(Commands::Auth(AuthArgs {
7969 command: AuthCommand::Set {
7970 provider: ProviderKind::Arcee,
7971 api_key: None,
7972 api_key_stdin: false,
7973 }
7974 }))
7975 ));
7976
7977 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
7978 assert!(matches!(
7979 cli.command,
7980 Some(Commands::Auth(AuthArgs {
7981 command: AuthCommand::Set {
7982 provider: ProviderKind::Moonshot,
7983 api_key: None,
7984 api_key_stdin: false,
7985 }
7986 }))
7987 ));
7988
7989 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
7990 assert!(matches!(
7991 cli.command,
7992 Some(Commands::Auth(AuthArgs {
7993 command: AuthCommand::Set {
7994 provider: ProviderKind::WanjieArk,
7995 api_key: None,
7996 api_key_stdin: false,
7997 }
7998 }))
7999 ));
8000
8001 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
8002 assert!(matches!(
8003 cli.command,
8004 Some(Commands::Auth(AuthArgs {
8005 command: AuthCommand::Get {
8006 provider: ProviderKind::Sglang
8007 }
8008 }))
8009 ));
8010
8011 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
8012 assert!(matches!(
8013 cli.command,
8014 Some(Commands::Auth(AuthArgs {
8015 command: AuthCommand::Get {
8016 provider: ProviderKind::Vllm
8017 }
8018 }))
8019 ));
8020
8021 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
8022 assert!(matches!(
8023 cli.command,
8024 Some(Commands::Auth(AuthArgs {
8025 command: AuthCommand::Set {
8026 provider: ProviderKind::Ollama,
8027 api_key: None,
8028 api_key_stdin: false,
8029 }
8030 }))
8031 ));
8032
8033 let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
8034 assert!(matches!(
8035 cli.command,
8036 Some(Commands::Auth(AuthArgs {
8037 command: AuthCommand::Status {
8038 provider: Some(ProviderKind::OpenaiCodex),
8039 diagnostic: false,
8040 }
8041 }))
8042 ));
8043
8044 let cli = parse_ok(&[
8045 "deepseek",
8046 "auth",
8047 "status",
8048 "--diagnostic",
8049 "--provider",
8050 "deepseek",
8051 ]);
8052 assert!(matches!(
8053 cli.command,
8054 Some(Commands::Auth(AuthArgs {
8055 command: AuthCommand::Status {
8056 provider: Some(ProviderKind::Deepseek),
8057 diagnostic: true,
8058 }
8059 }))
8060 ));
8061
8062 for (provider, expected) in [
8063 ("anthropic", ProviderKind::Anthropic),
8064 ("openmodel", ProviderKind::Openmodel),
8065 ("open-model", ProviderKind::Openmodel),
8066 ("zai", ProviderKind::Zai),
8067 ("stepfun", ProviderKind::Stepfun),
8068 ("minimax", ProviderKind::Minimax),
8069 ("minimax-anthropic", ProviderKind::MinimaxAnthropic),
8070 ("minimax_anthropic", ProviderKind::MinimaxAnthropic),
8071 ("deepinfra", ProviderKind::Deepinfra),
8072 ("deep-infra", ProviderKind::Deepinfra),
8073 ("siliconflow-cn", ProviderKind::SiliconflowCN),
8074 ("siliconflow-CN", ProviderKind::SiliconflowCN),
8075 ("siliconflow_china", ProviderKind::SiliconflowCN),
8076 ] {
8077 let cli = parse_ok(&[
8078 "deepseek",
8079 "auth",
8080 "set",
8081 "--provider",
8082 provider,
8083 "--api-key-stdin",
8084 ]);
8085 assert!(matches!(
8086 cli.command,
8087 Some(Commands::Auth(AuthArgs {
8088 command: AuthCommand::Set {
8089 provider,
8090 api_key: None,
8091 api_key_stdin: true,
8092 }
8093 })) if provider == expected
8094 ));
8095 }
8096
8097 let cli = parse_ok(&["deepseek", "auth", "list"]);
8098 assert!(matches!(
8099 cli.command,
8100 Some(Commands::Auth(AuthArgs {
8101 command: AuthCommand::List
8102 }))
8103 ));
8104
8105 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
8106 assert!(matches!(
8107 cli.command,
8108 Some(Commands::Auth(AuthArgs {
8109 command: AuthCommand::Migrate { dry_run: false }
8110 }))
8111 ));
8112
8113 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
8114 assert!(matches!(
8115 cli.command,
8116 Some(Commands::Auth(AuthArgs {
8117 command: AuthCommand::Migrate { dry_run: true }
8118 }))
8119 ));
8120 }
8121
8122 #[test]
8123 fn auth_help_describes_runtime_effective_diagnostics() {
8124 let get = help_for(&["codewhale", "auth", "get", "--help"]);
8125 assert!(get.contains("effective credential route"), "{get}");
8126 assert!(get.contains("structural OAuth/repair state"), "{get}");
8127
8128 let status = help_for(&["codewhale", "auth", "status", "--help"]);
8129 assert!(
8130 status.contains("runtime-effective credential route state"),
8131 "{status}"
8132 );
8133
8134 let list = help_for(&["codewhale", "auth", "list", "--help"]);
8135 assert!(list.contains("runtime-effective auth state"), "{list}");
8136 }
8137
8138 #[test]
8139 fn auth_set_writes_secret_store_and_keeps_config_credential_free() {
8140 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8141 use std::sync::Arc;
8142
8143 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8144 let path = std::env::temp_dir().join(format!(
8145 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
8146 std::process::id()
8147 ));
8148 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8149 let inner = Arc::new(InMemoryKeyringStore::new());
8150 let secrets = Secrets::new(inner.clone());
8151
8152 run_auth_command_with_secrets(
8153 &mut store,
8154 AuthCommand::Set {
8155 provider: ProviderKind::Deepseek,
8156 api_key: Some("sk-keyring".to_string()),
8157 api_key_stdin: false,
8158 },
8159 &secrets,
8160 )
8161 .expect("set should succeed");
8162
8163 assert!(store.config.api_key.is_none());
8164 assert!(store.config.providers.deepseek.api_key.is_none());
8165 let saved = std::fs::read_to_string(&path).unwrap_or_default();
8166 assert!(!saved.contains("sk-keyring"), "{saved}");
8167 assert!(
8168 !saved
8169 .lines()
8170 .any(|line| line.trim_start().starts_with("api_key ="))
8171 );
8172 assert_eq!(
8173 inner.get("deepseek").unwrap().as_deref(),
8174 Some("sk-keyring")
8175 );
8176
8177 let _ = std::fs::remove_file(path);
8178 }
8179
8180 #[test]
8181 fn auth_set_refuses_plaintext_config_when_secret_store_write_fails() {
8182 use codewhale_secrets::{KeyringStore, SecretsError};
8183 use std::sync::Arc;
8184
8185 struct FailingStore;
8186
8187 impl KeyringStore for FailingStore {
8188 fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
8189 Ok(None)
8190 }
8191
8192 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
8193 Err(SecretsError::Keyring("test write failure".to_string()))
8194 }
8195
8196 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
8197 Ok(())
8198 }
8199
8200 fn backend_name(&self) -> &'static str {
8201 "failing test store"
8202 }
8203 }
8204
8205 let dir = tempfile::TempDir::new().expect("tempdir");
8206 let path = dir.path().join("config.toml");
8207 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
8208 let secrets = Secrets::new(Arc::new(FailingStore));
8209
8210 let error = run_auth_command_with_secrets(
8211 &mut store,
8212 AuthCommand::Set {
8213 provider: ProviderKind::Openrouter,
8214 api_key: Some("fallback-test-credential".to_string()),
8215 api_key_stdin: false,
8216 },
8217 &secrets,
8218 )
8219 .expect_err("secret-store failure must not downgrade to plaintext");
8220
8221 let message = format!("{error:#}");
8222 assert!(message.contains("Secret storage write failed"), "{message}");
8223 assert!(message.contains("Refusing"), "{message}");
8224 assert!(
8225 message.contains(&codewhale_config::quote_os_path(store.path())),
8226 "{message}"
8227 );
8228 assert!(store.config.providers.openrouter.api_key.is_none());
8229 assert!(!path.exists(), "plaintext config must stay untouched");
8230 }
8231
8232 #[test]
8233 fn auth_set_provider_key_does_not_switch_active_provider() {
8234 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8235 let path = std::env::temp_dir().join(format!(
8236 "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
8237 std::process::id()
8238 ));
8239 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8240 store.config.provider = ProviderKind::Deepseek;
8241 let secrets = no_keyring_secrets();
8242
8243 run_auth_command_with_secrets(
8244 &mut store,
8245 AuthCommand::Set {
8246 provider: ProviderKind::Arcee,
8247 api_key: Some("arcee-key".to_string()),
8248 api_key_stdin: false,
8249 },
8250 &secrets,
8251 )
8252 .expect("set should succeed");
8253
8254 assert_eq!(store.config.provider, ProviderKind::Deepseek);
8255 assert!(store.config.providers.arcee.api_key.is_none());
8256 assert_eq!(
8257 store.config.providers.arcee.auth_mode.as_deref(),
8258 Some("api_key")
8259 );
8260
8261 let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
8262 assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
8263 assert!(reloaded.config.providers.arcee.api_key.is_none());
8264 assert_eq!(
8265 reloaded.config.providers.arcee.auth_mode.as_deref(),
8266 Some("api_key")
8267 );
8268
8269 let _ = std::fs::remove_file(path);
8270 }
8271
8272 #[test]
8273 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
8274 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8275 let path = std::env::temp_dir().join(format!(
8276 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
8277 std::process::id()
8278 ));
8279 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8280 store.config.provider = ProviderKind::Deepseek;
8281 let secrets = no_keyring_secrets();
8282
8283 run_auth_command_with_secrets(
8284 &mut store,
8285 AuthCommand::Set {
8286 provider: ProviderKind::Ollama,
8287 api_key: None,
8288 api_key_stdin: false,
8289 },
8290 &secrets,
8291 )
8292 .expect("ollama auth set should not require a key");
8293
8294 assert_eq!(store.config.provider, ProviderKind::Deepseek);
8295 assert_eq!(
8296 store.config.providers.ollama.base_url.as_deref(),
8297 Some("http://localhost:11434/v1")
8298 );
8299 assert_eq!(store.config.providers.ollama.api_key, None);
8300
8301 let _ = std::fs::remove_file(path);
8302 }
8303
8304 #[test]
8305 fn auth_clear_removes_from_config() {
8306 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8307 use std::sync::Arc;
8308
8309 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8310 let path = std::env::temp_dir().join(format!(
8311 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
8312 std::process::id()
8313 ));
8314 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8315 store.config.api_key = Some("sk-stale".to_string());
8316 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
8317 store.save().unwrap();
8318
8319 let inner = Arc::new(InMemoryKeyringStore::new());
8320 inner.set("deepseek", "sk-stale").unwrap();
8321 let secrets = Secrets::new(inner.clone());
8322
8323 run_auth_command_with_secrets(
8324 &mut store,
8325 AuthCommand::Clear {
8326 provider: ProviderKind::Deepseek,
8327 },
8328 &secrets,
8329 )
8330 .expect("clear should succeed");
8331
8332 assert!(store.config.api_key.is_none());
8333 assert!(store.config.providers.deepseek.api_key.is_none());
8334 assert_eq!(inner.get("deepseek").unwrap(), None);
8335
8336 let _ = std::fs::remove_file(path);
8337 }
8338
8339 #[test]
8340 fn antigravity_clear_removes_only_codewhale_owned_legacy_state() {
8341 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8342 use std::sync::Arc;
8343
8344 let dir = tempfile::TempDir::new().expect("isolated legacy fixture");
8345 let config_path = dir.path().join("config.toml");
8346 let external_session_path = dir.path().join("external-antigravity-session.db");
8347 let external_session = b"external session bytes must remain unchanged";
8348 std::fs::write(&external_session_path, external_session)
8349 .expect("write external session trap");
8350
8351 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load empty config");
8352 store.config.provider = ProviderKind::Antigravity;
8353 store.config.fallback_providers = vec![ProviderKind::Antigravity, ProviderKind::Google];
8354 {
8355 let legacy = &mut store.config.providers.antigravity;
8356 legacy.api_key = Some("legacy-codewhale-fixture-key".to_string());
8357 legacy.base_url = Some("https://legacy.invalid/v1".to_string());
8358 legacy.model = Some("legacy-fixture-model".to_string());
8359 legacy.context_window = Some(1234);
8360 legacy.mode = Some("legacy-fixture-mode".to_string());
8361 legacy.wire = Some("legacy-fixture-wire".to_string());
8362 legacy.auth_mode = Some("oauth".to_string());
8363 legacy.insecure_skip_tls_verify = Some(true);
8364 legacy
8365 .http_headers
8366 .insert("X-Legacy-Fixture".to_string(), "fixture".to_string());
8367 legacy.path_suffix = Some("legacy-fixture-path".to_string());
8368 legacy.external_credentials =
8369 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8370 ProviderKind::Antigravity,
8371 codewhale_config::ExternalCredentialSource::AgyCli,
8372 external_session_path.clone(),
8373 ));
8374 legacy.extras.insert(
8375 "legacy_fixture_extra".to_string(),
8376 toml::Value::String("remove-me".to_string()),
8377 );
8378 }
8379 store.config.providers.google.api_key = Some("google-fixture-key".to_string());
8380 store.config.providers.google.base_url = Some("https://google.example/v1".to_string());
8381 store.config.providers.google.model = Some("google-fixture-model".to_string());
8382 store.save().expect("save legacy fixture");
8383
8384 // Released configs accepted the short `[providers.agy]` table alias.
8385 // Exercise that on-disk spelling as well as the clear command's alias.
8386 let canonical = std::fs::read_to_string(&config_path).expect("read canonical fixture");
8387 let alias = canonical.replace("[providers.antigravity", "[providers.agy");
8388 std::fs::write(&config_path, alias).expect("write legacy alias fixture");
8389 let mut store = ConfigStore::load(Some(config_path.clone())).expect("reload alias fixture");
8390
8391 let inner = Arc::new(InMemoryKeyringStore::new());
8392 inner
8393 .set("antigravity", "legacy-codewhale-secret-slot")
8394 .expect("seed Codewhale-owned legacy secret slot");
8395 let secrets = Secrets::new(inner.clone());
8396
8397 run_auth_command_with_secrets(
8398 &mut store,
8399 AuthCommand::Clear {
8400 provider: ProviderKind::Antigravity,
8401 },
8402 &secrets,
8403 )
8404 .expect("legacy clear should succeed");
8405
8406 assert_eq!(store.config.provider, ProviderKind::default());
8407 assert_eq!(store.config.fallback_providers, vec![ProviderKind::Google]);
8408 assert!(store.config.providers.antigravity.is_empty());
8409 assert_eq!(inner.get("antigravity").unwrap(), None);
8410 assert_eq!(
8411 store.config.providers.google.api_key.as_deref(),
8412 Some("google-fixture-key")
8413 );
8414 assert_eq!(
8415 store.config.providers.google.base_url.as_deref(),
8416 Some("https://google.example/v1")
8417 );
8418 assert_eq!(
8419 store.config.providers.google.model.as_deref(),
8420 Some("google-fixture-model")
8421 );
8422 assert_eq!(
8423 std::fs::read(&external_session_path).expect("external session trap still exists"),
8424 external_session
8425 );
8426
8427 let raw = std::fs::read_to_string(&config_path).expect("read cleared config");
8428 assert!(!raw.contains("[providers.antigravity"), "{raw}");
8429 assert!(!raw.contains("[providers.agy"), "{raw}");
8430 assert!(!raw.contains("legacy_fixture_extra"), "{raw}");
8431 assert!(raw.contains("[providers.google]"), "{raw}");
8432
8433 let backup_path = config_path.with_file_name(format!(
8434 "{}.bak",
8435 config_path
8436 .file_name()
8437 .expect("config fixture has a file name")
8438 .to_string_lossy()
8439 ));
8440 let backup = std::fs::read_to_string(backup_path).expect("read cleared config backup");
8441 assert!(!backup.contains("[providers.antigravity"), "{backup}");
8442 assert!(!backup.contains("[providers.agy"), "{backup}");
8443 assert!(!backup.contains("legacy_fixture_extra"), "{backup}");
8444 assert!(
8445 !backup.contains(&external_session_path.to_string_lossy().to_string()),
8446 "{backup}"
8447 );
8448 assert!(
8449 backup.contains("base_url = \"https://google.example/v1\""),
8450 "{backup}"
8451 );
8452 assert!(
8453 backup.contains("model = \"google-fixture-model\""),
8454 "{backup}"
8455 );
8456
8457 let reloaded = ConfigStore::load(Some(config_path)).expect("reload cleared config");
8458 assert_eq!(reloaded.config.provider, ProviderKind::default());
8459 assert!(reloaded.config.providers.antigravity.is_empty());
8460 assert_eq!(
8461 reloaded.config.providers.google.api_key.as_deref(),
8462 Some("google-fixture-key")
8463 );
8464 }
8465
8466 #[test]
8467 fn antigravity_clear_restores_codewhale_secret_when_config_write_fails() {
8468 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8469 use std::sync::Arc;
8470
8471 let dir = tempfile::TempDir::new().expect("isolated rollback fixture");
8472 let config_path = dir.path().join("config.toml");
8473 let external_session_path = dir.path().join("external-session.db");
8474 let external_session = b"external session rollback trap";
8475 std::fs::write(&external_session_path, external_session)
8476 .expect("write external session trap");
8477 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load absent config");
8478 store.config.provider = ProviderKind::Antigravity;
8479 store.config.fallback_providers = vec![ProviderKind::Antigravity];
8480 store.config.providers.antigravity.api_key = Some("legacy-config-fixture".to_string());
8481 store.config.providers.antigravity.external_credentials =
8482 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8483 ProviderKind::Antigravity,
8484 codewhale_config::ExternalCredentialSource::AgyCli,
8485 external_session_path.clone(),
8486 ));
8487 std::fs::create_dir(&config_path).expect("make config target unwritable as a file");
8488
8489 let inner = Arc::new(InMemoryKeyringStore::new());
8490 inner
8491 .set("antigravity", "legacy-secret-fixture")
8492 .expect("seed Codewhale-owned legacy slot");
8493 let secrets = Secrets::new(inner.clone());
8494
8495 run_auth_command_with_secrets(
8496 &mut store,
8497 AuthCommand::Clear {
8498 provider: ProviderKind::Antigravity,
8499 },
8500 &secrets,
8501 )
8502 .expect_err("config failure must fail the clear transaction");
8503
8504 assert_eq!(store.config.provider, ProviderKind::Antigravity);
8505 assert_eq!(
8506 store.config.fallback_providers,
8507 vec![ProviderKind::Antigravity]
8508 );
8509 assert_eq!(
8510 store.config.providers.antigravity.api_key.as_deref(),
8511 Some("legacy-config-fixture")
8512 );
8513 assert!(
8514 store
8515 .config
8516 .providers
8517 .antigravity
8518 .external_credentials
8519 .is_some()
8520 );
8521 assert_eq!(
8522 inner
8523 .get("antigravity")
8524 .expect("read restored slot")
8525 .as_deref(),
8526 Some("legacy-secret-fixture")
8527 );
8528 assert_eq!(
8529 std::fs::read(external_session_path).expect("external session trap still exists"),
8530 external_session
8531 );
8532 }
8533
8534 #[test]
8535 fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
8536 use codewhale_secrets::{KeyringStore, SecretsError};
8537 use std::sync::{Arc, Mutex};
8538
8539 #[derive(Default)]
8540 struct RecordingStore {
8541 gets: Mutex<Vec<String>>,
8542 }
8543
8544 impl KeyringStore for RecordingStore {
8545 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
8546 self.gets.lock().unwrap().push(key.to_string());
8547 Ok(None)
8548 }
8549
8550 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
8551 Ok(())
8552 }
8553
8554 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
8555 Ok(())
8556 }
8557
8558 fn backend_name(&self) -> &'static str {
8559 "recording"
8560 }
8561 }
8562
8563 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8564 let path = std::env::temp_dir().join(format!(
8565 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
8566 std::process::id()
8567 ));
8568 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8569 store.config.provider = ProviderKind::Deepseek;
8570 let inner = Arc::new(RecordingStore::default());
8571 let secrets = Secrets::new(inner.clone());
8572
8573 run_auth_command_with_secrets(
8574 &mut store,
8575 AuthCommand::Status {
8576 provider: Some(ProviderKind::Deepseek),
8577 diagnostic: false,
8578 },
8579 &secrets,
8580 )
8581 .expect("status should succeed");
8582 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
8583 .expect("list should succeed");
8584
8585 let probed = inner.gets.lock().unwrap();
8586 // Scoped status probes only the requested provider.
8587 assert_eq!(probed[0], "deepseek");
8588 // List now probes all providers (not just active) to fix the
8589 // stale keyring-only-for-active-provider bug.
8590 assert!(probed.len() > 1, "list should probe all providers");
8591 assert!(
8592 ProviderKind::ALL
8593 .iter()
8594 .all(|p| probed.contains(&provider_slot(*p).to_string())),
8595 "every known provider should be probed by auth list: {:?}",
8596 *probed
8597 );
8598
8599 let _ = std::fs::remove_file(path);
8600 }
8601
8602 #[test]
8603 fn auth_diagnostic_reports_paths_and_presence_without_values() {
8604 let _lock = env_lock();
8605 let fixture = tempfile::TempDir::new().expect("fixture root");
8606 // macOS spells /var through a /private symlink. Canonicalize the
8607 // fixture root so the metadata-only backend diagnostic can prove every
8608 // ancestor is a real directory instead of truthfully returning
8609 // `unknown` for the symlinked spelling.
8610 let home = fixture
8611 .path()
8612 .canonicalize()
8613 .expect("canonical fixture root")
8614 .join("isolated-codewhale-home");
8615 let config_path = home.join("config.toml");
8616 let settings_path = home.join("settings.toml");
8617 let secret_path = home.join("secrets").join("secrets.json");
8618 std::fs::create_dir_all(secret_path.parent().expect("secret parent"))
8619 .expect("create diagnostic fixture");
8620 std::fs::write(
8621 &config_path,
8622 "api_key = \"diagnostic-config-secret-1234\"\n",
8623 )
8624 .expect("write config fixture");
8625 std::fs::write(&settings_path, "default_mode = \"plan\"\n")
8626 .expect("write settings fixture");
8627 std::fs::write(
8628 &secret_path,
8629 r#"{"deepseek":"diagnostic-store-secret-5678"}"#,
8630 )
8631 .expect("write secret fixture");
8632
8633 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
8634 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
8635 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "diagnostic-env-secret-9012");
8636 let store = ConfigStore::load(Some(config_path.clone())).expect("load config fixture");
8637
8638 let output = auth_diagnostic_lines(&store, Some(ProviderKind::Deepseek)).join("\n");
8639 assert!(
8640 output.contains(&format!(
8641 "codewhale home: {} (source: CODEWHALE_HOME (isolated); state: present)",
8642 codewhale_config::quote_os_path(&home)
8643 )),
8644 "{output}"
8645 );
8646 assert!(
8647 output.contains(&format!(
8648 "config: {} (present)",
8649 codewhale_config::quote_os_path(&config_path)
8650 )),
8651 "{output}"
8652 );
8653 assert!(
8654 output.contains(&format!(
8655 "settings: {} (present)",
8656 codewhale_config::quote_os_path(&settings_path)
8657 )),
8658 "{output}"
8659 );
8660 assert!(
8661 output.contains("secret backend: file (inspection: metadata_only)"),
8662 "{output}"
8663 );
8664 assert!(
8665 output.contains(&format!(
8666 "secret store: {} (present)",
8667 codewhale_config::quote_os_path(&secret_path)
8668 )),
8669 "{output}"
8670 );
8671 assert!(
8672 output.contains("provider deepseek sources: config_literal=present, secret_backend=present (provider entry unprobed), environment=present (DEEPSEEK_API_KEY)"),
8673 "{output}"
8674 );
8675 assert!(
8676 output.contains("legacy secret store: suppressed by explicit CODEWHALE_HOME isolation"),
8677 "{output}"
8678 );
8679 for secret_fragment in [
8680 "diagnostic-config-secret",
8681 "diagnostic-store-secret",
8682 "diagnostic-env-secret",
8683 "1234",
8684 "5678",
8685 "9012",
8686 "last4",
8687 ] {
8688 assert!(
8689 !output.contains(secret_fragment),
8690 "diagnostic leaked {secret_fragment:?}: {output}"
8691 );
8692 }
8693 }
8694
8695 #[test]
8696 fn auth_status_reports_all_active_provider_sources_with_last4() {
8697 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8698 use std::sync::Arc;
8699
8700 let _lock = env_lock();
8701 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
8702
8703 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8704 let path = std::env::temp_dir().join(format!(
8705 "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
8706 std::process::id()
8707 ));
8708 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8709 store.config.provider = ProviderKind::Deepseek;
8710 store.config.api_key = Some("sk-config-3333".to_string());
8711 store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
8712
8713 let inner = Arc::new(InMemoryKeyringStore::new());
8714 inner.set("deepseek", "sk-keyring-2222").unwrap();
8715 let secrets = Secrets::new(inner);
8716
8717 let output =
8718 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
8719
8720 assert!(output.contains("provider: deepseek"));
8721 assert!(output.contains("active source: config (last4: ...3333)"));
8722 assert!(output.contains("lookup order: config -> secret store -> env"));
8723 assert!(output.contains("config file: "));
8724 assert!(output.contains("set, last4: ...3333"));
8725 assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
8726 assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
8727 assert!(!output.contains("sk-config-3333"));
8728 assert!(!output.contains("sk-keyring-2222"));
8729 assert!(!output.contains("sk-env-1111"));
8730
8731 let _ = std::fs::remove_file(path);
8732 }
8733
8734 #[test]
8735 fn auth_status_all_providers_lists_every_known_provider() {
8736 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8737 use std::sync::Arc;
8738
8739 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8740 let path = std::env::temp_dir().join(format!(
8741 "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
8742 std::process::id()
8743 ));
8744 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8745 store.config.provider = ProviderKind::Deepseek;
8746 store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
8747
8748 let inner = Arc::new(InMemoryKeyringStore::new());
8749 inner.set("openrouter", "sk-or-test5678").unwrap();
8750 let secrets = Secrets::new(inner);
8751
8752 let output = auth_status_all_providers(&store, &secrets).join("\n");
8753
8754 assert!(output.contains("account:"), "{output}");
8755 assert!(output.contains("codewhale login"), "{output}");
8756 // No-brand invariant: the internal cloud-agent slot is not user
8757 // surface, so status never names it or teaches a set-slot command.
8758 assert!(!output.to_lowercase().contains("daytona"), "{output}");
8759 assert!(!output.contains("set-slot"), "{output}");
8760
8761 // Should list all known providers
8762 assert!(output.contains("deepseek"));
8763 assert!(output.contains("arcee"));
8764 assert!(output.contains("openrouter"));
8765 assert!(output.contains("huggingface"));
8766 assert!(output.contains("ollama"));
8767
8768 // Active provider should be marked
8769 assert!(output.contains("deepseek") && output.contains("*"));
8770
8771 // Arcee should show config source
8772 assert!(output.contains("config"));
8773
8774 // Should NOT leak raw keys
8775 assert!(!output.contains("sk-arcee-test1234"));
8776 assert!(!output.contains("sk-or-test5678"));
8777
8778 let _ = std::fs::remove_file(path);
8779 }
8780
8781 #[test]
8782 fn auth_status_never_probes_codex_file_and_reports_exact_consent() {
8783 use codewhale_secrets::InMemoryKeyringStore;
8784 use std::sync::Arc;
8785
8786 let _lock = env_lock();
8787 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
8788 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
8789
8790 let dir = tempfile::TempDir::new().expect("tempdir");
8791 let config_path = dir.path().join("config.toml");
8792 let auth_path = dir.path().join("auth.json");
8793 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
8794 .expect("write auth file");
8795 let auth_path_str = auth_path.to_string_lossy().into_owned();
8796 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
8797
8798 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
8799 store.config.provider = ProviderKind::OpenaiCodex;
8800 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
8801
8802 let output =
8803 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
8804
8805 assert!(output.contains("provider: openai-codex"));
8806 assert!(output.contains("auth mode: codex_oauth"));
8807 assert!(output.contains("active source: missing"));
8808 assert!(output.contains(
8809 "lookup order: env -> Codewhale-owned ChatGPT sign-in -> consent-gated exact Codex CLI file"
8810 ));
8811 assert!(output.contains("external credentials: disabled"));
8812 assert!(output.contains("scope_valid=false"));
8813 assert!(output.contains("disabled; no external-credential probing, reading"));
8814 assert!(output.contains("file not probed"));
8815 assert!(!output.contains("secret-token"));
8816
8817 store.config.providers.openai_codex.external_credentials =
8818 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8819 ProviderKind::OpenaiCodex,
8820 codewhale_config::ExternalCredentialSource::CodexCli,
8821 auth_path.clone(),
8822 ));
8823 let output =
8824 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
8825 assert!(
8826 output.contains("active source: external read-only consent (availability not probed)")
8827 );
8828 assert!(output.contains("external credentials: read_only"));
8829 assert!(output.contains("provider=openai-codex"));
8830 assert!(output.contains("source=codex_cli"));
8831 assert!(output.contains(&format!(
8832 "path={}",
8833 codewhale_config::quote_os_path(&auth_path)
8834 )));
8835 assert!(output.contains(&format!(
8836 "consent_version={}",
8837 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
8838 )));
8839 assert!(output.contains("file not probed"));
8840 assert!(!output.contains("secret-token"));
8841
8842 let ambient_path = dir.path().join("new-ambient-auth.json");
8843 let ambient_path_str = ambient_path.to_string_lossy().into_owned();
8844 let _ambient_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &ambient_path_str);
8845 let changed =
8846 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
8847 assert!(changed.contains("state=active"), "{changed}");
8848 assert!(changed.contains("ambient_path_changed=true"), "{changed}");
8849 assert!(changed.contains("consent remains pinned"), "{changed}");
8850 assert!(
8851 changed.contains(&codewhale_config::quote_os_path(&auth_path)),
8852 "{changed}"
8853 );
8854 assert!(!changed.contains(&ambient_path_str), "{changed}");
8855 }
8856
8857 #[test]
8858 fn xai_valid_owned_generation_blocks_external_consent_without_storage_probes() {
8859 use std::sync::Arc;
8860
8861 let _lock = env_lock();
8862 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
8863 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
8864 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
8865 let dir = tempfile::TempDir::new().expect("tempdir");
8866 let config_path = dir.path().join("config.toml");
8867 let external_path = dir.path().join("grok-auth.json");
8868 let external_raw = "external owner bytes must not be read";
8869 std::fs::write(&external_path, external_raw).expect("external auth trap");
8870 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
8871
8872 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
8873 store.config.provider = ProviderKind::Xai;
8874 store.config.providers.xai.auth_mode = Some("oauth".to_string());
8875 store.config.providers.xai.oauth_credential_generation =
8876 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
8877 store.config.providers.xai.external_credentials =
8878 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8879 ProviderKind::Xai,
8880 codewhale_config::ExternalCredentialSource::GrokCli,
8881 external_path.clone(),
8882 ));
8883 let keyring = Arc::new(RecordingKeyringStore::default());
8884 let secrets = Secrets::new(keyring.clone());
8885
8886 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
8887 assert!(
8888 scoped.contains(
8889 "credential route: Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
8890 ),
8891 "{scoped}"
8892 );
8893 assert!(scoped.contains("external credentials: blocked by the configured Codewhale-owned xAI OAuth generation"), "{scoped}");
8894 assert!(
8895 scoped.contains(
8896 "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
8897 ),
8898 "{scoped}"
8899 );
8900 assert!(
8901 !scoped.contains("active source: Codewhale-owned OAuth"),
8902 "a valid pointer is configured/unprobed, not an active credential: {scoped}"
8903 );
8904 assert!(
8905 !scoped.contains("fallback"),
8906 "an owned generation must never advertise Grok CLI fallback: {scoped}"
8907 );
8908
8909 let all = auth_status_all_providers(&store, &secrets).join("\n");
8910 let xai_row = all
8911 .lines()
8912 .find(|line| line.starts_with("xai"))
8913 .expect("xAI status row");
8914 assert!(
8915 xai_row.contains("Codewhale-owned OAuth configured/unprobed"),
8916 "{xai_row}"
8917 );
8918
8919 let list = auth_list_lines(&store, &secrets).join("\n");
8920 let xai_list_row = list
8921 .lines()
8922 .find(|line| line.starts_with("xai"))
8923 .expect("xAI list row");
8924 assert!(
8925 xai_list_row.ends_with("owned-oauth-configured"),
8926 "{xai_list_row}"
8927 );
8928
8929 let get = auth_get_line_with_runtime(
8930 &store,
8931 &secrets,
8932 ProviderKind::Xai,
8933 &CliRuntimeOverrides::default(),
8934 );
8935 assert!(
8936 get.starts_with("xai: configured (source: Codewhale-owned OAuth generation"),
8937 "{get}"
8938 );
8939 assert!(!get.starts_with("xai: set"), "{get}");
8940 assert!(!get.contains("fallback"), "{get}");
8941 assert!(
8942 !keyring.queried().iter().any(|slot| slot == "xai"),
8943 "owned OAuth diagnostics must not query the xAI API-key store: {:?}",
8944 keyring.queried()
8945 );
8946 assert_eq!(
8947 std::fs::read_to_string(external_path).expect("external trap unchanged"),
8948 external_raw
8949 );
8950
8951 store.config.providers.xai.auth_mode = None;
8952 store.config.auth_mode = Some("oauth".to_string());
8953 assert_eq!(
8954 xai_auth_diagnostics(&store, &CliRuntimeOverrides::default()).route,
8955 XaiAuthDiagnosticRoute::ApiKey,
8956 "a root auth mode must not select the xAI OAuth runtime route"
8957 );
8958 }
8959
8960 #[test]
8961 fn xai_invalid_generation_requires_repair_blocks_external_and_keeps_api_key_diagnostics() {
8962 use std::sync::Arc;
8963
8964 let _lock = env_lock();
8965 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
8966 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
8967 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
8968 let dir = tempfile::TempDir::new().expect("tempdir");
8969 let config_path = dir.path().join("config.toml");
8970 let external_path = dir.path().join("grok-auth.json");
8971 let external_raw = "external owner bytes must remain unread";
8972 std::fs::write(&external_path, external_raw).expect("external auth trap");
8973 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
8974
8975 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
8976 store.config.provider = ProviderKind::Xai;
8977 store.config.providers.xai.auth_mode = Some("oauth".to_string());
8978 store.config.providers.xai.api_key = Some("fake-cfg-key-1234".to_string());
8979 store.config.providers.xai.oauth_credential_generation = Some("../unsafe.json".to_string());
8980 store.config.providers.xai.external_credentials =
8981 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8982 ProviderKind::Xai,
8983 codewhale_config::ExternalCredentialSource::GrokCli,
8984 external_path.clone(),
8985 ));
8986 let keyring = Arc::new(RecordingKeyringStore::default());
8987 let secrets = Secrets::new(keyring.clone());
8988
8989 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
8990 assert!(
8991 scoped.contains("credential route: xAI OAuth needs repair"),
8992 "{scoped}"
8993 );
8994 assert!(
8995 scoped.contains("API-key fallback: config (last4: ...1234)"),
8996 "{scoped}"
8997 );
8998 assert!(scoped.contains("external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer"), "{scoped}");
8999 assert!(
9000 scoped.contains("repair: run `codewhale auth xai-device`"),
9001 "{scoped}"
9002 );
9003 assert!(
9004 !scoped.contains("external read-only consent (availability not probed)"),
9005 "invalid owned pointers must not activate Grok CLI consent: {scoped}"
9006 );
9007
9008 let all = auth_status_all_providers(&store, &secrets).join("\n");
9009 let xai_row = all
9010 .lines()
9011 .find(|line| line.starts_with("xai"))
9012 .expect("xAI status row");
9013 assert!(xai_row.contains("needs repair"), "{xai_row}");
9014 assert!(xai_row.contains("API-key fallback: config"), "{xai_row}");
9015
9016 let list = auth_list_lines(&store, &secrets).join("\n");
9017 let xai_list_row = list
9018 .lines()
9019 .find(|line| line.starts_with("xai"))
9020 .expect("xAI list row");
9021 assert!(xai_list_row.ends_with("needs-repair"), "{xai_list_row}");
9022
9023 let get = auth_get_line_with_runtime(
9024 &store,
9025 &secrets,
9026 ProviderKind::Xai,
9027 &CliRuntimeOverrides::default(),
9028 );
9029 assert!(get.contains("xai: needs repair"), "{get}");
9030 assert!(get.contains("API-key fallback: config-file"), "{get}");
9031 assert!(
9032 !keyring.queried().iter().any(|slot| slot == "xai"),
9033 "an invalid owned pointer must not query the xAI API-key store: {:?}",
9034 keyring.queried()
9035 );
9036 assert_eq!(
9037 std::fs::read_to_string(external_path).expect("external trap unchanged"),
9038 external_raw
9039 );
9040 }
9041
9042 #[test]
9043 fn xai_cli_custom_endpoint_rejects_inherited_api_key_sources() {
9044 use std::sync::Arc;
9045
9046 let _lock = env_lock();
9047 let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-3333");
9048 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9049 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9050 let dir = tempfile::TempDir::new().expect("tempdir");
9051 let config_path = dir.path().join("config.toml");
9052 let external_path = dir.path().join("grok-auth.json");
9053 let external_raw = "external owner bytes must remain unprobed";
9054 std::fs::write(&external_path, external_raw).expect("external auth trap");
9055 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9056
9057 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9058 store.config.provider = ProviderKind::Xai;
9059 store.config.providers.xai.api_key = Some("fake-cfg-key-1111".to_string());
9060 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9061 store.config.providers.xai.oauth_credential_generation =
9062 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
9063 store.config.providers.xai.external_credentials =
9064 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9065 ProviderKind::Xai,
9066 codewhale_config::ExternalCredentialSource::GrokCli,
9067 external_path.clone(),
9068 ));
9069 let keyring = Arc::new(RecordingKeyringStore::default());
9070 keyring.set_value("xai", "fake-store-key-2222");
9071 let secrets = Secrets::new(keyring.clone());
9072 let runtime_overrides = CliRuntimeOverrides {
9073 base_url: Some("https://gateway.example.test/v1".to_string()),
9074 ..CliRuntimeOverrides::default()
9075 };
9076
9077 let scoped = auth_status_lines_for_provider_with_runtime(
9078 &store,
9079 &secrets,
9080 ProviderKind::Xai,
9081 &runtime_overrides,
9082 )
9083 .join("\n");
9084 assert!(
9085 scoped.contains("route: https://gateway.example.test/v1"),
9086 "{scoped}"
9087 );
9088 assert!(scoped.contains("credential route: missing"), "{scoped}");
9089 assert!(
9090 scoped.contains("custom xAI endpoint; API-key-only"),
9091 "{scoped}"
9092 );
9093 assert!(
9094 scoped.contains("not eligible for this custom xAI endpoint"),
9095 "{scoped}"
9096 );
9097 assert!(
9098 scoped.contains("external credentials: unavailable on a custom xAI endpoint"),
9099 "{scoped}"
9100 );
9101 for redacted_tail in ["...1111", "...2222", "...3333"] {
9102 assert!(
9103 !scoped.contains(redacted_tail),
9104 "custom CLI route must not advertise an inherited credential: {scoped}"
9105 );
9106 }
9107
9108 let all =
9109 auth_status_all_providers_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
9110 let xai_row = all
9111 .lines()
9112 .find(|line| line.starts_with("xai"))
9113 .expect("xAI status row");
9114 assert!(xai_row.contains("unset"), "{xai_row}");
9115 assert!(
9116 !xai_row.contains("config") && !xai_row.contains("keyring") && !xai_row.contains("env"),
9117 "xAI summary must show runtime-effective sources only: {xai_row}"
9118 );
9119
9120 let list = auth_list_lines_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
9121 let xai_list_row = list
9122 .lines()
9123 .find(|line| line.starts_with("xai"))
9124 .expect("xAI list row");
9125 assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
9126
9127 let get =
9128 auth_get_line_with_runtime(&store, &secrets, ProviderKind::Xai, &runtime_overrides);
9129 assert_eq!(get, "xai: not set");
9130 assert!(
9131 !keyring.queried().iter().any(|slot| slot == "xai"),
9132 "a global custom endpoint must not query xAI keyring state: {:?}",
9133 keyring.queried()
9134 );
9135 assert_eq!(
9136 std::fs::read_to_string(external_path).expect("external trap unchanged"),
9137 external_raw
9138 );
9139 }
9140
9141 #[test]
9142 fn xai_env_custom_endpoint_rejects_inherited_api_key_sources() {
9143 use std::sync::Arc;
9144
9145 let _lock = env_lock();
9146 let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-6666");
9147 let _xai_base = ScopedEnvVar::set("XAI_BASE_URL", "https://env-gateway.example.test/v1");
9148 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9149 let dir = tempfile::TempDir::new().expect("tempdir");
9150 let config_path = dir.path().join("config.toml");
9151 let external_path = dir.path().join("grok-auth.json");
9152 let external_raw = "external owner bytes must remain unprobed";
9153 std::fs::write(&external_path, external_raw).expect("external auth trap");
9154 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9155
9156 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9157 store.config.provider = ProviderKind::Xai;
9158 store.config.providers.xai.api_key = Some("fake-cfg-key-4444".to_string());
9159 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9160 store.config.providers.xai.oauth_credential_generation =
9161 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
9162 store.config.providers.xai.external_credentials =
9163 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9164 ProviderKind::Xai,
9165 codewhale_config::ExternalCredentialSource::GrokCli,
9166 external_path.clone(),
9167 ));
9168 let keyring = Arc::new(RecordingKeyringStore::default());
9169 keyring.set_value("xai", "fake-store-key-5555");
9170 let secrets = Secrets::new(keyring.clone());
9171
9172 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9173 assert!(
9174 scoped.contains("route: https://env-gateway.example.test/v1"),
9175 "{scoped}"
9176 );
9177 assert!(scoped.contains("credential route: missing"), "{scoped}");
9178 assert!(
9179 scoped.contains("custom xAI endpoint; API-key-only"),
9180 "{scoped}"
9181 );
9182 for redacted_tail in ["...4444", "...5555", "...6666"] {
9183 assert!(
9184 !scoped.contains(redacted_tail),
9185 "custom env route must not advertise an inherited credential: {scoped}"
9186 );
9187 }
9188
9189 let all = auth_status_all_providers(&store, &secrets).join("\n");
9190 let xai_row = all
9191 .lines()
9192 .find(|line| line.starts_with("xai"))
9193 .expect("xAI status row");
9194 assert!(xai_row.contains("unset"), "{xai_row}");
9195
9196 let list = auth_list_lines(&store, &secrets).join("\n");
9197 let xai_list_row = list
9198 .lines()
9199 .find(|line| line.starts_with("xai"))
9200 .expect("xAI list row");
9201 assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
9202
9203 assert_eq!(
9204 auth_get_line_with_runtime(
9205 &store,
9206 &secrets,
9207 ProviderKind::Xai,
9208 &CliRuntimeOverrides::default(),
9209 ),
9210 "xai: not set"
9211 );
9212 assert!(
9213 !keyring.queried().iter().any(|slot| slot == "xai"),
9214 "an XAI_BASE_URL custom route must not query xAI keyring state: {:?}",
9215 keyring.queried()
9216 );
9217 assert_eq!(
9218 std::fs::read_to_string(external_path).expect("external trap unchanged"),
9219 external_raw
9220 );
9221 }
9222
9223 #[test]
9224 fn xai_config_bound_custom_endpoint_uses_its_route_key() {
9225 use std::sync::Arc;
9226
9227 let _lock = env_lock();
9228 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
9229 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9230 let dir = tempfile::TempDir::new().expect("tempdir");
9231 let config_path = dir.path().join("config.toml");
9232 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9233 store.config.provider = ProviderKind::Xai;
9234 store.config.providers.xai.base_url =
9235 Some("https://bound-gateway.example.test/v1".to_string());
9236 store.config.providers.xai.api_key = Some("fake-bound-key-7777".to_string());
9237 let keyring = Arc::new(RecordingKeyringStore::default());
9238 keyring.set_value("xai", "fake-store-key-8888");
9239 let secrets = Secrets::new(keyring.clone());
9240
9241 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9242 assert!(
9243 scoped.contains("credential route: config (last4: ...7777)"),
9244 "{scoped}"
9245 );
9246 assert!(
9247 scoped.contains("config file:") && scoped.contains("runtime-effective, last4: ...7777"),
9248 "{scoped}"
9249 );
9250 assert_eq!(
9251 auth_get_line_with_runtime(
9252 &store,
9253 &secrets,
9254 ProviderKind::Xai,
9255 &CliRuntimeOverrides::default(),
9256 ),
9257 "xai: set (source: config-file)"
9258 );
9259 assert!(
9260 !keyring.queried().iter().any(|slot| slot == "xai"),
9261 "an endpoint-bound config key should resolve before the xAI keyring: {:?}",
9262 keyring.queried()
9263 );
9264 }
9265
9266 #[test]
9267 fn xai_absent_generation_with_consent_is_external_configured_and_unprobed() {
9268 use std::sync::Arc;
9269
9270 let _lock = env_lock();
9271 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
9272 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9273 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9274 let dir = tempfile::TempDir::new().expect("tempdir");
9275 let config_path = dir.path().join("config.toml");
9276 let external_path = dir.path().join("grok-auth.json");
9277 let external_raw = "external owner bytes remain unprobed";
9278 std::fs::write(&external_path, external_raw).expect("external auth trap");
9279 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9280
9281 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9282 store.config.provider = ProviderKind::Xai;
9283 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9284 store.config.providers.xai.external_credentials =
9285 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9286 ProviderKind::Xai,
9287 codewhale_config::ExternalCredentialSource::GrokCli,
9288 external_path.clone(),
9289 ));
9290 let keyring = Arc::new(RecordingKeyringStore::default());
9291 let secrets = Secrets::new(keyring.clone());
9292
9293 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9294 assert!(
9295 scoped.contains("credential route: external read-only consent configured/unprobed"),
9296 "{scoped}"
9297 );
9298 assert!(
9299 scoped.contains("external credentials: read_only"),
9300 "{scoped}"
9301 );
9302 assert!(
9303 scoped.contains(
9304 "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)"
9305 ),
9306 "{scoped}"
9307 );
9308
9309 let all = auth_status_all_providers(&store, &secrets).join("\n");
9310 let xai_row = all
9311 .lines()
9312 .find(|line| line.starts_with("xai"))
9313 .expect("xAI status row");
9314 assert!(
9315 xai_row.contains("external consent configured/unprobed"),
9316 "{xai_row}"
9317 );
9318
9319 let list = auth_list_lines(&store, &secrets).join("\n");
9320 let xai_list_row = list
9321 .lines()
9322 .find(|line| line.starts_with("xai"))
9323 .expect("xAI list row");
9324 assert!(
9325 xai_list_row.ends_with("external-consent-configured"),
9326 "{xai_list_row}"
9327 );
9328
9329 let get = auth_get_line_with_runtime(
9330 &store,
9331 &secrets,
9332 ProviderKind::Xai,
9333 &CliRuntimeOverrides::default(),
9334 );
9335 assert!(
9336 get.contains("source: external read-only consent; availability unprobed"),
9337 "{get}"
9338 );
9339 assert!(
9340 !keyring.queried().iter().any(|slot| slot == "xai"),
9341 "external-consent diagnostics must not query the xAI API-key store: {:?}",
9342 keyring.queried()
9343 );
9344 assert_eq!(
9345 std::fs::read_to_string(external_path).expect("external trap unchanged"),
9346 external_raw
9347 );
9348 }
9349
9350 #[test]
9351 fn auth_list_uses_persisted_consent_without_probing_codex_file() {
9352 use codewhale_secrets::InMemoryKeyringStore;
9353 use std::sync::Arc;
9354
9355 let _lock = env_lock();
9356 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
9357 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
9358
9359 let dir = tempfile::TempDir::new().expect("tempdir");
9360 let config_path = dir.path().join("config.toml");
9361 let auth_path = dir.path().join("auth.json");
9362 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
9363 .expect("write auth file");
9364 let auth_path_str = auth_path.to_string_lossy().into_owned();
9365 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
9366
9367 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9368 store.config.provider = ProviderKind::OpenaiCodex;
9369 store.config.providers.openai_codex.external_credentials =
9370 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9371 ProviderKind::OpenaiCodex,
9372 codewhale_config::ExternalCredentialSource::CodexCli,
9373 auth_path,
9374 ));
9375 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
9376
9377 let output = auth_list_lines(&store, &secrets).join("\n");
9378 let row = output
9379 .lines()
9380 .find(|line| line.starts_with("openai-codex"))
9381 .unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
9382 assert!(row.ends_with("external-consent"), "{row}");
9383 assert!(!output.contains("secret-token"));
9384 }
9385
9386 #[test]
9387 fn auth_list_labels_each_row_by_its_own_provider() {
9388 // ProviderKind::secret_store_slot collapses families onto one durable
9389 // slot -- SiliconflowCN onto `siliconflow`, the four Model Studio
9390 // variants onto `modelstudio-token-plan` -- but this table has one row
9391 // per kind. Labelling rows by slot printed `siliconflow` twice and
9392 // `modelstudio-token-plan` four times, so a reader could not tell which
9393 // row belonged to which provider.
9394 let _lock = env_lock();
9395 let dir = tempfile::TempDir::new().expect("tempdir");
9396 let store =
9397 ConfigStore::load(Some(dir.path().join("config.toml"))).expect("store should load");
9398 let secrets = Secrets::new(std::sync::Arc::new(
9399 codewhale_secrets::InMemoryKeyringStore::new(),
9400 ));
9401
9402 let lines = auth_list_lines(&store, &secrets);
9403 let labels: Vec<&str> = lines
9404 .iter()
9405 .skip(1)
9406 .filter_map(|line| line.split_whitespace().next())
9407 .collect();
9408
9409 assert_eq!(
9410 labels.len(),
9411 ProviderKind::ALL.len(),
9412 "one row per provider kind: {labels:?}"
9413 );
9414 let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
9415 assert_eq!(
9416 unique.len(),
9417 labels.len(),
9418 "every row must name its own provider, not a shared slot: {labels:?}"
9419 );
9420 }
9421
9422 #[test]
9423 fn external_consent_persists_exact_scope_and_api_key_or_revoke_disables_it() {
9424 let _lock = env_lock();
9425 let dir = tempfile::TempDir::new().expect("tempdir");
9426 let home = dir
9427 .path()
9428 .canonicalize()
9429 .expect("canonical temp root")
9430 .join("codewhale-home");
9431 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9432 let config_path = dir.path().join("config.toml");
9433 let external_path = dir.path().join("grok-auth.json");
9434 let external_raw = r#"{"secret":"must-never-be-read-or-written"}"#;
9435 std::fs::write(&external_path, external_raw).expect("external auth trap");
9436 let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
9437 let secrets = no_keyring_secrets();
9438
9439 let preview = external_consent_preview_lines(
9440 ProviderKind::Xai,
9441 codewhale_config::ExternalCredentialSource::GrokCli,
9442 &external_path,
9443 )
9444 .join("\n");
9445 assert!(preview.contains("owning CLI: Grok CLI"), "{preview}");
9446 assert!(
9447 preview.contains(&format!(
9448 "exact resolved path: {}",
9449 codewhale_config::quote_os_path(&external_path)
9450 )),
9451 "{preview}"
9452 );
9453 assert!(preview.contains("no refresh, identity-provider or discovery requests"));
9454 assert!(preview.contains("normal requests to the explicitly selected provider"));
9455 assert!(preview.contains("managed: unavailable"));
9456
9457 let mut prompt = Vec::new();
9458 confirm_external_consent_answer(&mut "yes\n".as_bytes(), &mut prompt)
9459 .expect("exact yes confirms");
9460 assert!(
9461 String::from_utf8(prompt)
9462 .unwrap()
9463 .contains("exact read-only")
9464 );
9465 let cancelled = confirm_external_consent_answer(&mut "YES\n".as_bytes(), &mut Vec::new())
9466 .expect_err("confirmation is deliberate and case-sensitive");
9467 assert!(cancelled.to_string().contains("cancelled"));
9468
9469 let unconfirmed = run_auth_command_with_secrets(
9470 &mut store,
9471 AuthCommand::ExternalConsent {
9472 provider: ProviderKind::Xai,
9473 mode: ExternalCredentialModeArg::ReadOnly,
9474 path: Some(external_path.clone()),
9475 yes: false,
9476 },
9477 &secrets,
9478 )
9479 .expect_err("non-interactive consent requires --yes");
9480 assert!(unconfirmed.to_string().contains("requires explicit --yes"));
9481 assert!(store.config.providers.xai.external_credentials.is_none());
9482 assert!(
9483 !config_path.exists(),
9484 "unconfirmed consent must not persist"
9485 );
9486
9487 run_auth_command_with_secrets(
9488 &mut store,
9489 AuthCommand::ExternalConsent {
9490 provider: ProviderKind::Xai,
9491 mode: ExternalCredentialModeArg::ReadOnly,
9492 path: Some(external_path.clone()),
9493 yes: true,
9494 },
9495 &secrets,
9496 )
9497 .expect("read-only consent should persist");
9498
9499 let consent = store
9500 .config
9501 .providers
9502 .xai
9503 .external_credentials
9504 .as_ref()
9505 .expect("persisted consent");
9506 assert_eq!(
9507 consent.access,
9508 codewhale_config::ExternalCredentialAccess::ReadOnly
9509 );
9510 assert_eq!(consent.provider, ProviderKind::Xai.as_str());
9511 assert_eq!(
9512 consent.source,
9513 codewhale_config::ExternalCredentialSource::GrokCli
9514 );
9515 assert_eq!(consent.path, external_path);
9516 assert_eq!(
9517 consent.consent_version,
9518 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
9519 );
9520 assert_eq!(
9521 store.config.providers.xai.auth_mode.as_deref(),
9522 Some("oauth")
9523 );
9524 assert_eq!(
9525 std::fs::read_to_string(&consent.path).expect("external file unchanged"),
9526 external_raw
9527 );
9528
9529 let reloaded = ConfigStore::load(Some(config_path.clone())).expect("reload consent");
9530 let reloaded_consent = reloaded
9531 .config
9532 .providers
9533 .xai
9534 .external_credentials
9535 .as_ref()
9536 .expect("reloaded exact consent");
9537 assert_eq!(reloaded_consent.provider, ProviderKind::Xai.as_str());
9538 assert_eq!(
9539 reloaded_consent.source,
9540 codewhale_config::ExternalCredentialSource::GrokCli
9541 );
9542 assert_eq!(reloaded_consent.path, external_path);
9543 assert_eq!(
9544 reloaded_consent.consent_version,
9545 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
9546 );
9547
9548 run_auth_command_with_secrets(
9549 &mut store,
9550 AuthCommand::Set {
9551 provider: ProviderKind::Xai,
9552 api_key: Some("xai-codewhale-owned-key".to_string()),
9553 api_key_stdin: false,
9554 },
9555 &secrets,
9556 )
9557 .expect("Codewhale-owned API key should supersede external consent");
9558 assert!(store.config.providers.xai.external_credentials.is_none());
9559 assert_eq!(
9560 std::fs::read_to_string(&external_path).expect("external file still unchanged"),
9561 external_raw
9562 );
9563
9564 run_auth_command_with_secrets(
9565 &mut store,
9566 AuthCommand::ExternalConsent {
9567 provider: ProviderKind::Xai,
9568 mode: ExternalCredentialModeArg::ReadOnly,
9569 path: Some(external_path.clone()),
9570 yes: true,
9571 },
9572 &secrets,
9573 )
9574 .expect("consent can be granted again");
9575 run_auth_command_with_secrets(
9576 &mut store,
9577 AuthCommand::ExternalRevoke {
9578 provider: ProviderKind::Xai,
9579 },
9580 &secrets,
9581 )
9582 .expect("revoke should persist");
9583 assert!(store.config.providers.xai.external_credentials.is_none());
9584 assert_eq!(
9585 std::fs::read_to_string(&external_path).expect("revoke never touches external file"),
9586 external_raw
9587 );
9588 }
9589
9590 #[test]
9591 fn unsupported_managed_and_kimi_external_consent_fail_closed() {
9592 let dir = tempfile::TempDir::new().expect("tempdir");
9593 let config_path = dir.path().join("config.toml");
9594 let external_path = dir.path().join("external-auth.json");
9595 std::fs::write(&external_path, "must remain unchanged").expect("external fixture");
9596 let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
9597 let secrets = no_keyring_secrets();
9598
9599 let managed = run_auth_command_with_secrets(
9600 &mut store,
9601 AuthCommand::ExternalConsent {
9602 provider: ProviderKind::OpenaiCodex,
9603 mode: ExternalCredentialModeArg::Managed,
9604 path: Some(external_path.clone()),
9605 yes: true,
9606 },
9607 &secrets,
9608 )
9609 .expect_err("managed access must fail without a preservation adapter");
9610 assert!(
9611 managed
9612 .to_string()
9613 .contains("schema-safe preservation adapter")
9614 );
9615
9616 let kimi = run_auth_command_with_secrets(
9617 &mut store,
9618 AuthCommand::ExternalConsent {
9619 provider: ProviderKind::Moonshot,
9620 mode: ExternalCredentialModeArg::ReadOnly,
9621 path: Some(external_path.clone()),
9622 yes: true,
9623 },
9624 &secrets,
9625 )
9626 .expect_err("Kimi must remain API-key-only");
9627 assert!(kimi.to_string().contains("API-key-only"));
9628 assert!(
9629 kimi.to_string()
9630 .contains("https://platform.kimi.ai/console/api-keys")
9631 );
9632 assert!(
9633 store
9634 .config
9635 .providers
9636 .openai_codex
9637 .external_credentials
9638 .is_none()
9639 );
9640 assert!(
9641 store
9642 .config
9643 .providers
9644 .moonshot
9645 .external_credentials
9646 .is_none()
9647 );
9648 assert_eq!(
9649 std::fs::read_to_string(external_path).expect("external fixture unchanged"),
9650 "must remain unchanged"
9651 );
9652 assert!(
9653 !config_path.exists(),
9654 "rejected consent must not write config"
9655 );
9656 }
9657
9658 #[test]
9659 fn api_key_config_failure_restores_absent_and_existing_secret_state() {
9660 let _lock = env_lock();
9661 for prior in [None, Some("prior-xai-key")] {
9662 let dir = tempfile::TempDir::new().expect("tempdir");
9663 let home = dir
9664 .path()
9665 .canonicalize()
9666 .expect("canonical temp root")
9667 .join("codewhale-home");
9668 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9669 let config_path = dir.path().join("config.toml");
9670 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load store");
9671 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9672 store.config.providers.xai.external_credentials =
9673 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9674 ProviderKind::Xai,
9675 codewhale_config::ExternalCredentialSource::GrokCli,
9676 dir.path().join("external.json"),
9677 ));
9678 std::fs::create_dir(&config_path).expect("turn config target into a directory");
9679 let secrets = no_keyring_secrets();
9680 if let Some(prior) = prior {
9681 secrets.set("xai", prior).expect("seed prior secret");
9682 }
9683
9684 let error = run_auth_command_with_secrets(
9685 &mut store,
9686 AuthCommand::Set {
9687 provider: ProviderKind::Xai,
9688 api_key: Some("new-xai-key".to_string()),
9689 api_key_stdin: false,
9690 },
9691 &secrets,
9692 )
9693 .expect_err("config write must fail");
9694 assert!(error.to_string().contains("config"), "{error:#}");
9695 assert_eq!(
9696 secrets.get("xai").expect("restored secret"),
9697 prior.map(str::to_string)
9698 );
9699 assert_eq!(
9700 store.config.providers.xai.auth_mode.as_deref(),
9701 Some("oauth")
9702 );
9703 assert!(store.config.providers.xai.external_credentials.is_some());
9704 assert!(store.config.providers.xai.api_key.is_none());
9705 assert!(config_path.is_dir());
9706 }
9707 }
9708
9709 #[test]
9710 fn auth_status_scoped_provider_shows_detailed_info() {
9711 use codewhale_secrets::InMemoryKeyringStore;
9712 use std::sync::Arc;
9713
9714 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
9715 let path = std::env::temp_dir().join(format!(
9716 "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
9717 std::process::id()
9718 ));
9719 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9720 store.config.provider = ProviderKind::Deepseek;
9721 store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
9722
9723 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
9724
9725 let output =
9726 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
9727
9728 assert!(output.contains("provider: arcee"));
9729 assert!(output.contains("active source: config (last4: ...9999)"));
9730 assert!(output.contains("route:"));
9731 assert!(output.contains("model:"));
9732 assert!(!output.contains("sk-arcee-9999"));
9733
9734 for sentinel in [codewhale_config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
9735 store.config.providers.arcee.api_key = Some(sentinel.to_string());
9736 assert_eq!(provider_config_api_key(&store, ProviderKind::Arcee), None);
9737 }
9738
9739 let _ = std::fs::remove_file(path);
9740 }
9741
9742 #[test]
9743 fn dispatch_uses_secret_store_without_rehydrating_plaintext_config() {
9744 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
9745 use std::sync::Arc;
9746
9747 // Runtime resolution reads process-global provider environment overrides.
9748 // Serialize with the tests that temporarily set those overrides so this
9749 // in-memory DeepSeek credential is not resolved against another provider.
9750 let _lock = env_lock();
9751 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
9752 let path = std::env::temp_dir().join(format!(
9753 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
9754 std::process::id()
9755 ));
9756 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9757 let inner = Arc::new(InMemoryKeyringStore::new());
9758 inner.set("deepseek", "ring-key").unwrap();
9759 let secrets = Secrets::new(inner);
9760
9761 let resolved = resolve_runtime_for_dispatch_with_secrets(
9762 &mut store,
9763 &CliRuntimeOverrides::default(),
9764 &secrets,
9765 );
9766
9767 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
9768 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
9769 assert!(store.config.api_key.is_none());
9770 assert!(store.config.providers.deepseek.api_key.is_none());
9771 assert!(
9772 !path.exists(),
9773 "dispatch must not create config from a stored key"
9774 );
9775
9776 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
9777 &mut store,
9778 &CliRuntimeOverrides::default(),
9779 &secrets,
9780 );
9781 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
9782 assert_eq!(
9783 resolved_again.api_key_source,
9784 Some(RuntimeApiKeySource::Keyring)
9785 );
9786 assert!(
9787 !path.exists(),
9788 "repeat dispatch must remain credential-file free"
9789 );
9790
9791 let _ = std::fs::remove_file(path);
9792 }
9793
9794 #[test]
9795 fn logout_removes_plaintext_provider_keys() {
9796 let _lock = env_lock();
9797 let dir = tempfile::TempDir::new().expect("tempdir");
9798 let home = dir
9799 .path()
9800 .canonicalize()
9801 .expect("canonical temp root")
9802 .join("codewhale-home");
9803 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9804 let path = home.join("config.toml");
9805 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9806 store.config.api_key = Some("sk-stale".to_string());
9807 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
9808 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
9809 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9810 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
9811 store.config.providers.xai.oauth_credential_generation = Some(generation.to_string());
9812 store.save().unwrap();
9813 let credentials = home.join("credentials");
9814 codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
9815 owned.write(generation, b"xai-generation", false)?;
9816 owned.write(
9817 codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
9818 b"legacy-xai",
9819 false,
9820 )?;
9821 Ok(())
9822 })
9823 .expect("seed Codewhale-owned xAI credentials");
9824 std::fs::write(credentials.join("other-provider.json"), "preserve").unwrap();
9825
9826 let secrets = no_keyring_secrets();
9827
9828 run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
9829
9830 assert!(store.config.api_key.is_none());
9831 assert!(store.config.providers.deepseek.api_key.is_none());
9832 assert!(store.config.providers.fireworks.api_key.is_none());
9833 assert!(store.config.providers.xai.auth_mode.is_none());
9834 assert!(
9835 store
9836 .config
9837 .providers
9838 .xai
9839 .oauth_credential_generation
9840 .is_none()
9841 );
9842 assert!(!credentials.join(generation).exists());
9843 assert!(!credentials.join("xai-auth.json").exists());
9844 assert!(credentials.join("other-provider.json").exists());
9845
9846 let _ = std::fs::remove_file(path);
9847 }
9848
9849 #[test]
9850 fn logout_clears_keyring_credentials_for_all_providers() {
9851 // Logout used to delete the keyring secret only for the *active*
9852 // provider, leaving credentials stored under other providers
9853 // behind while printing "logged out".
9854 let _lock = env_lock();
9855 let dir = tempfile::TempDir::new().expect("tempdir");
9856 let home = dir
9857 .path()
9858 .canonicalize()
9859 .expect("canonical temp root")
9860 .join("codewhale-home");
9861 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9862 let path = home.join("config.toml");
9863 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9864 store.config.provider = ProviderKind::Deepseek;
9865
9866 let secrets = no_keyring_secrets();
9867 secrets
9868 .set(provider_slot(ProviderKind::Deepseek), "sk-deepseek")
9869 .expect("seed deepseek key");
9870 secrets
9871 .set(provider_slot(ProviderKind::Fireworks), "fw-stale")
9872 .expect("seed fireworks key");
9873
9874 run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
9875
9876 for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
9877 assert!(
9878 provider_keyring_api_key(&secrets, provider).is_none(),
9879 "keyring credential for {provider:?} survived logout"
9880 );
9881 }
9882
9883 let _ = std::fs::remove_file(path);
9884 }
9885
9886 #[test]
9887 fn logout_clears_account_session_and_daytona_slot() {
9888 use codewhale_secrets::account::{
9889 AccountAuthBundle, AccountSession, AccountSessionStore, AccountUser,
9890 DEFAULT_ACCOUNT_API_BASE, secure_account_session_secrets,
9891 };
9892
9893 let _lock = env_lock();
9894 let dir = tempfile::TempDir::new().expect("tempdir");
9895 let home = dir
9896 .path()
9897 .canonicalize()
9898 .expect("canonical temp root")
9899 .join("codewhale-home");
9900 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9901 let path = home.join("config.toml");
9902 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9903
9904 let secrets = no_keyring_secrets();
9905 secrets
9906 .set(codewhale_secrets::DAYTONA_TOKEN_SLOT, "dtn_logout")
9907 .expect("seed daytona token");
9908
9909 let account = secure_account_session_secrets().expect("account store");
9910 AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
9911 .save(AccountAuthBundle {
9912 token_type: "Bearer".to_string(),
9913 access_token: "access-logout".to_string(),
9914 refresh_token: "refresh-logout".to_string(),
9915 session: Some(AccountSession {
9916 id: "session-logout".to_string(),
9917 ..AccountSession::default()
9918 }),
9919 user: Some(AccountUser {
9920 id: "acct-logout".to_string(),
9921 ..AccountUser::default()
9922 }),
9923 })
9924 .expect("seed account session");
9925
9926 run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
9927
9928 assert!(
9929 secrets
9930 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
9931 .expect("read daytona")
9932 .is_none(),
9933 "daytona slot survived logout"
9934 );
9935 let account = secure_account_session_secrets().expect("account store after logout");
9936 assert!(
9937 AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
9938 .load()
9939 .expect("load account")
9940 .is_none(),
9941 "account session survived logout"
9942 );
9943
9944 let _ = std::fs::remove_file(path);
9945 }
9946
9947 #[test]
9948 fn auth_set_slot_daytona_has_no_user_surface() {
9949 // The internal cloud-agent credential is managed by Codewhale, not
9950 // users: no CLI command may write or clear it, and no help text may
9951 // teach it. Membership (`codewhale login`) is the only door.
9952 use codewhale_secrets::InMemoryKeyringStore;
9953 use std::sync::Arc;
9954
9955 for argv in [
9956 vec![
9957 "codewhale",
9958 "auth",
9959 "set-slot",
9960 "daytona",
9961 "--api-key",
9962 "dtn_saved",
9963 ],
9964 vec!["codewhale", "auth", "clear-slot", "daytona"],
9965 ] {
9966 assert!(
9967 Cli::try_parse_from(argv).is_err(),
9968 "slot commands must not parse"
9969 );
9970 }
9971
9972 let inner = Arc::new(InMemoryKeyringStore::new());
9973 let secrets = Secrets::new(inner);
9974 assert!(
9975 secrets
9976 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
9977 .expect("read slot")
9978 .is_none()
9979 );
9980 }
9981
9982 #[test]
9983 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
9984 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
9985 use std::sync::Arc;
9986
9987 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
9988 let path = std::env::temp_dir().join(format!(
9989 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
9990 std::process::id()
9991 ));
9992 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9993 store.config.api_key = Some("sk-deep".to_string());
9994 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
9995 store.config.providers.openrouter.api_key = Some("or-key".to_string());
9996 store.config.providers.novita.api_key = Some("nv-key".to_string());
9997 store.save().unwrap();
9998
9999 let inner = Arc::new(InMemoryKeyringStore::new());
10000 let secrets = Secrets::new(inner.clone());
10001
10002 run_auth_command_with_secrets(
10003 &mut store,
10004 AuthCommand::Migrate { dry_run: false },
10005 &secrets,
10006 )
10007 .expect("migrate should succeed");
10008
10009 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
10010 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
10011 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
10012
10013 // Config file must no longer contain the api keys.
10014 assert!(store.config.api_key.is_none());
10015 assert!(store.config.providers.deepseek.api_key.is_none());
10016 assert!(store.config.providers.openrouter.api_key.is_none());
10017 assert!(store.config.providers.novita.api_key.is_none());
10018
10019 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
10020 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
10021 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
10022 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
10023
10024 let backup_path = path.with_file_name(format!(
10025 "{}.bak",
10026 path.file_name().unwrap_or_default().to_string_lossy()
10027 ));
10028 let backup = std::fs::read_to_string(&backup_path).expect("credential-free backup");
10029 assert!(
10030 !backup.contains("sk-deep"),
10031 "plaintext leaked in backup: {backup}"
10032 );
10033 assert!(
10034 !backup.contains("or-key"),
10035 "plaintext leaked in backup: {backup}"
10036 );
10037 assert!(
10038 !backup.contains("nv-key"),
10039 "plaintext leaked in backup: {backup}"
10040 );
10041
10042 let resolved = resolve_runtime_for_dispatch_with_secrets(
10043 &mut store,
10044 &CliRuntimeOverrides::default(),
10045 &secrets,
10046 );
10047 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
10048 let after_dispatch = std::fs::read_to_string(&path).expect("config after dispatch");
10049 assert!(!after_dispatch.contains("sk-deep"), "{after_dispatch}");
10050 assert!(
10051 !after_dispatch
10052 .lines()
10053 .any(|line| line.trim_start().starts_with("api_key ="))
10054 );
10055
10056 let _ = std::fs::remove_file(path);
10057 }
10058
10059 #[test]
10060 fn auth_migrate_dry_run_does_not_modify_anything() {
10061 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10062 use std::sync::Arc;
10063
10064 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10065 let path = std::env::temp_dir().join(format!(
10066 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
10067 std::process::id()
10068 ));
10069 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10070 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
10071 store.save().unwrap();
10072
10073 let inner = Arc::new(InMemoryKeyringStore::new());
10074 let secrets = Secrets::new(inner.clone());
10075
10076 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
10077 .expect("dry-run should succeed");
10078
10079 assert_eq!(inner.get("openrouter").unwrap(), None);
10080 assert_eq!(
10081 store.config.providers.openrouter.api_key.as_deref(),
10082 Some("or-stay")
10083 );
10084
10085 let _ = std::fs::remove_file(path);
10086 }
10087
10088 #[test]
10089 fn parses_global_override_flags() {
10090 let cli = parse_ok(&[
10091 "deepseek",
10092 "--provider",
10093 "openai",
10094 "--config",
10095 "/tmp/deepseek.toml",
10096 "--profile",
10097 "work",
10098 "--model",
10099 "deepseek-v4-pro",
10100 "--output-mode",
10101 "json",
10102 "--verbosity",
10103 "concise",
10104 "--log-level",
10105 "debug",
10106 "--telemetry",
10107 "true",
10108 "--approval-policy",
10109 "on-request",
10110 "--sandbox-mode",
10111 "workspace-write",
10112 "--base-url",
10113 "https://openai-compatible.example/v1",
10114 "--api-key",
10115 "sk-test",
10116 "--workspace",
10117 "/tmp/workspace",
10118 "--no-mouse-capture",
10119 "--skip-onboarding",
10120 "model",
10121 "resolve",
10122 "deepseek-v4-pro",
10123 ]);
10124
10125 assert_eq!(cli.provider.as_deref(), Some("openai"));
10126 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
10127 assert_eq!(cli.profile.as_deref(), Some("work"));
10128 assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
10129 assert_eq!(cli.output_mode.as_deref(), Some("json"));
10130 assert_eq!(cli.verbosity.as_deref(), Some("concise"));
10131 assert_eq!(cli.log_level.as_deref(), Some("debug"));
10132 assert_eq!(cli.telemetry, Some(true));
10133 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
10134 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
10135 assert_eq!(
10136 cli.base_url.as_deref(),
10137 Some("https://openai-compatible.example/v1")
10138 );
10139 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
10140 assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
10141 assert!(cli.no_mouse_capture);
10142 assert!(!cli.mouse_capture);
10143 assert!(cli.skip_onboarding);
10144 }
10145
10146 #[test]
10147 fn cli_provider_helpers_follow_config_metadata() {
10148 let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
10149 .iter()
10150 .map(|provider| provider.kind())
10151 .collect();
10152 // Full registry keeps legacy dialect/plan kinds; ALL is the catalog surface.
10153 assert_eq!(registry_kinds.len(), 52);
10154 // The tombstone stays in the registry (old config must still parse
10155 // and clear) and left the catalog surface when it stopped being
10156 // selectable.
10157 assert_eq!(ProviderKind::ALL.len(), 46);
10158 for kind in ProviderKind::ALL {
10159 assert!(
10160 registry_kinds.contains(&kind),
10161 "catalog kind {kind:?} must remain in the full registry"
10162 );
10163 }
10164
10165 for provider in registry_kinds {
10166 assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
10167 // Shared-account families collapse onto one durable slot (see
10168 // ProviderKind::secret_store_slot); everything else uses its own id.
10169 assert_eq!(
10170 provider_slot(provider),
10171 provider.secret_store_slot(),
10172 "{provider:?} slot must match ProviderKind::secret_store_slot"
10173 );
10174 if provider == ProviderKind::SiliconflowCN {
10175 assert_eq!(
10176 provider_slot(provider),
10177 provider_slot(ProviderKind::Siliconflow)
10178 );
10179 } else if matches!(
10180 provider,
10181 ProviderKind::ModelstudioTokenPlan
10182 | ProviderKind::ModelstudioTokenPlanAnthropic
10183 | ProviderKind::ModelstudioCodingPlan
10184 | ProviderKind::ModelstudioCodingPlanAnthropic
10185 ) {
10186 assert_eq!(
10187 provider_slot(provider),
10188 "modelstudio-token-plan",
10189 "{provider:?} must share the Model Studio family slot"
10190 );
10191 } else {
10192 assert_eq!(provider_slot(provider), provider.provider().id());
10193 }
10194 }
10195 }
10196
10197 #[test]
10198 fn the_telemetry_flag_documents_itself_in_help() {
10199 // A consent control nobody can find is a consent control nobody has.
10200 let help = Cli::command().render_long_help().to_string();
10201 let telemetry_line = help
10202 .lines()
10203 .position(|line| line.contains("--telemetry"))
10204 .map(|index| help.lines().skip(index).take(3).collect::<String>())
10205 .expect("--telemetry must appear in --help");
10206 assert!(
10207 telemetry_line.contains("telemetry"),
10208 "expected a help string beside --telemetry, got: {telemetry_line}"
10209 );
10210 assert!(
10211 telemetry_line.contains("default on"),
10212 "the help string must disclose the default: {telemetry_line}"
10213 );
10214 assert!(
10215 telemetry_line.contains("CODEWHALE_TELEMETRY=0 always")
10216 && telemetry_line.contains("wins"),
10217 "the help string must document the always-winning opt-out: {telemetry_line}"
10218 );
10219 let help = help_for(&["codewhale", "config", "telemetry", "--help"]);
10220 assert!(help.contains("PostHog"));
10221 assert!(help.contains("--accept-notice"));
10222 }
10223
10224 #[test]
10225 fn cli_telemetry_acceptance_is_versioned_and_reuses_settings_persistence() {
10226 let _lock = env_lock();
10227 let temp = tempfile::tempdir().expect("tempdir");
10228 let _home = ScopedEnvVar::set("CODEWHALE_HOME", temp.path().to_str().unwrap());
10229 let path = temp.path().join("config.toml");
10230 write_config_fixture(&path, "telemetry = false\nverbosity = \"concise\"\n");
10231 let mut state = SetupState::default();
10232 state.record_telemetry_notice("3", false);
10233 state.save().expect("seed decline");
10234 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
10235
10236 // An explicit enable command clears a historical decline through Settings.
10237 run_config_command(
10238 &mut store,
10239 ConfigCommand::Set {
10240 key: "telemetry".into(),
10241 value: "true".into(),
10242 },
10243 false,
10244 &[],
10245 )
10246 .expect("save configuration preference");
10247 assert!(!SetupState::load().unwrap().unwrap().telemetry_opted_out());
10248 assert_eq!(
10249 telemetry_preference_status(Some(true)),
10250 "On (saved preference)"
10251 );
10252 let before = std::fs::read(SetupState::path().unwrap()).unwrap();
10253 run_config_command(
10254 &mut store,
10255 ConfigCommand::Telemetry {
10256 accept_notice: None,
10257 },
10258 false,
10259 &[],
10260 )
10261 .expect("read notice");
10262 assert!(
10263 run_config_command(
10264 &mut store,
10265 ConfigCommand::Telemetry {
10266 accept_notice: Some(3)
10267 },
10268 false,
10269 &[]
10270 )
10271 .is_err()
10272 );
10273 assert_eq!(std::fs::read(SetupState::path().unwrap()).unwrap(), before);
10274
10275 run_config_command(
10276 &mut store,
10277 ConfigCommand::Telemetry {
10278 accept_notice: Some(telemetry::NOTICE_VERSION),
10279 },
10280 false,
10281 &[],
10282 )
10283 .expect("accept current processor notice");
10284 assert_eq!(
10285 telemetry_preference_status(Some(true)),
10286 "On (saved preference)"
10287 );
10288 let saved = ConfigStore::load(Some(path)).unwrap();
10289 assert_eq!(saved.config.telemetry, Some(true));
10290 assert_eq!(saved.config.verbosity.as_deref(), Some("concise"));
10291 assert!(
10292 !temp.path().join("telemetry").exists(),
10293 "acceptance never arms this process"
10294 );
10295 }
10296
10297 #[test]
10298 fn cli_telemetry_acceptance_refuses_overlays_and_corrupt_privacy_records() {
10299 let _lock = env_lock();
10300 let temp = tempfile::tempdir().expect("tempdir");
10301 let _home = ScopedEnvVar::set("CODEWHALE_HOME", temp.path().to_str().unwrap());
10302 let path = temp.path().join("config.toml");
10303 write_config_fixture(&path, "telemetry = false\n");
10304 std::fs::write(SetupState::path().unwrap(), "not-json").unwrap();
10305 let before = std::fs::read(&path).unwrap();
10306 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
10307 for overrides in [vec![], vec!["telemetry=true".to_string()]] {
10308 assert!(
10309 run_config_command(
10310 &mut store,
10311 ConfigCommand::Telemetry {
10312 accept_notice: Some(telemetry::NOTICE_VERSION),
10313 },
10314 false,
10315 &overrides
10316 )
10317 .is_err()
10318 );
10319 assert_eq!(std::fs::read(&path).unwrap(), before);
10320 assert_eq!(
10321 std::fs::read_to_string(SetupState::path().unwrap()).unwrap(),
10322 "not-json"
10323 );
10324 }
10325 }
10326
10327 #[test]
10328 fn root_help_describes_product_actions_not_internal_tui_layers() {
10329 let help = Cli::command().render_long_help().to_string();
10330 assert!(
10331 !help.contains("TUI"),
10332 "root help must describe what Codewhale does, not its internal UI/runtime layers:\n{help}"
10333 );
10334 }
10335
10336 #[test]
10337 fn only_one_function_may_locate_and_spawn_the_tui() {
10338 // Single-binary invariant: no sibling TUI discovery exists. The
10339 // two-process glue has been deleted; the only TUI entry is codewhale_tui::run.
10340 let source = include_str!("lib.rs");
10341 let a = format!("{}{}", "locate_sibling", "_tui_binary");
10342 let b = format!("{}{}", "tui_spawn", "_error");
10343 let c = format!("{}{}", "build_tui", "_command");
10344 let d = format!("{}{}", "Command::new", "(&tui)");
10345 assert!(
10346 !source.contains(&a),
10347 "single binary must not contain sibling TUI discovery"
10348 );
10349 assert!(
10350 !source.contains(&b),
10351 "single binary must not contain tui spawn error"
10352 );
10353 assert!(
10354 !source.contains(&c),
10355 "single binary must not contain build_tui dispatch"
10356 );
10357 assert!(
10358 !source.contains(&d),
10359 "single binary must not contain Command new tui"
10360 );
10361 }
10362
10363 #[test]
10364 fn parses_no_project_config_before_subcommand() {
10365 let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "list the files"]);
10366 assert!(cli.no_project_config);
10367 match cli.command {
10368 Some(Commands::Exec(args)) => {
10369 assert_eq!(args.args, vec!["list the files".to_string()]);
10370 }
10371 other => panic!("expected exec subcommand, got {other:?}"),
10372 }
10373 }
10374
10375 #[test]
10376 fn no_project_config_after_passthrough_subcommand_is_not_the_dispatcher_flag() {
10377 // `exec` captures trailing args (`trailing_var_arg`), so a misplaced
10378 // `--no-project-config` is NOT honored as the dispatcher flag — it must
10379 // appear before the subcommand, exactly like `--skip-onboarding`.
10380 let cli = parse_ok(&["codewhale", "exec", "--no-project-config", "hi"]);
10381 assert!(!cli.no_project_config);
10382 match cli.command {
10383 Some(Commands::Exec(args)) => {
10384 assert!(args.args.iter().any(|a| a == "--no-project-config"));
10385 }
10386 other => panic!("expected exec subcommand, got {other:?}"),
10387 }
10388 }
10389
10390 #[test]
10391 fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
10392 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
10393
10394 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
10395 assert!(cli.prompt.is_empty());
10396 assert_eq!(
10397 root_tui_passthrough(&cli).unwrap(),
10398 vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
10399 );
10400 }
10401
10402 #[test]
10403 fn root_fresh_and_mouse_flags_forward_as_separate_tui_arguments() {
10404 for flags in [
10405 ["--fresh", "--mouse-capture"],
10406 ["--mouse-capture", "--fresh"],
10407 ] {
10408 let cli = parse_ok(&[
10409 "codewhale",
10410 "--workspace",
10411 "workspace with spaces",
10412 "--no-project-config",
10413 flags[0],
10414 flags[1],
10415 ]);
10416 assert_eq!(
10417 tui_argv(&cli, root_tui_passthrough(&cli).unwrap()),
10418 [
10419 "codewhale",
10420 "--workspace",
10421 "workspace with spaces",
10422 "--mouse-capture",
10423 "--fresh",
10424 "--no-project-config",
10425 ],
10426 "{flags:?} must remain launch flags, not a joined prompt"
10427 );
10428 }
10429 }
10430
10431 #[test]
10432 fn root_fresh_preserves_quoted_prompt_whitespace_and_split_tail() {
10433 let cli = parse_ok(&[
10434 "codewhale",
10435 "--fresh",
10436 "--mouse-capture",
10437 "--prompt",
10438 "Keep two spaces\nand a tab\there",
10439 "then",
10440 "explain them",
10441 ]);
10442 assert_eq!(
10443 tui_argv(&cli, root_tui_passthrough(&cli).unwrap()),
10444 [
10445 "codewhale",
10446 "--mouse-capture",
10447 "--fresh",
10448 "--prompt",
10449 "Keep two spaces\nand a tab\there then explain them",
10450 ]
10451 );
10452 }
10453
10454 #[test]
10455 fn root_prompt_tail_does_not_reinterpret_literal_launch_flags() {
10456 let cli = parse_ok(&[
10457 "codewhale",
10458 "Explain",
10459 "--fresh",
10460 "--mouse-capture",
10461 "as literal flags",
10462 ]);
10463 assert_eq!(
10464 tui_argv(&cli, root_tui_passthrough(&cli).unwrap()),
10465 [
10466 "codewhale",
10467 "--prompt",
10468 "Explain --fresh --mouse-capture as literal flags",
10469 ]
10470 );
10471 }
10472
10473 #[test]
10474 fn parses_top_level_continue_for_interactive_resume() {
10475 let cli = parse_ok(&["codewhale", "--continue"]);
10476
10477 assert!(cli.continue_session);
10478 assert!(cli.prompt_flag.is_none());
10479 assert!(cli.prompt.is_empty());
10480 assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
10481 }
10482
10483 #[test]
10484 fn parses_top_level_resume_flags_for_interactive_resume() {
10485 // The operations runbook advertises `codewhale --resume <id>`. Before
10486 // the root flag existed, the trailing prompt positional swallowed it
10487 // and forwarded `--prompt "--resume <id>"` to the TUI (exit 2).
10488 for argv in [
10489 &["codewhale", "--resume", "800596e6"][..],
10490 &["codewhale", "--resume=800596e6"][..],
10491 &["codewhale", "-r", "800596e6"][..],
10492 &["codewhale", "--session-id", "800596e6"][..],
10493 &["codewhale", "--session-id=800596e6"][..],
10494 ] {
10495 let cli = parse_ok(argv);
10496 assert!(
10497 cli.prompt.is_empty(),
10498 "{argv:?} must not be swallowed as a prompt: {:?}",
10499 cli.prompt
10500 );
10501 assert!(cli.prompt_flag.is_none(), "{argv:?}");
10502 assert!(cli.command.is_none(), "{argv:?}");
10503 assert_eq!(
10504 root_tui_passthrough(&cli).unwrap(),
10505 vec!["--resume".to_string(), "800596e6".to_string()],
10506 "{argv:?}"
10507 );
10508 }
10509 }
10510
10511 #[test]
10512 fn empty_resume_identifier_is_rejected_rather_than_starting_fresh() {
10513 // `codewhale --resume "$SESSION_ID"` with the variable unset used to
10514 // trim to empty, filter to None, and start a brand-new session while
10515 // looking like it resumed one. Losing the session the user asked for
10516 // must be loud.
10517 for argv in [
10518 &["codewhale", "--resume", ""][..],
10519 &["codewhale", "--session-id", " "][..],
10520 ] {
10521 let cli = parse_ok(argv);
10522 let err = root_tui_passthrough(&cli)
10523 .expect_err("an empty resume id must not silently start a fresh session");
10524 assert!(
10525 err.to_string().contains("needs a session id"),
10526 "{argv:?}: {err}"
10527 );
10528 }
10529 }
10530
10531 #[test]
10532 fn top_level_resume_rejects_startup_prompt_and_conflicting_flags() {
10533 let cli = parse_ok(&["codewhale", "--resume", "800596e6", "-p", "follow up"]);
10534 let err = root_tui_passthrough(&cli).expect_err("prompted resume should be rejected");
10535 assert!(
10536 err.to_string()
10537 .contains("codewhale exec --resume 800596e6 <PROMPT>"),
10538 "{err}"
10539 );
10540
10541 assert!(Cli::try_parse_from(["codewhale", "--resume", "800596e6", "--continue"]).is_err());
10542 assert!(Cli::try_parse_from(["codewhale", "--resume", "a", "--session-id", "b"]).is_err());
10543 assert!(Cli::try_parse_from(["codewhale", "--session-id", "b", "-c"]).is_err());
10544 }
10545
10546 #[test]
10547 fn parses_rc_as_the_account_owned_interactive_handoff() {
10548 let cli = parse_ok(&["codewhale", "rc"]);
10549
10550 let Some(Commands::Rc(args)) = cli.command else {
10551 panic!("rc should parse as the remote-control TUI handoff");
10552 };
10553 assert!(args.args.is_empty());
10554 }
10555
10556 #[test]
10557 fn top_level_continue_rejects_startup_prompt() {
10558 let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
10559
10560 let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
10561 assert!(
10562 err.to_string()
10563 .contains("codewhale exec --continue <PROMPT>")
10564 );
10565 }
10566
10567 #[test]
10568 fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
10569 let cli = parse_ok(&["deepseek", "hello", "world"]);
10570
10571 assert_eq!(cli.prompt, vec!["hello", "world"]);
10572 assert!(cli.command.is_none());
10573 assert_eq!(
10574 root_tui_passthrough(&cli).unwrap(),
10575 vec!["--prompt".to_string(), "hello world".to_string()]
10576 );
10577 }
10578
10579 #[test]
10580 fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
10581 let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
10582
10583 assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
10584 assert_eq!(cli.prompt, vec!["world"]);
10585 assert_eq!(
10586 root_tui_passthrough(&cli).unwrap(),
10587 vec!["--prompt".to_string(), "hello world".to_string()]
10588 );
10589 }
10590
10591 #[test]
10592 fn known_subcommands_still_parse_before_prompt_tail() {
10593 let cli = parse_ok(&["deepseek", "doctor"]);
10594
10595 assert!(cli.prompt.is_empty());
10596 assert!(matches!(cli.command, Some(Commands::Doctor(_))));
10597 }
10598
10599 #[test]
10600 fn root_help_surface_contains_expected_subcommands_and_globals() {
10601 let rendered = help_for(&["deepseek", "--help"]);
10602
10603 for token in [
10604 "run",
10605 "doctor",
10606 "models",
10607 "sessions",
10608 "resume",
10609 "setup",
10610 "login",
10611 "logout",
10612 "auth",
10613 "mcp-server",
10614 "config",
10615 "model",
10616 "providers",
10617 "thread",
10618 "sandbox",
10619 "app-server",
10620 "completion",
10621 "metrics",
10622 "--provider",
10623 "--model",
10624 "--config",
10625 "--profile",
10626 "--output-mode",
10627 "--log-level",
10628 "--telemetry",
10629 "--base-url",
10630 "--api-key",
10631 "--approval-policy",
10632 "--sandbox-mode",
10633 "--mouse-capture",
10634 "--no-mouse-capture",
10635 "--skip-onboarding",
10636 "--fresh",
10637 "--continue",
10638 "--prompt",
10639 ] {
10640 assert!(
10641 rendered.contains(token),
10642 "expected help to contain token: {token}"
10643 );
10644 }
10645 }
10646
10647 #[test]
10648 fn subcommand_help_surfaces_are_stable() {
10649 let cases = [
10650 ("config", vec!["get", "set", "unset", "list", "path"]),
10651 ("model", vec!["list", "resolve"]),
10652 (
10653 "thread",
10654 vec![
10655 "list",
10656 "read",
10657 "resume",
10658 "fork",
10659 "archive",
10660 "unarchive",
10661 "set-name",
10662 "clear-name",
10663 ],
10664 ),
10665 ("sandbox", vec!["check"]),
10666 (
10667 "exec",
10668 vec![
10669 "--auto",
10670 "--json",
10671 "--resume",
10672 "--session-id",
10673 "--continue",
10674 "--output-format",
10675 "stream-json",
10676 ],
10677 ),
10678 (
10679 "app-server",
10680 vec!["--host", "--port", "--config", "--stdio"],
10681 ),
10682 (
10683 "completion",
10684 vec![
10685 "<SHELL>",
10686 "bash",
10687 "Every script completes both `codewhale` and the `codew` shorthand.",
10688 "source <(codewhale completion bash)",
10689 "~/.local/share/bash-completion/completions/codewhale",
10690 "fpath=(~/.zfunc $fpath)",
10691 "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
10692 "codewhale completion powershell | Out-String | Invoke-Expression",
10693 "codewhale completion elvish >> ~/.config/elvish/rc.elv",
10694 ],
10695 ),
10696 ("metrics", vec!["--json", "--since"]),
10697 ];
10698
10699 for (subcommand, expected_tokens) in cases {
10700 let argv = ["deepseek", subcommand, "--help"];
10701 let rendered = help_for(&argv);
10702 for token in expected_tokens {
10703 assert!(
10704 rendered.contains(token),
10705 "expected help for `{subcommand}` to include `{token}`"
10706 );
10707 }
10708 }
10709 }
10710
10711 #[test]
10712 fn cli_telemetry_start_fails_closed_on_a_corrupt_setup_state() {
10713 let dir = tempfile::TempDir::new().expect("tempdir");
10714 let setup_path = dir.path().join("setup_state.json");
10715 std::fs::write(&setup_path, b"{not-json").expect("write corrupt setup state");
10716 let setup = telemetry::load_setup_state_for_decision_at(&setup_path);
10717 assert!(setup.is_none(), "corrupt privacy state must not default on");
10718
10719 let resolved =
10720 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
10721 assert!(
10722 resolve_cli_telemetry_consent(&resolved, None, Surface::Cli, setup).is_none(),
10723 "CLI startup must not obtain permission from an unreadable privacy record"
10724 );
10725 }
10726 }
10727
10727 lines RUST