Skip to main content

everruns_core/capabilities/
skill_contribution.rs

1//! Neutral skill-capability values shared by capability implementations.
2//!
3//! Concrete skill discovery and attachment capabilities live in
4//! `everruns-builtins`. Core retains the stable `skill:` identity namespace,
5//! mount contribution DTOs, and SKILL.md normalization used by declarative and
6//! custom capabilities.
7
8use crate::capability_types::{MountDirectoryBuilder, MountPoint};
9use everruns_capability::CapabilityId;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13/// Skill capability ID prefix.
14pub const SKILL_CAPABILITY_PREFIX: &str = "skill:";
15
16/// Default path for filesystem-based skill discovery.
17pub const SKILLS_DISCOVERY_PATH: &str = "/.agents/skills";
18
19/// Maximum number of skills in a single capability.
20pub const MAX_SKILLS_PER_CAPABILITY: usize = 50;
21
22/// Generate the stable capability ID for a skill.
23pub fn skill_capability_id(skill_id: Uuid) -> String {
24    format!("{SKILL_CAPABILITY_PREFIX}{skill_id}")
25}
26
27/// Check whether an ID uses the stable skill-capability namespace.
28pub fn is_skill_capability(capability_id: &str) -> bool {
29    capability_id.starts_with(SKILL_CAPABILITY_PREFIX)
30}
31
32/// Parse the skill UUID from a stable capability ID.
33pub fn parse_skill_capability_id(capability_id: &str) -> Option<Uuid> {
34    capability_id
35        .strip_prefix(SKILL_CAPABILITY_PREFIX)
36        .and_then(|value| Uuid::parse_str(value).ok())
37}
38
39/// Metadata for a discovered skill.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SkillMeta {
42    /// Skill name from SKILL.md frontmatter.
43    pub name: String,
44    /// Skill description from SKILL.md frontmatter.
45    pub description: String,
46    /// Source location.
47    pub source: SkillSource,
48    /// Whether this skill appears as a user slash command.
49    #[serde(default = "default_true")]
50    pub user_invocable: bool,
51    /// Whether the model is prevented from invoking this skill automatically.
52    #[serde(default)]
53    pub disable_model_invocation: bool,
54}
55
56fn default_true() -> bool {
57    true
58}
59
60/// Where a skill was discovered.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub enum SkillSource {
63    /// Discovered in the session filesystem.
64    Filesystem { path: String },
65    /// Loaded from a registry.
66    Registry { skill_id: String },
67}
68
69/// Full skill content loaded on activation.
70#[derive(Debug, Clone)]
71pub struct SkillInstructions {
72    /// Full SKILL.md body.
73    pub instructions: String,
74    /// Bundled files as path/content pairs.
75    pub files: Vec<(String, String)>,
76}
77
78/// A skill contributed by a capability in code.
79#[derive(Debug, Clone)]
80pub struct SkillContribution {
81    /// Skill name and mount-directory name.
82    pub name: String,
83    /// Short description shown in capability catalogs.
84    pub description: String,
85    /// SKILL.md instruction body.
86    pub instructions: String,
87    /// Bundled files mounted alongside SKILL.md.
88    pub files: Vec<(String, String)>,
89    /// Whether this skill is user-invocable.
90    pub user_invocable: bool,
91    /// Whether automatic model invocation is disabled.
92    pub disable_model_invocation: bool,
93}
94
95impl SkillContribution {
96    /// Create a contribution with user invocation enabled and model invocation allowed.
97    pub fn new(
98        name: impl Into<String>,
99        description: impl Into<String>,
100        instructions: impl Into<String>,
101    ) -> Self {
102        Self {
103            name: name.into(),
104            description: description.into(),
105            instructions: instructions.into(),
106            files: Vec::new(),
107            user_invocable: true,
108            disable_model_invocation: false,
109        }
110    }
111
112    /// Attach bundled files mounted alongside SKILL.md.
113    pub fn with_files(mut self, files: Vec<(String, String)>) -> Self {
114        self.files = files;
115        self
116    }
117
118    /// Set whether the skill is user-invocable.
119    pub fn with_user_invocable(mut self, flag: bool) -> Self {
120        self.user_invocable = flag;
121        self
122    }
123
124    /// Set whether model invocation is disabled.
125    pub fn with_disable_model_invocation(mut self, flag: bool) -> Self {
126        self.disable_model_invocation = flag;
127        self
128    }
129
130    /// Normalize the contribution into the read-only mount consumed by skill implementations.
131    pub fn to_mount(&self, owner_id: &str) -> MountPoint {
132        let skill_md = reconstruct_skill_md(
133            &self.name,
134            &self.description,
135            &self.instructions,
136            self.user_invocable,
137            self.disable_model_invocation,
138        );
139        let mut builder = MountDirectoryBuilder::new().file("SKILL.md", &skill_md);
140        for (path, content) in &self.files {
141            builder = builder.file(path, content);
142        }
143        MountPoint::readonly(
144            format!("{SKILLS_DISCOVERY_PATH}/{}", self.name),
145            builder.build(),
146            owner_id,
147        )
148    }
149}
150
151/// Reconstruct a canonical SKILL.md document from stored fields.
152pub fn reconstruct_skill_md(
153    name: &str,
154    description: &str,
155    instructions: &str,
156    user_invocable: bool,
157    disable_model_invocation: bool,
158) -> String {
159    // YAML serialization preserves escapes and line breaks in supplied metadata.
160    let safe_description = serde_yaml::to_string(description)
161        .expect("a string is serializable")
162        .trim_end_matches('\n')
163        .to_string();
164    let invocable_line = if user_invocable {
165        String::new()
166    } else {
167        "user-invocable: false\n".to_string()
168    };
169    let model_invocation_line = if disable_model_invocation {
170        "disable-model-invocation: true\n".to_string()
171    } else {
172        String::new()
173    };
174    format!(
175        "---\nname: {name}\ndescription: {safe_description}\n{invocable_line}{model_invocation_line}---\n\n{instructions}"
176    )
177}
178
179/// Parse SKILL.md files discovered in the session VFS.
180pub fn discover_skills_from_entries(
181    entries: &[(String, String)],
182) -> Vec<(SkillMeta, SkillInstructions)> {
183    let mut results = Vec::new();
184    for (path, content) in entries {
185        match crate::skill::parse_skill_md(content) {
186            Ok(parsed) => results.push((
187                SkillMeta {
188                    name: parsed.name,
189                    description: parsed.description,
190                    source: SkillSource::Filesystem { path: path.clone() },
191                    user_invocable: parsed.user_invocable,
192                    disable_model_invocation: parsed.disable_model_invocation,
193                },
194                SkillInstructions {
195                    instructions: parsed.instructions,
196                    files: Vec::new(),
197                },
198            )),
199            Err(errors) => tracing::warn!(
200                path = %path,
201                errors = ?errors,
202                "Skipping invalid SKILL.md"
203            ),
204        }
205    }
206    results
207}
208
209/// Stable `skill:` namespace helpers for [`CapabilityId`].
210pub trait SkillCapabilityIdExt: Sized {
211    /// Check whether this ID names a skill capability.
212    fn is_skill(&self) -> bool;
213    /// Create an ID for a skill.
214    fn skill(skill_id: Uuid) -> Self;
215    /// Parse the skill UUID from this ID.
216    fn skill_id(&self) -> Option<Uuid>;
217}
218
219impl SkillCapabilityIdExt for CapabilityId {
220    fn is_skill(&self) -> bool {
221        is_skill_capability(self.as_str())
222    }
223
224    fn skill(skill_id: Uuid) -> Self {
225        Self::new(skill_capability_id(skill_id))
226    }
227
228    fn skill_id(&self) -> Option<Uuid> {
229        parse_skill_capability_id(self.as_str())
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn skill_identity_uses_literal_namespace_and_rejects_invalid_ids() {
239        let uuid = Uuid::parse_str("12345678-1234-5678-9abc-123456789abc").unwrap();
240        let wire = "skill:12345678-1234-5678-9abc-123456789abc";
241        assert_eq!(skill_capability_id(uuid), wire);
242        let typed = CapabilityId::skill(uuid);
243        assert_eq!(typed.as_str(), wire);
244        assert!(typed.is_skill());
245        assert_eq!(typed.skill_id(), Some(uuid));
246        assert_eq!(parse_skill_capability_id(wire), Some(uuid));
247        for (candidate, namespace) in [
248            ("skill:", true),
249            ("skill:not-a-uuid", true),
250            ("skills:12345678-1234-5678-9abc-123456789abc", false),
251            ("mcp:docs", false),
252            ("", false),
253        ] {
254            assert_eq!(is_skill_capability(candidate), namespace);
255            assert_eq!(CapabilityId::new(candidate).is_skill(), namespace);
256            assert_eq!(parse_skill_capability_id(candidate), None);
257            assert_eq!(CapabilityId::new(candidate).skill_id(), None);
258        }
259    }
260
261    #[test]
262    fn contribution_mount_preserves_metadata_files_and_invocation_flags() {
263        use crate::capability_types::{MountAccess, MountSource};
264        for user in [false, true] {
265            for model_disabled in [false, true] {
266                let mount =
267                    SkillContribution::new("ops", "Operations", "Run safely.\nKeep exact body.")
268                        .with_files(vec![("reference.txt".into(), "Reference α".into())])
269                        .with_user_invocable(user)
270                        .with_disable_model_invocation(model_disabled)
271                        .to_mount("owner-42");
272                assert_eq!(mount.path, "/.agents/skills/ops");
273                assert_eq!(mount.capability_id, "owner-42");
274                assert_eq!(mount.access, MountAccess::ReadOnly);
275                let MountSource::InlineDirectory { entries } = mount.source else {
276                    panic!("expected directory")
277                };
278                assert_eq!(entries.len(), 2);
279                assert_eq!(
280                    entries["reference.txt"].source,
281                    MountSource::text_file("Reference α")
282                );
283                let MountSource::InlineFile { content, encoding } = &entries["SKILL.md"].source
284                else {
285                    panic!("expected SKILL.md")
286                };
287                assert_eq!(encoding, "text");
288                let parsed = crate::skill::parse_skill_md(content).unwrap();
289                assert_eq!(parsed.name, "ops");
290                assert_eq!(parsed.description, "Operations");
291                assert_eq!(parsed.instructions, "Run safely.\nKeep exact body.");
292                assert_eq!(parsed.user_invocable, user);
293                assert_eq!(parsed.disable_model_invocation, model_disabled);
294            }
295        }
296    }
297
298    #[test]
299    fn reconstruction_preserves_yaml_sensitive_description_text() {
300        for description in [
301            r#"Use "quotes" and C:\new\tools"#,
302            "Line one\nLine two",
303            "Carriage\rreturn\ttab",
304            "Before\n---\nafter",
305            "Unicode α\u{85}β\u{2028}γ\u{2029}δ",
306            "Backslash \\",
307        ] {
308            let content = reconstruct_skill_md("ops", description, "Instructions.", true, false);
309            let parsed = crate::skill::parse_skill_md(&content).unwrap();
310            assert_eq!(parsed.description, description, "{content}");
311            assert_eq!(parsed.instructions, "Instructions.");
312            assert!(parsed.user_invocable);
313            assert!(!parsed.disable_model_invocation);
314        }
315    }
316
317    #[test]
318    fn discovery_skips_invalid_entries_and_preserves_valid_content_and_source() {
319        let entries = vec![
320            ("/bad/SKILL.md".into(), "invalid".into()),
321            (
322                "/first/SKILL.md".into(),
323                reconstruct_skill_md("first", "First", "Body one", false, true),
324            ),
325            (
326                "/second/SKILL.md".into(),
327                reconstruct_skill_md("second", "Second", "Body two", true, false),
328            ),
329        ];
330        let found = discover_skills_from_entries(&entries);
331        assert_eq!(found.len(), 2);
332        for ((meta, instructions), (name, description, path, body, user, model_disabled)) in
333            found.iter().zip([
334                ("first", "First", "/first/SKILL.md", "Body one", false, true),
335                (
336                    "second",
337                    "Second",
338                    "/second/SKILL.md",
339                    "Body two",
340                    true,
341                    false,
342                ),
343            ])
344        {
345            assert_eq!(meta.name, name);
346            assert_eq!(meta.description, description);
347            assert_eq!(meta.source, SkillSource::Filesystem { path: path.into() });
348            assert_eq!(meta.user_invocable, user);
349            assert_eq!(meta.disable_model_invocation, model_disabled);
350            assert_eq!(instructions.instructions, body);
351            assert!(instructions.files.is_empty());
352        }
353    }
354    #[test]
355    fn skill_metadata_pins_both_source_wire_shapes_and_missing_flag_defaults() {
356        for (source, wire) in [
357            (
358                SkillSource::Registry {
359                    skill_id: "skill-42".into(),
360                },
361                serde_json::json!({"Registry":{"skill_id":"skill-42"}}),
362            ),
363            (
364                SkillSource::Filesystem {
365                    path: "/.agents/skills/ops/SKILL.md".into(),
366                },
367                serde_json::json!({"Filesystem":{"path":"/.agents/skills/ops/SKILL.md"}}),
368            ),
369        ] {
370            let meta = SkillMeta {
371                name: "ops".into(),
372                description: "Operations".into(),
373                source: source.clone(),
374                user_invocable: false,
375                disable_model_invocation: true,
376            };
377            let value = serde_json::json!({"name":"ops","description":"Operations","source":wire,"user_invocable":false,"disable_model_invocation":true});
378            assert_eq!(serde_json::to_value(&meta).unwrap(), value);
379            let parsed: SkillMeta = serde_json::from_value(value).unwrap();
380            assert_eq!(parsed.source, source);
381            assert!(!parsed.user_invocable);
382            assert!(parsed.disable_model_invocation);
383            let defaulted: SkillMeta = serde_json::from_value(
384                serde_json::json!({"name":"ops","description":"Operations","source":wire}),
385            )
386            .unwrap();
387            assert!(defaulted.user_invocable);
388            assert!(!defaulted.disable_model_invocation);
389        }
390    }
391}