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                    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    /// Write a skill's raw `SKILL.md` content verbatim (frontmatter preserved)
160    /// and reindex it.
161    ///
162    /// Unlike [`create_skill`], this does **not** re-synthesize the frontmatter
163    /// from `name`+`description` — the raw bytes are stored untouched so rich
164    /// metadata (`requires`, `install`, `allowed-tools`, …) survives. Use this
165    /// for inline edits and `.md` text imports.
166    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    /// Import a skill from raw `SKILL.md` text (frontmatter preserved).
179    ///
180    /// Validates the content by parsing it, derives the canonical name from
181    /// the frontmatter (falling back to `name_hint`, then erroring), and
182    /// writes the content verbatim via [`write_skill_raw`]. Used for the
183    /// text-paste and URL import modes.
184    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    /// Import a skill from a `.zip` / `.skill` archive's raw bytes.
201    ///
202    /// Extracts into a temp dir under `skills_dir` (same filesystem, so the
203    /// final rename is atomic), derives the canonical name from the parsed
204    /// `SKILL.md` frontmatter (falling back to `name_hint`), moves the
205    /// extracted tree to `skills_dir/{name}/`, records provenance under
206    /// `.imported/origin.json`, and reindexes.
207    pub async fn import_skill_zip(&self, name_hint: &str, bytes: &[u8]) -> Result<SkillEntry> {
208        use std::io::Cursor;
209
210        // 1. Extract into a temp sibling of the target (same FS → atomic rename).
211        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        // 2. Locate SKILL.md, read + parse for the canonical name.
223        let result = self
224            .finalize_import(&extract_dir, name_hint, &tmp_root)
225            .await;
226
227        // Best-effort cleanup of the temp dir on any outcome.
228        let _ = tokio::fs::remove_dir_all(&tmp_root).await;
229
230        result
231    }
232
233    /// Move an already-extracted skill tree into `skills_dir/{name}/`,
234    /// derive the name from frontmatter, write provenance, and reindex.
235    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        // 3. Move the extracted tree to skills_dir/{name}.
253        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        // Rename extract_dir → target (same filesystem as skills_dir).
260        std::fs::rename(extract_dir, &target)
261            .with_context(|| format!("moving imported skill to {}", target.display()))?;
262        // Drop the now-empty temp root.
263        let _ = std::fs::remove_dir_all(tmp_root);
264
265        // 4. Provenance.
266        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        // 5. Reindex.
276        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    /// Load additional skills from an external directory (e.g. bundled defaults).
307    /// Each subdirectory containing a `SKILL.md` is loaded as a bundled skill.
308    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
448/// Normalize an arbitrary string into a valid skill directory name:
449/// lowercase ascii, digits, and hyphens only. Used as a fallback when an
450/// imported skill has no `name` in its frontmatter.
451fn 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}