1#[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
22pub const SKILL_CAPABILITY_PREFIX: &str = "skill:";
24
25pub const SKILLS_DISCOVERY_PATH: &str = "/.agents/skills";
27
28pub const MAX_SKILLS_PER_CAPABILITY: usize = 50;
30
31pub fn skill_capability_id(skill_id: Uuid) -> String {
33 format!("{}{}", SKILL_CAPABILITY_PREFIX, skill_id)
34}
35
36pub fn is_skill_capability(capability_id: &str) -> bool {
38 capability_id.starts_with(SKILL_CAPABILITY_PREFIX)
39}
40
41pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SkillMeta {
53 pub name: String,
55 pub description: String,
57 pub source: SkillSource,
59 #[serde(default = "default_true")]
61 pub user_invocable: bool,
62 #[serde(default)]
64 pub disable_model_invocation: bool,
65}
66
67fn default_true() -> bool {
68 true
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub enum SkillSource {
74 Filesystem { path: String },
76 Registry { skill_id: String },
78}
79
80#[derive(Debug, Clone)]
82pub struct SkillInstructions {
83 pub instructions: String,
85 pub files: Vec<(String, String)>,
87}
88
89#[derive(Debug, Clone)]
97pub struct SkillContribution {
98 pub name: String,
100 pub description: String,
102 pub instructions: String,
104 pub files: Vec<(String, String)>,
106 pub user_invocable: bool,
108 pub disable_model_invocation: bool,
110}
111
112impl SkillContribution {
113 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 pub fn with_files(mut self, files: Vec<(String, String)>) -> Self {
132 self.files = files;
133 self
134 }
135
136 pub fn with_user_invocable(mut self, flag: bool) -> Self {
138 self.user_invocable = flag;
139 self
140 }
141
142 pub fn with_disable_model_invocation(mut self, flag: bool) -> Self {
144 self.disable_model_invocation = flag;
145 self
146 }
147
148 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#[derive(Debug, Clone)]
181pub struct AttachSkillCapability {
182 capability_id: String,
184 skill_name: String,
186 skill_description: String,
188 skill_md_content: String,
190 files: Vec<(String, String)>,
192 user_invocable: bool,
194 disable_model_invocation: bool,
196}
197
198impl AttachSkillCapability {
199 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 pub fn skill_name(&self) -> &str {
270 &self.skill_name
271 }
272
273 pub fn user_invocable(&self) -> bool {
275 self.user_invocable
276 }
277
278 pub fn disable_model_invocation(&self) -> bool {
280 self.disable_model_invocation
281 }
282
283 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
332pub 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 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
367pub 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![], };
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
405impl CapabilityId {
407 pub fn is_skill(&self) -> bool {
409 is_skill_capability(self.as_str())
410 }
411
412 pub fn skill(skill_id: Uuid) -> Self {
414 Self::new(skill_capability_id(skill_id))
415 }
416
417 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")); }
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 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 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}