Skip to main content

everruns_core/capabilities/
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::{CapabilityId, MountDirectoryBuilder, MountPoint};
17
18use super::Capability;
19use serde::{Deserialize, Serialize};
20use uuid::Uuid;
21
22/// Skill capability ID prefix
23pub const SKILL_CAPABILITY_PREFIX: &str = "skill:";
24
25/// Default path for filesystem-based skill discovery
26pub const SKILLS_DISCOVERY_PATH: &str = "/.agents/skills";
27
28/// Maximum number of skills in a single capability
29pub const MAX_SKILLS_PER_CAPABILITY: usize = 50;
30
31/// Generate capability ID for a skill
32pub fn skill_capability_id(skill_id: Uuid) -> String {
33    format!("{}{}", SKILL_CAPABILITY_PREFIX, skill_id)
34}
35
36/// Check if a capability ID is a skill capability
37pub fn is_skill_capability(capability_id: &str) -> bool {
38    capability_id.starts_with(SKILL_CAPABILITY_PREFIX)
39}
40
41/// Parse skill UUID from capability ID
42pub fn parse_skill_capability_id(capability_id: &str) -> Option<Uuid> {
43    if !capability_id.starts_with(SKILL_CAPABILITY_PREFIX) {
44        return None;
45    }
46    let uuid_str = &capability_id[SKILL_CAPABILITY_PREFIX.len()..];
47    Uuid::parse_str(uuid_str).ok()
48}
49
50/// Metadata for a discovered skill (lightweight, for system prompt injection)
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SkillMeta {
53    /// Skill name (from SKILL.md frontmatter)
54    pub name: String,
55    /// Skill description (from SKILL.md frontmatter)
56    pub description: String,
57    /// Source location (filesystem path or "registry")
58    pub source: SkillSource,
59    /// Whether this skill appears as a /slash command for users
60    #[serde(default = "default_true")]
61    pub user_invocable: bool,
62    /// Whether the model is prevented from auto-invoking this skill
63    #[serde(default)]
64    pub disable_model_invocation: bool,
65}
66
67fn default_true() -> bool {
68    true
69}
70
71/// Where a skill was discovered from
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub enum SkillSource {
74    /// Discovered from `.agents/skills/` in session filesystem
75    Filesystem { path: String },
76    /// Loaded from the database-backed registry
77    Registry { skill_id: String },
78}
79
80/// Full skill content loaded on activation
81#[derive(Debug, Clone)]
82pub struct SkillInstructions {
83    /// Full SKILL.md body (markdown instructions)
84    pub instructions: String,
85    /// Bundled files (path -> content), for VFS mounting
86    pub files: Vec<(String, String)>,
87}
88
89/// A skill contributed by a capability in code.
90///
91/// During session startup, contributions are normalized into mount points under
92/// `/.agents/skills/{name}/` so the built-in `SkillsCapability` discovers them
93/// alongside user-uploaded and registry-based skills. This reuses the existing
94/// discovery, prompt listing, and activation path rather than introducing a
95/// parallel skill pipeline.
96#[derive(Debug, Clone)]
97pub struct SkillContribution {
98    /// Skill name — also used as the mount directory name.
99    pub name: String,
100    /// Short description shown in the skill list and prompt.
101    pub description: String,
102    /// SKILL.md body (markdown instructions).
103    pub instructions: String,
104    /// Bundled files mounted alongside SKILL.md (path -> content).
105    pub files: Vec<(String, String)>,
106    /// Whether this skill is user-invocable as a /slash command.
107    pub user_invocable: bool,
108    /// Whether the model is prevented from auto-invoking this skill.
109    pub disable_model_invocation: bool,
110}
111
112impl SkillContribution {
113    /// Create a new skill contribution with default flags
114    /// (`user_invocable = true`, `disable_model_invocation = false`).
115    pub fn new(
116        name: impl Into<String>,
117        description: impl Into<String>,
118        instructions: impl Into<String>,
119    ) -> Self {
120        Self {
121            name: name.into(),
122            description: description.into(),
123            instructions: instructions.into(),
124            files: Vec::new(),
125            user_invocable: true,
126            disable_model_invocation: false,
127        }
128    }
129
130    /// Attach bundled files that will be mounted alongside SKILL.md.
131    pub fn with_files(mut self, files: Vec<(String, String)>) -> Self {
132        self.files = files;
133        self
134    }
135
136    /// Set whether the skill is user-invocable as a /slash command.
137    pub fn with_user_invocable(mut self, flag: bool) -> Self {
138        self.user_invocable = flag;
139        self
140    }
141
142    /// Set whether the model is prevented from auto-invoking this skill.
143    pub fn with_disable_model_invocation(mut self, flag: bool) -> Self {
144        self.disable_model_invocation = flag;
145        self
146    }
147
148    /// Build a read-only mount at `/.agents/skills/{name}/` containing the
149    /// reconstructed `SKILL.md` and all bundled files. `owner_id` is recorded
150    /// as the mount's owning capability, typically the contributing capability's
151    /// ID.
152    pub fn to_mount(&self, owner_id: &str) -> MountPoint {
153        let skill_md = reconstruct_skill_md(
154            &self.name,
155            &self.description,
156            &self.instructions,
157            self.user_invocable,
158            self.disable_model_invocation,
159        );
160        let mut builder = MountDirectoryBuilder::new();
161        builder = builder.file("SKILL.md", &skill_md);
162        for (path, content) in &self.files {
163            builder = builder.file(path, content);
164        }
165        MountPoint::readonly(
166            format!("{}/{}", SKILLS_DISCOVERY_PATH, self.name),
167            builder.build(),
168            owner_id,
169        )
170    }
171}
172
173/// Attach Skill Virtual Capability.
174///
175/// Mounts a database-registered skill into `/.agents/skills/{name}/` in the
176/// session VFS. The built-in `SkillsCapability` then discovers and serves it
177/// through its `list_skills` / `activate_skill` tools.
178///
179/// This capability does NOT contribute to the system prompt or provide tools.
180#[derive(Debug, Clone)]
181pub struct AttachSkillCapability {
182    /// Unique capability ID: "skill:{uuid}"
183    capability_id: String,
184    /// Skill name (used for display + mount path)
185    skill_name: String,
186    /// Skill description (for display)
187    skill_description: String,
188    /// Reconstructed SKILL.md content (frontmatter + instructions)
189    skill_md_content: String,
190    /// Bundled files (path -> content)
191    files: Vec<(String, String)>,
192    /// Whether this skill is user-invocable as a /slash command
193    user_invocable: bool,
194    /// Whether the model is prevented from auto-invoking this skill
195    disable_model_invocation: bool,
196}
197
198impl AttachSkillCapability {
199    /// Create an attach capability for a registry-based skill.
200    ///
201    /// Reconstructs a valid SKILL.md and prepares mount points so that
202    /// SkillsCapability can discover the skill from the VFS.
203    pub fn from_registry(
204        skill_id: Uuid,
205        name: String,
206        description: String,
207        instructions: String,
208        files: Vec<(String, String)>,
209    ) -> Self {
210        Self::from_registry_with_options(
211            skill_id,
212            name,
213            description,
214            instructions,
215            files,
216            true,
217            false,
218        )
219    }
220
221    pub fn from_registry_with_invocable(
222        skill_id: Uuid,
223        name: String,
224        description: String,
225        instructions: String,
226        files: Vec<(String, String)>,
227        user_invocable: bool,
228    ) -> Self {
229        Self::from_registry_with_options(
230            skill_id,
231            name,
232            description,
233            instructions,
234            files,
235            user_invocable,
236            false,
237        )
238    }
239
240    pub fn from_registry_with_options(
241        skill_id: Uuid,
242        name: String,
243        description: String,
244        instructions: String,
245        files: Vec<(String, String)>,
246        user_invocable: bool,
247        disable_model_invocation: bool,
248    ) -> Self {
249        let skill_md_content = reconstruct_skill_md(
250            &name,
251            &description,
252            &instructions,
253            user_invocable,
254            disable_model_invocation,
255        );
256
257        Self {
258            capability_id: skill_capability_id(skill_id),
259            skill_name: name,
260            skill_description: description,
261            skill_md_content,
262            files,
263            user_invocable,
264            disable_model_invocation,
265        }
266    }
267
268    /// Get the skill name
269    pub fn skill_name(&self) -> &str {
270        &self.skill_name
271    }
272
273    /// Whether this skill is user-invocable as a /slash command
274    pub fn user_invocable(&self) -> bool {
275        self.user_invocable
276    }
277
278    /// Whether the model is prevented from auto-invoking this skill
279    pub fn disable_model_invocation(&self) -> bool {
280        self.disable_model_invocation
281    }
282
283    /// Build mount points for the skill directory.
284    ///
285    /// Mounts SKILL.md + bundled files under `/.agents/skills/{name}/`.
286    fn build_mounts(&self) -> Vec<MountPoint> {
287        let mut builder = MountDirectoryBuilder::new();
288        builder = builder.file("SKILL.md", &self.skill_md_content);
289
290        for (path, content) in &self.files {
291            builder = builder.file(path, content);
292        }
293
294        vec![MountPoint::readonly(
295            format!("{}/{}", SKILLS_DISCOVERY_PATH, self.skill_name),
296            builder.build(),
297            &self.capability_id,
298        )]
299    }
300}
301
302impl Capability for AttachSkillCapability {
303    fn id(&self) -> &str {
304        Box::leak(self.capability_id.clone().into_boxed_str())
305    }
306
307    fn name(&self) -> &str {
308        Box::leak(self.skill_name.clone().into_boxed_str())
309    }
310
311    fn description(&self) -> &str {
312        Box::leak(self.skill_description.clone().into_boxed_str())
313    }
314
315    fn icon(&self) -> Option<&str> {
316        Some("wand")
317    }
318
319    fn category(&self) -> Option<&str> {
320        Some("Skills")
321    }
322
323    fn mounts(&self) -> Vec<MountPoint> {
324        self.build_mounts()
325    }
326
327    fn dependencies(&self) -> Vec<&'static str> {
328        vec!["session_file_system"]
329    }
330}
331
332/// Reconstruct a valid SKILL.md from stored fields.
333///
334/// Produces content that `parse_skill_md` can round-trip:
335/// ```text
336/// ---
337/// name: skill-name
338/// description: "Skill description here."
339/// ---
340///
341/// <instructions body>
342/// ```
343pub fn reconstruct_skill_md(
344    name: &str,
345    description: &str,
346    instructions: &str,
347    user_invocable: bool,
348    disable_model_invocation: bool,
349) -> String {
350    // Quote description to handle YAML-special characters (:, #, etc.)
351    let safe_description = format!("\"{}\"", description.replace('"', "\\\""));
352    let invocable_line = if user_invocable {
353        String::new()
354    } else {
355        "user-invocable: false\n".to_string()
356    };
357    let model_invocation_line = if disable_model_invocation {
358        "disable-model-invocation: true\n".to_string()
359    } else {
360        String::new()
361    };
362    format!(
363        "---\nname: {name}\ndescription: {safe_description}\n{invocable_line}{model_invocation_line}---\n\n{instructions}"
364    )
365}
366
367/// Parse SKILL.md files from a list of (path, content) entries discovered in the session VFS.
368///
369/// Each entry represents a directory under `.agents/skills/` containing a SKILL.md file.
370/// Returns parsed skill metadata and instructions for registration.
371pub fn discover_skills_from_entries(
372    entries: &[(String, String)],
373) -> Vec<(SkillMeta, SkillInstructions)> {
374    let mut results = Vec::new();
375
376    for (path, content) in entries {
377        match crate::skill::parse_skill_md(content) {
378            Ok(parsed) => {
379                let meta = SkillMeta {
380                    name: parsed.name.clone(),
381                    description: parsed.description.clone(),
382                    source: SkillSource::Filesystem { path: path.clone() },
383                    user_invocable: parsed.user_invocable,
384                    disable_model_invocation: parsed.disable_model_invocation,
385                };
386                let instructions = SkillInstructions {
387                    instructions: parsed.instructions,
388                    files: vec![], // Filesystem skills don't bundle files via this path
389                };
390                results.push((meta, instructions));
391            }
392            Err(errors) => {
393                tracing::warn!(
394                    path = %path,
395                    errors = ?errors,
396                    "Skipping invalid SKILL.md"
397                );
398            }
399        }
400    }
401
402    results
403}
404
405/// CapabilityId helpers for skill capabilities
406impl CapabilityId {
407    /// Check if this capability ID is for a skill
408    pub fn is_skill(&self) -> bool {
409        is_skill_capability(self.as_str())
410    }
411
412    /// Create a capability ID for a skill
413    pub fn skill(skill_id: Uuid) -> Self {
414        Self::new(skill_capability_id(skill_id))
415    }
416
417    /// Parse skill UUID from this capability ID
418    pub fn skill_id(&self) -> Option<Uuid> {
419        parse_skill_capability_id(self.as_str())
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_skill_capability_id() {
429        let skill_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
430        let cap_id = skill_capability_id(skill_id);
431        assert_eq!(cap_id, "skill:550e8400-e29b-41d4-a716-446655440000");
432    }
433
434    #[test]
435    fn test_is_skill_capability() {
436        assert!(is_skill_capability(
437            "skill:550e8400-e29b-41d4-a716-446655440000"
438        ));
439        assert!(!is_skill_capability("current_time"));
440        assert!(!is_skill_capability(
441            "mcp:550e8400-e29b-41d4-a716-446655440000"
442        ));
443        assert!(!is_skill_capability("skills")); // aggregate ID
444    }
445
446    #[test]
447    fn test_parse_skill_capability_id() {
448        let skill_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
449        let cap_id = skill_capability_id(skill_id);
450        let parsed = parse_skill_capability_id(&cap_id);
451        assert_eq!(parsed, Some(skill_id));
452
453        assert_eq!(parse_skill_capability_id("current_time"), None);
454        assert_eq!(parse_skill_capability_id("skill:invalid"), None);
455    }
456
457    #[test]
458    fn test_capability_id_skill_methods() {
459        let skill_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
460        let cap_id = CapabilityId::skill(skill_id);
461
462        assert!(cap_id.is_skill());
463        assert_eq!(cap_id.skill_id(), Some(skill_id));
464
465        let regular_cap = CapabilityId::new("current_time");
466        assert!(!regular_cap.is_skill());
467        assert_eq!(regular_cap.skill_id(), None);
468    }
469
470    #[test]
471    fn test_attach_skill_from_registry() {
472        let skill_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
473        let cap = AttachSkillCapability::from_registry(
474            skill_id,
475            "pdf-processing".to_string(),
476            "Extract text from PDFs".to_string(),
477            "# Instructions\nUse pdfplumber.".to_string(),
478            vec![(
479                "scripts/extract.py".to_string(),
480                "print('hello')".to_string(),
481            )],
482        );
483
484        assert_eq!(cap.id(), "skill:550e8400-e29b-41d4-a716-446655440000");
485        assert_eq!(cap.name(), "pdf-processing");
486        assert_eq!(cap.status(), CapabilityStatus::Available);
487        assert_eq!(cap.icon(), Some("wand"));
488        assert_eq!(cap.category(), Some("Skills"));
489    }
490
491    #[test]
492    fn test_attach_skill_no_system_prompt() {
493        let skill_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
494        let cap = AttachSkillCapability::from_registry(
495            skill_id,
496            "test-skill".to_string(),
497            "A test".to_string(),
498            "# Instructions".to_string(),
499            vec![],
500        );
501
502        assert!(cap.system_prompt_addition().is_none());
503    }
504
505    #[test]
506    fn test_attach_skill_no_tools() {
507        let skill_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
508        let cap = AttachSkillCapability::from_registry(
509            skill_id,
510            "test-skill".to_string(),
511            "A test".to_string(),
512            "# Instructions".to_string(),
513            vec![],
514        );
515
516        assert!(cap.tools().is_empty());
517        assert!(cap.tool_definitions().is_empty());
518    }
519
520    #[test]
521    fn test_attach_skill_mounts_skill_md() {
522        let skill_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
523        let cap = AttachSkillCapability::from_registry(
524            skill_id,
525            "pdf-tool".to_string(),
526            "Extract text from PDFs".to_string(),
527            "# Instructions\nUse pdfplumber.".to_string(),
528            vec![],
529        );
530
531        let mounts = cap.mounts();
532        assert_eq!(mounts.len(), 1);
533        assert_eq!(mounts[0].path, "/.agents/skills/pdf-tool");
534        assert!(mounts[0].is_readonly());
535    }
536
537    #[test]
538    fn test_attach_skill_mounts_with_files() {
539        let skill_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
540        let cap = AttachSkillCapability::from_registry(
541            skill_id,
542            "data-skill".to_string(),
543            "Analyze data".to_string(),
544            "# Instructions".to_string(),
545            vec![
546                ("scripts/run.py".to_string(), "print('hi')".to_string()),
547                ("references/REF.md".to_string(), "# Ref".to_string()),
548            ],
549        );
550
551        let mounts = cap.mounts();
552        assert_eq!(mounts.len(), 1);
553        assert_eq!(mounts[0].path, "/.agents/skills/data-skill");
554
555        // Verify directory contains SKILL.md + bundled files
556        use crate::capability_types::MountSource;
557        match &mounts[0].source {
558            MountSource::InlineDirectory { entries } => {
559                assert!(entries.contains_key("SKILL.md"));
560                assert!(entries.contains_key("scripts/run.py"));
561                assert!(entries.contains_key("references/REF.md"));
562                assert_eq!(entries.len(), 3);
563            }
564            _ => panic!("Expected InlineDirectory"),
565        }
566    }
567
568    #[test]
569    fn test_reconstruct_skill_md_roundtrips() {
570        let content = reconstruct_skill_md(
571            "test-skill",
572            "A test skill",
573            "# Instructions\nDo the thing.",
574            true,
575            false,
576        );
577
578        // Should be parseable by parse_skill_md
579        let parsed = crate::skill::parse_skill_md(&content).unwrap();
580        assert_eq!(parsed.name, "test-skill");
581        assert_eq!(parsed.description, "A test skill");
582        assert!(parsed.instructions.contains("# Instructions"));
583        assert!(parsed.user_invocable);
584    }
585
586    #[test]
587    fn test_reconstruct_skill_md_escapes_description() {
588        let content = reconstruct_skill_md(
589            "test-skill",
590            "Description with: colons and \"quotes\"",
591            "# Body",
592            true,
593            false,
594        );
595
596        let parsed = crate::skill::parse_skill_md(&content).unwrap();
597        assert_eq!(parsed.name, "test-skill");
598        assert_eq!(
599            parsed.description,
600            "Description with: colons and \"quotes\""
601        );
602    }
603
604    #[test]
605    fn test_reconstruct_skill_md_not_invocable() {
606        let content =
607            reconstruct_skill_md("bg-skill", "Background context", "# Body", false, false);
608
609        let parsed = crate::skill::parse_skill_md(&content).unwrap();
610        assert_eq!(parsed.name, "bg-skill");
611        assert!(!parsed.user_invocable);
612    }
613
614    #[test]
615    fn test_reconstruct_skill_md_disable_model_invocation() {
616        let content = reconstruct_skill_md("manual-skill", "Manual only", "# Body", true, true);
617
618        let parsed = crate::skill::parse_skill_md(&content).unwrap();
619        assert_eq!(parsed.name, "manual-skill");
620        assert!(parsed.user_invocable);
621        assert!(parsed.disable_model_invocation);
622    }
623
624    #[test]
625    fn test_reconstruct_skill_md_both_flags() {
626        let content = reconstruct_skill_md("both-flags", "Both flags set", "# Body", false, true);
627
628        let parsed = crate::skill::parse_skill_md(&content).unwrap();
629        assert!(!parsed.user_invocable);
630        assert!(parsed.disable_model_invocation);
631    }
632
633    #[test]
634    fn test_attach_skill_with_disable_model_invocation() {
635        let skill_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
636        let cap = AttachSkillCapability::from_registry_with_options(
637            skill_id,
638            "manual-skill".to_string(),
639            "Manual only".to_string(),
640            "# Instructions".to_string(),
641            vec![],
642            true,
643            true,
644        );
645
646        assert_eq!(cap.name(), "manual-skill");
647        assert!(cap.user_invocable());
648    }
649
650    #[test]
651    fn test_attach_skill_dependencies() {
652        let cap = AttachSkillCapability::from_registry(
653            Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
654            "test".to_string(),
655            "test".to_string(),
656            "body".to_string(),
657            vec![],
658        );
659        assert_eq!(cap.dependencies(), vec!["session_file_system"]);
660    }
661
662    #[test]
663    fn test_skill_meta_serialization() {
664        let meta = SkillMeta {
665            name: "test-skill".to_string(),
666            description: "A test".to_string(),
667            source: SkillSource::Registry {
668                skill_id: "abc".to_string(),
669            },
670            user_invocable: true,
671            disable_model_invocation: false,
672        };
673
674        let json = serde_json::to_string(&meta).unwrap();
675        assert!(json.contains("test-skill"));
676
677        let parsed: SkillMeta = serde_json::from_str(&json).unwrap();
678        assert_eq!(parsed.name, "test-skill");
679    }
680
681    fn inline_file_content<'a>(
682        entries: &'a std::collections::HashMap<String, crate::capability_types::MountEntry>,
683        name: &str,
684    ) -> &'a str {
685        use crate::capability_types::MountSource;
686        match &entries.get(name).expect("entry missing").source {
687            MountSource::InlineFile { content, .. } => content.as_str(),
688            _ => panic!("Expected InlineFile for {name}"),
689        }
690    }
691
692    #[test]
693    fn test_skill_contribution_to_mount_basic() {
694        let contribution = SkillContribution::new(
695            "search-playbook",
696            "Run a structured code search playbook",
697            "# Playbook\n1. Grep for symbol\n2. Read hits\n",
698        );
699
700        let mount = contribution.to_mount("cap:owner");
701
702        assert_eq!(mount.path, "/.agents/skills/search-playbook");
703        assert_eq!(mount.capability_id, "cap:owner");
704        assert!(mount.is_readonly());
705
706        use crate::capability_types::MountSource;
707        match &mount.source {
708            MountSource::InlineDirectory { entries } => {
709                let skill_md = inline_file_content(entries, "SKILL.md");
710                let parsed = crate::skill::parse_skill_md(skill_md).unwrap();
711                assert_eq!(parsed.name, "search-playbook");
712                assert_eq!(parsed.description, "Run a structured code search playbook");
713                assert!(parsed.user_invocable);
714                assert!(!parsed.disable_model_invocation);
715                assert!(parsed.instructions.contains("# Playbook"));
716                assert_eq!(entries.len(), 1);
717            }
718            _ => panic!("Expected InlineDirectory"),
719        }
720    }
721
722    #[test]
723    fn test_skill_contribution_to_mount_with_files_and_flags() {
724        let contribution = SkillContribution::new("ops", "Ops runbook", "# Ops\nRun the thing.")
725            .with_files(vec![
726                (
727                    "scripts/run.sh".to_string(),
728                    "#!/bin/sh\necho hi\n".to_string(),
729                ),
730                ("README.md".to_string(), "# Ops README".to_string()),
731            ])
732            .with_user_invocable(false)
733            .with_disable_model_invocation(true);
734
735        let mount = contribution.to_mount("gpt_image_gen");
736
737        use crate::capability_types::MountSource;
738        match &mount.source {
739            MountSource::InlineDirectory { entries } => {
740                assert_eq!(entries.len(), 3);
741                assert!(entries.contains_key("SKILL.md"));
742                assert!(entries.contains_key("scripts/run.sh"));
743                assert!(entries.contains_key("README.md"));
744
745                let parsed =
746                    crate::skill::parse_skill_md(inline_file_content(entries, "SKILL.md")).unwrap();
747                assert!(!parsed.user_invocable);
748                assert!(parsed.disable_model_invocation);
749            }
750            _ => panic!("Expected InlineDirectory"),
751        }
752    }
753
754    #[test]
755    fn test_discover_skills_from_entries() {
756        let entries = vec![
757            (
758                "/.agents/skills/test-skill".to_string(),
759                "---\nname: test-skill\ndescription: A test.\n---\n\n# Instructions\nDo things."
760                    .to_string(),
761            ),
762            (
763                "/.agents/skills/bad-skill".to_string(),
764                "no frontmatter here".to_string(),
765            ),
766        ];
767
768        let results = discover_skills_from_entries(&entries);
769        assert_eq!(results.len(), 1);
770        assert_eq!(results[0].0.name, "test-skill");
771        assert_eq!(results[0].0.description, "A test.");
772        assert!(results[0].1.instructions.contains("# Instructions"));
773    }
774}