1use 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
40pub struct EmbeddedSkillData {
42 pub skill_md: &'static str,
44 pub references: &'static [(&'static str, &'static str)],
46 pub assets: &'static [(&'static str, &'static str, Option<&'static str>)],
48}
49
50#[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 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 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}