mai_cli/cli/
install_cmd.rs1use 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 pub pack: String,
12
13 #[arg(short, long)]
15 pub tool: Option<String>,
16
17 #[arg(short = 'g', long, default_value = "false")]
19 pub global: bool,
20
21 #[arg(long)]
23 pub description: Option<String>,
24
25 #[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 let paths = if scope == InstallScope::Global {
40 XdgPaths::new()
41 } else {
42 let project_root = find_project_root()?;
44 XdgPaths::local(&project_root)
45 };
46
47 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 let registry = Registry::new(&paths);
68 let available_versions =
69 self.get_available_versions(®istry, &tool, &pack_name, pack_type)?;
70 let version = self.resolve_version(&version_req, &available_versions)?;
71
72 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 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 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 available
136 .iter()
137 .max()
138 .cloned()
139 .ok_or_else(|| Error::parse_error("No versions available"))
140 }
141 _ => {
142 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
174fn 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 Ok(std::env::current_dir()?)
188}