Skip to main content

everruns_builtins/
attach_skill.rs

1// Attach Skill Virtual Capability
2//
3// Mounts a database-registered skill into the session VFS so that the
4// built-in SkillsCapability can discover it alongside user-uploaded skills.
5//
6// Design decisions:
7// - Follows MCP capability pattern: virtual capability wrapping external resources
8// - Capability ID format: "skill:{skill_uuid}" for registry-based skills
9// - Does NOT contribute to system prompt or provide tools — SkillsCapability
10//   handles discovery, prompt injection, and the activate_skill tool.
11// - Mounts reconstructed SKILL.md + bundled files to /.agents/skills/{name}/
12// - Depends on `session_file_system` for VFS mounting
13
14#[cfg(test)]
15use crate::capability_types::CapabilityStatus;
16use crate::capability_types::{MountDirectoryBuilder, MountPoint};
17
18use super::Capability;
19pub use everruns_core::capabilities::{
20    MAX_SKILLS_PER_CAPABILITY, SKILL_CAPABILITY_PREFIX, SKILLS_DISCOVERY_PATH,
21    SkillCapabilityIdExt, SkillContribution, SkillInstructions, SkillMeta, SkillSource,
22    discover_skills_from_entries, is_skill_capability, parse_skill_capability_id,
23    reconstruct_skill_md, skill_capability_id,
24};
25use uuid::Uuid;
26
27/// Attach Skill Virtual Capability.
28///
29/// Mounts a database-registered skill into `/.agents/skills/{name}/` in the
30/// session VFS. The built-in `SkillsCapability` then discovers and serves it
31/// through its `list_skills` / `activate_skill` tools.
32///
33/// This capability does NOT contribute to the system prompt or provide tools.
34#[derive(Debug, Clone)]
35pub struct AttachSkillCapability {
36    /// Unique capability ID: "skill:{uuid}"
37    capability_id: String,
38    /// Skill name (used for display + mount path)
39    skill_name: String,
40    /// Skill description (for display)
41    skill_description: String,
42    /// Reconstructed SKILL.md content (frontmatter + instructions)
43    skill_md_content: String,
44    /// Bundled files (path -> content)
45    files: Vec<(String, String)>,
46    /// Whether this skill is user-invocable as a /slash command
47    user_invocable: bool,
48    /// Whether the model is prevented from auto-invoking this skill
49    disable_model_invocation: bool,
50}
51
52impl AttachSkillCapability {
53    /// Create an attach capability for a registry-based skill.
54    ///
55    /// Reconstructs a valid SKILL.md and prepares mount points so that
56    /// SkillsCapability can discover the skill from the VFS.
57    pub fn from_registry(
58        skill_id: Uuid,
59        name: String,
60        description: String,
61        instructions: String,
62        files: Vec<(String, String)>,
63    ) -> Self {
64        Self::from_registry_with_options(
65            skill_id,
66            name,
67            description,
68            instructions,
69            files,
70            true,
71            false,
72        )
73    }
74
75    pub fn from_registry_with_invocable(
76        skill_id: Uuid,
77        name: String,
78        description: String,
79        instructions: String,
80        files: Vec<(String, String)>,
81        user_invocable: bool,
82    ) -> Self {
83        Self::from_registry_with_options(
84            skill_id,
85            name,
86            description,
87            instructions,
88            files,
89            user_invocable,
90            false,
91        )
92    }
93
94    pub fn from_registry_with_options(
95        skill_id: Uuid,
96        name: String,
97        description: String,
98        instructions: String,
99        files: Vec<(String, String)>,
100        user_invocable: bool,
101        disable_model_invocation: bool,
102    ) -> Self {
103        let skill_md_content = reconstruct_skill_md(
104            &name,
105            &description,
106            &instructions,
107            user_invocable,
108            disable_model_invocation,
109        );
110
111        Self {
112            capability_id: skill_capability_id(skill_id),
113            skill_name: name,
114            skill_description: description,
115            skill_md_content,
116            files,
117            user_invocable,
118            disable_model_invocation,
119        }
120    }
121
122    /// Get the skill name
123    pub fn skill_name(&self) -> &str {
124        &self.skill_name
125    }
126
127    /// Whether this skill is user-invocable as a /slash command
128    pub fn user_invocable(&self) -> bool {
129        self.user_invocable
130    }
131
132    /// Whether the model is prevented from auto-invoking this skill
133    pub fn disable_model_invocation(&self) -> bool {
134        self.disable_model_invocation
135    }
136
137    /// Build mount points for the skill directory.
138    ///
139    /// Mounts SKILL.md + bundled files under `/.agents/skills/{name}/`.
140    fn build_mounts(&self) -> Vec<MountPoint> {
141        let mut builder = MountDirectoryBuilder::new();
142        builder = builder.file("SKILL.md", &self.skill_md_content);
143
144        for (path, content) in &self.files {
145            builder = builder.file(path, content);
146        }
147
148        vec![MountPoint::readonly(
149            format!("{}/{}", SKILLS_DISCOVERY_PATH, self.skill_name),
150            builder.build(),
151            &self.capability_id,
152        )]
153    }
154}
155
156impl Capability for AttachSkillCapability {
157    fn id(&self) -> &str {
158        &self.capability_id
159    }
160
161    fn name(&self) -> &str {
162        &self.skill_name
163    }
164
165    fn description(&self) -> &str {
166        &self.skill_description
167    }
168
169    fn icon(&self) -> Option<&str> {
170        Some("wand")
171    }
172
173    fn category(&self) -> Option<&str> {
174        Some("Skills")
175    }
176
177    fn mounts(&self) -> Vec<MountPoint> {
178        self.build_mounts()
179    }
180
181    fn dependencies(&self) -> Vec<&'static str> {
182        vec!["session_file_system"]
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn registry_constructors_preserve_metadata_flags_and_complete_mounts() {
192        use crate::capability_types::MountSource;
193        let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
194        for variant in 0..6 {
195            let (user, disabled) = match variant {
196                0 => (true, false),
197                1 => (false, false),
198                2 => (false, false),
199                3 => (false, true),
200                4 => (true, false),
201                _ => (true, true),
202            };
203            let files = vec![
204                ("scripts/run.sh".into(), "echo α".into()),
205                ("reference.md".into(), "# Reference".into()),
206            ];
207            let cap = match variant {
208                0 => AttachSkillCapability::from_registry(
209                    id,
210                    "ops".into(),
211                    "Operations".into(),
212                    "Exact instructions.".into(),
213                    files,
214                ),
215                1 => AttachSkillCapability::from_registry_with_invocable(
216                    id,
217                    "ops".into(),
218                    "Operations".into(),
219                    "Exact instructions.".into(),
220                    files,
221                    false,
222                ),
223                _ => AttachSkillCapability::from_registry_with_options(
224                    id,
225                    "ops".into(),
226                    "Operations".into(),
227                    "Exact instructions.".into(),
228                    files,
229                    user,
230                    disabled,
231                ),
232            };
233            assert_eq!(cap.id(), "skill:550e8400-e29b-41d4-a716-446655440000");
234            assert_eq!(cap.name(), "ops");
235            assert_eq!(cap.skill_name(), "ops");
236            assert_eq!(cap.description(), "Operations");
237            assert_eq!(cap.status(), CapabilityStatus::Available);
238            assert_eq!(cap.icon(), Some("wand"));
239            assert_eq!(cap.category(), Some("Skills"));
240            assert_eq!(cap.user_invocable(), user);
241            assert_eq!(cap.disable_model_invocation(), disabled);
242            assert_eq!(cap.dependencies(), ["session_file_system"]);
243            assert!(cap.system_prompt_addition().is_none());
244            assert!(cap.tools().is_empty());
245            assert!(cap.tool_definitions().is_empty());
246            let mounts = cap.mounts();
247            assert_eq!(mounts.len(), 1);
248            assert_eq!(mounts[0].path, "/.agents/skills/ops");
249            assert_eq!(
250                mounts[0].capability_id,
251                "skill:550e8400-e29b-41d4-a716-446655440000"
252            );
253            assert!(mounts[0].is_readonly());
254            let MountSource::InlineDirectory { entries } = &mounts[0].source else {
255                panic!("expected directory")
256            };
257            assert_eq!(entries.len(), 3);
258            assert_eq!(
259                entries["scripts/run.sh"].source,
260                MountSource::text_file("echo α")
261            );
262            assert_eq!(
263                entries["reference.md"].source,
264                MountSource::text_file("# Reference")
265            );
266            let MountSource::InlineFile { content, encoding } = &entries["SKILL.md"].source else {
267                panic!("expected SKILL.md")
268            };
269            assert_eq!(encoding, "text");
270            let parsed = crate::skill::parse_skill_md(content).unwrap();
271            assert_eq!(parsed.name, "ops");
272            assert_eq!(parsed.description, "Operations");
273            assert_eq!(parsed.instructions, "Exact instructions.");
274            assert_eq!(parsed.user_invocable, user);
275            assert_eq!(parsed.disable_model_invocation, disabled);
276        }
277    }
278}