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    // The `claude -p` subprocess backend is checked first so it wins over
68    // the existing USE_* flags if multiple are set. Credentials for this
69    // backend live inside the `claude` binary's own auth state, so we just
70    // verify the binary is on PATH.
71    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    // Check provider selection flags
108    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    // Check Ollama (no credentials required, just model)
117    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    // Check OpenAI
130    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        // Verify API key exists
143        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    // Check Bedrock
160    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        // Verify Bedrock configuration
173        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    // Default: Claude API
194    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    // Verify API key exists
206    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
227/// Validates that GitHub CLI is available and authenticated.
228///
229/// This checks:
230/// 1. `gh` CLI is installed and in PATH
231/// 2. User is authenticated (can access the current repo)
232///
233/// Use this at the start of commands that require GitHub API access.
234///
235/// `repo_root` anchors the repository-access probe to the injected repository
236/// rather than the process current working directory.
237pub fn check_github_cli(repo_root: &std::path::Path) -> Result<()> {
238    // Check if gh CLI is available. This probe is a PATH availability check
239    // (CWD-independent), so it is not anchored to `repo_root`.
240    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            // Test if gh can access the injected repo
247            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
278/// Validates that `repo_root` is a valid git repository.
279///
280/// A lightweight check that opens the repository without loading commit data.
281pub 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
288/// Validates that the working directory at `repo_root` is clean — no
289/// uncommitted changes (staged, unstaged, or untracked non-ignored files).
290///
291/// Use this before operations that require a clean working directory, like
292/// amending commits.
293pub 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
299/// Shared clean-worktree check over an already-opened repository.
300fn 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
317/// Performs combined preflight check for AI commands.
318///
319/// Validates:
320/// - Git repository access
321/// - AI credentials
322///
323/// Returns information about the AI provider that will be used.
324///
325/// `repo_root` anchors the git-repository check to the injected repository
326/// rather than the process current working directory.
327pub 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
335/// Performs combined preflight check for PR creation.
336///
337/// Validates:
338/// - Git repository access
339/// - AI credentials
340/// - GitHub CLI availability and authentication
341///
342/// Returns information about the AI provider that will be used.
343///
344/// `repo_root` anchors the git-repository and GitHub CLI checks to the injected
345/// repository rather than the process current working directory.
346pub 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        // Claude API path with a dummy key, no model override. A pure MapEnv
405        // means absent vars (USE_OPENAI, …) simply read as None — no need to
406        // clear anything, and no process-global env is touched.
407        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        // shim_lock guards the exec-script/ETXTBSY race (#642), not env.
466        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        // shim_lock guards the exec-script/ETXTBSY race (#642), not env.
483        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        // A nonexistent binary path never spawns, so no shim_lock is needed.
500        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        // The factory/preflight accept both `claude-cli` and `claude_cli`.
515        // Verify the second spelling routes the same way (missing-binary
516        // path exercises the selector cheaply).
517        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}