use anyhow::{bail, Context, Result};
use crate::claude::model_config::get_model_registry;
#[derive(Debug)]
pub struct AiCredentialInfo {
pub provider: AiProvider,
pub model: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiProvider {
Claude,
Bedrock,
OpenAi,
Ollama,
ClaudeCli,
}
impl std::fmt::Display for AiProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Claude => write!(f, "Claude API"),
Self::Bedrock => write!(f, "AWS Bedrock"),
Self::OpenAi => write!(f, "OpenAI API"),
Self::Ollama => write!(f, "Ollama"),
Self::ClaudeCli => write!(f, "Claude Code CLI"),
}
}
}
pub fn check_ai_credentials(model_override: Option<&str>) -> Result<AiCredentialInfo> {
check_ai_credentials_with(&crate::utils::settings::SettingsEnv::load(), model_override)
}
pub(crate) fn check_ai_credentials_with(
env: &impl crate::utils::env::EnvSource,
model_override: Option<&str>,
) -> Result<AiCredentialInfo> {
use crate::claude::backend::{self, AiBackend};
let ai_backend = backend::resolve_backend(env)?;
let model = backend::resolve_model(ai_backend, model_override, env, get_model_registry());
match ai_backend {
AiBackend::ClaudeCli => {
let binary = env
.var("OMNI_DEV_CLAUDE_CLI_BIN")
.unwrap_or_else(|| "claude".to_string());
let probe = std::process::Command::new(&binary)
.arg("--version")
.output();
match probe {
Ok(out) if out.status.success() => Ok(AiCredentialInfo {
provider: AiProvider::ClaudeCli,
model,
}),
_ => bail!(
"Claude Code CLI not available at '{binary}'.\n\
Install it from https://github.com/anthropics/claude-code \
or set OMNI_DEV_CLAUDE_CLI_BIN to its path."
),
}
}
AiBackend::Ollama => Ok(AiCredentialInfo {
provider: AiProvider::Ollama,
model,
}),
AiBackend::OpenAi => {
env.var_any(&["OPENAI_API_KEY", "OPENAI_AUTH_TOKEN"])
.ok_or_else(|| {
anyhow::anyhow!(
"OpenAI API key not found.\n\
Set one of these environment variables:\n\
- OPENAI_API_KEY\n\
- OPENAI_AUTH_TOKEN"
)
})?;
Ok(AiCredentialInfo {
provider: AiProvider::OpenAi,
model,
})
}
AiBackend::Bedrock => {
env.var("ANTHROPIC_AUTH_TOKEN").ok_or_else(|| {
anyhow::anyhow!(
"AWS Bedrock authentication not configured.\n\
Set ANTHROPIC_AUTH_TOKEN environment variable."
)
})?;
env.var("ANTHROPIC_BEDROCK_BASE_URL").ok_or_else(|| {
anyhow::anyhow!(
"AWS Bedrock base URL not configured.\n\
Set ANTHROPIC_BEDROCK_BASE_URL environment variable."
)
})?;
Ok(AiCredentialInfo {
provider: AiProvider::Bedrock,
model,
})
}
AiBackend::Default => {
env.var_any(&[
"CLAUDE_API_KEY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
])
.ok_or_else(|| {
anyhow::anyhow!(
"Claude API key not found.\n\
Set one of these environment variables:\n\
- CLAUDE_API_KEY\n\
- ANTHROPIC_API_KEY\n\
- ANTHROPIC_AUTH_TOKEN"
)
})?;
Ok(AiCredentialInfo {
provider: AiProvider::Claude,
model,
})
}
}
}
pub fn check_github_cli(repo_root: &std::path::Path) -> Result<()> {
let gh_check = std::process::Command::new("gh")
.args(["--version"])
.output();
match gh_check {
Ok(output) if output.status.success() => {
let repo_check = std::process::Command::new("gh")
.args(["repo", "view", "--json", "name"])
.current_dir(repo_root)
.output();
match repo_check {
Ok(repo_output) if repo_output.status.success() => Ok(()),
Ok(repo_output) => {
let error_details = String::from_utf8_lossy(&repo_output.stderr);
if error_details.contains("authentication") || error_details.contains("login") {
bail!(
"GitHub CLI authentication failed.\n\
Please run 'gh auth login' or set GITHUB_TOKEN environment variable."
)
}
bail!(
"GitHub CLI cannot access this repository.\n\
Error: {}",
error_details.trim()
)
}
Err(e) => bail!("Failed to test GitHub CLI access: {e}"),
}
}
_ => bail!(
"GitHub CLI (gh) is not installed or not in PATH.\n\
Please install it from https://cli.github.com/"
),
}
}
pub fn check_git_repository_at(repo_root: &std::path::Path) -> Result<()> {
crate::git::GitRepository::open_at(repo_root).context(
"Not in a git repository. Please run this command from within a git repository.",
)?;
Ok(())
}
pub fn check_working_directory_clean_at(repo_root: &std::path::Path) -> Result<()> {
let repo =
crate::git::GitRepository::open_at(repo_root).context("Failed to open git repository")?;
check_working_directory_clean_for(&repo)
}
fn check_working_directory_clean_for(repo: &crate::git::GitRepository) -> Result<()> {
let status = repo
.get_working_directory_status()
.context("Failed to get working directory status")?;
if !status.clean {
let mut message = String::from("Working directory has uncommitted changes:\n");
for change in &status.untracked_changes {
message.push_str(&format!(" {} {}\n", change.status, change.file));
}
message.push_str("\nPlease commit or stash your changes before proceeding.");
bail!(message);
}
Ok(())
}
pub fn check_ai_command_prerequisites(
model_override: Option<&str>,
repo_root: &std::path::Path,
) -> Result<AiCredentialInfo> {
check_git_repository_at(repo_root)?;
check_ai_credentials(model_override)
}
pub fn check_pr_command_prerequisites(
model_override: Option<&str>,
repo_root: &std::path::Path,
) -> Result<AiCredentialInfo> {
check_git_repository_at(repo_root)?;
let ai_info = check_ai_credentials(model_override)?;
check_github_cli(repo_root)?;
Ok(ai_info)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::test_support::env::MapEnv;
#[test]
fn ai_provider_display() {
assert_eq!(format!("{}", AiProvider::Claude), "Claude API");
assert_eq!(format!("{}", AiProvider::Bedrock), "AWS Bedrock");
assert_eq!(format!("{}", AiProvider::OpenAi), "OpenAI API");
assert_eq!(format!("{}", AiProvider::Ollama), "Ollama");
assert_eq!(format!("{}", AiProvider::ClaudeCli), "Claude Code CLI");
}
#[test]
fn ai_provider_equality() {
assert_eq!(AiProvider::Claude, AiProvider::Claude);
assert_ne!(AiProvider::Claude, AiProvider::OpenAi);
assert_ne!(AiProvider::Bedrock, AiProvider::Ollama);
}
#[test]
fn ai_provider_clone() {
let provider = AiProvider::Bedrock;
let cloned = provider;
assert_eq!(provider, cloned);
}
#[test]
fn ai_provider_debug() {
let debug_str = format!("{:?}", AiProvider::Claude);
assert_eq!(debug_str, "Claude");
}
#[test]
fn ai_credential_info_debug() {
let info = AiCredentialInfo {
provider: AiProvider::Ollama,
model: "llama2".to_string(),
};
let debug_str = format!("{info:?}");
assert!(debug_str.contains("Ollama"));
assert!(debug_str.contains("llama2"));
}
#[test]
fn claude_default_model_from_registry() {
let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::Claude);
assert_eq!(info.model, "claude-sonnet-4-6");
}
#[test]
fn openai_default_model_from_registry() {
let env = MapEnv::new()
.with("USE_OPENAI", "true")
.with("OPENAI_API_KEY", "sk-test-dummy");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::OpenAi);
assert_eq!(info.model, "gpt-5-mini");
}
#[test]
fn openai_errors_without_api_key() {
let env = MapEnv::new().with("USE_OPENAI", "true");
let err = check_ai_credentials_with(&env, None).unwrap_err();
assert!(err.to_string().contains("OpenAI API key not found"));
}
#[test]
fn bedrock_default_model_from_registry() {
let env = MapEnv::new()
.with("CLAUDE_CODE_USE_BEDROCK", "true")
.with("ANTHROPIC_AUTH_TOKEN", "test-token")
.with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::Bedrock);
assert_eq!(info.model, "claude-sonnet-4-6");
}
#[test]
fn bedrock_errors_without_auth_token() {
let env = MapEnv::new().with("CLAUDE_CODE_USE_BEDROCK", "true");
let err = check_ai_credentials_with(&env, None).unwrap_err();
assert!(err
.to_string()
.contains("AWS Bedrock authentication not configured"));
}
#[test]
fn bedrock_errors_without_base_url() {
let env = MapEnv::new()
.with("CLAUDE_CODE_USE_BEDROCK", "true")
.with("ANTHROPIC_AUTH_TOKEN", "test-token");
let err = check_ai_credentials_with(&env, None).unwrap_err();
assert!(err
.to_string()
.contains("AWS Bedrock base URL not configured"));
}
#[test]
fn model_override_takes_precedence() {
let env = MapEnv::new().with("ANTHROPIC_API_KEY", "sk-test-dummy");
let info = check_ai_credentials_with(&env, Some("claude-opus-4-6")).unwrap();
assert_eq!(info.model, "claude-opus-4-6");
}
#[cfg(unix)]
fn make_version_shim(tmp: &tempfile::TempDir, exit_code: i32) -> std::path::PathBuf {
let shim = tmp.path().join("claude-bin-shim");
crate::test_support::shim::write_exec_script(
&shim,
&format!("#!/bin/sh\necho 'fake-claude 0.0.0'\nexit {exit_code}\n"),
);
shim
}
#[test]
#[cfg(unix)]
fn claude_cli_backend_uses_version_probe() {
let _guard = crate::test_support::shim::shim_lock();
let tmp = tempfile::TempDir::new().unwrap();
let shim = make_version_shim(&tmp, 0);
let env = MapEnv::new()
.with("OMNI_DEV_AI_BACKEND", "claude-cli")
.with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap());
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::ClaudeCli);
assert_eq!(info.model, "claude-sonnet-4-6");
}
#[test]
#[cfg(unix)]
fn claude_cli_backend_uses_model_from_env() {
let _guard = crate::test_support::shim::shim_lock();
let tmp = tempfile::TempDir::new().unwrap();
let shim = make_version_shim(&tmp, 0);
let env = MapEnv::new()
.with("OMNI_DEV_AI_BACKEND", "claude-cli")
.with("OMNI_DEV_CLAUDE_CLI_BIN", shim.to_str().unwrap())
.with("CLAUDE_MODEL", "haiku");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::ClaudeCli);
assert_eq!(info.model, "haiku");
}
#[test]
fn claude_cli_backend_missing_binary_fails_preflight() {
let env = MapEnv::new()
.with("OMNI_DEV_AI_BACKEND", "claude-cli")
.with("OMNI_DEV_CLAUDE_CLI_BIN", "/nonexistent/claude-binary-xyz");
let err = check_ai_credentials_with(&env, None).expect_err("expected missing-binary error");
let chain = format!("{err:#}");
assert!(
chain.contains("Claude Code CLI not available"),
"unexpected error: {chain}"
);
}
#[test]
fn backend_env_var_overrides_legacy_use_flags() {
let env = MapEnv::new()
.with("OMNI_DEV_AI_BACKEND", "openai")
.with("USE_OLLAMA", "true")
.with("OPENAI_API_KEY", "sk-test-dummy");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::OpenAi);
assert_eq!(info.model, "gpt-5-mini");
}
#[test]
fn backend_default_value_forces_direct_api() {
let env = MapEnv::new()
.with("OMNI_DEV_AI_BACKEND", "default")
.with("USE_OLLAMA", "true")
.with("ANTHROPIC_API_KEY", "sk-test-dummy");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::Claude);
}
#[test]
fn backend_env_var_selects_ollama_and_bedrock() {
let env = MapEnv::new().with("OMNI_DEV_AI_BACKEND", "ollama");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::Ollama);
assert_eq!(info.model, "llama2");
let env = MapEnv::new()
.with("OMNI_DEV_AI_BACKEND", "bedrock")
.with("ANTHROPIC_AUTH_TOKEN", "tok")
.with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::Bedrock);
}
#[test]
fn unknown_backend_value_is_hard_error() {
let env = MapEnv::new()
.with("OMNI_DEV_AI_BACKEND", "junk")
.with("ANTHROPIC_API_KEY", "sk-test-dummy");
let err = check_ai_credentials_with(&env, None).expect_err("unknown backend must error");
assert!(format!("{err:#}").contains("junk"));
}
#[test]
fn claude_api_honours_claude_model_chain() {
let env = MapEnv::new()
.with("CLAUDE_MODEL", "claude-opus-4-6")
.with("ANTHROPIC_MODEL", "claude-sonnet-4-6")
.with("ANTHROPIC_API_KEY", "sk-test-dummy");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::Claude);
assert_eq!(info.model, "claude-opus-4-6");
}
#[test]
fn bedrock_honours_claude_model_chain() {
let env = MapEnv::new()
.with("CLAUDE_CODE_USE_BEDROCK", "true")
.with("CLAUDE_CODE_MODEL", "claude-opus-4-6")
.with("ANTHROPIC_AUTH_TOKEN", "tok")
.with("ANTHROPIC_BEDROCK_BASE_URL", "https://bedrock.example.com");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.provider, AiProvider::Bedrock);
assert_eq!(info.model, "claude-opus-4-6");
}
#[test]
fn omni_dev_model_beats_provider_var() {
let env = MapEnv::new()
.with("USE_OPENAI", "true")
.with("OMNI_DEV_MODEL", "gpt-4.1")
.with("OPENAI_MODEL", "gpt-5-mini")
.with("OPENAI_API_KEY", "sk-test-dummy");
let info = check_ai_credentials_with(&env, None).unwrap();
assert_eq!(info.model, "gpt-4.1");
}
#[test]
fn claude_cli_backend_accepts_underscore_alias() {
let env = MapEnv::new()
.with("OMNI_DEV_AI_BACKEND", "claude_cli")
.with("OMNI_DEV_CLAUDE_CLI_BIN", "/nonexistent/claude-binary-xyz");
let err = check_ai_credentials_with(&env, None).expect_err("expected missing-binary error");
let chain = format!("{err:#}");
assert!(chain.contains("Claude Code CLI not available"));
}
}