Skip to main content

aida_core/ai/
client.rs

1//! AI Client Module
2//!
3//! Handles communication with Claude via CLI or direct API.
4
5use crate::ai::prompts;
6use crate::ai::responses::{
7    self, DuplicatesResponse, EvaluationResponse, GenerateChildrenResponse,
8    ImproveDescriptionResponse, SuggestRelationshipsResponse,
9};
10use crate::models::{Requirement, RequirementsStore};
11use std::path::PathBuf;
12use std::process::Command;
13use thiserror::Error;
14use uuid::Uuid;
15
16/// Errors that can occur during AI operations
17#[derive(Error, Debug)]
18pub enum AiError {
19    #[error("Claude CLI not found at {0}")]
20    CliNotFound(PathBuf),
21
22    #[error("Claude CLI execution failed: {0}")]
23    CliExecFailed(String),
24
25    #[error("API key missing")]
26    ApiKeyMissing,
27
28    #[error("API request failed: {0}")]
29    ApiRequestFailed(String),
30
31    #[error("Invalid response from AI: {0}")]
32    InvalidResponse(String),
33
34    #[error("Rate limited - please wait before retrying")]
35    RateLimited,
36
37    #[error("Context too large for AI model")]
38    ContextTooLarge,
39
40    #[error("Requirement not found: {0}")]
41    RequirementNotFound(Uuid),
42
43    #[error("AI integration not available")]
44    NotAvailable,
45}
46
47/// AI operation mode
48#[derive(Debug, Clone)]
49pub enum AiMode {
50    /// Use Claude CLI with --print flag
51    ClaudeCli { path: PathBuf },
52    /// Direct API integration (future)
53    DirectApi { api_key: String },
54    /// AI features disabled
55    Disabled,
56}
57
58impl Default for AiMode {
59    fn default() -> Self {
60        AiMode::Disabled
61    }
62}
63
64/// AI Client for interacting with Claude
65#[derive(Debug, Clone)]
66pub struct AiClient {
67    mode: AiMode,
68}
69
70impl Default for AiClient {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl AiClient {
77    /// Create a new AI client with auto-detected mode
78    pub fn new() -> Self {
79        let mode = Self::detect_mode();
80        Self { mode }
81    }
82
83    /// Create a client with a specific mode
84    pub fn with_mode(mode: AiMode) -> Self {
85        Self { mode }
86    }
87
88    /// Detect the best available AI mode
89    fn detect_mode() -> AiMode {
90        // Try to find claude CLI
91        if let Some(path) = Self::find_claude_cli() {
92            return AiMode::ClaudeCli { path };
93        }
94
95        // Could check for API key in environment
96        if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
97            if !api_key.is_empty() {
98                return AiMode::DirectApi { api_key };
99            }
100        }
101
102        AiMode::Disabled
103    }
104
105    /// Find the claude CLI executable
106    fn find_claude_cli() -> Option<PathBuf> {
107        // Common locations to check
108        let candidates = [
109            // In PATH
110            "claude",
111            // npm global install locations
112            "/usr/local/bin/claude",
113            "/usr/bin/claude",
114        ];
115
116        // First check if 'claude' is in PATH
117        if let Ok(output) = Command::new("which").arg("claude").output() {
118            if output.status.success() {
119                let path_str = String::from_utf8_lossy(&output.stdout);
120                let path = PathBuf::from(path_str.trim());
121                if path.exists() {
122                    return Some(path);
123                }
124            }
125        }
126
127        // Check common locations
128        for candidate in candidates {
129            let path = PathBuf::from(candidate);
130            if path.exists() {
131                return Some(path);
132            }
133        }
134
135        // Check home directory npm global
136        if let Ok(home) = std::env::var("HOME") {
137            let npm_global = PathBuf::from(home).join(".npm-global/bin/claude");
138            if npm_global.exists() {
139                return Some(npm_global);
140            }
141        }
142
143        None
144    }
145
146    /// Check if AI features are available
147    pub fn is_available(&self) -> bool {
148        match &self.mode {
149            AiMode::ClaudeCli { path } => path.exists(),
150            AiMode::DirectApi { api_key } => !api_key.is_empty(),
151            AiMode::Disabled => false,
152        }
153    }
154
155    /// Get the current mode
156    pub fn mode(&self) -> &AiMode {
157        &self.mode
158    }
159
160    /// Get a description of the current mode
161    pub fn mode_description(&self) -> String {
162        match &self.mode {
163            AiMode::ClaudeCli { path } => format!("Claude CLI ({})", path.display()),
164            AiMode::DirectApi { .. } => "Direct API".to_string(),
165            AiMode::Disabled => "Disabled".to_string(),
166        }
167    }
168
169    /// Evaluate a requirement's quality
170    pub fn evaluate_requirement(
171        &self,
172        req: &Requirement,
173        store: &RequirementsStore,
174    ) -> Result<EvaluationResponse, AiError> {
175        let prompt = prompts::build_evaluation_prompt(req, store);
176        let response = self.send_request(&prompt)?;
177        responses::parse_evaluation_response(&response)
178    }
179
180    /// Find potential duplicate requirements
181    pub fn find_duplicates(
182        &self,
183        req: &Requirement,
184        store: &RequirementsStore,
185    ) -> Result<DuplicatesResponse, AiError> {
186        let prompt = prompts::build_duplicates_prompt(req, store);
187        let response = self.send_request(&prompt)?;
188        responses::parse_duplicates_response(&response)
189    }
190
191    /// Suggest relationships for a requirement
192    pub fn suggest_relationships(
193        &self,
194        req: &Requirement,
195        store: &RequirementsStore,
196    ) -> Result<SuggestRelationshipsResponse, AiError> {
197        let prompt = prompts::build_relationships_prompt(req, store);
198        let response = self.send_request(&prompt)?;
199        responses::parse_relationships_response(&response)
200    }
201
202    /// Improve a requirement's description
203    pub fn improve_description(
204        &self,
205        req: &Requirement,
206        store: &RequirementsStore,
207    ) -> Result<ImproveDescriptionResponse, AiError> {
208        let prompt = prompts::build_improve_prompt(req, store);
209        let response = self.send_request(&prompt)?;
210        responses::parse_improve_response(&response)
211    }
212
213    /// Generate child requirements
214    pub fn generate_children(
215        &self,
216        req: &Requirement,
217        store: &RequirementsStore,
218    ) -> Result<GenerateChildrenResponse, AiError> {
219        let prompt = prompts::build_generate_children_prompt(req, store);
220        let response = self.send_request(&prompt)?;
221        responses::parse_generate_children_response(&response)
222    }
223
224    /// Send a request to the AI
225    fn send_request(&self, prompt: &str) -> Result<String, AiError> {
226        match &self.mode {
227            AiMode::ClaudeCli { path } => self.send_cli_request(path, prompt),
228            AiMode::DirectApi { api_key: _ } => {
229                // Future: implement direct API
230                Err(AiError::NotAvailable)
231            }
232            AiMode::Disabled => Err(AiError::NotAvailable),
233        }
234    }
235
236    /// Send request via Claude CLI
237    fn send_cli_request(&self, cli_path: &PathBuf, prompt: &str) -> Result<String, AiError> {
238        // Use --print flag for non-interactive output
239        // Use -p flag to pass the prompt
240        let output = Command::new(cli_path)
241            .arg("--print")
242            .arg("-p")
243            .arg(prompt)
244            .output()
245            .map_err(|e| AiError::CliExecFailed(e.to_string()))?;
246
247        if !output.status.success() {
248            let stderr = String::from_utf8_lossy(&output.stderr);
249            return Err(AiError::CliExecFailed(format!(
250                "Exit code: {:?}, stderr: {}",
251                output.status.code(),
252                stderr
253            )));
254        }
255
256        let response = String::from_utf8_lossy(&output.stdout).to_string();
257
258        if response.is_empty() {
259            return Err(AiError::InvalidResponse(
260                "Empty response from CLI".to_string(),
261            ));
262        }
263
264        Ok(response)
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_mode_detection() {
274        let client = AiClient::new();
275        // Just ensure it doesn't panic
276        let _ = client.is_available();
277        let _ = client.mode_description();
278    }
279
280    #[test]
281    fn test_disabled_mode() {
282        let client = AiClient::with_mode(AiMode::Disabled);
283        assert!(!client.is_available());
284        assert_eq!(client.mode_description(), "Disabled");
285    }
286}