1pub mod lifecycle;
2pub mod lock;
3pub mod vars;
4
5use anyhow::{bail, Result};
6use chrono::Utc;
7use std::path::{Path, PathBuf};
8
9use crate::config::Config;
10use crate::hub::cache;
11use lifecycle::{execute_lifecycle, execute_update, sh_executor, Approver, LifecycleFile};
12use lock::{LockEntry, LockFile};
13
14pub fn skills_root(configured: Option<&str>, mode: Option<&str>) -> PathBuf {
15 let base = match configured {
16 Some(p) => {
17 let expanded = p.replacen(
18 '~',
19 &dirs::home_dir().unwrap_or_default().to_string_lossy(),
20 1,
21 );
22 PathBuf::from(expanded)
23 }
24 None => dirs::home_dir()
25 .unwrap_or_else(|| PathBuf::from("."))
26 .join(".agent")
27 .join("skills"),
28 };
29 match mode {
30 Some(m) => {
31 let name = format!(
33 "{}-{m}",
34 base.file_name().unwrap_or_default().to_string_lossy()
35 );
36 base.parent().unwrap_or(&base).join(name)
37 }
38 None => base,
39 }
40}
41
42pub fn install(
43 cfg_path: &Path,
44 lp: &Path,
45 name: &str,
46 hub_id: Option<&str>,
47 mode: Option<&str>,
48 quiet: bool,
49 approver: Approver,
50) -> Result<()> {
51 let cfg = Config::load_from(cfg_path)?;
52
53 let hub = match hub_id {
55 Some(id) => cfg
56 .skill_hubs
57 .iter()
58 .find(|h| h.id == id)
59 .ok_or_else(|| anyhow::anyhow!("hub '{id}' not found"))?,
60 None => cfg
61 .skill_hubs
62 .iter()
63 .find(|h| h.enabled)
64 .ok_or_else(|| anyhow::anyhow!("no enabled skill hub found"))?,
65 };
66
67 let index_json = cache::get(&hub.id, &hub.index_url, hub.ttl_hours)?;
69 let index: serde_json::Value = serde_json::from_str(&index_json)?;
70
71 let skill_entry = index["skills"]
73 .as_array()
74 .ok_or_else(|| anyhow::anyhow!("invalid index: missing skills array"))?
75 .iter()
76 .find(|s| s["slug"] == name || s["name"] == name)
77 .ok_or_else(|| anyhow::anyhow!("skill '{name}' not found in hub '{}'", hub.id))?;
78
79 let version = skill_entry["version"]
80 .as_str()
81 .unwrap_or("0.1.0")
82 .to_string();
83 let commit = skill_entry["commit_hash"]
84 .as_str()
85 .or_else(|| skill_entry["commit"].as_str())
86 .unwrap_or("")
87 .to_string();
88 let skill_path_rel = skill_entry["path"]
89 .as_str()
90 .ok_or_else(|| anyhow::anyhow!("missing path in skill entry"))?;
91
92 let git_url = hub
94 .git_url
95 .as_deref()
96 .ok_or_else(|| anyhow::anyhow!("hub '{}' has no git_url — cannot install", hub.id))?;
97
98 let install_dir = skills_root(cfg.skills_root.as_deref(), mode).join(name);
99 clone_skill(git_url, &commit, skill_path_rel, &install_dir)?;
100
101 let lifecycle_path = install_dir.join("lifecycle.yaml");
103 if lifecycle_path.exists() {
104 let yaml = std::fs::read_to_string(&lifecycle_path)?;
105 let lf: LifecycleFile = lifecycle::parse(&yaml)?;
106 let resolved_vars = vars::resolve(name, install_dir.to_str().unwrap_or(""), &lf.variables)?;
107 execute_lifecycle(&lf.install, &resolved_vars, quiet, approver, sh_executor)?;
108 }
109
110 let mut lock = LockFile::load(lp)?;
112 lock.insert(LockEntry {
113 hub_id: hub.id.clone(),
114 slug: name.to_string(),
115 version,
116 commit,
117 installed_path: install_dir.to_string_lossy().to_string(),
118 installed_at: Utc::now().to_rfc3339(),
119 });
120 lock.save(lp)?;
121
122 println!("✓ Installed skill '{name}'");
123 Ok(())
124}
125
126pub fn list(lp: &Path) -> Result<()> {
127 let lock = LockFile::load(lp)?;
128 if lock.skills.is_empty() {
129 println!("No skills installed.");
130 return Ok(());
131 }
132 for (key, entry) in &lock.skills {
133 println!(" {} {} ({})", key, entry.version, entry.installed_path);
134 }
135 Ok(())
136}
137
138pub fn remove(lp: &Path, name: &str, hub_id: &str, quiet: bool, approver: Approver) -> Result<()> {
139 let mut lock = LockFile::load(lp)?;
140 let entry = lock
141 .get(hub_id, name)
142 .ok_or_else(|| anyhow::anyhow!("skill '{name}' not installed"))?
143 .clone();
144
145 let install_dir = PathBuf::from(&entry.installed_path);
146 let lifecycle_path = install_dir.join("lifecycle.yaml");
147 if lifecycle_path.exists() {
148 let yaml = std::fs::read_to_string(&lifecycle_path)?;
149 let lf: LifecycleFile = lifecycle::parse(&yaml)?;
150 let resolved_vars = vars::resolve(name, &entry.installed_path, &lf.variables)?;
151 execute_lifecycle(&lf.uninstall, &resolved_vars, quiet, approver, sh_executor)?;
152 }
153
154 if install_dir.exists() {
155 std::fs::remove_dir_all(&install_dir)?;
156 }
157 lock.remove(hub_id, name);
158 lock.save(lp)?;
159
160 println!("✓ Removed skill '{name}'");
161 Ok(())
162}
163
164pub fn update(
165 cfg_path: &Path,
166 lp: &Path,
167 name: &str,
168 hub_id: Option<&str>,
169 quiet: bool,
170 force: bool,
171 approver: Approver,
172) -> Result<()> {
173 let mut lock = LockFile::load(lp)?;
174
175 let existing = if let Some(id) = hub_id {
177 lock.get(id, name)
178 .ok_or_else(|| anyhow::anyhow!("skill '{name}' not installed from hub '{id}'"))?
179 .clone()
180 } else {
181 lock.skills
182 .values()
183 .find(|e| e.slug == name)
184 .ok_or_else(|| anyhow::anyhow!("skill '{name}' not installed"))?
185 .clone()
186 };
187
188 let cfg = Config::load_from(cfg_path)?;
189 let hub = cfg
190 .skill_hubs
191 .iter()
192 .find(|h| h.id == existing.hub_id)
193 .ok_or_else(|| anyhow::anyhow!("hub '{}' not found", existing.hub_id))?;
194
195 let index_json = cache::get(&hub.id, &hub.index_url, 0)?; let index: serde_json::Value = serde_json::from_str(&index_json)?;
197
198 let skill_entry = index["skills"]
199 .as_array()
200 .unwrap()
201 .iter()
202 .find(|s| s["slug"] == name || s["name"] == name)
203 .ok_or_else(|| anyhow::anyhow!("skill '{name}' not found in hub"))?;
204
205 let new_version = skill_entry["version"].as_str().unwrap_or("0.1.0");
206 let new_commit = skill_entry["commit_hash"]
207 .as_str()
208 .or_else(|| skill_entry["commit"].as_str())
209 .unwrap_or("");
210
211 if new_version == existing.version && new_commit == existing.commit {
212 println!("Skill '{name}' is already up to date ({new_version}).");
213 return Ok(());
214 }
215
216 let git_url = hub
217 .git_url
218 .as_deref()
219 .ok_or_else(|| anyhow::anyhow!("hub '{}' has no git_url", hub.id))?;
220
221 let skill_path_rel = skill_entry["path"].as_str().unwrap_or(name);
222 let install_dir = PathBuf::from(&existing.installed_path);
223 clone_skill(git_url, new_commit, skill_path_rel, &install_dir)?;
224
225 let lifecycle_path = install_dir.join("lifecycle.yaml");
226 if lifecycle_path.exists() {
227 let yaml = std::fs::read_to_string(&lifecycle_path)?;
228 let lf: LifecycleFile = lifecycle::parse(&yaml)?;
229 let resolved_vars = vars::resolve(name, install_dir.to_str().unwrap_or(""), &lf.variables)?;
230 execute_update(&lf, &resolved_vars, quiet, force, approver, sh_executor)?;
231 }
232
233 lock.insert(LockEntry {
234 hub_id: existing.hub_id.clone(),
235 slug: name.to_string(),
236 version: new_version.to_string(),
237 commit: new_commit.to_string(),
238 installed_path: existing.installed_path.clone(),
239 installed_at: Utc::now().to_rfc3339(),
240 });
241 lock.save(lp)?;
242
243 println!("✓ Updated skill '{name}' to {new_version}");
244 Ok(())
245}
246
247fn clone_skill(git_url: &str, commit: &str, skill_path_rel: &str, dest: &Path) -> Result<()> {
248 let tmp_path = std::env::temp_dir().join(format!("agentctl-clone-{}", std::process::id()));
249 let status = std::process::Command::new("git")
250 .args(["clone", "--quiet", git_url, tmp_path.to_str().unwrap()])
251 .status()?;
252 if !status.success() {
253 bail!("git clone failed");
254 }
255 if !commit.is_empty() {
256 let status = std::process::Command::new("git")
257 .args([
258 "-C",
259 tmp_path.to_str().unwrap(),
260 "checkout",
261 "--quiet",
262 commit,
263 ])
264 .status()?;
265 if !status.success() {
266 std::fs::remove_dir_all(&tmp_path)?;
267 bail!("git checkout '{commit}' failed");
268 }
269 }
270 let src = tmp_path.join(skill_path_rel);
271 if !src.exists() {
272 std::fs::remove_dir_all(&tmp_path)?;
273 bail!("skill path '{skill_path_rel}' not found in repo");
274 }
275 if dest.exists() {
276 std::fs::remove_dir_all(dest)?;
277 }
278 copy_dir(&src, dest)?;
279 std::fs::remove_dir_all(&tmp_path)?;
280 Ok(())
281}
282
283fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
284 std::fs::create_dir_all(dst)?;
285 for entry in std::fs::read_dir(src)? {
286 let entry = entry?;
287 let dst_path = dst.join(entry.file_name());
288 if entry.file_type()?.is_dir() {
289 copy_dir(&entry.path(), &dst_path)?;
290 } else {
291 std::fs::copy(entry.path(), dst_path)?;
292 }
293 }
294 Ok(())
295}