Skip to main content

phi_core/context/
skills.rs

1//! Skills — load AgentSkills-compatible skill directories and inject into system prompts.
2//!
3//! Follows the [AgentSkills](https://agentskills.io) open standard.
4//! Skills are directories containing a `SKILL.md` file with YAML frontmatter.
5//!
6//! # Progressive Disclosure
7//!
8//! 1. **Metadata** (~100 tokens/skill) — name + description, always in the system prompt
9//! 2. **Instructions** (<5k tokens) — SKILL.md body, loaded by the agent when activated
10//! 3. **Resources** (unlimited) — scripts/, references/, assets/, loaded on demand
11//!
12//! The agent decides when to activate a skill based on the description. No trigger
13//! engine needed — the LLM is smart enough.
14//!
15//! # Example
16//!
17//! ```rust,no_run
18//! use phi_core::SkillSet;
19//!
20//! let skills = SkillSet::load(&["./skills", "~/.phi-core/skills"]).unwrap();
21//! println!("{}", skills.format_for_prompt());
22//! // Inject into system prompt via Agent::with_skills()
23//! ```
24
25use std::collections::HashMap;
26use std::fs;
27use std::path::{Path, PathBuf};
28
29/// A loaded skill with its metadata.
30#[derive(Debug, Clone)]
31pub struct Skill {
32    /// Skill name (must match directory name, lowercase + hyphens)
33    pub name: String,
34    /// Description of what the skill does and when to use it
35    pub description: String,
36    /// Absolute path to SKILL.md
37    pub file_path: PathBuf,
38    /// Absolute path to the skill directory
39    pub base_dir: PathBuf,
40    /// Where this skill was loaded from (e.g. "workspace", "global", or a custom label)
41    pub source: String,
42}
43
44/// A collection of loaded skills.
45#[derive(Debug, Clone, Default)]
46pub struct SkillSet {
47    skills: Vec<Skill>,
48}
49
50/*
51RUST QUIRK: `Path` vs `PathBuf`
52
53  `Path`    — borrowed path slice (like &str for strings), no allocation
54  `PathBuf` — owned, heap-allocated path (like String), can grow/modify
55
56Why does Skill store `PathBuf` (not `Path`)?
57Because Skill is a struct that OWNS its data — it must hold the path independently
58of wherever it was loaded from. PathBuf is the owned version.
59
60Why does `load_skills_from_dir(dir: &Path)` take `&Path`?
61Because the function only needs to READ the path — borrowing is cheaper than cloning.
62`impl AsRef<Path>` accepts &str, String, PathBuf, or &Path — all convert to &Path.
63
64Python analogy: PathBuf ≈ str (mutable), Path ≈ bytes (immutable view).
65In Python, you'd just use str or pathlib.Path without these distinctions.
66*/
67
68/// Errors during skill loading.
69/*
70RUST QUIRK: `thiserror::Error` derive macro — automatic error types
71
72`#[derive(thiserror::Error)]` generates the `std::error::Error` impl automatically.
73The `#[error("...")]` attribute defines the Display message for each variant.
74
75Interpolation in error strings:
76  {path}   — calls Display on the `path` field (PathBuf implements Display)
77  {source} — for `std::io::Error`, shows the OS error message
78  {field}  — for &'static str, shows the field name directly
79
80RUST QUIRK: `field: &'static str`
81
82`&'static str` means "a string reference that lives for the entire program lifetime."
83In practice, this means string literals: "name", "description" — they're baked into
84the binary. Using `&'static str` instead of `String` avoids allocation for these
85compile-time-known field names.
86
87If the field names were dynamic (computed at runtime), you'd use `String` instead.
88*/
89#[derive(Debug, thiserror::Error)]
90pub enum SkillError {
91    #[error("IO error reading {path}: {source}")]
92    Io {
93        path: PathBuf,
94        source: std::io::Error,
95    },
96    #[error("SKILL.md in {path} missing required frontmatter field: {field}")]
97    MissingField { path: PathBuf, field: &'static str }, // &'static = compile-time string literal
98    #[error("SKILL.md in {path} has invalid frontmatter: {detail}")]
99    InvalidFrontmatter { path: PathBuf, detail: String },
100}
101
102impl SkillSet {
103    /// Load skills from multiple directories. Later directories take precedence
104    /// (skills with the same name from later dirs override earlier ones).
105    pub fn load(
106        dirs: &[impl AsRef<Path>], // ORDERED DIRECTORIES — scanned left to right; later dirs win on name conflicts
107    ) -> Result<Self, SkillError> {
108        /*
109        RUST QUIRK: `HashMap` for deduplication (last-write-wins)
110
111        HashMap<String, Skill> maps skill name → Skill.
112        `.insert(key, value)` silently OVERWRITES if the key already exists.
113        Iterating dirs in order (first → last) means later dirs win on name conflict.
114
115        Python analogy: by_name = {}; by_name[skill.name] = skill
116
117        RUST QUIRK: `dirs: &[impl AsRef<Path>]`
118
119        `&[impl AsRef<Path>]` = a slice of "anything that can be viewed as a Path."
120        This accepts: &[&str], &[String], &[PathBuf], or any mix.
121        `dir.as_ref()` converts whatever type `dir` is into &Path.
122        */
123        let mut by_name: HashMap<String, Skill> = HashMap::new();
124
125        for (i, dir) in dirs.iter().enumerate() {
126            let dir = dir.as_ref(); // convert to &Path regardless of input type
127            if !dir.exists() {
128                continue; // silently skip non-existent dirs (not an error)
129            }
130            let source = format!("dir:{}", i);
131            /*
132            RUST QUIRK: `?` operator for error propagation
133
134            `load_skills_from_dir(dir, &source)?` means:
135              - If Ok(skills): unwrap and bind to `skills`
136              - If Err(e):      immediately RETURN Err(e) from the current function
137
138            Without `?`, you'd write:
139              let skills = match load_skills_from_dir(dir, &source) {
140                  Ok(s) => s,
141                  Err(e) => return Err(e),
142              };
143
144            `?` is syntactic sugar for this pattern. It makes error-propagating
145            code as readable as Python's try/except but without hiding the errors.
146            */
147            let skills = load_skills_from_dir(dir, &source)?;
148            for skill in skills {
149                by_name.insert(skill.name.clone(), skill); // later dirs overwrite
150            }
151        }
152
153        /*
154        RUST QUIRK: `into_values().collect()` — consuming a HashMap into a Vec
155
156        `by_name.into_values()` — consume the HashMap (ownership transfer), yield only the VALUES
157        `.collect()` — gather the iterator into a Vec<Skill>
158
159        `by_name.values()` would BORROW the values (&Skill), yielding references.
160        `by_name.into_values()` MOVES the values out (Skill), avoiding clones.
161        We use `into_values()` because we're done with the HashMap.
162        */
163        let mut skills: Vec<Skill> = by_name.into_values().collect();
164        /*
165        `.sort_by(|a, b| a.name.cmp(&b.name))` — sort in place with a comparator
166
167        sort_by takes a closure that returns std::cmp::Ordering (Less, Equal, Greater).
168        `.cmp()` on String does lexicographic comparison and returns Ordering.
169
170        Python analogy: skills.sort(key=lambda s: s.name)
171
172        Rust's sort_by is a stable sort (preserves relative order of equal elements).
173        */
174        skills.sort_by(|a, b| a.name.cmp(&b.name));
175        Ok(Self { skills }) // wrap in Ok() to match return type Result<Self, SkillError>
176    }
177
178    /// Load skills from a single directory with a custom source label.
179    pub fn load_dir(
180        dir: impl AsRef<Path>, // DIRECTORY — single skill directory to scan for subdirectories with SKILL.md
181        source: &str, // LABEL     — stored on each Skill for tracking origin (e.g. "workspace", "global")
182    ) -> Result<Self, SkillError> {
183        let skills = load_skills_from_dir(dir.as_ref(), source)?;
184        Ok(Self { skills })
185    }
186
187    /// Create an empty skill set.
188    pub fn empty() -> Self {
189        Self { skills: Vec::new() }
190    }
191
192    /// Merge another skill set into this one. Other's skills override on name conflict.
193    pub fn merge(
194        &mut self,
195        other: SkillSet, // INCOMING — skills from the other set; wins on name conflict (same behavior as later-dir-wins in load())
196    ) {
197        let mut by_name: HashMap<String, Skill> =
198            self.skills.drain(..).map(|s| (s.name.clone(), s)).collect();
199        for skill in other.skills {
200            by_name.insert(skill.name.clone(), skill);
201        }
202        self.skills = by_name.into_values().collect();
203        self.skills.sort_by(|a, b| a.name.cmp(&b.name));
204    }
205
206    /// Get all loaded skills.
207    pub fn skills(&self) -> &[Skill] {
208        &self.skills
209    }
210
211    /// Number of loaded skills.
212    pub fn len(&self) -> usize {
213        self.skills.len()
214    }
215
216    /// Whether no skills are loaded.
217    pub fn is_empty(&self) -> bool {
218        self.skills.is_empty()
219    }
220
221    /// Format skills for inclusion in a system prompt.
222    ///
223    /// Uses XML format per the [AgentSkills standard](https://agentskills.io/integrate-skills):
224    /// ```xml
225    /// <available_skills>
226    ///   <skill>
227    ///     <name>weather</name>
228    ///     <description>Get current weather and forecasts.</description>
229    ///     <location>/path/to/skills/weather/SKILL.md</location>
230    ///   </skill>
231    /// </available_skills>
232    /// ```
233    ///
234    /// Returns an empty string if no skills are loaded.
235    pub fn format_for_prompt(&self) -> String {
236        if self.skills.is_empty() {
237            return String::new();
238        }
239
240        let mut out = String::from("<available_skills>\n");
241        for skill in &self.skills {
242            out.push_str("  <skill>\n");
243            out.push_str(&format!("    <name>{}</name>\n", xml_escape(&skill.name)));
244            out.push_str(&format!(
245                "    <description>{}</description>\n",
246                xml_escape(&skill.description)
247            ));
248            out.push_str(&format!(
249                "    <location>{}</location>\n",
250                xml_escape(&skill.file_path.to_string_lossy())
251            ));
252            out.push_str("  </skill>\n");
253        }
254        out.push_str("</available_skills>");
255        out
256    }
257}
258
259/// Scan a directory for skills. Looks for:
260/// - `<dir>/<name>/SKILL.md` (standard layout)
261fn load_skills_from_dir(
262    dir: &Path, // DIRECTORY — scanned for subdirectories, each of which may be a skill (must contain SKILL.md)
263    source: &str, // LABEL     — stored verbatim on every Skill loaded from this dir (for provenance tracking)
264) -> Result<Vec<Skill>, SkillError> {
265    let mut skills = Vec::new();
266
267    /*
268    RUST QUIRK: `.map_err(|e| ...)` — converting error types
269
270    `fs::read_dir()` returns `Result<ReadDir, std::io::Error>`.
271    Our function returns `Result<Vec<Skill>, SkillError>`.
272    The types don't match — we need to convert `std::io::Error` → `SkillError`.
273
274    `.map_err(|e| SkillError::Io { path: ..., source: e })` transforms the Err variant:
275      Ok(v)  → Ok(v) unchanged
276      Err(e) → Err(SkillError::Io { path: dir.to_path_buf(), source: e })
277
278    Then `?` propagates the converted error if it's Err.
279
280    `dir.to_path_buf()` — converts &Path to owned PathBuf (heap allocation).
281    Required because SkillError stores PathBuf (owned), not &Path (borrowed).
282    */
283    let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
284        path: dir.to_path_buf(),
285        source: e,
286    })?;
287
288    for entry in entries {
289        let entry = entry.map_err(|e| SkillError::Io {
290            path: dir.to_path_buf(),
291            source: e,
292        })?;
293        let path = entry.path();
294        if !path.is_dir() {
295            continue;
296        }
297
298        let skill_md = path.join("SKILL.md");
299        if !skill_md.exists() {
300            continue;
301        }
302
303        let content = fs::read_to_string(&skill_md).map_err(|e| SkillError::Io {
304            path: skill_md.clone(),
305            source: e,
306        })?;
307
308        let (name, description) = parse_frontmatter(&content, &skill_md)?;
309
310        // Validate name matches directory
311        /*
312        RUST QUIRK: `to_string_lossy()` — graceful handling of non-UTF8 paths
313
314        File paths on some platforms (Linux) can contain arbitrary bytes, not just UTF-8.
315        `OsStr::to_string_lossy()` returns a `Cow<str>`:
316          - `Cow::Borrowed(&str)` if the path is valid UTF-8 (zero copy)
317          - `Cow::Owned(String)` if non-UTF8, replacing invalid sequences with U+FFFD (lossy)
318
319        `Cow` = "Clone On Write" — a smart pointer that avoids allocation when possible.
320        `.to_string()` at the end converts Cow<str> to owned String in both cases.
321
322        `file_name()` returns Option<&OsStr> — None if path ends with ".." or "/".
323        `unwrap_or_default()` returns OsStr::new("") on None.
324        */
325        let dir_name = path
326            .file_name()
327            .unwrap_or_default()
328            .to_string_lossy() // OsStr → Cow<str> (handles non-UTF8 gracefully)
329            .to_string();
330
331        // Use directory name if frontmatter name doesn't match (be lenient)
332        let name = if name == dir_name { name } else { dir_name };
333
334        let base_dir = fs::canonicalize(&path).unwrap_or(path);
335        let file_path = base_dir.join("SKILL.md");
336
337        skills.push(Skill {
338            name,
339            description,
340            file_path,
341            base_dir,
342            source: source.to_string(),
343        });
344    }
345
346    skills.sort_by(|a, b| a.name.cmp(&b.name));
347    Ok(skills)
348}
349
350/// Parse YAML frontmatter from SKILL.md content.
351/// Expects `---\n...\n---` block at the start.
352fn parse_frontmatter(
353    content: &str, // RAW TEXT — full contents of SKILL.md including the `---` frontmatter block
354    path: &Path,   // ERROR CONTEXT — the file path; used only in SkillError variants (not parsed)
355) -> Result<(String, String), SkillError> {
356    let trimmed = content.trim_start();
357    if !trimmed.starts_with("---") {
358        return Err(SkillError::InvalidFrontmatter {
359            path: path.to_path_buf(),
360            detail: "missing opening ---".into(),
361        });
362    }
363
364    let after_open = &trimmed[3..];
365    let end = after_open
366        .find("\n---")
367        .ok_or(SkillError::InvalidFrontmatter {
368            path: path.to_path_buf(),
369            detail: "missing closing ---".into(),
370        })?;
371
372    let yaml_block = &after_open[..end];
373
374    let mut name = None;
375    let mut description = None;
376
377    for line in yaml_block.lines() {
378        let line = line.trim();
379        if let Some(rest) = line.strip_prefix("name:") {
380            name = Some(unquote(rest.trim()));
381        } else if let Some(rest) = line.strip_prefix("description:") {
382            description = Some(unquote(rest.trim()));
383        }
384    }
385
386    let name = name.ok_or(SkillError::MissingField {
387        path: path.to_path_buf(),
388        field: "name",
389    })?;
390    let description = description.ok_or(SkillError::MissingField {
391        path: path.to_path_buf(),
392        field: "description",
393    })?;
394
395    if name.is_empty() {
396        return Err(SkillError::MissingField {
397            path: path.to_path_buf(),
398            field: "name",
399        });
400    }
401    if description.is_empty() {
402        return Err(SkillError::MissingField {
403            path: path.to_path_buf(),
404            field: "description",
405        });
406    }
407
408    Ok((name, description))
409}
410
411/// Remove surrounding quotes from a YAML value.
412fn unquote(s: &str) -> String {
413    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
414        s[1..s.len() - 1].to_string()
415    } else {
416        s.to_string()
417    }
418}
419
420/// Minimal XML escaping for prompt generation.
421fn xml_escape(s: &str) -> String {
422    s.replace('&', "&amp;")
423        .replace('<', "&lt;")
424        .replace('>', "&gt;")
425        .replace('"', "&quot;")
426        .replace('\'', "&apos;")
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use std::fs;
433    use tempfile::TempDir;
434
435    fn create_skill(dir: &Path, name: &str, description: &str) {
436        let skill_dir = dir.join(name);
437        fs::create_dir_all(&skill_dir).unwrap();
438        fs::write(
439            skill_dir.join("SKILL.md"),
440            format!(
441                "---\nname: {}\ndescription: {}\n---\n\n# {}\n\nInstructions here.\n",
442                name, description, name
443            ),
444        )
445        .unwrap();
446    }
447
448    #[test]
449    fn load_skills_from_directory() {
450        let tmp = TempDir::new().unwrap();
451        create_skill(tmp.path(), "weather", "Get current weather and forecasts.");
452        create_skill(tmp.path(), "git", "Git operations: commit, branch, merge.");
453
454        let skills = SkillSet::load(&[tmp.path()]).unwrap();
455        assert_eq!(skills.len(), 2);
456        assert_eq!(skills.skills()[0].name, "git");
457        assert_eq!(skills.skills()[1].name, "weather");
458    }
459
460    #[test]
461    fn format_for_prompt_xml() {
462        let tmp = TempDir::new().unwrap();
463        create_skill(tmp.path(), "weather", "Get weather.");
464
465        let skills = SkillSet::load(&[tmp.path()]).unwrap();
466        let prompt = skills.format_for_prompt();
467
468        assert!(prompt.contains("<available_skills>"));
469        assert!(prompt.contains("<name>weather</name>"));
470        assert!(prompt.contains("<description>Get weather.</description>"));
471        assert!(prompt.contains("SKILL.md</location>"));
472        assert!(prompt.contains("</available_skills>"));
473    }
474
475    #[test]
476    fn empty_when_no_skills() {
477        let tmp = TempDir::new().unwrap();
478        let skills = SkillSet::load(&[tmp.path()]).unwrap();
479        assert!(skills.is_empty());
480        assert_eq!(skills.format_for_prompt(), "");
481    }
482
483    #[test]
484    fn later_dirs_override_earlier() {
485        let dir1 = TempDir::new().unwrap();
486        let dir2 = TempDir::new().unwrap();
487        create_skill(dir1.path(), "weather", "Old description.");
488        create_skill(dir2.path(), "weather", "New description.");
489
490        let skills = SkillSet::load(&[dir1.path(), dir2.path()]).unwrap();
491        assert_eq!(skills.len(), 1);
492        assert_eq!(skills.skills()[0].description, "New description.");
493    }
494
495    #[test]
496    fn skips_nonexistent_dirs() {
497        let skills = SkillSet::load(&[Path::new("/nonexistent/path")]).unwrap();
498        assert!(skills.is_empty());
499    }
500
501    #[test]
502    fn skips_dirs_without_skill_md() {
503        let tmp = TempDir::new().unwrap();
504        fs::create_dir_all(tmp.path().join("not-a-skill")).unwrap();
505        fs::write(tmp.path().join("not-a-skill/README.md"), "hello").unwrap();
506
507        let skills = SkillSet::load(&[tmp.path()]).unwrap();
508        assert!(skills.is_empty());
509    }
510
511    #[test]
512    fn error_on_missing_frontmatter() {
513        let tmp = TempDir::new().unwrap();
514        let skill_dir = tmp.path().join("bad-skill");
515        fs::create_dir_all(&skill_dir).unwrap();
516        fs::write(skill_dir.join("SKILL.md"), "# No frontmatter\n").unwrap();
517
518        let result = SkillSet::load(&[tmp.path()]);
519        assert!(result.is_err());
520    }
521
522    #[test]
523    fn error_on_missing_name() {
524        let tmp = TempDir::new().unwrap();
525        let skill_dir = tmp.path().join("no-name");
526        fs::create_dir_all(&skill_dir).unwrap();
527        fs::write(
528            skill_dir.join("SKILL.md"),
529            "---\ndescription: Has desc but no name.\n---\n",
530        )
531        .unwrap();
532
533        let result = SkillSet::load(&[tmp.path()]);
534        assert!(result.is_err());
535    }
536
537    #[test]
538    fn error_on_missing_description() {
539        let tmp = TempDir::new().unwrap();
540        let skill_dir = tmp.path().join("no-desc");
541        fs::create_dir_all(&skill_dir).unwrap();
542        fs::write(skill_dir.join("SKILL.md"), "---\nname: no-desc\n---\n").unwrap();
543
544        let result = SkillSet::load(&[tmp.path()]);
545        assert!(result.is_err());
546    }
547
548    #[test]
549    fn quoted_frontmatter_values() {
550        let tmp = TempDir::new().unwrap();
551        let skill_dir = tmp.path().join("quoted");
552        fs::create_dir_all(&skill_dir).unwrap();
553        fs::write(
554            skill_dir.join("SKILL.md"),
555            "---\nname: \"quoted\"\ndescription: 'A quoted description.'\n---\n",
556        )
557        .unwrap();
558
559        let skills = SkillSet::load(&[tmp.path()]).unwrap();
560        assert_eq!(skills.skills()[0].name, "quoted");
561        assert_eq!(skills.skills()[0].description, "A quoted description.");
562    }
563
564    #[test]
565    fn xml_escaping() {
566        let tmp = TempDir::new().unwrap();
567        let skill_dir = tmp.path().join("escape-test");
568        fs::create_dir_all(&skill_dir).unwrap();
569        fs::write(
570            skill_dir.join("SKILL.md"),
571            "---\nname: escape-test\ndescription: Uses <tags> & \"quotes\"\n---\n",
572        )
573        .unwrap();
574
575        let skills = SkillSet::load(&[tmp.path()]).unwrap();
576        let prompt = skills.format_for_prompt();
577        assert!(prompt.contains("&lt;tags&gt;"));
578        assert!(prompt.contains("&amp;"));
579        assert!(prompt.contains("&quot;quotes&quot;"));
580    }
581
582    #[test]
583    fn merge_skill_sets() {
584        let dir1 = TempDir::new().unwrap();
585        let dir2 = TempDir::new().unwrap();
586        create_skill(dir1.path(), "weather", "Weather v1.");
587        create_skill(dir1.path(), "git", "Git operations.");
588        create_skill(dir2.path(), "weather", "Weather v2.");
589        create_skill(dir2.path(), "docker", "Docker management.");
590
591        let mut set1 = SkillSet::load(&[dir1.path()]).unwrap();
592        let set2 = SkillSet::load(&[dir2.path()]).unwrap();
593        set1.merge(set2);
594
595        assert_eq!(set1.len(), 3);
596        let names: Vec<&str> = set1.skills().iter().map(|s| s.name.as_str()).collect();
597        assert_eq!(names, vec!["docker", "git", "weather"]);
598        // weather should be v2 (merged override)
599        assert_eq!(
600            set1.skills()
601                .iter()
602                .find(|s| s.name == "weather")
603                .unwrap()
604                .description,
605            "Weather v2."
606        );
607    }
608
609    #[test]
610    fn load_real_agentskills_format() {
611        // Test with metadata field (should be ignored, we only parse name+description)
612        let tmp = TempDir::new().unwrap();
613        let skill_dir = tmp.path().join("nano-banana-pro");
614        fs::create_dir_all(&skill_dir).unwrap();
615        fs::write(
616            skill_dir.join("SKILL.md"),
617            r#"---
618name: nano-banana-pro
619description: Generate or edit images via Gemini 3 Pro Image.
620metadata:
621  {
622    "openclaw":
623      {
624        "emoji": "🍌",
625        "requires": { "bins": ["uv"], "env": ["GEMINI_API_KEY"] },
626      },
627  }
628---
629
630# Nano Banana Pro
631
632Use the bundled script to generate images.
633"#,
634        )
635        .unwrap();
636
637        let skills = SkillSet::load(&[tmp.path()]).unwrap();
638        assert_eq!(skills.len(), 1);
639        assert_eq!(skills.skills()[0].name, "nano-banana-pro");
640        assert_eq!(
641            skills.skills()[0].description,
642            "Generate or edit images via Gemini 3 Pro Image."
643        );
644    }
645}