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 if let Some(val) = env.var("OMNI_DEV_AI_BACKEND") {
72 if matches!(val.as_str(), "claude-cli" | "claude_cli") {
73 let binary = env
74 .var("OMNI_DEV_CLAUDE_CLI_BIN")
75 .unwrap_or_else(|| "claude".to_string());
76 let probe = std::process::Command::new(&binary)
77 .arg("--version")
78 .output();
79 match probe {
80 Ok(out) if out.status.success() => {
81 let registry = get_model_registry();
82 let model = model_override
83 .map(String::from)
84 .or_else(|| env.var("CLAUDE_MODEL"))
85 .or_else(|| env.var("CLAUDE_CODE_MODEL"))
86 .or_else(|| env.var("ANTHROPIC_MODEL"))
87 .unwrap_or_else(|| {
88 registry
89 .get_default_model("claude")
90 .unwrap_or("claude-sonnet-4-6")
91 .to_string()
92 });
93 return Ok(AiCredentialInfo {
94 provider: AiProvider::ClaudeCli,
95 model,
96 });
97 }
98 _ => bail!(
99 "Claude Code CLI not available at '{binary}'.\n\
100 Install it from https://github.com/anthropics/claude-code \
101 or set OMNI_DEV_CLAUDE_CLI_BIN to its path."
102 ),
103 }
104 }
105 }
106
107 let use_openai = env.var("USE_OPENAI").is_some_and(|val| val == "true");
109
110 let use_ollama = env.var("USE_OLLAMA").is_some_and(|val| val == "true");
111
112 let use_bedrock = env
113 .var("CLAUDE_CODE_USE_BEDROCK")
114 .is_some_and(|val| val == "true");
115
116 if use_ollama {
118 let model = model_override
119 .map(String::from)
120 .or_else(|| env.var("OLLAMA_MODEL"))
121 .unwrap_or_else(|| "llama2".to_string());
122
123 return Ok(AiCredentialInfo {
124 provider: AiProvider::Ollama,
125 model,
126 });
127 }
128
129 if use_openai {
131 let registry = get_model_registry();
132 let model = model_override
133 .map(String::from)
134 .or_else(|| env.var("OPENAI_MODEL"))
135 .unwrap_or_else(|| {
136 registry
137 .get_default_model("openai")
138 .unwrap_or("gpt-5")
139 .to_string()
140 });
141
142 env.var_any(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"])
144 .ok_or_else(|| {
145 anyhow::anyhow!(
146 "OpenAI API key not found.\n\
147 Set one of these environment variables:\n\
148 - OPENAI_API_KEY\n\
149 - OPENAI_AUTH_TOKEN"
150 )
151 })?;
152
153 return Ok(AiCredentialInfo {
154 provider: AiProvider::OpenAi,
155 model,
156 });
157 }
158
159 if use_bedrock {
161 let registry = get_model_registry();
162 let model = model_override
163 .map(String::from)
164 .or_else(|| env.var("ANTHROPIC_MODEL"))
165 .unwrap_or_else(|| {
166 registry
167 .get_default_model("claude")
168 .unwrap_or("claude-sonnet-4-6")
169 .to_string()
170 });
171
172 env.var("ANTHROPIC_AUTH_TOKEN").ok_or_else(|| {
174 anyhow::anyhow!(
175 "AWS Bedrock authentication not configured.\n\
176 Set ANTHROPIC_AUTH_TOKEN environment variable."
177 )
178 })?;
179
180 env.var("ANTHROPIC_BEDROCK_BASE_URL").ok_or_else(|| {
181 anyhow::anyhow!(
182 "AWS Bedrock base URL not configured.\n\
183 Set ANTHROPIC_BEDROCK_BASE_URL environment variable."
184 )
185 })?;
186
187 return Ok(AiCredentialInfo {
188 provider: AiProvider::Bedrock,
189 model,
190 });
191 }
192
193 let registry = get_model_registry();
195 let model = model_override
196 .map(String::from)
197 .or_else(|| env.var("ANTHROPIC_MODEL"))
198 .unwrap_or_else(|| {
199 registry
200 .get_default_model("claude")
201 .unwrap_or("claude-sonnet-4-6")
202 .to_string()
203 });
204
205 env.var_any(&[
207 "CLAUDE_API_KEY",
208 "ANTHROPIC_API_KEY",
209 "ANTHROPIC_AUTH_TOKEN",
210 ])
211 .ok_or_else(|| {
212 anyhow::anyhow!(
213 "Claude API key not found.\n\
214 Set one of these environment variables:\n\
215 - CLAUDE_API_KEY\n\
216 - ANTHROPIC_API_KEY\n\
217 - ANTHROPIC_AUTH_TOKEN"
218 )
219 })?;
220
221 Ok(AiCredentialInfo {
222 provider: AiProvider::Claude,
223 model,
224 })
225}
226
227pub fn check_github_cli(repo_root: &std::path::Path) -> Result<()> {
238 let gh_check = std::process::Command::new("gh")
241 .args(["--version"])
242 .output();
243
244 match gh_check {
245 Ok(output) if output.status.success() => {
246 let repo_check = std::process::Command::new("gh")
248 .args(["repo", "view", "--json", "name"])
249 .current_dir(repo_root)
250 .output();
251
252 match repo_check {
253 Ok(repo_output) if repo_output.status.success() => Ok(()),
254 Ok(repo_output) => {
255 let error_details = String::from_utf8_lossy(&repo_output.stderr);
256 if error_details.contains("authentication") || error_details.contains("login") {
257 bail!(
258 "GitHub CLI authentication failed.\n\
259 Please run 'gh auth login' or set GITHUB_TOKEN environment variable."
260 )
261 }
262 bail!(
263 "GitHub CLI cannot access this repository.\n\
264 Error: {}",
265 error_details.trim()
266 )
267 }
268 Err(e) => bail!("Failed to test GitHub CLI access: {e}"),
269 }
270 }
271 _ => bail!(
272 "GitHub CLI (gh) is not installed or not in PATH.\n\
273 Please install it from https://cli.github.com/"
274 ),
275 }
276}
277
278pub fn check_git_repository_at(repo_root: &std::path::Path) -> Result<()> {
282 crate::git::GitRepository::open_at(repo_root).context(
283 "Not in a git repository. Please run this command from within a git repository.",
284 )?;
285 Ok(())
286}
287
288pub fn check_working_directory_clean_at(repo_root: &std::path::Path) -> Result<()> {
294 let repo =
295 crate::git::GitRepository::open_at(repo_root).context("Failed to open git repository")?;
296 check_working_directory_clean_for(&repo)
297}
298
299fn check_working_directory_clean_for(repo: &crate::git::GitRepository) -> Result<()> {
301 let status = repo
302 .get_working_directory_status()
303 .context("Failed to get working directory status")?;
304
305 if !status.clean {
306 let mut message = String::from("Working directory has uncommitted changes:\n");
307 for change in &status.untracked_changes {
308 message.push_str(&format!(" {} {}\n", change.status, change.file));
309 }
310 message.push_str("\nPlease commit or stash your changes before proceeding.");
311 bail!(message);
312 }
313
314 Ok(())
315}
316
317pub fn check_ai_command_prerequisites(
328 model_override: Option<&str>,
329 repo_root: &std::path::Path,
330) -> Result<AiCredentialInfo> {
331 check_git_repository_at(repo_root)?;
332 check_ai_credentials(model_override)
333}
334
335pub fn check_pr_command_prerequisites(
347 model_override: Option<&str>,
348 repo_root: &std::path::Path,
349) -> Result<AiCredentialInfo> {
350 check_git_repository_at(repo_root)?;
351 let ai_info = check_ai_credentials(model_override)?;
352 check_github_cli(repo_root)?;
353 Ok(ai_info)
354}
355
356#[cfg(test)]
357#[allow(clippy::unwrap_used, clippy::expect_used)]
358mod tests {
359 use super::*;
360 use crate::test_support::env::MapEnv;
361
362 #[test]
363 fn ai_provider_display() {
364 assert_eq!(format!("{}", AiProvider::Claude), "Claude API");
365 assert_eq!(format!("{}", AiProvider::Bedrock), "AWS Bedrock");
366 assert_eq!(format!("{}", AiProvider::OpenAi), "OpenAI API");
367 assert_eq!(format!("{}", AiProvider::Ollama), "Ollama");
368 assert_eq!(format!("{}", AiProvider::ClaudeCli), "Claude Code CLI");
369 }
370
371 #[test]
372 fn ai_provider_equality() {
373 assert_eq!(AiProvider::Claude, AiProvider::Claude);
374 assert_ne!(AiProvider::Claude, AiProvider::OpenAi);
375 assert_ne!(AiProvider::Bedrock, AiProvider::Ollama);
376 }
377
378 #[test]
379 fn ai_provider_clone() {
380 let provider = AiProvider::Bedrock;
381 let cloned = provider;
382 assert_eq!(provider, cloned);
383 }
384
385 #[test]
386 fn ai_provider_debug() {
387 let debug_str = format!("{:?}", AiProvider::Claude);
388 assert_eq!(debug_str, "Claude");
389 }
390
391 #[test]
392 fn ai_credential_info_debug() {
393 let info = AiCredentialInfo {
394 provider: AiProvider::Ollama,
395 model: "llama2".to_string(),
396 };
397 let debug_str = format!("{info:?}");
398 assert!(debug_str.contains("Ollama"));
399 assert!(debug_str.contains("llama2"));
400 }
401
402 #[test]
403 fn claude_default_model_from_registry() {
404 let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
408
409 let info = check_ai_credentials_with(&env, None).unwrap();
410 assert_eq!(info.provider, AiProvider::Claude);
411 assert_eq!(info.model, "claude-sonnet-4-6");
412 }
413
414 #[test]
415 fn openai_default_model_from_registry() {
416 let env = MapEnv::new()
417 .with("USE_OPENAI", "true")
418 .with("OPENAI_API_KEY", "sk-test-dummy");
419
420 let info = check_ai_credentials_with(&env, None).unwrap();
421 assert_eq!(info.provider, AiProvider::OpenAi);
422 assert_eq!(info.model, "gpt-5-mini");
423 }
424
425 #[test]
426 fn openai_errors_without_api_key() {
427 let env = MapEnv::new().with("USE_OPENAI", "true");
428 let err = check_ai_credentials_with(&env, None).unwrap_err();
429 assert!(err.to_string().contains("OpenAI API key not found"));
430 }
431
432 #[test]
433 fn bedrock_default_model_from_registry() {
434 let env = MapEnv::new()
435 .with("CLAUDE_CODE_USE_BEDROCK", "true")
436 .with("ANTHROPIC_AUTH_TOKEN", "test-token")
437 .with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
438
439 let info = check_ai_credentials_with(&env, None).unwrap();
440 assert_eq!(info.provider, AiProvider::Bedrock);
441 assert_eq!(info.model, "claude-sonnet-4-6");
442 }
443
444 #[test]
445 fn model_override_takes_precedence() {
446 let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
447
448 let info = check_ai_credentials_with(&env, Some("claude-opus-4-6")).unwrap();
449 assert_eq!(info.model, "claude-opus-4-6");
450 }
451
452 #[cfg(unix)]
453 fn make_version_shim(tmp: &tempfile::TempDir, exit_code: i32) -> std::path::PathBuf {
454 let shim = tmp.path().join("claude-bin-shim");
455 crate::test_support::shim::write_exec_script(
456 &shim,
457 &format!("#!/bin/sh\necho 'fake-claude 0.0.0'\nexit {exit_code}\n"),
458 );
459 shim
460 }
461
462 #[test]
463 #[cfg(unix)]
464 fn claude_cli_backend_uses_version_probe() {
465 let _guard = crate::test_support::shim::shim_lock();
467 let tmp = tempfile::TempDir::new().unwrap();
468 let shim = make_version_shim(&tmp, 0);
469
470 let env = MapEnv::new()
471 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
472 .with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap());
473
474 let info = check_ai_credentials_with(&env, None).unwrap();
475 assert_eq!(info.provider, AiProvider::ClaudeCli);
476 assert_eq!(info.model, "claude-sonnet-4-6");
477 }
478
479 #[test]
480 #[cfg(unix)]
481 fn claude_cli_backend_uses_model_from_env() {
482 let _guard = crate::test_support::shim::shim_lock();
484 let tmp = tempfile::TempDir::new().unwrap();
485 let shim = make_version_shim(&tmp, 0);
486
487 let env = MapEnv::new()
488 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
489 .with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap())
490 .with("CLAUDE_MODEL", "haiku");
491
492 let info = check_ai_credentials_with(&env, None).unwrap();
493 assert_eq!(info.provider, AiProvider::ClaudeCli);
494 assert_eq!(info.model, "haiku");
495 }
496
497 #[test]
498 fn claude_cli_backend_missing_binary_fails_preflight() {
499 let env = MapEnv::new()
501 .with("OMNI_DEV_AI_BACKEND", "claude-cli")
502 .with("OMNI_DEV_CLAUDE_CLI_BIN", "/nonexistent/claude-binary-xyz");
503
504 let err = check_ai_credentials_with(&env, None).expect_err("expected missing-binary error");
505 let chain = format!("{err:#}");
506 assert!(
507 chain.contains("Claude Code CLI not available"),
508 "unexpected error: {chain}"
509 );
510 }
511
512 #[test]
513 fn claude_cli_backend_accepts_underscore_alias() {
514 let env = MapEnv::new()
518 .with("OMNI_DEV_AI_BACKEND", "claude_cli")
519 .with("OMNI_DEV_CLAUDE_CLI_BIN", "/nonexistent/claude-binary-xyz");
520
521 let err = check_ai_credentials_with(&env, None).expect_err("expected missing-binary error");
522 let chain = format!("{err:#}");
523 assert!(chain.contains("Claude Code CLI not available"));
524 }
525}