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