1pub mod agent_skill;
10pub mod autonomous_loop;
11pub mod brainstorming;
12pub mod context_builder;
13pub mod deep_research;
14pub mod design_farmer;
15pub mod obsidian;
16pub mod oracle;
17pub mod planner;
18pub mod playwright_cli;
19pub mod reviewer;
20pub mod scout;
21pub mod shell;
22pub mod super_review;
23pub mod wasm;
24pub mod worktree;
25
26use anyhow::{Context, Result};
27use std::collections::HashMap;
28use std::fmt;
29use std::path::{Path, PathBuf};
30
31#[derive(Debug, Clone)]
33pub struct Skill {
34 pub name: String,
36 pub description: String,
38 pub content: String,
40}
41
42impl fmt::Display for Skill {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 write!(f, "{}: {}", self.name, self.description)
45 }
46}
47
48pub struct SkillManager {
50 skills: HashMap<String, Skill>,
51}
52
53impl SkillManager {
54 pub fn load_from_dir(dir: &Path) -> Result<Self> {
65 let mut skills = HashMap::new();
66
67 if !dir.exists() {
68 tracing::debug!("Skills directory does not exist: {}", dir.display());
69 return Ok(Self { skills });
70 }
71
72 let entries = std::fs::read_dir(dir)
73 .with_context(|| format!("Failed to read skills directory: {}", dir.display()))?;
74
75 for entry in entries {
76 let entry = entry?;
77 let path = entry.path();
78
79 if !path.is_dir() {
81 continue;
82 }
83
84 let skill_file = path.join("SKILL.md");
85 if !skill_file.exists() {
86 tracing::debug!(
87 "No SKILL.md found in {}",
88 path.file_name().unwrap_or_default().to_string_lossy()
89 );
90 continue;
91 }
92
93 let name = path
94 .file_name()
95 .unwrap_or_default()
96 .to_string_lossy()
97 .to_string();
98
99 match Self::load_skill(&name, &skill_file) {
100 Ok(skill) => {
101 tracing::debug!("Loaded skill: {}", skill.name);
102 skills.insert(name.to_lowercase(), skill);
103 }
104 Err(e) => {
105 tracing::warn!("Failed to load skill from {}: {}", skill_file.display(), e);
106 }
107 }
108 }
109
110 tracing::info!("Loaded {} skill(s) from {}", skills.len(), dir.display());
111 Ok(Self { skills })
112 }
113
114 fn load_skill(name: &str, path: &Path) -> Result<Skill> {
116 let content = std::fs::read_to_string(path)
117 .with_context(|| format!("Failed to read {}", path.display()))?;
118
119 let description = Self::extract_description(&content);
120
121 Ok(Skill {
122 name: name.to_string(),
123 description,
124 content,
125 })
126 }
127
128 fn extract_description(content: &str) -> String {
133 for line in content.lines() {
134 let trimmed = line.trim();
135 if trimmed.is_empty() || trimmed == "---" {
137 continue;
138 }
139 if trimmed.contains(':') && !trimmed.starts_with('#') && !trimmed.starts_with('-') {
141 continue;
142 }
143 if let Some(heading) = trimmed.strip_prefix('#') {
145 return heading.trim().to_string();
146 }
147 if !trimmed.starts_with('-') && !trimmed.starts_with('>') && trimmed.len() > 3 {
149 return trimmed.to_string();
150 }
151 }
152 "No description".to_string()
153 }
154
155 pub fn get(&self, name: &str) -> Option<&Skill> {
157 self.skills.get(&name.to_lowercase())
158 }
159
160 pub fn all(&self) -> Vec<&Skill> {
162 let mut skills: Vec<&Skill> = self.skills.values().collect();
163 skills.sort_by(|a, b| a.name.cmp(&b.name));
164 skills
165 }
166
167 pub fn search(&self, query: &str) -> Vec<&Skill> {
172 let query_lower = query.to_lowercase();
173 let mut name_matches: Vec<&Skill> = Vec::new();
174 let mut desc_matches: Vec<&Skill> = Vec::new();
175
176 for skill in self.skills.values() {
177 let name_lower = skill.name.to_lowercase();
178 let desc_lower = skill.description.to_lowercase();
179
180 if name_lower.contains(&query_lower) {
181 name_matches.push(skill);
182 } else if desc_lower.contains(&query_lower) {
183 desc_matches.push(skill);
184 }
185 }
186
187 for skill in self.skills.values() {
189 if !name_matches.iter().any(|s| s.name == skill.name)
190 && !desc_matches.iter().any(|s| s.name == skill.name)
191 && skill.content.to_lowercase().contains(&query_lower)
192 {
193 desc_matches.push(skill);
194 }
195 }
196
197 name_matches.sort_by(|a, b| a.name.cmp(&b.name));
198 desc_matches.sort_by(|a, b| a.name.cmp(&b.name));
199
200 name_matches.extend(desc_matches);
201 name_matches
202 }
203
204 pub fn len(&self) -> usize {
206 self.skills.len()
207 }
208
209 pub fn is_empty(&self) -> bool {
211 self.skills.is_empty()
212 }
213
214 pub fn skills_dir() -> Result<PathBuf> {
216 let home = dirs::home_dir().context("Cannot determine home directory")?;
217 Ok(home.join(".oxi").join("skills"))
218 }
219}
220
221impl fmt::Debug for SkillManager {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 f.debug_struct("SkillManager")
224 .field("count", &self.skills.len())
225 .field(
226 "names",
227 &self
228 .skills
229 .keys()
230 .cloned()
231 .collect::<Vec<String>>(),
232 )
233 .finish()
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use std::fs;
241
242 #[test]
243 fn test_load_from_empty_dir() {
244 let tmp = tempfile::tempdir().unwrap();
245 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
246 assert!(manager.is_empty());
247 assert_eq!(manager.len(), 0);
248 }
249
250 #[test]
251 fn test_load_from_nonexistent_dir() {
252 let manager = SkillManager::load_from_dir(Path::new("/nonexistent/skills")).unwrap();
253 assert!(manager.is_empty());
254 }
255
256 #[test]
257 fn test_load_single_skill() {
258 let tmp = tempfile::tempdir().unwrap();
259 let skill_dir = tmp.path().join("my-skill");
260 fs::create_dir_all(&skill_dir).unwrap();
261 fs::write(
262 skill_dir.join("SKILL.md"),
263 "# My Skill\n\nThis skill does something cool.\n\n## Usage\nDo X then Y.",
264 )
265 .unwrap();
266
267 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
268 assert_eq!(manager.len(), 1);
269
270 let skill = manager.get("my-skill").unwrap();
271 assert_eq!(skill.name, "my-skill");
272 assert_eq!(skill.description, "My Skill");
273 assert!(skill.content.contains("This skill does something cool"));
274 }
275
276 #[test]
277 fn test_load_multiple_skills() {
278 let tmp = tempfile::tempdir().unwrap();
279
280 let dir_a = tmp.path().join("skill-a");
282 fs::create_dir_all(&dir_a).unwrap();
283 fs::write(dir_a.join("SKILL.md"), "# Skill A\nDescription A").unwrap();
284
285 let dir_b = tmp.path().join("skill-b");
287 fs::create_dir_all(&dir_b).unwrap();
288 fs::write(dir_b.join("SKILL.md"), "# Skill B\nDescription B").unwrap();
289
290 let dir_empty = tmp.path().join("empty-dir");
292 fs::create_dir_all(&dir_empty).unwrap();
293
294 fs::write(tmp.path().join("not-a-dir.txt"), "ignore me").unwrap();
296
297 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
298 assert_eq!(manager.len(), 2);
299 assert!(manager.get("skill-a").is_some());
300 assert!(manager.get("skill-b").is_some());
301 assert!(manager.get("empty-dir").is_none());
302 }
303
304 #[test]
305 fn test_get_case_insensitive() {
306 let tmp = tempfile::tempdir().unwrap();
307 let dir = tmp.path().join("My-Skill");
308 fs::create_dir_all(&dir).unwrap();
309 fs::write(dir.join("SKILL.md"), "# Test\nContent").unwrap();
310
311 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
312 assert!(manager.get("My-Skill").is_some());
314 }
315
316 #[test]
317 fn test_search_by_name() {
318 let tmp = tempfile::tempdir().unwrap();
319 let dir = tmp.path().join("rust-expert");
320 fs::create_dir_all(&dir).unwrap();
321 fs::write(dir.join("SKILL.md"), "# Rust Expert\nAn expert in Rust").unwrap();
322
323 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
324 let results = manager.search("rust");
325 assert_eq!(results.len(), 1);
326 assert_eq!(results[0].name, "rust-expert");
327 }
328
329 #[test]
330 fn test_search_by_description() {
331 let tmp = tempfile::tempdir().unwrap();
332 let dir = tmp.path().join("helper");
333 fs::create_dir_all(&dir).unwrap();
334 fs::write(dir.join("SKILL.md"), "# Helper\nA database optimization expert").unwrap();
335
336 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
337 let results = manager.search("database");
338 assert_eq!(results.len(), 1);
339 assert_eq!(results[0].name, "helper");
340 }
341
342 #[test]
343 fn test_search_by_content() {
344 let tmp = tempfile::tempdir().unwrap();
345 let dir = tmp.path().join("coder");
346 fs::create_dir_all(&dir).unwrap();
347 fs::write(dir.join("SKILL.md"), "# Coder\nA coding assistant\n\n## Details\nFocuses on async patterns").unwrap();
348
349 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
350 let results = manager.search("async");
351 assert_eq!(results.len(), 1);
352 }
353
354 #[test]
355 fn test_search_no_results() {
356 let tmp = tempfile::tempdir().unwrap();
357 let dir = tmp.path().join("skill");
358 fs::create_dir_all(&dir).unwrap();
359 fs::write(dir.join("SKILL.md"), "# Skill\nA skill").unwrap();
360
361 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
362 let results = manager.search("nonexistent");
363 assert!(results.is_empty());
364 }
365
366 #[test]
367 fn test_all_sorted() {
368 let tmp = tempfile::tempdir().unwrap();
369
370 for name in &["zebra", "alpha", "middle"] {
371 let dir = tmp.path().join(name);
372 fs::create_dir_all(&dir).unwrap();
373 fs::write(dir.join("SKILL.md"), format!("# {}\nDesc", name)).unwrap();
374 }
375
376 let manager = SkillManager::load_from_dir(tmp.path()).unwrap();
377 let all = manager.all();
378 assert_eq!(all.len(), 3);
379 assert_eq!(all[0].name, "alpha");
380 assert_eq!(all[1].name, "middle");
381 assert_eq!(all[2].name, "zebra");
382 }
383
384 #[test]
385 fn test_extract_description_from_heading() {
386 let content = "# My Cool Skill\n\nSome body text";
387 assert_eq!(SkillManager::extract_description(content), "My Cool Skill");
388 }
389
390 #[test]
391 fn test_extract_description_from_paragraph() {
392 let content = "This is a skill that does things.\n\nMore text.";
393 assert_eq!(
394 SkillManager::extract_description(content),
395 "This is a skill that does things."
396 );
397 }
398
399 #[test]
400 fn test_extract_description_empty() {
401 let content = "---\n---\n";
402 assert_eq!(SkillManager::extract_description(content), "No description");
403 }
404
405 #[test]
406 fn test_skills_dir() {
407 let dir = SkillManager::skills_dir().unwrap();
408 assert!(dir.to_string_lossy().contains(".oxi"));
409 assert!(dir.to_string_lossy().contains("skills"));
410 }
411}