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