1use anyhow::{bail, Context, Result};
8
9use crate::claude::model_config::get_model_registry;
10
11#[derive(Debug)]
13pub struct AiCredentialInfo {
14 pub provider: AiProvider,
16 pub model: String,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum AiProvider {
23 Claude,
25 Bedrock,
27 OpenAi,
29 Ollama,
31 ClaudeCli,
33}
34
35impl std::fmt::Display for AiProvider {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::Claude => write!(f, "Claude API"),
39 Self::Bedrock => write!(f, "AWS Bedrock"),
40 Self::OpenAi => write!(f, "OpenAI API"),
41 Self::Ollama => write!(f, "Ollama"),
42 Self::ClaudeCli => write!(f, "Claude Code CLI"),
43 }
44 }
45}
46
47pub fn check_ai_credentials(model_override: Option<&str>) -> Result<AiCredentialInfo> {
53 check_ai_credentials_with(&crate::utils::settings::SettingsEnv::load(), model_override)
54}
55
56pub(crate) fn check_ai_credentials_with(
64 env: &impl crate::utils::env::EnvSource,
65 model_override: Option<&str>,
66) -> Result<AiCredentialInfo> {
67 use crate::claude::backend::{self, AiBackend};
68
69 let ai_backend = backend::resolve_backend(env)?;
70 let model = backend::resolve_model(ai_backend, model_override, env, get_model_registry());
71
72 match ai_backend {
73 AiBackend::ClaudeCli => {
76 let binary = env
77 .var("OMNI_DEV_CLAUDE_CLI_BIN")
78 .unwrap_or_else(|| "claude".to_string());
79 let probe = std::process::Command::new(&binary)
80 .arg("--version")
81 .output();
82 match probe {
83 Ok(out) if out.status.success() => Ok(AiCredentialInfo {
84 provider: AiProvider::ClaudeCli,
85 model,
86 }),
87 _ => bail!(
88 "Claude Code CLI not available at '{binary}'.\n\
89 Install it from https://github.com/anthropics/claude-code \
90 or set OMNI_DEV_CLAUDE_CLI_BIN to its path."
91 ),
92 }
93 }
94
95 AiBackend::Ollama => Ok(AiCredentialInfo {
97 provider: AiProvider::Ollama,
98 model,
99 }),
100
101 AiBackend::OpenAi => {
102 env.var_any(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"])
104 .ok_or_else(|| {
105 anyhow::anyhow!(
106 "OpenAI API key not found.\n\
107 Set one of these environment variables:\n\
108 - OPENAI_API_KEY\n\
109 - OPENAI_AUTH_TOKEN"
110 )
111 })?;
112
113 Ok(AiCredentialInfo {
114 provider: AiProvider::OpenAi,
115 model,
116 })
117 }
118
119 AiBackend::Bedrock => {
120 env.var("ANTHROPIC_AUTH_TOKEN").ok_or_else(|| {
122 anyhow::anyhow!(
123 "AWS Bedrock authentication not configured.\n\
124 Set ANTHROPIC_AUTH_TOKEN environment variable."
125 )
126 })?;
127
128 env.var("ANTHROPIC_BEDROCK_BASE_URL").ok_or_else(|| {
129 anyhow::anyhow!(
130 "AWS Bedrock base URL not configured.\n\
131 Set ANTHROPIC_BEDROCK_BASE_URL environment variable."
132 )
133 })?;
134
135 Ok(AiCredentialInfo {
136 provider: AiProvider::Bedrock,
137 model,
138 })
139 }
140
141 AiBackend::Default => {
142 env.var_any(&[
144 "CLAUDE_API_KEY",
145 "ANTHROPIC_API_KEY",
146 "ANTHROPIC_AUTH_TOKEN",
147 ])
148 .ok_or_else(|| {
149 anyhow::anyhow!(
150 "Claude API key not found.\n\
151 Set one of these environment variables:\n\
152 - CLAUDE_API_KEY\n\
153 - ANTHROPIC_API_KEY\n\
154 - ANTHROPIC_AUTH_TOKEN"
155 )
156 })?;
157
158 Ok(AiCredentialInfo {
159 provider: AiProvider::Claude,
160 model,
161 })
162 }
163 }
164}
165
166pub fn check_github_cli(repo_root: &std::path::Path) -> Result<()> {
177 let gh_check = std::process::Command::new("gh")
180 .args(["--version"])
181 .output();
182
183 match gh_check {
184 Ok(output) if output.status.success() => {
185 let repo_check = std::process::Command::new("gh")
187 .args(["repo", "view", "--json", "name"])
188 .current_dir(repo_root)
189 .output();
190
191 match repo_check {
192 Ok(repo_output) if repo_output.status.success() => Ok(()),
193 Ok(repo_output) => {
194 let error_details = String::from_utf8_lossy(&repo_output.stderr);
195 if error_details.contains("authentication") || error_details.contains("login") {
196 bail!(
197 "GitHub CLI authentication failed.\n\
198 Please run 'gh auth login' or set GITHUB_TOKEN environment variable."
199 )
200 }
201 bail!(
202 "GitHub CLI cannot access this repository.\n\
203 Error: {}",
204 error_details.trim()
205 )
206 }
207 Err(e) => bail!("Failed to test GitHub CLI access: {e}"),
208 }
209 }
210 _ => bail!(
211 "GitHub CLI (gh) is not installed or not in PATH.\n\
212 Please install it from https://cli.github.com/"
213 ),
214 }
215}
216
217pub fn check_git_repository_at(repo_root: &std::path::Path) -> Result<()> {
221 crate::git::GitRepository::open_at(repo_root).context(
222 "Not in a git repository. Please run this command from within a git repository.",
223 )?;
224 Ok(())
225}
226
227pub fn check_working_directory_clean_at(repo_root: &std::path::Path) -> Result<()> {
233 let repo =
234 crate::git::GitRepository::open_at(repo_root).context("Failed to open git repository")?;
235 check_working_directory_clean_for(&repo)
236}
237
238fn check_working_directory_clean_for(repo: &crate::git::GitRepository) -> Result<()> {
240 let status = repo
241 .get_working_directory_status()
242 .context("Failed to get working directory status")?;
243
244 if !status.clean {
245 let mut message = String::from("Working directory has uncommitted changes:\n");
246 for change in &status.untracked_changes {
247 message.push_str(&format!(" {} {}\n", change.status, change.file));
248 }
249 message.push_str("\nPlease commit or stash your changes before proceeding.");
250 bail!(message);
251 }
252
253 Ok(())
254}
255
256pub fn check_ai_command_prerequisites(
267 model_override: Option<&str>,
268 repo_root: &std::path::Path,
269) -> Result<AiCredentialInfo> {
270 check_git_repository_at(repo_root)?;
271 check_ai_credentials(model_override)
272}
273
274pub fn check_pr_command_prerequisites(
286 model_override: Option<&str>,
287 repo_root: &std::path::Path,
288) -> Result<AiCredentialInfo> {
289 check_git_repository_at(repo_root)?;
290 let ai_info = check_ai_credentials(model_override)?;
291 check_github_cli(repo_root)?;
292 Ok(ai_info)
293}
294
295#[cfg(test)]
296#[allow(clippy::unwrap_used, clippy::expect_used)]
297mod tests {
298 use super::*;
299 use crate::test_support::env::MapEnv;
300
301 #[test]
302 fn ai_provider_display() {
303 assert_eq!(format!("{}", AiProvider::Claude), "Claude API");
304 assert_eq!(format!("{}", AiProvider::Bedrock), "AWS Bedrock");
305 assert_eq!(format!("{}", AiProvider::OpenAi), "OpenAI API");
306 assert_eq!(format!("{}", AiProvider::Ollama), "Ollama");
307 assert_eq!(format!("{}", AiProvider::ClaudeCli), "Claude Code CLI");
308 }
309
310 #[test]
311 fn ai_provider_equality() {
312 assert_eq!(AiProvider::Claude, AiProvider::Claude);
313 assert_ne!(AiProvider::Claude, AiProvider::OpenAi);
314 assert_ne!(AiProvider::Bedrock, AiProvider::Ollama);
315 }
316
317 #[test]
318 fn ai_provider_clone() {
319 let provider = AiProvider::Bedrock;
320 let cloned = provider;
321 assert_eq!(provider, cloned);
322 }
323
324 #[test]
325 fn ai_provider_debug() {
326 let debug_str = format!("{:?}", AiProvider::Claude);
327 assert_eq!(debug_str, "Claude");
328 }
329
330 #[test]
331 fn ai_credential_info_debug() {
332 let info = AiCredentialInfo {
333 provider: AiProvider::Ollama,
334 model: "llama2".to_string(),
335 };
336 let debug_str = format!("{info:?}");
337 assert!(debug_str.contains("Ollama"));
338 assert!(debug_str.contains("llama2"));
339 }
340
341 #[test]
342 fn claude_default_model_from_registry() {
343 let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
347
348 let info = check_ai_credentials_with(&env, None).unwrap();
349 assert_eq!(info.provider, AiProvider::Claude);
350 assert_eq!(info.model, "claude-sonnet-4-6");
351 }
352
353 #[test]
354 fn openai_default_model_from_registry() {
355 let env = MapEnv::new()
356 .with("USE_OPENAI", "true")
357 .with("OPENAI_API_KEY", "sk-test-dummy");
358
359 let info = check_ai_credentials_with(&env, None).unwrap();
360 assert_eq!(info.provider, AiProvider::OpenAi);
361 assert_eq!(info.model, "gpt-5-mini");
362 }
363
364 #[test]
365 fn openai_errors_without_api_key() {
366 let env = MapEnv::new().with("USE_OPENAI", "true");
367 let err = check_ai_credentials_with(&env, None).unwrap_err();
368 assert!(err.to_string().contains("OpenAI API key not found"));
369 }
370
371 #[test]
372 fn bedrock_default_model_from_registry() {
373 let env = MapEnv::new()
374 .with("CLAUDE_CODE_USE_BEDROCK", "true")
375 .with("ANTHROPIC_AUTH_TOKEN", "test-token")
376 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
377
378 let info = check_ai_credentials_with(&env, None).unwrap();
379 assert_eq!(info.provider, AiProvider::Bedrock);
380 assert_eq!(info.model, "claude-sonnet-4-6");
381 }
382
383 #[test]
384 fn bedrock_errors_without_auth_token() {
385 let env = MapEnv::new().with("CLAUDE_CODE_USE_BEDROCK", "true");
386 let err = check_ai_credentials_with(&env, None).unwrap_err();
387 assert!(err
388 .to_string()
389 .contains("AWS Bedrock authentication not configured"));
390 }
391
392 #[test]
393 fn bedrock_errors_without_base_url() {
394 let env = MapEnv::new()
395 .with("CLAUDE_CODE_USE_BEDROCK", "true")
396 .with("ANTHROPIC_AUTH_TOKEN", "test-token");
397 let err = check_ai_credentials_with(&env, None).unwrap_err();
398 assert!(err
399 .to_string()
400 .contains("AWS Bedrock base URL not configured"));
401 }
402
403 #[test]
404 fn model_override_takes_precedence() {
405 let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
406
407 let info = check_ai_credentials_with(&env, Some("claude-opus-4-6")).unwrap();
408 assert_eq!(info.model, "claude-opus-4-6");
409 }
410
411 #[cfg(unix)]
412 fn make_version_shim(tmp: &tempfile::TempDir, exit_code: i32) -> std::path::PathBuf {
413 let shim = tmp.path().join("claude-bin-shim");
414 crate::test_support::shim::write_exec_script(
415 &shim,
416 &format!("#!/bin/sh\necho 'fake-claude 0.0.0'\nexit {exit_code}\n"),
417 );
418 shim
419 }
420
421 #[test]
422 #[cfg(unix)]
423 fn claude_cli_backend_uses_version_probe() {
424 let _guard = crate::test_support::shim::shim_lock();
426 let tmp = tempfile::TempDir::new().unwrap();
427 let shim = make_version_shim(&tmp, 0);
428
429 let env = MapEnv::new()
430 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
431 .with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap());
432
433 let info = check_ai_credentials_with(&env, None).unwrap();
434 assert_eq!(info.provider, AiProvider::ClaudeCli);
435 assert_eq!(info.model, "claude-sonnet-4-6");
436 }
437
438 #[test]
439 #[cfg(unix)]
440 fn claude_cli_backend_uses_model_from_env() {
441 let _guard = crate::test_support::shim::shim_lock();
443 let tmp = tempfile::TempDir::new().unwrap();
444 let shim = make_version_shim(&tmp, 0);
445
446 let env = MapEnv::new()
447 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
448 .with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap())
449 .with("CLAUDE_MODEL", "haiku");
450
451 let info = check_ai_credentials_with(&env, None).unwrap();
452 assert_eq!(info.provider, AiProvider::ClaudeCli);
453 assert_eq!(info.model, "haiku");
454 }
455
456 #[test]
457 fn claude_cli_backend_missing_binary_fails_preflight() {
458 let env = MapEnv::new()
460 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
461 .with("OMNI_DEV_CLAUDE_CLI_BIN", "/nonexistent/claude-binary-xyz");
462
463 let err = check_ai_credentials_with(&env, None).expect_err("expected missing-binary error");
464 let chain = format!("{err:#}");
465 assert!(
466 chain.contains("Claude Code CLI not available"),
467 "unexpected error: {chain}"
468 );
469 }
470
471 #[test]
472 fn backend_env_var_overrides_legacy_use_flags() {
473 let env = MapEnv::new()
475 .with("OMNI_DEV_AI_BACKEND", "openai")
476 .with("USE_OLLAMA", "true")
477 .with("OPENAI_API_KEY", "sk-test-dummy");
478
479 let info = check_ai_credentials_with(&env, None).unwrap();
480 assert_eq!(info.provider, AiProvider::OpenAi);
481 assert_eq!(info.model, "gpt-5-mini");
482 }
483
484 #[test]
485 fn backend_default_value_forces_direct_api() {
486 let env = MapEnv::new()
489 .with("OMNI_DEV_AI_BACKEND", "default")
490 .with("USE_OLLAMA", "true")
491 .with("ANTHROPIC_API_KEY", "sk-test-dummy");
492
493 let info = check_ai_credentials_with(&env, None).unwrap();
494 assert_eq!(info.provider, AiProvider::Claude);
495 }
496
497 #[test]
498 fn backend_env_var_selects_ollama_and_bedrock() {
499 let env = MapEnv::new().with("OMNI_DEV_AI_BACKEND", "ollama");
500 let info = check_ai_credentials_with(&env, None).unwrap();
501 assert_eq!(info.provider, AiProvider::Ollama);
502 assert_eq!(info.model, "llama2");
503
504 let env = MapEnv::new()
505 .with("OMNI_DEV_AI_BACKEND", "bedrock")
506 .with("ANTHROPIC_AUTH_TOKEN", "tok")
507 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
508 let info = check_ai_credentials_with(&env, None).unwrap();
509 assert_eq!(info.provider, AiProvider::Bedrock);
510 }
511
512 #[test]
513 fn unknown_backend_value_is_hard_error() {
514 let env = MapEnv::new()
515 .with("OMNI_DEV_AI_BACKEND", "junk")
516 .with("ANTHROPIC_API_KEY", "sk-test-dummy");
517
518 let err = check_ai_credentials_with(&env, None).expect_err("unknown backend must error");
519 assert!(format!("{err:#}").contains("junk"));
520 }
521
522 #[test]
523 fn claude_api_honours_claude_model_chain() {
524 let env = MapEnv::new()
527 .with("CLAUDE_MODEL", "claude-opus-4-6")
528 .with("ANTHROPIC_MODEL", "claude-sonnet-4-6")
529 .with("ANTHROPIC_API_KEY", "sk-test-dummy");
530
531 let info = check_ai_credentials_with(&env, None).unwrap();
532 assert_eq!(info.provider, AiProvider::Claude);
533 assert_eq!(info.model, "claude-opus-4-6");
534 }
535
536 #[test]
537 fn bedrock_honours_claude_model_chain() {
538 let env = MapEnv::new()
539 .with("CLAUDE_CODE_USE_BEDROCK", "true")
540 .with("CLAUDE_CODE_MODEL", "claude-opus-4-6")
541 .with("ANTHROPIC_AUTH_TOKEN", "tok")
542 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
543
544 let info = check_ai_credentials_with(&env, None).unwrap();
545 assert_eq!(info.provider, AiProvider::Bedrock);
546 assert_eq!(info.model, "claude-opus-4-6");
547 }
548
549 #[test]
550 fn omni_dev_model_beats_provider_var() {
551 let env = MapEnv::new()
552 .with("USE_OPENAI", "true")
553 .with("OMNI_DEV_MODEL", "gpt-4.1")
554 .with("OPENAI_MODEL", "gpt-5-mini")
555 .with("OPENAI_API_KEY", "sk-test-dummy");
556
557 let info = check_ai_credentials_with(&env, None).unwrap();
558 assert_eq!(info.model, "gpt-4.1");
559 }
560
561 #[test]
562 fn claude_cli_backend_accepts_underscore_alias() {
563 let env = MapEnv::new()
567 .with("OMNI_DEV_AI_BACKEND", "claude_cli")
568 .with("OMNI_DEV_CLAUDE_CLI_BIN", "/nonexistent/claude-binary-xyz");
569
570 let err = check_ai_credentials_with(&env, None).expect_err("expected missing-binary error");
571 let chain = format!("{err:#}");
572 assert!(chain.contains("Claude Code CLI not available"));
573 }
574}