Skip to main content

oxios_kernel/skill/
manager.rs

1#![allow(missing_docs)]
2//! Skill manager — loads, stores, and manages skills.
3
4use 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    /// Write a skill's raw `SKILL.md` content verbatim (frontmatter preserved)
155    /// and reindex it.
156    ///
157    /// Unlike [`create_skill`], this does **not** re-synthesize the frontmatter
158    /// from `name`+`description` — the raw bytes are stored untouched so rich
159    /// metadata (`requires`, `install`, `allowed-tools`, …) survives. Use this
160    /// for inline edits and `.md` text imports.
161    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    /// Import a skill from raw `SKILL.md` text (frontmatter preserved).
174    ///
175    /// Validates the content by parsing it, derives the canonical name from
176    /// the frontmatter (falling back to `name_hint`, then erroring), and
177    /// writes the content verbatim via [`write_skill_raw`]. Used for the
178    /// text-paste and URL import modes.
179    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    /// Import a skill from a `.zip` / `.skill` archive's raw bytes.
196    ///
197    /// Extracts into a temp dir under `skills_dir` (same filesystem, so the
198    /// final rename is atomic), derives the canonical name from the parsed
199    /// `SKILL.md` frontmatter (falling back to `name_hint`), moves the
200    /// extracted tree to `skills_dir/{name}/`, records provenance under
201    /// `.imported/origin.json`, and reindexes.
202    pub async fn import_skill_zip(&self, name_hint: &str, bytes: &[u8]) -> Result<SkillEntry> {
203        use std::io::Cursor;
204
205        // 1. Extract into a temp sibling of the target (same FS → atomic rename).
206        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        // 2. Locate SKILL.md, read + parse for the canonical name.
218        let result = self
219            .finalize_import(&extract_dir, name_hint, &tmp_root)
220            .await;
221
222        // Best-effort cleanup of the temp dir on any outcome.
223        let _ = tokio::fs::remove_dir_all(&tmp_root).await;
224
225        result
226    }
227
228    /// Move an already-extracted skill tree into `skills_dir/{name}/`,
229    /// derive the name from frontmatter, write provenance, and reindex.
230    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        // 3. Move the extracted tree to skills_dir/{name}.
248        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        // Rename extract_dir → target (same filesystem as skills_dir).
255        std::fs::rename(extract_dir, &target)
256            .with_context(|| format!("moving imported skill to {}", target.display()))?;
257        // Drop the now-empty temp root.
258        let _ = std::fs::remove_dir_all(tmp_root);
259
260        // 4. Provenance.
261        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        // 5. Reindex.
271        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    /// Load additional skills from an external directory (e.g. bundled defaults).
302    /// Each subdirectory containing a `SKILL.md` is loaded as a bundled skill.
303    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
443/// Normalize an arbitrary string into a valid skill directory name:
444/// lowercase ascii, digits, and hyphens only. Used as a fallback when an
445/// imported skill has no `name` in its frontmatter.
446fn 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}