agentic_warden/
cli_manager.rs

1//! CLI Tool Detection Module
2//!
3//! This module provides CLI tool detection and metadata for AI CLI tools
4//! (Claude, Codex, Gemini). It focuses on core functionality without
5//! interactive UI components.
6
7#![allow(dead_code)] // CLI管理模块,部分功能当前未使用
8
9use anyhow::Result;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13/// CLI Tool information
14#[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/// Installation type
27#[derive(Debug, Clone, PartialEq)]
28pub enum InstallType {
29    Native, // Native executable
30    Npm,    // NPM package
31    #[allow(dead_code)]
32    Unknown, // Unknown installation type
33}
34
35/// Native installation information
36#[derive(Debug, Clone)]
37pub struct NativeInstallInfo {
38    pub version: Option<String>,
39    pub path: PathBuf,
40}
41
42/// NPM installation information
43#[derive(Debug, Clone)]
44pub struct NpmInstallInfo {
45    pub version: Option<String>,
46    pub path: PathBuf,
47}
48
49/// CLI Tool Detector for non-interactive operations
50#[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    /// Create a new CLI tool detector
63    pub fn new() -> Self {
64        let mut detector = Self { tools: Vec::new() };
65        detector.initialize_tools();
66        detector
67    }
68
69    /// Initialize known AI CLI tools
70    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    /// Detect all CLI tools status
106    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    /// Get all tools
114    pub fn get_tools(&self) -> &[CliTool] {
115        &self.tools
116    }
117
118    /// Get installed tools only
119    pub fn get_installed_tools(&self) -> Vec<&CliTool> {
120        self.tools.iter().filter(|tool| tool.installed).collect()
121    }
122
123    /// Get uninstalled tools only
124    pub fn get_uninstalled_tools(&self) -> Vec<&CliTool> {
125        self.tools.iter().filter(|tool| !tool.installed).collect()
126    }
127
128    /// Get tool by command name
129    pub fn get_tool_by_command(&self, command: &str) -> Option<&CliTool> {
130        self.tools.iter().find(|tool| tool.command == command)
131    }
132
133    /// Detect tool installation status
134    fn detect_tool_installation_static(tool: &mut CliTool) {
135        // Check if command is available
136        if let Ok(path) = which::which(&tool.command) {
137            tool.installed = true;
138            tool.install_path = Some(path.clone());
139
140            // Try to detect installation type
141            tool.install_type = Self::detect_install_type_static(&path);
142
143            // Try to get version
144            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    /// Detect installation type from path
154    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    /// Get tool version
164    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    /// Check if Node.js is available
175    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    /// Check if npm is available
186    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    /// Auto-install Node.js if not available (cross-platform)
195    pub async fn auto_install_nodejs() -> Result<()> {
196        // First check if Node.js is already installed
197        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        // Verify installation
221        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    /// Install Node.js via nvm (Linux/macOS unified method)
240    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        // Step 1: Check if nvm is already installed
245        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            // Download and install nvm
258            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            // Source nvm in current shell session
279            println!("  🔄 Loading nvm...");
280        } else {
281            println!("  ✅ nvm is already installed");
282        }
283
284        // Step 2: Install Node.js LTS via nvm
285        println!("  📦 Installing Node.js LTS via nvm...");
286
287        // Construct nvm command with proper environment sourcing
288        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    /// Install Node.js on Windows using winget (preferred) or chocolatey
316    async fn install_nodejs_windows() -> Result<()> {
317        println!("🪟 Installing Node.js on Windows...");
318
319        // Try winget first (available on Windows 10+ by default)
320        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        // Fallback to chocolatey
338        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    /// Get installation hint for a tool based on OS
355    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    /// Check for updates (non-interactive version check)
365    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    /// Check latest version from npm registry
383    async fn check_latest_version(package: &str) -> Option<String> {
384        // URL encode the package name for scoped packages (e.g., @anthropic-ai/claude-cli)
385        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    /// Get OS type for tool recommendations
413    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
432/// Convenience function to create a detector and detect all tools
433pub 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
439/// Get installation commands for all uninstalled tools
440pub 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
449/// Execute update/install for AI CLI tools
450///
451/// If tool_name is None, update all installed tools
452/// If tool_name is Some, update/install that specific tool
453pub async fn execute_update(tool_name: Option<&str>) -> Result<Vec<(String, bool, String)>> {
454    // Step 0: Ensure Node.js is installed (required for all AI CLI tools)
455    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    // Determine which tools to process
468    let tools_to_process: Vec<&CliTool> = if let Some(name) = tool_name {
469        // Single tool mode - find the tool by command name
470        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        // All installed tools mode
481        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    // Process each tool
491    for tool in tools_to_process {
492        println!("\n🔧 Processing {}...", tool.name);
493
494        // All tools use npm for installation and updates
495        let npm_package = &tool.npm_package;
496        let current_version = tool.version.clone();
497
498        // Get latest version from npm
499        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        // Execute npm install
528        println!("  Installing...");
529        let install_cmd = detector.get_install_hint(&tool.command);
530
531        // Replace version if updating to latest
532        let install_cmd = if current_version.is_some() {
533            // Update mode - add @latest
534            format!("{}@latest", install_cmd)
535        } else {
536            // Install mode - use as is
537            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    // Print summary
569    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        // Should return commands for tools that aren't installed
594        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        // Test codex (available on npm)
620        let codex_hint = detector.get_install_hint("codex");
621        assert!(codex_hint.contains("npm install"));
622        assert!(codex_hint.contains("@openai/codex"));
623
624        // Test gemini (available on npm)
625        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        // Test unknown tool
630        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        // Claude now uses npm like other tools
640        assert!(claude_hint.contains("npm install"));
641        assert!(claude_hint.contains("@anthropic-ai/claude-code"));
642    }
643}