1#![allow(dead_code)] use anyhow::Result;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13#[derive(Debug, Clone)]
15pub struct CliTool {
16 pub name: String,
17 pub command: String,
18 pub npm_package: String,
19 pub description: String,
20 pub installed: bool,
21 pub version: Option<String>,
22 pub install_type: Option<InstallType>,
23 pub install_path: Option<PathBuf>,
24}
25
26#[derive(Debug, Clone, PartialEq)]
28pub enum InstallType {
29 Native, Npm, #[allow(dead_code)]
32 Unknown, }
34
35#[derive(Debug, Clone)]
37pub struct NativeInstallInfo {
38 pub version: Option<String>,
39 pub path: PathBuf,
40}
41
42#[derive(Debug, Clone)]
44pub struct NpmInstallInfo {
45 pub version: Option<String>,
46 pub path: PathBuf,
47}
48
49#[allow(dead_code)]
51pub struct CliToolDetector {
52 tools: Vec<CliTool>,
53}
54
55impl Default for CliToolDetector {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61impl CliToolDetector {
62 pub fn new() -> Self {
64 let mut detector = Self { tools: Vec::new() };
65 detector.initialize_tools();
66 detector
67 }
68
69 fn initialize_tools(&mut self) {
71 self.tools = vec![
72 CliTool {
73 name: "Claude Code".to_string(),
74 command: "claude".to_string(),
75 npm_package: "@anthropic-ai/claude-code".to_string(),
76 description: "Anthropic Claude Code CLI tool".to_string(),
77 installed: false,
78 version: None,
79 install_type: None,
80 install_path: None,
81 },
82 CliTool {
83 name: "Codex CLI".to_string(),
84 command: "codex".to_string(),
85 npm_package: "@openai/codex".to_string(),
86 description: "OpenAI Codex CLI tool".to_string(),
87 installed: false,
88 version: None,
89 install_type: None,
90 install_path: None,
91 },
92 CliTool {
93 name: "Gemini CLI".to_string(),
94 command: "gemini".to_string(),
95 npm_package: "@google/gemini-cli".to_string(),
96 description: "Google Gemini CLI tool".to_string(),
97 installed: false,
98 version: None,
99 install_type: None,
100 install_path: None,
101 },
102 ];
103 }
104
105 pub fn detect_all_tools(&mut self) -> Result<()> {
107 for tool in &mut self.tools {
108 Self::detect_tool_installation_static(tool);
109 }
110 Ok(())
111 }
112
113 pub fn get_tools(&self) -> &[CliTool] {
115 &self.tools
116 }
117
118 pub fn get_installed_tools(&self) -> Vec<&CliTool> {
120 self.tools.iter().filter(|tool| tool.installed).collect()
121 }
122
123 pub fn get_uninstalled_tools(&self) -> Vec<&CliTool> {
125 self.tools.iter().filter(|tool| !tool.installed).collect()
126 }
127
128 pub fn get_tool_by_command(&self, command: &str) -> Option<&CliTool> {
130 self.tools.iter().find(|tool| tool.command == command)
131 }
132
133 fn detect_tool_installation_static(tool: &mut CliTool) {
135 if let Ok(path) = which::which(&tool.command) {
137 tool.installed = true;
138 tool.install_path = Some(path.clone());
139
140 tool.install_type = Self::detect_install_type_static(&path);
142
143 tool.version = Self::get_tool_version_static(&tool.command);
145 } else {
146 tool.installed = false;
147 tool.install_path = None;
148 tool.install_type = None;
149 tool.version = None;
150 }
151 }
152
153 fn detect_install_type_static(path: &Path) -> Option<InstallType> {
155 if let Some(path_str) = path.to_str() {
156 if path_str.contains("node_modules") || path_str.contains("npm") {
157 return Some(InstallType::Npm);
158 }
159 }
160 Some(InstallType::Native)
161 }
162
163 fn get_tool_version_static(command: &str) -> Option<String> {
165 Command::new(command)
166 .arg("--version")
167 .output()
168 .ok()
169 .filter(|output| output.status.success())
170 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
171 .filter(|s| !s.is_empty())
172 }
173
174 pub fn detect_nodejs(&self) -> Option<String> {
176 Command::new("node")
177 .arg("--version")
178 .output()
179 .ok()
180 .filter(|output| output.status.success())
181 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
182 .filter(|s| !s.is_empty())
183 }
184
185 pub fn detect_npm(&self) -> bool {
187 Command::new("npm")
188 .arg("--version")
189 .output()
190 .map(|output| output.status.success())
191 .unwrap_or(false)
192 }
193
194 pub async fn auto_install_nodejs() -> Result<()> {
196 if Command::new("node")
198 .arg("--version")
199 .output()
200 .map(|output| output.status.success())
201 .unwrap_or(false)
202 {
203 println!("✅ Node.js is already installed");
204 return Ok(());
205 }
206
207 println!("📦 Node.js not detected. Attempting to install via nvm...");
208
209 let os = Self::get_os_type();
210 let install_result = match os {
211 "Windows" => Self::install_nodejs_windows().await,
212 "macOS" | "Linux" => Self::install_nodejs_via_nvm().await,
213 _ => {
214 anyhow::bail!("Unsupported operating system: {}", os);
215 }
216 };
217
218 install_result?;
219
220 println!("🔍 Verifying Node.js installation...");
222 tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
223
224 if Command::new("node")
225 .arg("--version")
226 .output()
227 .map(|output| output.status.success())
228 .unwrap_or(false)
229 {
230 println!("✅ Node.js installed successfully!");
231 Ok(())
232 } else {
233 println!("⚠️ Node.js installation completed but not immediately available.");
234 println!(" Please restart your terminal and try again.");
235 anyhow::bail!("Node.js verification failed - terminal restart required");
236 }
237 }
238
239 async fn install_nodejs_via_nvm() -> Result<()> {
241 let os = Self::get_os_type();
242 println!("🔧 Installing Node.js via nvm on {}...", os);
243
244 let nvm_check = Command::new("bash")
246 .arg("-c")
247 .arg("command -v nvm")
248 .output();
249
250 let nvm_installed = nvm_check
251 .map(|output| output.status.success())
252 .unwrap_or(false);
253
254 if !nvm_installed {
255 println!(" 📥 nvm not found. Installing nvm...");
256
257 let nvm_install_script =
259 "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash";
260
261 let install_result = Command::new("bash")
262 .arg("-c")
263 .arg(nvm_install_script)
264 .status();
265
266 match install_result {
267 Ok(status) if status.success() => {
268 println!(" ✅ nvm installed successfully");
269 }
270 _ => {
271 anyhow::bail!(
272 "Failed to install nvm. Please install manually: \
273 curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash"
274 );
275 }
276 }
277
278 println!(" 🔄 Loading nvm...");
280 } else {
281 println!(" ✅ nvm is already installed");
282 }
283
284 println!(" 📦 Installing Node.js LTS via nvm...");
286
287 let nvm_install_node = r#"
289 export NVM_DIR="$HOME/.nvm"
290 [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
291 nvm install --lts
292 nvm use --lts
293 nvm alias default lts/*
294 "#;
295
296 let node_install = Command::new("bash")
297 .arg("-c")
298 .arg(nvm_install_node)
299 .status();
300
301 match node_install {
302 Ok(status) if status.success() => {
303 println!(" ✅ Node.js LTS installed via nvm");
304 Ok(())
305 }
306 _ => {
307 anyhow::bail!(
308 "Failed to install Node.js via nvm. Please run manually: \
309 nvm install --lts && nvm use --lts"
310 );
311 }
312 }
313 }
314
315 async fn install_nodejs_windows() -> Result<()> {
317 println!("🪟 Installing Node.js on Windows...");
318
319 println!(" Trying winget...");
321 let winget_result = Command::new("winget")
322 .args(&[
323 "install",
324 "OpenJS.NodeJS",
325 "--accept-package-agreements",
326 "--accept-source-agreements",
327 ])
328 .status();
329
330 if let Ok(status) = winget_result {
331 if status.success() {
332 return Ok(());
333 }
334 println!(" Winget installation failed, trying chocolatey...");
335 }
336
337 println!(" Trying chocolatey...");
339 let choco_result = Command::new("choco")
340 .args(&["install", "nodejs", "-y"])
341 .status();
342
343 match choco_result {
344 Ok(status) if status.success() => Ok(()),
345 _ => {
346 anyhow::bail!(
347 "Failed to install Node.js on Windows. Please install manually from https://nodejs.org/ \
348 or install winget/chocolatey first."
349 );
350 }
351 }
352 }
353
354 pub fn get_install_hint(&self, command: &str) -> String {
356 match command.to_lowercase().as_str() {
357 "claude" => "npm install -g @anthropic-ai/claude-code".to_string(),
358 "codex" => "npm install -g @openai/codex".to_string(),
359 "gemini" => "npm install -g @google/gemini-cli".to_string(),
360 _ => format!("Install {} via appropriate package manager", command),
361 }
362 }
363
364 pub async fn check_for_updates(&self) -> Result<Vec<(String, Option<String>, Option<String>)>> {
366 let mut updates = Vec::new();
367
368 for tool in &self.tools {
369 if !tool.installed {
370 continue;
371 }
372
373 let current_version = tool.version.clone();
374 let latest_version = Self::check_latest_version(&tool.npm_package).await;
375
376 updates.push((tool.name.clone(), current_version, latest_version));
377 }
378
379 Ok(updates)
380 }
381
382 async fn check_latest_version(package: &str) -> Option<String> {
384 let encoded_package = urlencoding::encode(package);
386 let url = format!("https://registry.npmjs.org/{}/latest", encoded_package);
387
388 match reqwest::get(&url).await {
389 Ok(response) => {
390 if response.status().is_success() {
391 match response.json::<serde_json::Value>().await {
392 Ok(json) => {
393 if let Some(version) = json.get("version").and_then(|v| v.as_str()) {
394 return Some(version.to_string());
395 }
396 }
397 Err(e) => {
398 eprintln!("Failed to parse npm registry response: {}", e);
399 }
400 }
401 } else {
402 eprintln!("NPM registry returned status: {}", response.status());
403 }
404 }
405 Err(e) => {
406 eprintln!("Failed to query npm registry: {}", e);
407 }
408 }
409 None
410 }
411
412 pub fn get_os_type() -> &'static str {
414 #[cfg(target_os = "windows")]
415 return "Windows";
416 #[cfg(target_os = "macos")]
417 return "macOS";
418 #[cfg(target_os = "linux")]
419 return "Linux";
420 #[cfg(target_os = "freebsd")]
421 return "FreeBSD";
422 #[cfg(not(any(
423 target_os = "windows",
424 target_os = "macos",
425 target_os = "linux",
426 target_os = "freebsd"
427 )))]
428 return "Unknown";
429 }
430}
431
432pub async fn detect_ai_cli_tools() -> Result<Vec<CliTool>> {
434 let mut detector = CliToolDetector::new();
435 detector.detect_all_tools()?;
436 Ok(detector.tools)
437}
438
439pub fn get_install_commands() -> Vec<(String, String)> {
441 let detector = CliToolDetector::new();
442 detector
443 .get_uninstalled_tools()
444 .into_iter()
445 .map(|tool| (tool.name.clone(), detector.get_install_hint(&tool.command)))
446 .collect()
447}
448
449pub async fn execute_update(tool_name: Option<&str>) -> Result<Vec<(String, bool, String)>> {
454 println!("🔍 Checking Node.js installation...");
456 if let Err(e) = CliToolDetector::auto_install_nodejs().await {
457 eprintln!("⚠️ Node.js auto-install failed: {}", e);
458 eprintln!("Please install Node.js manually from https://nodejs.org/");
459 anyhow::bail!("Node.js is required but not available");
460 }
461
462 let mut detector = CliToolDetector::new();
463 detector.detect_all_tools()?;
464
465 let mut results = Vec::new();
466
467 let tools_to_process: Vec<&CliTool> = if let Some(name) = tool_name {
469 match detector.get_tool_by_command(name) {
471 Some(tool) => vec![tool],
472 None => {
473 anyhow::bail!(
474 "Unknown AI CLI tool: {}. Supported: claude, codex, gemini",
475 name
476 );
477 }
478 }
479 } else {
480 detector.get_installed_tools()
482 };
483
484 if tools_to_process.is_empty() {
485 println!("No AI CLI tools to update.");
486 println!("Use 'agentic-warden update <tool>' to install a specific tool.");
487 return Ok(results);
488 }
489
490 for tool in tools_to_process {
492 println!("\n🔧 Processing {}...", tool.name);
493
494 let npm_package = &tool.npm_package;
496 let current_version = tool.version.clone();
497
498 println!(" Checking latest version...");
500 let latest_version = match CliToolDetector::check_latest_version(npm_package).await {
501 Some(version) => version,
502 None => {
503 eprintln!(" ❌ Failed to get latest version for {}", npm_package);
504 results.push((
505 tool.name.clone(),
506 false,
507 "Failed to check version".to_string(),
508 ));
509 continue;
510 }
511 };
512
513 println!(" Latest version: {}", latest_version);
514
515 if let Some(ref current) = current_version {
516 println!(" Current version: {}", current);
517
518 if current == &latest_version {
519 println!(" ✅ Already up to date!");
520 results.push((tool.name.clone(), true, "Already up to date".to_string()));
521 continue;
522 }
523 } else {
524 println!(" Not currently installed");
525 }
526
527 println!(" Installing...");
529 let install_cmd = detector.get_install_hint(&tool.command);
530
531 let install_cmd = if current_version.is_some() {
533 format!("{}@latest", install_cmd)
535 } else {
536 install_cmd
538 };
539
540 match std::process::Command::new("sh")
541 .arg("-c")
542 .arg(&install_cmd)
543 .status()
544 {
545 Ok(status) => {
546 if status.success() {
547 println!(" ✅ Successfully updated/installed!");
548 results.push((tool.name.clone(), true, "Success".to_string()));
549 } else {
550 eprintln!(
551 " ❌ Installation failed with exit code: {:?}",
552 status.code()
553 );
554 results.push((
555 tool.name.clone(),
556 false,
557 format!("Installation failed: {:?}", status.code()),
558 ));
559 }
560 }
561 Err(e) => {
562 eprintln!(" ❌ Failed to execute npm: {}", e);
563 results.push((tool.name.clone(), false, format!("Execution error: {}", e)));
564 }
565 }
566 }
567
568 println!("\n{}", "=".repeat(60));
570 println!("📊 Update Summary:");
571 for (name, success, message) in &results {
572 let status = if *success { "✅" } else { "❌" };
573 println!(" {} {} - {}", status, name, message);
574 }
575 println!("{}", "=".repeat(60));
576
577 Ok(results)
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 #[test]
585 fn test_cli_tool_detector_creation() {
586 let detector = CliToolDetector::new();
587 assert_eq!(detector.tools.len(), 3);
588 }
589
590 #[test]
591 fn test_get_install_commands() {
592 let commands = get_install_commands();
593 assert!(!commands.is_empty());
595 }
596
597 #[test]
598 fn test_os_type_detection() {
599 let os_type = CliToolDetector::get_os_type();
600 assert!(!os_type.is_empty());
601 assert_ne!(os_type, "Unknown");
602 }
603
604 #[test]
605 fn test_get_tool_by_command() {
606 let detector = CliToolDetector::new();
607 let claude_tool = detector.get_tool_by_command("claude");
608 assert!(claude_tool.is_some());
609 assert_eq!(claude_tool.unwrap().command, "claude");
610
611 let nonexistent = detector.get_tool_by_command("nonexistent");
612 assert!(nonexistent.is_none());
613 }
614
615 #[test]
616 fn test_get_install_hint() {
617 let detector = CliToolDetector::new();
618
619 let codex_hint = detector.get_install_hint("codex");
621 assert!(codex_hint.contains("npm install"));
622 assert!(codex_hint.contains("@openai/codex"));
623
624 let gemini_hint = detector.get_install_hint("gemini");
626 assert!(gemini_hint.contains("npm install"));
627 assert!(gemini_hint.contains("@google/gemini-cli"));
628
629 let unknown_hint = detector.get_install_hint("unknown");
631 assert!(unknown_hint.contains("Install"));
632 }
633
634 #[test]
635 fn test_get_install_hint_claude() {
636 let detector = CliToolDetector::new();
637 let claude_hint = detector.get_install_hint("claude");
638
639 assert!(claude_hint.contains("npm install"));
641 assert!(claude_hint.contains("@anthropic-ai/claude-code"));
642 }
643}