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