1use std::path::{Path, PathBuf};
33
34pub fn split_frontmatter(content: &str) -> Option<(String, String)> {
38 let trimmed = content.trim_start();
39 if !trimmed.starts_with("---") {
40 return Some((String::new(), content.to_string()));
41 }
42 let after_first = &trimmed[3..].trim_start_matches(['\r', '\n']);
43 let end = after_first.find("\n---")?;
44 let frontmatter = after_first[..end].to_string();
45 let body = after_first[end + 4..].to_string();
46 Some((frontmatter, body))
47}
48
49pub fn extract_field(frontmatter: &str, key: &str) -> Option<String> {
51 for line in frontmatter.lines() {
52 let line = line.trim();
53 if let Some(rest) = line.strip_prefix(key) {
54 let rest = rest.trim_start();
55 if let Some(value) = rest.strip_prefix(':') {
56 return Some(
57 value
58 .trim()
59 .trim_matches('"')
60 .trim_matches('\'')
61 .to_string(),
62 );
63 }
64 }
65 }
66 None
67}
68
69pub fn extract_string_list(frontmatter: &str, key: &str) -> Vec<String> {
71 let Some(value) = extract_field(frontmatter, key) else {
72 return vec![];
73 };
74 let trimmed = value.trim().trim_start_matches('[').trim_end_matches(']');
75 if trimmed.is_empty() {
76 return vec![];
77 }
78 trimmed
79 .split(',')
80 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
81 .filter(|s| !s.is_empty())
82 .collect()
83}
84
85#[derive(Debug, Clone)]
89pub struct Skill {
90 pub name: String,
91 pub description: String,
92 pub triggers: Vec<String>,
94 pub priority: u32,
96 pub keywords: Vec<String>,
98 pub body: String,
100 pub path: Option<PathBuf>,
102}
103
104pub fn parse_skill(content: &str) -> Option<Skill> {
106 let (frontmatter, body) = split_frontmatter(content)?;
107 if frontmatter.is_empty() {
108 return None; }
110
111 let name = extract_field(&frontmatter, "name")?;
112 let description = extract_field(&frontmatter, "description").unwrap_or_default();
113 let priority = extract_field(&frontmatter, "priority")
114 .and_then(|p| p.parse().ok())
115 .unwrap_or(1);
116 let triggers = extract_string_list(&frontmatter, "triggers");
117 let keywords = extract_string_list(&frontmatter, "keywords");
118
119 Some(Skill {
120 name,
121 description,
122 triggers,
123 priority,
124 keywords,
125 body: body.trim().to_string(),
126 path: None,
127 })
128}
129
130pub fn load_skills_from_dir(dir: &Path) -> Vec<Skill> {
134 let mut skills = Vec::new();
135 let Ok(entries) = std::fs::read_dir(dir) else {
136 return skills;
137 };
138 for entry in entries.flatten() {
139 let skill_path = entry.path().join("SKILL.md");
140 if !skill_path.exists() {
141 continue;
142 }
143 let Ok(content) = std::fs::read_to_string(&skill_path) else {
144 continue;
145 };
146 if let Some(mut skill) = parse_skill(&content) {
147 skill.path = Some(skill_path);
148 skills.push(skill);
149 }
150 }
151 skills.sort_by(|a, b| b.priority.cmp(&a.priority));
152 skills
153}
154
155#[derive(Debug, Default)]
159pub struct SkillRegistry {
160 skills: Vec<Skill>,
161}
162
163impl SkillRegistry {
164 pub fn new() -> Self {
165 Self { skills: Vec::new() }
166 }
167
168 pub fn from_skills(mut skills: Vec<Skill>) -> Self {
170 skills.sort_by(|a, b| b.priority.cmp(&a.priority));
171 Self { skills }
172 }
173
174 pub fn from_dir(dir: &Path) -> Self {
176 Self {
177 skills: load_skills_from_dir(dir),
178 }
179 }
180
181 pub fn len(&self) -> usize {
183 self.skills.len()
184 }
185
186 pub fn is_empty(&self) -> bool {
187 self.skills.is_empty()
188 }
189
190 pub fn select(&self, labels: &[&str], instruction: &str) -> Option<&Skill> {
194 let mut candidates: Vec<&Skill> = self
196 .skills
197 .iter()
198 .filter(|s| s.triggers.iter().any(|t| labels.contains(&t.as_str())))
199 .collect();
200
201 if candidates.is_empty() {
202 return None;
203 }
204
205 if candidates.len() > 1 {
207 let instr_lower = instruction.to_lowercase();
208 let keyword_match: Vec<&Skill> = candidates
209 .iter()
210 .filter(|s| {
211 !s.keywords.is_empty()
212 && s.keywords
213 .iter()
214 .any(|kw| instr_lower.contains(&kw.to_lowercase()))
215 })
216 .copied()
217 .collect();
218 if !keyword_match.is_empty() {
219 candidates = keyword_match;
220 }
221 }
222
223 candidates.first().copied()
225 }
226
227 pub fn get(&self, name: &str) -> Option<&Skill> {
229 self.skills.iter().find(|s| s.name == name)
230 }
231
232 pub fn list(&self) -> Vec<(&str, &str)> {
234 self.skills
235 .iter()
236 .map(|s| (s.name.as_str(), s.description.as_str()))
237 .collect()
238 }
239
240 pub fn skills(&self) -> &[Skill] {
242 &self.skills
243 }
244}
245
246#[cfg(feature = "agent")]
249mod skill_tools {
250 use super::*;
251 use crate::agent_tool::{Tool, ToolError, ToolOutput};
252 use crate::context::AgentContext;
253 use async_trait::async_trait;
254 use serde_json::Value;
255 use std::sync::Arc;
256
257 pub struct ListSkillsTool(pub Arc<SkillRegistry>);
259
260 #[async_trait]
261 impl Tool for ListSkillsTool {
262 fn name(&self) -> &str {
263 "list_skills"
264 }
265 fn description(&self) -> &str {
266 "List all available skill workflows. Use when current instructions don't match the task."
267 }
268 fn is_read_only(&self) -> bool {
269 true
270 }
271 fn parameters_schema(&self) -> Value {
272 serde_json::json!({ "type": "object", "properties": {} })
273 }
274 async fn execute(
275 &self,
276 _args: Value,
277 _ctx: &mut AgentContext,
278 ) -> Result<ToolOutput, ToolError> {
279 let list = self.0.list();
280 let text = list
281 .iter()
282 .map(|(name, desc)| format!("- {}: {}", name, desc))
283 .collect::<Vec<_>>()
284 .join("\n");
285 Ok(ToolOutput::text(format!(
286 "Available skills:\n{}\n\nUse get_skill(name) to load full instructions.",
287 text
288 )))
289 }
290 }
291
292 pub struct GetSkillTool(pub Arc<SkillRegistry>);
294
295 #[async_trait]
296 impl Tool for GetSkillTool {
297 fn name(&self) -> &str {
298 "get_skill"
299 }
300 fn description(&self) -> &str {
301 "Load full instructions for a specific skill. Use after list_skills to switch to correct workflow."
302 }
303 fn is_read_only(&self) -> bool {
304 true
305 }
306 fn parameters_schema(&self) -> Value {
307 serde_json::json!({
308 "type": "object",
309 "properties": {
310 "name": { "type": "string", "description": "Skill name from list_skills" }
311 },
312 "required": ["name"]
313 })
314 }
315 async fn execute(
316 &self,
317 args: Value,
318 _ctx: &mut AgentContext,
319 ) -> Result<ToolOutput, ToolError> {
320 let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("");
321 match self.0.get(name) {
322 Some(skill) => Ok(ToolOutput::text(format!(
323 "# Skill: {}\n{}\n\n---\n{}",
324 skill.name, skill.description, skill.body
325 ))),
326 None => Err(ToolError::Execution(format!(
327 "Skill '{}' not found. Use list_skills to see available skills.",
328 name
329 ))),
330 }
331 }
332 }
333}
334
335#[cfg(feature = "agent")]
336pub use skill_tools::{GetSkillTool, ListSkillsTool};
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 const SAMPLE_SKILL: &str = "\
343---
344name: test-skill
345description: A test skill for unit testing
346triggers: [crm, intent_query]
347priority: 10
348keywords: [lookup, find]
349---
350
351WORKFLOW:
352 1. Search for the target
353 2. Read the found file
354
355EXAMPLE:
356 search({}) → result
357 answer({})";
358
359 #[test]
360 fn parse_basic() {
361 let skill = parse_skill(SAMPLE_SKILL).unwrap();
362 assert_eq!(skill.name, "test-skill");
363 assert_eq!(skill.description, "A test skill for unit testing");
364 assert_eq!(skill.triggers, vec!["crm", "intent_query"]);
365 assert_eq!(skill.priority, 10);
366 assert_eq!(skill.keywords, vec!["lookup", "find"]);
367 assert!(skill.body.contains("WORKFLOW:"));
368 assert!(skill.body.contains("EXAMPLE:"));
369 }
370
371 #[test]
372 fn parse_no_frontmatter() {
373 assert!(parse_skill("just body text").is_none());
374 }
375
376 #[test]
377 fn parse_no_name() {
378 let content = "---\ndescription: no name\n---\nbody";
379 assert!(parse_skill(content).is_none());
380 }
381
382 #[test]
383 fn parse_minimal() {
384 let content = "---\nname: minimal\n---\nbody";
385 let skill = parse_skill(content).unwrap();
386 assert_eq!(skill.name, "minimal");
387 assert_eq!(skill.priority, 1);
388 assert!(skill.triggers.is_empty());
389 }
390
391 #[test]
392 fn split_frontmatter_basic() {
393 let (fm, body) = split_frontmatter("---\nname: x\n---\nbody").unwrap();
394 assert!(fm.contains("name: x"));
395 assert!(body.contains("body"));
396 }
397
398 #[test]
399 fn split_frontmatter_no_markers() {
400 let (fm, body) = split_frontmatter("just text").unwrap();
401 assert!(fm.is_empty());
402 assert_eq!(body, "just text");
403 }
404
405 #[test]
406 fn extract_field_basic() {
407 let fm = "name: hello\ndescription: world";
408 assert_eq!(extract_field(fm, "name"), Some("hello".into()));
409 assert_eq!(extract_field(fm, "description"), Some("world".into()));
410 assert_eq!(extract_field(fm, "missing"), None);
411 }
412
413 #[test]
414 fn extract_field_quoted() {
415 let fm = "name: \"quoted value\"";
416 assert_eq!(extract_field(fm, "name"), Some("quoted value".into()));
417 }
418
419 #[test]
420 fn extract_string_list_basic() {
421 let fm = "triggers: [crm, intent_query, injection]";
422 assert_eq!(
423 extract_string_list(fm, "triggers"),
424 vec!["crm", "intent_query", "injection"]
425 );
426 }
427
428 #[test]
429 fn extract_string_list_empty() {
430 let fm = "triggers: []";
431 assert!(extract_string_list(fm, "triggers").is_empty());
432 }
433
434 #[test]
435 fn registry_select_by_trigger() {
436 let skills = vec![
437 parse_skill("---\nname: a\ntriggers: [crm]\npriority: 1\n---\nA body").unwrap(),
438 parse_skill("---\nname: b\ntriggers: [injection]\npriority: 1\n---\nB body").unwrap(),
439 ];
440 let reg = SkillRegistry::from_skills(skills);
441 let selected = reg.select(&["injection"], "test").unwrap();
442 assert_eq!(selected.name, "b");
443 }
444
445 #[test]
446 fn registry_select_by_keyword() {
447 let skills = vec![
448 parse_skill("---\nname: general\ntriggers: [crm]\npriority: 1\n---\nGeneral").unwrap(),
449 parse_skill("---\nname: invoice\ntriggers: [crm]\npriority: 20\nkeywords: [invoice, resend]\n---\nInvoice").unwrap(),
450 ];
451 let reg = SkillRegistry::from_skills(skills);
452 let selected = reg.select(&["crm"], "resend the invoice please").unwrap();
453 assert_eq!(selected.name, "invoice");
454 }
455
456 #[test]
457 fn registry_select_fallback_priority() {
458 let skills = vec![
459 parse_skill("---\nname: low\ntriggers: [crm]\npriority: 1\n---\nLow").unwrap(),
460 parse_skill("---\nname: high\ntriggers: [crm]\npriority: 50\n---\nHigh").unwrap(),
461 ];
462 let reg = SkillRegistry::from_skills(skills);
463 let selected = reg.select(&["crm"], "anything").unwrap();
464 assert_eq!(selected.name, "high");
465 }
466
467 #[test]
468 fn registry_no_match() {
469 let skills =
470 vec![parse_skill("---\nname: a\ntriggers: [crm]\npriority: 1\n---\nA").unwrap()];
471 let reg = SkillRegistry::from_skills(skills);
472 assert!(reg.select(&["injection"], "test").is_none());
473 }
474
475 #[test]
476 fn registry_get_by_name() {
477 let skills = vec![
478 parse_skill("---\nname: alpha\ntriggers: [crm]\npriority: 1\n---\nA").unwrap(),
479 parse_skill("---\nname: beta\ntriggers: [crm]\npriority: 1\n---\nB").unwrap(),
480 ];
481 let reg = SkillRegistry::from_skills(skills);
482 assert_eq!(reg.get("beta").unwrap().body, "B");
483 assert!(reg.get("gamma").is_none());
484 }
485
486 #[test]
487 fn registry_list() {
488 let skills = vec![
489 parse_skill("---\nname: a\ndescription: Alpha\ntriggers: []\npriority: 1\n---\n")
490 .unwrap(),
491 parse_skill("---\nname: b\ndescription: Beta\ntriggers: []\npriority: 2\n---\n")
492 .unwrap(),
493 ];
494 let reg = SkillRegistry::from_skills(skills);
495 let list = reg.list();
496 assert_eq!(list.len(), 2);
497 assert_eq!(list[0].0, "b");
499 }
500}