Skip to main content

mai_cli/cli/
install_cmd.rs

1use crate::core::{InstallScope, MaiConfig, Pack, PackMetadata, PackType, Version, VersionReq};
2use crate::error::{Error, Result};
3use crate::storage::{Registry, XdgPaths};
4use clap::Args;
5use std::path::PathBuf;
6
7#[derive(Debug, Args)]
8pub struct InstallCommand {
9    /// Pack to install (format: name[@version] or type/name[@version])
10    /// Version supports: exact (1.2.3), latest, caret (^1.2.3), tilde (~1.2.3), ranges (>=1.0.0, <2.0.0)
11    pub pack: String,
12
13    /// Tool to install for (uses active tool if not specified)
14    #[arg(short, long)]
15    pub tool: Option<String>,
16
17    /// Install globally (user-wide) instead of locally (project-specific)
18    #[arg(short = 'g', long, default_value = "false")]
19    pub global: bool,
20
21    /// Pack description
22    #[arg(long)]
23    pub description: Option<String>,
24
25    /// Pack source URL
26    #[arg(long)]
27    pub source: Option<String>,
28}
29
30impl InstallCommand {
31    pub fn run(&self) -> Result<()> {
32        let scope = if self.global {
33            InstallScope::Global
34        } else {
35            InstallScope::Local
36        };
37
38        // Determine paths based on scope
39        let paths = if scope == InstallScope::Global {
40            XdgPaths::new()
41        } else {
42            // For local installation, find project root
43            let project_root = find_project_root()?;
44            XdgPaths::local(&project_root)
45        };
46
47        // Ensure directories exist
48        crate::storage::ensure_dirs(&paths)?;
49
50        let config_path = paths.config_file();
51
52        let mut config = if config_path.exists() {
53            let content = std::fs::read_to_string(&config_path)?;
54            toml::from_str::<MaiConfig>(&content).unwrap_or_default()
55        } else {
56            MaiConfig::new()
57        };
58
59        let (pack_name, version_req, pack_type) = self.parse_pack()?;
60        let tool = self
61            .tool
62            .clone()
63            .or_else(|| config.active_tool().map(String::from))
64            .unwrap_or_else(|| "default".to_string());
65
66        // Resolve version using semver logic
67        let registry = Registry::new(&paths);
68        let available_versions =
69            self.get_available_versions(&registry, &tool, &pack_name, pack_type)?;
70        let version = self.resolve_version(&version_req, &available_versions)?;
71
72        // Build metadata
73        let mut metadata = PackMetadata::new();
74        if let Some(ref desc) = self.description {
75            metadata = metadata.with_description(desc);
76        }
77        if let Some(ref source) = self.source {
78            metadata = metadata.with_source_url(source);
79        }
80
81        let pack = Pack::new(&pack_name, pack_type, version)
82            .with_tool(&tool)
83            .with_metadata(metadata)
84            .with_scope(scope);
85
86        registry.install_pack(&tool, &pack)?;
87
88        config
89            .tools
90            .entry(tool.clone())
91            .or_default()
92            .installed_packs
93            .push(pack.clone());
94
95        let content = toml::to_string_pretty(&config)?;
96        std::fs::write(&config_path, content)?;
97
98        println!("✓ Installed: {} ({})", pack.id(), scope);
99        Ok(())
100    }
101
102    fn get_available_versions(
103        &self,
104        registry: &Registry,
105        tool: &str,
106        pack_name: &str,
107        pack_type: PackType,
108    ) -> Result<Vec<Version>> {
109        // Get all versions of this pack from the registry
110        let all_packs = registry.list_packs(Some(tool), None)?;
111        let versions: Vec<Version> = all_packs
112            .iter()
113            .filter(|p| p.name == pack_name && p.pack_type == pack_type)
114            .map(|p| p.version.clone())
115            .collect();
116
117        // If no existing versions, return a default set for demonstration
118        // In Phase 3, this would query a remote registry
119        if versions.is_empty() {
120            Ok(vec![
121                Version::new(1, 0, 0),
122                Version::new(1, 1, 0),
123                Version::new(1, 2, 0),
124                Version::new(2, 0, 0),
125            ])
126        } else {
127            Ok(versions)
128        }
129    }
130
131    fn resolve_version(&self, req: &VersionReq, available: &[Version]) -> Result<Version> {
132        match req {
133            VersionReq::Latest => {
134                // Select the highest version
135                available
136                    .iter()
137                    .max()
138                    .cloned()
139                    .ok_or_else(|| Error::parse_error("No versions available"))
140            }
141            _ => {
142                // Use semver selection logic
143                req.select_best(available).cloned().ok_or_else(|| {
144                    Error::parse_error(format!("No version matches requirement: {}", req))
145                })
146            }
147        }
148    }
149
150    fn parse_pack(&self) -> Result<(String, VersionReq, PackType)> {
151        let mut parts = self.pack.splitn(2, '@');
152        let name_part = parts.next().unwrap();
153        let version_str = parts.next().unwrap_or("latest");
154
155        let (pack_type, name) = if let Some((t, n)) = name_part.split_once('/') {
156            let pack_type = match t.to_lowercase().as_str() {
157                "skill" => PackType::Skill,
158                "command" => PackType::Command,
159                "mcp" => PackType::Mcp,
160                _ => PackType::Skill,
161            };
162            (pack_type, n.to_string())
163        } else {
164            (PackType::Skill, name_part.to_string())
165        };
166
167        let version_req = VersionReq::parse(version_str)
168            .map_err(|e| Error::parse_error(format!("Invalid version requirement: {}", e)))?;
169
170        Ok((name, version_req, pack_type))
171    }
172}
173
174/// Find the project root directory (directory containing mai.toml or .git)
175fn find_project_root() -> Result<PathBuf> {
176    let current = std::env::current_dir()?;
177
178    let mut current = current.as_path();
179    while let Some(parent) = current.parent() {
180        if current.join("mai.toml").exists() || current.join(".git").exists() {
181            return Ok(current.to_path_buf());
182        }
183        current = parent;
184    }
185
186    // If no project root found, use current directory
187    Ok(std::env::current_dir()?)
188}