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