Skip to main content

omni_dev/utils/
preflight.rs

1//! Preflight validation checks for early failure detection.
2//!
3//! This module provides functions to validate required services and credentials
4//! before starting expensive operations. Commands should call these checks early
5//! to fail fast with clear error messages.
6
7use anyhow::{bail, Context, Result};
8
9use crate::claude::model_config::get_model_registry;
10
11/// Result of AI credential validation.
12#[derive(Debug)]
13pub struct AiCredentialInfo {
14    /// The AI provider that will be used.
15    pub provider: AiProvider,
16    /// The model that will be used.
17    pub model: String,
18}
19
20/// AI provider types.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum AiProvider {
23    /// Anthropic Claude API.
24    Claude,
25    /// AWS Bedrock with Claude.
26    Bedrock,
27    /// OpenAI API.
28    OpenAi,
29    /// Local Ollama.
30    Ollama,
31    /// `claude -p` subprocess (Claude Code CLI).
32    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
47/// Validates that AI credentials are available before processing.
48///
49/// This performs a lightweight check of environment variables without
50/// creating a full AI client. Use this at the start of commands that
51/// require AI to fail fast if credentials are missing.
52pub fn check_ai_credentials(model_override: Option<&str>) -> Result<AiCredentialInfo> {
53    check_ai_credentials_with(&crate::utils::settings::SettingsEnv::load(), model_override)
54}
55
56/// [`check_ai_credentials`] over an injected
57/// [`EnvSource`](crate::utils::env::EnvSource).
58///
59/// The production wrapper passes `&SettingsEnv::load()` (process env with a
60/// settings.json fallback); tests pass a pure `MapEnv`, so this env-parsing
61/// boundary is exercised without mutating the process environment or taking a
62/// lock (issue #1030).
63pub(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        // Credentials for the `claude -p` subprocess backend live inside the
74        // `claude` binary's own auth state, so we just verify the binary runs.
75        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        // Ollama needs no credentials, just a model.
96        AiBackend::Ollama => Ok(AiCredentialInfo {
97            provider: AiProvider::Ollama,
98            model,
99        }),
100
101        AiBackend::OpenAi => {
102            // Verify API key exists
103            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            // Verify Bedrock configuration
121            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            // Verify API key exists
143            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
166/// Validates that GitHub CLI is available and authenticated.
167///
168/// This checks:
169/// 1. `gh` CLI is installed and in PATH
170/// 2. User is authenticated (can access the current repo)
171///
172/// Use this at the start of commands that require GitHub API access.
173///
174/// `repo_root` anchors the repository-access probe to the injected repository
175/// rather than the process current working directory.
176pub fn check_github_cli(repo_root: &std::path::Path) -> Result<()> {
177    // Check if gh CLI is available. This probe is a PATH availability check
178    // (CWD-independent), so it is not anchored to `repo_root`.
179    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            // Test if gh can access the injected repo
186            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
217/// Validates that `repo_root` is a valid git repository.
218///
219/// A lightweight check that opens the repository without loading commit data.
220pub 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
227/// Validates that the working directory at `repo_root` is clean — no
228/// uncommitted changes (staged, unstaged, or untracked non-ignored files).
229///
230/// Use this before operations that require a clean working directory, like
231/// amending commits.
232pub 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
238/// Shared clean-worktree check over an already-opened repository.
239fn 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
256/// Performs combined preflight check for AI commands.
257///
258/// Validates:
259/// - Git repository access
260/// - AI credentials
261///
262/// Returns information about the AI provider that will be used.
263///
264/// `repo_root` anchors the git-repository check to the injected repository
265/// rather than the process current working directory.
266pub 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
274/// Performs combined preflight check for PR creation.
275///
276/// Validates:
277/// - Git repository access
278/// - AI credentials
279/// - GitHub CLI availability and authentication
280///
281/// Returns information about the AI provider that will be used.
282///
283/// `repo_root` anchors the git-repository and GitHub CLI checks to the injected
284/// repository rather than the process current working directory.
285pub 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        // Claude API path with a dummy key, no model override. A pure MapEnv
344        // means absent vars (USE_OPENAI, …) simply read as None — no need to
345        // clear anything, and no process-global env is touched.
346        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        // shim_lock guards the exec-script/ETXTBSY race (#642), not env.
425        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        // shim_lock guards the exec-script/ETXTBSY race (#642), not env.
442        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        // A nonexistent binary path never spawns, so no shim_lock is needed.
459        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        // OMNI_DEV_AI_BACKEND=openai wins even though USE_OLLAMA is set.
474        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        // `--ai-backend default` propagates as OMNI_DEV_AI_BACKEND=default and
487        // must override the USE_* soup.
488        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        // The headline #1118 bug: CLAUDE_MODEL / CLAUDE_CODE_MODEL were
525        // silently ignored by the direct-API and Bedrock paths.
526        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        // The factory/preflight accept both `claude-cli` and `claude_cli`.
564        // Verify the second spelling routes the same way (missing-binary
565        // path exercises the selector cheaply).
566        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}