tirea_extension_skills/
embedded_registry.rs

1//! Embedded skill — skills loaded from in-memory content at compile time.
2//!
3//! This is the compile-time counterpart to [`FsSkill`]. Instead of discovering
4//! skills from the filesystem, skills are provided as static string slices
5//! (typically via `include_str!`). The skill parses and validates at construction
6//! time, so any errors are caught early.
7//!
8//! Scripts are **not** supported because there is no filesystem to execute from;
9//! calling `run_script` returns [`SkillError::Unsupported`].
10//!
11//! # Example
12//!
13//! ```ignore
14//! use tirea::extensions::skills::{EmbeddedSkillData, EmbeddedSkill, SkillSubsystem};
15//!
16//! static SKILLS: &[EmbeddedSkillData] = &[
17//!     EmbeddedSkillData {
18//!         skill_md: include_str!("../skills/my-skill/SKILL.md"),
19//!         references: &[
20//!             ("references/guide.md", include_str!("../skills/my-skill/references/guide.md")),
21//!         ],
22//!         assets: &[],
23//!     },
24//! ];
25//!
26//! let skills: Vec<EmbeddedSkill> = SKILLS.iter().map(|d| EmbeddedSkill::new(d).unwrap()).collect();
27//! ```
28
29use crate::skill_md::{parse_allowed_tools, parse_skill_md};
30use crate::{
31    LoadedAsset, LoadedReference, ScriptResult, Skill, SkillError, SkillMeta, SkillResource,
32    SkillResourceKind,
33};
34use async_trait::async_trait;
35use base64::Engine as _;
36use sha2::{Digest, Sha256};
37use std::collections::HashMap;
38use std::sync::Arc;
39
40/// Static data for an embedded skill, designed for `include_str!`.
41pub struct EmbeddedSkillData {
42    /// Raw `SKILL.md` content (YAML frontmatter + markdown body).
43    pub skill_md: &'static str,
44    /// Reference files as `(relative_path, content)` pairs.
45    pub references: &'static [(&'static str, &'static str)],
46    /// Asset files as `(relative_path, content_base64, media_type)` tuples.
47    pub assets: &'static [(&'static str, &'static str, Option<&'static str>)],
48}
49
50/// An in-memory skill built from static content.
51///
52/// Implements [`Skill`] directly. Constructed via [`EmbeddedSkill::new`] which
53/// parses and validates the SKILL.md at construction time.
54#[derive(Debug, Clone)]
55pub struct EmbeddedSkill {
56    meta: SkillMeta,
57    skill_md: String,
58    references: HashMap<String, LoadedReference>,
59    assets: HashMap<String, LoadedAsset>,
60}
61
62impl EmbeddedSkill {
63    /// Build an embedded skill from static data.
64    ///
65    /// Parses and validates the SKILL.md content. Returns an error if the
66    /// content is invalid or if base64 asset decoding fails.
67    pub fn new(data: &EmbeddedSkillData) -> Result<Self, SkillError> {
68        let doc =
69            parse_skill_md(data.skill_md).map_err(|e| SkillError::InvalidSkillMd(e.to_string()))?;
70
71        let fm = &doc.frontmatter;
72        let id = fm.name.clone();
73
74        let allowed_tools = fm
75            .allowed_tools
76            .as_deref()
77            .map(parse_allowed_tools)
78            .transpose()
79            .map_err(|e| SkillError::InvalidSkillMd(e.to_string()))?
80            .unwrap_or_default()
81            .into_iter()
82            .map(|t| t.raw)
83            .collect::<Vec<_>>();
84
85        let meta = SkillMeta {
86            id: id.clone(),
87            name: id.clone(),
88            description: fm.description.clone(),
89            allowed_tools,
90        };
91
92        let mut references = HashMap::new();
93        for &(path, content) in data.references {
94            let sha = format!("{:x}", Sha256::digest(content.as_bytes()));
95            references.insert(
96                path.to_string(),
97                LoadedReference {
98                    skill: id.clone(),
99                    path: path.to_string(),
100                    sha256: sha,
101                    truncated: false,
102                    content: content.to_string(),
103                    bytes: content.len() as u64,
104                },
105            );
106        }
107
108        let mut assets = HashMap::new();
109        for &(path, content_base64, media_type) in data.assets {
110            let decoded = base64::engine::general_purpose::STANDARD
111                .decode(content_base64.as_bytes())
112                .map_err(|e| {
113                    SkillError::InvalidSkillMd(format!(
114                        "invalid base64 asset '{path}' for skill '{id}': {e}"
115                    ))
116                })?;
117            let sha = format!("{:x}", Sha256::digest(&decoded));
118            assets.insert(
119                path.to_string(),
120                LoadedAsset {
121                    skill: id.clone(),
122                    path: path.to_string(),
123                    sha256: sha,
124                    truncated: false,
125                    bytes: decoded.len() as u64,
126                    media_type: media_type.map(|m| m.to_string()),
127                    encoding: "base64".to_string(),
128                    content: content_base64.to_string(),
129                },
130            );
131        }
132
133        Ok(Self {
134            meta,
135            skill_md: data.skill_md.to_string(),
136            references,
137            assets,
138        })
139    }
140
141    /// Construct multiple embedded skills from static data, collecting into trait objects.
142    pub fn from_static_slice(
143        data: &[EmbeddedSkillData],
144    ) -> Result<Vec<Arc<dyn Skill>>, SkillError> {
145        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
146        let mut skills: Vec<Arc<dyn Skill>> = Vec::new();
147        for d in data {
148            let skill = Self::new(d)?;
149            if !seen.insert(skill.meta.id.clone()) {
150                return Err(SkillError::DuplicateSkillId(skill.meta.id));
151            }
152            skills.push(Arc::new(skill));
153        }
154        Ok(skills)
155    }
156}
157
158#[async_trait]
159impl Skill for EmbeddedSkill {
160    fn meta(&self) -> &SkillMeta {
161        &self.meta
162    }
163
164    async fn read_instructions(&self) -> Result<String, SkillError> {
165        Ok(self.skill_md.clone())
166    }
167
168    async fn load_resource(
169        &self,
170        kind: SkillResourceKind,
171        path: &str,
172    ) -> Result<SkillResource, SkillError> {
173        match kind {
174            SkillResourceKind::Reference => self
175                .references
176                .get(path)
177                .cloned()
178                .map(SkillResource::Reference)
179                .ok_or_else(|| SkillError::Unsupported(format!("reference not available: {path}"))),
180            SkillResourceKind::Asset => self
181                .assets
182                .get(path)
183                .cloned()
184                .map(SkillResource::Asset)
185                .ok_or_else(|| SkillError::Unsupported(format!("asset not available: {path}"))),
186        }
187    }
188
189    async fn run_script(&self, script: &str, _args: &[String]) -> Result<ScriptResult, SkillError> {
190        Err(SkillError::Unsupported(format!(
191            "embedded skills do not support script execution: {script}"
192        )))
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    const VALID_SKILL_MD: &str = "\
201---
202name: test-skill
203description: A test skill for unit testing.
204allowed-tools: read_file write_file
205---
206# Test Skill
207
208Follow these instructions to do testing.
209";
210
211    const VALID_SKILL_MD_2: &str = "\
212---
213name: another-skill
214description: Another test skill.
215---
216# Another
217
218More instructions.
219";
220
221    const REFERENCE_CONTENT: &str = "# Guide\n\nSome reference material.\n";
222
223    #[test]
224    fn new_parses_valid_skill() {
225        let data = EmbeddedSkillData {
226            skill_md: VALID_SKILL_MD,
227            references: &[("references/guide.md", REFERENCE_CONTENT)],
228            assets: &[],
229        };
230
231        let skill = EmbeddedSkill::new(&data).unwrap();
232        assert_eq!(skill.meta.id, "test-skill");
233        assert_eq!(skill.meta.name, "test-skill");
234        assert_eq!(skill.meta.description, "A test skill for unit testing.");
235        assert_eq!(
236            skill.meta.allowed_tools,
237            vec!["read_file".to_string(), "write_file".to_string()]
238        );
239    }
240
241    #[test]
242    fn new_rejects_invalid_skill_md() {
243        let data = EmbeddedSkillData {
244            skill_md: "not valid frontmatter",
245            references: &[],
246            assets: &[],
247        };
248
249        let err = EmbeddedSkill::new(&data).unwrap_err();
250        assert!(matches!(err, SkillError::InvalidSkillMd(_)));
251    }
252
253    #[test]
254    fn from_static_slice_rejects_duplicate_ids() {
255        let data = &[
256            EmbeddedSkillData {
257                skill_md: VALID_SKILL_MD,
258                references: &[],
259                assets: &[],
260            },
261            EmbeddedSkillData {
262                skill_md: VALID_SKILL_MD,
263                references: &[],
264                assets: &[],
265            },
266        ];
267
268        let err = EmbeddedSkill::from_static_slice(data).unwrap_err();
269        assert!(matches!(err, SkillError::DuplicateSkillId(ref id) if id == "test-skill"));
270    }
271
272    #[test]
273    fn from_static_slice_sorts_not_required_by_caller() {
274        let data = &[
275            EmbeddedSkillData {
276                skill_md: VALID_SKILL_MD,
277                references: &[],
278                assets: &[],
279            },
280            EmbeddedSkillData {
281                skill_md: VALID_SKILL_MD_2,
282                references: &[],
283                assets: &[],
284            },
285        ];
286
287        let skills = EmbeddedSkill::from_static_slice(data).unwrap();
288        assert_eq!(skills.len(), 2);
289    }
290
291    #[tokio::test]
292    async fn read_instructions_returns_raw_content() {
293        let data = EmbeddedSkillData {
294            skill_md: VALID_SKILL_MD,
295            references: &[],
296            assets: &[],
297        };
298        let skill = EmbeddedSkill::new(&data).unwrap();
299
300        let md = skill.read_instructions().await.unwrap();
301        assert!(md.contains("# Test Skill"));
302        assert!(md.contains("name: test-skill"));
303    }
304
305    #[tokio::test]
306    async fn load_reference_returns_content() {
307        let data = EmbeddedSkillData {
308            skill_md: VALID_SKILL_MD,
309            references: &[("references/guide.md", REFERENCE_CONTENT)],
310            assets: &[],
311        };
312        let skill = EmbeddedSkill::new(&data).unwrap();
313
314        let r = skill
315            .load_resource(SkillResourceKind::Reference, "references/guide.md")
316            .await
317            .unwrap();
318        let SkillResource::Reference(r) = r else {
319            panic!("expected reference resource");
320        };
321        assert_eq!(r.skill, "test-skill");
322        assert_eq!(r.path, "references/guide.md");
323        assert_eq!(r.content, REFERENCE_CONTENT);
324        assert_eq!(r.bytes, REFERENCE_CONTENT.len() as u64);
325        assert!(!r.truncated);
326        assert!(!r.sha256.is_empty());
327    }
328
329    #[tokio::test]
330    async fn load_reference_unknown_returns_error() {
331        let data = EmbeddedSkillData {
332            skill_md: VALID_SKILL_MD,
333            references: &[],
334            assets: &[],
335        };
336        let skill = EmbeddedSkill::new(&data).unwrap();
337
338        let err = skill
339            .load_resource(SkillResourceKind::Reference, "references/missing.md")
340            .await
341            .unwrap_err();
342        assert!(matches!(err, SkillError::Unsupported(_)));
343    }
344
345    #[tokio::test]
346    async fn run_script_returns_unsupported() {
347        let data = EmbeddedSkillData {
348            skill_md: VALID_SKILL_MD,
349            references: &[],
350            assets: &[],
351        };
352        let skill = EmbeddedSkill::new(&data).unwrap();
353
354        let err = skill.run_script("scripts/run.sh", &[]).await.unwrap_err();
355        assert!(matches!(err, SkillError::Unsupported(_)));
356        assert!(err.to_string().contains("embedded skills do not support"));
357    }
358
359    #[test]
360    fn skill_without_allowed_tools_has_empty_vec() {
361        let data = EmbeddedSkillData {
362            skill_md: VALID_SKILL_MD_2,
363            references: &[],
364            assets: &[],
365        };
366        let skill = EmbeddedSkill::new(&data).unwrap();
367        assert!(skill.meta.allowed_tools.is_empty());
368    }
369
370    static MULTI_REF_DATA: EmbeddedSkillData = EmbeddedSkillData {
371        skill_md: VALID_SKILL_MD,
372        references: &[
373            ("references/a.md", "Content A"),
374            ("references/b.md", "Content B"),
375        ],
376        assets: &[],
377    };
378
379    #[tokio::test]
380    async fn multiple_references_per_skill() {
381        let skill = EmbeddedSkill::new(&MULTI_REF_DATA).unwrap();
382
383        let a = skill
384            .load_resource(SkillResourceKind::Reference, "references/a.md")
385            .await
386            .unwrap();
387        let SkillResource::Reference(a) = a else {
388            panic!("expected reference resource");
389        };
390        assert_eq!(a.content, "Content A");
391
392        let b = skill
393            .load_resource(SkillResourceKind::Reference, "references/b.md")
394            .await
395            .unwrap();
396        let SkillResource::Reference(b) = b else {
397            panic!("expected reference resource");
398        };
399        assert_eq!(b.content, "Content B");
400    }
401
402    #[test]
403    fn reference_sha256_is_deterministic() {
404        let data = EmbeddedSkillData {
405            skill_md: VALID_SKILL_MD,
406            references: &[("references/guide.md", REFERENCE_CONTENT)],
407            assets: &[],
408        };
409        let skill1 = EmbeddedSkill::new(&data).unwrap();
410        let skill2 = EmbeddedSkill::new(&data).unwrap();
411
412        let hash1 = &skill1.references.get("references/guide.md").unwrap().sha256;
413        let hash2 = &skill2.references.get("references/guide.md").unwrap().sha256;
414        assert_eq!(hash1, hash2);
415        assert_eq!(hash1.len(), 64);
416    }
417
418    #[test]
419    fn clone_produces_equal_skill() {
420        let data = EmbeddedSkillData {
421            skill_md: VALID_SKILL_MD,
422            references: &[("references/guide.md", REFERENCE_CONTENT)],
423            assets: &[],
424        };
425        let skill = EmbeddedSkill::new(&data).unwrap();
426        let cloned = skill.clone();
427
428        assert_eq!(skill.meta.id, cloned.meta.id);
429    }
430
431    #[tokio::test]
432    async fn works_with_skill_subsystem() {
433        use crate::{InMemorySkillRegistry, SkillRegistry, SkillSubsystem};
434
435        let data = &[
436            EmbeddedSkillData {
437                skill_md: VALID_SKILL_MD,
438                references: &[("references/guide.md", REFERENCE_CONTENT)],
439                assets: &[],
440            },
441            EmbeddedSkillData {
442                skill_md: VALID_SKILL_MD_2,
443                references: &[],
444                assets: &[],
445            },
446        ];
447        let skills = EmbeddedSkill::from_static_slice(data).unwrap();
448        let registry: Arc<dyn SkillRegistry> = Arc::new(InMemorySkillRegistry::from_skills(skills));
449        let subsystem = SkillSubsystem::new(registry);
450
451        let tools = subsystem.tools();
452        assert!(tools.contains_key("skill"));
453        assert!(tools.contains_key("load_skill_resource"));
454        assert!(tools.contains_key("skill_script"));
455
456        let skills_map = subsystem.registry().snapshot();
457        assert_eq!(skills_map.len(), 2);
458
459        let test_skill = skills_map.get("test-skill").unwrap();
460        let md = test_skill.read_instructions().await.unwrap();
461        assert!(md.contains("# Test Skill"));
462    }
463
464    #[tokio::test]
465    async fn load_reference_for_unknown_path_returns_error() {
466        let data = EmbeddedSkillData {
467            skill_md: VALID_SKILL_MD,
468            references: &[("references/guide.md", REFERENCE_CONTENT)],
469            assets: &[],
470        };
471        let skill = EmbeddedSkill::new(&data).unwrap();
472
473        let err = skill
474            .load_resource(SkillResourceKind::Reference, "references/nonexistent.md")
475            .await
476            .unwrap_err();
477        assert!(matches!(err, SkillError::Unsupported(_)));
478    }
479}