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 required_integrations: e
103 .metadata
104 .as_ref()
105 .map(|m| m.requires.integrations.clone())
106 .unwrap_or_default(),
107 })
108 .collect(),
109 skill_filter: skill_filter.map(|f| f.to_vec()),
110 }
111 }
112 pub async fn set_enabled(&self, name: &str, enabled: bool) -> Result<()> {
113 let mut installed = self.installed.write().await;
114 if let Some(entry) = installed.get_mut(name) {
115 let state = SkillState {
116 enabled,
117 installed_at: chrono::Utc::now().to_rfc3339(),
118 last_modified: chrono::Utc::now().to_rfc3339(),
119 };
120 tokio::fs::write(
121 entry.skill.base_dir.join("state.json"),
122 serde_json::to_string_pretty(&state)?,
123 )
124 .await?;
125 entry.status = if enabled {
126 if entry.eligibility.eligible {
127 SkillStatus::Ready
128 } else {
129 SkillStatus::NeedsSetup
130 }
131 } else {
132 SkillStatus::Disabled
133 };
134 } else {
135 anyhow::bail!("skill not found: {name}");
136 }
137 Ok(())
138 }
139 pub async fn create_skill(&self, name: &str, description: &str, content: &str) -> Result<()> {
140 let dir = self.skills_dir.join(name);
141 tokio::fs::create_dir_all(&dir).await?;
142 tokio::fs::write(
143 dir.join("SKILL.md"),
144 format!("---\nname: {name}\ndescription: {description}\n---\n\n{content}"),
145 )
146 .await?;
147 let entry = Self::load_skill_entry(&dir.join("SKILL.md"), false)?;
148 self.installed.write().await.insert(name.to_string(), entry);
149 Ok(())
150 }
151 pub async fn delete_skill(&self, name: &str) -> Result<()> {
152 let dir = self.skills_dir.join(name);
153 if dir.exists() {
154 tokio::fs::remove_dir_all(&dir).await?;
155 }
156 self.installed.write().await.remove(name);
157 Ok(())
158 }
159 pub async fn write_skill_raw(&self, name: &str, raw: &str) -> Result<SkillEntry> {
167 let dir = self.skills_dir.join(name);
168 tokio::fs::create_dir_all(&dir).await?;
169 tokio::fs::write(dir.join("SKILL.md"), raw).await?;
170 let entry = Self::load_skill_entry(&dir.join("SKILL.md"), false)?;
171 self.installed
172 .write()
173 .await
174 .insert(name.to_string(), entry.clone());
175 Ok(entry)
176 }
177
178 pub async fn import_skill_text(
185 &self,
186 content: &str,
187 name_hint: Option<&str>,
188 ) -> Result<SkillEntry> {
189 let (parsed, _) = parse_skill(content, &self.skills_dir)?;
190 let name = if !parsed.name.is_empty() {
191 parsed.name.clone()
192 } else if let Some(hint) = name_hint {
193 sanitize_skill_name(hint)
194 } else {
195 anyhow::bail!("skill has no name in its frontmatter and none was provided");
196 };
197 self.write_skill_raw(&name, content).await
198 }
199
200 pub async fn import_skill_zip(&self, name_hint: &str, bytes: &[u8]) -> Result<SkillEntry> {
208 use std::io::Cursor;
209
210 let tmp_root = self
212 .skills_dir
213 .join(format!(".import-tmp-{}", uuid::Uuid::new_v4()));
214 let extract_dir = tmp_root.join("extract");
215 tokio::fs::create_dir_all(&extract_dir).await?;
216 {
217 let cursor = Cursor::new(bytes);
218 let mut zip = zip::ZipArchive::new(cursor).context("read zip archive")?;
219 crate::skill::archive::extract_skill_zip(&mut zip, &extract_dir)?;
220 }
221
222 let result = self
224 .finalize_import(&extract_dir, name_hint, &tmp_root)
225 .await;
226
227 let _ = tokio::fs::remove_dir_all(&tmp_root).await;
229
230 result
231 }
232
233 async fn finalize_import(
236 &self,
237 extract_dir: &Path,
238 name_hint: &str,
239 tmp_root: &Path,
240 ) -> Result<SkillEntry> {
241 let skill_md = crate::skill::archive::find_skill_md(extract_dir)
242 .context("no SKILL.md found in archive")?;
243 let raw = std::fs::read_to_string(&skill_md)
244 .with_context(|| format!("reading {}", skill_md.display()))?;
245 let (parsed, _) = parse_skill(&raw, extract_dir)?;
246 let name = if !parsed.name.is_empty() {
247 parsed.name.clone()
248 } else {
249 sanitize_skill_name(name_hint)
250 };
251
252 let target = self.skills_dir.join(&name);
254 if target.exists() {
255 tokio::fs::remove_dir_all(&target)
256 .await
257 .context("remove existing skill")?;
258 }
259 std::fs::rename(extract_dir, &target)
261 .with_context(|| format!("moving imported skill to {}", target.display()))?;
262 let _ = std::fs::remove_dir_all(tmp_root);
264
265 let origin_dir = target.join(".imported");
267 tokio::fs::create_dir_all(&origin_dir).await?;
268 let origin = serde_json::json!({
269 "source": "file",
270 "format": format!("{:?}", parsed.format).to_lowercase(),
271 "imported_at": chrono::Utc::now().to_rfc3339(),
272 });
273 tokio::fs::write(origin_dir.join("origin.json"), origin.to_string()).await?;
274
275 let entry = Self::load_skill_entry(&target.join("SKILL.md"), false)?;
277 self.installed
278 .write()
279 .await
280 .insert(name.clone(), entry.clone());
281 Ok(entry)
282 }
283 pub async fn list_skills_meta(&self) -> Vec<SkillMeta> {
284 let mut m: Vec<SkillMeta> = self
285 .installed
286 .read()
287 .await
288 .values()
289 .map(|e| SkillMeta::from(&e.skill))
290 .collect();
291 m.sort_by(|a, b| a.name.cmp(&b.name));
292 m
293 }
294 pub async fn load_skill(&self, name: &str) -> Result<Option<Skill>> {
295 Ok(self
296 .installed
297 .read()
298 .await
299 .get(name)
300 .map(|e| e.skill.clone()))
301 }
302 pub fn path(&self) -> &PathBuf {
303 &self.skills_dir
304 }
305
306 pub async fn load_from_dir(&self, dir: &Path) -> Result<()> {
309 let mut map = self.installed.write().await;
310 self.load_skills_from_dir(dir, true, &mut map).await?;
311 Ok(())
312 }
313
314 async fn load_skills_from_dir(
315 &self,
316 dir: &Path,
317 bundled: bool,
318 map: &mut HashMap<String, SkillEntry>,
319 ) -> Result<()> {
320 if !dir.exists() {
321 return Ok(());
322 }
323 let mut entries = tokio::fs::read_dir(dir).await?;
324 while let Some(entry) = entries.next_entry().await? {
325 let path = entry.path();
326 if path.is_dir() {
327 let sf = path.join("SKILL.md");
328 if sf.exists() {
329 match Self::load_skill_entry(&sf, bundled) {
330 Ok(se) => {
331 let n = se.skill.name.clone();
332 if bundled && map.contains_key(&n) {
333 continue;
334 }
335 map.insert(n, se);
336 }
337 Err(e) => {
338 tracing::warn!("failed to parse skill {:?}: {}", sf, e);
339 }
340 }
341 }
342 }
343 }
344 Ok(())
345 }
346 fn load_skill_entry(skill_file: &Path, bundled: bool) -> Result<SkillEntry> {
347 let content = std::fs::read_to_string(skill_file)
348 .with_context(|| format!("reading {skill_file:?}"))?;
349 let skill_dir = skill_file.parent().context("no parent")?;
350 let (parsed, body) = parse_skill(&content, skill_dir)?;
351 let name = if parsed.name.is_empty() {
352 skill_dir
353 .file_name()
354 .and_then(|n| n.to_str())
355 .unwrap_or("unknown")
356 .to_string()
357 } else {
358 parsed.name
359 };
360 let base_dir = skill_dir.parent().context("no grandparent")?.to_path_buf();
361 let skill = Skill {
362 name: name.clone(),
363 description: parsed.description,
364 content: body,
365 path: skill_file.to_path_buf(),
366 base_dir,
367 file_path: skill_file.to_path_buf(),
368 };
369 let (eligibility, status) = {
370 let c = check_requirements(&parsed.metadata);
371 let elig = c.eligible;
372 (
373 c,
374 if elig {
375 SkillStatus::Ready
376 } else {
377 SkillStatus::NeedsSetup
378 },
379 )
380 };
381 let status = {
382 let sp = skill.path.parent().unwrap().join("state.json");
383 if sp.exists() {
384 if let Ok(sc) = std::fs::read_to_string(&sp) {
385 if let Ok(s) = serde_json::from_str::<SkillState>(&sc) {
386 if !s.enabled {
387 SkillStatus::Disabled
388 } else {
389 status
390 }
391 } else {
392 status
393 }
394 } else {
395 status
396 }
397 } else {
398 status
399 }
400 };
401 Ok(SkillEntry {
402 skill,
403 metadata: Some(parsed.metadata),
404 eligibility,
405 status,
406 bundled,
407 source: if bundled {
408 SkillSource::Bundled
409 } else {
410 SkillSource::Managed
411 },
412 invocation: parsed.invocation,
413 format: parsed.format,
414 raw_yaml: parsed.raw_yaml,
415 })
416 }
417 async fn bootstrap_from_bundled(&self) -> Result<()> {
418 let mut entries = tokio::fs::read_dir(&self.bundled_dir).await?;
419 while let Some(entry) = entries.next_entry().await? {
420 let src = entry.path();
421 if src.is_dir() {
422 let name = src
423 .file_name()
424 .and_then(|n| n.to_str())
425 .unwrap_or("unknown");
426 let dest = self.skills_dir.join(name);
427 tokio::fs::create_dir_all(&dest).await?;
428 let sf = src.join("SKILL.md");
429 if sf.exists() {
430 let df = dest.join("SKILL.md");
431 if !df.exists() {
432 tokio::fs::write(&df, tokio::fs::read_to_string(&sf).await?).await?;
433 }
434 }
435 }
436 }
437 Ok(())
438 }
439 async fn is_dir_empty(&self, dir: &Path) -> Result<bool> {
440 if !dir.exists() {
441 return Ok(true);
442 }
443 let mut e = tokio::fs::read_dir(dir).await?;
444 Ok(e.next_entry().await?.is_none())
445 }
446}
447
448fn sanitize_skill_name(raw: &str) -> String {
452 let stem = raw.rsplit(['/', '.']).next().unwrap_or(raw);
453 let s: String = stem
454 .chars()
455 .map(|c| {
456 if c.is_ascii_alphanumeric() && c.is_ascii_lowercase() {
457 c
458 } else if c.is_ascii_alphanumeric() {
459 c.to_ascii_lowercase()
460 } else {
461 '-'
462 }
463 })
464 .collect();
465 let s = s.trim_matches('-').to_string();
466 if s.is_empty() {
467 "imported-skill".to_string()
468 } else {
469 s
470 }
471}