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 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    // find existing entry to get hub_id if not provided
176    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)?; // force refresh
196    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}