oxios_kernel/skill/
manager.rs1#![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 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 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}