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    pub async fn list_skills_meta(&self) -> Vec<SkillMeta> {
155        let mut m: Vec<SkillMeta> = self
156            .installed
157            .read()
158            .await
159            .values()
160            .map(|e| SkillMeta::from(&e.skill))
161            .collect();
162        m.sort_by(|a, b| a.name.cmp(&b.name));
163        m
164    }
165    pub async fn load_skill(&self, name: &str) -> Result<Option<Skill>> {
166        Ok(self
167            .installed
168            .read()
169            .await
170            .get(name)
171            .map(|e| e.skill.clone()))
172    }
173    pub fn path(&self) -> &PathBuf {
174        &self.skills_dir
175    }
176
177    /// Load additional skills from an external directory (e.g. bundled defaults).
178    /// Each subdirectory containing a `SKILL.md` is loaded as a bundled skill.
179    pub async fn load_from_dir(&self, dir: &Path) -> Result<()> {
180        let mut map = self.installed.write().await;
181        self.load_skills_from_dir(dir, true, &mut map).await?;
182        Ok(())
183    }
184
185    async fn load_skills_from_dir(
186        &self,
187        dir: &Path,
188        bundled: bool,
189        map: &mut HashMap<String, SkillEntry>,
190    ) -> Result<()> {
191        if !dir.exists() {
192            return Ok(());
193        }
194        let mut entries = tokio::fs::read_dir(dir).await?;
195        while let Some(entry) = entries.next_entry().await? {
196            let path = entry.path();
197            if path.is_dir() {
198                let sf = path.join("SKILL.md");
199                if sf.exists() {
200                    match Self::load_skill_entry(&sf, bundled) {
201                        Ok(se) => {
202                            let n = se.skill.name.clone();
203                            if bundled && map.contains_key(&n) {
204                                continue;
205                            }
206                            map.insert(n, se);
207                        }
208                        Err(e) => {
209                            tracing::warn!("failed to parse skill {:?}: {}", sf, e);
210                        }
211                    }
212                }
213            }
214        }
215        Ok(())
216    }
217    fn load_skill_entry(skill_file: &Path, bundled: bool) -> Result<SkillEntry> {
218        let content = std::fs::read_to_string(skill_file)
219            .with_context(|| format!("reading {skill_file:?}"))?;
220        let skill_dir = skill_file.parent().context("no parent")?;
221        let (parsed, body) = parse_skill(&content, skill_dir)?;
222        let name = if parsed.name.is_empty() {
223            skill_dir
224                .file_name()
225                .and_then(|n| n.to_str())
226                .unwrap_or("unknown")
227                .to_string()
228        } else {
229            parsed.name
230        };
231        let base_dir = skill_dir.parent().context("no grandparent")?.to_path_buf();
232        let skill = Skill {
233            name: name.clone(),
234            description: parsed.description,
235            content: body,
236            path: skill_file.to_path_buf(),
237            base_dir,
238            file_path: skill_file.to_path_buf(),
239        };
240        let (eligibility, status) = {
241            let c = check_requirements(&parsed.metadata);
242            let elig = c.eligible;
243            (
244                c,
245                if elig {
246                    SkillStatus::Ready
247                } else {
248                    SkillStatus::NeedsSetup
249                },
250            )
251        };
252        let status = {
253            let sp = skill.path.parent().unwrap().join("state.json");
254            if sp.exists() {
255                if let Ok(sc) = std::fs::read_to_string(&sp) {
256                    if let Ok(s) = serde_json::from_str::<SkillState>(&sc) {
257                        if !s.enabled {
258                            SkillStatus::Disabled
259                        } else {
260                            status
261                        }
262                    } else {
263                        status
264                    }
265                } else {
266                    status
267                }
268            } else {
269                status
270            }
271        };
272        Ok(SkillEntry {
273            skill,
274            metadata: Some(parsed.metadata),
275            eligibility,
276            status,
277            bundled,
278            source: if bundled {
279                SkillSource::Bundled
280            } else {
281                SkillSource::Managed
282            },
283            invocation: parsed.invocation,
284            format: parsed.format,
285            raw_yaml: parsed.raw_yaml,
286        })
287    }
288    async fn bootstrap_from_bundled(&self) -> Result<()> {
289        let mut entries = tokio::fs::read_dir(&self.bundled_dir).await?;
290        while let Some(entry) = entries.next_entry().await? {
291            let src = entry.path();
292            if src.is_dir() {
293                let name = src
294                    .file_name()
295                    .and_then(|n| n.to_str())
296                    .unwrap_or("unknown");
297                let dest = self.skills_dir.join(name);
298                tokio::fs::create_dir_all(&dest).await?;
299                let sf = src.join("SKILL.md");
300                if sf.exists() {
301                    let df = dest.join("SKILL.md");
302                    if !df.exists() {
303                        tokio::fs::write(&df, tokio::fs::read_to_string(&sf).await?).await?;
304                    }
305                }
306            }
307        }
308        Ok(())
309    }
310    async fn is_dir_empty(&self, dir: &Path) -> Result<bool> {
311        if !dir.exists() {
312            return Ok(true);
313        }
314        let mut e = tokio::fs::read_dir(dir).await?;
315        Ok(e.next_entry().await?.is_none())
316    }
317}