1#![allow(clippy::uninlined_format_args)]
2
3mod cloud;
4mod config_bundles;
5mod credential_handoff;
6mod dispatch;
7mod metrics;
8#[cfg(not(target_env = "ohos"))]
9mod update;
10
11use std::io::{self, IsTerminal, Read, Write};
12use std::net::SocketAddr;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16use anyhow::{Context, Result, anyhow, bail};
17use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
18use clap_complete::{Shell, generate};
19use codewhale_agent::ModelRegistry;
20use codewhale_app_server::daemon_socket::{DaemonSocketOptions, run_daemon_socket};
21use codewhale_app_server::{
22 AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
23};
24use codewhale_config::route::{ProvidersExport, parse_route_kind};
25use codewhale_config::{
26 CliRuntimeOverrides, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind,
27 ProviderSource, ResolvedRuntimeOptions, RuntimeApiKeySource, SetupState,
28 classify_config_api_key_value, provider_base_url_is_official,
29};
30use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
31use codewhale_mcp::{McpServerDefinition, run_stdio_server};
32use codewhale_secrets::Secrets;
33use codewhale_state::{StateStore, ThreadListFilters};
34use codewhale_telemetry::{
35 self as telemetry, Counters, DurationBucket, Errors, Event, ExitClass, SessionSource, Surface,
36 TelemetryDecision, TurnWall,
37};
38
39fn is_antigravity_legacy_selector(value: &str) -> bool {
40 matches!(
41 value.trim().to_ascii_lowercase().as_str(),
42 "antigravity" | "agy"
43 )
44}
45
46fn parse_catalog_route(value: &str) -> std::result::Result<ProviderKind, String> {
48 if is_antigravity_legacy_selector(value) {
49 return Err(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE.to_string());
50 }
51 parse_route_kind(value).ok_or_else(|| {
52 format!(
53 "unknown route '{value}'; expected a catalog route id (see `codewhale providers export --json`)"
54 )
55 })
56}
57
58fn builtin_provider_arg(value: &str) -> Option<ProviderKind> {
59 parse_route_kind(value).filter(|provider| *provider != ProviderKind::Antigravity)
60}
61
62fn parse_auth_clear_provider(value: &str) -> std::result::Result<ProviderKind, String> {
66 if is_antigravity_legacy_selector(value) {
67 return Ok(ProviderKind::Antigravity);
68 }
69 parse_catalog_route(value)
70}
71
72fn parse_provider_identifier(value: &str) -> std::result::Result<String, String> {
73 if value.is_empty()
74 || value == "__custom__"
75 || !value
76 .chars()
77 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
78 {
79 return Err(
80 "provider must be a simple identifier using letters, numbers, '-', '_', or '.'"
81 .to_string(),
82 );
83 }
84 Ok(value.to_string())
85}
86
87#[derive(Debug, Parser)]
88#[command(
89 name = "codewhale",
90 version = env!("CODEWHALE_BUILD_VERSION"),
91 bin_name = "codewhale",
92 override_usage = "codewhale [OPTIONS] [PROMPT]\n codewhale [OPTIONS] <COMMAND> [ARGS]"
93)]
94struct Cli {
95 #[arg(long)]
96 config: Option<PathBuf>,
97 #[arg(long)]
98 profile: Option<String>,
99 #[arg(
100 long,
101 value_name = "PROVIDER",
102 value_parser = parse_provider_identifier,
103 help = "Provider selector; exec/fleet also accept configured custom provider identifiers"
104 )]
105 provider: Option<String>,
106 #[arg(long)]
107 model: Option<String>,
108 #[arg(long = "output-mode")]
109 output_mode: Option<String>,
110 #[arg(
111 long = "verbosity",
112 value_name = "LEVEL",
113 help = "Controls transcript and output verbosity (normal, concise)"
114 )]
115 verbosity: Option<String>,
116 #[arg(long = "log-level")]
117 log_level: Option<String>,
118 #[arg(
119 long,
120 value_name = "BOOL",
121 help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
122 durable off: config set telemetry false; CODEWHALE_TELEMETRY=0 always wins)"
123 )]
124 telemetry: Option<bool>,
125 #[arg(long)]
126 approval_policy: Option<String>,
127 #[arg(long)]
128 sandbox_mode: Option<String>,
129 #[arg(long)]
130 api_key: Option<String>,
131 #[arg(long)]
132 base_url: Option<String>,
133 #[arg(short = 'C', long = "workspace", alias = "cd", value_name = "DIR")]
135 workspace: Option<PathBuf>,
136 #[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
137 mouse_capture: bool,
138 #[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
139 no_mouse_capture: bool,
140 #[arg(long = "skip-onboarding")]
141 skip_onboarding: bool,
142 #[arg(long)]
144 fresh: bool,
145 #[arg(long = "no-project-config")]
149 no_project_config: bool,
150 #[arg(long, hide = true)]
152 yolo: bool,
153 #[arg(short = 'c', long = "continue")]
155 continue_session: bool,
156 #[arg(
158 short = 'r',
159 long = "resume",
160 value_name = "SESSION_ID",
161 conflicts_with_all = ["continue_session", "session_id"]
162 )]
163 resume: Option<String>,
164 #[arg(
166 long = "session-id",
167 value_name = "SESSION_ID",
168 conflicts_with_all = ["continue_session", "resume"]
169 )]
170 session_id: Option<String>,
171 #[arg(short = 'p', long = "prompt", value_name = "PROMPT")]
172 prompt_flag: Option<String>,
173 #[arg(long = "set", value_name = "KEY=VALUE")]
179 overrides: Vec<String>,
180 #[arg(
181 value_name = "PROMPT",
182 trailing_var_arg = true,
183 allow_hyphen_values = true
184 )]
185 prompt: Vec<String>,
186 #[command(subcommand)]
187 command: Option<Commands>,
188}
189
190#[derive(Debug, Subcommand)]
191enum Commands {
192 Run(RunArgs),
194 Doctor(TuiPassthroughArgs),
196 #[command(
198 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."
199 )]
200 Models(TuiPassthroughArgs),
201 #[command(visible_alias = "tts")]
203 Speech(TuiPassthroughArgs),
204 Sessions(TuiPassthroughArgs),
206 Resume(TuiPassthroughArgs),
208 Rc(TuiPassthroughArgs),
210 Fork(TuiPassthroughArgs),
212 Init(TuiPassthroughArgs),
214 Setup(TuiPassthroughArgs),
216 RemoteSetup(RemoteSetupArgs),
218 #[command(after_help = "\
220Examples:
221 codewhale exec \"explain this function\"
222 codewhale exec --auto \"list crates/ with ls\"
223 codewhale exec --auto --output-format stream-json \"fix the failing test\"
224
225Common forwarded flags:
226 --auto Enable tool-backed agent mode with auto-approvals
227 --json Emit summary JSON
228 --resume <SESSION_ID> Resume a previous session by ID or prefix
229 --session-id <SESSION_ID> Resume a previous session by ID or prefix
230 --continue Continue the most recent session for this workspace
231 --output-format <FORMAT> Output format: text or stream-json
232
233Plain `codewhale exec` is a one-shot model response. Use `--auto` for
234non-interactive filesystem/shell tool use, matching the supported automation
235path used by stream-json wrappers.
236")]
237 Exec(TuiPassthroughArgs),
238 #[command(
240 name = "fleet",
241 after_help = "\
242Examples:
243 codewhale fleet init
244 codewhale fleet run tasks.json --max-workers 4
245 codewhale fleet status
246
247The durable ledger `.codewhale/fleet.jsonl`, saved rosters `fleets/<name>.toml`,
248the `[fleet]` and `[fleets.*]` config tables, and `workflow run --fleet` keep
249the Fleet name across versions."
250 )]
251 Fleet(TuiPassthroughArgs),
252 #[command(name = "workflow-tool", hide = true)]
254 WorkflowTool(TuiPassthroughArgs),
255 #[command(name = "lane-log-proxy", hide = true)]
257 LaneLogProxy(LaneLogProxyArgs),
258 #[command(after_help = "\
260Examples:
261 codewhale workflow run stopship --fleet stopship --runtime tmux --goal verify-release-candidate
262 codewhale workflow run stopship --fleet stopship --runtime inline --verify
263
264`workflow run` validates the checked-in Workflow source and named Fleet roster,
265creates a Lane record, then dispatches the Workflow tool directly through the
266selected Runtime backend without an operator model turn.
267")]
268 Workflow(WorkflowArgs),
269 #[command(after_help = "\
271Examples:
272 codewhale lane list
273 codewhale lane status <lane-id>
274 codewhale lane attach <lane-id>
275 codewhale lane logs <lane-id>
276 codewhale lane interrupt <lane-id>
277 codewhale lane interrupt <lane-id>@<lifecycle-seq>
278 codewhale lane start --workflow stopship --fleet stopship --runtime tmux --goal verify-release-candidate -- echo hello
279
280Lane records persist under $CODEWHALE_HOME/lanes/. tmux durability belongs to
281Runtime, not Fleet.
282
283list/status/interrupt/restart/resume share one control-plane contract with the
284`/lane` slash command and its hotbar action: same verb ids, same availability,
285same read-vs-write authority, same exact-identity target selection, and the
286same receipt (`--json`). `lane stop` is a compatibility spelling of
287`lane interrupt`. Appending `@<lifecycle-seq>` fences a write to the exact
288lifecycle generation you observed.
289")]
290 Lane(LaneArgs),
291 Review(TuiPassthroughArgs),
293 Apply(TuiPassthroughArgs),
295 Eval(TuiPassthroughArgs),
297 Mcp(TuiPassthroughArgs),
299 Features(TuiPassthroughArgs),
301 Integrations(TuiPassthroughArgs),
303 #[command(after_help = "\
305Forwarded serve options:
306 --mcp Start MCP server over stdio
307 --http Start runtime HTTP/SSE API server
308 --mobile Start runtime HTTP/SSE API server with the mobile control page
309 --web Start the embedded loopback-only browser client
310 --qr Show a QR code for the mobile URL (requires --mobile)
311 --acp Start ACP server over stdio for editor clients
312 --host <HOST> Bind host (default 127.0.0.1; --mobile defaults to 0.0.0.0)
313 --port <PORT> Bind port [default: 7878]
314 --workers <WORKERS> Background task worker count (1-8)
315 --cors-origin <URL> Additional CORS origin to allow (repeatable)
316 --auth-token <TOKEN> Require this bearer token for /v1/* runtime API routes
317 --insecure Disable runtime API auth when no token is configured
318
319`codewhale serve --http` and `codewhale serve --mobile` remain compatibility
320aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
321New integrations should prefer `codewhale app-server`.")]
322 Serve(TuiPassthroughArgs),
323 #[command(
325 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."
326 )]
327 Web(WebArgs),
328 Login(LoginArgs),
330 Logout,
332 Auth(AuthArgs),
334 #[command(visible_alias = "cloud")]
336 Account(cloud::CloudArgs),
337 #[command(visible_alias = "cloud-agent")]
339 Dispatch(dispatch::DispatchArgs),
340 McpServer,
342 Config(ConfigArgs),
344 Model(ModelArgs),
346 Thread(ThreadArgs),
348 Sandbox(SandboxArgs),
350 #[command(after_help = "\
352Transports:
353 codewhale app-server --http Full HTTP/SSE runtime API (/v1/*) on 127.0.0.1:7878
354 codewhale app-server --mobile Runtime API + phone control page (binds 0.0.0.0)
355 codewhale app-server --stdio JSON-RPC control transport over stdio (no listener)
356 codewhale app-server Legacy in-process app-server HTTP on 127.0.0.1:8787
357
358`--http` and `--mobile` serve the same mature runtime API as `codewhale serve
359--http`/`--mobile`, which remain as compatibility aliases. The runtime API token
360is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN.
361
362See docs/RUNTIME_API.md.")]
363 AppServer(AppServerArgs),
364 #[command(
366 visible_alias = "completions",
367 after_help = r#"Every script completes both `codewhale` and the `codew` shorthand.
368
369Examples:
370 Bash (current shell only):
371 source <(codewhale completion bash)
372
373 Bash (persistent, Linux/bash-completion):
374 mkdir -p ~/.local/share/bash-completion/completions
375 codewhale completion bash > ~/.local/share/bash-completion/completions/codewhale
376 # Requires bash-completion to be installed and loaded by your shell.
377
378 Zsh:
379 mkdir -p ~/.zfunc
380 codewhale completion zsh > ~/.zfunc/_codewhale
381 # Add to ~/.zshrc if needed:
382 # fpath=(~/.zfunc $fpath)
383 # autoload -Uz compinit && compinit
384
385 Fish:
386 mkdir -p ~/.config/fish/completions
387 codewhale completion fish > ~/.config/fish/completions/codewhale.fish
388
389 PowerShell (current shell only):
390 codewhale completion powershell | Out-String | Invoke-Expression
391
392 PowerShell (persistent):
393 New-Item -ItemType Directory -Force -Path (Split-Path -Parent $PROFILE)
394 codewhale completion powershell >> $PROFILE
395
396 Elvish:
397 codewhale completion elvish >> ~/.config/elvish/rc.elv
398
399The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#
400 )]
401 Completion {
402 #[arg(value_enum)]
403 shell: Shell,
404 },
405 Metrics(MetricsArgs),
407 #[command(
409 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"
410 )]
411 Update(UpdateArgs),
412 Providers(ProvidersArgs),
414}
415
416#[derive(Debug, Args)]
417struct ProvidersArgs {
418 #[command(subcommand)]
419 command: ProvidersCommand,
420}
421
422#[derive(Debug, Subcommand)]
423enum ProvidersCommand {
424 Export {
426 #[arg(long)]
428 json: bool,
429 },
430}
431
432const COMPLETION_BIN_NAME: &str = "codewhale";
441
442const COMPLETION_ALIAS_NAME: &str = "codew";
447
448fn render_completion_script(shell: Shell) -> String {
451 let mut cmd = Cli::command();
452 let mut buf = Vec::new();
453 generate(shell, &mut cmd, COMPLETION_BIN_NAME, &mut buf);
454 let script = String::from_utf8_lossy(&buf).into_owned();
455 register_completion_alias(shell, script)
456}
457
458fn register_completion_alias(shell: Shell, script: String) -> String {
466 let bin = COMPLETION_BIN_NAME;
467 let alias = COMPLETION_ALIAS_NAME;
468 match shell {
469 Shell::Bash => format!(
470 "{script}\n\
471 if [[ \"${{BASH_VERSINFO[0]}}\" -eq 4 && \"${{BASH_VERSINFO[1]}}\" -ge 4 || \"${{BASH_VERSINFO[0]}}\" -gt 4 ]]; then\n \
472 complete -F _{bin} -o nosort -o bashdefault -o default {alias}\n\
473 else\n \
474 complete -F _{bin} -o bashdefault -o default {alias}\n\
475 fi\n"
476 ),
477 Shell::Zsh => {
483 let tagged = match script.strip_prefix(&format!("#compdef {bin}\n")) {
484 Some(rest) => format!("#compdef {bin} {alias}\n{rest}"),
485 None => script,
486 };
487 format!(
488 "{tagged}\nif [ \"$funcstack[1]\" != \"_{bin}\" ]; then\n \
489 compdef _{bin} {alias}\n\
490 fi\n"
491 )
492 }
493 Shell::Fish => format!("{script}\ncomplete -c {alias} -w {bin}\n"),
494 Shell::PowerShell => script.replacen(
495 &format!("-CommandName '{bin}'"),
496 &format!("-CommandName '{bin}','{alias}'"),
497 1,
498 ),
499 Shell::Elvish => format!(
500 "{script}\n\
501 set edit:completion:arg-completer[{alias}] = $edit:completion:arg-completer[{bin}]\n"
502 ),
503 _ => script,
504 }
505}
506
507fn command_accepts_raw_provider(command: Option<&Commands>) -> bool {
508 matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_)))
509}
510
511fn top_level_provider_override(
512 provider: Option<&str>,
513 command: Option<&Commands>,
514) -> Result<Option<ProviderKind>> {
515 let Some(provider) = provider else {
516 return Ok(None);
517 };
518 if is_antigravity_legacy_selector(provider) {
519 bail!(codewhale_config::LEGACY_ANTIGRAVITY_TOMBSTONE_MESSAGE);
520 }
521 if let Some(provider) = builtin_provider_arg(provider) {
522 return Ok(Some(provider));
523 }
524 if command_accepts_raw_provider(command) {
525 return Ok(None);
526 }
527
528 let expected = ProviderKind::names_hint();
529 bail!(
530 "invalid value '{provider}' for '--provider <PROVIDER>': expected one of {expected}; configured custom providers are accepted only by exec and fleet"
531 )
532}
533
534fn prepare_raw_provider_tui_dispatch(
535 cli: &Cli,
536 command: Option<&Commands>,
537 runtime_overrides: &CliRuntimeOverrides,
538) -> Result<Option<(ResolvedRuntimeOptions, Vec<String>)>> {
539 let Some(provider) = cli.provider.as_deref() else {
540 return Ok(None);
541 };
542 if builtin_provider_arg(provider).is_some() || !command_accepts_raw_provider(command) {
543 return Ok(None);
544 }
545
546 let passthrough = match command {
547 Some(Commands::Exec(args)) => {
548 reject_exec_global_flags(&args.args)?;
549 tui_args("exec", args.clone())
550 }
551 Some(Commands::Fleet(args)) => tui_args("fleet", args.clone()),
552 _ => unreachable!("raw provider validation only permits Exec and Fleet"),
553 };
554
555 let resolved_runtime = ConfigToml::default().resolve_runtime_options(runtime_overrides);
559 Ok(Some((resolved_runtime, passthrough)))
560}
561
562#[derive(Debug, Args)]
563struct UpdateArgs {
564 #[arg(long)]
566 beta: bool,
567 #[arg(long)]
569 check: bool,
570 #[arg(long, value_name = "URL")]
572 proxy: Option<String>,
573}
574
575#[derive(Debug, Args)]
576struct MetricsArgs {
577 #[arg(long)]
579 json: bool,
580 #[arg(long, value_name = "DURATION")]
582 since: Option<String>,
583}
584
585#[derive(Debug, Args)]
586struct RunArgs {
587 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
588 args: Vec<String>,
589}
590
591#[derive(Debug, Args, Clone)]
592struct TuiPassthroughArgs {
593 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
594 args: Vec<String>,
595}
596
597#[derive(Debug, Args)]
598struct WebArgs {
599 #[arg(long, default_value_t = 7878)]
601 port: u16,
602}
603
604#[derive(Debug, Args)]
605struct LaneLogProxyArgs {
606 #[arg(long, value_name = "PATH")]
607 log_path: PathBuf,
608 #[arg(long, value_name = "PATH")]
609 receipt_path: PathBuf,
610 #[arg(long, value_name = "PATH")]
611 receipt_tmp_path: PathBuf,
612 #[arg(long, value_name = "PATH")]
613 environment_path: Option<PathBuf>,
614 #[arg(long)]
615 lane_id: String,
616 #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
617 command: Vec<String>,
618}
619
620#[derive(Debug, Args)]
622struct LaneArgs {
623 #[command(subcommand)]
624 command: LaneCommand,
625}
626
627#[derive(Debug, Subcommand)]
628#[allow(clippy::large_enum_variant)]
632enum LaneCommand {
633 List {
635 #[arg(long, default_value_t = false)]
637 json: bool,
638 },
639 Status {
641 lane_id: String,
643 #[arg(long, default_value_t = false)]
644 json: bool,
645 },
646 Attach {
648 lane_id: String,
649 #[arg(long, default_value_t = false)]
651 print: bool,
652 },
653 Logs {
655 lane_id: String,
656 #[arg(long, short = 'f', default_value_t = false)]
658 follow: bool,
659 #[arg(long, default_value_t = 50)]
661 tail: usize,
662 },
663 Stop { lane_id: String },
668 Interrupt {
673 lane_id: String,
674 #[arg(long, default_value_t = false)]
675 json: bool,
676 },
677 Restart {
679 lane_id: String,
680 #[arg(long, default_value_t = false)]
681 json: bool,
682 },
683 Resume {
685 lane_id: String,
686 #[arg(long, default_value_t = false)]
687 json: bool,
688 },
689 Start {
691 #[arg(long)]
693 workflow: Option<String>,
694 #[arg(long)]
696 fleet: Option<String>,
697 #[arg(long)]
699 issue: Option<String>,
700 #[arg(long)]
702 goal: Option<String>,
703 #[arg(long, default_value = "tmux")]
705 runtime: String,
706 #[arg(long, value_name = "DIR")]
708 worktree_repo: Option<PathBuf>,
709 #[arg(long)]
711 branch: Option<String>,
712 #[arg(long, value_name = "DIR")]
714 worktree_path: Option<PathBuf>,
715 #[arg(long)]
717 worktree_ttl_secs: Option<u64>,
718 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
720 command: Vec<String>,
721 },
722}
723
724#[derive(Debug, Args)]
726struct WorkflowArgs {
727 #[command(subcommand)]
728 command: WorkflowCommand,
729}
730
731#[derive(Debug, Subcommand)]
732enum WorkflowCommand {
733 Run {
735 workflow: String,
737 #[arg(long)]
741 fleet: Option<String>,
742 #[arg(long)]
744 issue: Option<String>,
745 #[arg(long)]
747 goal: Option<String>,
748 #[arg(long, default_value = "tmux")]
750 runtime: String,
751 #[arg(long, value_name = "PATH")]
753 source_path: Option<PathBuf>,
754 #[arg(long)]
756 token_budget: Option<u64>,
757 #[arg(long, default_value_t = false)]
759 verify: bool,
760 #[arg(long, value_name = "DIR")]
762 worktree_repo: Option<PathBuf>,
763 #[arg(long)]
765 branch: Option<String>,
766 #[arg(long, value_name = "DIR")]
768 worktree_path: Option<PathBuf>,
769 #[arg(long)]
771 worktree_ttl_secs: Option<u64>,
772 },
773}
774
775struct LaneStartRequest {
776 workflow: Option<String>,
777 fleet: Option<String>,
778 issue: Option<String>,
779 goal: Option<String>,
780 runtime: String,
781 worktree_repo: Option<PathBuf>,
782 branch: Option<String>,
783 worktree_path: Option<PathBuf>,
784 worktree_ttl_secs: Option<u64>,
785 command: Vec<String>,
786 environment: Vec<(String, String)>,
787 cwd: Option<PathBuf>,
788}
789
790fn start_lane(request: LaneStartRequest) -> Result<()> {
791 use codewhale_lane::{
792 LaneRegistry, LaneStartSpec, RuntimeBackendKind, WorktreeProvision, resolve_backend,
793 };
794
795 let LaneStartRequest {
796 workflow,
797 fleet,
798 issue,
799 goal,
800 runtime,
801 worktree_repo,
802 branch,
803 worktree_path,
804 worktree_ttl_secs,
805 command,
806 environment,
807 cwd,
808 } = request;
809 let kind = RuntimeBackendKind::parse(&runtime)?;
810 let reg = LaneRegistry::open_default()?;
811 let mut record = reg.create_pending(workflow, fleet, issue, goal, kind, worktree_ttl_secs)?;
812 let worktree = match (worktree_repo, branch) {
813 (Some(repo_root), Some(branch_name)) => {
814 let path = worktree_path
815 .unwrap_or_else(|| repo_root.join(".codewhale").join("lanes").join(&record.id));
816 Some(WorktreeProvision {
817 repo_root,
818 branch: branch_name,
819 path,
820 base_ref: None,
821 })
822 }
823 (None, None) => None,
824 _ => bail!("--worktree-repo and --branch must be provided together"),
825 };
826 let cmd = if command.is_empty() {
827 vec![
828 "sh".into(),
829 "-c".into(),
830 format!("echo lane {} started", record.id),
831 ]
832 } else {
833 command
834 };
835 let spec = LaneStartSpec {
836 command: cmd,
837 cwd,
838 environment,
839 log_proxy: (kind == RuntimeBackendKind::Tmux)
840 .then(std::env::current_exe)
841 .transpose()
842 .context("resolve current Codewhale executable for tmux log proxy")?,
843 worktree,
844 };
845 let backend = resolve_backend(kind);
846 backend.start(®, &mut record, &spec)?;
847 println!("started {}", record.id);
848 println!("status: {}", record.status.as_str());
849 println!("runtime: {}", record.runtime.as_str());
850 println!("log: {}", record.log_path.display());
851 if let Some(attach) = backend.attach_command(&record) {
852 println!("attach: {attach}");
853 }
854 Ok(())
855}
856
857fn emit_control_receipt(receipt: &codewhale_lane::ControlReceipt, json: bool) -> Result<()> {
863 if json {
864 match receipt.operation {
870 codewhale_lane::ControlOperation::LaneList => {
871 println!("{}", serde_json::to_string_pretty(&receipt.lane_records)?);
872 }
873 codewhale_lane::ControlOperation::LaneStatus => match receipt.lane_records.first() {
874 Some(record) => println!("{}", serde_json::to_string_pretty(record)?),
875 None if receipt.is_error() => {}
881 None => println!("{}", serde_json::to_string_pretty(receipt)?),
882 },
883 _ => println!("{}", serde_json::to_string_pretty(receipt)?),
884 }
885 } else if receipt.is_error() {
886 eprintln!("{}", receipt.render());
887 } else {
888 println!("{}", receipt.render());
889 }
890 if receipt.is_error() {
891 let detail = receipt
892 .failure
893 .as_ref()
894 .map(ToString::to_string)
895 .unwrap_or_else(|| receipt.outcome.as_str().to_string());
896 bail!("{}: {detail}", receipt.operation_id);
897 }
898 Ok(())
899}
900
901fn run_lane_control(
902 operation: codewhale_lane::ControlOperation,
903 lane_id: Option<&str>,
904 json: bool,
905) -> Result<()> {
906 let receipt = codewhale_lane::control::execute_lane_control(
907 codewhale_lane::ControlSurface::Cli,
908 operation,
909 lane_id,
910 );
911 emit_control_receipt(&receipt, json)
912}
913
914fn run_lane_command(args: LaneArgs) -> Result<()> {
915 use codewhale_lane::{ControlOperation, LaneRegistry, backend_for};
916 use std::io::{BufRead, Seek, Write};
917 use std::process::Command;
918 use std::thread;
919 use std::time::Duration;
920
921 match args.command {
922 LaneCommand::List { json } => run_lane_control(ControlOperation::LaneList, None, json),
923 LaneCommand::Status { lane_id, json } => {
924 run_lane_control(ControlOperation::LaneStatus, Some(&lane_id), json)
925 }
926 LaneCommand::Interrupt { lane_id, json } => {
927 run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), json)
928 }
929 LaneCommand::Restart { lane_id, json } => {
930 run_lane_control(ControlOperation::LaneRestart, Some(&lane_id), json)
931 }
932 LaneCommand::Resume { lane_id, json } => {
933 run_lane_control(ControlOperation::LaneResume, Some(&lane_id), json)
934 }
935 LaneCommand::Attach { lane_id, print } => {
936 let reg = LaneRegistry::open_default()?;
937 let mut lane = reg.load(&lane_id)?;
938 let backend = backend_for(&lane);
939 backend.reconcile(®, &mut lane)?;
940 let Some(attach) = backend.attach_command(&lane) else {
941 if !lane.status.is_active() {
942 bail!(
943 "lane `{lane_id}` is {} and has no active attach target",
944 lane.status.as_str()
945 );
946 }
947 bail!(
948 "lane `{lane_id}` runtime `{}` has no attach target",
949 lane.runtime.as_str()
950 );
951 };
952 if print {
953 println!("{attach}");
954 return Ok(());
955 }
956 if let Some(session) = lane.tmux_session.as_deref() {
957 let socket = lane
958 .tmux_socket
959 .as_deref()
960 .context("tmux lane is missing its pinned server socket")?;
961 let status = Command::new("tmux")
962 .arg("-S")
963 .arg(socket)
964 .args(["attach", "-t", session])
965 .status();
966 match status {
967 Ok(s) if s.success() => Ok(()),
968 Ok(s) => bail!("tmux attach failed ({s}); command was: {attach}"),
969 Err(err) => {
970 eprintln!("could not exec tmux: {err}");
971 println!("{attach}");
972 bail!("tmux attach unavailable");
973 }
974 }
975 } else {
976 println!("{attach}");
977 Ok(())
978 }
979 }
980 LaneCommand::Logs {
981 lane_id,
982 follow,
983 tail,
984 } => {
985 let reg = LaneRegistry::open_default()?;
986 let lane = reg.load(&lane_id)?;
987 let path = lane.log_path;
988 if !path.exists() {
989 bail!("log file missing: {}", path.display());
990 }
991 let content = std::fs::read(&path)?;
992 let lines: Vec<&[u8]> = content
993 .split(|byte| *byte == b'\n')
994 .filter(|line| !line.is_empty())
995 .collect();
996 let start = lines.len().saturating_sub(tail);
997 let mut stdout = std::io::stdout().lock();
998 for line in &lines[start..] {
999 stdout.write_all(String::from_utf8_lossy(line).as_bytes())?;
1000 stdout.write_all(b"\n")?;
1001 }
1002 stdout.flush()?;
1003 if !follow {
1004 return Ok(());
1005 }
1006 let mut file = std::fs::File::open(&path)?;
1007 file.seek(std::io::SeekFrom::End(0))?;
1008 let mut reader = std::io::BufReader::new(file);
1009 loop {
1010 let mut line = Vec::new();
1011 match reader.read_until(b'\n', &mut line) {
1012 Ok(0) => {
1013 thread::sleep(Duration::from_millis(200));
1014 continue;
1015 }
1016 Ok(_) => {
1017 let mut stdout = std::io::stdout().lock();
1018 stdout.write_all(String::from_utf8_lossy(&line).as_bytes())?;
1019 stdout.flush()?;
1020 }
1021 Err(err) => return Err(err.into()),
1022 }
1023 }
1024 }
1025 LaneCommand::Stop { lane_id } => {
1029 run_lane_control(ControlOperation::LaneInterrupt, Some(&lane_id), false)
1030 }
1031 LaneCommand::Start {
1032 workflow,
1033 fleet,
1034 issue,
1035 goal,
1036 runtime,
1037 worktree_repo,
1038 branch,
1039 worktree_path,
1040 worktree_ttl_secs,
1041 command,
1042 } => start_lane(LaneStartRequest {
1043 workflow,
1044 fleet,
1045 issue,
1046 goal,
1047 runtime,
1048 worktree_repo,
1049 branch,
1050 worktree_path,
1051 worktree_ttl_secs,
1052 command,
1053 environment: Vec::new(),
1054 cwd: None,
1055 }),
1056 }
1057}
1058
1059fn run_lane_log_proxy_command(args: LaneLogProxyArgs) -> Result<()> {
1060 let exit_code = codewhale_lane::run_lane_log_proxy(codewhale_lane::LaneLogProxySpec {
1061 command: args.command,
1062 log_path: args.log_path,
1063 receipt_path: args.receipt_path,
1064 receipt_tmp_path: args.receipt_tmp_path,
1065 environment_path: args.environment_path,
1066 lane_id: args.lane_id,
1067 })?;
1068 std::process::exit(exit_code);
1069}
1070
1071fn run_workflow_command(
1072 cli: &Cli,
1073 resolved_runtime: &ResolvedRuntimeOptions,
1074 config_path: &Path,
1075 args: WorkflowArgs,
1076) -> Result<()> {
1077 match args.command {
1078 WorkflowCommand::Run {
1079 workflow,
1080 fleet,
1081 issue,
1082 goal,
1083 runtime,
1084 source_path,
1085 token_budget,
1086 verify,
1087 worktree_repo,
1088 branch,
1089 worktree_path,
1090 worktree_ttl_secs,
1091 } => {
1092 let workspace = workflow_workspace_root(cli.workspace.as_deref())?;
1093 let source_path =
1094 resolve_workflow_source_path(&workflow, source_path.as_ref(), &workspace)?;
1095 validate_workflow_source_file(&source_path)?;
1096
1097 let source_root = if let Some(repo) = worktree_repo.as_deref() {
1098 repo.canonicalize()
1099 .with_context(|| format!("resolve --worktree-repo {}", repo.display()))?
1100 } else {
1101 workspace.clone()
1102 };
1103
1104 if let Some(name) = fleet.as_deref() {
1109 let roots = named_fleet_search_roots(&workspace);
1110 let loaded =
1111 codewhale_workflow::load_named_fleet(name, &roots).with_context(|| {
1112 format!("load Fleet `{name}` from {}", display_roots(&roots))
1113 })?;
1114 if workflow == "stopship" || name == "stopship" {
1115 loaded
1116 .validate_stopship_roles()
1117 .with_context(|| format!("validate stopship roles in Fleet `{name}`"))?;
1118 }
1119 }
1120
1121 let process = workflow_exec_command(WorkflowExecSpec {
1122 cli,
1123 resolved_runtime,
1124 config_path,
1125 source_root: &source_root,
1126 source_path: &source_path,
1127 workflow: &workflow,
1128 fleet: fleet.as_deref(),
1129 issue: issue.as_deref(),
1130 goal: goal.as_deref(),
1131 token_budget,
1132 verify,
1133 })?;
1134 start_lane(LaneStartRequest {
1135 workflow: Some(workflow),
1136 fleet,
1137 issue,
1138 goal,
1139 runtime,
1140 worktree_repo,
1141 branch,
1142 worktree_path,
1143 worktree_ttl_secs,
1144 command: process.command,
1145 environment: process.environment,
1146 cwd: Some(workspace),
1147 })
1148 }
1149 }
1150}
1151
1152fn workflow_workspace_root(explicit: Option<&Path>) -> Result<PathBuf> {
1153 if let Some(path) = explicit {
1154 return path
1155 .canonicalize()
1156 .with_context(|| format!("resolve workflow workspace {}", path.display()));
1157 }
1158 let cwd = std::env::current_dir().context("resolve current directory")?;
1159 let output = Command::new("git")
1160 .args(["rev-parse", "--show-toplevel"])
1161 .current_dir(&cwd)
1162 .output();
1163 if let Ok(output) = output
1164 && output.status.success()
1165 {
1166 let text = String::from_utf8_lossy(&output.stdout);
1167 let root = text.trim();
1168 if !root.is_empty() {
1169 let root = PathBuf::from(root);
1170 return Ok(root.canonicalize().unwrap_or(root));
1171 }
1172 }
1173 Ok(cwd)
1174}
1175
1176fn resolve_workflow_source_path(
1177 workflow: &str,
1178 source_path: Option<&PathBuf>,
1179 workspace: &Path,
1180) -> Result<PathBuf> {
1181 let candidates = workflow_source_candidates(workflow, source_path, workspace);
1182 for candidate in &candidates {
1183 if candidate.is_file() {
1184 return Ok(candidate.clone());
1185 }
1186 }
1187 bail!(
1188 "workflow source for `{workflow}` not found; tried {}",
1189 candidates
1190 .iter()
1191 .map(|p| p.display().to_string())
1192 .collect::<Vec<_>>()
1193 .join(", ")
1194 )
1195}
1196
1197fn workflow_source_candidates(
1198 workflow: &str,
1199 source_path: Option<&PathBuf>,
1200 workspace: &Path,
1201) -> Vec<PathBuf> {
1202 let mut candidates = Vec::new();
1203 if let Some(path) = source_path {
1204 candidates.push(resolve_against_workspace(path, workspace));
1205 return candidates;
1206 }
1207
1208 let raw = workflow.trim();
1209 let workflow_path = PathBuf::from(raw);
1210 if raw.contains('/') || raw.contains('\\') || raw.ends_with(".js") || raw.ends_with(".ts") {
1211 candidates.push(resolve_against_workspace(&workflow_path, workspace));
1212 return candidates;
1213 }
1214
1215 let normalized = raw.replace('-', "_");
1216 for rel in [
1217 format!("workflows/{raw}.workflow.js"),
1218 format!("workflows/{normalized}.workflow.js"),
1219 ] {
1220 let path = workspace.join(rel);
1221 if !candidates.iter().any(|existing| existing == &path) {
1222 candidates.push(path);
1223 }
1224 }
1225 candidates
1226}
1227
1228fn resolve_against_workspace(path: &Path, workspace: &Path) -> PathBuf {
1229 if path.is_absolute() {
1230 path.to_path_buf()
1231 } else {
1232 workspace.join(path)
1233 }
1234}
1235
1236fn validate_workflow_source_file(path: &Path) -> Result<()> {
1237 let source =
1238 std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1239 if source.trim_start().starts_with("export default workflow(")
1240 || source.trim_start().starts_with("workflow(")
1241 || source.contains("\nworkflow(")
1242 {
1243 let identifier = path.display().to_string();
1244 if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
1245 codewhale_workflow::compile_typescript_workflow(&identifier, &source)
1246 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1247 } else {
1248 codewhale_workflow::compile_javascript_workflow(&identifier, &source)
1249 .with_context(|| format!("parse declarative Workflow {}", path.display()))?;
1250 }
1251 }
1252 Ok(())
1253}
1254
1255fn named_fleet_search_roots(workspace: &Path) -> Vec<PathBuf> {
1256 let mut roots = Vec::new();
1257 if let Ok(home) = codewhale_config::codewhale_home() {
1258 roots.push(home);
1259 }
1260 roots.push(workspace.to_path_buf());
1261 roots
1262}
1263
1264fn display_roots(roots: &[PathBuf]) -> String {
1265 roots
1266 .iter()
1267 .map(|root| root.display().to_string())
1268 .collect::<Vec<_>>()
1269 .join(", ")
1270}
1271
1272struct WorkflowExecSpec<'a> {
1273 cli: &'a Cli,
1274 resolved_runtime: &'a ResolvedRuntimeOptions,
1275 config_path: &'a Path,
1276 source_root: &'a Path,
1277 source_path: &'a Path,
1278 workflow: &'a str,
1279 fleet: Option<&'a str>,
1280 issue: Option<&'a str>,
1281 goal: Option<&'a str>,
1282 token_budget: Option<u64>,
1283 verify: bool,
1284}
1285
1286struct WorkflowProcessSpec {
1287 command: Vec<String>,
1288 environment: Vec<(String, String)>,
1289}
1290
1291fn workflow_exec_command(spec: WorkflowExecSpec<'_>) -> Result<WorkflowProcessSpec> {
1292 let WorkflowExecSpec {
1293 cli,
1294 resolved_runtime,
1295 config_path,
1296 source_root,
1297 source_path,
1298 workflow,
1299 fleet,
1300 issue,
1301 goal,
1302 token_budget,
1303 verify,
1304 } = spec;
1305 let source_arg = source_path
1306 .strip_prefix(source_root)
1307 .with_context(|| {
1308 format!(
1309 "workflow source {} must be inside execution root {}",
1310 source_path.display(),
1311 source_root.display()
1312 )
1313 })?
1314 .display()
1315 .to_string();
1316 let mut payload = serde_json::json!({
1317 "action": "run",
1318 "source_path": source_arg,
1319 "fleet": fleet,
1320 "args": {
1321 "workflow": workflow,
1322 "fleet": fleet,
1323 "issue": issue,
1324 "goal": goal,
1325 },
1326 "verify": verify,
1327 });
1328 if let Some(token_budget) = token_budget {
1329 payload["token_budget"] = serde_json::json!(token_budget);
1330 }
1331 let input_json = serde_json::to_string(&payload)?;
1332 let passthrough = vec![
1333 "workflow-tool".to_string(),
1334 "--approval-source".to_string(),
1335 "explicit-workflow-command".to_string(),
1336 "--input-json".to_string(),
1337 input_json,
1338 ];
1339 let argv = {
1340 let mut args = Vec::new();
1342 let executable = std::env::current_exe()
1343 .context("resolve current Codewhale executable for workflow lane")?;
1344 let executable = executable.into_os_string().into_string().map_err(|path| {
1345 anyhow!(
1346 "current Codewhale executable path is not valid UTF-8: {}",
1347 PathBuf::from(path).display()
1348 )
1349 })?;
1350 args.push(executable);
1351 let cfg = Some(config_path);
1353 if let Some(cp) = cfg {
1354 args.push("--config".to_string());
1355 args.push(cp.display().to_string());
1356 } else if let Some(cp) = cli.config.as_deref() {
1357 args.push("--config".to_string());
1358 args.push(cp.display().to_string());
1359 }
1360 if let Some(profile) = cli.profile.as_ref() {
1361 args.push("--profile".to_string());
1362 args.push(profile.clone());
1363 }
1364
1365 if cli.mouse_capture {
1366 args.push("--mouse-capture".to_string());
1367 }
1368 if cli.no_mouse_capture {
1369 args.push("--no-mouse-capture".to_string());
1370 }
1371 if cli.skip_onboarding {
1372 args.push("--skip-onboarding".to_string());
1373 }
1374 if cli.no_project_config {
1375 args.push("--no-project-config".to_string());
1376 }
1377 args.extend(passthrough.clone());
1378 args
1379 };
1380 apply_tui_env(cli, resolved_runtime, &passthrough);
1381 lane_process_spec_from_argv(&argv)
1382}
1383
1384fn valid_lane_environment_key(key: &str) -> bool {
1385 let mut chars = key.chars();
1386 chars
1387 .next()
1388 .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
1389 && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
1390}
1391
1392fn shell_owned_lane_environment(key: &str) -> bool {
1393 matches!(
1394 key,
1395 "PWD" | "OLDPWD" | "SHLVL" | "_" | "TERM" | "TMUX" | "TMUX_PANE"
1396 )
1397}
1398
1399fn lane_process_spec_from_argv(argv: &[String]) -> Result<WorkflowProcessSpec> {
1400 let mut environment = std::collections::BTreeMap::new();
1401 for (key, value) in std::env::vars_os() {
1402 let (Some(key), Some(value)) = (key.to_str(), value.to_str()) else {
1403 continue;
1404 };
1405 if valid_lane_environment_key(key) && !shell_owned_lane_environment(key) {
1406 environment.insert(key.to_string(), value.to_string());
1407 }
1408 }
1409 Ok(WorkflowProcessSpec {
1410 command: argv.to_vec(),
1411 environment: environment.into_iter().collect(),
1412 })
1413}
1414
1415#[derive(Debug, Args, Clone, Default)]
1418struct RemoteSetupArgs {
1419 #[arg(long)]
1421 cloud: Option<String>,
1422 #[arg(long)]
1424 bridge: Option<String>,
1425 #[arg(long)]
1427 provider: Option<String>,
1428 #[arg(long, value_name = "DIR")]
1430 out: Option<PathBuf>,
1431 #[arg(long, default_value_t = false)]
1433 generate_only: bool,
1434 #[arg(long, default_value_t = false, conflicts_with = "generate_only")]
1436 apply: bool,
1437 #[arg(long, default_value_t = false)]
1439 yes: bool,
1440 #[arg(long, default_value_t = false)]
1442 non_interactive: bool,
1443}
1444
1445fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
1449 let mut forwarded = vec!["remote-setup".to_string()];
1450 if let Some(cloud) = args.cloud {
1451 forwarded.push("--cloud".to_string());
1452 forwarded.push(cloud);
1453 }
1454 if let Some(bridge) = args.bridge {
1455 forwarded.push("--bridge".to_string());
1456 forwarded.push(bridge);
1457 }
1458 if let Some(provider) = args.provider {
1459 forwarded.push("--provider".to_string());
1460 forwarded.push(provider);
1461 }
1462 if let Some(out) = args.out {
1463 forwarded.push("--out".to_string());
1464 forwarded.push(out.to_string_lossy().into_owned());
1465 }
1466 if args.generate_only {
1467 forwarded.push("--generate-only".to_string());
1468 }
1469 if args.apply {
1470 forwarded.push("--apply".to_string());
1471 }
1472 if args.yes {
1473 forwarded.push("--yes".to_string());
1474 }
1475 if args.non_interactive {
1476 forwarded.push("--non-interactive".to_string());
1477 }
1478 forwarded
1479}
1480
1481#[derive(Debug, Args)]
1482struct LoginArgs {
1483 #[arg(long, default_value_t = false)]
1485 no_open: bool,
1486 #[arg(
1488 long = "timeout-seconds",
1489 default_value_t = cloud::DEFAULT_LOGIN_TIMEOUT_SECONDS,
1490 value_parser = clap::value_parser!(u64).range(1..=cloud::MAX_LOGIN_TIMEOUT_SECONDS)
1491 )]
1492 timeout_seconds: u64,
1493 #[arg(long, hide = true)]
1495 api_key: Option<String>,
1496 #[arg(long, value_parser = parse_catalog_route, hide = true)]
1498 provider: Option<ProviderKind>,
1499}
1500
1501#[derive(Debug, Args)]
1502struct AuthArgs {
1503 #[command(subcommand)]
1504 command: AuthCommand,
1505}
1506
1507#[derive(Debug, Subcommand)]
1508enum AuthCommand {
1509 #[command(name = "xai-device")]
1511 XaiDevice,
1512 #[command(name = "chatgpt")]
1514 Chatgpt,
1515 #[command(name = "chatgpt-revoke")]
1517 ChatgptRevoke,
1518 #[command(name = "external-consent")]
1521 ExternalConsent {
1522 #[arg(long, value_parser = parse_catalog_route)]
1523 provider: ProviderKind,
1524 #[arg(long, value_enum)]
1525 mode: ExternalCredentialModeArg,
1526 #[arg(long, value_name = "PATH")]
1529 path: Option<PathBuf>,
1530 #[arg(long, default_value_t = false)]
1533 yes: bool,
1534 },
1535 #[command(name = "external-revoke")]
1537 ExternalRevoke {
1538 #[arg(long, value_parser = parse_catalog_route)]
1539 provider: ProviderKind,
1540 },
1541 Status {
1545 #[arg(long, value_parser = parse_catalog_route)]
1547 provider: Option<ProviderKind>,
1548 #[arg(long, default_value_t = false)]
1552 diagnostic: bool,
1553 },
1554 Set {
1558 #[arg(long, value_parser = parse_catalog_route)]
1559 provider: ProviderKind,
1560 #[arg(long)]
1562 api_key: Option<String>,
1563 #[arg(long = "api-key-stdin", default_value_t = false)]
1565 api_key_stdin: bool,
1566 },
1567 Get {
1570 #[arg(long, value_parser = parse_catalog_route)]
1571 provider: ProviderKind,
1572 },
1573 PrintApiKey {
1575 #[arg(long, value_parser = parse_catalog_route)]
1576 provider: ProviderKind,
1577 },
1578 Clear {
1580 #[arg(long, value_parser = parse_auth_clear_provider)]
1581 provider: ProviderKind,
1582 },
1583 List,
1586 #[command(hide = true)]
1588 Migrate {
1589 #[arg(long, default_value_t = false)]
1591 dry_run: bool,
1592 },
1593}
1594
1595#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1596enum ExternalCredentialModeArg {
1597 ReadOnly,
1598 Managed,
1599}
1600
1601#[derive(Debug, Args)]
1602struct ConfigArgs {
1603 #[command(subcommand)]
1604 command: ConfigCommand,
1605}
1606
1607#[derive(Debug, Subcommand)]
1608enum ConfigCommand {
1609 Get {
1610 key: String,
1611 },
1612 Set {
1613 key: String,
1614 value: String,
1615 },
1616 Unset {
1617 key: String,
1618 },
1619 Telemetry {
1621 #[arg(long, value_name = "VERSION")]
1623 accept_notice: Option<u32>,
1624 },
1625 List,
1626 Path,
1627 Edit,
1629 Doctor,
1633 Dump,
1636 Import(config_bundles::ImportArgs),
1638 Export(config_bundles::ExportArgs),
1640}
1641
1642#[derive(Debug, Args)]
1643struct ModelArgs {
1644 #[command(subcommand)]
1645 command: ModelCommand,
1646}
1647
1648#[derive(Debug, Subcommand)]
1649enum ModelCommand {
1650 List {
1651 #[arg(long, value_parser = parse_catalog_route)]
1652 provider: Option<ProviderKind>,
1653 },
1654 Resolve {
1655 model: Option<String>,
1656 #[arg(long, value_parser = parse_catalog_route)]
1657 provider: Option<ProviderKind>,
1658 },
1659 Set { model: String },
1661}
1662
1663#[derive(Debug, Args)]
1664struct ThreadArgs {
1665 #[command(subcommand)]
1666 command: ThreadCommand,
1667}
1668
1669#[derive(Debug, Subcommand)]
1670enum ThreadCommand {
1671 List {
1672 #[arg(long, default_value_t = false)]
1673 all: bool,
1674 #[arg(long)]
1675 limit: Option<usize>,
1676 },
1677 Read {
1678 thread_id: String,
1679 },
1680 Resume {
1681 thread_id: String,
1682 },
1683 Fork {
1684 thread_id: String,
1685 },
1686 Archive {
1687 thread_id: String,
1688 },
1689 Unarchive {
1690 thread_id: String,
1691 },
1692 SetName {
1693 thread_id: String,
1694 name: String,
1695 },
1696 ClearName {
1699 thread_id: String,
1700 },
1701}
1702
1703#[derive(Debug, Args)]
1704struct SandboxArgs {
1705 #[command(subcommand)]
1706 command: SandboxCommand,
1707}
1708
1709#[derive(Debug, Subcommand)]
1710enum SandboxCommand {
1711 Check {
1712 command: String,
1713 #[arg(long, value_enum, default_value_t = ApprovalModeArg::OnRequest)]
1714 ask: ApprovalModeArg,
1715 },
1716}
1717
1718#[derive(Debug, Clone, Copy, ValueEnum)]
1719enum ApprovalModeArg {
1720 UnlessTrusted,
1721 OnFailure,
1722 OnRequest,
1723 Never,
1724}
1725
1726impl From<ApprovalModeArg> for AskForApproval {
1727 fn from(value: ApprovalModeArg) -> Self {
1728 match value {
1729 ApprovalModeArg::UnlessTrusted => AskForApproval::UnlessTrusted,
1730 ApprovalModeArg::OnFailure => AskForApproval::OnFailure,
1731 ApprovalModeArg::OnRequest => AskForApproval::OnRequest,
1732 ApprovalModeArg::Never => AskForApproval::Never,
1733 }
1734 }
1735}
1736
1737#[derive(Debug, Args)]
1738struct AppServerArgs {
1739 #[arg(long, conflicts_with_all = ["stdio", "mobile"])]
1743 http: bool,
1744 #[arg(long, conflicts_with = "stdio")]
1747 mobile: bool,
1748 #[arg(long, default_value_t = false)]
1751 stdio: bool,
1752 #[arg(long, default_value_t = false, conflicts_with_all = ["stdio", "http", "mobile"])]
1757 socket: bool,
1758 #[arg(long = "socket-path", requires = "socket")]
1763 socket_path: Option<PathBuf>,
1764 #[arg(long, requires = "mobile")]
1766 qr: bool,
1767 #[arg(long)]
1770 host: Option<String>,
1771 #[arg(long)]
1774 port: Option<u16>,
1775 #[arg(long)]
1777 workers: Option<usize>,
1778 #[arg(long)]
1779 config: Option<PathBuf>,
1780 #[arg(long = "auth-token")]
1781 auth_token: Option<String>,
1782 #[arg(long, default_value_t = false)]
1783 insecure_no_auth: bool,
1784 #[arg(long = "cors-origin")]
1785 cors_origin: Vec<String>,
1786}
1787
1788const MCP_SERVER_DEFINITIONS_KEY: &str = "mcp.server_definitions";
1789
1790fn install_rustls_crypto_provider() {
1791 let _ = rustls::crypto::ring::default_provider().install_default();
1792}
1793
1794pub fn run_cli() -> std::process::ExitCode {
1795 install_rustls_crypto_provider();
1796
1797 match run() {
1798 Ok(()) => std::process::ExitCode::SUCCESS,
1799 Err(err) => {
1800 eprintln!("error: {err}");
1808 for cause in err.chain().skip(1) {
1809 eprintln!(" caused by: {cause}");
1810 }
1811 if let Some(machine) = err.downcast_ref::<cloud::machine::MachineError>() {
1816 eprintln!(
1817 " codewhale: code={} status={}",
1818 machine.code, machine.status
1819 );
1820 if let Ok(code) = u8::try_from(machine.exit_code) {
1821 return std::process::ExitCode::from(code);
1822 }
1823 }
1824 std::process::ExitCode::FAILURE
1825 }
1826 }
1827}
1828
1829fn split_lane_log_proxy_command(
1830 command: Option<Commands>,
1831) -> (Option<LaneLogProxyArgs>, Option<Commands>) {
1832 match command {
1833 Some(Commands::LaneLogProxy(args)) => (Some(args), None),
1834 command => (None, command),
1835 }
1836}
1837
1838fn config_command_targets_project(matches: &clap::ArgMatches) -> bool {
1839 let Some(config_matches) = matches.subcommand_matches("config") else {
1840 return false;
1841 };
1842 let Some((command, command_matches)) = config_matches.subcommand() else {
1843 return false;
1844 };
1845 if !matches!(command, "import" | "export") {
1846 return false;
1847 }
1848 command_matches
1849 .try_get_one::<bool>("project")
1850 .ok()
1851 .flatten()
1852 .copied()
1853 .unwrap_or(false)
1854}
1855
1856fn config_store_path_for_dispatch(
1857 explicit_path: Option<PathBuf>,
1858 project_bundle_scope: bool,
1859 cwd: &Path,
1860) -> Option<PathBuf> {
1861 if explicit_path.is_none() && project_bundle_scope {
1862 let current = cwd
1867 .join(codewhale_config::CODEWHALE_APP_DIR)
1868 .join(codewhale_config::CONFIG_FILE_NAME);
1869 let legacy = cwd
1870 .join(codewhale_config::LEGACY_APP_DIR)
1871 .join(codewhale_config::CONFIG_FILE_NAME);
1872 if !current.is_file() && legacy.is_file() {
1873 return Some(legacy);
1874 }
1875 return Some(current);
1876 }
1877 explicit_path
1878}
1879
1880fn apply_runtime_set_overrides(cli: &mut Cli) -> Result<()> {
1884 let mut values = CliRuntimeOverrides::default();
1885 let mut provider = None;
1886 for spec in &cli.overrides {
1887 let (key, value) = spec
1888 .split_once('=')
1889 .context("invalid --set: expected KEY=VALUE (value omitted)")?;
1890 match key.trim() {
1891 "provider" => {
1892 provider = Some(
1893 parse_provider_identifier(value)
1894 .map_err(|_| anyhow!("invalid --set provider (value omitted)"))?,
1895 );
1896 }
1897 "model" | "default_text_model" => values.model = Some(value.to_string()),
1898 "verbosity" => values.verbosity = Some(value.to_string()),
1899 "approval_policy" => values.approval_policy = Some(value.to_string()),
1900 "sandbox_mode" => values.sandbox_mode = Some(value.to_string()),
1901 "telemetry" => {
1902 let mut config = ConfigToml::default();
1903 config
1904 .set_value("telemetry", value)
1905 .map_err(|_| anyhow!("invalid --set telemetry: expected a boolean"))?;
1906 values.telemetry = config.telemetry;
1907 }
1908 _ => bail!(
1909 "unsupported runtime --set key (value omitted): supported keys are provider, \
1910 model, default_text_model, verbosity, approval_policy, sandbox_mode and \
1911 telemetry; use the dedicated option or config set for other keys"
1912 ),
1913 }
1914 if value.trim().is_empty() {
1915 bail!("invalid runtime --set: value must not be empty");
1916 }
1917 }
1918 cli.provider = cli.provider.take().or(provider);
1921 cli.model = cli.model.take().or(values.model);
1922 cli.verbosity = cli.verbosity.take().or(values.verbosity);
1923 cli.approval_policy = cli.approval_policy.take().or(values.approval_policy);
1924 cli.sandbox_mode = cli.sandbox_mode.take().or(values.sandbox_mode);
1925 cli.telemetry = cli.telemetry.or(values.telemetry);
1926 Ok(())
1927}
1928
1929fn run() -> Result<()> {
1930 let matches = Cli::command().get_matches();
1931 let project_bundle_scope = config_command_targets_project(&matches);
1932 let mut cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit());
1933
1934 let (proxy, command) = split_lane_log_proxy_command(cli.command.take());
1938 if let Some(args) = proxy {
1939 return run_lane_log_proxy_command(args);
1940 }
1941
1942 if !cli.overrides.is_empty() && matches!(command, Some(Commands::Auth(_))) {
1943 bail!("--set is not supported by auth commands; use a saved config");
1944 }
1945 if !cli.overrides.is_empty()
1946 && matches!(&command, Some(Commands::AppServer(args)) if !args.http && !args.mobile)
1947 {
1948 bail!(
1949 "--set is not supported by the legacy app-server transport; use app-server --http or a saved config"
1950 );
1951 }
1952 if !matches!(command, Some(Commands::Config(_))) {
1953 apply_runtime_set_overrides(&mut cli)?;
1954 }
1955
1956 let pipe_api_key_handoff = matches!(
1957 &command,
1958 Some(Commands::Auth(AuthArgs {
1959 command: AuthCommand::PrintApiKey { .. }
1960 }))
1961 );
1962 if pipe_api_key_handoff {
1963 credential_handoff::prepare_stdout(io::stdout().is_terminal())?;
1964 }
1965 let runtime_provider = top_level_provider_override(cli.provider.as_deref(), command.as_ref())?;
1966 let uses_raw_tui_provider = cli.provider.is_some() && runtime_provider.is_none();
1967 let runtime_overrides = CliRuntimeOverrides {
1968 provider: runtime_provider,
1969 model: cli.model.clone(),
1970 api_key: cli.api_key.clone(),
1971 base_url: cli.base_url.clone(),
1972 auth_mode: None,
1973 output_mode: cli.output_mode.clone(),
1974 log_level: cli.log_level.clone(),
1975 telemetry: cli.telemetry,
1976 approval_policy: cli.approval_policy.clone(),
1977 sandbox_mode: cli.sandbox_mode.clone(),
1978 yolo: Some(cli.yolo),
1979 verbosity: cli.verbosity.clone(),
1980 };
1981 if uses_raw_tui_provider
1982 && let Some((resolved_runtime, passthrough)) =
1983 prepare_raw_provider_tui_dispatch(&cli, command.as_ref(), &runtime_overrides)?
1984 {
1985 return run_tui_in_process(&cli, &resolved_runtime, passthrough);
1986 }
1987
1988 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1989 let config_path =
1990 config_store_path_for_dispatch(cli.config.clone(), project_bundle_scope, &cwd);
1991 let mut store = ConfigStore::load(config_path).map_err(|error| {
1992 if pipe_api_key_handoff {
1993 anyhow!("unavailable credential")
1994 } else {
1995 error
1996 }
1997 })?;
1998 if command.is_some()
2003 && (cli.continue_session || cli.resume.is_some() || cli.session_id.is_some())
2004 {
2005 anyhow::bail!(
2006 "--continue/--resume/--session-id apply to the interactive session and \
2007 cannot be combined with a subcommand. Run them without a subcommand, or \
2008 use the subcommand's own flag (for example `codewhale exec --session-id <id>`)."
2009 );
2010 }
2011 if matches!(command, Some(Commands::Config(_))) {
2015 apply_per_run_overrides(&mut store, &cli.overrides)?;
2016 }
2017
2018 match command {
2019 Some(Commands::Run(args)) => {
2020 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2021 run_tui_in_process(&cli, &resolved_runtime, args.args)
2022 }
2023 Some(Commands::Doctor(args)) => {
2024 let resolved_runtime =
2025 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2026 run_tui_in_process(&cli, &resolved_runtime, tui_args("doctor", args))
2027 }
2028 Some(Commands::Models(args)) => {
2029 let resolved_runtime =
2030 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2031 run_tui_in_process(&cli, &resolved_runtime, tui_args("models", args))
2032 }
2033 Some(Commands::Speech(args)) => {
2034 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2035 run_tui_in_process(&cli, &resolved_runtime, tui_args("speech", args))
2036 }
2037 Some(Commands::Sessions(args)) => {
2038 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2039 run_tui_in_process(&cli, &resolved_runtime, tui_args("sessions", args))
2040 }
2041 Some(Commands::Resume(args)) => {
2042 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2043 run_resume_command(&cli, &resolved_runtime, args)
2044 }
2045 Some(Commands::Rc(args)) => {
2046 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2047 let mut passthrough = vec!["--remote-control".to_string()];
2048 passthrough.extend(args.args);
2049 run_tui_in_process(&cli, &resolved_runtime, passthrough)
2050 }
2051 Some(Commands::Fork(args)) => {
2052 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2053 run_tui_in_process(&cli, &resolved_runtime, tui_args("fork", args))
2054 }
2055 Some(Commands::Init(args)) => {
2056 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2057 run_tui_in_process(&cli, &resolved_runtime, tui_args("init", args))
2058 }
2059 Some(Commands::Setup(args)) => {
2060 let resolved_runtime = if setup_is_status_report(&args) {
2061 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides)
2062 } else {
2063 resolve_runtime_for_dispatch(&mut store, &runtime_overrides)
2064 };
2065 run_tui_in_process(&cli, &resolved_runtime, tui_args("setup", args))
2066 }
2067 Some(Commands::RemoteSetup(args)) => {
2068 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2069 run_tui_in_process(&cli, &resolved_runtime, remote_setup_tui_args(args))
2070 }
2071 Some(Commands::Exec(args)) => {
2072 reject_exec_global_flags(&args.args)?;
2073 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2074 run_tui_in_process(&cli, &resolved_runtime, tui_args("exec", args))
2075 }
2076 Some(Commands::Fleet(args)) => {
2077 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2078 run_tui_in_process(&cli, &resolved_runtime, tui_args("fleet", args))
2079 }
2080 Some(Commands::WorkflowTool(args)) => {
2081 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2082 run_tui_in_process(&cli, &resolved_runtime, tui_args("workflow-tool", args))
2083 }
2084 Some(Commands::LaneLogProxy(_)) => unreachable!("lane log proxy dispatched above"),
2085 Some(Commands::Workflow(args)) => {
2086 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2087 let config_path = store.path().to_path_buf();
2088 run_workflow_command(&cli, &resolved_runtime, &config_path, args)
2089 }
2090 Some(Commands::Lane(args)) => run_lane_command(args),
2091 Some(Commands::Review(args)) => {
2092 let mut overrides = runtime_overrides.clone();
2097 if overrides.provider.is_none()
2098 && let Some(provider) = cloud::machine_review_provider()?
2099 {
2100 overrides.provider = Some(provider);
2101 }
2102 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &overrides);
2103 run_tui_in_process(&cli, &resolved_runtime, tui_args("review", args))
2104 }
2105 Some(Commands::Apply(args)) => {
2106 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2107 run_tui_in_process(&cli, &resolved_runtime, tui_args("apply", args))
2108 }
2109 Some(Commands::Eval(args)) => {
2110 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2111 run_tui_in_process(&cli, &resolved_runtime, tui_args("eval", args))
2112 }
2113 Some(Commands::Mcp(args)) => {
2114 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2115 run_tui_in_process(&cli, &resolved_runtime, tui_args("mcp", args))
2116 }
2117 Some(Commands::Integrations(args)) => {
2118 let resolved_runtime =
2122 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2123 run_tui_in_process(&cli, &resolved_runtime, tui_args("integrations", args))
2124 }
2125 Some(Commands::Features(args)) => {
2126 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2127 run_tui_in_process(&cli, &resolved_runtime, tui_args("features", args))
2128 }
2129 Some(Commands::Serve(args)) => {
2130 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2131 run_tui_server_in_process(&cli, &resolved_runtime, tui_args("serve", args))
2134 }
2135 Some(Commands::Web(args)) => {
2136 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2137 run_tui_server_in_process(&cli, &resolved_runtime, web_serve_passthrough(&args))
2138 }
2139 Some(Commands::Login(args)) => {
2140 reject_legacy_login_provider_args(&args)?;
2141 cloud::reject_inline_api_key(cli.api_key.as_deref())?;
2142 cloud::run_account_login(
2143 args.no_open,
2144 args.timeout_seconds,
2145 cli.profile.as_deref(),
2146 &store,
2147 )
2148 }
2149 Some(Commands::Logout) => run_logout_command(&mut store, cli.profile.as_deref()),
2150 Some(Commands::Auth(args)) => match args.command {
2151 AuthCommand::XaiDevice => {
2152 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2153 run_tui_in_process(
2154 &cli,
2155 &resolved_runtime,
2156 vec!["auth".to_string(), "xai-device".to_string()],
2157 )
2158 }
2159 AuthCommand::Chatgpt => {
2160 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2161 run_tui_in_process(
2162 &cli,
2163 &resolved_runtime,
2164 vec!["auth".to_string(), "chatgpt".to_string()],
2165 )
2166 }
2167 AuthCommand::ChatgptRevoke => {
2168 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2169 run_tui_in_process(
2170 &cli,
2171 &resolved_runtime,
2172 vec!["auth".to_string(), "chatgpt-revoke".to_string()],
2173 )
2174 }
2175 command @ AuthCommand::Status {
2176 diagnostic: true, ..
2177 } => {
2178 run_auth_command_with_runtime(&mut store, command, &runtime_overrides)
2183 }
2184 command => {
2185 let resolved_runtime =
2186 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2187 let session = start_cli_telemetry(
2188 &resolved_runtime,
2189 Some(store.path().to_path_buf()),
2190 Surface::Cli,
2191 );
2192 let outcome =
2193 run_auth_command_with_runtime(&mut store, command, &runtime_overrides);
2194 finish_cli_telemetry(session, &outcome);
2195 outcome
2196 }
2197 },
2198 Some(Commands::Account(args)) => {
2199 cloud::reject_inline_api_key(cli.api_key.as_deref())?;
2200 cloud::run(args, cli.profile.as_deref(), &store)
2201 }
2202 Some(Commands::Dispatch(args)) => dispatch::run(args),
2203 Some(Commands::McpServer) => {
2204 let resolved_runtime =
2211 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2212 let session = start_cli_telemetry(
2213 &resolved_runtime,
2214 Some(store.path().to_path_buf()),
2215 Surface::McpServer,
2216 );
2217 let outcome = run_mcp_server_command(&mut store);
2218 finish_cli_telemetry(session, &outcome);
2219 outcome
2220 }
2221 Some(Commands::Config(args)) => {
2222 let resolved_runtime =
2223 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2224 let session = start_cli_telemetry(
2225 &resolved_runtime,
2226 Some(store.path().to_path_buf()),
2227 Surface::Cli,
2228 );
2229 let outcome = run_config_command(
2230 &mut store,
2231 args.command,
2232 project_bundle_scope,
2233 &cli.overrides,
2234 );
2235 finish_cli_telemetry(session, &outcome);
2236 outcome
2237 }
2238 Some(Commands::Model(args)) => {
2239 let resolved_runtime =
2243 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2244 run_model_command(
2245 &mut store,
2246 args.command,
2247 runtime_overrides.provider,
2248 &resolved_runtime,
2249 )
2250 }
2251 Some(Commands::Thread(args)) => {
2252 run_thread_command(&cli, &mut store, &runtime_overrides, args.command)
2253 }
2254 Some(Commands::Sandbox(args)) => run_sandbox_command(args.command),
2255 Some(Commands::AppServer(args)) => {
2256 if (args.http || args.mobile) && cli.config.is_none() && args.config.is_some() {
2261 cli.config = args.config.clone();
2262 store = ConfigStore::load(cli.config.clone())?;
2263 }
2264 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2265 run_app_server_command(&cli, &resolved_runtime, args)
2266 }
2267 Some(Commands::Completion { shell }) => {
2268 let mut stdout = io::stdout();
2269 stdout.write_all(render_completion_script(shell).as_bytes())?;
2270 stdout.flush()?;
2271 Ok(())
2272 }
2273 Some(Commands::Metrics(args)) => run_metrics_command(args),
2274 Some(Commands::Update(args)) => {
2275 let resolved_runtime =
2276 resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
2277 let session = start_cli_telemetry(
2278 &resolved_runtime,
2279 Some(store.path().to_path_buf()),
2280 Surface::Cli,
2281 );
2282 #[cfg(not(target_env = "ohos"))]
2283 let outcome = update::run_update(args.beta, args.check, args.proxy);
2284 #[cfg(target_env = "ohos")]
2285 let outcome = {
2286 let _ = args;
2287 Err(anyhow!(
2288 "self-update is not supported on HarmonyOS/OpenHarmony yet"
2289 ))
2290 };
2291 finish_cli_telemetry(session, &outcome);
2292 outcome
2293 }
2294 Some(Commands::Providers(args)) => run_providers_command(args),
2295 None => {
2296 let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
2297 let forwarded = root_tui_passthrough(&cli)?;
2298 run_tui_in_process(&cli, &resolved_runtime, forwarded)
2299 }
2300 }
2301}
2302
2303fn root_tui_passthrough(cli: &Cli) -> Result<Vec<String>> {
2304 let mut forwarded = Vec::new();
2305 if cli.continue_session {
2306 forwarded.push("--continue".to_string());
2307 }
2308 let resume_session_id = cli
2309 .resume
2310 .as_deref()
2311 .or(cli.session_id.as_deref())
2312 .map(str::trim);
2313 if resume_session_id.is_some_and(str::is_empty) {
2314 bail!(
2319 "--resume/--session-id needs a session id, but got an empty value \
2320 (an unset shell variable?). Use `codewhale --continue` to resume \
2321 the most recent session."
2322 );
2323 }
2324 if let Some(session_id) = resume_session_id {
2325 forwarded.push("--resume".to_string());
2326 forwarded.push(session_id.to_string());
2327 }
2328
2329 let prompt =
2330 cli.prompt_flag
2331 .iter()
2332 .chain(cli.prompt.iter())
2333 .fold(String::new(), |mut acc, part| {
2334 if !acc.is_empty() {
2335 acc.push(' ');
2336 }
2337 acc.push_str(part);
2338 acc
2339 });
2340 if !prompt.is_empty() {
2341 if cli.continue_session {
2342 bail!(
2343 "`codewhale --continue` resumes the interactive TUI. Use `codewhale exec --continue <PROMPT>` to continue a session non-interactively."
2344 );
2345 }
2346 if let Some(session_id) = resume_session_id {
2347 bail!(
2348 "`codewhale --resume {session_id}` resumes the interactive TUI. Use `codewhale exec --resume {session_id} <PROMPT>` to continue a session non-interactively."
2349 );
2350 }
2351 forwarded.push("--prompt".to_string());
2352 forwarded.push(prompt);
2353 }
2354
2355 Ok(forwarded)
2356}
2357
2358fn resolve_runtime_for_dispatch(
2359 store: &mut ConfigStore,
2360 runtime_overrides: &CliRuntimeOverrides,
2361) -> ResolvedRuntimeOptions {
2362 let runtime_secrets = Secrets::auto_detect();
2363 resolve_runtime_for_dispatch_with_secrets(store, runtime_overrides, &runtime_secrets)
2364}
2365
2366fn resolve_runtime_for_diagnostic_dispatch(
2374 store: &ConfigStore,
2375 runtime_overrides: &CliRuntimeOverrides,
2376) -> ResolvedRuntimeOptions {
2377 store.config.resolve_runtime_options(runtime_overrides)
2378}
2379
2380struct CliTelemetrySession {
2387 started: std::time::Instant,
2388}
2389
2390fn start_cli_telemetry(
2400 resolved: &ResolvedRuntimeOptions,
2401 config_path: Option<PathBuf>,
2402 surface: Surface,
2403) -> Option<CliTelemetrySession> {
2404 let consent = resolve_cli_telemetry_consent(
2405 resolved,
2406 config_path,
2407 surface,
2408 telemetry::load_setup_state_for_decision(),
2409 )?;
2410 telemetry::init(consent);
2411 telemetry::record(Event::SessionStart {
2412 source: SessionSource::Unknown,
2413 });
2414 Some(CliTelemetrySession {
2415 started: std::time::Instant::now(),
2416 })
2417}
2418
2419fn resolve_cli_telemetry_consent(
2420 resolved: &ResolvedRuntimeOptions,
2421 config_path: Option<PathBuf>,
2422 surface: Surface,
2423 setup: Option<SetupState>,
2424) -> Option<telemetry::TelemetryConsent> {
2425 let setup = setup?;
2426 let TelemetryDecision::Enabled(consent) = telemetry::decide(resolved, &setup, surface) else {
2427 return None;
2428 };
2429 Some(consent.with_config_path(config_path))
2430}
2431
2432fn finish_cli_telemetry(session: Option<CliTelemetrySession>, outcome: &Result<()>) {
2442 let Some(session) = session else {
2443 return;
2444 };
2445 telemetry::set_exit_class(if outcome.is_ok() {
2446 ExitClass::Clean
2447 } else {
2448 ExitClass::Error
2449 });
2450 telemetry::record(Event::SessionEnd {
2451 duration_bucket: DurationBucket::from_secs(session.started.elapsed().as_secs()),
2452 exit_class: telemetry::exit_class(),
2453 cold_start_bucket: None,
2457 providers: Vec::new(),
2458 counters: Counters::default(),
2459 errors: Errors::default(),
2460 turn_wall: TurnWall::default(),
2461 });
2462 let _ = telemetry::shutdown_blocking(telemetry::SHUTDOWN_FLUSH_TIMEOUT);
2463}
2464
2465fn resolve_runtime_for_dispatch_with_secrets(
2466 store: &mut ConfigStore,
2467 runtime_overrides: &CliRuntimeOverrides,
2468 secrets: &Secrets,
2469) -> ResolvedRuntimeOptions {
2470 store
2471 .config
2472 .resolve_runtime_options_with_secrets(runtime_overrides, secrets)
2473}
2474
2475fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
2476 let mut forwarded = Vec::with_capacity(args.args.len() + 1);
2477 forwarded.push(command.to_string());
2478 forwarded.extend(args.args);
2479 forwarded
2480}
2481
2482fn setup_is_status_report(args: &TuiPassthroughArgs) -> bool {
2483 args.args.iter().any(|arg| arg == "--status")
2484}
2485
2486fn reject_exec_global_flags(args: &[String]) -> Result<()> {
2487 const GLOBAL_ONLY_FLAGS: &[&str] = &["--provider", "--model", "--api-key", "--base-url"];
2488
2489 for arg in args {
2490 if arg == "--" {
2491 break;
2492 }
2493 let flag = arg.split_once('=').map_or(arg.as_str(), |(flag, _)| flag);
2494 if GLOBAL_ONLY_FLAGS.contains(&flag) {
2495 bail!(
2496 "{flag} must be placed before `exec`.\n\nUse:\n codewhale {flag} <value> exec \"<prompt>\""
2497 );
2498 }
2499 }
2500
2501 Ok(())
2502}
2503
2504fn reject_legacy_login_provider_args(args: &LoginArgs) -> Result<()> {
2508 if args.api_key.is_none() && args.provider.is_none() {
2509 return Ok(());
2510 }
2511 bail!(
2512 "`codewhale login` now signs in to your Codewhale account via the browser device flow. \
2513 To configure a provider key, run `codewhale auth set --provider <provider>` (hidden prompt) \
2514 or `codewhale auth set --provider <provider> --api-key-stdin`."
2515 )
2516}
2517
2518fn run_logout_command(store: &mut ConfigStore, profile: Option<&str>) -> Result<()> {
2519 run_logout_command_with_secrets(store, &Secrets::auto_detect(), profile)
2520}
2521
2522fn run_logout_command_with_secrets(
2523 store: &mut ConfigStore,
2524 secrets: &Secrets,
2525 profile: Option<&str>,
2526) -> Result<()> {
2527 codewhale_config::with_xai_oauth_revocation_transaction(|| {
2528 run_logout_command_with_secrets_unlocked(store, secrets, profile)
2529 })
2530}
2531
2532fn run_logout_command_with_secrets_unlocked(
2533 store: &mut ConfigStore,
2534 secrets: &Secrets,
2535 profile: Option<&str>,
2536) -> Result<()> {
2537 let original_config = store.config.clone();
2538 store.config.api_key = None;
2539 for provider in ProviderKind::ALL {
2540 clear_provider_api_key_from_config(store, provider);
2541 store
2542 .config
2543 .providers
2544 .for_provider_mut(provider)
2545 .external_credentials = None;
2546 }
2547 let xai = store.config.providers.for_provider_mut(ProviderKind::Xai);
2548 xai.oauth_credential_generation = None;
2549 xai.auth_mode = None;
2550 let openai_codex = store
2551 .config
2552 .providers
2553 .for_provider_mut(ProviderKind::OpenaiCodex);
2554 if openai_codex
2555 .oauth_credential_generation
2556 .as_deref()
2557 .is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation)
2558 {
2559 openai_codex.oauth_credential_generation = None;
2560 if openai_codex.auth_mode.as_deref() == Some("oauth") {
2561 openai_codex.auth_mode = None;
2562 }
2563 }
2564 store.config.auth_mode = None;
2565 if let Err(error) = store.save() {
2566 store.config = original_config;
2567 return Err(error);
2568 }
2569 let mut keyring_failures = clear_all_provider_api_keys_from_keyring(secrets);
2570 if let Err(error) = codewhale_config::clear_all_chatgpt_oauth_credentials_locked() {
2573 keyring_failures.push(format!("chatgpt oauth: {error}"));
2574 }
2575 if let Err(error) = clear_daytona_slot(secrets) {
2576 keyring_failures.push(format!(
2577 "{}: {error}",
2578 codewhale_secrets::DAYTONA_TOKEN_SLOT
2579 ));
2580 }
2581 if let Err(error) = clear_account_session(profile) {
2582 keyring_failures.push(format!("account session: {error}"));
2583 }
2584 if keyring_failures.is_empty() {
2585 println!("logged out");
2586 } else {
2587 eprintln!(
2588 "failed to delete stored credentials for: {}",
2589 keyring_failures.join(", ")
2590 );
2591 println!("logged out (some stored credentials could not be deleted)");
2592 }
2593 Ok(())
2594}
2595
2596fn clear_daytona_slot(secrets: &Secrets) -> Result<(), codewhale_secrets::SecretsError> {
2597 if secrets
2598 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)?
2599 .is_some_and(|value| !value.trim().is_empty())
2600 {
2601 secrets.delete(codewhale_secrets::DAYTONA_TOKEN_SLOT)?;
2602 }
2603 Ok(())
2604}
2605
2606fn clear_account_session(profile: Option<&str>) -> Result<(), String> {
2607 use codewhale_secrets::account::{
2608 ACCOUNT_API_BASE_ENV, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
2609 secure_account_session_secrets,
2610 };
2611 let secrets = secure_account_session_secrets().map_err(|error| error.to_string())?;
2612 let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
2613 .ok()
2614 .map(|value| value.trim().trim_end_matches('/').to_string())
2615 .filter(|value| !value.is_empty())
2616 .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
2617 AccountSessionStore::new(secrets, profile, &api_base)
2618 .clear()
2619 .map_err(|error| error.to_string())
2620}
2621
2622fn provider_slot(provider: ProviderKind) -> &'static str {
2624 provider.secret_store_slot()
2627}
2628
2629fn credential_metadata_store(store: &ConfigStore) -> Result<Option<ConfigStore>> {
2644 if !codewhale_config::config_path_is_workspace_scoped(store.path()) {
2645 return Ok(None);
2646 }
2647 let global = codewhale_config::default_config_path()?;
2648 eprintln!(
2649 "ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead",
2650 codewhale_config::quote_os_path(store.path()),
2651 codewhale_config::quote_os_path(&global),
2652 );
2653 ConfigStore::load(Some(global)).map(Some)
2654}
2655
2656#[cfg(test)]
2657fn no_keyring_secrets() -> Secrets {
2658 Secrets::new(std::sync::Arc::new(
2659 codewhale_secrets::InMemoryKeyringStore::new(),
2660 ))
2661}
2662
2663fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
2664 store.config.auth_mode = Some("api_key".to_string());
2665 let provider_config = store.config.providers.for_provider_mut(provider);
2666 provider_config.auth_mode = Some("api_key".to_string());
2667 provider_config.external_credentials = None;
2668 if provider == ProviderKind::Xai {
2669 provider_config.oauth_credential_generation = None;
2670 }
2671 if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() {
2672 store.config.default_text_model = Some(
2673 store
2674 .config
2675 .providers
2676 .deepseek
2677 .model
2678 .clone()
2679 .unwrap_or_else(|| "deepseek-v4-pro".to_string()),
2680 );
2681 }
2682}
2683
2684fn persist_provider_api_key(
2687 store: &mut ConfigStore,
2688 secrets: &Secrets,
2689 provider: ProviderKind,
2690 api_key: &str,
2691) -> Result<bool> {
2692 if provider == ProviderKind::Xai {
2693 return codewhale_config::with_xai_oauth_revocation_transaction(|| {
2694 persist_provider_api_key_unlocked(store, secrets, provider, api_key)
2695 });
2696 }
2697 persist_provider_api_key_unlocked(store, secrets, provider, api_key)
2698}
2699
2700fn persist_provider_api_key_unlocked(
2701 store: &mut ConfigStore,
2702 secrets: &Secrets,
2703 provider: ProviderKind,
2704 api_key: &str,
2705) -> Result<bool> {
2706 let original_config = store.config.clone();
2707 prepare_provider_api_key_metadata(store, provider);
2708 let slot = provider_slot(provider);
2709 let prior_secret = secrets.get(slot);
2713 let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
2714 Ok(_) => match secrets.set(slot, api_key) {
2715 Ok(()) => {
2716 clear_provider_api_key_from_config(store, provider);
2717 true
2718 }
2719 Err(err) => {
2720 store.config = original_config;
2721 return Err(anyhow::anyhow!(
2722 "Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
2723 codewhale_config::quote_os_path(store.path())
2724 ));
2725 }
2726 },
2727 Err(error) => {
2728 store.config = original_config;
2729 return Err(anyhow::anyhow!(
2730 "Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
2731 codewhale_config::quote_os_path(store.path())
2732 ));
2733 }
2734 };
2735 if let Err(error) = store.save() {
2736 store.config = original_config;
2737 if secret_store_saved {
2738 let current = secrets
2739 .get(slot)
2740 .map_err(|rollback| anyhow::anyhow!(
2741 "{error}; additionally could not verify secret-store rollback for {slot}: {rollback}"
2742 ))?;
2743 if current.as_deref() == Some(api_key) {
2744 match prior_secret.expect("snapshot succeeded before secret write") {
2745 Some(previous) => secrets.set(slot, &previous),
2746 None => secrets.delete(slot),
2747 }
2748 .map_err(|rollback| anyhow::anyhow!(
2749 "{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}"
2750 ))?;
2751 }
2752 }
2753 return Err(error);
2754 }
2755 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
2756 Ok(secret_store_saved)
2757}
2758
2759fn clear_auth_provider(
2760 store: &mut ConfigStore,
2761 secrets: &Secrets,
2762 provider: ProviderKind,
2763) -> Result<()> {
2764 if provider == ProviderKind::Antigravity {
2765 return clear_legacy_antigravity_config(store, secrets);
2766 }
2767 let slot = provider_slot(provider);
2768 let original_config = store.config.clone();
2769 clear_provider_api_key_from_config(store, provider);
2770 if provider == ProviderKind::Xai {
2771 let xai = store.config.providers.for_provider_mut(provider);
2772 xai.oauth_credential_generation = None;
2773 xai.auth_mode = None;
2774 xai.external_credentials = None;
2775 }
2776 if let Err(error) = store.save() {
2777 store.config = original_config;
2778 return Err(error);
2779 }
2780 clear_provider_api_key_from_keyring(secrets, provider);
2781 if provider == ProviderKind::Xai {
2782 println!("cleared xAI credentials from config, secret store, and owned OAuth storage");
2783 } else {
2784 println!("cleared API key for {slot} from config and secret store");
2785 }
2786 Ok(())
2787}
2788
2789fn clear_legacy_antigravity_config(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
2796 let provider = ProviderKind::Antigravity;
2797 let slot = provider_slot(provider);
2798 let original_config = store.config.clone();
2799 let prior_secret = secrets.get(slot).map_err(|error| {
2800 anyhow!(
2801 "could not snapshot the Codewhale-owned legacy {slot} secret slot before clearing it: {error}; config was not changed"
2802 )
2803 })?;
2804
2805 store.config.providers.antigravity = Default::default();
2806 store
2807 .config
2808 .fallback_providers
2809 .retain(|fallback| *fallback != provider);
2810 if store.config.provider == provider {
2811 store.config.provider = ProviderKind::default();
2812 store.config.selected_provider_id = None;
2813 }
2814
2815 if let Err(error) = secrets.delete(slot) {
2816 store.config = original_config;
2817 return Err(anyhow!(
2818 "could not clear the Codewhale-owned legacy {slot} secret slot: {error}; config was not changed"
2819 ));
2820 }
2821
2822 if let Err(error) = store.save() {
2823 store.config = original_config;
2824 if let Some(previous) = prior_secret {
2825 let current = secrets.get(slot).map_err(|rollback| {
2826 anyhow!(
2827 "{error}; additionally could not verify rollback of the Codewhale-owned legacy {slot} secret slot: {rollback}"
2828 )
2829 })?;
2830 match current {
2831 None => secrets.set(slot, &previous).map_err(|rollback| {
2832 anyhow!(
2833 "{error}; additionally failed to restore the Codewhale-owned legacy {slot} secret slot: {rollback}"
2834 )
2835 })?,
2836 Some(current) if current == previous => {}
2837 Some(_) => {
2838 return Err(anyhow!(
2839 "{error}; additionally the Codewhale-owned legacy {slot} secret slot changed concurrently and was not overwritten during rollback"
2840 ));
2841 }
2842 }
2843 }
2844 return Err(error);
2845 }
2846
2847 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
2848 codewhale_config::scrub_legacy_antigravity_from_config_backup(store.path())?;
2849 println!(
2850 "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"
2851 );
2852 Ok(())
2853}
2854
2855fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
2856 store.config.providers.for_provider_mut(provider).api_key = None;
2857 if provider == ProviderKind::Deepseek {
2858 store.config.api_key = None;
2859 }
2860}
2861
2862fn provider_env_set(provider: ProviderKind) -> bool {
2863 provider_env_value(provider).is_some()
2864}
2865
2866fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] {
2867 provider.provider().env_vars()
2868}
2869
2870fn provider_env_value(provider: ProviderKind) -> Option<(&'static str, String)> {
2871 provider_env_vars(provider).iter().find_map(|var| {
2872 std::env::var(var)
2873 .ok()
2874 .filter(|value| !value.trim().is_empty())
2875 .map(|value| (*var, value))
2876 })
2877}
2878
2879fn openai_codex_auth_file_path() -> PathBuf {
2880 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
2881 let path = PathBuf::from(path);
2882 if !path.as_os_str().is_empty() {
2883 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2884 }
2885 }
2886
2887 let codex_home = std::env::var("CODEX_HOME")
2888 .map(PathBuf::from)
2889 .unwrap_or_else(|_| {
2890 dirs::home_dir()
2891 .unwrap_or_else(|| PathBuf::from("."))
2892 .join(".codex")
2893 });
2894 let path = codex_home.join("auth.json");
2895 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2896}
2897
2898fn grok_auth_file_path() -> PathBuf {
2899 for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
2900 if let Ok(path) = std::env::var(key) {
2901 let path = PathBuf::from(path.trim());
2902 if !path.as_os_str().is_empty() {
2903 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2904 }
2905 }
2906 }
2907 if let Ok(home) = std::env::var("GROK_HOME") {
2908 let home = PathBuf::from(home.trim());
2909 if !home.as_os_str().is_empty() {
2910 let path = home.join("auth.json");
2911 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2912 }
2913 }
2914 let path = dirs::home_dir()
2915 .unwrap_or_else(|| PathBuf::from("."))
2916 .join(".grok")
2917 .join("auth.json");
2918 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2919}
2920
2921fn external_credential_target(
2922 provider: ProviderKind,
2923 path_override: Option<PathBuf>,
2924) -> Result<(codewhale_config::ExternalCredentialSource, PathBuf)> {
2925 let (source, default_path) = match provider {
2926 ProviderKind::OpenaiCodex => (
2927 codewhale_config::ExternalCredentialSource::CodexCli,
2928 openai_codex_auth_file_path(),
2929 ),
2930 ProviderKind::Xai => (
2931 codewhale_config::ExternalCredentialSource::GrokCli,
2932 grok_auth_file_path(),
2933 ),
2934 ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => (
2935 codewhale_config::ExternalCredentialSource::DshCli,
2936 codewhale_config::default_dsh_credentials_path(),
2937 ),
2938 ProviderKind::Moonshot => bail!(
2939 "Kimi is API-key-only in Codewhale. Create a key at https://platform.kimi.ai/console/api-keys; Kimi CLI OAuth import is unsupported."
2940 ),
2941 _ => bail!(
2942 "{} has no supported external CLI credential source",
2943 provider.as_str()
2944 ),
2945 };
2946 let path =
2947 codewhale_config::resolve_external_credential_path(path_override.unwrap_or(default_path))?;
2948 Ok((source, path))
2949}
2950
2951fn provider_config_api_key(store: &ConfigStore, provider: ProviderKind) -> Option<&str> {
2952 let slot = store
2953 .config
2954 .providers
2955 .for_provider(provider)
2956 .api_key
2957 .as_deref();
2958 let root = (provider == ProviderKind::Deepseek)
2959 .then_some(store.config.api_key.as_deref())
2960 .flatten();
2961 slot.or(root)
2962 .filter(|value| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal)
2963}
2964
2965fn provider_config_set(store: &ConfigStore, provider: ProviderKind) -> bool {
2966 provider_config_api_key(store, provider).is_some()
2967}
2968
2969fn provider_keyring_api_key(secrets: &Secrets, provider: ProviderKind) -> Option<String> {
2970 secrets
2971 .get(provider_slot(provider))
2972 .ok()
2973 .flatten()
2974 .filter(|v| !v.trim().is_empty())
2975}
2976
2977fn provider_keyring_set(secrets: &Secrets, provider: ProviderKind) -> bool {
2978 provider_keyring_api_key(secrets, provider).is_some()
2979}
2980
2981fn clear_provider_api_key_from_keyring(secrets: &Secrets, provider: ProviderKind) {
2982 let _ = secrets.delete(provider_slot(provider));
2983}
2984
2985fn clear_all_provider_api_keys_from_keyring(secrets: &Secrets) -> Vec<String> {
2992 let mut failures = Vec::new();
2993 let mut cleared_slots = std::collections::HashSet::new();
2994 for provider in ProviderKind::ALL {
2995 let slot = provider_slot(provider);
2996 if !cleared_slots.insert(slot) {
2997 continue;
2998 }
2999 if !provider_keyring_set(secrets, provider) {
3000 continue;
3001 }
3002 if let Err(error) = secrets.delete(slot) {
3003 failures.push(format!("{slot}: {error}"));
3004 }
3005 }
3006 failures
3007}
3008
3009fn external_consent(
3010 store: &ConfigStore,
3011 provider: ProviderKind,
3012) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
3013 store
3014 .config
3015 .providers
3016 .for_provider(provider)
3017 .external_credentials
3018 .as_ref()
3019}
3020
3021fn external_read_consent(
3022 store: &ConfigStore,
3023 provider: ProviderKind,
3024) -> Option<&codewhale_config::ExternalCredentialConsentToml> {
3025 let (source, expected_path) = external_credential_target(provider, None).ok()?;
3026 external_consent(store, provider)
3027 .filter(|consent| consent.read_grant(provider, source, &expected_path).is_ok())
3028}
3029
3030fn external_oauth_selected(store: &ConfigStore, provider: ProviderKind) -> bool {
3031 if external_read_consent(store, provider).is_none() {
3032 return false;
3033 }
3034 if provider == ProviderKind::OpenaiCodex {
3035 return true;
3036 }
3037 provider == ProviderKind::Xai
3038 && xai_oauth_mode_selected(store.config.providers.xai.auth_mode.as_deref())
3039}
3040
3041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3042enum XaiOAuthGenerationPointer {
3043 Absent,
3044 Valid,
3045 Invalid,
3046}
3047
3048#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3049enum XaiAuthDiagnosticRoute {
3050 ApiKey,
3053 OwnedOAuth,
3056 NeedsRepair,
3059 ExternalConsent,
3062}
3063
3064#[derive(Debug, Clone)]
3065struct XaiAuthDiagnostics {
3066 base_url: String,
3067 official_endpoint: bool,
3068 auth_mode: Option<String>,
3069 oauth_selected: bool,
3070 generation: XaiOAuthGenerationPointer,
3071 route: XaiAuthDiagnosticRoute,
3072}
3073
3074impl XaiAuthDiagnostics {
3075 fn evaluates_runtime_api_key(&self) -> bool {
3079 matches!(
3080 self.route,
3081 XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::NeedsRepair
3082 )
3083 }
3084
3085 fn is_custom_endpoint(&self) -> bool {
3086 !self.official_endpoint
3087 }
3088}
3089
3090#[derive(Debug, Clone, Default)]
3094struct XaiRuntimeApiKey {
3095 source: Option<RuntimeApiKeySource>,
3096 last4: Option<String>,
3097}
3098
3099impl XaiRuntimeApiKey {
3100 fn source_name(&self) -> Option<&'static str> {
3101 match self.source {
3102 Some(RuntimeApiKeySource::Cli) => Some("cli"),
3103 Some(RuntimeApiKeySource::ConfigFile) => Some("config"),
3104 Some(RuntimeApiKeySource::Keyring) => Some("secret store"),
3105 Some(RuntimeApiKeySource::Env) => Some("env"),
3106 None => None,
3107 }
3108 }
3109
3110 fn source_with_last4(&self) -> Option<String> {
3111 self.source_name()
3112 .map(|source| match self.last4.as_deref() {
3113 Some(last4) => format!("{source} (last4: {last4})"),
3114 None => source.to_string(),
3115 })
3116 }
3117
3118 fn uses(&self, source: RuntimeApiKeySource) -> bool {
3119 self.source == Some(source)
3120 }
3121}
3122
3123fn runtime_overrides_for_provider(
3124 runtime_overrides: &CliRuntimeOverrides,
3125 provider: ProviderKind,
3126) -> CliRuntimeOverrides {
3127 let mut overrides = runtime_overrides.clone();
3128 overrides.provider = Some(provider);
3129 overrides
3130}
3131
3132fn xai_oauth_mode_selected(auth_mode: Option<&str>) -> bool {
3133 auth_mode.is_some_and(|mode| {
3134 matches!(
3135 mode.trim()
3136 .to_ascii_lowercase()
3137 .replace(['-', ' '], "_")
3138 .as_str(),
3139 "oauth"
3140 | "xai_oauth"
3141 | "xai"
3142 | "grok"
3143 | "grok_oauth"
3144 | "grok_cli"
3145 | "device"
3146 | "device_code"
3147 | "device_auth"
3148 )
3149 })
3150}
3151
3152fn xai_oauth_generation_pointer(store: &ConfigStore) -> XaiOAuthGenerationPointer {
3153 match store
3154 .config
3155 .providers
3156 .xai
3157 .oauth_credential_generation
3158 .as_deref()
3159 {
3160 None => XaiOAuthGenerationPointer::Absent,
3161 Some(generation) if codewhale_config::is_valid_xai_oauth_generation(generation) => {
3162 XaiOAuthGenerationPointer::Valid
3163 }
3164 Some(_) => XaiOAuthGenerationPointer::Invalid,
3165 }
3166}
3167
3168fn xai_auth_diagnostics(
3173 store: &ConfigStore,
3174 runtime_overrides: &CliRuntimeOverrides,
3175) -> XaiAuthDiagnostics {
3176 let mut route_overrides = runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai);
3180 route_overrides.api_key = None;
3181 route_overrides.auth_mode = Some("none".to_string());
3182 let resolved = store.config.resolve_runtime_options(&route_overrides);
3183 let official_endpoint =
3184 provider_base_url_is_official(ProviderKind::Xai, resolved.base_url.as_str());
3185 let auth_mode = store.config.providers.xai.auth_mode.clone();
3189 let generation = xai_oauth_generation_pointer(store);
3190 let oauth_selected = xai_oauth_mode_selected(auth_mode.as_deref());
3191 let route = if !official_endpoint || !oauth_selected {
3192 XaiAuthDiagnosticRoute::ApiKey
3193 } else {
3194 match generation {
3195 XaiOAuthGenerationPointer::Valid => XaiAuthDiagnosticRoute::OwnedOAuth,
3196 XaiOAuthGenerationPointer::Invalid => XaiAuthDiagnosticRoute::NeedsRepair,
3197 XaiOAuthGenerationPointer::Absent
3198 if external_read_consent(store, ProviderKind::Xai).is_some() =>
3199 {
3200 XaiAuthDiagnosticRoute::ExternalConsent
3201 }
3202 XaiOAuthGenerationPointer::Absent => XaiAuthDiagnosticRoute::ApiKey,
3203 }
3204 };
3205
3206 XaiAuthDiagnostics {
3207 base_url: resolved.base_url,
3208 official_endpoint,
3209 auth_mode,
3210 oauth_selected,
3211 generation,
3212 route,
3213 }
3214}
3215
3216fn xai_runtime_api_key(
3221 store: &ConfigStore,
3222 secrets: &Secrets,
3223 runtime_overrides: &CliRuntimeOverrides,
3224) -> XaiRuntimeApiKey {
3225 let resolved = store.config.resolve_runtime_options_with_secrets(
3226 &runtime_overrides_for_provider(runtime_overrides, ProviderKind::Xai),
3227 secrets,
3228 );
3229 debug_assert_eq!(resolved.provider, ProviderKind::Xai);
3230 XaiRuntimeApiKey {
3231 source: resolved.api_key_source,
3232 last4: resolved.api_key.as_deref().map(last4_label),
3233 }
3234}
3235
3236fn api_key_source_name(
3237 config_key: Option<&str>,
3238 keyring_key: Option<&str>,
3239 env_key: Option<&(&'static str, String)>,
3240) -> Option<&'static str> {
3241 if config_key.is_some() {
3242 Some("config")
3243 } else if keyring_key.is_some() {
3244 Some("secret store")
3245 } else if env_key.is_some() {
3246 Some("env")
3247 } else {
3248 None
3249 }
3250}
3251
3252fn xai_status_summary_source(
3253 diagnostics: &XaiAuthDiagnostics,
3254 api_key: Option<&XaiRuntimeApiKey>,
3255) -> String {
3256 match diagnostics.route {
3257 XaiAuthDiagnosticRoute::OwnedOAuth => {
3258 "Codewhale-owned OAuth configured/unprobed (valid generation pointer)".to_string()
3259 }
3260 XaiAuthDiagnosticRoute::NeedsRepair => {
3261 let api_key = api_key
3262 .and_then(XaiRuntimeApiKey::source_name)
3263 .unwrap_or("no runtime-effective API key");
3264 format!("needs repair (invalid OAuth generation pointer; API-key fallback: {api_key})")
3265 }
3266 XaiAuthDiagnosticRoute::ExternalConsent => {
3267 "external consent configured/unprobed".to_string()
3268 }
3269 XaiAuthDiagnosticRoute::ApiKey => api_key
3270 .and_then(XaiRuntimeApiKey::source_name)
3271 .unwrap_or("unset")
3272 .to_string(),
3273 }
3274}
3275
3276fn xai_credential_route_label(
3277 diagnostics: &XaiAuthDiagnostics,
3278 api_key: Option<&XaiRuntimeApiKey>,
3279) -> String {
3280 match diagnostics.route {
3281 XaiAuthDiagnosticRoute::OwnedOAuth => {
3282 "Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
3283 .to_string()
3284 }
3285 XaiAuthDiagnosticRoute::NeedsRepair => {
3286 let api_key = api_key
3287 .and_then(XaiRuntimeApiKey::source_with_last4)
3288 .unwrap_or_else(|| "no runtime-effective API key".to_string());
3289 format!(
3290 "xAI OAuth needs repair (invalid Codewhale-owned generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
3291 )
3292 }
3293 XaiAuthDiagnosticRoute::ExternalConsent => {
3294 "external read-only consent configured/unprobed".to_string()
3295 }
3296 XaiAuthDiagnosticRoute::ApiKey => api_key
3297 .and_then(XaiRuntimeApiKey::source_with_last4)
3298 .unwrap_or_else(|| "missing".to_string()),
3299 }
3300}
3301
3302fn xai_table_storage_status(
3303 api_key: Option<&XaiRuntimeApiKey>,
3304 source: RuntimeApiKeySource,
3305) -> &'static str {
3306 match api_key {
3307 Some(api_key) if api_key.uses(source) => "set",
3308 Some(_) => "-",
3309 None => "unprobed",
3312 }
3313}
3314
3315fn xai_list_storage_status(
3316 api_key: Option<&XaiRuntimeApiKey>,
3317 source: RuntimeApiKeySource,
3318) -> &'static str {
3319 match api_key {
3320 Some(api_key) if api_key.uses(source) => "yes",
3321 Some(_) => "no",
3322 None => "?",
3323 }
3324}
3325
3326fn xai_list_route(
3327 diagnostics: &XaiAuthDiagnostics,
3328 api_key: Option<&XaiRuntimeApiKey>,
3329) -> &'static str {
3330 match diagnostics.route {
3331 XaiAuthDiagnosticRoute::OwnedOAuth => "owned-oauth-configured",
3332 XaiAuthDiagnosticRoute::NeedsRepair => "needs-repair",
3333 XaiAuthDiagnosticRoute::ExternalConsent => "external-consent-configured",
3334 XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(|api_key| api_key.source) {
3335 Some(RuntimeApiKeySource::Cli) => "cli",
3336 Some(RuntimeApiKeySource::ConfigFile) => "config",
3337 Some(RuntimeApiKeySource::Keyring) => "store",
3338 Some(RuntimeApiKeySource::Env) => "env",
3339 None => "missing",
3340 },
3341 }
3342}
3343
3344fn xai_storage_detail(
3345 diagnostics: &XaiAuthDiagnostics,
3346 api_key: Option<&XaiRuntimeApiKey>,
3347 source: RuntimeApiKeySource,
3348) -> String {
3349 match api_key {
3350 Some(api_key) if api_key.uses(source) => api_key
3351 .last4
3352 .as_deref()
3353 .map(|last4| format!("runtime-effective, last4: {last4}"))
3354 .unwrap_or_else(|| "runtime-effective".to_string()),
3355 Some(_) if diagnostics.is_custom_endpoint() => {
3356 "not eligible for this custom xAI endpoint".to_string()
3357 }
3358 Some(_) => "not selected by the runtime resolver".to_string(),
3359 None if diagnostics.evaluates_runtime_api_key() && diagnostics.is_custom_endpoint() => {
3360 "not eligible for this custom xAI endpoint".to_string()
3361 }
3362 None if diagnostics.evaluates_runtime_api_key() => {
3363 "not set for this runtime route".to_string()
3364 }
3365 None => "unprobed (structural OAuth/consent route)".to_string(),
3366 }
3367}
3368
3369fn xai_lookup_order(diagnostics: &XaiAuthDiagnostics) -> String {
3370 match diagnostics.route {
3371 XaiAuthDiagnosticRoute::OwnedOAuth => {
3372 "lookup order: configured Codewhale-owned OAuth generation (storage unprobed); Grok CLI consent blocked".to_string()
3373 }
3374 XaiAuthDiagnosticRoute::NeedsRepair => {
3375 "lookup order: invalid Codewhale-owned OAuth generation blocks Grok CLI consent; runtime-effective API-key fallback: CLI -> config -> secret store -> env".to_string()
3376 }
3377 XaiAuthDiagnosticRoute::ExternalConsent => {
3378 "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)".to_string()
3379 }
3380 XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
3381 "lookup order: endpoint-bound API key only for this custom xAI endpoint (explicit CLI key or route-bound config key)".to_string()
3382 }
3383 XaiAuthDiagnosticRoute::ApiKey => {
3384 "lookup order: CLI -> config -> secret store -> env".to_string()
3385 }
3386 }
3387}
3388
3389fn xai_get_line(diagnostics: &XaiAuthDiagnostics, api_key: Option<&XaiRuntimeApiKey>) -> String {
3390 match diagnostics.route {
3391 XaiAuthDiagnosticRoute::OwnedOAuth => {
3392 "xai: configured (source: Codewhale-owned OAuth generation; valid pointer; storage unprobed)".to_string()
3393 }
3394 XaiAuthDiagnosticRoute::NeedsRepair => {
3395 let api_key = match api_key.and_then(XaiRuntimeApiKey::source_name) {
3396 Some("config") => "config-file".to_string(),
3397 Some("secret store") => "secret-store".to_string(),
3398 Some("env") => "env".to_string(),
3399 Some("cli") => "cli".to_string(),
3400 Some(other) => other.to_string(),
3401 None => "no runtime-effective API key".to_string(),
3402 };
3403 format!(
3404 "xai: needs repair (invalid Codewhale-owned OAuth generation pointer; Grok CLI consent blocked; API-key fallback: {api_key})"
3405 )
3406 }
3407 XaiAuthDiagnosticRoute::ExternalConsent => {
3408 "xai: configured (source: external read-only consent; availability unprobed)".to_string()
3409 }
3410 XaiAuthDiagnosticRoute::ApiKey => match api_key.and_then(XaiRuntimeApiKey::source_name) {
3411 Some("config") => "xai: set (source: config-file)".to_string(),
3412 Some("secret store") => "xai: set (source: secret-store)".to_string(),
3413 Some("env") => "xai: set (source: env)".to_string(),
3414 Some("cli") => "xai: set (source: cli)".to_string(),
3415 Some(other) => format!("xai: set (source: {other})"),
3416 None => "xai: not set".to_string(),
3417 },
3418 }
3419}
3420
3421fn auth_get_line_with_runtime(
3422 store: &ConfigStore,
3423 secrets: &Secrets,
3424 provider: ProviderKind,
3425 runtime_overrides: &CliRuntimeOverrides,
3426) -> String {
3427 let slot = provider_slot(provider);
3428 if provider == ProviderKind::Xai {
3429 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3430 let api_key = diagnostics
3431 .evaluates_runtime_api_key()
3432 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3433 return xai_get_line(&diagnostics, api_key.as_ref());
3434 }
3435
3436 let config_key = provider_config_api_key(store, provider);
3437 let keyring_key = config_key
3438 .is_none()
3439 .then(|| provider_keyring_api_key(secrets, provider))
3440 .flatten();
3441 let env_key = provider_env_value(provider);
3442
3443 match api_key_source_name(config_key, keyring_key.as_deref(), env_key.as_ref()) {
3444 Some("config") => format!("{slot}: set (source: config-file)"),
3445 Some("secret store") => format!("{slot}: set (source: secret-store)"),
3446 Some("env") => format!("{slot}: set (source: env)"),
3447 Some(other) => format!("{slot}: set (source: {other})"),
3448 None => format!("{slot}: not set"),
3449 }
3450}
3451
3452#[cfg(test)]
3453fn auth_status_all_providers(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
3454 auth_status_all_providers_with_runtime(store, secrets, &CliRuntimeOverrides::default())
3455}
3456
3457fn auth_status_all_providers_with_runtime(
3458 store: &ConfigStore,
3459 secrets: &Secrets,
3460 runtime_overrides: &CliRuntimeOverrides,
3461) -> Vec<String> {
3462 let active_provider = store.config.provider;
3463 let mut lines = Vec::new();
3464 lines.push(account_status_line());
3465 lines.push(String::new());
3466 lines.push(format!(
3467 "active provider: {} (set via config or CODEWHALE_PROVIDER)",
3468 active_provider.as_str()
3469 ));
3470 lines.push(String::new());
3471 lines.push(format!(
3472 "{:<14} {:<8} {:<10} {:<8} {}",
3473 "provider", "config", "keyring", "env", "status"
3474 ));
3475 lines.push("-".repeat(70));
3476
3477 for provider in ProviderKind::ALL {
3478 if provider == ProviderKind::Xai {
3479 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3480 let api_key = diagnostics
3481 .evaluates_runtime_api_key()
3482 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3483 let active_marker = if provider == active_provider {
3484 " *"
3485 } else {
3486 ""
3487 };
3488 lines.push(format!(
3489 "{:<14} {:<8} {:<10} {:<8} {}{}",
3490 provider.as_str(),
3491 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
3492 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
3493 xai_table_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
3494 xai_status_summary_source(&diagnostics, api_key.as_ref()),
3495 active_marker
3496 ));
3497 continue;
3498 }
3499
3500 let config_key = provider_config_api_key(store, provider);
3501 let keyring_key = provider_keyring_api_key(secrets, provider);
3502 let env_key = provider_env_value(provider);
3503 let external_selected = external_oauth_selected(store, provider);
3504
3505 let config_status = config_key.map(|_| "set").unwrap_or("-");
3506 let keyring_status = keyring_key.as_ref().map(|_| "set").unwrap_or("-");
3507 let env_status = env_key.as_ref().map(|_| "set").unwrap_or("-");
3508
3509 let source = if provider == ProviderKind::OpenaiCodex {
3510 if env_key.is_some() {
3514 "env".to_string()
3515 } else if external_selected {
3516 "external consent (not probed)".to_string()
3517 } else {
3518 "unset".to_string()
3519 }
3520 } else if external_selected {
3521 "external consent (not probed)".to_string()
3522 } else if config_key.is_some() {
3523 "config".to_string()
3524 } else if keyring_key.is_some() {
3525 "keyring".to_string()
3526 } else if env_key.is_some() {
3527 "env".to_string()
3528 } else {
3529 "unset".to_string()
3530 };
3531
3532 let active_marker = if provider == active_provider {
3533 " *"
3534 } else {
3535 ""
3536 };
3537
3538 lines.push(format!(
3539 "{:<14} {:<8} {:<10} {:<8} {}{}",
3540 provider.as_str(),
3541 config_status,
3542 keyring_status,
3543 env_status,
3544 source,
3545 active_marker
3546 ));
3547 }
3548
3549 lines.push(String::new());
3550 lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
3551 lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
3552 lines.push("Account sign-in is `codewhale login`.".to_string());
3553 lines
3554}
3555
3556fn account_status_line() -> String {
3557 use codewhale_secrets::account::{
3558 ACCOUNT_API_BASE_ENV, AccountSessionState, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
3559 secure_account_session_secrets,
3560 };
3561 let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
3562 .ok()
3563 .map(|value| value.trim().trim_end_matches('/').to_string())
3564 .filter(|value| !value.is_empty())
3565 .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
3566 match secure_account_session_secrets() {
3567 Ok(secrets) => {
3568 match AccountSessionStore::new(secrets, None, &api_base)
3569 .runtime_info_at(chrono::Utc::now())
3570 {
3571 Ok(info) => {
3572 let state = match info.state {
3573 AccountSessionState::SignedOut => "not signed in",
3574 AccountSessionState::Authenticated => "signed in",
3575 AccountSessionState::OfflineCached => "offline (cached)",
3576 AccountSessionState::Expired => "expired",
3577 AccountSessionState::Revoked => "revoked",
3578 };
3579 format!("account: {state} (api {api_base})")
3580 }
3581 Err(error) => format!("account: unavailable ({error})"),
3582 }
3583 }
3584 Err(error) => format!("account: unavailable ({error})"),
3585 }
3586}
3587
3588fn diagnostic_path_state(path: &Path, directory: bool) -> &'static str {
3589 match std::fs::symlink_metadata(path) {
3590 Ok(metadata) if metadata.file_type().is_symlink() => "present (symlink; not followed)",
3591 Ok(metadata) if directory && metadata.is_dir() => "present",
3592 Ok(metadata) if !directory && metadata.is_file() => "present",
3593 Ok(_) => "present (unexpected type)",
3594 Err(error) if error.kind() == std::io::ErrorKind::NotFound => "missing",
3595 Err(_) => "unknown",
3596 }
3597}
3598
3599const fn secret_backend_kind_label(
3600 kind: codewhale_secrets::SecretBackendDiagnosticKind,
3601) -> &'static str {
3602 match kind {
3603 codewhale_secrets::SecretBackendDiagnosticKind::File => "file",
3604 codewhale_secrets::SecretBackendDiagnosticKind::System => "system",
3605 codewhale_secrets::SecretBackendDiagnosticKind::Unknown => "unknown",
3606 }
3607}
3608
3609const fn secret_backend_inspection_label(
3610 inspection: codewhale_secrets::SecretBackendInspection,
3611) -> &'static str {
3612 match inspection {
3613 codewhale_secrets::SecretBackendInspection::MetadataOnly => "metadata_only",
3614 codewhale_secrets::SecretBackendInspection::NotProbed => "not_probed",
3615 }
3616}
3617
3618const fn secret_backend_presence_label(
3619 presence: codewhale_secrets::SecretBackendPresence,
3620) -> &'static str {
3621 match presence {
3622 codewhale_secrets::SecretBackendPresence::Present => "present",
3623 codewhale_secrets::SecretBackendPresence::Absent => "missing",
3624 codewhale_secrets::SecretBackendPresence::Unknown => "unknown",
3625 }
3626}
3627
3628fn auth_diagnostic_lines(store: &ConfigStore, provider: Option<ProviderKind>) -> Vec<String> {
3635 let explicit_home = codewhale_paths::codewhale_home_is_explicit();
3636 let resolved_home = codewhale_paths::codewhale_home();
3637 let mut lines = vec![
3638 "auth diagnostic (structural only; credential values are never printed and provider credential stores were not opened)".to_string(),
3639 String::new(),
3640 ];
3641
3642 let home = match resolved_home {
3643 Ok(Some(path)) => {
3644 lines.push(format!(
3645 "codewhale home: {} (source: {}; state: {})",
3646 codewhale_config::quote_os_path(&path),
3647 if explicit_home {
3648 "CODEWHALE_HOME (isolated)"
3649 } else {
3650 "platform home"
3651 },
3652 diagnostic_path_state(&path, true),
3653 ));
3654 Some(path)
3655 }
3656 Ok(None) => {
3657 lines.push("codewhale home: unavailable (no user home resolved)".to_string());
3658 None
3659 }
3660 Err(error) => {
3661 lines.push(format!("codewhale home: unavailable ({error})"));
3662 None
3663 }
3664 };
3665
3666 lines.push(format!(
3667 "config: {} ({})",
3668 codewhale_config::quote_os_path(store.path()),
3669 diagnostic_path_state(store.path(), false),
3670 ));
3671 if let Some(home) = home.as_ref() {
3672 let settings = home.join("settings.toml");
3673 lines.push(format!(
3674 "settings: {} ({})",
3675 codewhale_config::quote_os_path(&settings),
3676 diagnostic_path_state(&settings, false),
3677 ));
3678 } else {
3679 lines.push("settings: unavailable (Codewhale home unresolved)".to_string());
3680 }
3681
3682 let backend = codewhale_secrets::diagnose_secret_backend();
3683 lines.push(format!(
3684 "secret backend: {} (inspection: {})",
3685 secret_backend_kind_label(backend.backend),
3686 secret_backend_inspection_label(backend.inspection),
3687 ));
3688 if let Some(path) = backend.path.as_ref() {
3689 lines.push(format!(
3690 "secret store: {} ({})",
3691 codewhale_config::quote_os_path(path),
3692 secret_backend_presence_label(backend.presence),
3693 ));
3694 } else {
3695 lines.push(format!(
3696 "secret store: unavailable ({})",
3697 secret_backend_presence_label(backend.presence),
3698 ));
3699 }
3700 if let Some(path) = backend.legacy_path.as_ref() {
3701 lines.push(format!(
3702 "legacy secret store: {} ({})",
3703 codewhale_config::quote_os_path(path),
3704 secret_backend_presence_label(backend.legacy_presence),
3705 ));
3706 } else if explicit_home {
3707 lines.push(
3708 "legacy secret store: suppressed by explicit CODEWHALE_HOME isolation".to_string(),
3709 );
3710 } else {
3711 lines.push("legacy secret store: unavailable (not probed)".to_string());
3712 }
3713
3714 lines.push(String::new());
3715 let providers = [provider.unwrap_or(store.config.provider)];
3719 for provider in providers {
3720 let config_present = provider_config_api_key(store, provider).is_some();
3721 let environment_present = provider_env_vars(provider)
3722 .iter()
3723 .any(|name| std::env::var(name).is_ok_and(|value| !value.trim().is_empty()));
3724 let environment_names = match provider_env_vars(provider) {
3725 [] => "none configured".to_string(),
3726 names => names.join("/"),
3727 };
3728 let external_configured = external_consent(store, provider).is_some();
3729 lines.push(format!(
3730 "provider {} sources: config_literal={}, secret_backend={} (provider entry unprobed), environment={} ({}), external_consent={}",
3731 provider.as_str(),
3732 if config_present { "present" } else { "missing" },
3733 secret_backend_presence_label(backend.presence),
3734 if environment_present { "present" } else { "missing" },
3735 environment_names,
3736 if external_configured {
3737 "configured"
3738 } else {
3739 "missing"
3740 },
3741 ));
3742 }
3743 lines
3744}
3745
3746fn run_auth_diagnostic(store: &ConfigStore, provider: Option<ProviderKind>) -> Result<()> {
3747 for line in auth_diagnostic_lines(store, provider) {
3748 println!("{line}");
3749 }
3750 Ok(())
3751}
3752
3753#[cfg(test)]
3754fn auth_list_lines(store: &ConfigStore, secrets: &Secrets) -> Vec<String> {
3755 auth_list_lines_with_runtime(store, secrets, &CliRuntimeOverrides::default())
3756}
3757
3758fn auth_list_lines_with_runtime(
3759 store: &ConfigStore,
3760 secrets: &Secrets,
3761 runtime_overrides: &CliRuntimeOverrides,
3762) -> Vec<String> {
3763 let mut lines = Vec::new();
3764 lines.push("provider config store env route".to_string());
3765 for provider in ProviderKind::ALL {
3766 let label = provider.as_str();
3776 if provider == ProviderKind::Xai {
3777 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3778 let api_key = diagnostics
3779 .evaluates_runtime_api_key()
3780 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3781 lines.push(format!(
3782 "{label:<12} {} {} {} {}",
3783 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::ConfigFile),
3784 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Keyring),
3785 xai_list_storage_status(api_key.as_ref(), RuntimeApiKeySource::Env),
3786 xai_list_route(&diagnostics, api_key.as_ref())
3787 ));
3788 continue;
3789 }
3790
3791 let file = provider_config_set(store, provider);
3792 let keyring = (!file).then(|| provider_keyring_set(secrets, provider));
3793 let env = provider_env_set(provider);
3794 let external_selected = external_oauth_selected(store, provider);
3795 let active = if provider == ProviderKind::OpenaiCodex {
3796 if env {
3797 "env"
3798 } else if external_selected {
3799 "external-consent"
3800 } else {
3801 "missing"
3802 }
3803 } else if external_selected {
3804 "external-consent"
3805 } else if file {
3806 "config"
3807 } else if keyring == Some(true) {
3808 "store"
3809 } else if env {
3810 "env"
3811 } else {
3812 "missing"
3813 };
3814 lines.push(format!(
3815 "{label:<12} {} {} {} {active}",
3816 yes_no(file),
3817 keyring_status_short(keyring),
3818 yes_no(env)
3819 ));
3820 }
3821 lines
3822}
3823
3824#[cfg(test)]
3825fn auth_status_lines_for_provider(
3826 store: &ConfigStore,
3827 secrets: &Secrets,
3828 provider: ProviderKind,
3829) -> Vec<String> {
3830 auth_status_lines_for_provider_with_runtime(
3831 store,
3832 secrets,
3833 provider,
3834 &CliRuntimeOverrides::default(),
3835 )
3836}
3837
3838fn auth_status_lines_for_provider_with_runtime(
3839 store: &ConfigStore,
3840 secrets: &Secrets,
3841 provider: ProviderKind,
3842 runtime_overrides: &CliRuntimeOverrides,
3843) -> Vec<String> {
3844 if provider == ProviderKind::Xai {
3845 return xai_auth_status_lines_for_provider(store, secrets, runtime_overrides);
3846 }
3847
3848 let config_key = provider_config_api_key(store, provider);
3849 let keyring_key = provider_keyring_api_key(secrets, provider);
3850 let env_key = provider_env_value(provider);
3851 let external = external_consent(store, provider);
3852 let external_selected = external_oauth_selected(store, provider);
3853
3854 let active_label = {
3855 let active_source = if provider == ProviderKind::OpenaiCodex {
3856 if env_key.is_some() {
3857 "env"
3858 } else if store
3859 .config
3860 .providers
3861 .openai_codex
3862 .oauth_credential_generation
3863 .as_deref()
3864 .is_some_and(codewhale_config::is_valid_chatgpt_oauth_generation)
3865 {
3866 "codewhale-owned ChatGPT sign-in (availability not probed)"
3867 } else if external_selected {
3868 "external read-only consent (availability not probed)"
3869 } else {
3870 "missing"
3871 }
3872 } else if external_selected {
3873 "external read-only consent (availability not probed)"
3874 } else if config_key.is_some() {
3875 "config"
3876 } else if keyring_key.is_some() {
3877 "secret store"
3878 } else if env_key.is_some() {
3879 "env"
3880 } else {
3881 "missing"
3882 };
3883 let active_last4 = if provider == ProviderKind::OpenaiCodex {
3884 env_key.as_ref().map(|(_, value)| last4_label(value))
3885 } else {
3886 config_key
3887 .map(last4_label)
3888 .or_else(|| keyring_key.as_deref().map(last4_label))
3889 .or_else(|| env_key.as_ref().map(|(_, value)| last4_label(value)))
3890 };
3891 active_last4
3892 .map(|last4| format!("{active_source} (last4: {last4})"))
3893 .unwrap_or_else(|| active_source.to_string())
3894 };
3895
3896 let env_var_label = env_key
3897 .as_ref()
3898 .map(|(name, _)| (*name).to_string())
3899 .unwrap_or_else(|| provider_env_vars(provider).join("/"));
3900 let env_status = env_key
3901 .as_ref()
3902 .map(|(_, value)| format!("set, last4: {}", last4_label(value)))
3903 .unwrap_or_else(|| "unset".to_string());
3904
3905 let is_active = provider == store.config.provider;
3906 let active_marker = if is_active { " (active provider)" } else { "" };
3907
3908 let provider_cfg = store.config.providers.for_provider(provider);
3909 let base_url = provider_cfg.base_url.as_deref().unwrap_or("(default)");
3910 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3911
3912 let lookup_order = if provider == ProviderKind::OpenaiCodex {
3913 "lookup order: env -> Codewhale-owned ChatGPT sign-in -> consent-gated exact Codex CLI file"
3914 .to_string()
3915 } else {
3916 "lookup order: config -> secret store -> env".to_string()
3917 };
3918 let auth_mode = if provider == ProviderKind::OpenaiCodex {
3919 "codex_oauth".to_string()
3920 } else {
3921 provider_cfg
3922 .auth_mode
3923 .as_deref()
3924 .or(store.config.auth_mode.as_deref())
3925 .unwrap_or("api_key")
3926 .to_string()
3927 };
3928
3929 let mut lines = vec![
3930 format!("provider: {}{}", provider.as_str(), active_marker),
3931 format!("route: {}", base_url),
3932 format!("model: {}", model),
3933 format!("auth mode: {auth_mode}"),
3934 format!("active source: {active_label}"),
3935 lookup_order,
3936 format!(
3937 "config file: {} ({})",
3938 codewhale_config::quote_os_path(store.path()),
3939 source_status(config_key, "missing")
3940 ),
3941 format!(
3942 "secret store: {} ({})",
3943 secrets.backend_name(),
3944 source_status(keyring_key.as_deref(), "missing")
3945 ),
3946 format!("env var: {env_var_label} ({env_status})"),
3947 ];
3948
3949 if let Ok((source, expected_path)) = external_credential_target(provider, None) {
3950 let status = codewhale_config::external_credential_consent_status(
3951 external,
3952 provider,
3953 source,
3954 &expected_path,
3955 store.config.provider,
3956 );
3957 lines.push(format!(
3958 "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
3959 status.access.as_str(),
3960 status.provider,
3961 status.source.as_str(),
3962 status.owner,
3963 codewhale_config::quote_os_path(&status.path),
3964 status.consent_version,
3965 status.route_state,
3966 status.scope_valid,
3967 status.ambient_path_changed,
3968 ));
3969 lines.push(format!("semantics: {}", status.semantics));
3970 lines.push(format!("revoke: {}", status.revoke_command));
3971 if let Some(warning) = status.ambient_path_warning() {
3972 lines.push(warning);
3973 }
3974 } else {
3975 lines.push("external credentials: disabled (no file was probed)".to_string());
3976 }
3977 lines
3978}
3979
3980fn xai_auth_status_lines_for_provider(
3981 store: &ConfigStore,
3982 secrets: &Secrets,
3983 runtime_overrides: &CliRuntimeOverrides,
3984) -> Vec<String> {
3985 let diagnostics = xai_auth_diagnostics(store, runtime_overrides);
3986 let api_key = diagnostics
3987 .evaluates_runtime_api_key()
3988 .then(|| xai_runtime_api_key(store, secrets, runtime_overrides));
3989 let external = external_consent(store, ProviderKind::Xai);
3990 let selected_marker = if store.config.provider == ProviderKind::Xai {
3991 " (selected provider)"
3992 } else {
3993 ""
3994 };
3995 let provider_cfg = &store.config.providers.xai;
3996 let model = provider_cfg.model.as_deref().unwrap_or("(default)");
3997 let auth_mode = diagnostics.auth_mode.as_deref().unwrap_or("api_key");
3998
3999 let mut lines = vec![
4000 format!("provider: xai{selected_marker}"),
4001 format!("route: {}", diagnostics.base_url),
4002 format!("model: {model}"),
4003 format!("auth mode: {auth_mode}"),
4004 format!(
4005 "credential route: {}",
4006 xai_credential_route_label(&diagnostics, api_key.as_ref())
4007 ),
4008 xai_lookup_order(&diagnostics),
4009 format!(
4010 "config file: {} ({})",
4011 codewhale_config::quote_os_path(store.path()),
4012 xai_storage_detail(
4013 &diagnostics,
4014 api_key.as_ref(),
4015 RuntimeApiKeySource::ConfigFile
4016 )
4017 ),
4018 format!(
4019 "secret store: {} ({})",
4020 secrets.backend_name(),
4021 xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Keyring)
4022 ),
4023 format!(
4024 "env var: {} ({})",
4025 provider_env_vars(ProviderKind::Xai).join("/"),
4026 xai_storage_detail(&diagnostics, api_key.as_ref(), RuntimeApiKeySource::Env)
4027 ),
4028 format!(
4029 "endpoint policy: {}",
4030 if diagnostics.official_endpoint {
4031 "official xAI endpoint"
4032 } else {
4033 "custom xAI endpoint; API-key-only (owned and external OAuth are inactive)"
4034 }
4035 ),
4036 ];
4037
4038 lines.push(match diagnostics.generation {
4039 XaiOAuthGenerationPointer::Absent => "xAI OAuth generation: absent".to_string(),
4040 XaiOAuthGenerationPointer::Valid
4041 if diagnostics.route == XaiAuthDiagnosticRoute::OwnedOAuth =>
4042 {
4043 "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
4044 .to_string()
4045 }
4046 XaiOAuthGenerationPointer::Valid => {
4047 "xAI OAuth generation: valid but inactive for this route".to_string()
4048 }
4049 XaiOAuthGenerationPointer::Invalid => {
4050 "xAI OAuth generation: invalid Codewhale-owned pointer".to_string()
4051 }
4052 });
4053
4054 match diagnostics.route {
4055 XaiAuthDiagnosticRoute::OwnedOAuth => {
4056 lines.push(
4057 "external credentials: blocked by the configured Codewhale-owned xAI OAuth generation (file not probed)"
4058 .to_string(),
4059 );
4060 return lines;
4061 }
4062 XaiAuthDiagnosticRoute::NeedsRepair => {
4063 lines.push(
4064 "external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer (file not probed)"
4065 .to_string(),
4066 );
4067 lines.push(
4068 "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."
4069 .to_string(),
4070 );
4071 return lines;
4072 }
4073 XaiAuthDiagnosticRoute::ApiKey if diagnostics.is_custom_endpoint() => {
4074 lines.push(
4075 "external credentials: unavailable on a custom xAI endpoint (API-key-only; file not probed)"
4076 .to_string(),
4077 );
4078 return lines;
4079 }
4080 XaiAuthDiagnosticRoute::ApiKey if !diagnostics.oauth_selected && external.is_some() => {
4081 lines.push(
4082 "external credentials: configured but inactive because xAI OAuth mode is not selected (file not probed)"
4083 .to_string(),
4084 );
4085 return lines;
4086 }
4087 XaiAuthDiagnosticRoute::ApiKey | XaiAuthDiagnosticRoute::ExternalConsent => {}
4088 }
4089
4090 if let Ok((source, expected_path)) = external_credential_target(ProviderKind::Xai, None) {
4091 let status = codewhale_config::external_credential_consent_status(
4092 external,
4093 ProviderKind::Xai,
4094 source,
4095 &expected_path,
4096 store.config.provider,
4097 );
4098 lines.push(format!(
4099 "external credentials: {} (provider={}, source={}, owner={}, path={}, consent_version={}, state={}, scope_valid={}, ambient_path_changed={}; file not probed)",
4100 status.access.as_str(),
4101 status.provider,
4102 status.source.as_str(),
4103 status.owner,
4104 codewhale_config::quote_os_path(&status.path),
4105 status.consent_version,
4106 status.route_state,
4107 status.scope_valid,
4108 status.ambient_path_changed,
4109 ));
4110 lines.push(format!("semantics: {}", status.semantics));
4111 lines.push(format!("revoke: {}", status.revoke_command));
4112 if let Some(warning) = status.ambient_path_warning() {
4113 lines.push(warning);
4114 }
4115 } else {
4116 lines.push("external credentials: disabled (no file was probed)".to_string());
4117 }
4118 lines
4119}
4120
4121fn source_status(value: Option<&str>, missing_label: &str) -> String {
4122 value
4123 .map(|v| format!("set, last4: {}", last4_label(v)))
4124 .unwrap_or_else(|| missing_label.to_string())
4125}
4126
4127fn last4_label(value: &str) -> String {
4128 let trimmed = value.trim();
4129 let chars: Vec<char> = trimmed.chars().collect();
4130 if chars.len() <= 4 {
4131 return "<redacted>".to_string();
4132 }
4133 let last4: String = chars[chars.len() - 4..].iter().collect();
4134 format!("...{last4}")
4135}
4136
4137fn run_auth_command_with_runtime(
4138 store: &mut ConfigStore,
4139 command: AuthCommand,
4140 runtime_overrides: &CliRuntimeOverrides,
4141) -> Result<()> {
4142 let command = match command {
4143 AuthCommand::Status {
4144 provider,
4145 diagnostic: true,
4146 } => {
4147 return run_auth_diagnostic(store, provider);
4152 }
4153 command => command,
4154 };
4155 run_auth_command_with_secrets_and_runtime(
4156 store,
4157 command,
4158 &Secrets::auto_detect(),
4159 runtime_overrides,
4160 )
4161}
4162
4163#[cfg(test)]
4164fn run_auth_command_with_secrets(
4165 store: &mut ConfigStore,
4166 command: AuthCommand,
4167 secrets: &Secrets,
4168) -> Result<()> {
4169 run_auth_command_with_secrets_and_runtime(
4170 store,
4171 command,
4172 secrets,
4173 &CliRuntimeOverrides::default(),
4174 )
4175}
4176
4177fn run_auth_command_with_secrets_and_runtime(
4178 store: &mut ConfigStore,
4179 command: AuthCommand,
4180 secrets: &Secrets,
4181 runtime_overrides: &CliRuntimeOverrides,
4182) -> Result<()> {
4183 match command {
4184 AuthCommand::XaiDevice => {
4185 let argv = vec![
4186 "codewhale".to_string(),
4187 "auth".to_string(),
4188 "xai-device".to_string(),
4189 ];
4190 let code = codewhale_tui::run(argv);
4191 std::process::exit(if code == std::process::ExitCode::SUCCESS {
4192 0
4193 } else {
4194 1
4195 })
4196 }
4197 AuthCommand::Chatgpt => {
4198 let argv = vec![
4199 "codewhale".to_string(),
4200 "auth".to_string(),
4201 "chatgpt".to_string(),
4202 ];
4203 let code = codewhale_tui::run(argv);
4204 std::process::exit(if code == std::process::ExitCode::SUCCESS {
4205 0
4206 } else {
4207 1
4208 })
4209 }
4210 AuthCommand::ChatgptRevoke => {
4211 let argv = vec![
4212 "codewhale".to_string(),
4213 "auth".to_string(),
4214 "chatgpt-revoke".to_string(),
4215 ];
4216 let code = codewhale_tui::run(argv);
4217 std::process::exit(if code == std::process::ExitCode::SUCCESS {
4218 0
4219 } else {
4220 1
4221 })
4222 }
4223 AuthCommand::ExternalConsent {
4224 provider,
4225 mode,
4226 path,
4227 yes,
4228 } => {
4229 let (source, path) = external_credential_target(provider, path)?;
4230 let preview = external_consent_preview_lines(provider, source, &path);
4231 for line in &preview {
4232 println!("{line}");
4233 }
4234 if mode == ExternalCredentialModeArg::Managed {
4235 bail!(
4236 "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."
4237 );
4238 }
4239 confirm_external_consent(yes)?;
4240 let path_value = path.to_str().context(
4241 "external credential path cannot be persisted losslessly because it is not valid UTF-8",
4242 )?;
4243 let provider_key = provider.provider().provider_config_key();
4244 codewhale_config::mutate_config_document(store.path(), |document| {
4245 if matches!(provider, ProviderKind::OpenaiCodex | ProviderKind::Xai) {
4246 codewhale_config::set_config_document_value(
4247 document,
4248 &["providers", provider_key, "auth_mode"],
4249 "oauth",
4250 )?;
4251 }
4252 let prefix = &["providers", provider_key, "external_credentials"];
4253 codewhale_config::set_config_document_value(
4254 document,
4255 &[prefix[0], prefix[1], prefix[2], "access"],
4256 "read_only",
4257 )?;
4258 codewhale_config::set_config_document_value(
4259 document,
4260 &[prefix[0], prefix[1], prefix[2], "provider"],
4261 provider.as_str(),
4262 )?;
4263 codewhale_config::set_config_document_value(
4264 document,
4265 &[prefix[0], prefix[1], prefix[2], "source"],
4266 source.as_str(),
4267 )?;
4268 codewhale_config::set_config_document_value(
4269 document,
4270 &[prefix[0], prefix[1], prefix[2], "path"],
4271 path_value,
4272 )?;
4273 codewhale_config::set_config_document_value(
4274 document,
4275 &[prefix[0], prefix[1], prefix[2], "consent_version"],
4276 i64::from(codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION),
4277 )
4278 })?;
4279 store
4280 .reload()
4281 .context("external consent was saved, but config reload failed")?;
4282 println!(
4283 "saved read-only external credential consent: provider={}, owner={}, path={}, consent_version={} ({})",
4284 provider.as_str(),
4285 source.as_str(),
4286 codewhale_config::quote_os_path(&path),
4287 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION,
4288 codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS,
4289 );
4290 println!(
4291 "revoke with: codewhale auth external-revoke --provider {}",
4292 provider.as_str()
4293 );
4294 Ok(())
4295 }
4296 AuthCommand::ExternalRevoke { provider } => {
4297 let provider_key = provider.provider().provider_config_key();
4298 codewhale_config::mutate_config_document(store.path(), |document| {
4299 codewhale_config::unset_config_document_value(
4300 document,
4301 &["providers", provider_key, "external_credentials"],
4302 )?;
4303 Ok(())
4304 })?;
4305 store
4306 .reload()
4307 .context("external consent was revoked, but config reload failed")?;
4308 println!(
4309 "external credential access disabled for {}",
4310 provider.as_str()
4311 );
4312 Ok(())
4313 }
4314 AuthCommand::Status {
4315 provider,
4316 diagnostic,
4317 } => {
4318 if diagnostic {
4319 return run_auth_diagnostic(store, provider);
4320 }
4321 match provider {
4322 Some(provider) => {
4323 for line in auth_status_lines_for_provider_with_runtime(
4324 store,
4325 secrets,
4326 provider,
4327 runtime_overrides,
4328 ) {
4329 println!("{line}");
4330 }
4331 }
4332 None => {
4333 for line in
4334 auth_status_all_providers_with_runtime(store, secrets, runtime_overrides)
4335 {
4336 println!("{line}");
4337 }
4338 }
4339 }
4340 Ok(())
4341 }
4342 AuthCommand::Set {
4343 provider,
4344 api_key,
4345 api_key_stdin,
4346 } => {
4347 let slot = provider_slot(provider);
4348 if provider == ProviderKind::Ollama && api_key.is_none() && !api_key_stdin {
4349 let provider_cfg = store.config.providers.for_provider_mut(provider);
4350 if provider_cfg.base_url.is_none() {
4351 provider_cfg.base_url = Some("http://localhost:11434/v1".to_string());
4352 }
4353 store.save()?;
4354 println!(
4355 "configured {slot} provider in {} (API key optional)",
4356 store.path().display()
4357 );
4358 return Ok(());
4359 }
4360 let api_key = match (api_key, api_key_stdin) {
4361 (Some(v), _) => v,
4362 (None, true) => read_api_key_from_stdin()?,
4363 (None, false) => prompt_api_key(slot)?,
4364 };
4365 let mut credential_store = credential_metadata_store(store)?;
4366 let store = credential_store.as_mut().unwrap_or(store);
4367 let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
4368 if secret_store_saved {
4370 println!(
4371 "saved API key for {slot} to {} (config contains metadata only)",
4372 secrets.backend_name(),
4373 );
4374 } else {
4375 println!("saved API key for {slot} to {}", store.path().display());
4376 }
4377 Ok(())
4378 }
4379 AuthCommand::Get { provider } => {
4380 println!(
4381 "{}",
4382 auth_get_line_with_runtime(store, secrets, provider, runtime_overrides)
4383 );
4384 Ok(())
4385 }
4386 AuthCommand::PrintApiKey { provider } => {
4387 let mut stdout = io::stdout().lock();
4388 credential_handoff::handoff_secret_line(&mut stdout, io::stdout().is_terminal(), || {
4389 credential_handoff::resolve_api_key(store, secrets, provider, runtime_overrides)
4390 })
4391 }
4392 AuthCommand::Clear { provider } => {
4393 if provider == ProviderKind::Xai {
4394 codewhale_config::with_xai_oauth_revocation_transaction(|| {
4395 clear_auth_provider(store, secrets, provider)
4396 })
4397 } else {
4398 clear_auth_provider(store, secrets, provider)
4399 }
4400 }
4401 AuthCommand::List => {
4402 for line in auth_list_lines_with_runtime(store, secrets, runtime_overrides) {
4403 println!("{line}");
4404 }
4405 Ok(())
4406 }
4407 AuthCommand::Migrate { dry_run } => run_auth_migrate(store, secrets, dry_run),
4408 }
4409}
4410
4411fn external_consent_preview_lines(
4412 provider: ProviderKind,
4413 source: codewhale_config::ExternalCredentialSource,
4414 path: &Path,
4415) -> Vec<String> {
4416 vec![
4417 "External credential consent preview (nothing has been saved):".to_string(),
4418 format!(" provider: {}", provider.as_str()),
4419 format!(
4420 " owning CLI: {} ({})",
4421 source.owner_label(),
4422 source.as_str()
4423 ),
4424 format!(
4425 " exact resolved path: {}",
4426 codewhale_config::quote_os_path(path)
4427 ),
4428 format!(
4429 " access: read_only ({})",
4430 codewhale_config::EXTERNAL_CREDENTIAL_READ_ONLY_SEMANTICS
4431 ),
4432 " managed: unavailable (no reviewed schema-safe preservation adapter)".to_string(),
4433 format!(
4434 " revoke: codewhale auth external-revoke --provider {}",
4435 provider.as_str()
4436 ),
4437 ]
4438}
4439
4440fn confirm_external_consent(yes: bool) -> Result<()> {
4441 use std::io::IsTerminal;
4442
4443 if yes {
4444 return Ok(());
4445 }
4446 if !std::io::stdin().is_terminal() {
4447 bail!(
4448 "external credential consent was not saved: non-interactive use requires explicit --yes after reviewing the preview"
4449 );
4450 }
4451 confirm_external_consent_answer(&mut std::io::stdin().lock(), &mut std::io::stdout().lock())
4452}
4453
4454fn confirm_external_consent_answer(
4455 reader: &mut impl std::io::BufRead,
4456 writer: &mut impl std::io::Write,
4457) -> Result<()> {
4458 write!(writer, "Type 'yes' to grant this exact read-only access: ")?;
4459 writer.flush()?;
4460 let mut answer = String::new();
4461 reader
4462 .read_line(&mut answer)
4463 .context("reading external credential consent confirmation")?;
4464 if answer.trim() != "yes" {
4465 bail!("external credential consent cancelled; no configuration was changed");
4466 }
4467 Ok(())
4468}
4469
4470fn yes_no(b: bool) -> &'static str {
4471 if b { "yes" } else { "no " }
4472}
4473
4474fn keyring_status_short(state: Option<bool>) -> &'static str {
4475 match state {
4476 Some(true) => "yes",
4477 Some(false) => "no ",
4478 None => "n/a",
4479 }
4480}
4481
4482fn prompt_api_key(slot: &str) -> Result<String> {
4483 use std::io::{IsTerminal, Write};
4484 eprint!("Enter API key for {slot}: ");
4485 io::stderr().flush().ok();
4486 if !io::stdin().is_terminal() {
4487 return read_api_key_from_stdin();
4489 }
4490 let mut buf = String::new();
4491 io::stdin()
4492 .read_line(&mut buf)
4493 .context("failed to read API key from stdin")?;
4494 let key = buf.trim().to_string();
4495 if key.is_empty() {
4496 bail!("empty API key provided");
4497 }
4498 Ok(key)
4499}
4500
4501fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -> Result<()> {
4504 let mut migrated: Vec<(ProviderKind, &'static str)> = Vec::new();
4505 let mut warnings: Vec<String> = Vec::new();
4506 let literal =
4507 |value: &String| classify_config_api_key_value(value) == ConfigApiKeyValueKind::Literal;
4508
4509 for provider in ProviderKind::ALL {
4510 let slot = provider_slot(provider);
4511 let from_provider_block = store
4512 .config
4513 .providers
4514 .for_provider(provider)
4515 .api_key
4516 .clone()
4517 .filter(literal);
4518 let from_root = (provider == ProviderKind::Deepseek)
4519 .then(|| store.config.api_key.clone())
4520 .flatten()
4521 .filter(literal);
4522 let value = from_provider_block.or(from_root);
4523 let Some(value) = value else { continue };
4524
4525 if let Ok(Some(existing)) = secrets.get(slot)
4526 && existing == value
4527 {
4528 } else if dry_run {
4530 migrated.push((provider, slot));
4531 continue;
4532 } else if let Err(err) = secrets.set(slot, &value) {
4533 warnings.push(format!(
4534 "skipped {slot}: failed to write to secret store: {err}"
4535 ));
4536 continue;
4537 }
4538 if !dry_run {
4539 store.config.providers.for_provider_mut(provider).api_key = None;
4540 if provider == ProviderKind::Deepseek {
4541 store.config.api_key = None;
4542 }
4543 }
4544 migrated.push((provider, slot));
4545 }
4546
4547 if !dry_run && !migrated.is_empty() {
4548 store
4549 .save()
4550 .context("failed to write updated config.toml")?;
4551 }
4552 if !dry_run {
4553 codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())
4554 .context("failed to remove plaintext API keys from config backup")?;
4555 }
4556
4557 println!("secret store backend: {}", secrets.backend_name());
4558 if migrated.is_empty() {
4559 println!("nothing to migrate (config.toml has no plaintext api_key entries)");
4560 } else {
4561 println!(
4562 "{} {} provider key(s):",
4563 if dry_run { "would migrate" } else { "migrated" },
4564 migrated.len()
4565 );
4566 for (_, slot) in &migrated {
4567 println!(" - {slot}");
4568 }
4569 if !dry_run {
4570 println!(
4571 "config.toml at {} no longer contains api_key entries for migrated providers.",
4572 store.path().display()
4573 );
4574 }
4575 }
4576 for w in warnings {
4577 eprintln!("warning: {w}");
4578 }
4579 Ok(())
4580}
4581
4582fn run_config_command(
4583 store: &mut ConfigStore,
4584 command: ConfigCommand,
4585 project_bundle_scope: bool,
4586 per_run_overrides: &[String],
4587) -> Result<()> {
4588 if project_bundle_scope && !codewhale_config::config_path_is_workspace_scoped(store.path()) {
4589 bail!(
4590 "--project requires a workspace config ({} is the user-global document)",
4591 store.path().display()
4592 );
4593 }
4594 if !per_run_overrides.is_empty()
4597 && matches!(
4598 command,
4599 ConfigCommand::Set { .. }
4600 | ConfigCommand::Unset { .. }
4601 | ConfigCommand::Import(_)
4602 | ConfigCommand::Telemetry {
4603 accept_notice: Some(_)
4604 }
4605 )
4606 {
4607 bail!(
4608 "--set is per-run and never saved; it cannot be combined with `config set`, \
4609 `config unset`, or `config import`. Drop --set, or use a read command."
4610 );
4611 }
4612 match command {
4613 ConfigCommand::Get { key } => {
4614 if per_run_overrides.is_empty() && codewhale_tui::route_preferences::is_route_key(&key)
4615 {
4616 if let Some(value) = codewhale_tui::route_preferences::get(store.path(), &key)? {
4617 println!("{value}");
4618 return Ok(());
4619 }
4620 bail!("key not found: {key}");
4621 }
4622 if codewhale_config::notifications::in_namespace(&key) {
4623 let config = codewhale_config::notifications::from_extras(&store.config.extras)?;
4624 let keys = if key.eq_ignore_ascii_case("notifications") {
4625 codewhale_config::notifications::NotificationSetting::ALL.to_vec()
4626 } else {
4627 vec![codewhale_config::notifications::NotificationSetting::required(&key)?]
4628 };
4629 for setting in keys {
4630 if key.eq_ignore_ascii_case("notifications") {
4631 println!(
4632 "notifications.{} = {}",
4633 setting.key(),
4634 config.display(setting)
4635 );
4636 } else {
4637 println!("{}", config.display(setting));
4638 }
4639 }
4640 return Ok(());
4641 }
4642 if let Some(value) = store.config.get_display_value(&key) {
4643 if key == "telemetry" {
4644 println!(
4645 "Usage reporting: {}",
4646 telemetry_preference_status(store.config.telemetry)
4647 );
4648 println!("Details: codewhale config telemetry");
4649 } else {
4650 println!("{value}");
4651 }
4652 return Ok(());
4653 }
4654 bail!("key not found: {key}");
4655 }
4656 ConfigCommand::Set { key, value } => {
4657 if codewhale_tui::route_preferences::is_route_key(&key) {
4658 codewhale_tui::route_preferences::set(store.path(), &key, &value)?;
4659 store.reload()?;
4660 println!("set {key}");
4661 return Ok(());
4662 }
4663 if codewhale_config::notifications::in_namespace(&key) {
4664 let setting = codewhale_config::notifications::NotificationSetting::required(&key)?;
4665 codewhale_config::notifications::NotificationConfigUpdate::parse(setting, &value)?
4666 .persist(store.path())?;
4667 store.reload()?;
4668 println!("set notifications.{}", setting.key());
4669 return Ok(());
4670 }
4671 store.config.set_value(&key, &value)?;
4672 if key == "telemetry" {
4673 let enabled = store
4674 .config
4675 .telemetry
4676 .context("telemetry must be true or false")?;
4677 let receipt = codewhale_tui::set_telemetry_preference(
4678 Some(store.path().to_path_buf()),
4679 enabled,
4680 )?;
4681 println!("{receipt}");
4682 if enabled {
4683 println!("{}", telemetry::notice::STARTUP_DISCLOSURE);
4684 }
4685 } else {
4686 store.save()?;
4687 println!("set {key}");
4688 }
4689 Ok(())
4690 }
4691 ConfigCommand::Telemetry { accept_notice } => {
4692 println!("{}\n", telemetry::notice::NOTICE_BODY);
4693 if let Some(version) = accept_notice {
4694 let receipt = codewhale_tui::accept_telemetry_notice(
4695 Some(store.path().to_path_buf()),
4696 version,
4697 )?;
4698 println!("{receipt}");
4699 } else {
4700 println!(
4701 "Usage reporting: {}",
4702 telemetry_preference_status(store.config.telemetry)
4703 );
4704 println!("To enable: codewhale config set telemetry true");
4705 println!("To opt out: codewhale config set telemetry false");
4706 }
4707 Ok(())
4708 }
4709 ConfigCommand::Unset { key } => {
4710 if codewhale_tui::route_preferences::is_route_key(&key) {
4711 codewhale_tui::route_preferences::unset(store.path(), &key)?;
4712 store.reload()?;
4713 println!("unset {key}");
4714 return Ok(());
4715 }
4716 if codewhale_config::notifications::in_namespace(&key) {
4717 let setting = codewhale_config::notifications::NotificationSetting::required(&key)?;
4718 setting.unset(store.path())?;
4719 store.reload()?;
4720 println!("unset notifications.{}", setting.key());
4721 return Ok(());
4722 }
4723 store.config.unset_value(&key)?;
4724 store.save()?;
4725 println!("unset {key}");
4726 Ok(())
4727 }
4728 ConfigCommand::List => {
4729 println!("# configured values ({})", store.path().display());
4734 println!(
4735 "# a running session keeps the route it resolved at launch; `codewhale model resolve` reports the route a new session would take"
4736 );
4737 for (key, value) in store.config.list_values() {
4738 println!("{key} = {value}");
4739 }
4740 Ok(())
4741 }
4742 ConfigCommand::Path => {
4743 println!("{}", store.path().display());
4744 Ok(())
4745 }
4746 ConfigCommand::Edit => {
4747 let path = store.path().to_path_buf();
4748 println!("{}", path.display());
4749 let editor = std::env::var("VISUAL")
4750 .or_else(|_| std::env::var("EDITOR"))
4751 .unwrap_or_else(|_| "vi".to_string());
4752 let status = Command::new(&editor)
4753 .arg(&path)
4754 .status()
4755 .with_context(|| format!("failed to launch editor {editor:?}"))?;
4756 if !status.success() {
4757 bail!("editor {editor:?} exited with {status}");
4758 }
4759 Ok(())
4760 }
4761 ConfigCommand::Doctor => run_config_doctor(store),
4762 ConfigCommand::Dump => {
4763 if !per_run_overrides.is_empty() {
4764 println!(
4765 "# {} per-run --set override(s), not saved",
4766 per_run_overrides.len()
4767 );
4768 }
4769 println!("# {}", store.path().display());
4770 print!(
4771 "{}",
4772 toml::to_string_pretty(&store.config.redacted_toml_value())?
4773 );
4774 Ok(())
4775 }
4776 ConfigCommand::Import(args) => {
4777 let workspace = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
4778 config_bundles::run_import(&args, store, &workspace)
4779 }
4780 ConfigCommand::Export(args) => config_bundles::run_export(&args, store),
4781 }
4782}
4783
4784fn apply_per_run_overrides(store: &mut ConfigStore, specs: &[String]) -> Result<()> {
4788 for spec in specs {
4789 let (key, value) = spec
4790 .split_once('=')
4791 .with_context(|| format!("invalid --set {spec:?}: expected KEY=VALUE"))?;
4792 store
4793 .config
4794 .set_value(key.trim(), value)
4795 .with_context(|| format!("invalid --set {spec:?}"))?;
4796 }
4797 Ok(())
4798}
4799
4800fn run_config_doctor(store: &ConfigStore) -> Result<()> {
4804 println!("# {}", store.path().display());
4805 let mut errors: Vec<String> = Vec::new();
4806 if !store.config.extras.is_empty() {
4807 println!(
4808 "note: additional settings are preserved for runtime readers; this check does not classify their support"
4809 );
4810 }
4811
4812 let mut secrets: Vec<(String, Option<String>)> =
4813 vec![("api_key".to_string(), store.config.api_key.clone())];
4814 let mut endpoints: Vec<(String, Option<String>)> =
4815 vec![("base_url".to_string(), store.config.base_url.clone())];
4816 for provider in ProviderKind::ALL {
4817 let table = store.config.providers.for_provider(provider);
4818 secrets.push((format!("{provider:?}.api_key"), table.api_key.clone()));
4819 endpoints.push((format!("{provider:?}.base_url"), table.base_url.clone()));
4820 }
4821 for (name, secret) in secrets {
4822 if secret
4823 .as_deref()
4824 .is_some_and(|value| value.trim().is_empty())
4825 {
4826 errors.push(format!("`{name}` is set but empty"));
4827 }
4828 }
4829 for (name, endpoint) in endpoints {
4830 if let Some(url) = endpoint.as_deref()
4831 && !url.starts_with("http://")
4832 && !url.starts_with("https://")
4833 {
4834 errors.push(format!("`{name}` is not an http(s) URL: {url}"));
4835 }
4836 }
4837
4838 if !errors.is_empty() {
4839 for error in &errors {
4840 println!("error: {error}");
4841 }
4842 bail!("doctor: {} error(s): {}", errors.len(), errors.join("; "));
4843 }
4844 println!("doctor: credentials and endpoints clean");
4845 Ok(())
4846}
4847
4848fn telemetry_preference_status(preference: Option<bool>) -> &'static str {
4849 let (enabled, source) = codewhale_config::resolved_telemetry_consent(preference);
4850 if !enabled {
4851 return match source {
4852 codewhale_config::TelemetrySource::Env => "Off (environment or run kill switch)",
4853 _ => "Off (saved preference)",
4854 };
4855 }
4856 match telemetry::load_setup_state_for_decision() {
4857 Some(state) if state.telemetry_opted_out() => "Off (saved opt-out)",
4858 None => "Off (privacy state unreadable)",
4859 Some(_) => match source {
4860 codewhale_config::TelemetrySource::Default => "On (default)",
4861 codewhale_config::TelemetrySource::Env | codewhale_config::TelemetrySource::Cli => {
4862 "On (environment or run preference)"
4863 }
4864 codewhale_config::TelemetrySource::Config => "On (saved preference)",
4865 },
4866 }
4867}
4868
4869fn model_command_provider_hint(
4870 command_provider: Option<ProviderKind>,
4871 top_level_provider: Option<ProviderKind>,
4872) -> Option<ProviderKind> {
4873 command_provider.or(top_level_provider)
4874}
4875
4876fn provider_source_label(source: ProviderSource) -> String {
4877 match source {
4878 ProviderSource::Cli => "--provider".to_string(),
4879 ProviderSource::Env(name) => format!("environment ({name})"),
4880 ProviderSource::Config => "config".to_string(),
4881 }
4882}
4883
4884fn canonical_model_for_set(model: &str) -> &str {
4885 match model.to_ascii_lowercase().as_str() {
4886 "pro" | "deepseek-v4pro" => "deepseek-v4-pro",
4887 "flash" | "deepseek-v4flash" => "deepseek-v4-flash",
4888 "flash-vision" | "deepseek-v4flashvisionexp" => "deepseek-v4-flash-vision-exp",
4889 "auto" => "auto",
4890 _ => model,
4891 }
4892}
4893
4894fn run_model_command(
4895 store: &mut ConfigStore,
4896 command: ModelCommand,
4897 top_level_provider: Option<ProviderKind>,
4898 resolved_runtime: &ResolvedRuntimeOptions,
4899) -> Result<()> {
4900 let registry = ModelRegistry::default();
4901 match command {
4902 ModelCommand::List { provider } => {
4903 let filter = model_command_provider_hint(provider, top_level_provider);
4904 for model in registry.list().into_iter().filter(|m| match filter {
4905 Some(p) => m.provider == p,
4906 None => true,
4907 }) {
4908 println!("{} ({})", model.id, model.provider.as_str());
4909 }
4910 Ok(())
4911 }
4912 ModelCommand::Resolve { model, provider } => {
4913 let subcommand_provider = provider;
4921 let queried = model.as_deref().map(str::trim).filter(|m| !m.is_empty());
4922
4923 if queried.is_none() && subcommand_provider.is_none() {
4928 let saved = if matches!(resolved_runtime.provider_source, ProviderSource::Config)
4929 && !matches!(
4930 resolved_runtime.model_source,
4931 codewhale_config::ModelSource::Cli | codewhale_config::ModelSource::Env
4932 ) {
4933 Some(codewhale_tui::route_preferences::selected_route(
4934 store.path(),
4935 )?)
4936 } else {
4937 None
4938 };
4939 let provider = saved
4940 .as_ref()
4941 .map_or(resolved_runtime.provider.as_str(), |(provider, _, _)| {
4942 provider.as_str()
4943 });
4944 let model = saved
4945 .as_ref()
4946 .map_or(resolved_runtime.model.as_str(), |(_, model, _)| {
4947 model.as_str()
4948 });
4949 let source = saved
4950 .as_ref()
4951 .map_or(resolved_runtime.model_source, |(_, _, source)| *source);
4952 println!(
4953 "requested: {}",
4954 if source.is_explicit() { model } else { "" }
4955 );
4956 println!("resolved: {model}");
4957 println!("provider: {provider}");
4958 println!("used_fallback: {}", !source.is_explicit());
4959 println!(
4960 "provider_source: {}",
4961 provider_source_label(resolved_runtime.provider_source)
4962 );
4963 println!("model_source: {}", source.as_str());
4964 return Ok(());
4965 }
4966
4967 let provider_hint = subcommand_provider.or(Some(resolved_runtime.provider));
4972 let resolved = registry.resolve(queried, provider_hint)?;
4973 println!("requested: {}", resolved.requested.unwrap_or_default());
4974 println!("resolved: {}", resolved.resolved.id);
4975 println!("provider: {}", resolved.resolved.provider.as_str());
4976 println!("used_fallback: {}", resolved.used_fallback);
4977 println!(
4978 "provider_source: {}",
4979 if subcommand_provider.is_some() {
4980 "--provider".to_string()
4981 } else {
4982 provider_source_label(resolved_runtime.provider_source)
4983 }
4984 );
4985 println!(
4986 "model_source: {}",
4987 if queried.is_some() {
4988 "argument"
4989 } else {
4990 "provider default"
4995 }
4996 );
4997 Ok(())
4998 }
4999 ModelCommand::Set { model } => {
5000 let trimmed = model.trim();
5001 if trimmed.is_empty() {
5002 bail!("Model name cannot be empty");
5003 }
5004 let canonical = canonical_model_for_set(trimmed);
5005 codewhale_tui::route_preferences::set(store.path(), "model", canonical)?;
5006 store.reload()?;
5007 println!("Default model set to '{canonical}'");
5008 Ok(())
5009 }
5010 }
5011}
5012
5013fn thread_delegation(command: &ThreadCommand) -> Option<Vec<String>> {
5019 match command {
5020 ThreadCommand::Resume { thread_id } => Some(vec!["resume".to_string(), thread_id.clone()]),
5021 ThreadCommand::Fork { thread_id } => Some(vec!["fork".to_string(), thread_id.clone()]),
5022 ThreadCommand::List { .. }
5023 | ThreadCommand::Read { .. }
5024 | ThreadCommand::Archive { .. }
5025 | ThreadCommand::Unarchive { .. }
5026 | ThreadCommand::SetName { .. }
5027 | ThreadCommand::ClearName { .. } => None,
5028 }
5029}
5030
5031fn run_thread_command(
5032 cli: &Cli,
5033 store: &mut ConfigStore,
5034 runtime_overrides: &CliRuntimeOverrides,
5035 command: ThreadCommand,
5036) -> Result<()> {
5037 if let Some(passthrough) = thread_delegation(&command) {
5047 let resolved_runtime = resolve_runtime_for_dispatch(store, runtime_overrides);
5048 return run_tui_in_process(cli, &resolved_runtime, passthrough);
5049 }
5050 let state = StateStore::open(None)?;
5051 match command {
5052 ThreadCommand::List { all, limit } => {
5053 let threads = state.list_threads(ThreadListFilters {
5054 include_archived: all,
5055 limit,
5056 })?;
5057 for thread in threads {
5058 println!(
5059 "{} | {} | {} | {}",
5060 thread.id,
5061 thread
5062 .name
5063 .clone()
5064 .unwrap_or_else(|| "(unnamed)".to_string()),
5065 thread.model_provider,
5066 thread.cwd.display()
5067 );
5068 }
5069 Ok(())
5070 }
5071 ThreadCommand::Read { thread_id } => {
5072 let thread = state.get_thread(&thread_id)?;
5073 println!("{}", serde_json::to_string_pretty(&thread)?);
5074 Ok(())
5075 }
5076 ThreadCommand::Resume { .. } | ThreadCommand::Fork { .. } => {
5077 unreachable!("thread_delegation routes resume and fork before this match")
5078 }
5079 ThreadCommand::Archive { thread_id } => {
5080 state.mark_archived(&thread_id)?;
5081 println!("archived {thread_id}");
5082 Ok(())
5083 }
5084 ThreadCommand::Unarchive { thread_id } => {
5085 state.mark_unarchived(&thread_id)?;
5086 println!("unarchived {thread_id}");
5087 Ok(())
5088 }
5089 ThreadCommand::SetName { thread_id, name } => {
5090 let mut thread = state
5091 .get_thread(&thread_id)?
5092 .with_context(|| format!("thread not found: {thread_id}"))?;
5093 thread.name = Some(name);
5094 thread.updated_at = chrono::Utc::now().timestamp();
5095 state.upsert_thread(&thread)?;
5096 println!("renamed {thread_id}");
5097 Ok(())
5098 }
5099 ThreadCommand::ClearName { thread_id } => {
5100 let mut thread = state
5101 .get_thread(&thread_id)?
5102 .with_context(|| format!("thread not found: {thread_id}"))?;
5103 thread.name = None;
5104 thread.updated_at = chrono::Utc::now().timestamp();
5105 state.upsert_thread(&thread)?;
5106 println!("cleared name for {thread_id}");
5107 Ok(())
5108 }
5109 }
5110}
5111
5112fn run_sandbox_command(command: SandboxCommand) -> Result<()> {
5113 match command {
5114 SandboxCommand::Check { command, ask } => {
5115 let engine = ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]);
5116 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
5117 let decision = engine.check(ExecPolicyContext {
5118 command: &command,
5119 cwd: &cwd.display().to_string(),
5120 tool: Some("exec_shell"),
5121 path: None,
5122 ask_for_approval: ask.into(),
5123 sandbox_mode: Some("workspace-write"),
5124 })?;
5125 println!("{}", serde_json::to_string_pretty(&decision)?);
5126 Ok(())
5127 }
5128 }
5129}
5130
5131fn run_app_server_command(
5132 cli: &Cli,
5133 resolved_runtime: &ResolvedRuntimeOptions,
5134 args: AppServerArgs,
5135) -> Result<()> {
5136 if args.http || args.mobile {
5141 return run_tui_server_in_process(
5144 cli,
5145 resolved_runtime,
5146 app_server_serve_passthrough(&args),
5147 );
5148 }
5149
5150 let session = start_cli_telemetry(
5154 resolved_runtime,
5155 args.config.clone().or_else(|| cli.config.clone()),
5156 Surface::AppServer,
5157 );
5158
5159 let runtime = match tokio::runtime::Builder::new_multi_thread()
5160 .enable_all()
5161 .build()
5162 .context("failed to create tokio runtime")
5163 {
5164 Ok(runtime) => runtime,
5165 Err(error) => {
5166 let outcome = Err(error);
5167 finish_cli_telemetry(session, &outcome);
5168 return outcome;
5169 }
5170 };
5171 if args.stdio {
5172 let outcome = runtime.block_on(run_app_server_stdio(args.config));
5173 finish_cli_telemetry(session, &outcome);
5174 return outcome;
5175 }
5176 if args.socket {
5177 let outcome = runtime.block_on(run_daemon_socket(DaemonSocketOptions {
5178 socket_path: args.socket_path,
5179 config_path: args.config,
5180 }));
5181 finish_cli_telemetry(session, &outcome);
5182 return outcome;
5183 }
5184 let host = args.host.as_deref().unwrap_or("127.0.0.1");
5191 let port = args.port.unwrap_or(8787);
5192 let outcome = format!("{host}:{port}")
5193 .parse::<SocketAddr>()
5194 .with_context(|| format!("invalid app-server listen address {host}:{port}"))
5195 .and_then(|listen| {
5196 runtime.block_on(run_app_server(AppServerOptions {
5197 listen,
5198 config_path: args.config,
5199 auth_token: args.auth_token.or_else(app_server_token_from_env),
5200 insecure_no_auth: args.insecure_no_auth,
5201 cors_origins: args.cors_origin,
5202 }))
5203 });
5204 finish_cli_telemetry(session, &outcome);
5205 outcome
5206}
5207
5208fn app_server_serve_passthrough(args: &AppServerArgs) -> Vec<String> {
5216 let mut forwarded = vec!["serve".to_string()];
5217 forwarded.push(if args.mobile { "--mobile" } else { "--http" }.to_string());
5218 if let Some(host) = args.host.as_ref() {
5219 forwarded.push("--host".to_string());
5220 forwarded.push(host.clone());
5221 }
5222 if let Some(port) = args.port {
5223 forwarded.push("--port".to_string());
5224 forwarded.push(port.to_string());
5225 }
5226 if let Some(workers) = args.workers {
5227 forwarded.push("--workers".to_string());
5228 forwarded.push(workers.to_string());
5229 }
5230 for origin in &args.cors_origin {
5231 forwarded.push("--cors-origin".to_string());
5232 forwarded.push(origin.clone());
5233 }
5234 if let Some(token) = args.auth_token.as_ref() {
5235 forwarded.push("--auth-token".to_string());
5236 forwarded.push(token.clone());
5237 }
5238 if args.insecure_no_auth {
5239 forwarded.push("--insecure".to_string());
5240 }
5241 if args.qr {
5242 forwarded.push("--qr".to_string());
5243 }
5244 forwarded
5245}
5246
5247fn web_serve_passthrough(args: &WebArgs) -> Vec<String> {
5248 vec![
5249 "serve".to_string(),
5250 "--web".to_string(),
5251 "--port".to_string(),
5252 args.port.to_string(),
5253 ]
5254}
5255
5256fn app_server_token_from_env() -> Option<String> {
5257 std::env::var("CODEWHALE_APP_SERVER_TOKEN")
5258 .ok()
5259 .or_else(|| std::env::var("DEEPSEEK_APP_SERVER_TOKEN").ok())
5260}
5261
5262fn run_mcp_server_command(store: &mut ConfigStore) -> Result<()> {
5263 let persisted = load_mcp_server_definitions(store);
5264 let updated = run_stdio_server(persisted)?;
5265 persist_mcp_server_definitions(store, &updated)
5266}
5267
5268fn load_mcp_server_definitions(store: &ConfigStore) -> Vec<McpServerDefinition> {
5269 let raw = store
5275 .config
5276 .get_raw_string(MCP_SERVER_DEFINITIONS_KEY)
5277 .map(ToOwned::to_owned)
5278 .or_else(|| store.config.get_value(MCP_SERVER_DEFINITIONS_KEY));
5279 let Some(raw) = raw else {
5280 return Vec::new();
5281 };
5282
5283 match parse_mcp_server_definitions(&raw) {
5284 Ok(definitions) => definitions,
5285 Err(err) => {
5286 eprintln!(
5287 "warning: failed to parse persisted MCP server definitions ({MCP_SERVER_DEFINITIONS_KEY}): {err}"
5288 );
5289 Vec::new()
5290 }
5291 }
5292}
5293
5294fn parse_mcp_server_definitions(raw: &str) -> Result<Vec<McpServerDefinition>> {
5295 if let Ok(parsed) = serde_json::from_str::<Vec<McpServerDefinition>>(raw) {
5296 return Ok(parsed);
5297 }
5298
5299 let unwrapped: String = serde_json::from_str(raw).map_err(|_| {
5300 anyhow!("invalid JSON payload at key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted")
5301 })?;
5302 serde_json::from_str::<Vec<McpServerDefinition>>(&unwrapped).map_err(|_| {
5303 anyhow!(
5304 "invalid MCP server definition list in key {MCP_SERVER_DEFINITIONS_KEY}; contents were omitted"
5305 )
5306 })
5307}
5308
5309fn persist_mcp_server_definitions(
5310 store: &mut ConfigStore,
5311 definitions: &[McpServerDefinition],
5312) -> Result<()> {
5313 let encoded =
5314 serde_json::to_string(definitions).context("failed to encode MCP server definitions")?;
5315 store
5316 .config
5317 .set_value(MCP_SERVER_DEFINITIONS_KEY, &encoded)?;
5318 store.save()
5319}
5320
5321#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
5345#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
5346
5347#[derive(Debug)]
5349
5350#[cfg(unix)]
5357#[cfg(not(unix))]
5358
5359#[cfg(windows)]
5366#[cfg(windows)]
5367#[cfg(windows)]
5370unsafe impl Send for ServerChildJob {}
5371
5372fn run_resume_command(
5373 cli: &Cli,
5374 resolved_runtime: &ResolvedRuntimeOptions,
5375 args: TuiPassthroughArgs,
5376) -> Result<()> {
5377 let passthrough = tui_args("resume", args);
5378 if should_pick_resume_in_dispatcher(&passthrough, cfg!(windows)) {
5379 return run_dispatcher_resume_picker(cli, resolved_runtime);
5380 }
5381 run_tui_in_process(cli, resolved_runtime, passthrough)
5382}
5383
5384fn run_dispatcher_resume_picker(
5385 cli: &Cli,
5386 resolved_runtime: &ResolvedRuntimeOptions,
5387) -> Result<()> {
5388 let argv = tui_argv(cli, vec!["sessions".to_string()]);
5389 apply_tui_env(cli, resolved_runtime, &argv);
5390 let code = codewhale_tui::run(argv);
5391 if code != std::process::ExitCode::SUCCESS {
5392 std::process::exit(if code == std::process::ExitCode::SUCCESS {
5393 0
5394 } else {
5395 1
5396 })
5397 }
5398
5399 println!();
5400 println!("Windows note: enter a session id or prefix from the list above.");
5401 println!("You can also run `codewhale resume --last` to skip this prompt.");
5402 print!("Session id/prefix (Enter to cancel): ");
5403 io::stdout().flush()?;
5404
5405 let mut input = String::new();
5406 io::stdin()
5407 .read_line(&mut input)
5408 .context("failed to read session selection")?;
5409 let session_id = input.trim();
5410 if session_id.is_empty() {
5411 bail!("No session selected.");
5412 }
5413
5414 run_tui_in_process(
5415 cli,
5416 resolved_runtime,
5417 vec!["resume".to_string(), session_id.to_string()],
5418 )
5419}
5420
5421fn should_pick_resume_in_dispatcher(passthrough: &[String], is_windows: bool) -> bool {
5422 is_windows && passthrough == ["resume"]
5423}
5424
5425fn run_tui_in_process(
5426 cli: &Cli,
5427 resolved_runtime: &ResolvedRuntimeOptions,
5428 passthrough: Vec<String>,
5429) -> Result<()> {
5430 let argv = tui_argv(cli, passthrough.clone());
5431 apply_tui_env(cli, resolved_runtime, &passthrough);
5432 let code = codewhale_tui::run(argv);
5433 std::process::exit(if code == std::process::ExitCode::SUCCESS {
5434 0
5435 } else {
5436 1
5437 })
5438}
5439
5440fn run_tui_server_in_process(
5441 cli: &Cli,
5442 resolved_runtime: &ResolvedRuntimeOptions,
5443 passthrough: Vec<String>,
5444) -> Result<()> {
5445 let argv = tui_argv(cli, passthrough.clone());
5446 apply_tui_env(cli, resolved_runtime, &passthrough);
5447 let code = codewhale_tui::run(argv);
5448 std::process::exit(if code == std::process::ExitCode::SUCCESS {
5449 0
5450 } else {
5451 1
5452 })
5453}
5454
5455fn tui_argv(cli: &Cli, passthrough: Vec<String>) -> Vec<String> {
5456 let mut args = Vec::new();
5457 args.push("codewhale".to_string());
5458 if let Some(config) = cli.config.as_deref() {
5459 args.push("--config".to_string());
5460 args.push(config.display().to_string());
5461 }
5462 if let Some(profile) = cli.profile.as_ref() {
5463 args.push("--profile".to_string());
5464 args.push(profile.clone());
5465 }
5466 if let Some(workspace) = cli.workspace.as_deref() {
5467 args.push("--workspace".to_string());
5468 args.push(workspace.display().to_string());
5469 }
5470 if cli.mouse_capture {
5471 args.push("--mouse-capture".to_string());
5472 }
5473 if cli.no_mouse_capture {
5474 args.push("--no-mouse-capture".to_string());
5475 }
5476 if cli.skip_onboarding {
5477 args.push("--skip-onboarding".to_string());
5478 }
5479 if cli.fresh {
5480 args.push("--fresh".to_string());
5481 }
5482 if cli.no_project_config {
5483 args.push("--no-project-config".to_string());
5484 }
5485 args.extend(passthrough);
5486 args
5487}
5488
5489fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrough: &[String]) {
5490 let mut verbosity = if cli.profile.is_some() {
5491 cli.verbosity.clone()
5492 } else {
5493 resolved_runtime.verbosity.clone()
5494 };
5495 if verbosity.is_none()
5496 && passthrough
5497 .iter()
5498 .any(|arg| matches!(arg.as_str(), "exec" | "eval"))
5499 {
5500 verbosity = Some("concise".to_string());
5501 }
5502 let uses_raw_tui_provider = cli
5503 .provider
5504 .as_deref()
5505 .is_some_and(|provider| builtin_provider_arg(provider).is_none());
5506 let keyring_bridge_provider = resolved_runtime.provider;
5507 let keyring_bridge_api_key = resolved_runtime.api_key.as_ref();
5508 let keyring_bridge_source = resolved_runtime.api_key_source;
5509 if let Some(provider) = cli.provider.as_deref() {
5510 let provider = builtin_provider_arg(provider).map_or_else(
5511 || provider.to_string(),
5512 |provider| provider.as_str().to_string(),
5513 );
5514 unsafe {
5515 std::env::set_var("CODEWHALE_PROVIDER", &provider);
5516 std::env::set_var("DEEPSEEK_PROVIDER", provider);
5517 }
5518 }
5519 if !(uses_raw_tui_provider
5520 || (cli.profile.is_some()
5521 && matches!(resolved_runtime.provider_source, ProviderSource::Config)))
5522 && matches!(keyring_bridge_source, Some(RuntimeApiKeySource::Keyring))
5523 && let Some(api_key) = keyring_bridge_api_key
5524 {
5525 unsafe {
5526 for var in provider_env_vars(keyring_bridge_provider) {
5527 std::env::set_var(var, api_key);
5528 }
5529 std::env::set_var(
5530 codewhale_config::CLI_API_KEY_SOURCE_ENV,
5531 RuntimeApiKeySource::Keyring.as_env_value(),
5532 );
5533 }
5534 }
5535 if let Some(model) = cli.model.as_ref() {
5536 unsafe {
5537 std::env::set_var("CODEWHALE_MODEL", model);
5538 std::env::set_var("DEEPSEEK_MODEL", model);
5539 }
5540 }
5541 if let Some(output_mode) = cli.output_mode.as_ref() {
5542 unsafe {
5543 std::env::set_var("CODEWHALE_OUTPUT_MODE", output_mode);
5544 std::env::set_var("DEEPSEEK_OUTPUT_MODE", output_mode);
5545 }
5546 }
5547 if let Some(v) = verbosity.as_ref() {
5548 unsafe {
5549 std::env::set_var("CODEWHALE_VERBOSITY", v);
5550 std::env::set_var("DEEPSEEK_VERBOSITY", v);
5551 }
5552 }
5553 if let Some(log_level) = cli.log_level.as_ref() {
5554 unsafe {
5555 std::env::set_var("CODEWHALE_LOG_LEVEL", log_level);
5556 std::env::set_var("DEEPSEEK_LOG_LEVEL", log_level);
5557 }
5558 }
5559 let telemetry = resolved_runtime.telemetry.to_string();
5560 unsafe {
5561 std::env::set_var("CODEWHALE_TELEMETRY", &telemetry);
5562 std::env::set_var("DEEPSEEK_TELEMETRY", &telemetry);
5563 }
5564 let floor = cli.telemetry == Some(false) || codewhale_config::telemetry_floor_in_force();
5565 unsafe {
5566 std::env::set_var(
5567 codewhale_config::TELEMETRY_FLOOR_ENV,
5568 if floor { "1" } else { "0" },
5569 );
5570 }
5571 if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
5572 unsafe {
5573 std::env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
5574 std::env::set_var("DEEPSEEK_TELEMETRY_ENDPOINT", endpoint);
5575 }
5576 }
5577 if let Some(policy) = cli.approval_policy.as_ref() {
5578 unsafe {
5579 std::env::set_var("CODEWHALE_APPROVAL_POLICY", policy);
5580 std::env::set_var("DEEPSEEK_APPROVAL_POLICY", policy);
5581 }
5582 }
5583 if let Some(mode) = cli.sandbox_mode.as_ref() {
5584 unsafe {
5585 std::env::set_var("CODEWHALE_SANDBOX_MODE", mode);
5586 std::env::set_var("DEEPSEEK_SANDBOX_MODE", mode);
5587 }
5588 }
5589 if cli.yolo {
5590 unsafe {
5591 std::env::set_var("CODEWHALE_YOLO", "true");
5592 }
5593 }
5594 if let Some(api_key) = cli.api_key.as_ref() {
5595 unsafe {
5596 std::env::set_var(codewhale_config::CLI_API_KEY_ENV, api_key);
5597 }
5598 if !uses_raw_tui_provider && (cli.profile.is_none() || cli.provider.is_some()) {
5599 unsafe {
5600 for var in provider_env_vars(resolved_runtime.provider) {
5601 std::env::set_var(var, api_key);
5602 }
5603 }
5604 }
5605 unsafe {
5606 std::env::set_var(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli");
5607 }
5608 }
5609 if let Some(base_url) = cli.base_url.as_ref() {
5610 unsafe {
5611 std::env::set_var("CODEWHALE_BASE_URL", base_url);
5612 std::env::set_var("DEEPSEEK_BASE_URL", base_url);
5613 }
5614 }
5615}
5616
5617fn run_providers_command(args: ProvidersArgs) -> Result<()> {
5625 match args.command {
5626 ProvidersCommand::Export { json } => {
5627 if !json {
5628 bail!("`codewhale providers export` requires `--json`");
5629 }
5630 let export = ProvidersExport::from_registry(env!("CODEWHALE_BUILD_VERSION"));
5631 serde_json::to_writer_pretty(io::stdout(), &export)
5632 .context("failed to write providers export")?;
5633 println!();
5634 Ok(())
5635 }
5636 }
5637}
5638
5639fn run_metrics_command(args: MetricsArgs) -> Result<()> {
5640 let since = match args.since.as_deref() {
5641 Some(s) => {
5642 Some(metrics::parse_since(s).with_context(|| format!("invalid --since value: {s:?}"))?)
5643 }
5644 None => None,
5645 };
5646 metrics::run(metrics::MetricsArgs {
5647 json: args.json,
5648 since,
5649 })
5650}
5651
5652fn read_api_key_from_stdin() -> Result<String> {
5653 let mut input = String::new();
5654 io::stdin()
5655 .read_to_string(&mut input)
5656 .context("failed to read api key from stdin")?;
5657 let key = input.trim().to_string();
5658 if key.is_empty() {
5659 bail!("empty API key provided");
5660 }
5661 Ok(key)
5662}
5663
5664#[cfg(test)]
5665mod tests {
5666 use super::*;
5667 use clap::error::ErrorKind;
5668 use codewhale_config::{ModelSource, ProviderSource};
5669 use std::ffi::OsString;
5670 use std::sync::{Mutex, OnceLock};
5671
5672 fn parse_ok(argv: &[&str]) -> Cli {
5673 Cli::try_parse_from(argv).unwrap_or_else(|err| panic!("parse failed for {argv:?}: {err}"))
5674 }
5675
5676 fn help_for(argv: &[&str]) -> String {
5677 let err = Cli::try_parse_from(argv).expect_err("expected --help to short-circuit parsing");
5678 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
5679 err.to_string()
5680 }
5681
5682 pub(crate) fn env_lock() -> std::sync::MutexGuard<'static, ()> {
5683 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
5684 LOCK.get_or_init(|| Mutex::new(()))
5685 .lock()
5686 .unwrap_or_else(|p| p.into_inner())
5687 }
5688
5689 pub(crate) struct ScopedEnvVar {
5690 name: &'static str,
5691 previous: Option<OsString>,
5692 }
5693
5694 impl ScopedEnvVar {
5695 pub(crate) fn set(name: &'static str, value: &str) -> Self {
5696 let previous = std::env::var_os(name);
5697 unsafe { std::env::set_var(name, value) };
5700 Self { name, previous }
5701 }
5702
5703 pub(crate) fn remove(name: &'static str) -> Self {
5704 let previous = std::env::var_os(name);
5705 unsafe { std::env::remove_var(name) };
5708 Self { name, previous }
5709 }
5710 }
5711
5712 impl Drop for ScopedEnvVar {
5713 fn drop(&mut self) {
5714 unsafe {
5716 if let Some(previous) = self.previous.take() {
5717 std::env::set_var(self.name, previous.clone());
5718 } else {
5719 std::env::remove_var(self.name);
5720 }
5721 }
5722 }
5723 }
5724
5725 #[derive(Default)]
5726 struct RecordingKeyringStore {
5727 gets: Mutex<Vec<String>>,
5728 values: Mutex<std::collections::BTreeMap<String, String>>,
5729 }
5730
5731 impl RecordingKeyringStore {
5732 fn set_value(&self, key: &str, value: &str) {
5733 self.values
5734 .lock()
5735 .expect("recording values lock")
5736 .insert(key.to_string(), value.to_string());
5737 }
5738
5739 fn queried(&self) -> Vec<String> {
5740 self.gets.lock().expect("recording gets lock").clone()
5741 }
5742 }
5743
5744 impl codewhale_secrets::KeyringStore for RecordingKeyringStore {
5745 fn get(
5746 &self,
5747 key: &str,
5748 ) -> std::result::Result<Option<String>, codewhale_secrets::SecretsError> {
5749 self.gets
5750 .lock()
5751 .expect("recording gets lock")
5752 .push(key.to_string());
5753 Ok(self
5754 .values
5755 .lock()
5756 .expect("recording values lock")
5757 .get(key)
5758 .cloned())
5759 }
5760
5761 fn set(
5762 &self,
5763 key: &str,
5764 value: &str,
5765 ) -> std::result::Result<(), codewhale_secrets::SecretsError> {
5766 self.set_value(key, value);
5767 Ok(())
5768 }
5769
5770 fn delete(&self, key: &str) -> std::result::Result<(), codewhale_secrets::SecretsError> {
5771 self.values
5772 .lock()
5773 .expect("recording values lock")
5774 .remove(key);
5775 Ok(())
5776 }
5777
5778 fn backend_name(&self) -> &'static str {
5779 "recording"
5780 }
5781 }
5782
5783 fn install_fake_tui_binary() -> (tempfile::TempDir, ScopedEnvVar) {
5784 let dir = tempfile::TempDir::new().expect("tempdir");
5785 let custom = dir
5786 .path()
5787 .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
5788 std::fs::write(&custom, b"").unwrap();
5789 let custom_str = custom.to_string_lossy();
5790 let bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
5791 (dir, bin)
5792 }
5793
5794 fn resolved_runtime_for_test(
5795 provider: ProviderKind,
5796 provider_source: ProviderSource,
5797 ) -> ResolvedRuntimeOptions {
5798 ResolvedRuntimeOptions {
5799 provider,
5800 provider_source,
5801 model: "test-model".to_string(),
5802 model_source: ModelSource::ProviderDefault,
5803 api_key: None,
5804 api_key_source: None,
5805 base_url: "http://localhost:8000/v1".to_string(),
5806 auth_mode: None,
5807 insecure_skip_tls_verify: false,
5808 output_mode: None,
5809 log_level: None,
5810 telemetry: false,
5811 telemetry_source: codewhale_config::TelemetrySource::Default,
5812 telemetry_explicit_off: false,
5813 telemetry_endpoint: None,
5814 approval_policy: None,
5815 sandbox_mode: None,
5816 yolo: None,
5817 verbosity: None,
5818 http_headers: std::collections::BTreeMap::new(),
5819 route: None,
5820 }
5821 }
5822
5823 #[test]
5824 fn tui_credential_handoff_stays_with_the_selected_provider() {
5825 let _lock = env_lock();
5826 let mut names = ProviderKind::ALL
5827 .into_iter()
5828 .flat_map(provider_env_vars)
5829 .copied()
5830 .collect::<Vec<_>>();
5831 names.extend([
5832 codewhale_config::CLI_API_KEY_ENV,
5833 codewhale_config::CLI_API_KEY_SOURCE_ENV,
5834 codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV,
5835 "CODEWHALE_PROVIDER",
5836 "DEEPSEEK_PROVIDER",
5837 "CODEWHALE_TELEMETRY",
5838 "DEEPSEEK_TELEMETRY",
5839 codewhale_config::TELEMETRY_FLOOR_ENV,
5840 ]);
5841 names.sort_unstable();
5842 names.dedup();
5843 let _clean_env = names
5844 .into_iter()
5845 .map(ScopedEnvVar::remove)
5846 .collect::<Vec<_>>();
5847 let _deepseek_key = ScopedEnvVar::set("DEEPSEEK_API_KEY", "existing-deepseek-key");
5848
5849 let clear_bridge = || {
5850 unsafe {
5853 for var in ProviderKind::ALL
5854 .into_iter()
5855 .flat_map(provider_env_vars)
5856 .filter(|var| **var != "DEEPSEEK_API_KEY")
5857 {
5858 std::env::remove_var(var);
5859 }
5860 std::env::remove_var(codewhale_config::CLI_API_KEY_ENV);
5861 std::env::remove_var(codewhale_config::CLI_API_KEY_SOURCE_ENV);
5862 std::env::remove_var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV);
5863 }
5864 };
5865
5866 for (provider_arg, provider) in [
5867 ("nvidia-nim", ProviderKind::NvidiaNim),
5868 ("openrouter", ProviderKind::Openrouter),
5869 ("anthropic", ProviderKind::Anthropic),
5870 ] {
5871 clear_bridge();
5872 let keyring_key = format!("{provider_arg}-keyring-key");
5873 let mut keyring_runtime = resolved_runtime_for_test(provider, ProviderSource::Cli);
5874 keyring_runtime.api_key = Some(keyring_key.clone());
5875 keyring_runtime.api_key_source = Some(RuntimeApiKeySource::Keyring);
5876 let keyring_cli = parse_ok(&["codewhale", "--provider", provider_arg]);
5877
5878 apply_tui_env(&keyring_cli, &keyring_runtime, &[]);
5879
5880 assert_eq!(
5881 std::env::var("DEEPSEEK_API_KEY").as_deref(),
5882 Ok("existing-deepseek-key"),
5883 "{provider_arg} keyring handoff replaced DeepSeek's credential"
5884 );
5885 for var in provider_env_vars(provider) {
5886 assert_eq!(
5887 std::env::var(var).as_deref(),
5888 Ok(keyring_key.as_str()),
5889 "{provider_arg} keyring handoff missed {var}"
5890 );
5891 }
5892 assert_eq!(
5893 std::env::var(codewhale_config::CLI_API_KEY_SOURCE_ENV).as_deref(),
5894 Ok("keyring")
5895 );
5896 assert!(std::env::var(codewhale_config::CLI_API_KEY_ENV).is_err());
5897 assert!(
5898 std::env::var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV).is_err(),
5899 "new dispatchers must not write the retired vendor-named marker"
5900 );
5901
5902 clear_bridge();
5903 let explicit_key = format!("{provider_arg}-explicit-key");
5904 let explicit_cli = parse_ok(&[
5905 "codewhale",
5906 "--provider",
5907 provider_arg,
5908 "--api-key",
5909 explicit_key.as_str(),
5910 ]);
5911 let explicit_runtime = resolved_runtime_for_test(provider, ProviderSource::Cli);
5912
5913 apply_tui_env(&explicit_cli, &explicit_runtime, &[]);
5914
5915 assert_eq!(
5916 std::env::var("DEEPSEEK_API_KEY").as_deref(),
5917 Ok("existing-deepseek-key"),
5918 "{provider_arg} explicit CLI credential handoff replaced DeepSeek's credential"
5919 );
5920 for var in provider_env_vars(provider) {
5921 assert_eq!(
5922 std::env::var(var).as_deref(),
5923 Ok(explicit_key.as_str()),
5924 "{provider_arg} explicit CLI credential handoff missed {var}"
5925 );
5926 }
5927 assert_eq!(
5928 std::env::var(codewhale_config::CLI_API_KEY_ENV).as_deref(),
5929 Ok(explicit_key.as_str())
5930 );
5931 assert_eq!(
5932 std::env::var(codewhale_config::CLI_API_KEY_SOURCE_ENV).as_deref(),
5933 Ok("cli")
5934 );
5935 assert!(std::env::var(codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV).is_err());
5936 }
5937 }
5938
5939 #[test]
5940 fn yolo_flag_writes_only_the_codewhale_env_var() {
5941 let _lock = env_lock();
5942 let _guards = [
5943 ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
5944 ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
5945 ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
5946 ScopedEnvVar::remove("CODEWHALE_YOLO"),
5947 ScopedEnvVar::remove("DEEPSEEK_YOLO"),
5948 ];
5949
5950 let cli = parse_ok(&["codewhale", "--yolo"]);
5951 let runtime = resolved_runtime_for_test(ProviderKind::NvidiaNim, ProviderSource::Cli);
5952 apply_tui_env(&cli, &runtime, &[]);
5953
5954 assert_eq!(
5955 std::env::var("CODEWHALE_YOLO").as_deref(),
5956 Ok("true"),
5957 "--yolo must still enable the posture via CODEWHALE_YOLO"
5958 );
5959 assert!(
5960 std::env::var("DEEPSEEK_YOLO").is_err(),
5961 "--yolo must not write the retired DEEPSEEK_YOLO alias (#5443)"
5962 );
5963 }
5964
5965 #[test]
5966 fn clap_command_definition_is_consistent() {
5967 Cli::command().debug_assert();
5968 }
5969
5970 #[test]
5977 fn anyhow_chain_surfaces_toml_parse_cause() {
5978 use anyhow::Context;
5979 let inner = anyhow::anyhow!("TOML parse error at line 1, column 20");
5980 let err = Err::<(), _>(inner)
5981 .context("failed to parse config at C:\\Users\\test\\.deepseek\\config.toml")
5982 .unwrap_err();
5983
5984 assert_eq!(
5986 err.to_string(),
5987 "failed to parse config at C:\\Users\\test\\.deepseek\\config.toml",
5988 );
5989
5990 let causes: Vec<String> = err.chain().skip(1).map(ToString::to_string).collect();
5992 assert_eq!(causes, vec!["TOML parse error at line 1, column 20"]);
5993 }
5994
5995 #[test]
5996 fn malformed_persisted_mcp_json_omits_secret_contents_and_keys() {
5997 let secret = "sentinel";
5998 let raw =
5999 format!(r#"[{{"name":"private","env":{{"PRIVATE_TOKEN":"{secret}"}} trailing-junk}}]"#);
6000 let error = parse_mcp_server_definitions(&raw).expect_err("malformed JSON must fail");
6001 let diagnostic = format!("{error:#}");
6002 assert!(!diagnostic.contains(secret), "{diagnostic}");
6003 assert!(!diagnostic.contains("PRIVATE_TOKEN"), "{diagnostic}");
6004 assert!(diagnostic.contains("contents were omitted"), "{diagnostic}");
6005 }
6006
6007 #[test]
6008 fn parses_config_command_matrix() {
6009 let cli = parse_ok(&["deepseek", "config", "get", "provider"]);
6010 assert!(matches!(
6011 cli.command,
6012 Some(Commands::Config(ConfigArgs {
6013 command: ConfigCommand::Get { ref key }
6014 })) if key == "provider"
6015 ));
6016
6017 let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
6018 assert!(matches!(
6019 cli.command,
6020 Some(Commands::Config(ConfigArgs {
6021 command: ConfigCommand::Set { ref key, ref value }
6022 })) if key == "model" && value == "deepseek-v4-flash"
6023 ));
6024
6025 let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
6026 assert!(matches!(
6027 cli.command,
6028 Some(Commands::Config(ConfigArgs {
6029 command: ConfigCommand::Unset { ref key }
6030 })) if key == "model"
6031 ));
6032
6033 assert!(matches!(
6034 parse_ok(&["deepseek", "config", "list"]).command,
6035 Some(Commands::Config(ConfigArgs {
6036 command: ConfigCommand::List
6037 }))
6038 ));
6039 assert!(matches!(
6040 parse_ok(&["deepseek", "config", "path"]).command,
6041 Some(Commands::Config(ConfigArgs {
6042 command: ConfigCommand::Path
6043 }))
6044 ));
6045 assert!(matches!(
6046 parse_ok(&["codewhale", "config", "edit"]).command,
6047 Some(Commands::Config(ConfigArgs {
6048 command: ConfigCommand::Edit
6049 }))
6050 ));
6051 assert!(matches!(
6052 parse_ok(&["codewhale", "config", "doctor"]).command,
6053 Some(Commands::Config(ConfigArgs {
6054 command: ConfigCommand::Doctor
6055 }))
6056 ));
6057 assert!(matches!(
6058 parse_ok(&["codewhale", "config", "dump"]).command,
6059 Some(Commands::Config(ConfigArgs {
6060 command: ConfigCommand::Dump
6061 }))
6062 ));
6063 }
6064
6065 #[test]
6066 fn parses_repeatable_global_set_overrides() {
6067 let cli = parse_ok(&[
6068 "codewhale",
6069 "--set",
6070 "verbosity=concise",
6071 "--set",
6072 "model=deepseek-v4-flash",
6073 "config",
6074 "get",
6075 "verbosity",
6076 ]);
6077 assert_eq!(
6078 cli.overrides,
6079 vec![
6080 "verbosity=concise".to_string(),
6081 "model=deepseek-v4-flash".to_string()
6082 ]
6083 );
6084 }
6085
6086 #[test]
6087 fn config_doctor_is_clean_on_minimal_config() {
6088 let temp = tempfile::tempdir().expect("tempdir");
6089 let path = temp.path().join("config.toml");
6090 write_config_fixture(&path, "verbosity = \"concise\"\n");
6091 let store = ConfigStore::load(Some(path)).expect("load fixture");
6092 run_config_doctor(&store).expect("clean doctor");
6093 }
6094
6095 #[test]
6096 fn config_doctor_preserves_keys_owned_by_other_readers() {
6097 let temp = tempfile::tempdir().expect("tempdir");
6098 let path = temp.path().join("config.toml");
6099 write_config_fixture(&path, "zzz_unknown = 1\n");
6100 let store = ConfigStore::load(Some(path)).expect("load fixture");
6101 assert!(!store.config.extras.is_empty());
6102 run_config_doctor(&store).expect("extras do not establish unsupported settings");
6103 }
6104
6105 #[test]
6106 fn config_doctor_fails_on_empty_secret_and_bad_url() {
6107 let temp = tempfile::tempdir().expect("tempdir");
6108 let path = temp.path().join("config.toml");
6109 write_config_fixture(&path, "api_key = \"\"\nbase_url = \"gopher://x\"\n");
6110 let store = ConfigStore::load(Some(path)).expect("load fixture");
6111 let error = run_config_doctor(&store).expect_err("doctor must fail");
6112 let message = format!("{error:#}");
6113 assert!(
6114 message.contains("api_key") && message.contains("empty"),
6115 "{message}"
6116 );
6117 assert!(
6118 message.contains("base_url") && message.contains("http"),
6119 "{message}"
6120 );
6121 }
6122
6123 #[test]
6124 fn per_run_overrides_apply_in_memory_and_never_save() {
6125 let temp = tempfile::tempdir().expect("tempdir");
6126 let path = temp.path().join("config.toml");
6127 write_config_fixture(&path, "verbosity = \"normal\"\n");
6128 let mut store = ConfigStore::load(Some(path.clone())).expect("load fixture");
6129 apply_per_run_overrides(&mut store, &["verbosity=concise".to_string()])
6130 .expect("overlay applies");
6131 assert_eq!(store.config.verbosity.as_deref(), Some("concise"));
6132 let error = apply_per_run_overrides(&mut store, &["no-equals-here".to_string()])
6133 .expect_err("missing = must fail");
6134 assert!(format!("{error:#}").contains("KEY=VALUE"));
6135 let reloaded = ConfigStore::load(Some(path)).expect("reload");
6137 assert_eq!(reloaded.config.verbosity.as_deref(), Some("normal"));
6138 }
6139
6140 #[test]
6141 fn unsupported_nested_config_set_preserves_original_file_bytes() {
6142 let temp = tempfile::tempdir().unwrap();
6143 let path = temp.path().join("config.toml");
6144 let original =
6145 "# Keep this comment and spacing\n[tools]\nuser_input_timeout_seconds = 7 # fixture\n";
6146 write_config_fixture(&path, original);
6147 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
6148 let err = run_config_command(
6149 &mut store,
6150 ConfigCommand::Set {
6151 key: "tools.user_input_timeout_seconds".into(),
6152 value: "0".into(),
6153 },
6154 false,
6155 &[],
6156 )
6157 .unwrap_err();
6158 assert!(err.to_string().contains("[tools]"));
6159 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
6160 }
6161
6162 #[test]
6163 fn mutating_config_commands_refuse_per_run_overrides() {
6164 let temp = tempfile::tempdir().expect("tempdir");
6165 let path = temp.path().join("config.toml");
6166 write_config_fixture(&path, "verbosity = \"normal\"\n");
6167 let mut store = ConfigStore::load(Some(path)).expect("load fixture");
6168 let overrides = vec!["verbosity=concise".to_string()];
6169 let error = run_config_command(
6170 &mut store,
6171 ConfigCommand::Set {
6172 key: "verbosity".to_string(),
6173 value: "concise".to_string(),
6174 },
6175 false,
6176 &overrides,
6177 )
6178 .expect_err("set with --set must refuse");
6179 assert!(format!("{error:#}").contains("--set"), "{error:#}");
6180 run_config_command(&mut store, ConfigCommand::List, false, &overrides)
6182 .expect("list with --set");
6183 }
6184
6185 fn config_dispatch_from(
6186 argv: &[OsString],
6187 cwd: &Path,
6188 ) -> (Option<PathBuf>, ConfigCommand, bool) {
6189 let matches = Cli::command()
6190 .try_get_matches_from(argv.iter().cloned())
6191 .unwrap_or_else(|error| panic!("config command should parse: {error}"));
6192 let project_bundle_scope = config_command_targets_project(&matches);
6193 let cli = Cli::from_arg_matches(&matches)
6194 .unwrap_or_else(|error| panic!("config command should decode: {error}"));
6195 let selected_path = config_store_path_for_dispatch(cli.config, project_bundle_scope, cwd);
6196 let Some(Commands::Config(ConfigArgs { command })) = cli.command else {
6197 panic!("expected config command");
6198 };
6199 (selected_path, command, project_bundle_scope)
6200 }
6201
6202 fn write_config_fixture(path: &Path, body: &str) {
6203 std::fs::create_dir_all(path.parent().expect("config should have a parent"))
6204 .expect("create config parent");
6205 std::fs::write(path, body).expect("write config fixture");
6206 }
6207
6208 #[test]
6209 fn project_config_dispatch_prefers_current_app_dir_and_falls_back_to_legacy() {
6210 let temp = tempfile::tempdir().expect("tempdir");
6211 let workspace = temp.path().join("workspace");
6212 let current = workspace.join(".codewhale/config.toml");
6213 let legacy = workspace.join(".deepseek/config.toml");
6214
6215 std::fs::create_dir_all(&workspace).expect("workspace");
6217 assert_eq!(
6218 config_store_path_for_dispatch(None, true, &workspace),
6219 Some(current.clone())
6220 );
6221
6222 write_config_fixture(&legacy, "verbosity = \"legacy\"\n");
6224 assert_eq!(
6225 config_store_path_for_dispatch(None, true, &workspace),
6226 Some(legacy.clone())
6227 );
6228
6229 write_config_fixture(¤t, "verbosity = \"current\"\n");
6231 assert_eq!(
6232 config_store_path_for_dispatch(None, true, &workspace),
6233 Some(current.clone())
6234 );
6235
6236 let explicit = temp.path().join("explicit.toml");
6238 assert_eq!(
6239 config_store_path_for_dispatch(Some(explicit.clone()), true, &workspace),
6240 Some(explicit)
6241 );
6242 assert_eq!(
6243 config_store_path_for_dispatch(None, false, &workspace),
6244 None
6245 );
6246 }
6247
6248 #[test]
6249 fn project_config_import_dispatches_to_the_cwd_document() {
6250 let temp = tempfile::tempdir().expect("tempdir");
6251 let workspace = temp.path().join("workspace");
6252 std::fs::create_dir_all(workspace.join(".git")).expect("create checkout marker");
6253 let project_path = workspace.join(".codewhale/config.toml");
6254 let global_path = temp.path().join("global-config.toml");
6255 write_config_fixture(&project_path, "verbosity = \"project-before\"\n");
6256 write_config_fixture(&global_path, "verbosity = \"global-only\"\n");
6257
6258 let bundle_path = temp.path().join("project-bundle.toml");
6259 std::fs::write(
6260 &bundle_path,
6261 r#"schema_version = 1
6262kind = "codewhale.portable-config"
6263
6264[project]
6265verbosity = "project-imported"
6266"#,
6267 )
6268 .expect("write project bundle");
6269 let argv = [
6270 OsString::from("codewhale"),
6271 OsString::from("config"),
6272 OsString::from("import"),
6273 bundle_path.as_os_str().to_owned(),
6274 OsString::from("--yes"),
6275 OsString::from("--project"),
6276 ];
6277 let (selected_path, command, project_bundle_scope) =
6278 config_dispatch_from(&argv, &workspace);
6279 assert_eq!(selected_path.as_deref(), Some(project_path.as_path()));
6280
6281 let mut store = ConfigStore::load(selected_path).expect("load selected project config");
6282 run_config_command(&mut store, command, project_bundle_scope, &[])
6283 .expect("import project bundle");
6284 let project = ConfigStore::load(Some(project_path.clone())).expect("reload project");
6285 let global = ConfigStore::load(Some(global_path.clone())).expect("reload global");
6286 assert_eq!(
6287 project.config.verbosity.as_deref(),
6288 Some("project-imported")
6289 );
6290 assert_eq!(global.config.verbosity.as_deref(), Some("global-only"));
6291
6292 let explicit_argv = [
6293 OsString::from("codewhale"),
6294 OsString::from("--config"),
6295 global_path.as_os_str().to_owned(),
6296 OsString::from("config"),
6297 OsString::from("import"),
6298 bundle_path.as_os_str().to_owned(),
6299 OsString::from("--yes"),
6300 OsString::from("--project"),
6301 ];
6302 let global_before = std::fs::read(&global_path).expect("read global before refusal");
6303 let (selected_path, command, project_bundle_scope) =
6304 config_dispatch_from(&explicit_argv, &workspace);
6305 assert_eq!(selected_path.as_deref(), Some(global_path.as_path()));
6306 let mut explicit_store =
6307 ConfigStore::load(selected_path).expect("load explicit global config");
6308 let error = run_config_command(&mut explicit_store, command, project_bundle_scope, &[])
6309 .expect_err("project import must reject an explicit non-workspace config");
6310 assert!(
6311 error
6312 .to_string()
6313 .contains("--project requires a workspace config"),
6314 "{error:#}"
6315 );
6316 assert_eq!(
6317 std::fs::read(&global_path).expect("read global after refusal"),
6318 global_before
6319 );
6320 }
6321
6322 #[test]
6323 fn project_config_export_reads_the_cwd_document() {
6324 let temp = tempfile::tempdir().expect("tempdir");
6325 let workspace = temp.path().join("workspace");
6326 std::fs::create_dir_all(workspace.join(".git")).expect("create checkout marker");
6327 let project_path = workspace.join(".codewhale/config.toml");
6328 let global_path = temp.path().join("global-config.toml");
6329 let output_path = temp.path().join("portable.toml");
6330 write_config_fixture(&project_path, "verbosity = \"project-only\"\n");
6331 write_config_fixture(&global_path, "verbosity = \"global-only\"\n");
6332
6333 let argv = [
6334 OsString::from("codewhale"),
6335 OsString::from("config"),
6336 OsString::from("export"),
6337 OsString::from("--portable"),
6338 OsString::from("--project"),
6339 OsString::from("--out"),
6340 output_path.as_os_str().to_owned(),
6341 ];
6342 let (selected_path, command, project_bundle_scope) =
6343 config_dispatch_from(&argv, &workspace);
6344 assert_eq!(selected_path.as_deref(), Some(project_path.as_path()));
6345
6346 let mut store = ConfigStore::load(selected_path).expect("load selected project config");
6347 run_config_command(&mut store, command, project_bundle_scope, &[])
6348 .expect("export project bundle");
6349 let body = std::fs::read_to_string(&output_path).expect("read portable export");
6350 let bundle = config_bundles::parse_bundle_str(&body, "portable.toml")
6351 .expect("parse portable export");
6352 assert_eq!(
6353 bundle
6354 .project
6355 .entries
6356 .get("verbosity")
6357 .and_then(toml::Value::as_str),
6358 Some("project-only")
6359 );
6360 assert!(bundle.global.entries.is_empty());
6361
6362 let explicit_output_path = temp.path().join("explicit-portable.toml");
6363 let explicit_argv = [
6364 OsString::from("codewhale"),
6365 OsString::from("--config"),
6366 global_path.as_os_str().to_owned(),
6367 OsString::from("config"),
6368 OsString::from("export"),
6369 OsString::from("--portable"),
6370 OsString::from("--project"),
6371 OsString::from("--out"),
6372 explicit_output_path.as_os_str().to_owned(),
6373 ];
6374 let global_before = std::fs::read(&global_path).expect("read global before refusal");
6375 let (selected_path, command, project_bundle_scope) =
6376 config_dispatch_from(&explicit_argv, &workspace);
6377 assert_eq!(selected_path.as_deref(), Some(global_path.as_path()));
6378 let mut explicit_store =
6379 ConfigStore::load(selected_path).expect("load explicit global config");
6380 let error = run_config_command(&mut explicit_store, command, project_bundle_scope, &[])
6381 .expect_err("project export must reject an explicit non-workspace config");
6382 assert!(
6383 error
6384 .to_string()
6385 .contains("--project requires a workspace config"),
6386 "{error:#}"
6387 );
6388 assert!(!explicit_output_path.exists());
6389 assert_eq!(
6390 std::fs::read(&global_path).expect("read global after refusal"),
6391 global_before
6392 );
6393 }
6394
6395 #[test]
6396 fn parses_update_beta_flag() {
6397 let cli = parse_ok(&["codewhale", "update"]);
6398 assert!(matches!(
6399 cli.command,
6400 Some(Commands::Update(UpdateArgs {
6401 beta: false,
6402 check: false,
6403 proxy: None
6404 }))
6405 ));
6406
6407 let cli = parse_ok(&["codewhale", "update", "--beta"]);
6408 assert!(matches!(
6409 cli.command,
6410 Some(Commands::Update(UpdateArgs {
6411 beta: true,
6412 check: false,
6413 proxy: None
6414 }))
6415 ));
6416
6417 let cli = parse_ok(&["codewhale", "update", "--check"]);
6418 assert!(matches!(
6419 cli.command,
6420 Some(Commands::Update(UpdateArgs {
6421 beta: false,
6422 check: true,
6423 proxy: None
6424 }))
6425 ));
6426
6427 let cli = parse_ok(&["codewhale", "update", "--proxy", "socks5://127.0.0.1:1080"]);
6428 let Some(Commands::Update(args)) = cli.command else {
6429 panic!("expected update command");
6430 };
6431 assert!(!args.beta);
6432 assert!(!args.check);
6433 assert_eq!(args.proxy.as_deref(), Some("socks5://127.0.0.1:1080"));
6434 }
6435
6436 #[test]
6437 fn parses_model_command_matrix() {
6438 let cli = parse_ok(&["deepseek", "model", "list"]);
6439 assert!(matches!(
6440 cli.command,
6441 Some(Commands::Model(ModelArgs {
6442 command: ModelCommand::List { provider: None }
6443 }))
6444 ));
6445
6446 let cli = parse_ok(&["deepseek", "model", "list", "--provider", "openai"]);
6447 assert!(matches!(
6448 cli.command,
6449 Some(Commands::Model(ModelArgs {
6450 command: ModelCommand::List {
6451 provider: Some(ProviderKind::Openai)
6452 }
6453 }))
6454 ));
6455
6456 let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
6457 assert!(matches!(
6458 cli.command,
6459 Some(Commands::Model(ModelArgs {
6460 command: ModelCommand::Resolve {
6461 model: Some(ref model),
6462 provider: None
6463 }
6464 })) if model == "deepseek-v4-flash"
6465 ));
6466
6467 let cli = parse_ok(&[
6468 "deepseek",
6469 "model",
6470 "resolve",
6471 "--provider",
6472 "deepseek",
6473 "deepseek-v4-pro",
6474 ]);
6475 assert!(matches!(
6476 cli.command,
6477 Some(Commands::Model(ModelArgs {
6478 command: ModelCommand::Resolve {
6479 model: Some(ref model),
6480 provider: Some(ProviderKind::Deepseek)
6481 }
6482 })) if model == "deepseek-v4-pro"
6483 ));
6484
6485 let cli = parse_ok(&["deepseek", "model", "set", "pro"]);
6486 assert!(matches!(
6487 cli.command,
6488 Some(Commands::Model(ModelArgs {
6489 command: ModelCommand::Set { ref model }
6490 })) if model == "pro"
6491 ));
6492 }
6493
6494 #[test]
6495 fn model_command_provider_hint_uses_subcommand_then_top_level_provider() {
6496 assert_eq!(
6497 model_command_provider_hint(None, Some(ProviderKind::Zai)),
6498 Some(ProviderKind::Zai)
6499 );
6500 assert_eq!(
6501 model_command_provider_hint(Some(ProviderKind::Minimax), Some(ProviderKind::Zai)),
6502 Some(ProviderKind::Minimax)
6503 );
6504 assert_eq!(model_command_provider_hint(None, None), None);
6505
6506 let cli = parse_ok(&["codewhale", "--provider", "zai", "model", "list"]);
6507 assert_eq!(cli.provider.as_deref(), Some("zai"));
6508 assert!(matches!(
6509 cli.command,
6510 Some(Commands::Model(ModelArgs {
6511 command: ModelCommand::List { provider: None }
6512 }))
6513 ));
6514 }
6515
6516 #[test]
6517 fn durable_cli_route_edits_use_canonical_config_and_keep_temporary_overrides_unsaved() {
6518 let _env = env_lock();
6519 let home = tempfile::tempdir().expect("isolated home");
6520 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.path().to_string_lossy());
6521 let _config_path = ScopedEnvVar::remove("CODEWHALE_CONFIG_PATH");
6522 let _legacy_config_path = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
6523 let path = home.path().join("config.toml");
6524 std::fs::write(&path, "provider = \"deepseek\"\ndefault_text_model = \"deepseek-v4-pro\"\n[providers.zai]\nmodel = \"GLM-5.2\"\n").unwrap();
6525 let settings_path = home.path().join("settings.toml");
6526 let settings = "default_provider = \"zai\"\n[provider_models]\nzai = \"GLM-5.3\"\n";
6527 std::fs::write(&settings_path, settings).unwrap();
6528 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
6529 let runtime = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
6530 run_model_command(
6531 &mut store,
6532 ModelCommand::Set {
6533 model: "GLM-5.2".into(),
6534 },
6535 None,
6536 &runtime,
6537 )
6538 .unwrap();
6539 assert_eq!(store.config.provider, ProviderKind::Zai);
6540 assert_eq!(store.config.providers.zai.model.as_deref(), Some("GLM-5.2"));
6541 assert_eq!(
6542 store.config.extras["route_preferences_version"].as_integer(),
6543 Some(1)
6544 );
6545
6546 run_config_command(
6547 &mut store,
6548 ConfigCommand::Set {
6549 key: "default_text_model".into(),
6550 value: "GLM-5.1".into(),
6551 },
6552 false,
6553 &[],
6554 )
6555 .unwrap();
6556 assert_eq!(store.config.providers.zai.model.as_deref(), Some("GLM-5.1"));
6557 assert_eq!(
6558 codewhale_tui::route_preferences::get(&path, "model")
6559 .unwrap()
6560 .as_deref(),
6561 Some("GLM-5.1")
6562 );
6563 run_config_command(
6564 &mut store,
6565 ConfigCommand::Unset {
6566 key: "providers.zai.model".into(),
6567 },
6568 false,
6569 &[],
6570 )
6571 .unwrap();
6572 assert!(store.config.providers.zai.model.is_none());
6573 assert_eq!(std::fs::read_to_string(settings_path).unwrap(), settings);
6574
6575 let before = std::fs::read(&path).unwrap();
6576 let overrides = vec!["model=temporary-model".to_string()];
6577 assert!(
6578 run_config_command(
6579 &mut store,
6580 ConfigCommand::Set {
6581 key: "model".into(),
6582 value: "GLM-5.2".into(),
6583 },
6584 false,
6585 &overrides
6586 )
6587 .is_err()
6588 );
6589 apply_per_run_overrides(&mut store, &overrides).unwrap();
6590 assert_eq!(store.config.model.as_deref(), Some("temporary-model"));
6591 assert_eq!(std::fs::read(&path).unwrap(), before);
6592 }
6593
6594 #[test]
6595 fn model_set_canonicalizes_deepseek_vision_aliases() {
6596 for alias in ["flash-vision", "deepseek-v4flashvisionexp"] {
6597 assert_eq!(
6598 canonical_model_for_set(alias),
6599 "deepseek-v4-flash-vision-exp"
6600 );
6601 }
6602 assert_eq!(
6603 canonical_model_for_set("deepseek-v4-flash-vision-exp"),
6604 "deepseek-v4-flash-vision-exp"
6605 );
6606 }
6607
6608 #[test]
6609 fn parses_thread_command_matrix() {
6610 let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]);
6611 assert!(matches!(
6612 cli.command,
6613 Some(Commands::Thread(ThreadArgs {
6614 command: ThreadCommand::List {
6615 all: true,
6616 limit: Some(50)
6617 }
6618 }))
6619 ));
6620
6621 let cli = parse_ok(&["deepseek", "thread", "read", "thread-1"]);
6622 assert!(matches!(
6623 cli.command,
6624 Some(Commands::Thread(ThreadArgs {
6625 command: ThreadCommand::Read { ref thread_id }
6626 })) if thread_id == "thread-1"
6627 ));
6628
6629 let cli = parse_ok(&["deepseek", "thread", "resume", "thread-2"]);
6630 assert!(matches!(
6631 cli.command,
6632 Some(Commands::Thread(ThreadArgs {
6633 command: ThreadCommand::Resume { ref thread_id }
6634 })) if thread_id == "thread-2"
6635 ));
6636
6637 let cli = parse_ok(&["deepseek", "thread", "fork", "thread-3"]);
6638 assert!(matches!(
6639 cli.command,
6640 Some(Commands::Thread(ThreadArgs {
6641 command: ThreadCommand::Fork { ref thread_id }
6642 })) if thread_id == "thread-3"
6643 ));
6644
6645 let cli = parse_ok(&["deepseek", "thread", "archive", "thread-4"]);
6646 assert!(matches!(
6647 cli.command,
6648 Some(Commands::Thread(ThreadArgs {
6649 command: ThreadCommand::Archive { ref thread_id }
6650 })) if thread_id == "thread-4"
6651 ));
6652
6653 let cli = parse_ok(&["deepseek", "thread", "unarchive", "thread-5"]);
6654 assert!(matches!(
6655 cli.command,
6656 Some(Commands::Thread(ThreadArgs {
6657 command: ThreadCommand::Unarchive { ref thread_id }
6658 })) if thread_id == "thread-5"
6659 ));
6660
6661 let cli = parse_ok(&["deepseek", "thread", "set-name", "thread-6", "My Thread"]);
6662 assert!(matches!(
6663 cli.command,
6664 Some(Commands::Thread(ThreadArgs {
6665 command: ThreadCommand::SetName {
6666 ref thread_id,
6667 ref name
6668 }
6669 })) if thread_id == "thread-6" && name == "My Thread"
6670 ));
6671
6672 let cli = parse_ok(&["deepseek", "thread", "clear-name", "thread-7"]);
6673 assert!(matches!(
6674 cli.command,
6675 Some(Commands::Thread(ThreadArgs {
6676 command: ThreadCommand::ClearName { ref thread_id }
6677 })) if thread_id == "thread-7"
6678 ));
6679 }
6680
6681 #[test]
6682 fn parses_sandbox_app_server_and_completion_matrix() {
6683 let cli = parse_ok(&[
6684 "deepseek",
6685 "sandbox",
6686 "check",
6687 "echo hello",
6688 "--ask",
6689 "on-failure",
6690 ]);
6691 assert!(matches!(
6692 cli.command,
6693 Some(Commands::Sandbox(SandboxArgs {
6694 command: SandboxCommand::Check {
6695 ref command,
6696 ask: ApprovalModeArg::OnFailure
6697 }
6698 })) if command == "echo hello"
6699 ));
6700
6701 let cli = parse_ok(&[
6702 "deepseek",
6703 "app-server",
6704 "--host",
6705 "0.0.0.0",
6706 "--port",
6707 "9999",
6708 ]);
6709 assert!(matches!(
6710 cli.command,
6711 Some(Commands::AppServer(AppServerArgs {
6712 host: Some(ref host),
6713 port: Some(9999),
6714 stdio: false,
6715 http: false,
6716 mobile: false,
6717 ..
6718 })) if host == "0.0.0.0"
6719 ));
6720
6721 let cli = parse_ok(&["deepseek", "app-server", "--stdio"]);
6722 assert!(matches!(
6723 cli.command,
6724 Some(Commands::AppServer(AppServerArgs { stdio: true, .. }))
6725 ));
6726
6727 let cli = parse_ok(&["deepseek", "completion", "bash"]);
6728 assert!(matches!(
6729 cli.command,
6730 Some(Commands::Completion { shell: Shell::Bash })
6731 ));
6732 }
6733
6734 fn declared_bin_name() -> String {
6739 let manifest = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"))
6740 .expect("read crates/cli/Cargo.toml");
6741 let bin_section = manifest
6742 .split("[[bin]]")
6743 .nth(1)
6744 .expect("crates/cli/Cargo.toml declares a [[bin]] target");
6745 for line in bin_section.lines() {
6746 let line = line.trim();
6747 if let Some(rest) = line.strip_prefix("name") {
6748 let value = rest.trim_start().trim_start_matches('=').trim();
6749 return value.trim_matches('"').to_string();
6750 }
6751 }
6752 panic!("[[bin]] section has no name key");
6753 }
6754
6755 #[test]
6756 fn completion_bin_name_matches_the_declared_bin_target() {
6757 assert_eq!(
6758 COMPLETION_BIN_NAME,
6759 declared_bin_name(),
6760 "completion scripts must register the binary this crate actually builds"
6761 );
6762 }
6763
6764 #[test]
6769 fn generated_completion_scripts_register_the_published_command_names() {
6770 let bin = declared_bin_name();
6771 let alias = COMPLETION_ALIAS_NAME;
6772
6773 let has_line =
6777 |script: &str, wanted: &str| script.lines().any(|line| line.trim() == wanted);
6778
6779 let bash = render_completion_script(Shell::Bash);
6780 assert!(
6781 has_line(
6782 &bash,
6783 &format!("complete -F _{bin} -o bashdefault -o default {bin}")
6784 ),
6785 "bash script must bind the real binary name:\n{bash}"
6786 );
6787 assert!(
6788 has_line(
6789 &bash,
6790 &format!("complete -F _{bin} -o bashdefault -o default {alias}")
6791 ),
6792 "bash script must bind the {alias} shorthand too"
6793 );
6794
6795 let zsh = render_completion_script(Shell::Zsh);
6796 assert_eq!(
6797 zsh.lines().next(),
6798 Some(format!("#compdef {bin} {alias}").as_str()),
6799 "zsh compdef tag line must list both published command names"
6800 );
6801 assert!(
6802 has_line(&zsh, &format!("compdef _{bin} {bin}")),
6803 "zsh script must bind {bin} on the sourced path"
6804 );
6805 assert!(
6806 has_line(&zsh, &format!("compdef _{bin} {alias}")),
6807 "zsh script must bind {alias} on the sourced path too"
6808 );
6809
6810 let fish = render_completion_script(Shell::Fish);
6811 assert!(
6812 fish.contains(&format!("complete -c {bin} ")),
6813 "fish script must complete the real binary name"
6814 );
6815 assert!(
6816 has_line(&fish, &format!("complete -c {alias} -w {bin}")),
6817 "fish script must wrap the {alias} shorthand onto {bin}"
6818 );
6819
6820 let powershell = render_completion_script(Shell::PowerShell);
6821 assert!(
6822 powershell.contains(&format!(
6823 "Register-ArgumentCompleter -Native -CommandName '{bin}','{alias}'"
6824 )),
6825 "PowerShell script must register both published command names"
6826 );
6827
6828 let elvish = render_completion_script(Shell::Elvish);
6829 assert!(
6830 has_line(
6831 &elvish,
6832 &format!("set edit:completion:arg-completer[{bin}] = {{|@words|")
6833 ),
6834 "elvish script must bind the real binary name:\n{elvish}"
6835 );
6836 assert!(
6837 has_line(
6838 &elvish,
6839 &format!(
6840 "set edit:completion:arg-completer[{alias}] = $edit:completion:arg-completer[{bin}]"
6841 )
6842 ),
6843 "elvish script must alias the {alias} shorthand onto {bin}"
6844 );
6845
6846 for (shell, script) in [
6847 ("bash", &bash),
6848 ("zsh", &zsh),
6849 ("fish", &fish),
6850 ("powershell", &powershell),
6851 ("elvish", &elvish),
6852 ] {
6853 assert!(
6854 !script.contains("codewhale-tui"),
6855 "{shell} completions leaked the in-tree codewhale-tui name (#5526)"
6856 );
6857 }
6858 }
6859
6860 #[test]
6864 fn generated_completion_scripts_cover_the_real_subcommand_surface() {
6865 let bash = render_completion_script(Shell::Bash);
6866 for sub in Cli::command().get_subcommands() {
6867 if sub.is_hide_set() {
6868 continue;
6869 }
6870 let name = sub.get_name();
6871 assert!(
6872 bash.contains(name),
6873 "bash completions omit the `{name}` subcommand"
6874 );
6875 }
6876 }
6877
6878 #[test]
6881 fn completions_is_an_alias_for_completion() {
6882 assert!(matches!(
6883 parse_ok(&["codewhale", "completions", "powershell"]).command,
6884 Some(Commands::Completion {
6885 shell: Shell::PowerShell
6886 })
6887 ));
6888 }
6889
6890 #[test]
6891 fn app_server_transports_are_mutually_exclusive() {
6892 assert!(matches!(
6893 parse_ok(&["deepseek", "app-server", "--http"]).command,
6894 Some(Commands::AppServer(AppServerArgs {
6895 http: true,
6896 mobile: false,
6897 stdio: false,
6898 ..
6899 }))
6900 ));
6901 assert!(matches!(
6902 parse_ok(&["deepseek", "app-server", "--mobile"]).command,
6903 Some(Commands::AppServer(AppServerArgs {
6904 mobile: true,
6905 http: false,
6906 stdio: false,
6907 ..
6908 }))
6909 ));
6910
6911 assert!(matches!(
6912 parse_ok(&["deepseek", "app-server", "--socket"]).command,
6913 Some(Commands::AppServer(AppServerArgs {
6914 socket: true,
6915 socket_path: None,
6916 http: false,
6917 mobile: false,
6918 stdio: false,
6919 ..
6920 }))
6921 ));
6922
6923 for argv in [
6924 ["deepseek", "app-server", "--http", "--mobile"].as_slice(),
6925 ["deepseek", "app-server", "--http", "--stdio"].as_slice(),
6926 ["deepseek", "app-server", "--mobile", "--stdio"].as_slice(),
6927 ["deepseek", "app-server", "--socket", "--stdio"].as_slice(),
6928 ["deepseek", "app-server", "--socket", "--http"].as_slice(),
6929 ["deepseek", "app-server", "--socket", "--mobile"].as_slice(),
6930 ] {
6931 let err = Cli::try_parse_from(argv).expect_err("conflicting transports must fail");
6932 assert_eq!(err.kind(), ErrorKind::ArgumentConflict, "argv={argv:?}");
6933 }
6934 }
6935
6936 #[test]
6937 fn app_server_socket_path_requires_socket() {
6938 let err = Cli::try_parse_from(["deepseek", "app-server", "--socket-path", "/tmp/d.sock"])
6939 .expect_err("--socket-path without --socket must fail");
6940 assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
6941 match parse_ok(&[
6942 "deepseek",
6943 "app-server",
6944 "--socket",
6945 "--socket-path",
6946 "/tmp/d.sock",
6947 ])
6948 .command
6949 {
6950 Some(Commands::AppServer(AppServerArgs {
6951 socket: true,
6952 socket_path: Some(path),
6953 ..
6954 })) => assert_eq!(path, PathBuf::from("/tmp/d.sock")),
6955 other => panic!("unexpected parse: {other:?}"),
6956 }
6957 }
6958
6959 #[test]
6960 fn app_server_qr_requires_mobile() {
6961 let err = Cli::try_parse_from(["deepseek", "app-server", "--qr"])
6962 .expect_err("--qr without --mobile must fail");
6963 assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
6964 assert!(matches!(
6965 parse_ok(&["deepseek", "app-server", "--mobile", "--qr"]).command,
6966 Some(Commands::AppServer(AppServerArgs {
6967 mobile: true,
6968 qr: true,
6969 ..
6970 }))
6971 ));
6972 }
6973
6974 #[test]
6975 fn app_server_serve_passthrough_maps_flags_to_serve() {
6976 let args = AppServerArgs {
6977 http: true,
6978 mobile: false,
6979 stdio: false,
6980 socket: false,
6981 socket_path: None,
6982 qr: false,
6983 host: Some("127.0.0.1".to_string()),
6984 port: Some(9000),
6985 workers: Some(4),
6986 config: None,
6987 auth_token: Some("tok".to_string()),
6988 insecure_no_auth: true,
6989 cors_origin: vec!["http://localhost:5173".to_string()],
6990 };
6991 let argv = app_server_serve_passthrough(&args);
6992 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
6993 assert_eq!(
6995 as_str,
6996 vec![
6997 "serve",
6998 "--http",
6999 "--host",
7000 "127.0.0.1",
7001 "--port",
7002 "9000",
7003 "--workers",
7004 "4",
7005 "--cors-origin",
7006 "http://localhost:5173",
7007 "--auth-token",
7008 "tok",
7009 "--insecure",
7010 ]
7011 );
7012 }
7013
7014 #[test]
7015 fn app_server_serve_passthrough_mobile_defaults_are_minimal() {
7016 let args = AppServerArgs {
7017 http: false,
7018 mobile: true,
7019 stdio: false,
7020 socket: false,
7021 socket_path: None,
7022 qr: true,
7023 host: None,
7024 port: None,
7025 workers: None,
7026 config: None,
7027 auth_token: None,
7028 insecure_no_auth: false,
7029 cors_origin: vec![],
7030 };
7031 let argv = app_server_serve_passthrough(&args);
7032 let as_str: Vec<&str> = argv.iter().map(String::as_str).collect();
7033 assert_eq!(as_str, vec!["serve", "--mobile", "--qr"]);
7036 }
7037
7038 #[test]
7039 fn web_command_is_typed_and_delegates_without_auth_material() {
7040 let cli = parse_ok(&["codewhale", "web", "--port", "9091"]);
7041 let args = match cli.command {
7042 Some(Commands::Web(args)) => args,
7043 other => panic!("expected web command, got {other:?}"),
7044 };
7045 assert_eq!(args.port, 9091);
7046 let forwarded = web_serve_passthrough(&args);
7047 assert_eq!(forwarded, ["serve", "--web", "--port", "9091"]);
7048 assert!(!forwarded.iter().any(|arg| arg.contains("token")));
7049 }
7050
7051 #[test]
7052 fn web_command_defaults_to_runtime_port_and_documents_bootstrap_boundary() {
7053 let cli = parse_ok(&["codewhale", "web"]);
7054 assert!(matches!(
7055 cli.command,
7056 Some(Commands::Web(WebArgs { port: 7878 }))
7057 ));
7058 let help = help_for(&["codewhale", "web", "--help"]);
7059 assert!(help.contains("--port"));
7060 assert!(help.contains("one-time loopback bootstrap"));
7061 assert!(!help.contains("--auth-token"));
7062 }
7063
7064 #[test]
7065 fn serve_help_documents_forwarded_runtime_modes() {
7066 let help = help_for(&["codewhale", "serve", "--help"]);
7067 for flag in ["--http", "--mobile", "--web", "--mcp", "--acp"] {
7068 assert!(
7069 help.contains(flag),
7070 "serve help should document forwarded flag {flag}; help was:\n{help}"
7071 );
7072 }
7073 assert!(help.contains("compatibility"));
7074 }
7075
7076 #[test]
7077 fn parses_direct_tui_command_aliases() {
7078 let cli = parse_ok(&["deepseek", "doctor"]);
7079 assert!(matches!(
7080 cli.command,
7081 Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
7082 ));
7083
7084 let cli = parse_ok(&["deepseek", "models", "--json"]);
7085 assert!(matches!(
7086 cli.command,
7087 Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
7088 ));
7089
7090 let cli = parse_ok(&["deepseek", "resume", "abc123"]);
7091 assert!(matches!(
7092 cli.command,
7093 Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
7094 ));
7095
7096 let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
7097 assert!(matches!(
7098 cli.command,
7099 Some(Commands::Setup(TuiPassthroughArgs { ref args }))
7100 if args == &["--skills", "--local"]
7101 ));
7102
7103 let cli = parse_ok(&["codewhale", "fleet", "init"]);
7104 assert!(cli.prompt.is_empty());
7105 assert!(matches!(
7106 cli.command,
7107 Some(Commands::Fleet(TuiPassthroughArgs { ref args })) if args == &["init"]
7108 ));
7109
7110 let cli = parse_ok(&[
7111 "codewhale",
7112 "fleet",
7113 "run",
7114 "tasks.json",
7115 "--max-workers",
7116 "2",
7117 ]);
7118 assert!(cli.prompt.is_empty());
7119 assert!(matches!(
7120 cli.command,
7121 Some(Commands::Fleet(TuiPassthroughArgs { ref args }))
7122 if args == &["run", "tasks.json", "--max-workers", "2"]
7123 ));
7124
7125 let cli = parse_ok(&[
7126 "codewhale",
7127 "workflow",
7128 "run",
7129 "stopship",
7130 "--fleet",
7131 "stopship",
7132 "--runtime",
7133 "tmux",
7134 "--issue",
7135 "4375",
7136 ]);
7137 assert!(matches!(
7138 cli.command,
7139 Some(Commands::Workflow(WorkflowArgs {
7140 command: WorkflowCommand::Run {
7141 ref workflow,
7142 ref fleet,
7143 ref runtime,
7144 ref issue,
7145 ..
7146 }
7147 })) if workflow == "stopship"
7148 && fleet.as_deref() == Some("stopship")
7149 && runtime == "tmux"
7150 && issue.as_deref() == Some("4375")
7151 ));
7152 }
7153
7154 #[test]
7157 fn fleet_is_the_only_top_level_command_and_pod_is_rejected() {
7158 for tail in [
7159 vec!["init"],
7160 vec!["status"],
7161 vec!["run", "tasks.json", "--max-workers", "2"],
7162 ] {
7163 let fleet = parse_ok(
7164 &std::iter::once("codewhale")
7165 .chain(["fleet"])
7166 .chain(tail.iter().copied())
7167 .collect::<Vec<_>>(),
7168 );
7169 let Some(Commands::Fleet(fleet_args)) = &fleet.command else {
7170 panic!("fleet must parse into the fleet command: {tail:?}");
7171 };
7172 assert_eq!(fleet_args.args, tail, "{tail:?}");
7173 assert!(fleet.prompt.is_empty(), "{tail:?}");
7174
7175 let retired = parse_ok(
7176 &std::iter::once("codewhale")
7177 .chain(["pod"])
7178 .chain(tail.iter().copied())
7179 .collect::<Vec<_>>(),
7180 );
7181 assert!(
7182 retired.command.is_none(),
7183 "retired pod must not dispatch to any command: {tail:?}"
7184 );
7185 assert_eq!(
7186 retired.prompt.first().map(String::as_str),
7187 Some("pod"),
7188 "retired pod words fall through to prompt text: {tail:?}"
7189 );
7190 }
7191
7192 let help = help_for(&["codewhale", "--help"]);
7194 let commands = help
7195 .lines()
7196 .map(str::trim_start)
7197 .filter(|line| line.starts_with("fleet"))
7198 .collect::<Vec<_>>();
7199 assert_eq!(
7200 commands.len(),
7201 1,
7202 "expected exactly one entry: {commands:?}"
7203 );
7204 assert!(commands[0].starts_with("fleet"), "{commands:?}");
7205 assert!(
7206 commands[0].contains("fleet"),
7207 "help summary should name fleet: {commands:?}"
7208 );
7209 assert!(
7210 !help.contains("Manage durable Agent Fleet runs"),
7211 "the retired Fleet-led summary must be gone from top-level help"
7212 );
7213
7214 let fleet_help = help_for(&["codewhale", "fleet", "--help"]);
7215 assert!(fleet_help.contains("Manage durable Agent fleet runs"));
7216 assert!(fleet_help.contains("codewhale fleet run tasks.json --max-workers 4"));
7217
7218 let args = TuiPassthroughArgs {
7221 args: vec!["status".into()],
7222 };
7223 assert_eq!(
7224 tui_args("fleet", args.clone()),
7225 vec!["fleet".to_string(), "status".to_string()]
7226 );
7227 assert!(command_accepts_raw_provider(Some(&Commands::Fleet(args))));
7228 }
7229
7230 #[test]
7231 fn exec_and_fleet_accept_builtin_and_raw_provider_identifiers() {
7232 let builtin = parse_ok(&["codewhale", "--provider", "openrouter", "exec", "Reply OK"]);
7233 assert_eq!(builtin.provider.as_deref(), Some("openrouter"));
7234 assert_eq!(
7235 top_level_provider_override(builtin.provider.as_deref(), builtin.command.as_ref())
7236 .expect("built-in Exec provider"),
7237 Some(ProviderKind::Openrouter)
7238 );
7239
7240 assert_eq!(
7241 top_level_provider_override(
7242 Some("qianfan"),
7243 Some(&Commands::Exec(TuiPassthroughArgs {
7244 args: vec!["Reply OK".into()]
7245 }))
7246 )
7247 .expect("qianfan is a catalog route"),
7248 Some(ProviderKind::Qianfan)
7249 );
7250
7251 for (provider, command) in [
7252 ("lm-studio", vec!["exec", "Reply OK"]),
7253 ("lm-studio", vec!["fleet", "status"]),
7254 ] {
7255 let argv = std::iter::once("codewhale")
7256 .chain(["--provider", provider])
7257 .chain(command.iter().copied())
7258 .collect::<Vec<_>>();
7259 let cli = parse_ok(&argv);
7260 assert_eq!(cli.provider.as_deref(), Some(provider));
7261 assert_eq!(
7262 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
7263 .expect("raw TUI provider"),
7264 None,
7265 "{argv:?} should defer the raw provider id to the TUI"
7266 );
7267 }
7268 }
7269
7270 #[test]
7271 fn opencode_go_provider_aliases_parse_as_builtin() {
7272 for alias in ["opencode-go", "opencode_go", "opencodego"] {
7273 assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OpencodeGo));
7274 }
7275 }
7276
7277 #[test]
7278 fn ollama_cloud_provider_aliases_parse_as_builtin() {
7279 for alias in ["ollama-cloud", "ollama_cloud"] {
7280 assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OllamaCloud));
7281 }
7282 }
7283
7284 #[test]
7285 fn antigravity_provider_aliases_are_clear_only_and_never_raw_custom() {
7286 for alias in ["antigravity", "agy"] {
7287 assert_eq!(builtin_provider_arg(alias), None, "{alias}");
7288 assert_eq!(
7289 parse_auth_clear_provider(alias),
7290 Ok(ProviderKind::Antigravity),
7291 "{alias}"
7292 );
7293 let error = parse_catalog_route(alias).expect_err("legacy route is not selectable");
7294 assert!(error.contains("non-runnable legacy provider"), "{error}");
7295 assert!(error.contains("--provider antigravity"), "{error}");
7296 assert!(error.contains("google"), "{error}");
7297 assert!(error.contains("GEMINI_API_KEY"), "{error}");
7298
7299 let clear = parse_ok(&["codewhale", "auth", "clear", "--provider", alias]);
7300 assert!(matches!(
7301 clear.command,
7302 Some(Commands::Auth(AuthArgs {
7303 command: AuthCommand::Clear {
7304 provider: ProviderKind::Antigravity,
7305 }
7306 }))
7307 ));
7308
7309 for argv in [
7310 vec!["codewhale", "auth", "set", "--provider", alias],
7311 vec!["codewhale", "auth", "get", "--provider", alias],
7312 vec!["codewhale", "auth", "print-api-key", "--provider", alias],
7313 vec!["codewhale", "auth", "status", "--provider", alias],
7314 vec!["codewhale", "auth", "external-revoke", "--provider", alias],
7315 vec![
7316 "codewhale",
7317 "auth",
7318 "external-consent",
7319 "--provider",
7320 alias,
7321 "--mode",
7322 "read-only",
7323 "--yes",
7324 ],
7325 vec!["codewhale", "model", "list", "--provider", alias],
7326 vec!["codewhale", "model", "resolve", "--provider", alias],
7327 ] {
7328 let error = Cli::try_parse_from(argv)
7329 .expect_err("legacy Antigravity route must be rejected outside auth clear");
7330 assert_eq!(error.kind(), ErrorKind::ValueValidation);
7331 assert!(
7332 error.to_string().contains("non-runnable legacy provider"),
7333 "{error}"
7334 );
7335 }
7336
7337 for command in [
7338 Commands::Exec(TuiPassthroughArgs {
7339 args: vec!["Reply OK".into()],
7340 }),
7341 Commands::Fleet(TuiPassthroughArgs {
7342 args: vec!["status".into()],
7343 }),
7344 ] {
7345 let error = top_level_provider_override(Some(alias), Some(&command))
7346 .expect_err("legacy alias must not fall through as a raw custom provider");
7347 assert!(
7348 error.to_string().contains("non-runnable legacy provider"),
7349 "{error}"
7350 );
7351 }
7352 }
7353 }
7354
7355 #[test]
7356 fn legacy_dual_wire_provider_flag_keeps_named_table_kind() {
7357 for alias in [
7361 "minimax-anthropic",
7362 "minimax_anthropic",
7363 "mini-max-anthropic",
7364 "mini_max_anthropic",
7365 ] {
7366 assert_eq!(
7367 builtin_provider_arg(alias),
7368 Some(ProviderKind::MinimaxAnthropic),
7369 "{alias}"
7370 );
7371 }
7372 let cli = parse_ok(&[
7373 "codewhale",
7374 "--provider",
7375 "minimax-anthropic",
7376 "exec",
7377 "Reply OK",
7378 ]);
7379 assert_eq!(
7380 top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
7381 .expect("legacy dual-wire provider"),
7382 Some(ProviderKind::MinimaxAnthropic)
7383 );
7384 }
7385
7386 #[test]
7387 fn opencode_zen_provider_aliases_parse_as_builtin() {
7388 for alias in [
7389 "opencode-zen",
7390 "opencode_zen",
7391 "opencodezen",
7392 "zen",
7393 "opencode",
7394 ] {
7395 assert_eq!(builtin_provider_arg(alias), Some(ProviderKind::OpencodeZen));
7396 }
7397 }
7398
7399 #[test]
7400 fn raw_provider_ids_remain_restricted_to_exec_and_fleet() {
7401 let cli = parse_ok(&["codewhale", "--provider", "lm-studio", "model", "list"]);
7402 let err = top_level_provider_override(cli.provider.as_deref(), cli.command.as_ref())
7403 .expect_err("model registry commands still require a built-in provider");
7404 assert!(
7405 err.to_string()
7406 .contains("configured custom providers are accepted only by exec and fleet")
7407 );
7408
7409 let err = Cli::try_parse_from(["codewhale", "auth", "set", "--provider", "lm-studio"])
7410 .expect_err("auth keeps enum-only provider validation");
7411 assert_eq!(err.kind(), ErrorKind::ValueValidation);
7412
7413 let err = Cli::try_parse_from([
7414 "codewhale",
7415 "--provider",
7416 "../../lm-studio",
7417 "exec",
7418 "Reply OK",
7419 ])
7420 .expect_err("provider ids must stay simple tokens");
7421 assert!(
7422 err.to_string()
7423 .contains("provider must be a simple identifier")
7424 );
7425 }
7426
7427 #[test]
7428 fn hidden_lane_log_proxy_parses_child_argv_and_preserves_other_commands() {
7429 let cli = parse_ok(&[
7430 "codewhale",
7431 "lane-log-proxy",
7432 "--log-path",
7433 "/tmp/lane.ndjson",
7434 "--receipt-path",
7435 "/tmp/lane.exit.json",
7436 "--receipt-tmp-path",
7437 "/tmp/lane.exit.json.tmp",
7438 "--environment-path",
7439 "/tmp/lane.env.json",
7440 "--lane-id",
7441 "lane-proof",
7442 "--",
7443 "/bin/echo",
7444 "--child-flag",
7445 "hello",
7446 ]);
7447 let (proxy, command) = split_lane_log_proxy_command(cli.command);
7448 assert!(command.is_none());
7449 let proxy = proxy.expect("proxy args");
7450 assert_eq!(proxy.lane_id, "lane-proof");
7451 assert_eq!(
7452 proxy.command,
7453 ["/bin/echo", "--child-flag", "hello"].map(str::to_string)
7454 );
7455
7456 let cli = parse_ok(&["codewhale", "lane", "list", "--json"]);
7457 let (proxy, command) = split_lane_log_proxy_command(cli.command);
7458 assert!(proxy.is_none());
7459 assert!(matches!(
7460 command,
7461 Some(Commands::Lane(LaneArgs {
7462 command: LaneCommand::List { json: true }
7463 }))
7464 ));
7465 }
7466
7467 #[test]
7470 fn cli_lane_subcommands_cover_the_shared_control_contract() {
7471 use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface};
7472
7473 for descriptor in codewhale_lane::control::operations_for_domain(ControlDomain::Lane) {
7474 let argv = [
7475 "codewhale".to_string(),
7476 "lane".to_string(),
7477 descriptor.verb.to_string(),
7478 ];
7479 let mut argv: Vec<&str> = argv.iter().map(String::as_str).collect();
7480 if descriptor.target.requires_identity() {
7481 argv.push("lane-a1b2c3d4");
7482 }
7483 let cli = parse_ok(&argv);
7484 let Some(Commands::Lane(args)) = cli.command else {
7485 panic!("`{}` must parse as a lane subcommand", descriptor.verb);
7486 };
7487 let parsed = match args.command {
7488 LaneCommand::List { .. } => ControlOperation::LaneList,
7489 LaneCommand::Status { .. } => ControlOperation::LaneStatus,
7490 LaneCommand::Interrupt { .. } | LaneCommand::Stop { .. } => {
7491 ControlOperation::LaneInterrupt
7492 }
7493 LaneCommand::Restart { .. } => ControlOperation::LaneRestart,
7494 LaneCommand::Resume { .. } => ControlOperation::LaneResume,
7495 other => panic!(
7496 "unexpected lane subcommand for {}: {other:?}",
7497 descriptor.verb
7498 ),
7499 };
7500 assert_eq!(
7501 parsed, descriptor.operation,
7502 "`codewhale lane {}` must map to {}",
7503 descriptor.verb, descriptor.id
7504 );
7505 assert!(
7506 descriptor.offers(ControlSurface::Cli),
7507 "{} must be declared on the CLI surface",
7508 descriptor.id
7509 );
7510 }
7511 }
7512
7513 #[test]
7515 fn lane_stop_and_interrupt_resolve_to_one_verb() {
7516 use codewhale_lane::{ControlDomain, ControlOperation};
7517
7518 for spelling in ["stop", "interrupt", "cancel", "kill"] {
7519 assert_eq!(
7520 ControlOperation::parse_verb(ControlDomain::Lane, spelling),
7521 Some(ControlOperation::LaneInterrupt),
7522 "{spelling}"
7523 );
7524 }
7525 let stop = parse_ok(&["codewhale", "lane", "stop", "lane-a1b2c3d4"]);
7526 assert!(matches!(
7527 stop.command,
7528 Some(Commands::Lane(LaneArgs {
7529 command: LaneCommand::Stop { .. }
7530 }))
7531 ));
7532 }
7533
7534 #[test]
7535 fn short_workflow_names_do_not_resolve_version_pinned_files() {
7536 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
7537 .join("..")
7538 .join("..");
7539 let candidates = workflow_source_candidates("issue-sweep", None, &workspace);
7543 assert!(candidates.iter().all(|path| {
7544 !path
7545 .file_name()
7546 .is_some_and(|name| name.to_string_lossy().starts_with("v0868_"))
7547 }));
7548 assert!(resolve_workflow_source_path("issue-sweep", None, &workspace).is_err());
7549
7550 let explicit =
7553 resolve_workflow_source_path("workflows/stopship.workflow.js", None, &workspace)
7554 .expect("explicit workflow path");
7555 assert!(explicit.ends_with("workflows/stopship.workflow.js"));
7556 }
7557
7558 #[test]
7559 fn workflow_run_resolves_stopship_alias_and_payload() {
7560 let _lock = env_lock();
7561 let (_dir, _tui) = install_fake_tui_binary();
7562 let _provider = ScopedEnvVar::remove("DEEPSEEK_PROVIDER");
7563 let _model = ScopedEnvVar::remove("DEEPSEEK_MODEL");
7564 let _base_url = ScopedEnvVar::remove("DEEPSEEK_BASE_URL");
7565 let _api_key = ScopedEnvVar::remove("DEEPSEEK_API_KEY");
7566 let _cli_api_key = ScopedEnvVar::remove("CODEWHALE_CLI_API_KEY");
7567 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
7568 .join("..")
7569 .join("..");
7570 let cli = parse_ok(&[
7571 "codewhale",
7572 "--profile",
7573 "workflow-profile",
7574 "--model",
7575 "explicit-workflow-model",
7576 "--api-key",
7577 "explicit-profile-key",
7578 "--workspace",
7579 workspace.to_str().expect("workspace UTF-8"),
7580 ]);
7581 let resolved = resolved_runtime_for_test(ProviderKind::Deepseek, ProviderSource::Config);
7582 let source = resolve_workflow_source_path("stopship", None, &workspace)
7583 .expect("stopship workflow source");
7584 assert!(source.ends_with("workflows/stopship.workflow.js"));
7585
7586 let process = workflow_exec_command(WorkflowExecSpec {
7587 cli: &cli,
7588 resolved_runtime: &resolved,
7589 config_path: &workspace.join("config.toml"),
7590 source_root: &workspace,
7591 source_path: &source,
7592 workflow: "stopship",
7593 fleet: Some("stopship"),
7594 issue: Some("4375"),
7595 goal: Some("fix stopship"),
7596 token_budget: Some(25_000),
7597 verify: true,
7598 })
7599 .expect("command");
7600 let current_executable = std::env::current_exe().expect("current executable");
7601 assert_eq!(
7602 process.command.first().map(String::as_str),
7603 current_executable.to_str(),
7604 "workflow lanes must launch the exact runtime that built their process spec"
7605 );
7606 let joined = process.command.join("\n");
7607 assert!(joined.contains("workflow-tool"));
7608 assert!(joined.contains("explicit-workflow-command"));
7609 assert!(joined.contains("--input-json"));
7610 assert!(!process.command.iter().any(|arg| arg == "exec"));
7611 assert!(!process.command.iter().any(|arg| arg == "--workspace"));
7612 assert!(
7613 process
7614 .command
7615 .windows(2)
7616 .any(|pair| pair == ["--profile", "workflow-profile"])
7617 );
7618 assert!(!joined.contains("Run the CodeWhale"));
7619 assert!(joined.contains("\"source_path\":\"workflows/stopship.workflow.js\""));
7620 assert!(joined.contains("\"fleet\":\"stopship\""));
7621 assert!(joined.contains("\"issue\":\"4375\""));
7622 assert!(joined.contains("\"token_budget\":25000"));
7623 assert!(joined.contains("\"verify\":true"));
7624 assert!(
7625 process.environment.iter().any(|(key, value)| {
7626 key == "DEEPSEEK_MODEL" && value == "explicit-workflow-model"
7627 })
7628 );
7629 assert!(
7630 !process
7631 .environment
7632 .iter()
7633 .any(|(key, _)| key == "DEEPSEEK_PROVIDER")
7634 );
7635 assert!(
7636 !process
7637 .environment
7638 .iter()
7639 .any(|(key, _)| key == "DEEPSEEK_BASE_URL")
7640 );
7641 assert!(
7642 !process
7643 .environment
7644 .iter()
7645 .any(|(key, _)| key == "DEEPSEEK_API_KEY")
7646 );
7647 assert!(process.environment.iter().any(|(key, value)| {
7648 key == "CODEWHALE_CLI_API_KEY" && value == "explicit-profile-key"
7649 }));
7650 assert!(
7651 !process
7652 .command
7653 .iter()
7654 .any(|argument| argument.contains("explicit-profile-key"))
7655 );
7656 assert!(
7657 process
7658 .environment
7659 .iter()
7660 .all(|(_, value)| value != "test-model")
7661 );
7662 }
7663
7664 #[test]
7665 fn exec_keeps_global_looking_flags_as_passthrough_args() {
7666 let cli = parse_ok(&[
7667 "codewhale",
7668 "exec",
7669 "--provider",
7670 "definitely-not-a-provider",
7671 "Reply OK",
7672 ]);
7673
7674 let Some(Commands::Exec(args)) = cli.command else {
7675 panic!("expected exec command");
7676 };
7677
7678 assert_eq!(
7679 args.args,
7680 vec![
7681 "--provider".to_string(),
7682 "definitely-not-a-provider".to_string(),
7683 "Reply OK".to_string(),
7684 ]
7685 );
7686 }
7687
7688 #[test]
7689 fn exec_rejects_provider_after_subcommand() {
7690 let args = vec![
7691 "--provider".to_string(),
7692 "definitely-not-a-provider".to_string(),
7693 "Reply OK".to_string(),
7694 ];
7695
7696 let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
7697
7698 assert!(
7699 err.to_string()
7700 .contains("--provider must be placed before `exec`")
7701 );
7702 }
7703
7704 #[test]
7705 fn exec_rejects_equals_form_provider_after_subcommand() {
7706 let args = vec!["--provider=openmodel".to_string(), "Reply OK".to_string()];
7707
7708 let err = reject_exec_global_flags(&args).expect_err("provider after exec should fail");
7709
7710 assert!(
7711 err.to_string()
7712 .contains("--provider must be placed before `exec`")
7713 );
7714 }
7715
7716 #[test]
7717 fn exec_allows_documented_forwarded_flags() {
7718 let args = vec![
7719 "--auto".to_string(),
7720 "--output-format".to_string(),
7721 "stream-json".to_string(),
7722 "fix tests".to_string(),
7723 ];
7724
7725 reject_exec_global_flags(&args).expect("documented exec flags should pass");
7726 }
7727
7728 #[test]
7729 fn exec_allows_literal_prompt_flags_after_separator() {
7730 let args = vec![
7731 "--".to_string(),
7732 "--provider".to_string(),
7733 "is literal prompt text".to_string(),
7734 ];
7735
7736 reject_exec_global_flags(&args).expect("separator should stop global flag validation");
7737 }
7738
7739 #[test]
7740 fn dispatcher_resume_picker_only_handles_bare_windows_resume() {
7741 assert!(should_pick_resume_in_dispatcher(
7742 &["resume".to_string()],
7743 true
7744 ));
7745 assert!(!should_pick_resume_in_dispatcher(
7746 &["resume".to_string(), "--last".to_string()],
7747 true
7748 ));
7749 assert!(!should_pick_resume_in_dispatcher(
7750 &["resume".to_string(), "abc123".to_string()],
7751 true
7752 ));
7753 assert!(!should_pick_resume_in_dispatcher(
7754 &["resume".to_string()],
7755 false
7756 ));
7757 }
7758
7759 #[test]
7760 fn auth_set_uses_isolated_file_store_and_preserves_tui_defaults() {
7761 let _lock = env_lock();
7762 let dir = tempfile::TempDir::new().expect("tempdir");
7763 let codewhale_home = dir.path().join("codewhale-home");
7764 let codewhale_home_value = codewhale_home.to_string_lossy().into_owned();
7765 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home_value);
7766 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
7767 let path = codewhale_home.join("config.toml");
7768 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
7769 let secrets = Secrets::auto_detect();
7770
7771 run_auth_command_with_secrets(
7772 &mut store,
7773 AuthCommand::Set {
7774 provider: ProviderKind::Deepseek,
7775 api_key: Some("sk-test".to_string()),
7776 api_key_stdin: false,
7777 },
7778 &secrets,
7779 )
7780 .expect("auth set should persist credential");
7781
7782 assert!(store.config.api_key.is_none());
7783 assert!(store.config.providers.deepseek.api_key.is_none());
7784 assert_eq!(
7785 store.config.default_text_model.as_deref(),
7786 Some("deepseek-v4-pro")
7787 );
7788 let saved = std::fs::read_to_string(&path).expect("config should be written");
7789 assert!(!saved.contains("sk-test"), "{saved}");
7790 assert!(
7791 !saved
7792 .lines()
7793 .any(|line| line.trim_start().starts_with("api_key="))
7794 );
7795 assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
7796 assert_eq!(
7797 secrets.get("deepseek").expect("read secret").as_deref(),
7798 Some("sk-test")
7799 );
7800 }
7801
7802 #[test]
7805 fn login_parses_account_device_flow_flags() {
7806 let cli = parse_ok(&["codewhale", "login", "--no-open", "--timeout-seconds", "5"]);
7807 let Some(Commands::Login(args)) = cli.command else {
7808 panic!("expected Login");
7809 };
7810 assert!(args.no_open);
7811 assert_eq!(args.timeout_seconds, 5);
7812 assert!(args.api_key.is_none());
7813 assert!(args.provider.is_none());
7814
7815 let cli = parse_ok(&["codewhale", "login"]);
7816 let Some(Commands::Login(args)) = cli.command else {
7817 panic!("expected Login");
7818 };
7819 assert!(!args.no_open);
7820 assert_eq!(args.timeout_seconds, 600);
7821 }
7822
7823 #[test]
7826 fn login_rejects_legacy_provider_flags_with_redirect() {
7827 let err = reject_legacy_login_provider_args(&LoginArgs {
7828 no_open: false,
7829 timeout_seconds: 600,
7830 api_key: Some("sk-x".to_string()),
7831 provider: None,
7832 })
7833 .expect_err("legacy --api-key must be rejected");
7834 let rendered = err.to_string();
7835 assert!(
7836 rendered.contains("auth set --provider"),
7837 "redirect must name `auth set --provider`: {rendered}"
7838 );
7839
7840 let err = reject_legacy_login_provider_args(&LoginArgs {
7841 no_open: false,
7842 timeout_seconds: 600,
7843 api_key: None,
7844 provider: Some(ProviderKind::Deepseek),
7845 })
7846 .expect_err("legacy --provider must be rejected");
7847 assert!(
7848 err.to_string().contains("auth set --provider"),
7849 "redirect must name `auth set --provider`"
7850 );
7851
7852 reject_legacy_login_provider_args(&LoginArgs {
7853 no_open: false,
7854 timeout_seconds: 600,
7855 api_key: None,
7856 provider: None,
7857 })
7858 .expect("plain account login carries no legacy flags");
7859 }
7860
7861 #[test]
7864 fn login_help_describes_account_signin() {
7865 let help = help_for(&["codewhale", "login", "--help"]);
7866 assert!(
7867 help.contains("Codewhale account"),
7868 "login help must describe account sign-in: {help}"
7869 );
7870 assert!(
7871 !help.to_lowercase().contains("api key"),
7872 "login help must not advertise provider API keys: {help}"
7873 );
7874 }
7875
7876 #[test]
7877 fn auth_parses_daytona_slot_commands_as_unknown() {
7878 for argv in [
7881 vec![
7882 "codewhale",
7883 "auth",
7884 "set-slot",
7885 "daytona",
7886 "--api-key-stdin",
7887 ],
7888 vec!["codewhale", "auth", "clear-slot", "daytona"],
7889 ] {
7890 let error = Cli::try_parse_from(argv).expect_err("slot commands must not parse");
7891 assert_eq!(error.kind(), ErrorKind::InvalidSubcommand, "{error}");
7892 }
7893 let help = help_for(&["codewhale", "auth", "--help"]);
7894 assert!(!help.contains("set-slot"), "{help}");
7895 assert!(!help.contains("clear-slot"), "{help}");
7896 assert!(!help.to_lowercase().contains("daytona"), "{help}");
7897 }
7898
7899 #[test]
7902 fn auth_set_with_repo_scoped_ambient_config_writes_user_global_metadata() {
7903 let _lock = env_lock();
7904 let dir = tempfile::TempDir::new().expect("tempdir");
7905 let repo = dir.path().join("repo");
7906 std::fs::create_dir_all(repo.join(".git")).expect("git marker");
7907 let repo_config_dir = repo.join(".codewhale");
7908 std::fs::create_dir_all(&repo_config_dir).expect("repo config dir");
7909 let repo_config = repo_config_dir.join("config.toml");
7910 std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config");
7911
7912 let codewhale_home = dir.path().join("codewhale-home");
7913 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy());
7914 let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy());
7915 let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH");
7916 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
7917 let mut store = ConfigStore::load(None).expect("ambient store should load");
7918 let secrets = Secrets::auto_detect();
7919
7920 run_auth_command_with_secrets(
7921 &mut store,
7922 AuthCommand::Set {
7923 provider: ProviderKind::Openrouter,
7924 api_key: Some("sk-or-repo-scoped".to_string()),
7925 api_key_stdin: false,
7926 },
7927 &secrets,
7928 )
7929 .expect("auth set should persist credential");
7930
7931 assert_eq!(
7932 secrets.get("openrouter").expect("read secret").as_deref(),
7933 Some("sk-or-repo-scoped")
7934 );
7935 let global = std::fs::read_to_string(codewhale_home.join("config.toml"))
7936 .expect("user-global config");
7937 assert!(
7938 global.contains("auth_mode = \"api_key\""),
7939 "user-global config must carry the auth markers: {global}"
7940 );
7941 assert!(
7942 global.contains("openrouter"),
7943 "user-global config must name the provider table: {global}"
7944 );
7945 assert!(!global.contains("sk-or-repo-scoped"), "{global}");
7946 let repo_after = std::fs::read_to_string(&repo_config).expect("repo config");
7947 assert_eq!(
7948 repo_after, "approval_policy = \"never\"\n",
7949 "workspace config must stay untouched by credential metadata: {repo_after}"
7950 );
7951 }
7952
7953 #[test]
7954 fn parses_auth_subcommand_matrix() {
7955 let cli = parse_ok(&["deepseek", "auth", "xai-device"]);
7956 assert!(matches!(
7957 cli.command,
7958 Some(Commands::Auth(AuthArgs {
7959 command: AuthCommand::XaiDevice
7960 }))
7961 ));
7962
7963 let cli = parse_ok(&["deepseek", "auth", "chatgpt"]);
7964 assert!(matches!(
7965 cli.command,
7966 Some(Commands::Auth(AuthArgs {
7967 command: AuthCommand::Chatgpt
7968 }))
7969 ));
7970
7971 let cli = parse_ok(&["deepseek", "auth", "chatgpt-revoke"]);
7972 assert!(matches!(
7973 cli.command,
7974 Some(Commands::Auth(AuthArgs {
7975 command: AuthCommand::ChatgptRevoke
7976 }))
7977 ));
7978
7979 let cli = parse_ok(&[
7980 "deepseek",
7981 "auth",
7982 "external-consent",
7983 "--provider",
7984 "openai-codex",
7985 "--mode",
7986 "read-only",
7987 "--path",
7988 "/tmp/codex-auth.json",
7989 "--yes",
7990 ]);
7991 assert!(matches!(
7992 cli.command,
7993 Some(Commands::Auth(AuthArgs {
7994 command: AuthCommand::ExternalConsent {
7995 provider: ProviderKind::OpenaiCodex,
7996 mode: ExternalCredentialModeArg::ReadOnly,
7997 path: Some(_),
7998 yes: true,
7999 }
8000 }))
8001 ));
8002
8003 let cli = parse_ok(&["deepseek", "auth", "external-revoke", "--provider", "xai"]);
8004 assert!(matches!(
8005 cli.command,
8006 Some(Commands::Auth(AuthArgs {
8007 command: AuthCommand::ExternalRevoke {
8008 provider: ProviderKind::Xai,
8009 }
8010 }))
8011 ));
8012
8013 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "deepseek"]);
8014 assert!(matches!(
8015 cli.command,
8016 Some(Commands::Auth(AuthArgs {
8017 command: AuthCommand::Set {
8018 provider: ProviderKind::Deepseek,
8019 api_key: None,
8020 api_key_stdin: false,
8021 }
8022 }))
8023 ));
8024
8025 let cli = parse_ok(&[
8026 "deepseek",
8027 "auth",
8028 "set",
8029 "--provider",
8030 "openrouter",
8031 "--api-key-stdin",
8032 ]);
8033 assert!(matches!(
8034 cli.command,
8035 Some(Commands::Auth(AuthArgs {
8036 command: AuthCommand::Set {
8037 provider: ProviderKind::Openrouter,
8038 api_key: None,
8039 api_key_stdin: true,
8040 }
8041 }))
8042 ));
8043
8044 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "novita"]);
8045 assert!(matches!(
8046 cli.command,
8047 Some(Commands::Auth(AuthArgs {
8048 command: AuthCommand::Get {
8049 provider: ProviderKind::Novita
8050 }
8051 }))
8052 ));
8053
8054 let cli = parse_ok(&["deepseek", "auth", "clear", "--provider", "nvidia-nim"]);
8055 assert!(matches!(
8056 cli.command,
8057 Some(Commands::Auth(AuthArgs {
8058 command: AuthCommand::Clear {
8059 provider: ProviderKind::NvidiaNim
8060 }
8061 }))
8062 ));
8063
8064 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "fireworks"]);
8065 assert!(matches!(
8066 cli.command,
8067 Some(Commands::Auth(AuthArgs {
8068 command: AuthCommand::Set {
8069 provider: ProviderKind::Fireworks,
8070 api_key: None,
8071 api_key_stdin: false,
8072 }
8073 }))
8074 ));
8075
8076 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]);
8077 assert!(matches!(
8078 cli.command,
8079 Some(Commands::Auth(AuthArgs {
8080 command: AuthCommand::Set {
8081 provider: ProviderKind::Siliconflow,
8082 api_key: None,
8083 api_key_stdin: false,
8084 }
8085 }))
8086 ));
8087
8088 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "arcee"]);
8089 assert!(matches!(
8090 cli.command,
8091 Some(Commands::Auth(AuthArgs {
8092 command: AuthCommand::Set {
8093 provider: ProviderKind::Arcee,
8094 api_key: None,
8095 api_key_stdin: false,
8096 }
8097 }))
8098 ));
8099
8100 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]);
8101 assert!(matches!(
8102 cli.command,
8103 Some(Commands::Auth(AuthArgs {
8104 command: AuthCommand::Set {
8105 provider: ProviderKind::Moonshot,
8106 api_key: None,
8107 api_key_stdin: false,
8108 }
8109 }))
8110 ));
8111
8112 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "wanjie-ark"]);
8113 assert!(matches!(
8114 cli.command,
8115 Some(Commands::Auth(AuthArgs {
8116 command: AuthCommand::Set {
8117 provider: ProviderKind::WanjieArk,
8118 api_key: None,
8119 api_key_stdin: false,
8120 }
8121 }))
8122 ));
8123
8124 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "sglang"]);
8125 assert!(matches!(
8126 cli.command,
8127 Some(Commands::Auth(AuthArgs {
8128 command: AuthCommand::Get {
8129 provider: ProviderKind::Sglang
8130 }
8131 }))
8132 ));
8133
8134 let cli = parse_ok(&["deepseek", "auth", "get", "--provider", "vllm"]);
8135 assert!(matches!(
8136 cli.command,
8137 Some(Commands::Auth(AuthArgs {
8138 command: AuthCommand::Get {
8139 provider: ProviderKind::Vllm
8140 }
8141 }))
8142 ));
8143
8144 let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "ollama"]);
8145 assert!(matches!(
8146 cli.command,
8147 Some(Commands::Auth(AuthArgs {
8148 command: AuthCommand::Set {
8149 provider: ProviderKind::Ollama,
8150 api_key: None,
8151 api_key_stdin: false,
8152 }
8153 }))
8154 ));
8155
8156 let cli = parse_ok(&["deepseek", "auth", "status", "--provider", "openai-codex"]);
8157 assert!(matches!(
8158 cli.command,
8159 Some(Commands::Auth(AuthArgs {
8160 command: AuthCommand::Status {
8161 provider: Some(ProviderKind::OpenaiCodex),
8162 diagnostic: false,
8163 }
8164 }))
8165 ));
8166
8167 let cli = parse_ok(&[
8168 "deepseek",
8169 "auth",
8170 "status",
8171 "--diagnostic",
8172 "--provider",
8173 "deepseek",
8174 ]);
8175 assert!(matches!(
8176 cli.command,
8177 Some(Commands::Auth(AuthArgs {
8178 command: AuthCommand::Status {
8179 provider: Some(ProviderKind::Deepseek),
8180 diagnostic: true,
8181 }
8182 }))
8183 ));
8184
8185 for (provider, expected) in [
8186 ("anthropic", ProviderKind::Anthropic),
8187 ("openmodel", ProviderKind::Openmodel),
8188 ("open-model", ProviderKind::Openmodel),
8189 ("zai", ProviderKind::Zai),
8190 ("stepfun", ProviderKind::Stepfun),
8191 ("minimax", ProviderKind::Minimax),
8192 ("minimax-anthropic", ProviderKind::MinimaxAnthropic),
8193 ("minimax_anthropic", ProviderKind::MinimaxAnthropic),
8194 ("deepinfra", ProviderKind::Deepinfra),
8195 ("deep-infra", ProviderKind::Deepinfra),
8196 ("siliconflow-cn", ProviderKind::SiliconflowCN),
8197 ("siliconflow-CN", ProviderKind::SiliconflowCN),
8198 ("siliconflow_china", ProviderKind::SiliconflowCN),
8199 ] {
8200 let cli = parse_ok(&[
8201 "deepseek",
8202 "auth",
8203 "set",
8204 "--provider",
8205 provider,
8206 "--api-key-stdin",
8207 ]);
8208 assert!(matches!(
8209 cli.command,
8210 Some(Commands::Auth(AuthArgs {
8211 command: AuthCommand::Set {
8212 provider,
8213 api_key: None,
8214 api_key_stdin: true,
8215 }
8216 })) if provider == expected
8217 ));
8218 }
8219
8220 let cli = parse_ok(&["deepseek", "auth", "list"]);
8221 assert!(matches!(
8222 cli.command,
8223 Some(Commands::Auth(AuthArgs {
8224 command: AuthCommand::List
8225 }))
8226 ));
8227
8228 let cli = parse_ok(&["deepseek", "auth", "migrate"]);
8229 assert!(matches!(
8230 cli.command,
8231 Some(Commands::Auth(AuthArgs {
8232 command: AuthCommand::Migrate { dry_run: false }
8233 }))
8234 ));
8235
8236 let cli = parse_ok(&["deepseek", "auth", "migrate", "--dry-run"]);
8237 assert!(matches!(
8238 cli.command,
8239 Some(Commands::Auth(AuthArgs {
8240 command: AuthCommand::Migrate { dry_run: true }
8241 }))
8242 ));
8243 }
8244
8245 #[test]
8246 fn auth_help_describes_runtime_effective_diagnostics() {
8247 let get = help_for(&["codewhale", "auth", "get", "--help"]);
8248 assert!(get.contains("effective credential route"), "{get}");
8249 assert!(get.contains("structural OAuth/repair state"), "{get}");
8250
8251 let status = help_for(&["codewhale", "auth", "status", "--help"]);
8252 assert!(
8253 status.contains("runtime-effective credential route state"),
8254 "{status}"
8255 );
8256
8257 let list = help_for(&["codewhale", "auth", "list", "--help"]);
8258 assert!(list.contains("runtime-effective auth state"), "{list}");
8259 }
8260
8261 #[test]
8262 fn auth_set_writes_secret_store_and_keeps_config_credential_free() {
8263 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8264 use std::sync::Arc;
8265
8266 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8267 let path = std::env::temp_dir().join(format!(
8268 "deepseek-cli-auth-set-test-{}-{nanos}.toml",
8269 std::process::id()
8270 ));
8271 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8272 let inner = Arc::new(InMemoryKeyringStore::new());
8273 let secrets = Secrets::new(inner.clone());
8274
8275 run_auth_command_with_secrets(
8276 &mut store,
8277 AuthCommand::Set {
8278 provider: ProviderKind::Deepseek,
8279 api_key: Some("sk-keyring".to_string()),
8280 api_key_stdin: false,
8281 },
8282 &secrets,
8283 )
8284 .expect("set should succeed");
8285
8286 assert!(store.config.api_key.is_none());
8287 assert!(store.config.providers.deepseek.api_key.is_none());
8288 let saved = std::fs::read_to_string(&path).unwrap_or_default();
8289 assert!(!saved.contains("sk-keyring"), "{saved}");
8290 assert!(
8291 !saved
8292 .lines()
8293 .any(|line| line.trim_start().starts_with("api_key ="))
8294 );
8295 assert_eq!(
8296 inner.get("deepseek").unwrap().as_deref(),
8297 Some("sk-keyring")
8298 );
8299
8300 let _ = std::fs::remove_file(path);
8301 }
8302
8303 #[test]
8304 fn auth_set_refuses_plaintext_config_when_secret_store_write_fails() {
8305 use codewhale_secrets::{KeyringStore, SecretsError};
8306 use std::sync::Arc;
8307
8308 struct FailingStore;
8309
8310 impl KeyringStore for FailingStore {
8311 fn get(&self, _key: &str) -> Result<Option<String>, SecretsError> {
8312 Ok(None)
8313 }
8314
8315 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
8316 Err(SecretsError::Keyring("test write failure".to_string()))
8317 }
8318
8319 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
8320 Ok(())
8321 }
8322
8323 fn backend_name(&self) -> &'static str {
8324 "failing test store"
8325 }
8326 }
8327
8328 let dir = tempfile::TempDir::new().expect("tempdir");
8329 let path = dir.path().join("config.toml");
8330 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
8331 let secrets = Secrets::new(Arc::new(FailingStore));
8332
8333 let error = run_auth_command_with_secrets(
8334 &mut store,
8335 AuthCommand::Set {
8336 provider: ProviderKind::Openrouter,
8337 api_key: Some("fallback-test-credential".to_string()),
8338 api_key_stdin: false,
8339 },
8340 &secrets,
8341 )
8342 .expect_err("secret-store failure must not downgrade to plaintext");
8343
8344 let message = format!("{error:#}");
8345 assert!(message.contains("Secret storage write failed"), "{message}");
8346 assert!(message.contains("Refusing"), "{message}");
8347 assert!(
8348 message.contains(&codewhale_config::quote_os_path(store.path())),
8349 "{message}"
8350 );
8351 assert!(store.config.providers.openrouter.api_key.is_none());
8352 assert!(!path.exists(), "plaintext config must stay untouched");
8353 }
8354
8355 #[test]
8356 fn auth_set_provider_key_does_not_switch_active_provider() {
8357 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8358 let path = std::env::temp_dir().join(format!(
8359 "deepseek-cli-auth-set-preserve-provider-test-{}-{nanos}.toml",
8360 std::process::id()
8361 ));
8362 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8363 store.config.provider = ProviderKind::Deepseek;
8364 let secrets = no_keyring_secrets();
8365
8366 run_auth_command_with_secrets(
8367 &mut store,
8368 AuthCommand::Set {
8369 provider: ProviderKind::Arcee,
8370 api_key: Some("arcee-key".to_string()),
8371 api_key_stdin: false,
8372 },
8373 &secrets,
8374 )
8375 .expect("set should succeed");
8376
8377 assert_eq!(store.config.provider, ProviderKind::Deepseek);
8378 assert!(store.config.providers.arcee.api_key.is_none());
8379 assert_eq!(
8380 store.config.providers.arcee.auth_mode.as_deref(),
8381 Some("api_key")
8382 );
8383
8384 let reloaded = ConfigStore::load(Some(path.clone())).expect("store should reload");
8385 assert_eq!(reloaded.config.provider, ProviderKind::Deepseek);
8386 assert!(reloaded.config.providers.arcee.api_key.is_none());
8387 assert_eq!(
8388 reloaded.config.providers.arcee.auth_mode.as_deref(),
8389 Some("api_key")
8390 );
8391
8392 let _ = std::fs::remove_file(path);
8393 }
8394
8395 #[test]
8396 fn auth_set_ollama_accepts_empty_key_and_records_base_url() {
8397 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8398 let path = std::env::temp_dir().join(format!(
8399 "deepseek-cli-auth-ollama-test-{}-{nanos}.toml",
8400 std::process::id()
8401 ));
8402 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8403 store.config.provider = ProviderKind::Deepseek;
8404 let secrets = no_keyring_secrets();
8405
8406 run_auth_command_with_secrets(
8407 &mut store,
8408 AuthCommand::Set {
8409 provider: ProviderKind::Ollama,
8410 api_key: None,
8411 api_key_stdin: false,
8412 },
8413 &secrets,
8414 )
8415 .expect("ollama auth set should not require a key");
8416
8417 assert_eq!(store.config.provider, ProviderKind::Deepseek);
8418 assert_eq!(
8419 store.config.providers.ollama.base_url.as_deref(),
8420 Some("http://localhost:11434/v1")
8421 );
8422 assert_eq!(store.config.providers.ollama.api_key, None);
8423
8424 let _ = std::fs::remove_file(path);
8425 }
8426
8427 #[test]
8428 fn auth_clear_removes_from_config() {
8429 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8430 use std::sync::Arc;
8431
8432 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8433 let path = std::env::temp_dir().join(format!(
8434 "deepseek-cli-auth-clear-test-{}-{nanos}.toml",
8435 std::process::id()
8436 ));
8437 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8438 store.config.api_key = Some("sk-stale".to_string());
8439 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
8440 store.save().unwrap();
8441
8442 let inner = Arc::new(InMemoryKeyringStore::new());
8443 inner.set("deepseek", "sk-stale").unwrap();
8444 let secrets = Secrets::new(inner.clone());
8445
8446 run_auth_command_with_secrets(
8447 &mut store,
8448 AuthCommand::Clear {
8449 provider: ProviderKind::Deepseek,
8450 },
8451 &secrets,
8452 )
8453 .expect("clear should succeed");
8454
8455 assert!(store.config.api_key.is_none());
8456 assert!(store.config.providers.deepseek.api_key.is_none());
8457 assert_eq!(inner.get("deepseek").unwrap(), None);
8458
8459 let _ = std::fs::remove_file(path);
8460 }
8461
8462 #[test]
8463 fn antigravity_clear_removes_only_codewhale_owned_legacy_state() {
8464 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8465 use std::sync::Arc;
8466
8467 let dir = tempfile::TempDir::new().expect("isolated legacy fixture");
8468 let config_path = dir.path().join("config.toml");
8469 let external_session_path = dir.path().join("external-antigravity-session.db");
8470 let external_session = b"external session bytes must remain unchanged";
8471 std::fs::write(&external_session_path, external_session)
8472 .expect("write external session trap");
8473
8474 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load empty config");
8475 store.config.provider = ProviderKind::Antigravity;
8476 store.config.fallback_providers = vec![ProviderKind::Antigravity, ProviderKind::Google];
8477 {
8478 let legacy = &mut store.config.providers.antigravity;
8479 legacy.api_key = Some("legacy-codewhale-fixture-key".to_string());
8480 legacy.base_url = Some("https://legacy.invalid/v1".to_string());
8481 legacy.model = Some("legacy-fixture-model".to_string());
8482 legacy.context_window = Some(1234);
8483 legacy.mode = Some("legacy-fixture-mode".to_string());
8484 legacy.wire = Some("legacy-fixture-wire".to_string());
8485 legacy.auth_mode = Some("oauth".to_string());
8486 legacy.insecure_skip_tls_verify = Some(true);
8487 legacy
8488 .http_headers
8489 .insert("X-Legacy-Fixture".to_string(), "fixture".to_string());
8490 legacy.path_suffix = Some("legacy-fixture-path".to_string());
8491 legacy.external_credentials =
8492 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8493 ProviderKind::Antigravity,
8494 codewhale_config::ExternalCredentialSource::AgyCli,
8495 external_session_path.clone(),
8496 ));
8497 legacy.extras.insert(
8498 "legacy_fixture_extra".to_string(),
8499 toml::Value::String("remove-me".to_string()),
8500 );
8501 }
8502 store.config.providers.google.api_key = Some("google-fixture-key".to_string());
8503 store.config.providers.google.base_url = Some("https://google.example/v1".to_string());
8504 store.config.providers.google.model = Some("google-fixture-model".to_string());
8505 store.save().expect("save legacy fixture");
8506
8507 let canonical = std::fs::read_to_string(&config_path).expect("read canonical fixture");
8510 let alias = canonical.replace("[providers.antigravity", "[providers.agy");
8511 std::fs::write(&config_path, alias).expect("write legacy alias fixture");
8512 let mut store = ConfigStore::load(Some(config_path.clone())).expect("reload alias fixture");
8513
8514 let inner = Arc::new(InMemoryKeyringStore::new());
8515 inner
8516 .set("antigravity", "legacy-codewhale-secret-slot")
8517 .expect("seed Codewhale-owned legacy secret slot");
8518 let secrets = Secrets::new(inner.clone());
8519
8520 run_auth_command_with_secrets(
8521 &mut store,
8522 AuthCommand::Clear {
8523 provider: ProviderKind::Antigravity,
8524 },
8525 &secrets,
8526 )
8527 .expect("legacy clear should succeed");
8528
8529 assert_eq!(store.config.provider, ProviderKind::default());
8530 assert_eq!(store.config.fallback_providers, vec![ProviderKind::Google]);
8531 assert!(store.config.providers.antigravity.is_empty());
8532 assert_eq!(inner.get("antigravity").unwrap(), None);
8533 assert_eq!(
8534 store.config.providers.google.api_key.as_deref(),
8535 Some("google-fixture-key")
8536 );
8537 assert_eq!(
8538 store.config.providers.google.base_url.as_deref(),
8539 Some("https://google.example/v1")
8540 );
8541 assert_eq!(
8542 store.config.providers.google.model.as_deref(),
8543 Some("google-fixture-model")
8544 );
8545 assert_eq!(
8546 std::fs::read(&external_session_path).expect("external session trap still exists"),
8547 external_session
8548 );
8549
8550 let raw = std::fs::read_to_string(&config_path).expect("read cleared config");
8551 assert!(!raw.contains("[providers.antigravity"), "{raw}");
8552 assert!(!raw.contains("[providers.agy"), "{raw}");
8553 assert!(!raw.contains("legacy_fixture_extra"), "{raw}");
8554 assert!(raw.contains("[providers.google]"), "{raw}");
8555
8556 let backup_path = config_path.with_file_name(format!(
8557 "{}.bak",
8558 config_path
8559 .file_name()
8560 .expect("config fixture has a file name")
8561 .to_string_lossy()
8562 ));
8563 let backup = std::fs::read_to_string(backup_path).expect("read cleared config backup");
8564 assert!(!backup.contains("[providers.antigravity"), "{backup}");
8565 assert!(!backup.contains("[providers.agy"), "{backup}");
8566 assert!(!backup.contains("legacy_fixture_extra"), "{backup}");
8567 assert!(
8568 !backup.contains(&external_session_path.to_string_lossy().to_string()),
8569 "{backup}"
8570 );
8571 assert!(
8572 backup.contains("base_url = \"https://google.example/v1\""),
8573 "{backup}"
8574 );
8575 assert!(
8576 backup.contains("model = \"google-fixture-model\""),
8577 "{backup}"
8578 );
8579
8580 let reloaded = ConfigStore::load(Some(config_path)).expect("reload cleared config");
8581 assert_eq!(reloaded.config.provider, ProviderKind::default());
8582 assert!(reloaded.config.providers.antigravity.is_empty());
8583 assert_eq!(
8584 reloaded.config.providers.google.api_key.as_deref(),
8585 Some("google-fixture-key")
8586 );
8587 }
8588
8589 #[test]
8590 fn antigravity_clear_restores_codewhale_secret_when_config_write_fails() {
8591 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8592 use std::sync::Arc;
8593
8594 let dir = tempfile::TempDir::new().expect("isolated rollback fixture");
8595 let config_path = dir.path().join("config.toml");
8596 let external_session_path = dir.path().join("external-session.db");
8597 let external_session = b"external session rollback trap";
8598 std::fs::write(&external_session_path, external_session)
8599 .expect("write external session trap");
8600 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load absent config");
8601 store.config.provider = ProviderKind::Antigravity;
8602 store.config.fallback_providers = vec![ProviderKind::Antigravity];
8603 store.config.providers.antigravity.api_key = Some("legacy-config-fixture".to_string());
8604 store.config.providers.antigravity.external_credentials =
8605 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8606 ProviderKind::Antigravity,
8607 codewhale_config::ExternalCredentialSource::AgyCli,
8608 external_session_path.clone(),
8609 ));
8610 std::fs::create_dir(&config_path).expect("make config target unwritable as a file");
8611
8612 let inner = Arc::new(InMemoryKeyringStore::new());
8613 inner
8614 .set("antigravity", "legacy-secret-fixture")
8615 .expect("seed Codewhale-owned legacy slot");
8616 let secrets = Secrets::new(inner.clone());
8617
8618 run_auth_command_with_secrets(
8619 &mut store,
8620 AuthCommand::Clear {
8621 provider: ProviderKind::Antigravity,
8622 },
8623 &secrets,
8624 )
8625 .expect_err("config failure must fail the clear transaction");
8626
8627 assert_eq!(store.config.provider, ProviderKind::Antigravity);
8628 assert_eq!(
8629 store.config.fallback_providers,
8630 vec![ProviderKind::Antigravity]
8631 );
8632 assert_eq!(
8633 store.config.providers.antigravity.api_key.as_deref(),
8634 Some("legacy-config-fixture")
8635 );
8636 assert!(
8637 store
8638 .config
8639 .providers
8640 .antigravity
8641 .external_credentials
8642 .is_some()
8643 );
8644 assert_eq!(
8645 inner
8646 .get("antigravity")
8647 .expect("read restored slot")
8648 .as_deref(),
8649 Some("legacy-secret-fixture")
8650 );
8651 assert_eq!(
8652 std::fs::read(external_session_path).expect("external session trap still exists"),
8653 external_session
8654 );
8655 }
8656
8657 #[test]
8658 fn auth_status_scoped_probe_and_list_all_provider_keyrings() {
8659 use codewhale_secrets::{KeyringStore, SecretsError};
8660 use std::sync::{Arc, Mutex};
8661
8662 #[derive(Default)]
8663 struct RecordingStore {
8664 gets: Mutex<Vec<String>>,
8665 }
8666
8667 impl KeyringStore for RecordingStore {
8668 fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
8669 self.gets.lock().unwrap().push(key.to_string());
8670 Ok(None)
8671 }
8672
8673 fn set(&self, _key: &str, _value: &str) -> Result<(), SecretsError> {
8674 Ok(())
8675 }
8676
8677 fn delete(&self, _key: &str) -> Result<(), SecretsError> {
8678 Ok(())
8679 }
8680
8681 fn backend_name(&self) -> &'static str {
8682 "recording"
8683 }
8684 }
8685
8686 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8687 let path = std::env::temp_dir().join(format!(
8688 "deepseek-cli-auth-active-keyring-test-{}-{nanos}.toml",
8689 std::process::id()
8690 ));
8691 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8692 store.config.provider = ProviderKind::Deepseek;
8693 let inner = Arc::new(RecordingStore::default());
8694 let secrets = Secrets::new(inner.clone());
8695
8696 run_auth_command_with_secrets(
8697 &mut store,
8698 AuthCommand::Status {
8699 provider: Some(ProviderKind::Deepseek),
8700 diagnostic: false,
8701 },
8702 &secrets,
8703 )
8704 .expect("status should succeed");
8705 run_auth_command_with_secrets(&mut store, AuthCommand::List, &secrets)
8706 .expect("list should succeed");
8707
8708 let probed = inner.gets.lock().unwrap();
8709 assert_eq!(probed[0], "deepseek");
8711 assert!(probed.len() > 1, "list should probe all providers");
8714 assert!(
8715 ProviderKind::ALL
8716 .iter()
8717 .all(|p| probed.contains(&provider_slot(*p).to_string())),
8718 "every known provider should be probed by auth list: {:?}",
8719 *probed
8720 );
8721
8722 let _ = std::fs::remove_file(path);
8723 }
8724
8725 #[test]
8726 fn auth_diagnostic_reports_paths_and_presence_without_values() {
8727 let _lock = env_lock();
8728 let fixture = tempfile::TempDir::new().expect("fixture root");
8729 let home = fixture
8734 .path()
8735 .canonicalize()
8736 .expect("canonical fixture root")
8737 .join("isolated-codewhale-home");
8738 let config_path = home.join("config.toml");
8739 let settings_path = home.join("settings.toml");
8740 let secret_path = home.join("secrets").join("secrets.json");
8741 std::fs::create_dir_all(secret_path.parent().expect("secret parent"))
8742 .expect("create diagnostic fixture");
8743 std::fs::write(
8744 &config_path,
8745 "api_key = \"diagnostic-config-secret-1234\"\n",
8746 )
8747 .expect("write config fixture");
8748 std::fs::write(&settings_path, "default_mode = \"plan\"\n")
8749 .expect("write settings fixture");
8750 std::fs::write(
8751 &secret_path,
8752 r#"{"deepseek":"diagnostic-store-secret-5678"}"#,
8753 )
8754 .expect("write secret fixture");
8755
8756 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
8757 let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file");
8758 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "diagnostic-env-secret-9012");
8759 let store = ConfigStore::load(Some(config_path.clone())).expect("load config fixture");
8760
8761 let output = auth_diagnostic_lines(&store, Some(ProviderKind::Deepseek)).join("\n");
8762 assert!(
8763 output.contains(&format!(
8764 "codewhale home: {} (source: CODEWHALE_HOME (isolated); state: present)",
8765 codewhale_config::quote_os_path(&home)
8766 )),
8767 "{output}"
8768 );
8769 assert!(
8770 output.contains(&format!(
8771 "config: {} (present)",
8772 codewhale_config::quote_os_path(&config_path)
8773 )),
8774 "{output}"
8775 );
8776 assert!(
8777 output.contains(&format!(
8778 "settings: {} (present)",
8779 codewhale_config::quote_os_path(&settings_path)
8780 )),
8781 "{output}"
8782 );
8783 assert!(
8784 output.contains("secret backend: file (inspection: metadata_only)"),
8785 "{output}"
8786 );
8787 assert!(
8788 output.contains(&format!(
8789 "secret store: {} (present)",
8790 codewhale_config::quote_os_path(&secret_path)
8791 )),
8792 "{output}"
8793 );
8794 assert!(
8795 output.contains("provider deepseek sources: config_literal=present, secret_backend=present (provider entry unprobed), environment=present (DEEPSEEK_API_KEY)"),
8796 "{output}"
8797 );
8798 assert!(
8799 output.contains("legacy secret store: suppressed by explicit CODEWHALE_HOME isolation"),
8800 "{output}"
8801 );
8802 for secret_fragment in [
8803 "diagnostic-config-secret",
8804 "diagnostic-store-secret",
8805 "diagnostic-env-secret",
8806 "1234",
8807 "5678",
8808 "9012",
8809 "last4",
8810 ] {
8811 assert!(
8812 !output.contains(secret_fragment),
8813 "diagnostic leaked {secret_fragment:?}: {output}"
8814 );
8815 }
8816 }
8817
8818 #[test]
8819 fn auth_status_reports_all_active_provider_sources_with_last4() {
8820 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8821 use std::sync::Arc;
8822
8823 let _lock = env_lock();
8824 let _env = ScopedEnvVar::set("DEEPSEEK_API_KEY", "sk-env-1111");
8825
8826 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8827 let path = std::env::temp_dir().join(format!(
8828 "deepseek-cli-auth-status-table-test-{}-{nanos}.toml",
8829 std::process::id()
8830 ));
8831 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8832 store.config.provider = ProviderKind::Deepseek;
8833 store.config.api_key = Some("sk-config-3333".to_string());
8834 store.config.providers.deepseek.api_key = Some("sk-config-3333".to_string());
8835
8836 let inner = Arc::new(InMemoryKeyringStore::new());
8837 inner.set("deepseek", "sk-keyring-2222").unwrap();
8838 let secrets = Secrets::new(inner);
8839
8840 let output =
8841 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Deepseek).join("\n");
8842
8843 assert!(output.contains("provider: deepseek"));
8844 assert!(output.contains("active source: config (last4: ...3333)"));
8845 assert!(output.contains("lookup order: config -> secret store -> env"));
8846 assert!(output.contains("config file: "));
8847 assert!(output.contains("set, last4: ...3333"));
8848 assert!(output.contains("secret store: in-memory (test) (set, last4: ...2222)"));
8849 assert!(output.contains("env var: DEEPSEEK_API_KEY (set, last4: ...1111)"));
8850 assert!(!output.contains("sk-config-3333"));
8851 assert!(!output.contains("sk-keyring-2222"));
8852 assert!(!output.contains("sk-env-1111"));
8853
8854 let _ = std::fs::remove_file(path);
8855 }
8856
8857 #[test]
8858 fn auth_status_all_providers_lists_every_known_provider() {
8859 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
8860 use std::sync::Arc;
8861
8862 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
8863 let path = std::env::temp_dir().join(format!(
8864 "deepseek-cli-auth-all-status-test-{}-{nanos}.toml",
8865 std::process::id()
8866 ));
8867 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
8868 store.config.provider = ProviderKind::Deepseek;
8869 store.config.providers.arcee.api_key = Some("sk-arcee-test1234".to_string());
8870
8871 let inner = Arc::new(InMemoryKeyringStore::new());
8872 inner.set("openrouter", "sk-or-test5678").unwrap();
8873 let secrets = Secrets::new(inner);
8874
8875 let output = auth_status_all_providers(&store, &secrets).join("\n");
8876
8877 assert!(output.contains("account:"), "{output}");
8878 assert!(output.contains("codewhale login"), "{output}");
8879 assert!(!output.to_lowercase().contains("daytona"), "{output}");
8882 assert!(!output.contains("set-slot"), "{output}");
8883
8884 assert!(output.contains("deepseek"));
8886 assert!(output.contains("arcee"));
8887 assert!(output.contains("openrouter"));
8888 assert!(output.contains("huggingface"));
8889 assert!(output.contains("ollama"));
8890
8891 assert!(output.contains("deepseek") && output.contains("*"));
8893
8894 assert!(output.contains("config"));
8896
8897 assert!(!output.contains("sk-arcee-test1234"));
8899 assert!(!output.contains("sk-or-test5678"));
8900
8901 let _ = std::fs::remove_file(path);
8902 }
8903
8904 #[test]
8905 fn auth_status_never_probes_codex_file_and_reports_exact_consent() {
8906 use codewhale_secrets::InMemoryKeyringStore;
8907 use std::sync::Arc;
8908
8909 let _lock = env_lock();
8910 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
8911 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
8912
8913 let dir = tempfile::TempDir::new().expect("tempdir");
8914 let config_path = dir.path().join("config.toml");
8915 let auth_path = dir.path().join("auth.json");
8916 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
8917 .expect("write auth file");
8918 let auth_path_str = auth_path.to_string_lossy().into_owned();
8919 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
8920
8921 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
8922 store.config.provider = ProviderKind::OpenaiCodex;
8923 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
8924
8925 let output =
8926 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
8927
8928 assert!(output.contains("provider: openai-codex"));
8929 assert!(output.contains("auth mode: codex_oauth"));
8930 assert!(output.contains("active source: missing"));
8931 assert!(output.contains(
8932 "lookup order: env -> Codewhale-owned ChatGPT sign-in -> consent-gated exact Codex CLI file"
8933 ));
8934 assert!(output.contains("external credentials: disabled"));
8935 assert!(output.contains("scope_valid=false"));
8936 assert!(output.contains("disabled; no external-credential probing, reading"));
8937 assert!(output.contains("file not probed"));
8938 assert!(!output.contains("secret-token"));
8939
8940 store.config.providers.openai_codex.external_credentials =
8941 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
8942 ProviderKind::OpenaiCodex,
8943 codewhale_config::ExternalCredentialSource::CodexCli,
8944 auth_path.clone(),
8945 ));
8946 let output =
8947 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
8948 assert!(
8949 output.contains("active source: external read-only consent (availability not probed)")
8950 );
8951 assert!(output.contains("external credentials: read_only"));
8952 assert!(output.contains("provider=openai-codex"));
8953 assert!(output.contains("source=codex_cli"));
8954 assert!(output.contains(&format!(
8955 "path={}",
8956 codewhale_config::quote_os_path(&auth_path)
8957 )));
8958 assert!(output.contains(&format!(
8959 "consent_version={}",
8960 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
8961 )));
8962 assert!(output.contains("file not probed"));
8963 assert!(!output.contains("secret-token"));
8964
8965 let ambient_path = dir.path().join("new-ambient-auth.json");
8966 let ambient_path_str = ambient_path.to_string_lossy().into_owned();
8967 let _ambient_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &ambient_path_str);
8968 let changed =
8969 auth_status_lines_for_provider(&store, &secrets, ProviderKind::OpenaiCodex).join("\n");
8970 assert!(changed.contains("state=active"), "{changed}");
8971 assert!(changed.contains("ambient_path_changed=true"), "{changed}");
8972 assert!(changed.contains("consent remains pinned"), "{changed}");
8973 assert!(
8974 changed.contains(&codewhale_config::quote_os_path(&auth_path)),
8975 "{changed}"
8976 );
8977 assert!(!changed.contains(&ambient_path_str), "{changed}");
8978 }
8979
8980 #[test]
8981 fn xai_valid_owned_generation_blocks_external_consent_without_storage_probes() {
8982 use std::sync::Arc;
8983
8984 let _lock = env_lock();
8985 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
8986 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
8987 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
8988 let dir = tempfile::TempDir::new().expect("tempdir");
8989 let config_path = dir.path().join("config.toml");
8990 let external_path = dir.path().join("grok-auth.json");
8991 let external_raw = "external owner bytes must not be read";
8992 std::fs::write(&external_path, external_raw).expect("external auth trap");
8993 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
8994
8995 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
8996 store.config.provider = ProviderKind::Xai;
8997 store.config.providers.xai.auth_mode = Some("oauth".to_string());
8998 store.config.providers.xai.oauth_credential_generation =
8999 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
9000 store.config.providers.xai.external_credentials =
9001 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9002 ProviderKind::Xai,
9003 codewhale_config::ExternalCredentialSource::GrokCli,
9004 external_path.clone(),
9005 ));
9006 let keyring = Arc::new(RecordingKeyringStore::default());
9007 let secrets = Secrets::new(keyring.clone());
9008
9009 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9010 assert!(
9011 scoped.contains(
9012 "credential route: Codewhale-owned OAuth configured/unprobed (valid generation pointer; storage unprobed)"
9013 ),
9014 "{scoped}"
9015 );
9016 assert!(scoped.contains("external credentials: blocked by the configured Codewhale-owned xAI OAuth generation"), "{scoped}");
9017 assert!(
9018 scoped.contains(
9019 "xAI OAuth generation: configured Codewhale-owned pointer (storage unprobed)"
9020 ),
9021 "{scoped}"
9022 );
9023 assert!(
9024 !scoped.contains("active source: Codewhale-owned OAuth"),
9025 "a valid pointer is configured/unprobed, not an active credential: {scoped}"
9026 );
9027 assert!(
9028 !scoped.contains("fallback"),
9029 "an owned generation must never advertise Grok CLI fallback: {scoped}"
9030 );
9031
9032 let all = auth_status_all_providers(&store, &secrets).join("\n");
9033 let xai_row = all
9034 .lines()
9035 .find(|line| line.starts_with("xai"))
9036 .expect("xAI status row");
9037 assert!(
9038 xai_row.contains("Codewhale-owned OAuth configured/unprobed"),
9039 "{xai_row}"
9040 );
9041
9042 let list = auth_list_lines(&store, &secrets).join("\n");
9043 let xai_list_row = list
9044 .lines()
9045 .find(|line| line.starts_with("xai"))
9046 .expect("xAI list row");
9047 assert!(
9048 xai_list_row.ends_with("owned-oauth-configured"),
9049 "{xai_list_row}"
9050 );
9051
9052 let get = auth_get_line_with_runtime(
9053 &store,
9054 &secrets,
9055 ProviderKind::Xai,
9056 &CliRuntimeOverrides::default(),
9057 );
9058 assert!(
9059 get.starts_with("xai: configured (source: Codewhale-owned OAuth generation"),
9060 "{get}"
9061 );
9062 assert!(!get.starts_with("xai: set"), "{get}");
9063 assert!(!get.contains("fallback"), "{get}");
9064 assert!(
9065 !keyring.queried().iter().any(|slot| slot == "xai"),
9066 "owned OAuth diagnostics must not query the xAI API-key store: {:?}",
9067 keyring.queried()
9068 );
9069 assert_eq!(
9070 std::fs::read_to_string(external_path).expect("external trap unchanged"),
9071 external_raw
9072 );
9073
9074 store.config.providers.xai.auth_mode = None;
9075 store.config.auth_mode = Some("oauth".to_string());
9076 assert_eq!(
9077 xai_auth_diagnostics(&store, &CliRuntimeOverrides::default()).route,
9078 XaiAuthDiagnosticRoute::ApiKey,
9079 "a root auth mode must not select the xAI OAuth runtime route"
9080 );
9081 }
9082
9083 #[test]
9084 fn xai_invalid_generation_requires_repair_blocks_external_and_keeps_api_key_diagnostics() {
9085 use std::sync::Arc;
9086
9087 let _lock = env_lock();
9088 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
9089 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9090 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9091 let dir = tempfile::TempDir::new().expect("tempdir");
9092 let config_path = dir.path().join("config.toml");
9093 let external_path = dir.path().join("grok-auth.json");
9094 let external_raw = "external owner bytes must remain unread";
9095 std::fs::write(&external_path, external_raw).expect("external auth trap");
9096 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9097
9098 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9099 store.config.provider = ProviderKind::Xai;
9100 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9101 store.config.providers.xai.api_key = Some("fake-cfg-key-1234".to_string());
9102 store.config.providers.xai.oauth_credential_generation = Some("../unsafe.json".to_string());
9103 store.config.providers.xai.external_credentials =
9104 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9105 ProviderKind::Xai,
9106 codewhale_config::ExternalCredentialSource::GrokCli,
9107 external_path.clone(),
9108 ));
9109 let keyring = Arc::new(RecordingKeyringStore::default());
9110 let secrets = Secrets::new(keyring.clone());
9111
9112 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9113 assert!(
9114 scoped.contains("credential route: xAI OAuth needs repair"),
9115 "{scoped}"
9116 );
9117 assert!(
9118 scoped.contains("API-key fallback: config (last4: ...1234)"),
9119 "{scoped}"
9120 );
9121 assert!(scoped.contains("external credentials: blocked by the invalid Codewhale-owned xAI OAuth generation pointer"), "{scoped}");
9122 assert!(
9123 scoped.contains("repair: run `codewhale auth xai-device`"),
9124 "{scoped}"
9125 );
9126 assert!(
9127 !scoped.contains("external read-only consent (availability not probed)"),
9128 "invalid owned pointers must not activate Grok CLI consent: {scoped}"
9129 );
9130
9131 let all = auth_status_all_providers(&store, &secrets).join("\n");
9132 let xai_row = all
9133 .lines()
9134 .find(|line| line.starts_with("xai"))
9135 .expect("xAI status row");
9136 assert!(xai_row.contains("needs repair"), "{xai_row}");
9137 assert!(xai_row.contains("API-key fallback: config"), "{xai_row}");
9138
9139 let list = auth_list_lines(&store, &secrets).join("\n");
9140 let xai_list_row = list
9141 .lines()
9142 .find(|line| line.starts_with("xai"))
9143 .expect("xAI list row");
9144 assert!(xai_list_row.ends_with("needs-repair"), "{xai_list_row}");
9145
9146 let get = auth_get_line_with_runtime(
9147 &store,
9148 &secrets,
9149 ProviderKind::Xai,
9150 &CliRuntimeOverrides::default(),
9151 );
9152 assert!(get.contains("xai: needs repair"), "{get}");
9153 assert!(get.contains("API-key fallback: config-file"), "{get}");
9154 assert!(
9155 !keyring.queried().iter().any(|slot| slot == "xai"),
9156 "an invalid owned pointer must not query the xAI API-key store: {:?}",
9157 keyring.queried()
9158 );
9159 assert_eq!(
9160 std::fs::read_to_string(external_path).expect("external trap unchanged"),
9161 external_raw
9162 );
9163 }
9164
9165 #[test]
9166 fn xai_cli_custom_endpoint_rejects_inherited_api_key_sources() {
9167 use std::sync::Arc;
9168
9169 let _lock = env_lock();
9170 let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-3333");
9171 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9172 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9173 let dir = tempfile::TempDir::new().expect("tempdir");
9174 let config_path = dir.path().join("config.toml");
9175 let external_path = dir.path().join("grok-auth.json");
9176 let external_raw = "external owner bytes must remain unprobed";
9177 std::fs::write(&external_path, external_raw).expect("external auth trap");
9178 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9179
9180 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9181 store.config.provider = ProviderKind::Xai;
9182 store.config.providers.xai.api_key = Some("fake-cfg-key-1111".to_string());
9183 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9184 store.config.providers.xai.oauth_credential_generation =
9185 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
9186 store.config.providers.xai.external_credentials =
9187 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9188 ProviderKind::Xai,
9189 codewhale_config::ExternalCredentialSource::GrokCli,
9190 external_path.clone(),
9191 ));
9192 let keyring = Arc::new(RecordingKeyringStore::default());
9193 keyring.set_value("xai", "fake-store-key-2222");
9194 let secrets = Secrets::new(keyring.clone());
9195 let runtime_overrides = CliRuntimeOverrides {
9196 base_url: Some("https://gateway.example.test/v1".to_string()),
9197 ..CliRuntimeOverrides::default()
9198 };
9199
9200 let scoped = auth_status_lines_for_provider_with_runtime(
9201 &store,
9202 &secrets,
9203 ProviderKind::Xai,
9204 &runtime_overrides,
9205 )
9206 .join("\n");
9207 assert!(
9208 scoped.contains("route: https://gateway.example.test/v1"),
9209 "{scoped}"
9210 );
9211 assert!(scoped.contains("credential route: missing"), "{scoped}");
9212 assert!(
9213 scoped.contains("custom xAI endpoint; API-key-only"),
9214 "{scoped}"
9215 );
9216 assert!(
9217 scoped.contains("not eligible for this custom xAI endpoint"),
9218 "{scoped}"
9219 );
9220 assert!(
9221 scoped.contains("external credentials: unavailable on a custom xAI endpoint"),
9222 "{scoped}"
9223 );
9224 for redacted_tail in ["...1111", "...2222", "...3333"] {
9225 assert!(
9226 !scoped.contains(redacted_tail),
9227 "custom CLI route must not advertise an inherited credential: {scoped}"
9228 );
9229 }
9230
9231 let all =
9232 auth_status_all_providers_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
9233 let xai_row = all
9234 .lines()
9235 .find(|line| line.starts_with("xai"))
9236 .expect("xAI status row");
9237 assert!(xai_row.contains("unset"), "{xai_row}");
9238 assert!(
9239 !xai_row.contains("config") && !xai_row.contains("keyring") && !xai_row.contains("env"),
9240 "xAI summary must show runtime-effective sources only: {xai_row}"
9241 );
9242
9243 let list = auth_list_lines_with_runtime(&store, &secrets, &runtime_overrides).join("\n");
9244 let xai_list_row = list
9245 .lines()
9246 .find(|line| line.starts_with("xai"))
9247 .expect("xAI list row");
9248 assert!(xai_list_row.ends_with("missing"), "{xai_list_row}");
9249
9250 let get =
9251 auth_get_line_with_runtime(&store, &secrets, ProviderKind::Xai, &runtime_overrides);
9252 assert_eq!(get, "xai: not set");
9253 assert!(
9254 !keyring.queried().iter().any(|slot| slot == "xai"),
9255 "a global custom endpoint must not query xAI keyring state: {:?}",
9256 keyring.queried()
9257 );
9258 assert_eq!(
9259 std::fs::read_to_string(external_path).expect("external trap unchanged"),
9260 external_raw
9261 );
9262 }
9263
9264 #[test]
9265 fn xai_env_custom_endpoint_rejects_inherited_api_key_sources() {
9266 use std::sync::Arc;
9267
9268 let _lock = env_lock();
9269 let _xai_key = ScopedEnvVar::set("XAI_API_KEY", "fake-ambient-key-6666");
9270 let _xai_base = ScopedEnvVar::set("XAI_BASE_URL", "https://env-gateway.example.test/v1");
9271 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9272 let dir = tempfile::TempDir::new().expect("tempdir");
9273 let config_path = dir.path().join("config.toml");
9274 let external_path = dir.path().join("grok-auth.json");
9275 let external_raw = "external owner bytes must remain unprobed";
9276 std::fs::write(&external_path, external_raw).expect("external auth trap");
9277 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9278
9279 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9280 store.config.provider = ProviderKind::Xai;
9281 store.config.providers.xai.api_key = Some("fake-cfg-key-4444".to_string());
9282 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9283 store.config.providers.xai.oauth_credential_generation =
9284 Some("xai-auth-0123456789abcdef0123456789abcdef.json".to_string());
9285 store.config.providers.xai.external_credentials =
9286 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9287 ProviderKind::Xai,
9288 codewhale_config::ExternalCredentialSource::GrokCli,
9289 external_path.clone(),
9290 ));
9291 let keyring = Arc::new(RecordingKeyringStore::default());
9292 keyring.set_value("xai", "fake-store-key-5555");
9293 let secrets = Secrets::new(keyring.clone());
9294
9295 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9296 assert!(
9297 scoped.contains("route: https://env-gateway.example.test/v1"),
9298 "{scoped}"
9299 );
9300 assert!(scoped.contains("credential route: missing"), "{scoped}");
9301 assert!(
9302 scoped.contains("custom xAI endpoint; API-key-only"),
9303 "{scoped}"
9304 );
9305 for redacted_tail in ["...4444", "...5555", "...6666"] {
9306 assert!(
9307 !scoped.contains(redacted_tail),
9308 "custom env route must not advertise an inherited credential: {scoped}"
9309 );
9310 }
9311
9312 let all = auth_status_all_providers(&store, &secrets).join("\n");
9313 let xai_row = all
9314 .lines()
9315 .find(|line| line.starts_with("xai"))
9316 .expect("xAI status row");
9317 assert!(xai_row.contains("unset"), "{xai_row}");
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!(xai_list_row.ends_with("missing"), "{xai_list_row}");
9325
9326 assert_eq!(
9327 auth_get_line_with_runtime(
9328 &store,
9329 &secrets,
9330 ProviderKind::Xai,
9331 &CliRuntimeOverrides::default(),
9332 ),
9333 "xai: not set"
9334 );
9335 assert!(
9336 !keyring.queried().iter().any(|slot| slot == "xai"),
9337 "an XAI_BASE_URL custom route must not query xAI keyring state: {:?}",
9338 keyring.queried()
9339 );
9340 assert_eq!(
9341 std::fs::read_to_string(external_path).expect("external trap unchanged"),
9342 external_raw
9343 );
9344 }
9345
9346 #[test]
9347 fn xai_config_bound_custom_endpoint_uses_its_route_key() {
9348 use std::sync::Arc;
9349
9350 let _lock = env_lock();
9351 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
9352 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9353 let dir = tempfile::TempDir::new().expect("tempdir");
9354 let config_path = dir.path().join("config.toml");
9355 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9356 store.config.provider = ProviderKind::Xai;
9357 store.config.providers.xai.base_url =
9358 Some("https://bound-gateway.example.test/v1".to_string());
9359 store.config.providers.xai.api_key = Some("fake-bound-key-7777".to_string());
9360 let keyring = Arc::new(RecordingKeyringStore::default());
9361 keyring.set_value("xai", "fake-store-key-8888");
9362 let secrets = Secrets::new(keyring.clone());
9363
9364 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9365 assert!(
9366 scoped.contains("credential route: config (last4: ...7777)"),
9367 "{scoped}"
9368 );
9369 assert!(
9370 scoped.contains("config file:") && scoped.contains("runtime-effective, last4: ...7777"),
9371 "{scoped}"
9372 );
9373 assert_eq!(
9374 auth_get_line_with_runtime(
9375 &store,
9376 &secrets,
9377 ProviderKind::Xai,
9378 &CliRuntimeOverrides::default(),
9379 ),
9380 "xai: set (source: config-file)"
9381 );
9382 assert!(
9383 !keyring.queried().iter().any(|slot| slot == "xai"),
9384 "an endpoint-bound config key should resolve before the xAI keyring: {:?}",
9385 keyring.queried()
9386 );
9387 }
9388
9389 #[test]
9390 fn xai_absent_generation_with_consent_is_external_configured_and_unprobed() {
9391 use std::sync::Arc;
9392
9393 let _lock = env_lock();
9394 let _xai_key = ScopedEnvVar::remove("XAI_API_KEY");
9395 let _xai_base = ScopedEnvVar::remove("XAI_BASE_URL");
9396 let _auth_mode = ScopedEnvVar::remove("DEEPSEEK_AUTH_MODE");
9397 let dir = tempfile::TempDir::new().expect("tempdir");
9398 let config_path = dir.path().join("config.toml");
9399 let external_path = dir.path().join("grok-auth.json");
9400 let external_raw = "external owner bytes remain unprobed";
9401 std::fs::write(&external_path, external_raw).expect("external auth trap");
9402 let _grok_auth_path = ScopedEnvVar::set("GROK_AUTH_PATH", &external_path.to_string_lossy());
9403
9404 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9405 store.config.provider = ProviderKind::Xai;
9406 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9407 store.config.providers.xai.external_credentials =
9408 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9409 ProviderKind::Xai,
9410 codewhale_config::ExternalCredentialSource::GrokCli,
9411 external_path.clone(),
9412 ));
9413 let keyring = Arc::new(RecordingKeyringStore::default());
9414 let secrets = Secrets::new(keyring.clone());
9415
9416 let scoped = auth_status_lines_for_provider(&store, &secrets, ProviderKind::Xai).join("\n");
9417 assert!(
9418 scoped.contains("credential route: external read-only consent configured/unprobed"),
9419 "{scoped}"
9420 );
9421 assert!(
9422 scoped.contains("external credentials: read_only"),
9423 "{scoped}"
9424 );
9425 assert!(
9426 scoped.contains(
9427 "lookup order: configured consent-gated exact Grok CLI file (availability unprobed)"
9428 ),
9429 "{scoped}"
9430 );
9431
9432 let all = auth_status_all_providers(&store, &secrets).join("\n");
9433 let xai_row = all
9434 .lines()
9435 .find(|line| line.starts_with("xai"))
9436 .expect("xAI status row");
9437 assert!(
9438 xai_row.contains("external consent configured/unprobed"),
9439 "{xai_row}"
9440 );
9441
9442 let list = auth_list_lines(&store, &secrets).join("\n");
9443 let xai_list_row = list
9444 .lines()
9445 .find(|line| line.starts_with("xai"))
9446 .expect("xAI list row");
9447 assert!(
9448 xai_list_row.ends_with("external-consent-configured"),
9449 "{xai_list_row}"
9450 );
9451
9452 let get = auth_get_line_with_runtime(
9453 &store,
9454 &secrets,
9455 ProviderKind::Xai,
9456 &CliRuntimeOverrides::default(),
9457 );
9458 assert!(
9459 get.contains("source: external read-only consent; availability unprobed"),
9460 "{get}"
9461 );
9462 assert!(
9463 !keyring.queried().iter().any(|slot| slot == "xai"),
9464 "external-consent diagnostics must not query the xAI API-key store: {:?}",
9465 keyring.queried()
9466 );
9467 assert_eq!(
9468 std::fs::read_to_string(external_path).expect("external trap unchanged"),
9469 external_raw
9470 );
9471 }
9472
9473 #[test]
9474 fn auth_list_uses_persisted_consent_without_probing_codex_file() {
9475 use codewhale_secrets::InMemoryKeyringStore;
9476 use std::sync::Arc;
9477
9478 let _lock = env_lock();
9479 let _access_token = ScopedEnvVar::set("OPENAI_CODEX_ACCESS_TOKEN", "");
9480 let _codex_token = ScopedEnvVar::set("CODEX_ACCESS_TOKEN", "");
9481
9482 let dir = tempfile::TempDir::new().expect("tempdir");
9483 let config_path = dir.path().join("config.toml");
9484 let auth_path = dir.path().join("auth.json");
9485 std::fs::write(&auth_path, r#"{"tokens":{"access_token":"secret-token"}}"#)
9486 .expect("write auth file");
9487 let auth_path_str = auth_path.to_string_lossy().into_owned();
9488 let _auth_file = ScopedEnvVar::set("OPENAI_CODEX_AUTH_FILE", &auth_path_str);
9489
9490 let mut store = ConfigStore::load(Some(config_path)).expect("store should load");
9491 store.config.provider = ProviderKind::OpenaiCodex;
9492 store.config.providers.openai_codex.external_credentials =
9493 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9494 ProviderKind::OpenaiCodex,
9495 codewhale_config::ExternalCredentialSource::CodexCli,
9496 auth_path,
9497 ));
9498 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
9499
9500 let output = auth_list_lines(&store, &secrets).join("\n");
9501 let row = output
9502 .lines()
9503 .find(|line| line.starts_with("openai-codex"))
9504 .unwrap_or_else(|| panic!("missing openai-codex row:\n{output}"));
9505 assert!(row.ends_with("external-consent"), "{row}");
9506 assert!(!output.contains("secret-token"));
9507 }
9508
9509 #[test]
9510 fn auth_list_labels_each_row_by_its_own_provider() {
9511 let _lock = env_lock();
9518 let dir = tempfile::TempDir::new().expect("tempdir");
9519 let store =
9520 ConfigStore::load(Some(dir.path().join("config.toml"))).expect("store should load");
9521 let secrets = Secrets::new(std::sync::Arc::new(
9522 codewhale_secrets::InMemoryKeyringStore::new(),
9523 ));
9524
9525 let lines = auth_list_lines(&store, &secrets);
9526 let labels: Vec<&str> = lines
9527 .iter()
9528 .skip(1)
9529 .filter_map(|line| line.split_whitespace().next())
9530 .collect();
9531
9532 assert_eq!(
9533 labels.len(),
9534 ProviderKind::ALL.len(),
9535 "one row per provider kind: {labels:?}"
9536 );
9537 let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
9538 assert_eq!(
9539 unique.len(),
9540 labels.len(),
9541 "every row must name its own provider, not a shared slot: {labels:?}"
9542 );
9543 }
9544
9545 #[test]
9546 fn external_consent_persists_exact_scope_and_api_key_or_revoke_disables_it() {
9547 let _lock = env_lock();
9548 let dir = tempfile::TempDir::new().expect("tempdir");
9549 let home = dir
9550 .path()
9551 .canonicalize()
9552 .expect("canonical temp root")
9553 .join("codewhale-home");
9554 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9555 let config_path = dir.path().join("config.toml");
9556 let external_path = dir.path().join("grok-auth.json");
9557 let external_raw = r#"{"secret":"must-never-be-read-or-written"}"#;
9558 std::fs::write(&external_path, external_raw).expect("external auth trap");
9559 let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
9560 let secrets = no_keyring_secrets();
9561
9562 let preview = external_consent_preview_lines(
9563 ProviderKind::Xai,
9564 codewhale_config::ExternalCredentialSource::GrokCli,
9565 &external_path,
9566 )
9567 .join("\n");
9568 assert!(preview.contains("owning CLI: Grok CLI"), "{preview}");
9569 assert!(
9570 preview.contains(&format!(
9571 "exact resolved path: {}",
9572 codewhale_config::quote_os_path(&external_path)
9573 )),
9574 "{preview}"
9575 );
9576 assert!(preview.contains("no refresh, identity-provider or discovery requests"));
9577 assert!(preview.contains("normal requests to the explicitly selected provider"));
9578 assert!(preview.contains("managed: unavailable"));
9579
9580 let mut prompt = Vec::new();
9581 confirm_external_consent_answer(&mut "yes\n".as_bytes(), &mut prompt)
9582 .expect("exact yes confirms");
9583 assert!(
9584 String::from_utf8(prompt)
9585 .unwrap()
9586 .contains("exact read-only")
9587 );
9588 let cancelled = confirm_external_consent_answer(&mut "YES\n".as_bytes(), &mut Vec::new())
9589 .expect_err("confirmation is deliberate and case-sensitive");
9590 assert!(cancelled.to_string().contains("cancelled"));
9591
9592 let unconfirmed = run_auth_command_with_secrets(
9593 &mut store,
9594 AuthCommand::ExternalConsent {
9595 provider: ProviderKind::Xai,
9596 mode: ExternalCredentialModeArg::ReadOnly,
9597 path: Some(external_path.clone()),
9598 yes: false,
9599 },
9600 &secrets,
9601 )
9602 .expect_err("non-interactive consent requires --yes");
9603 assert!(unconfirmed.to_string().contains("requires explicit --yes"));
9604 assert!(store.config.providers.xai.external_credentials.is_none());
9605 assert!(
9606 !config_path.exists(),
9607 "unconfirmed consent must not persist"
9608 );
9609
9610 run_auth_command_with_secrets(
9611 &mut store,
9612 AuthCommand::ExternalConsent {
9613 provider: ProviderKind::Xai,
9614 mode: ExternalCredentialModeArg::ReadOnly,
9615 path: Some(external_path.clone()),
9616 yes: true,
9617 },
9618 &secrets,
9619 )
9620 .expect("read-only consent should persist");
9621
9622 let consent = store
9623 .config
9624 .providers
9625 .xai
9626 .external_credentials
9627 .as_ref()
9628 .expect("persisted consent");
9629 assert_eq!(
9630 consent.access,
9631 codewhale_config::ExternalCredentialAccess::ReadOnly
9632 );
9633 assert_eq!(consent.provider, ProviderKind::Xai.as_str());
9634 assert_eq!(
9635 consent.source,
9636 codewhale_config::ExternalCredentialSource::GrokCli
9637 );
9638 assert_eq!(consent.path, external_path);
9639 assert_eq!(
9640 consent.consent_version,
9641 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
9642 );
9643 assert_eq!(
9644 store.config.providers.xai.auth_mode.as_deref(),
9645 Some("oauth")
9646 );
9647 assert_eq!(
9648 std::fs::read_to_string(&consent.path).expect("external file unchanged"),
9649 external_raw
9650 );
9651
9652 let reloaded = ConfigStore::load(Some(config_path.clone())).expect("reload consent");
9653 let reloaded_consent = reloaded
9654 .config
9655 .providers
9656 .xai
9657 .external_credentials
9658 .as_ref()
9659 .expect("reloaded exact consent");
9660 assert_eq!(reloaded_consent.provider, ProviderKind::Xai.as_str());
9661 assert_eq!(
9662 reloaded_consent.source,
9663 codewhale_config::ExternalCredentialSource::GrokCli
9664 );
9665 assert_eq!(reloaded_consent.path, external_path);
9666 assert_eq!(
9667 reloaded_consent.consent_version,
9668 codewhale_config::EXTERNAL_CREDENTIAL_CONSENT_VERSION
9669 );
9670
9671 run_auth_command_with_secrets(
9672 &mut store,
9673 AuthCommand::Set {
9674 provider: ProviderKind::Xai,
9675 api_key: Some("xai-codewhale-owned-key".to_string()),
9676 api_key_stdin: false,
9677 },
9678 &secrets,
9679 )
9680 .expect("Codewhale-owned API key should supersede external consent");
9681 assert!(store.config.providers.xai.external_credentials.is_none());
9682 assert_eq!(
9683 std::fs::read_to_string(&external_path).expect("external file still unchanged"),
9684 external_raw
9685 );
9686
9687 run_auth_command_with_secrets(
9688 &mut store,
9689 AuthCommand::ExternalConsent {
9690 provider: ProviderKind::Xai,
9691 mode: ExternalCredentialModeArg::ReadOnly,
9692 path: Some(external_path.clone()),
9693 yes: true,
9694 },
9695 &secrets,
9696 )
9697 .expect("consent can be granted again");
9698 run_auth_command_with_secrets(
9699 &mut store,
9700 AuthCommand::ExternalRevoke {
9701 provider: ProviderKind::Xai,
9702 },
9703 &secrets,
9704 )
9705 .expect("revoke should persist");
9706 assert!(store.config.providers.xai.external_credentials.is_none());
9707 assert_eq!(
9708 std::fs::read_to_string(&external_path).expect("revoke never touches external file"),
9709 external_raw
9710 );
9711 }
9712
9713 #[test]
9714 fn unsupported_managed_and_kimi_external_consent_fail_closed() {
9715 let dir = tempfile::TempDir::new().expect("tempdir");
9716 let config_path = dir.path().join("config.toml");
9717 let external_path = dir.path().join("external-auth.json");
9718 std::fs::write(&external_path, "must remain unchanged").expect("external fixture");
9719 let mut store = ConfigStore::load(Some(config_path.clone())).expect("store should load");
9720 let secrets = no_keyring_secrets();
9721
9722 let managed = run_auth_command_with_secrets(
9723 &mut store,
9724 AuthCommand::ExternalConsent {
9725 provider: ProviderKind::OpenaiCodex,
9726 mode: ExternalCredentialModeArg::Managed,
9727 path: Some(external_path.clone()),
9728 yes: true,
9729 },
9730 &secrets,
9731 )
9732 .expect_err("managed access must fail without a preservation adapter");
9733 assert!(
9734 managed
9735 .to_string()
9736 .contains("schema-safe preservation adapter")
9737 );
9738
9739 let kimi = run_auth_command_with_secrets(
9740 &mut store,
9741 AuthCommand::ExternalConsent {
9742 provider: ProviderKind::Moonshot,
9743 mode: ExternalCredentialModeArg::ReadOnly,
9744 path: Some(external_path.clone()),
9745 yes: true,
9746 },
9747 &secrets,
9748 )
9749 .expect_err("Kimi must remain API-key-only");
9750 assert!(kimi.to_string().contains("API-key-only"));
9751 assert!(
9752 kimi.to_string()
9753 .contains("https://platform.kimi.ai/console/api-keys")
9754 );
9755 assert!(
9756 store
9757 .config
9758 .providers
9759 .openai_codex
9760 .external_credentials
9761 .is_none()
9762 );
9763 assert!(
9764 store
9765 .config
9766 .providers
9767 .moonshot
9768 .external_credentials
9769 .is_none()
9770 );
9771 assert_eq!(
9772 std::fs::read_to_string(external_path).expect("external fixture unchanged"),
9773 "must remain unchanged"
9774 );
9775 assert!(
9776 !config_path.exists(),
9777 "rejected consent must not write config"
9778 );
9779 }
9780
9781 #[test]
9782 fn api_key_config_failure_restores_absent_and_existing_secret_state() {
9783 let _lock = env_lock();
9784 for prior in [None, Some("prior-xai-key")] {
9785 let dir = tempfile::TempDir::new().expect("tempdir");
9786 let home = dir
9787 .path()
9788 .canonicalize()
9789 .expect("canonical temp root")
9790 .join("codewhale-home");
9791 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9792 let config_path = dir.path().join("config.toml");
9793 let mut store = ConfigStore::load(Some(config_path.clone())).expect("load store");
9794 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9795 store.config.providers.xai.external_credentials =
9796 Some(codewhale_config::ExternalCredentialConsentToml::read_only(
9797 ProviderKind::Xai,
9798 codewhale_config::ExternalCredentialSource::GrokCli,
9799 dir.path().join("external.json"),
9800 ));
9801 std::fs::create_dir(&config_path).expect("turn config target into a directory");
9802 let secrets = no_keyring_secrets();
9803 if let Some(prior) = prior {
9804 secrets.set("xai", prior).expect("seed prior secret");
9805 }
9806
9807 let error = run_auth_command_with_secrets(
9808 &mut store,
9809 AuthCommand::Set {
9810 provider: ProviderKind::Xai,
9811 api_key: Some("new-xai-key".to_string()),
9812 api_key_stdin: false,
9813 },
9814 &secrets,
9815 )
9816 .expect_err("config write must fail");
9817 assert!(error.to_string().contains("config"), "{error:#}");
9818 assert_eq!(
9819 secrets.get("xai").expect("restored secret"),
9820 prior.map(str::to_string)
9821 );
9822 assert_eq!(
9823 store.config.providers.xai.auth_mode.as_deref(),
9824 Some("oauth")
9825 );
9826 assert!(store.config.providers.xai.external_credentials.is_some());
9827 assert!(store.config.providers.xai.api_key.is_none());
9828 assert!(config_path.is_dir());
9829 }
9830 }
9831
9832 #[test]
9833 fn auth_status_scoped_provider_shows_detailed_info() {
9834 use codewhale_secrets::InMemoryKeyringStore;
9835 use std::sync::Arc;
9836
9837 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
9838 let path = std::env::temp_dir().join(format!(
9839 "deepseek-cli-auth-scoped-test-{}-{nanos}.toml",
9840 std::process::id()
9841 ));
9842 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9843 store.config.provider = ProviderKind::Deepseek;
9844 store.config.providers.arcee.api_key = Some("sk-arcee-9999".to_string());
9845
9846 let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
9847
9848 let output =
9849 auth_status_lines_for_provider(&store, &secrets, ProviderKind::Arcee).join("\n");
9850
9851 assert!(output.contains("provider: arcee"));
9852 assert!(output.contains("active source: config (last4: ...9999)"));
9853 assert!(output.contains("route:"));
9854 assert!(output.contains("model:"));
9855 assert!(!output.contains("sk-arcee-9999"));
9856
9857 for sentinel in [codewhale_config::API_KEYRING_SENTINEL, " __KEYRING__ "] {
9858 store.config.providers.arcee.api_key = Some(sentinel.to_string());
9859 assert_eq!(provider_config_api_key(&store, ProviderKind::Arcee), None);
9860 }
9861
9862 let _ = std::fs::remove_file(path);
9863 }
9864
9865 #[test]
9866 fn dispatch_uses_secret_store_without_rehydrating_plaintext_config() {
9867 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
9868 use std::sync::Arc;
9869
9870 let _lock = env_lock();
9874 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
9875 let path = std::env::temp_dir().join(format!(
9876 "deepseek-cli-dispatch-keyring-heal-test-{}-{nanos}.toml",
9877 std::process::id()
9878 ));
9879 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9880 let inner = Arc::new(InMemoryKeyringStore::new());
9881 inner.set("deepseek", "ring-key").unwrap();
9882 let secrets = Secrets::new(inner);
9883
9884 let resolved = resolve_runtime_for_dispatch_with_secrets(
9885 &mut store,
9886 &CliRuntimeOverrides::default(),
9887 &secrets,
9888 );
9889
9890 assert_eq!(resolved.api_key.as_deref(), Some("ring-key"));
9891 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
9892 assert!(store.config.api_key.is_none());
9893 assert!(store.config.providers.deepseek.api_key.is_none());
9894 assert!(
9895 !path.exists(),
9896 "dispatch must not create config from a stored key"
9897 );
9898
9899 let resolved_again = resolve_runtime_for_dispatch_with_secrets(
9900 &mut store,
9901 &CliRuntimeOverrides::default(),
9902 &secrets,
9903 );
9904 assert_eq!(resolved_again.api_key.as_deref(), Some("ring-key"));
9905 assert_eq!(
9906 resolved_again.api_key_source,
9907 Some(RuntimeApiKeySource::Keyring)
9908 );
9909 assert!(
9910 !path.exists(),
9911 "repeat dispatch must remain credential-file free"
9912 );
9913
9914 let _ = std::fs::remove_file(path);
9915 }
9916
9917 #[test]
9918 fn logout_removes_plaintext_provider_keys() {
9919 let _lock = env_lock();
9920 let dir = tempfile::TempDir::new().expect("tempdir");
9921 let home = dir
9922 .path()
9923 .canonicalize()
9924 .expect("canonical temp root")
9925 .join("codewhale-home");
9926 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9927 let path = home.join("config.toml");
9928 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9929 store.config.api_key = Some("sk-stale".to_string());
9930 store.config.providers.deepseek.api_key = Some("sk-stale".to_string());
9931 store.config.providers.fireworks.api_key = Some("fw-stale".to_string());
9932 store.config.providers.xai.auth_mode = Some("oauth".to_string());
9933 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json";
9934 store.config.providers.xai.oauth_credential_generation = Some(generation.to_string());
9935 store.save().unwrap();
9936 let credentials = home.join("credentials");
9937 codewhale_config::with_xai_oauth_lifecycle_lock(|owned| {
9938 owned.write(generation, b"xai-generation", false)?;
9939 owned.write(
9940 codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
9941 b"legacy-xai",
9942 false,
9943 )?;
9944 Ok(())
9945 })
9946 .expect("seed Codewhale-owned xAI credentials");
9947 std::fs::write(credentials.join("other-provider.json"), "preserve").unwrap();
9948
9949 let secrets = no_keyring_secrets();
9950
9951 run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
9952
9953 assert!(store.config.api_key.is_none());
9954 assert!(store.config.providers.deepseek.api_key.is_none());
9955 assert!(store.config.providers.fireworks.api_key.is_none());
9956 assert!(store.config.providers.xai.auth_mode.is_none());
9957 assert!(
9958 store
9959 .config
9960 .providers
9961 .xai
9962 .oauth_credential_generation
9963 .is_none()
9964 );
9965 assert!(!credentials.join(generation).exists());
9966 assert!(!credentials.join("xai-auth.json").exists());
9967 assert!(credentials.join("other-provider.json").exists());
9968
9969 let _ = std::fs::remove_file(path);
9970 }
9971
9972 #[test]
9973 fn logout_clears_keyring_credentials_for_all_providers() {
9974 let _lock = env_lock();
9978 let dir = tempfile::TempDir::new().expect("tempdir");
9979 let home = dir
9980 .path()
9981 .canonicalize()
9982 .expect("canonical temp root")
9983 .join("codewhale-home");
9984 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
9985 let path = home.join("config.toml");
9986 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
9987 store.config.provider = ProviderKind::Deepseek;
9988
9989 let secrets = no_keyring_secrets();
9990 secrets
9991 .set(provider_slot(ProviderKind::Deepseek), "sk-deepseek")
9992 .expect("seed deepseek key");
9993 secrets
9994 .set(provider_slot(ProviderKind::Fireworks), "fw-stale")
9995 .expect("seed fireworks key");
9996
9997 run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
9998
9999 for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
10000 assert!(
10001 provider_keyring_api_key(&secrets, provider).is_none(),
10002 "keyring credential for {provider:?} survived logout"
10003 );
10004 }
10005
10006 let _ = std::fs::remove_file(path);
10007 }
10008
10009 #[test]
10010 fn logout_clears_account_session_and_daytona_slot() {
10011 use codewhale_secrets::account::{
10012 AccountAuthBundle, AccountSession, AccountSessionStore, AccountUser,
10013 DEFAULT_ACCOUNT_API_BASE, secure_account_session_secrets,
10014 };
10015
10016 let _lock = env_lock();
10017 let dir = tempfile::TempDir::new().expect("tempdir");
10018 let home = dir
10019 .path()
10020 .canonicalize()
10021 .expect("canonical temp root")
10022 .join("codewhale-home");
10023 let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
10024 let path = home.join("config.toml");
10025 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10026
10027 let secrets = no_keyring_secrets();
10028 secrets
10029 .set(codewhale_secrets::DAYTONA_TOKEN_SLOT, "dtn_logout")
10030 .expect("seed daytona token");
10031
10032 let account = secure_account_session_secrets().expect("account store");
10033 AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
10034 .save(AccountAuthBundle {
10035 token_type: "Bearer".to_string(),
10036 access_token: "access-logout".to_string(),
10037 refresh_token: "refresh-logout".to_string(),
10038 session: Some(AccountSession {
10039 id: "session-logout".to_string(),
10040 ..AccountSession::default()
10041 }),
10042 user: Some(AccountUser {
10043 id: "acct-logout".to_string(),
10044 ..AccountUser::default()
10045 }),
10046 })
10047 .expect("seed account session");
10048
10049 run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
10050
10051 assert!(
10052 secrets
10053 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
10054 .expect("read daytona")
10055 .is_none(),
10056 "daytona slot survived logout"
10057 );
10058 let account = secure_account_session_secrets().expect("account store after logout");
10059 assert!(
10060 AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
10061 .load()
10062 .expect("load account")
10063 .is_none(),
10064 "account session survived logout"
10065 );
10066
10067 let _ = std::fs::remove_file(path);
10068 }
10069
10070 #[test]
10071 fn auth_set_slot_daytona_has_no_user_surface() {
10072 use codewhale_secrets::InMemoryKeyringStore;
10076 use std::sync::Arc;
10077
10078 for argv in [
10079 vec![
10080 "codewhale",
10081 "auth",
10082 "set-slot",
10083 "daytona",
10084 "--api-key",
10085 "dtn_saved",
10086 ],
10087 vec!["codewhale", "auth", "clear-slot", "daytona"],
10088 ] {
10089 assert!(
10090 Cli::try_parse_from(argv).is_err(),
10091 "slot commands must not parse"
10092 );
10093 }
10094
10095 let inner = Arc::new(InMemoryKeyringStore::new());
10096 let secrets = Secrets::new(inner);
10097 assert!(
10098 secrets
10099 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
10100 .expect("read slot")
10101 .is_none()
10102 );
10103 }
10104
10105 #[test]
10106 fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
10107 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10108 use std::sync::Arc;
10109
10110 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10111 let path = std::env::temp_dir().join(format!(
10112 "deepseek-cli-auth-migrate-test-{}-{nanos}.toml",
10113 std::process::id()
10114 ));
10115 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10116 store.config.api_key = Some("sk-deep".to_string());
10117 store.config.providers.deepseek.api_key = Some("sk-deep".to_string());
10118 store.config.providers.openrouter.api_key = Some("or-key".to_string());
10119 store.config.providers.novita.api_key = Some("nv-key".to_string());
10120 store.save().unwrap();
10121
10122 let inner = Arc::new(InMemoryKeyringStore::new());
10123 let secrets = Secrets::new(inner.clone());
10124
10125 run_auth_command_with_secrets(
10126 &mut store,
10127 AuthCommand::Migrate { dry_run: false },
10128 &secrets,
10129 )
10130 .expect("migrate should succeed");
10131
10132 assert_eq!(inner.get("deepseek").unwrap(), Some("sk-deep".to_string()));
10133 assert_eq!(inner.get("openrouter").unwrap(), Some("or-key".to_string()));
10134 assert_eq!(inner.get("novita").unwrap(), Some("nv-key".to_string()));
10135
10136 assert!(store.config.api_key.is_none());
10138 assert!(store.config.providers.deepseek.api_key.is_none());
10139 assert!(store.config.providers.openrouter.api_key.is_none());
10140 assert!(store.config.providers.novita.api_key.is_none());
10141
10142 let saved = std::fs::read_to_string(&path).expect("config exists post-migrate");
10143 assert!(!saved.contains("sk-deep"), "plaintext leaked: {saved}");
10144 assert!(!saved.contains("or-key"), "plaintext leaked: {saved}");
10145 assert!(!saved.contains("nv-key"), "plaintext leaked: {saved}");
10146
10147 let backup_path = path.with_file_name(format!(
10148 "{}.bak",
10149 path.file_name().unwrap_or_default().to_string_lossy()
10150 ));
10151 let backup = std::fs::read_to_string(&backup_path).expect("credential-free backup");
10152 assert!(
10153 !backup.contains("sk-deep"),
10154 "plaintext leaked in backup: {backup}"
10155 );
10156 assert!(
10157 !backup.contains("or-key"),
10158 "plaintext leaked in backup: {backup}"
10159 );
10160 assert!(
10161 !backup.contains("nv-key"),
10162 "plaintext leaked in backup: {backup}"
10163 );
10164
10165 let resolved = resolve_runtime_for_dispatch_with_secrets(
10166 &mut store,
10167 &CliRuntimeOverrides::default(),
10168 &secrets,
10169 );
10170 assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
10171 let after_dispatch = std::fs::read_to_string(&path).expect("config after dispatch");
10172 assert!(!after_dispatch.contains("sk-deep"), "{after_dispatch}");
10173 assert!(
10174 !after_dispatch
10175 .lines()
10176 .any(|line| line.trim_start().starts_with("api_key ="))
10177 );
10178
10179 let _ = std::fs::remove_file(path);
10180 }
10181
10182 #[test]
10183 fn auth_migrate_dry_run_does_not_modify_anything() {
10184 use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
10185 use std::sync::Arc;
10186
10187 let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
10188 let path = std::env::temp_dir().join(format!(
10189 "deepseek-cli-auth-migrate-dry-{}-{nanos}.toml",
10190 std::process::id()
10191 ));
10192 let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
10193 store.config.providers.openrouter.api_key = Some("or-stay".to_string());
10194 store.save().unwrap();
10195
10196 let inner = Arc::new(InMemoryKeyringStore::new());
10197 let secrets = Secrets::new(inner.clone());
10198
10199 run_auth_command_with_secrets(&mut store, AuthCommand::Migrate { dry_run: true }, &secrets)
10200 .expect("dry-run should succeed");
10201
10202 assert_eq!(inner.get("openrouter").unwrap(), None);
10203 assert_eq!(
10204 store.config.providers.openrouter.api_key.as_deref(),
10205 Some("or-stay")
10206 );
10207
10208 let _ = std::fs::remove_file(path);
10209 }
10210
10211 #[test]
10212 fn parses_global_override_flags() {
10213 let cli = parse_ok(&[
10214 "deepseek",
10215 "--provider",
10216 "openai",
10217 "--config",
10218 "/tmp/deepseek.toml",
10219 "--profile",
10220 "work",
10221 "--model",
10222 "deepseek-v4-pro",
10223 "--output-mode",
10224 "json",
10225 "--verbosity",
10226 "concise",
10227 "--log-level",
10228 "debug",
10229 "--telemetry",
10230 "true",
10231 "--approval-policy",
10232 "on-request",
10233 "--sandbox-mode",
10234 "workspace-write",
10235 "--base-url",
10236 "https://openai-compatible.example/v1",
10237 "--api-key",
10238 "sk-test",
10239 "--workspace",
10240 "/tmp/workspace",
10241 "--no-mouse-capture",
10242 "--skip-onboarding",
10243 "model",
10244 "resolve",
10245 "deepseek-v4-pro",
10246 ]);
10247
10248 assert_eq!(cli.provider.as_deref(), Some("openai"));
10249 assert_eq!(cli.config, Some(PathBuf::from("/tmp/deepseek.toml")));
10250 assert_eq!(cli.profile.as_deref(), Some("work"));
10251 assert_eq!(cli.model.as_deref(), Some("deepseek-v4-pro"));
10252 assert_eq!(cli.output_mode.as_deref(), Some("json"));
10253 assert_eq!(cli.verbosity.as_deref(), Some("concise"));
10254 assert_eq!(cli.log_level.as_deref(), Some("debug"));
10255 assert_eq!(cli.telemetry, Some(true));
10256 assert_eq!(cli.approval_policy.as_deref(), Some("on-request"));
10257 assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
10258 assert_eq!(
10259 cli.base_url.as_deref(),
10260 Some("https://openai-compatible.example/v1")
10261 );
10262 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
10263 assert_eq!(cli.workspace, Some(PathBuf::from("/tmp/workspace")));
10264 assert!(cli.no_mouse_capture);
10265 assert!(!cli.mouse_capture);
10266 assert!(cli.skip_onboarding);
10267 }
10268
10269 #[test]
10270 fn cli_provider_helpers_follow_config_metadata() {
10271 let registry_kinds: Vec<ProviderKind> = codewhale_config::provider::all_providers()
10272 .iter()
10273 .map(|provider| provider.kind())
10274 .collect();
10275 assert_eq!(registry_kinds.len(), 49);
10277 assert_eq!(ProviderKind::ALL.len(), 43);
10281 for kind in ProviderKind::ALL {
10282 assert!(
10283 registry_kinds.contains(&kind),
10284 "catalog kind {kind:?} must remain in the full registry"
10285 );
10286 }
10287
10288 for provider in registry_kinds {
10289 assert_eq!(provider_env_vars(provider), provider.provider().env_vars());
10290 assert_eq!(
10293 provider_slot(provider),
10294 provider.secret_store_slot(),
10295 "{provider:?} slot must match ProviderKind::secret_store_slot"
10296 );
10297 if provider == ProviderKind::SiliconflowCN {
10298 assert_eq!(
10299 provider_slot(provider),
10300 provider_slot(ProviderKind::Siliconflow)
10301 );
10302 } else if matches!(
10303 provider,
10304 ProviderKind::ModelstudioTokenPlan
10305 | ProviderKind::ModelstudioTokenPlanAnthropic
10306 | ProviderKind::ModelstudioCodingPlan
10307 | ProviderKind::ModelstudioCodingPlanAnthropic
10308 ) {
10309 assert_eq!(
10310 provider_slot(provider),
10311 "modelstudio-token-plan",
10312 "{provider:?} must share the Model Studio family slot"
10313 );
10314 } else {
10315 assert_eq!(provider_slot(provider), provider.provider().id());
10316 }
10317 }
10318 }
10319
10320 #[test]
10321 fn the_telemetry_flag_documents_itself_in_help() {
10322 let help = Cli::command().render_long_help().to_string();
10324 let telemetry_line = help
10325 .lines()
10326 .position(|line| line.contains("--telemetry"))
10327 .map(|index| help.lines().skip(index).take(3).collect::<String>())
10328 .expect("--telemetry must appear in --help");
10329 assert!(
10330 telemetry_line.contains("telemetry"),
10331 "expected a help string beside --telemetry, got: {telemetry_line}"
10332 );
10333 assert!(
10334 telemetry_line.contains("default on"),
10335 "the help string must disclose the default: {telemetry_line}"
10336 );
10337 assert!(
10338 telemetry_line.contains("CODEWHALE_TELEMETRY=0 always")
10339 && telemetry_line.contains("wins"),
10340 "the help string must document the always-winning opt-out: {telemetry_line}"
10341 );
10342 let help = help_for(&["codewhale", "config", "telemetry", "--help"]);
10343 assert!(help.contains("PostHog"));
10344 assert!(help.contains("--accept-notice"));
10345 }
10346
10347 #[test]
10348 fn cli_telemetry_acceptance_is_versioned_and_reuses_settings_persistence() {
10349 let _lock = env_lock();
10350 let temp = tempfile::tempdir().expect("tempdir");
10351 let _home = ScopedEnvVar::set("CODEWHALE_HOME", temp.path().to_str().unwrap());
10352 let path = temp.path().join("config.toml");
10353 write_config_fixture(&path, "telemetry = false\nverbosity = \"concise\"\n");
10354 let mut state = SetupState::default();
10355 state.record_telemetry_notice("3", false);
10356 state.save().expect("seed decline");
10357 let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
10358
10359 run_config_command(
10361 &mut store,
10362 ConfigCommand::Set {
10363 key: "telemetry".into(),
10364 value: "true".into(),
10365 },
10366 false,
10367 &[],
10368 )
10369 .expect("save configuration preference");
10370 assert!(!SetupState::load().unwrap().unwrap().telemetry_opted_out());
10371 assert_eq!(
10372 telemetry_preference_status(Some(true)),
10373 "On (saved preference)"
10374 );
10375 let before = std::fs::read(SetupState::path().unwrap()).unwrap();
10376 run_config_command(
10377 &mut store,
10378 ConfigCommand::Telemetry {
10379 accept_notice: None,
10380 },
10381 false,
10382 &[],
10383 )
10384 .expect("read notice");
10385 assert!(
10386 run_config_command(
10387 &mut store,
10388 ConfigCommand::Telemetry {
10389 accept_notice: Some(3)
10390 },
10391 false,
10392 &[]
10393 )
10394 .is_err()
10395 );
10396 assert_eq!(std::fs::read(SetupState::path().unwrap()).unwrap(), before);
10397
10398 run_config_command(
10399 &mut store,
10400 ConfigCommand::Telemetry {
10401 accept_notice: Some(telemetry::NOTICE_VERSION),
10402 },
10403 false,
10404 &[],
10405 )
10406 .expect("accept current processor notice");
10407 assert_eq!(
10408 telemetry_preference_status(Some(true)),
10409 "On (saved preference)"
10410 );
10411 let saved = ConfigStore::load(Some(path)).unwrap();
10412 assert_eq!(saved.config.telemetry, Some(true));
10413 assert_eq!(saved.config.verbosity.as_deref(), Some("concise"));
10414 assert!(
10415 !temp.path().join("telemetry").exists(),
10416 "acceptance never arms this process"
10417 );
10418 }
10419
10420 #[test]
10421 fn cli_telemetry_acceptance_refuses_overlays_and_corrupt_privacy_records() {
10422 let _lock = env_lock();
10423 let temp = tempfile::tempdir().expect("tempdir");
10424 let _home = ScopedEnvVar::set("CODEWHALE_HOME", temp.path().to_str().unwrap());
10425 let path = temp.path().join("config.toml");
10426 write_config_fixture(&path, "telemetry = false\n");
10427 std::fs::write(SetupState::path().unwrap(), "not-json").unwrap();
10428 let before = std::fs::read(&path).unwrap();
10429 let mut store = ConfigStore::load(Some(path.clone())).unwrap();
10430 for overrides in [vec![], vec!["telemetry=true".to_string()]] {
10431 assert!(
10432 run_config_command(
10433 &mut store,
10434 ConfigCommand::Telemetry {
10435 accept_notice: Some(telemetry::NOTICE_VERSION),
10436 },
10437 false,
10438 &overrides
10439 )
10440 .is_err()
10441 );
10442 assert_eq!(std::fs::read(&path).unwrap(), before);
10443 assert_eq!(
10444 std::fs::read_to_string(SetupState::path().unwrap()).unwrap(),
10445 "not-json"
10446 );
10447 }
10448 }
10449
10450 #[test]
10451 fn root_help_describes_product_actions_not_internal_tui_layers() {
10452 let help = Cli::command().render_long_help().to_string();
10453 assert!(
10454 !help.contains("TUI"),
10455 "root help must describe what Codewhale does, not its internal UI/runtime layers:\n{help}"
10456 );
10457 }
10458
10459 #[test]
10460 fn only_one_function_may_locate_and_spawn_the_tui() {
10461 let source = include_str!("lib.rs");
10464 let a = format!("{}{}", "locate_sibling", "_tui_binary");
10465 let b = format!("{}{}", "tui_spawn", "_error");
10466 let c = format!("{}{}", "build_tui", "_command");
10467 let d = format!("{}{}", "Command::new", "(&tui)");
10468 assert!(
10469 !source.contains(&a),
10470 "single binary must not contain sibling TUI discovery"
10471 );
10472 assert!(
10473 !source.contains(&b),
10474 "single binary must not contain tui spawn error"
10475 );
10476 assert!(
10477 !source.contains(&c),
10478 "single binary must not contain build_tui dispatch"
10479 );
10480 assert!(
10481 !source.contains(&d),
10482 "single binary must not contain Command new tui"
10483 );
10484 }
10485
10486 #[test]
10487 fn parses_no_project_config_before_subcommand() {
10488 let cli = parse_ok(&["codewhale", "--no-project-config", "exec", "list the files"]);
10489 assert!(cli.no_project_config);
10490 match cli.command {
10491 Some(Commands::Exec(args)) => {
10492 assert_eq!(args.args, vec!["list the files".to_string()]);
10493 }
10494 other => panic!("expected exec subcommand, got {other:?}"),
10495 }
10496 }
10497
10498 #[test]
10499 fn no_project_config_after_passthrough_subcommand_is_not_the_dispatcher_flag() {
10500 let cli = parse_ok(&["codewhale", "exec", "--no-project-config", "hi"]);
10504 assert!(!cli.no_project_config);
10505 match cli.command {
10506 Some(Commands::Exec(args)) => {
10507 assert!(args.args.iter().any(|a| a == "--no-project-config"));
10508 }
10509 other => panic!("expected exec subcommand, got {other:?}"),
10510 }
10511 }
10512
10513 #[test]
10514 fn parses_top_level_prompt_flag_for_interactive_startup_prompt() {
10515 let cli = parse_ok(&["deepseek", "-p", "Reply with exactly OK."]);
10516
10517 assert_eq!(cli.prompt_flag.as_deref(), Some("Reply with exactly OK."));
10518 assert!(cli.prompt.is_empty());
10519 assert_eq!(
10520 root_tui_passthrough(&cli).unwrap(),
10521 vec!["--prompt".to_string(), "Reply with exactly OK.".to_string()]
10522 );
10523 }
10524
10525 #[test]
10526 fn root_fresh_and_mouse_flags_forward_as_separate_tui_arguments() {
10527 for flags in [
10528 ["--fresh", "--mouse-capture"],
10529 ["--mouse-capture", "--fresh"],
10530 ] {
10531 let cli = parse_ok(&[
10532 "codewhale",
10533 "--workspace",
10534 "workspace with spaces",
10535 "--no-project-config",
10536 flags[0],
10537 flags[1],
10538 ]);
10539 assert_eq!(
10540 tui_argv(&cli, root_tui_passthrough(&cli).unwrap()),
10541 [
10542 "codewhale",
10543 "--workspace",
10544 "workspace with spaces",
10545 "--mouse-capture",
10546 "--fresh",
10547 "--no-project-config",
10548 ],
10549 "{flags:?} must remain launch flags, not a joined prompt"
10550 );
10551 }
10552 }
10553
10554 #[test]
10555 fn root_fresh_preserves_quoted_prompt_whitespace_and_split_tail() {
10556 let cli = parse_ok(&[
10557 "codewhale",
10558 "--fresh",
10559 "--mouse-capture",
10560 "--prompt",
10561 "Keep two spaces\nand a tab\there",
10562 "then",
10563 "explain them",
10564 ]);
10565 assert_eq!(
10566 tui_argv(&cli, root_tui_passthrough(&cli).unwrap()),
10567 [
10568 "codewhale",
10569 "--mouse-capture",
10570 "--fresh",
10571 "--prompt",
10572 "Keep two spaces\nand a tab\there then explain them",
10573 ]
10574 );
10575 }
10576
10577 #[test]
10578 fn root_prompt_tail_does_not_reinterpret_literal_launch_flags() {
10579 let cli = parse_ok(&[
10580 "codewhale",
10581 "Explain",
10582 "--fresh",
10583 "--mouse-capture",
10584 "as literal flags",
10585 ]);
10586 assert_eq!(
10587 tui_argv(&cli, root_tui_passthrough(&cli).unwrap()),
10588 [
10589 "codewhale",
10590 "--prompt",
10591 "Explain --fresh --mouse-capture as literal flags",
10592 ]
10593 );
10594 }
10595
10596 #[test]
10597 fn parses_top_level_continue_for_interactive_resume() {
10598 let cli = parse_ok(&["codewhale", "--continue"]);
10599
10600 assert!(cli.continue_session);
10601 assert!(cli.prompt_flag.is_none());
10602 assert!(cli.prompt.is_empty());
10603 assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
10604 }
10605
10606 #[test]
10607 fn parses_top_level_resume_flags_for_interactive_resume() {
10608 for argv in [
10612 &["codewhale", "--resume", "800596e6"][..],
10613 &["codewhale", "--resume=800596e6"][..],
10614 &["codewhale", "-r", "800596e6"][..],
10615 &["codewhale", "--session-id", "800596e6"][..],
10616 &["codewhale", "--session-id=800596e6"][..],
10617 ] {
10618 let cli = parse_ok(argv);
10619 assert!(
10620 cli.prompt.is_empty(),
10621 "{argv:?} must not be swallowed as a prompt: {:?}",
10622 cli.prompt
10623 );
10624 assert!(cli.prompt_flag.is_none(), "{argv:?}");
10625 assert!(cli.command.is_none(), "{argv:?}");
10626 assert_eq!(
10627 root_tui_passthrough(&cli).unwrap(),
10628 vec!["--resume".to_string(), "800596e6".to_string()],
10629 "{argv:?}"
10630 );
10631 }
10632 }
10633
10634 #[test]
10635 fn empty_resume_identifier_is_rejected_rather_than_starting_fresh() {
10636 for argv in [
10641 &["codewhale", "--resume", ""][..],
10642 &["codewhale", "--session-id", " "][..],
10643 ] {
10644 let cli = parse_ok(argv);
10645 let err = root_tui_passthrough(&cli)
10646 .expect_err("an empty resume id must not silently start a fresh session");
10647 assert!(
10648 err.to_string().contains("needs a session id"),
10649 "{argv:?}: {err}"
10650 );
10651 }
10652 }
10653
10654 #[test]
10655 fn top_level_resume_rejects_startup_prompt_and_conflicting_flags() {
10656 let cli = parse_ok(&["codewhale", "--resume", "800596e6", "-p", "follow up"]);
10657 let err = root_tui_passthrough(&cli).expect_err("prompted resume should be rejected");
10658 assert!(
10659 err.to_string()
10660 .contains("codewhale exec --resume 800596e6 <PROMPT>"),
10661 "{err}"
10662 );
10663
10664 assert!(Cli::try_parse_from(["codewhale", "--resume", "800596e6", "--continue"]).is_err());
10665 assert!(Cli::try_parse_from(["codewhale", "--resume", "a", "--session-id", "b"]).is_err());
10666 assert!(Cli::try_parse_from(["codewhale", "--session-id", "b", "-c"]).is_err());
10667 }
10668
10669 #[test]
10670 fn parses_rc_as_the_account_owned_interactive_handoff() {
10671 let cli = parse_ok(&["codewhale", "rc"]);
10672
10673 let Some(Commands::Rc(args)) = cli.command else {
10674 panic!("rc should parse as the remote-control TUI handoff");
10675 };
10676 assert!(args.args.is_empty());
10677 }
10678
10679 #[test]
10680 fn top_level_continue_rejects_startup_prompt() {
10681 let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
10682
10683 let err = root_tui_passthrough(&cli).expect_err("prompted continue should be rejected");
10684 assert!(
10685 err.to_string()
10686 .contains("codewhale exec --continue <PROMPT>")
10687 );
10688 }
10689
10690 #[test]
10691 fn parses_split_top_level_prompt_words_for_windows_cmd_shims() {
10692 let cli = parse_ok(&["deepseek", "hello", "world"]);
10693
10694 assert_eq!(cli.prompt, vec!["hello", "world"]);
10695 assert!(cli.command.is_none());
10696 assert_eq!(
10697 root_tui_passthrough(&cli).unwrap(),
10698 vec!["--prompt".to_string(), "hello world".to_string()]
10699 );
10700 }
10701
10702 #[test]
10703 fn prompt_flag_keeps_split_tail_words_for_windows_cmd_shims() {
10704 let cli = parse_ok(&["deepseek", "-p", "hello", "world"]);
10705
10706 assert_eq!(cli.prompt_flag.as_deref(), Some("hello"));
10707 assert_eq!(cli.prompt, vec!["world"]);
10708 assert_eq!(
10709 root_tui_passthrough(&cli).unwrap(),
10710 vec!["--prompt".to_string(), "hello world".to_string()]
10711 );
10712 }
10713
10714 #[test]
10715 fn known_subcommands_still_parse_before_prompt_tail() {
10716 let cli = parse_ok(&["deepseek", "doctor"]);
10717
10718 assert!(cli.prompt.is_empty());
10719 assert!(matches!(cli.command, Some(Commands::Doctor(_))));
10720 }
10721
10722 #[test]
10723 fn root_help_surface_contains_expected_subcommands_and_globals() {
10724 let rendered = help_for(&["deepseek", "--help"]);
10725
10726 for token in [
10727 "run",
10728 "doctor",
10729 "models",
10730 "sessions",
10731 "resume",
10732 "setup",
10733 "login",
10734 "logout",
10735 "auth",
10736 "mcp-server",
10737 "config",
10738 "model",
10739 "providers",
10740 "thread",
10741 "sandbox",
10742 "app-server",
10743 "completion",
10744 "metrics",
10745 "--provider",
10746 "--model",
10747 "--config",
10748 "--profile",
10749 "--output-mode",
10750 "--log-level",
10751 "--telemetry",
10752 "--base-url",
10753 "--api-key",
10754 "--approval-policy",
10755 "--sandbox-mode",
10756 "--mouse-capture",
10757 "--no-mouse-capture",
10758 "--skip-onboarding",
10759 "--fresh",
10760 "--continue",
10761 "--prompt",
10762 ] {
10763 assert!(
10764 rendered.contains(token),
10765 "expected help to contain token: {token}"
10766 );
10767 }
10768 }
10769
10770 #[test]
10771 fn subcommand_help_surfaces_are_stable() {
10772 let cases = [
10773 ("config", vec!["get", "set", "unset", "list", "path"]),
10774 ("model", vec!["list", "resolve"]),
10775 (
10776 "thread",
10777 vec![
10778 "list",
10779 "read",
10780 "resume",
10781 "fork",
10782 "archive",
10783 "unarchive",
10784 "set-name",
10785 "clear-name",
10786 ],
10787 ),
10788 ("sandbox", vec!["check"]),
10789 (
10790 "exec",
10791 vec![
10792 "--auto",
10793 "--json",
10794 "--resume",
10795 "--session-id",
10796 "--continue",
10797 "--output-format",
10798 "stream-json",
10799 ],
10800 ),
10801 (
10802 "app-server",
10803 vec!["--host", "--port", "--config", "--stdio"],
10804 ),
10805 (
10806 "completion",
10807 vec![
10808 "<SHELL>",
10809 "bash",
10810 "Every script completes both `codewhale` and the `codew` shorthand.",
10811 "source <(codewhale completion bash)",
10812 "~/.local/share/bash-completion/completions/codewhale",
10813 "fpath=(~/.zfunc $fpath)",
10814 "codewhale completion fish > ~/.config/fish/completions/codewhale.fish",
10815 "codewhale completion powershell | Out-String | Invoke-Expression",
10816 "codewhale completion elvish >> ~/.config/elvish/rc.elv",
10817 ],
10818 ),
10819 ("metrics", vec!["--json", "--since"]),
10820 ];
10821
10822 for (subcommand, expected_tokens) in cases {
10823 let argv = ["deepseek", subcommand, "--help"];
10824 let rendered = help_for(&argv);
10825 for token in expected_tokens {
10826 assert!(
10827 rendered.contains(token),
10828 "expected help for `{subcommand}` to include `{token}`"
10829 );
10830 }
10831 }
10832 }
10833
10834 #[test]
10835 fn cli_telemetry_start_fails_closed_on_a_corrupt_setup_state() {
10836 let dir = tempfile::TempDir::new().expect("tempdir");
10837 let setup_path = dir.path().join("setup_state.json");
10838 std::fs::write(&setup_path, b"{not-json").expect("write corrupt setup state");
10839 let setup = telemetry::load_setup_state_for_decision_at(&setup_path);
10840 assert!(setup.is_none(), "corrupt privacy state must not default on");
10841
10842 let resolved =
10843 ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
10844 assert!(
10845 resolve_cli_telemetry_consent(&resolved, None, Surface::Cli, setup).is_none(),
10846 "CLI startup must not obtain permission from an unreadable privacy record"
10847 );
10848 }
10849}