Skip to main content

agentctl/skill/
mod.rs

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            // append mode suffix to the last component
32            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    // find hub entry
54    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    // load index from cache
68    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    // find skill entry in index
72    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    // clone repo and copy skill dir
93    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    // run lifecycle install
102    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    // write lock entry
111    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    // find existing entry to get hub_id if not provided
183    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)?; // force refresh
203    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}