1#![allow(missing_docs)]
2use super::frontmatter::parse_skill;
5use super::prompt::{compact_path, format_skills_for_prompt};
6use super::requirements::check_requirements;
7use super::types::*;
8use anyhow::{Context, Result};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use tokio::sync::RwLock;
12
13pub struct SkillManager {
14 skills_dir: PathBuf,
15 bundled_dir: PathBuf,
16 installed: RwLock<HashMap<String, SkillEntry>>,
17}
18impl std::fmt::Debug for SkillManager {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 f.debug_struct("SkillManager")
21 .field("skills_dir", &self.skills_dir)
22 .field("bundled_dir", &self.bundled_dir)
23 .finish()
24 }
25}
26impl SkillManager {
27 pub fn new(skills_dir: PathBuf, bundled_dir: PathBuf) -> Self {
28 Self {
29 skills_dir,
30 bundled_dir,
31 installed: RwLock::new(HashMap::new()),
32 }
33 }
34 pub async fn init(&self) -> Result<()> {
35 if !self.skills_dir.exists() {
36 tokio::fs::create_dir_all(&self.skills_dir).await?;
37 }
38 if self.is_dir_empty(&self.skills_dir).await? && self.bundled_dir.exists() {
39 self.bootstrap_from_bundled().await?;
40 }
41 let mut map: HashMap<String, SkillEntry> = HashMap::new();
42 if self.bundled_dir.exists() {
43 self.load_skills_from_dir(&self.bundled_dir, true, &mut map)
44 .await?;
45 }
46 self.load_skills_from_dir(&self.skills_dir, false, &mut map)
47 .await?;
48 *self.installed.write().await = map;
49 Ok(())
50 }
51 pub async fn list_skills(&self) -> Vec<SkillEntry> {
52 let mut s: Vec<SkillEntry> = self.installed.read().await.values().cloned().collect();
53 s.sort_by(|a, b| a.skill.name.cmp(&b.skill.name));
54 s
55 }
56 pub async fn get_skill(&self, name: &str) -> Option<SkillEntry> {
57 self.installed.read().await.get(name).cloned()
58 }
59 pub async fn get_skill_content(&self, name: &str) -> Option<String> {
60 self.installed
61 .read()
62 .await
63 .get(name)
64 .map(|e| e.skill.content.clone())
65 }
66 pub async fn build_snapshot(
67 &self,
68 _agent_id: Option<&str>,
69 skill_filter: Option<&[String]>,
70 ) -> SkillSnapshot {
71 let entries = self.list_skills().await;
72 let visible: Vec<&SkillEntry> = entries
73 .iter()
74 .filter(|e| {
75 e.status != SkillStatus::Disabled
76 && e.eligibility.eligible
77 && !e.invocation.disable_model_invocation
78 })
79 .collect();
80 let filtered: Vec<&SkillEntry> = if let Some(f) = skill_filter {
81 visible
82 .into_iter()
83 .filter(|e| f.contains(&e.skill.name))
84 .collect()
85 } else {
86 visible
87 };
88 SkillSnapshot {
89 prompt: format_skills_for_prompt(&filtered),
90 skills: filtered
91 .iter()
92 .map(|e| SkillRef {
93 name: e.skill.name.clone(),
94 description: e.skill.description.clone(),
95 file_path: compact_path(&e.skill.file_path),
96 primary_env: e.metadata.as_ref().and_then(|m| m.primary_env.clone()),
97 required_env: e
98 .metadata
99 .as_ref()
100 .map(|m| m.requires.env.clone())
101 .unwrap_or_default(),
102 })
103 .collect(),
104 skill_filter: skill_filter.map(|f| f.to_vec()),
105 }
106 }
107 pub async fn set_enabled(&self, name: &str, enabled: bool) -> Result<()> {
108 let mut installed = self.installed.write().await;
109 if let Some(entry) = installed.get_mut(name) {
110 let state = SkillState {
111 enabled,
112 installed_at: chrono::Utc::now().to_rfc3339(),
113 last_modified: chrono::Utc::now().to_rfc3339(),
114 };
115 tokio::fs::write(
116 entry.skill.base_dir.join("state.json"),
117 serde_json::to_string_pretty(&state)?,
118 )
119 .await?;
120 entry.status = if enabled {
121 if entry.eligibility.eligible {
122 SkillStatus::Ready
123 } else {
124 SkillStatus::NeedsSetup
125 }
126 } else {
127 SkillStatus::Disabled
128 };
129 } else {
130 anyhow::bail!("skill not found: {name}");
131 }
132 Ok(())
133 }
134 pub async fn create_skill(&self, name: &str, description: &str, content: &str) -> Result<()> {
135 let dir = self.skills_dir.join(name);
136 tokio::fs::create_dir_all(&dir).await?;
137 tokio::fs::write(
138 dir.join("SKILL.md"),
139 format!("---\nname: {name}\ndescription: {description}\n---\n\n{content}"),
140 )
141 .await?;
142 let entry = Self::load_skill_entry(&dir.join("SKILL.md"), false)?;
143 self.installed.write().await.insert(name.to_string(), entry);
144 Ok(())
145 }
146 pub async fn delete_skill(&self, name: &str) -> Result<()> {
147 let dir = self.skills_dir.join(name);
148 if dir.exists() {
149 tokio::fs::remove_dir_all(&dir).await?;
150 }
151 self.installed.write().await.remove(name);
152 Ok(())
153 }
154 pub async fn write_skill_raw(&self, name: &str, raw: &str) -> Result<SkillEntry> {
162 let dir = self.skills_dir.join(name);
163 tokio::fs::create_dir_all(&dir).await?;
164 tokio::fs::write(dir.join("SKILL.md"), raw).await?;
165 let entry = Self::load_skill_entry(&dir.join("SKILL.md"), false)?;
166 self.installed
167 .write()
168 .await
169 .insert(name.to_string(), entry.clone());
170 Ok(entry)
171 }
172
173 pub async fn import_skill_text(
180 &self,
181 content: &str,
182 name_hint: Option<&str>,
183 ) -> Result<SkillEntry> {
184 let (parsed, _) = parse_skill(content, &self.skills_dir)?;
185 let name = if !parsed.name.is_empty() {
186 parsed.name.clone()
187 } else if let Some(hint) = name_hint {
188 sanitize_skill_name(hint)
189 } else {
190 anyhow::bail!("skill has no name in its frontmatter and none was provided");
191 };
192 self.write_skill_raw(&name, content).await
193 }
194
195 pub async fn import_skill_zip(&self, name_hint: &str, bytes: &[u8]) -> Result<SkillEntry> {
203 use std::io::Cursor;
204
205 let tmp_root = self
207 .skills_dir
208 .join(format!(".import-tmp-{}", uuid::Uuid::new_v4()));
209 let extract_dir = tmp_root.join("extract");
210 tokio::fs::create_dir_all(&extract_dir).await?;
211 {
212 let cursor = Cursor::new(bytes);
213 let mut zip = zip::ZipArchive::new(cursor).context("read zip archive")?;
214 crate::skill::archive::extract_skill_zip(&mut zip, &extract_dir)?;
215 }
216
217 let result = self
219 .finalize_import(&extract_dir, name_hint, &tmp_root)
220 .await;
221
222 let _ = tokio::fs::remove_dir_all(&tmp_root).await;
224
225 result
226 }
227
228 async fn finalize_import(
231 &self,
232 extract_dir: &Path,
233 name_hint: &str,
234 tmp_root: &Path,
235 ) -> Result<SkillEntry> {
236 let skill_md = crate::skill::archive::find_skill_md(extract_dir)
237 .context("no SKILL.md found in archive")?;
238 let raw = std::fs::read_to_string(&skill_md)
239 .with_context(|| format!("reading {}", skill_md.display()))?;
240 let (parsed, _) = parse_skill(&raw, extract_dir)?;
241 let name = if !parsed.name.is_empty() {
242 parsed.name.clone()
243 } else {
244 sanitize_skill_name(name_hint)
245 };
246
247 let target = self.skills_dir.join(&name);
249 if target.exists() {
250 tokio::fs::remove_dir_all(&target)
251 .await
252 .context("remove existing skill")?;
253 }
254 std::fs::rename(extract_dir, &target)
256 .with_context(|| format!("moving imported skill to {}", target.display()))?;
257 let _ = std::fs::remove_dir_all(tmp_root);
259
260 let origin_dir = target.join(".imported");
262 tokio::fs::create_dir_all(&origin_dir).await?;
263 let origin = serde_json::json!({
264 "source": "file",
265 "format": format!("{:?}", parsed.format).to_lowercase(),
266 "imported_at": chrono::Utc::now().to_rfc3339(),
267 });
268 tokio::fs::write(origin_dir.join("origin.json"), origin.to_string()).await?;
269
270 let entry = Self::load_skill_entry(&target.join("SKILL.md"), false)?;
272 self.installed
273 .write()
274 .await
275 .insert(name.clone(), entry.clone());
276 Ok(entry)
277 }
278 pub async fn list_skills_meta(&self) -> Vec<SkillMeta> {
279 let mut m: Vec<SkillMeta> = self
280 .installed
281 .read()
282 .await
283 .values()
284 .map(|e| SkillMeta::from(&e.skill))
285 .collect();
286 m.sort_by(|a, b| a.name.cmp(&b.name));
287 m
288 }
289 pub async fn load_skill(&self, name: &str) -> Result<Option<Skill>> {
290 Ok(self
291 .installed
292 .read()
293 .await
294 .get(name)
295 .map(|e| e.skill.clone()))
296 }
297 pub fn path(&self) -> &PathBuf {
298 &self.skills_dir
299 }
300
301 pub async fn load_from_dir(&self, dir: &Path) -> Result<()> {
304 let mut map = self.installed.write().await;
305 self.load_skills_from_dir(dir, true, &mut map).await?;
306 Ok(())
307 }
308
309 async fn load_skills_from_dir(
310 &self,
311 dir: &Path,
312 bundled: bool,
313 map: &mut HashMap<String, SkillEntry>,
314 ) -> Result<()> {
315 if !dir.exists() {
316 return Ok(());
317 }
318 let mut entries = tokio::fs::read_dir(dir).await?;
319 while let Some(entry) = entries.next_entry().await? {
320 let path = entry.path();
321 if path.is_dir() {
322 let sf = path.join("SKILL.md");
323 if sf.exists() {
324 match Self::load_skill_entry(&sf, bundled) {
325 Ok(se) => {
326 let n = se.skill.name.clone();
327 if bundled && map.contains_key(&n) {
328 continue;
329 }
330 map.insert(n, se);
331 }
332 Err(e) => {
333 tracing::warn!("failed to parse skill {:?}: {}", sf, e);
334 }
335 }
336 }
337 }
338 }
339 Ok(())
340 }
341 fn load_skill_entry(skill_file: &Path, bundled: bool) -> Result<SkillEntry> {
342 let content = std::fs::read_to_string(skill_file)
343 .with_context(|| format!("reading {skill_file:?}"))?;
344 let skill_dir = skill_file.parent().context("no parent")?;
345 let (parsed, body) = parse_skill(&content, skill_dir)?;
346 let name = if parsed.name.is_empty() {
347 skill_dir
348 .file_name()
349 .and_then(|n| n.to_str())
350 .unwrap_or("unknown")
351 .to_string()
352 } else {
353 parsed.name
354 };
355 let base_dir = skill_dir.parent().context("no grandparent")?.to_path_buf();
356 let skill = Skill {
357 name: name.clone(),
358 description: parsed.description,
359 content: body,
360 path: skill_file.to_path_buf(),
361 base_dir,
362 file_path: skill_file.to_path_buf(),
363 };
364 let (eligibility, status) = {
365 let c = check_requirements(&parsed.metadata);
366 let elig = c.eligible;
367 (
368 c,
369 if elig {
370 SkillStatus::Ready
371 } else {
372 SkillStatus::NeedsSetup
373 },
374 )
375 };
376 let status = {
377 let sp = skill.path.parent().unwrap().join("state.json");
378 if sp.exists() {
379 if let Ok(sc) = std::fs::read_to_string(&sp) {
380 if let Ok(s) = serde_json::from_str::<SkillState>(&sc) {
381 if !s.enabled {
382 SkillStatus::Disabled
383 } else {
384 status
385 }
386 } else {
387 status
388 }
389 } else {
390 status
391 }
392 } else {
393 status
394 }
395 };
396 Ok(SkillEntry {
397 skill,
398 metadata: Some(parsed.metadata),
399 eligibility,
400 status,
401 bundled,
402 source: if bundled {
403 SkillSource::Bundled
404 } else {
405 SkillSource::Managed
406 },
407 invocation: parsed.invocation,
408 format: parsed.format,
409 raw_yaml: parsed.raw_yaml,
410 })
411 }
412 async fn bootstrap_from_bundled(&self) -> Result<()> {
413 let mut entries = tokio::fs::read_dir(&self.bundled_dir).await?;
414 while let Some(entry) = entries.next_entry().await? {
415 let src = entry.path();
416 if src.is_dir() {
417 let name = src
418 .file_name()
419 .and_then(|n| n.to_str())
420 .unwrap_or("unknown");
421 let dest = self.skills_dir.join(name);
422 tokio::fs::create_dir_all(&dest).await?;
423 let sf = src.join("SKILL.md");
424 if sf.exists() {
425 let df = dest.join("SKILL.md");
426 if !df.exists() {
427 tokio::fs::write(&df, tokio::fs::read_to_string(&sf).await?).await?;
428 }
429 }
430 }
431 }
432 Ok(())
433 }
434 async fn is_dir_empty(&self, dir: &Path) -> Result<bool> {
435 if !dir.exists() {
436 return Ok(true);
437 }
438 let mut e = tokio::fs::read_dir(dir).await?;
439 Ok(e.next_entry().await?.is_none())
440 }
441}
442
443fn sanitize_skill_name(raw: &str) -> String {
447 let stem = raw.rsplit(['/', '.']).next().unwrap_or(raw);
448 let s: String = stem
449 .chars()
450 .map(|c| {
451 if c.is_ascii_alphanumeric() && c.is_ascii_lowercase() {
452 c
453 } else if c.is_ascii_alphanumeric() {
454 c.to_ascii_lowercase()
455 } else {
456 '-'
457 }
458 })
459 .collect();
460 let s = s.trim_matches('-').to_string();
461 if s.is_empty() {
462 "imported-skill".to_string()
463 } else {
464 s
465 }
466}