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 export(lp: &Path) -> Result<()> {
127 let lock = LockFile::load(lp)?;
128 let json = serde_json::to_string_pretty(&lock)?;
129 println!("{}", json);
130 Ok(())
131}
132
133pub fn list(lp: &Path) -> Result<()> {
134 let lock = LockFile::load(lp)?;
135 if lock.skills.is_empty() {
136 println!("No skills installed.");
137 return Ok(());
138 }
139 for (key, entry) in &lock.skills {
140 println!(" {} {} ({})", key, entry.version, entry.installed_path);
141 }
142 Ok(())
143}
144
145pub fn remove(lp: &Path, name: &str, hub_id: &str, quiet: bool, approver: Approver) -> Result<()> {
146 let mut lock = LockFile::load(lp)?;
147 let entry = lock
148 .get(hub_id, name)
149 .ok_or_else(|| anyhow::anyhow!("skill '{name}' not installed"))?
150 .clone();
151
152 let install_dir = PathBuf::from(&entry.installed_path);
153 let lifecycle_path = install_dir.join("lifecycle.yaml");
154 if lifecycle_path.exists() {
155 let yaml = std::fs::read_to_string(&lifecycle_path)?;
156 let lf: LifecycleFile = lifecycle::parse(&yaml)?;
157 let resolved_vars = vars::resolve(name, &entry.installed_path, &lf.variables)?;
158 execute_lifecycle(&lf.uninstall, &resolved_vars, quiet, approver, sh_executor)?;
159 }
160
161 if install_dir.exists() {
162 std::fs::remove_dir_all(&install_dir)?;
163 }
164 lock.remove(hub_id, name);
165 lock.save(lp)?;
166
167 println!("✓ Removed skill '{name}'");
168 Ok(())
169}
170
171pub fn update(
172 cfg_path: &Path,
173 lp: &Path,
174 name: &str,
175 hub_id: Option<&str>,
176 quiet: bool,
177 force: bool,
178 approver: Approver,
179) -> Result<()> {
180 let mut lock = LockFile::load(lp)?;
181
182 let existing = if let Some(id) = hub_id {
184 lock.get(id, name)
185 .ok_or_else(|| anyhow::anyhow!("skill '{name}' not installed from hub '{id}'"))?
186 .clone()
187 } else {
188 lock.skills
189 .values()
190 .find(|e| e.slug == name)
191 .ok_or_else(|| anyhow::anyhow!("skill '{name}' not installed"))?
192 .clone()
193 };
194
195 let cfg = Config::load_from(cfg_path)?;
196 let hub = cfg
197 .skill_hubs
198 .iter()
199 .find(|h| h.id == existing.hub_id)
200 .ok_or_else(|| anyhow::anyhow!("hub '{}' not found", existing.hub_id))?;
201
202 let index_json = cache::get(&hub.id, &hub.index_url, 0)?; let index: serde_json::Value = serde_json::from_str(&index_json)?;
204
205 let skill_entry = index["skills"]
206 .as_array()
207 .unwrap()
208 .iter()
209 .find(|s| s["slug"] == name || s["name"] == name)
210 .ok_or_else(|| anyhow::anyhow!("skill '{name}' not found in hub"))?;
211
212 let new_version = skill_entry["version"].as_str().unwrap_or("0.1.0");
213 let new_commit = skill_entry["commit_hash"]
214 .as_str()
215 .or_else(|| skill_entry["commit"].as_str())
216 .unwrap_or("");
217
218 if new_version == existing.version && new_commit == existing.commit {
219 println!("Skill '{name}' is already up to date ({new_version}).");
220 return Ok(());
221 }
222
223 let git_url = hub
224 .git_url
225 .as_deref()
226 .ok_or_else(|| anyhow::anyhow!("hub '{}' has no git_url", hub.id))?;
227
228 let skill_path_rel = skill_entry["path"].as_str().unwrap_or(name);
229 let install_dir = PathBuf::from(&existing.installed_path);
230 clone_skill(git_url, new_commit, skill_path_rel, &install_dir)?;
231
232 let lifecycle_path = install_dir.join("lifecycle.yaml");
233 if lifecycle_path.exists() {
234 let yaml = std::fs::read_to_string(&lifecycle_path)?;
235 let lf: LifecycleFile = lifecycle::parse(&yaml)?;
236 let resolved_vars = vars::resolve(name, install_dir.to_str().unwrap_or(""), &lf.variables)?;
237 execute_update(&lf, &resolved_vars, quiet, force, approver, sh_executor)?;
238 }
239
240 lock.insert(LockEntry {
241 hub_id: existing.hub_id.clone(),
242 slug: name.to_string(),
243 version: new_version.to_string(),
244 commit: new_commit.to_string(),
245 installed_path: existing.installed_path.clone(),
246 installed_at: Utc::now().to_rfc3339(),
247 });
248 lock.save(lp)?;
249
250 println!("✓ Updated skill '{name}' to {new_version}");
251 Ok(())
252}
253
254fn clone_skill(git_url: &str, commit: &str, skill_path_rel: &str, dest: &Path) -> Result<()> {
255 let tmp_path = std::env::temp_dir().join(format!("agentctl-clone-{}", std::process::id()));
256 let status = std::process::Command::new("git")
257 .args(["clone", "--quiet", git_url, tmp_path.to_str().unwrap()])
258 .status()?;
259 if !status.success() {
260 bail!("git clone failed");
261 }
262 if !commit.is_empty() {
263 let status = std::process::Command::new("git")
264 .args([
265 "-C",
266 tmp_path.to_str().unwrap(),
267 "checkout",
268 "--quiet",
269 commit,
270 ])
271 .status()?;
272 if !status.success() {
273 std::fs::remove_dir_all(&tmp_path)?;
274 bail!("git checkout '{commit}' failed");
275 }
276 }
277 let src = tmp_path.join(skill_path_rel);
278 if !src.exists() {
279 std::fs::remove_dir_all(&tmp_path)?;
280 bail!("skill path '{skill_path_rel}' not found in repo");
281 }
282 if dest.exists() {
283 std::fs::remove_dir_all(dest)?;
284 }
285 copy_dir(&src, dest)?;
286 std::fs::remove_dir_all(&tmp_path)?;
287 Ok(())
288}
289
290fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
291 std::fs::create_dir_all(dst)?;
292 for entry in std::fs::read_dir(src)? {
293 let entry = entry?;
294 let dst_path = dst.join(entry.file_name());
295 if entry.file_type()?.is_dir() {
296 copy_dir(&entry.path(), &dst_path)?;
297 } else {
298 std::fs::copy(entry.path(), dst_path)?;
299 }
300 }
301 Ok(())
302}