Skip to main content

fluers_runtime/
skill.rs

1//! Skills — `SKILL.md` loading and packaged-skill directories.
2//!
3//! Mirrors Flue's skill loading (`Skill`, `PackagedSkillDirectory`, the
4//! `/.flue/packaged-skills/` convention).
5
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{RuntimeError, RuntimeResult};
12
13/// Where packaged skills live inside a project.
14pub const PACKAGED_SKILLS_ROOT: &str = "/.flue/packaged-skills/";
15
16/// A loaded skill.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Skill {
19    /// Skill name (from frontmatter `name`).
20    pub name: String,
21    /// One-line description (from frontmatter `description`).
22    pub description: String,
23    /// The full markdown body.
24    pub body: String,
25    /// Where it was loaded from.
26    pub source: PathBuf,
27}
28
29impl Skill {
30    /// Load a skill from a `SKILL.md` file.
31    ///
32    /// MVP: parses only `name:` and `description:` frontmatter keys; the full
33    /// frontmatter schema (triggers, model, etc.) lands later.
34    pub async fn load(path: impl AsRef<Path>) -> RuntimeResult<Arc<Self>> {
35        let path = path.as_ref();
36        let raw = tokio::fs::read_to_string(path)
37            .await
38            .map_err(RuntimeError::Io)?;
39        let (front, body) = split_frontmatter(&raw);
40        let name = front
41            .iter()
42            .find(|(k, _)| k == "name")
43            .map(|(_, v)| v.clone())
44            .unwrap_or_else(|| {
45                path.file_stem()
46                    .and_then(|s| s.to_str())
47                    .unwrap_or("skill")
48                    .to_string()
49            });
50        let description = front
51            .iter()
52            .find(|(k, _)| k == "description")
53            .map(|(_, v)| v.clone())
54            .unwrap_or_default();
55        Ok(Arc::new(Self {
56            name,
57            description,
58            body: body.to_string(),
59            source: path.to_path_buf(),
60        }))
61    }
62}
63
64/// Split `---\nkey: val\n---\nbody` into `(frontmatter pairs, body)`.
65pub(crate) fn split_frontmatter(raw: &str) -> (Vec<(String, String)>, &str) {
66    let raw = raw.strip_prefix("---\n").unwrap_or(raw);
67    let Some(end) = raw.find("\n---\n") else {
68        return (Vec::new(), raw);
69    };
70    let front = &raw[..end];
71    let body = &raw[end + "\n---\n".len()..];
72    let pairs = front
73        .lines()
74        .filter_map(|line| line.split_once(':'))
75        .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
76        .collect();
77    (pairs, body)
78}