1use indexmap::IndexMap;
50use serde::{Deserialize, Serialize};
51use std::collections::HashSet;
52use std::path::{Component, Path, PathBuf};
53use std::sync::{Arc, RwLock};
54use tokio::io::AsyncReadExt;
55
56use crate::tool::{
57 Tool, ToolContext, ToolError, ToolMemoryPolicy, ToolNamespace, ToolOutput, ToolPolicy,
58 ToolResult, ToolSchema, ToolSource, ToolTrustLevel,
59};
60
61const MAX_SKILL_FILE_BYTES: u64 = 1024 * 1024;
64
65const MAX_SKILL_BODY_CHARS: usize = 256 * 1024;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[non_exhaustive]
73pub enum SkillMode {
74 Progressive,
76 Inline,
78 Manual,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[non_exhaustive]
85pub enum SkillSourceTrust {
86 Project,
88 UserInstalled,
90 Untrusted,
92}
93
94impl From<SkillSourceTrust> for ToolTrustLevel {
95 fn from(value: SkillSourceTrust) -> Self {
96 match value {
97 SkillSourceTrust::Project => ToolTrustLevel::Project,
98 SkillSourceTrust::UserInstalled => ToolTrustLevel::UserInstalled,
99 SkillSourceTrust::Untrusted => ToolTrustLevel::Untrusted,
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(default)]
107#[non_exhaustive]
108pub struct SkillLayerConfig {
109 pub(crate) max_menu_chars: usize,
111 pub(crate) max_body_chars: usize,
113 pub(crate) max_reference_bytes: usize,
115 pub(crate) strict_allowed_tools: bool,
117 pub(crate) source_trust: SkillSourceTrust,
119}
120
121impl Default for SkillLayerConfig {
122 fn default() -> Self {
123 Self {
124 max_menu_chars: 32 * 1024,
125 max_body_chars: MAX_SKILL_BODY_CHARS,
126 max_reference_bytes: 256 * 1024,
127 strict_allowed_tools: false,
128 source_trust: SkillSourceTrust::Project,
129 }
130 }
131}
132
133impl SkillLayerConfig {
134 pub fn new() -> Self {
136 Self::default()
137 }
138
139 pub fn max_menu_chars(&self) -> usize {
141 self.max_menu_chars
142 }
143
144 pub fn with_max_menu_chars(mut self, max_menu_chars: usize) -> Self {
146 self.max_menu_chars = max_menu_chars;
147 self
148 }
149
150 pub fn max_body_chars(&self) -> usize {
152 self.max_body_chars
153 }
154
155 pub fn with_max_body_chars(mut self, max_body_chars: usize) -> Self {
157 self.max_body_chars = max_body_chars;
158 self
159 }
160
161 pub fn max_reference_bytes(&self) -> usize {
163 self.max_reference_bytes
164 }
165
166 pub fn with_max_reference_bytes(mut self, max_reference_bytes: usize) -> Self {
168 self.max_reference_bytes = max_reference_bytes;
169 self
170 }
171
172 pub fn strict_allowed_tools(&self) -> bool {
174 self.strict_allowed_tools
175 }
176
177 pub fn with_strict_allowed_tools(mut self, strict_allowed_tools: bool) -> Self {
179 self.strict_allowed_tools = strict_allowed_tools;
180 self
181 }
182
183 pub fn source_trust(&self) -> SkillSourceTrust {
185 self.source_trust
186 }
187
188 pub fn with_source_trust(mut self, source_trust: SkillSourceTrust) -> Self {
190 self.source_trust = source_trust;
191 self
192 }
193}
194
195#[derive(Clone, Default)]
201pub struct SkillActivationState {
202 loaded: Arc<RwLock<HashSet<String>>>,
203 pinned: Arc<RwLock<Vec<String>>>,
204}
205
206impl SkillActivationState {
207 pub fn new() -> Self {
209 Self::default()
210 }
211
212 pub fn mark_loaded(&self, name: &str) -> bool {
216 self.loaded
217 .write()
218 .expect("SkillActivationState loaded lock poisoned")
219 .insert(name.to_string())
220 }
221
222 pub fn pin(&self, name: &str) -> bool {
226 let mut pinned = self
227 .pinned
228 .write()
229 .expect("SkillActivationState pinned lock poisoned");
230 if pinned.iter().any(|existing| existing == name) {
231 false
232 } else {
233 pinned.push(name.to_string());
234 true
235 }
236 }
237
238 pub fn unpin(&self, name: &str) -> bool {
240 let mut pinned = self
241 .pinned
242 .write()
243 .expect("SkillActivationState pinned lock poisoned");
244 if let Some(pos) = pinned.iter().position(|existing| existing == name) {
245 pinned.remove(pos);
246 true
247 } else {
248 false
249 }
250 }
251
252 pub fn is_active(&self, name: &str) -> bool {
254 self.loaded
255 .read()
256 .expect("SkillActivationState loaded lock poisoned")
257 .contains(name)
258 || self
259 .pinned
260 .read()
261 .expect("SkillActivationState pinned lock poisoned")
262 .iter()
263 .any(|existing| existing == name)
264 }
265
266 pub fn loaded(&self) -> Vec<String> {
268 self.loaded
269 .read()
270 .expect("SkillActivationState loaded lock poisoned")
271 .iter()
272 .cloned()
273 .collect()
274 }
275
276 pub fn pinned(&self) -> Vec<String> {
278 self.pinned
279 .read()
280 .expect("SkillActivationState pinned lock poisoned")
281 .clone()
282 }
283}
284
285impl std::fmt::Debug for SkillActivationState {
286 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 f.debug_struct("SkillActivationState")
288 .field("loaded", &self.loaded())
289 .field("pinned", &self.pinned())
290 .finish()
291 }
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct AllowedTool {
325 pub name: String,
327 pub scope: Option<String>,
331}
332
333impl AllowedTool {
334 pub fn permits(&self, tool: &str, args: &str) -> bool {
343 if self.name != tool {
344 return false;
345 }
346 match &self.scope {
347 None => true,
348 Some(scope) => {
349 let prefix = scope.strip_suffix('*').unwrap_or(scope);
352 args.starts_with(prefix)
353 }
354 }
355 }
356}
357
358#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct Skill {
371 name: String,
372 description: String,
373 body: String,
374 license: Option<String>,
375 compatibility: Option<String>,
376 metadata: Vec<(String, String)>,
377 allowed_tools: Vec<AllowedTool>,
378 resources: Vec<PathBuf>,
379 base_dir: Option<PathBuf>,
380}
381
382impl Skill {
383 pub fn parse(content: &str) -> Result<Skill, SkillError> {
425 let (fm, body) = parse_frontmatter(content)?;
426
427 let name = fm
428 .name
429 .ok_or_else(|| SkillError::InvalidName("missing name field".into()))?;
430 validate_name(&name)?;
431
432 let description = fm
433 .description
434 .ok_or_else(|| SkillError::InvalidDescription("missing description field".into()))?;
435 validate_description(&description)?;
436
437 if body.chars().count() > MAX_SKILL_BODY_CHARS {
440 return Err(SkillError::InvalidBody(format!(
441 "body exceeds size limit ({MAX_SKILL_BODY_CHARS} chars)"
442 )));
443 }
444
445 Ok(Skill {
446 name,
447 description,
448 body,
449 license: fm.license,
450 compatibility: fm.compatibility,
451 metadata: fm.metadata,
452 allowed_tools: fm.allowed_tools,
453 resources: Vec::new(),
454 base_dir: None,
455 })
456 }
457
458 pub async fn from_dir(path: &Path) -> Result<Skill, SkillError> {
474 let dir = tokio::fs::canonicalize(path)
480 .await
481 .map_err(|e| SkillError::Io(format!("cannot resolve skill root: {e}")))?;
482 let content = {
483 let skill_md = match tokio::fs::canonicalize(dir.join("SKILL.md")).await {
484 Ok(c) => c,
485 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
486 return Err(SkillError::NotFound(format!(
489 "no SKILL.md in directory: {}",
490 dir.display()
491 )));
492 }
493 Err(e) => return Err(e.into()),
494 };
495 if !skill_md.starts_with(&dir) {
496 return Err(SkillError::NotFound("SKILL.md escapes skill root".into()));
497 }
498 let mut buf = String::new();
502 let file = tokio::fs::File::open(&skill_md).await?;
503 file.take(MAX_SKILL_FILE_BYTES + 1)
504 .read_to_string(&mut buf)
505 .await?;
506 if buf.len() > MAX_SKILL_FILE_BYTES as usize {
507 return Err(SkillError::InvalidBody(format!(
508 "SKILL.md exceeds size limit ({MAX_SKILL_FILE_BYTES} bytes)"
509 )));
510 }
511 buf
512 };
513
514 let mut skill = Skill::parse(&content)?;
515 let dir_name = dir
516 .file_name()
517 .and_then(|n| n.to_str())
518 .unwrap_or_default()
519 .to_string();
520 if skill.name != dir_name {
521 return Err(SkillError::NameMismatch {
522 name: skill.name.clone(),
523 dir: dir_name,
524 });
525 }
526 skill.resources = collect_resources(&dir).await;
527 skill.base_dir = Some(dir);
528 Ok(skill)
529 }
530
531 pub fn name(&self) -> &str {
533 &self.name
534 }
535
536 pub fn description(&self) -> &str {
539 &self.description
540 }
541
542 pub fn body(&self) -> &str {
544 &self.body
545 }
546
547 pub fn license(&self) -> Option<&str> {
549 self.license.as_deref()
550 }
551
552 pub fn compatibility(&self) -> Option<&str> {
555 self.compatibility.as_deref()
556 }
557
558 pub fn metadata(&self) -> &[(String, String)] {
561 &self.metadata
562 }
563
564 pub fn allowed_tools(&self) -> &[AllowedTool] {
567 &self.allowed_tools
568 }
569
570 pub fn resources(&self) -> &[PathBuf] {
573 &self.resources
574 }
575
576 pub fn base_dir(&self) -> Option<&Path> {
579 self.base_dir.as_deref()
580 }
581
582 pub async fn load_reference(&self, name: &str) -> Result<String, SkillError> {
598 let Some(base) = &self.base_dir else {
599 return Err(SkillError::NotFound(
600 "no resource directory: skill parsed from text".into(),
601 ));
602 };
603 let name_path = Path::new(name);
604 if name_path.is_absolute()
605 || name_path.components().any(|c| {
606 matches!(
607 c,
608 Component::ParentDir | Component::RootDir | Component::Prefix(_)
609 )
610 })
611 {
612 return Err(SkillError::NotFound(format!(
613 "invalid resource path: {name}"
614 )));
615 }
616 let base = tokio::fs::canonicalize(base)
617 .await
618 .map_err(|e| SkillError::Io(format!("cannot resolve skill root: {e}")))?;
619 let canonical = tokio::fs::canonicalize(base.join(name_path))
620 .await
621 .map_err(|e| match e.kind() {
622 std::io::ErrorKind::NotFound => {
623 SkillError::NotFound(format!("resource not found: {name}"))
624 }
625 _ => SkillError::Io(e.to_string()),
626 })?;
627 if !canonical.starts_with(&base) {
628 return Err(SkillError::NotFound(format!(
629 "resource escapes skill root: {name}"
630 )));
631 }
632 tokio::fs::read_to_string(canonical)
633 .await
634 .map_err(|e| match e.kind() {
635 std::io::ErrorKind::NotFound => {
636 SkillError::NotFound(format!("resource not found: {name}"))
637 }
638 _ => SkillError::Io(e.to_string()),
639 })
640 }
641}
642
643#[derive(Default)]
691pub struct SkillRegistry {
692 skills: RwLock<IndexMap<String, Skill>>,
696}
697
698impl Clone for SkillRegistry {
699 fn clone(&self) -> Self {
702 let skills = self
703 .skills
704 .read()
705 .expect("SkillRegistry internal lock poisoned")
706 .clone();
707 Self {
708 skills: RwLock::new(skills),
709 }
710 }
711}
712
713impl SkillRegistry {
714 pub fn new() -> Self {
716 Self::default()
717 }
718
719 pub fn add(&self, skill: Skill) -> &Self {
726 let mut guard = self
727 .skills
728 .write()
729 .expect("SkillRegistry internal lock poisoned");
730 guard.insert(skill.name.clone(), skill);
731 self
732 }
733
734 pub fn remove(&self, name: &str) -> bool {
742 let mut guard = self
743 .skills
744 .write()
745 .expect("SkillRegistry internal lock poisoned");
746 guard.shift_remove(name).is_some()
747 }
748
749 pub fn get(&self, name: &str) -> Option<Skill> {
752 let guard = self
753 .skills
754 .read()
755 .expect("SkillRegistry internal lock poisoned");
756 guard.get(name).cloned()
757 }
758
759 pub async fn from_dir(path: &Path) -> Result<Self, SkillError> {
771 let registry = SkillRegistry::new();
772 let mut entries = tokio::fs::read_dir(path).await?;
773 let mut dirs = Vec::new();
774 loop {
777 match entries.next_entry().await {
778 Ok(Some(entry)) => {
779 let is_dir = match entry.file_type().await {
780 Ok(ft) => ft.is_dir(),
781 Err(_) => false,
782 };
783 if is_dir {
784 dirs.push(entry.path());
785 }
786 }
787 Ok(None) => break,
788 Err(e) => {
789 #[cfg(feature = "tracing")]
792 tracing::warn!("failed to read skill directory entry: {e}");
793 #[cfg(not(feature = "tracing"))]
794 let _ = e;
795 continue;
796 }
797 }
798 }
799 for dir in dirs {
800 match Skill::from_dir(&dir).await {
801 Ok(skill) => {
802 registry.add(skill);
803 }
804 Err(err) => {
805 #[cfg(feature = "tracing")]
806 tracing::warn!("skipping skill directory {}: {err}", dir.display());
807 #[cfg(not(feature = "tracing"))]
808 let _ = err;
809 }
810 }
811 }
812 Ok(registry)
813 }
814 pub async fn from_dirs<P: AsRef<Path>>(paths: &[P]) -> Self {
845 let registry = SkillRegistry::new();
846 for path in paths {
847 let path = path.as_ref();
848 match Self::from_dir(path).await {
849 Ok(found) => {
850 for skill in found.skills() {
851 registry.add(skill);
852 }
853 }
854 Err(err) => {
855 #[cfg(feature = "tracing")]
856 tracing::warn!("skipping skill source directory {}: {err}", path.display());
857 #[cfg(not(feature = "tracing"))]
858 let _ = err;
859 }
860 }
861 }
862 registry
863 }
864
865 pub fn menu(&self) -> String {
872 let guard = self
873 .skills
874 .read()
875 .expect("SkillRegistry internal lock poisoned");
876 let mut out = String::new();
877 for (i, skill) in guard.values().enumerate() {
878 if i > 0 {
879 out.push('\n');
880 }
881 out.push_str(&format!("- {}: {}", skill.name, skill.description));
882 }
883 out
884 }
885
886 pub fn skills(&self) -> Vec<Skill> {
891 let guard = self
892 .skills
893 .read()
894 .expect("SkillRegistry internal lock poisoned");
895 guard.values().cloned().collect()
896 }
897}
898
899impl std::fmt::Debug for SkillRegistry {
900 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
901 match self.skills.try_read() {
904 Ok(guard) => f
905 .debug_list()
906 .entries(guard.values().map(|s| s.name.as_str()))
907 .finish(),
908 Err(_) => f.write_str("<locked>"),
909 }
910 }
911}
912
913impl Extend<Skill> for SkillRegistry {
914 fn extend<I>(&mut self, iter: I)
915 where
916 I: IntoIterator<Item = Skill>,
917 {
918 for skill in iter {
919 self.add(skill);
920 }
921 }
922}
923
924impl FromIterator<Skill> for SkillRegistry {
925 fn from_iter<I>(iter: I) -> Self
926 where
927 I: IntoIterator<Item = Skill>,
928 {
929 let mut registry = Self::new();
930 registry.extend(iter);
931 registry
932 }
933}
934
935#[derive(Debug, Clone)]
937pub struct SkillLayerAssembly {
938 pub prompt_fragment: String,
940 pub load_skill_tool: Option<LoadSkillTool>,
942 pub manifest: SkillLayerManifest,
944}
945
946#[derive(Debug, Clone, PartialEq, Eq)]
948pub struct SkillLayerManifest {
949 pub layer_id: String,
951 pub mode: SkillMode,
953 pub visible_skills: Vec<String>,
955 pub active_skills: Vec<String>,
957}
958
959#[derive(Debug, Clone)]
965pub struct SkillLayer {
966 registry: Arc<SkillRegistry>,
967 enabled: Option<Arc<HashSet<String>>>,
968 mode: SkillMode,
969 activation: SkillActivationState,
970 config: SkillLayerConfig,
971 layer_id: String,
972}
973
974impl SkillLayer {
975 pub fn new(registry: Arc<SkillRegistry>) -> Self {
977 Self {
978 registry,
979 enabled: None,
980 mode: SkillMode::Progressive,
981 activation: SkillActivationState::new(),
982 config: SkillLayerConfig::default(),
983 layer_id: "skills".to_string(),
984 }
985 }
986
987 pub fn with_enabled_skills(mut self, names: &[&str]) -> Self {
989 self.enabled = Some(Arc::new(
990 names.iter().map(|name| name.to_string()).collect(),
991 ));
992 self
993 }
994
995 pub fn with_enabled_set(mut self, enabled: Option<Arc<HashSet<String>>>) -> Self {
997 self.enabled = enabled;
998 self
999 }
1000
1001 pub fn with_mode(mut self, mode: SkillMode) -> Self {
1003 self.mode = mode;
1004 self
1005 }
1006
1007 pub fn with_config(mut self, config: SkillLayerConfig) -> Self {
1009 self.config = config;
1010 self
1011 }
1012
1013 pub fn with_layer_id(mut self, layer_id: impl Into<String>) -> Self {
1015 self.layer_id = layer_id.into();
1016 self
1017 }
1018
1019 pub fn activation_state(&self) -> SkillActivationState {
1021 self.activation.clone()
1022 }
1023
1024 pub fn registry(&self) -> Arc<SkillRegistry> {
1026 Arc::clone(&self.registry)
1027 }
1028
1029 pub fn mode(&self) -> SkillMode {
1031 self.mode
1032 }
1033
1034 pub fn is_enabled(&self, name: &str) -> bool {
1036 match &self.enabled {
1037 None => true,
1038 Some(enabled) => enabled.contains(name),
1039 }
1040 }
1041
1042 pub fn activate_skill(&self, name: &str) -> bool {
1044 if self.mode != SkillMode::Progressive {
1045 return false;
1046 }
1047 if !self.is_enabled(name) || self.registry.get(name).is_none() {
1048 return false;
1049 }
1050 self.activation.pin(name);
1051 true
1052 }
1053
1054 pub fn deactivate_skill(&self, name: &str) -> bool {
1056 if self.mode != SkillMode::Progressive {
1057 return false;
1058 }
1059 self.activation.unpin(name)
1060 }
1061
1062 pub fn assemble(&self) -> SkillLayerAssembly {
1064 let visible = self.visible_skills();
1065 let prompt_fragment = match self.mode {
1066 SkillMode::Manual => String::new(),
1067 SkillMode::Progressive => self.progressive_prompt(&visible),
1068 SkillMode::Inline => self.inline_prompt(&visible),
1069 };
1070 let load_skill_tool = if self.mode == SkillMode::Progressive {
1071 Some(LoadSkillTool::with_activation(
1072 Arc::clone(&self.registry),
1073 self.enabled.clone(),
1074 self.activation.clone(),
1075 ))
1076 } else {
1077 None
1078 };
1079 SkillLayerAssembly {
1080 prompt_fragment,
1081 load_skill_tool,
1082 manifest: SkillLayerManifest {
1083 layer_id: self.layer_id.clone(),
1084 mode: self.mode,
1085 visible_skills: visible
1086 .iter()
1087 .map(|skill| skill.name().to_string())
1088 .collect(),
1089 active_skills: {
1090 let mut active = self.activation.loaded();
1091 for pinned in self.activation.pinned() {
1092 if !active.contains(&pinned) {
1093 active.push(pinned);
1094 }
1095 }
1096 active
1097 },
1098 },
1099 }
1100 }
1101
1102 fn visible_skills(&self) -> Vec<Skill> {
1103 self.registry
1104 .skills()
1105 .into_iter()
1106 .filter(|skill| self.is_enabled(skill.name()))
1107 .collect()
1108 }
1109
1110 fn progressive_prompt(&self, visible: &[Skill]) -> String {
1111 let menu: Vec<String> = visible
1112 .iter()
1113 .filter(|skill| !self.activation.is_active(skill.name()))
1114 .map(|skill| format!("- {}: {}", skill.name(), skill.description()))
1115 .collect();
1116 let mut out = join_limited_sections(menu, self.config.max_menu_chars);
1117 let pinned: Vec<String> = self
1118 .activation
1119 .pinned()
1120 .into_iter()
1121 .filter_map(|name| self.registry.get(&name))
1122 .map(|skill| {
1123 format!(
1124 "[Skill {}]\n{}",
1125 skill.name(),
1126 limit_chars(skill.body(), self.config.max_body_chars)
1127 )
1128 })
1129 .collect();
1130 append_sections(&mut out, &pinned);
1131 out
1132 }
1133
1134 fn inline_prompt(&self, visible: &[Skill]) -> String {
1135 let bodies = visible.iter().map(|skill| {
1136 format!(
1137 "[Skill {}]\n{}",
1138 skill.name(),
1139 limit_chars(skill.body(), self.config.max_body_chars)
1140 )
1141 });
1142 join_limited_sections(
1143 bodies,
1144 self.config.max_body_chars.saturating_mul(visible.len()),
1145 )
1146 }
1147
1148 pub fn load_skill_source(&self) -> ToolSource {
1150 LoadSkillTool::source(self.layer_id.clone(), self.config.source_trust.into())
1151 }
1152}
1153
1154fn append_sections(out: &mut String, sections: &[String]) {
1155 for section in sections {
1156 if section.is_empty() {
1157 continue;
1158 }
1159 if !out.is_empty() {
1160 out.push_str("\n\n");
1161 }
1162 out.push_str(section);
1163 }
1164}
1165
1166fn join_limited_sections(sections: impl IntoIterator<Item = String>, max_chars: usize) -> String {
1167 let mut out = String::new();
1168 for section in sections {
1169 if section.is_empty() {
1170 continue;
1171 }
1172 let separator = if out.is_empty() { "" } else { "\n" };
1173 let next_len = out.chars().count() + separator.chars().count() + section.chars().count();
1174 if next_len > max_chars {
1175 if !out.is_empty() {
1176 out.push_str("\n[truncated]");
1177 }
1178 break;
1179 }
1180 out.push_str(separator);
1181 out.push_str(§ion);
1182 }
1183 out
1184}
1185
1186fn limit_chars(text: &str, max_chars: usize) -> String {
1187 let mut out = String::new();
1188 for (idx, ch) in text.chars().enumerate() {
1189 if idx >= max_chars {
1190 out.push_str("\n[truncated]");
1191 return out;
1192 }
1193 out.push(ch);
1194 }
1195 out
1196}
1197
1198#[derive(Debug, Clone)]
1236pub struct LoadSkillTool {
1237 registry: Arc<SkillRegistry>,
1238 enabled: Option<Arc<HashSet<String>>>,
1239 activated: SkillActivationState,
1241}
1242
1243impl LoadSkillTool {
1244 pub fn new(registry: Arc<SkillRegistry>, enabled: Option<Arc<HashSet<String>>>) -> Self {
1247 Self {
1248 registry,
1249 enabled,
1250 activated: SkillActivationState::new(),
1251 }
1252 }
1253
1254 pub fn with_activation(
1256 registry: Arc<SkillRegistry>,
1257 enabled: Option<Arc<HashSet<String>>>,
1258 activated: SkillActivationState,
1259 ) -> Self {
1260 Self {
1261 registry,
1262 enabled,
1263 activated,
1264 }
1265 }
1266
1267 pub fn source(layer_id: impl Into<String>, trust: ToolTrustLevel) -> ToolSource {
1269 ToolSource::new(
1270 ToolNamespace::skill_layer(layer_id),
1271 "load_skill",
1272 "load_skill",
1273 )
1274 .with_trust(trust)
1275 }
1276}
1277
1278#[async_trait::async_trait]
1279impl Tool for LoadSkillTool {
1280 fn schema(&self) -> ToolSchema {
1281 let mut parameters = serde_json::to_value(schemars::schema_for!(LoadSkillArgs))
1287 .expect("LoadSkillArgs JSON Schema serialization must not fail");
1288 let available: Vec<String> = self
1289 .registry
1290 .skills()
1291 .iter()
1292 .filter(|s| self.is_enabled(s.name()))
1293 .map(|s| s.name().to_string())
1294 .collect();
1295 parameters["properties"]["name"]["enum"] = serde_json::json!(available);
1296 ToolSchema::new(
1297 "load_skill",
1298 "Load and activate a skill: the name argument is the skill name, and the skill body is returned. The available skills are listed in the system prompt.",
1299 parameters,
1300 )
1301 .with_policy(ToolPolicy {
1302 memory_policy: ToolMemoryPolicy::Protected,
1303 ..Default::default()
1304 })
1305 }
1306
1307 async fn call(
1308 &self,
1309 arguments: serde_json::Value,
1310 _context: ToolContext<'_>,
1311 ) -> Result<ToolResult, ToolError> {
1312 let name = serde_json::from_value::<LoadSkillArgs>(arguments)
1316 .map_err(ToolError::from)?
1317 .name;
1318 if !self.is_enabled(&name) {
1319 return Err(ToolError::Execution(format!(
1320 "skill '{name}' is not enabled"
1321 )));
1322 }
1323 let skill = match self.registry.get(&name) {
1324 Some(skill) => skill,
1325 None => return Err(ToolError::Execution(format!("skill '{name}' not found"))),
1326 };
1327 if !self.activated.mark_loaded(&name) {
1331 return Ok(ToolOutput::text(format!(
1332 "skill '{name}' is already active in this conversation"
1333 ))
1334 .with_memory_policy(ToolMemoryPolicy::Protected)
1335 .into());
1336 }
1337 Ok(ToolOutput::text(format_skill_content(&skill))
1338 .with_memory_policy(ToolMemoryPolicy::Protected)
1339 .into())
1340 }
1341}
1342
1343#[derive(serde::Deserialize, schemars::JsonSchema)]
1347struct LoadSkillArgs {
1348 name: String,
1351}
1352
1353impl LoadSkillTool {
1354 fn is_enabled(&self, name: &str) -> bool {
1357 match &self.enabled {
1358 None => true,
1359 Some(enabled) => enabled.contains(name),
1360 }
1361 }
1362}
1363
1364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1366#[serde(default)]
1367#[non_exhaustive]
1368pub struct SkillResourceStore {
1369 pub(crate) max_reference_bytes: usize,
1371}
1372
1373impl Default for SkillResourceStore {
1374 fn default() -> Self {
1375 Self {
1376 max_reference_bytes: 256 * 1024,
1377 }
1378 }
1379}
1380
1381impl SkillResourceStore {
1382 pub fn new() -> Self {
1384 Self::default()
1385 }
1386
1387 pub fn max_reference_bytes(&self) -> usize {
1389 self.max_reference_bytes
1390 }
1391
1392 pub fn with_max_reference_bytes(mut self, max_reference_bytes: usize) -> Self {
1394 self.max_reference_bytes = max_reference_bytes;
1395 self
1396 }
1397}
1398
1399#[derive(Debug, Clone)]
1404pub struct LoadSkillReferenceTool {
1405 registry: Arc<SkillRegistry>,
1406 enabled: Option<Arc<HashSet<String>>>,
1407 activated: SkillActivationState,
1408 store: SkillResourceStore,
1409}
1410
1411impl LoadSkillReferenceTool {
1412 pub fn new(
1414 registry: Arc<SkillRegistry>,
1415 enabled: Option<Arc<HashSet<String>>>,
1416 activated: SkillActivationState,
1417 store: SkillResourceStore,
1418 ) -> Self {
1419 Self {
1420 registry,
1421 enabled,
1422 activated,
1423 store,
1424 }
1425 }
1426
1427 pub fn source(layer_id: impl Into<String>, trust: ToolTrustLevel) -> ToolSource {
1429 ToolSource::new(
1430 ToolNamespace::skill_layer(layer_id),
1431 "load_skill_reference",
1432 "load_skill_reference",
1433 )
1434 .with_trust(trust)
1435 }
1436
1437 fn is_enabled(&self, name: &str) -> bool {
1438 match &self.enabled {
1439 None => true,
1440 Some(enabled) => enabled.contains(name),
1441 }
1442 }
1443}
1444
1445#[derive(serde::Deserialize, schemars::JsonSchema)]
1446struct LoadSkillReferenceArgs {
1447 skill: String,
1449 path: String,
1451}
1452
1453#[async_trait::async_trait]
1454impl Tool for LoadSkillReferenceTool {
1455 fn schema(&self) -> ToolSchema {
1456 let parameters = serde_json::to_value(schemars::schema_for!(LoadSkillReferenceArgs))
1457 .expect("LoadSkillReferenceArgs JSON Schema serialization must not fail");
1458 ToolSchema::new(
1459 "load_skill_reference",
1460 "Load a text reference file for an already active skill. The path must be under references/.",
1461 parameters,
1462 )
1463 .with_policy(ToolPolicy {
1464 memory_policy: ToolMemoryPolicy::Protected,
1465 ..Default::default()
1466 })
1467 }
1468
1469 async fn call(
1470 &self,
1471 arguments: serde_json::Value,
1472 _context: ToolContext<'_>,
1473 ) -> Result<ToolResult, ToolError> {
1474 let args =
1475 serde_json::from_value::<LoadSkillReferenceArgs>(arguments).map_err(ToolError::from)?;
1476 if !self.is_enabled(&args.skill) {
1477 return Err(ToolError::Execution(format!(
1478 "skill '{}' is not enabled",
1479 args.skill
1480 )));
1481 }
1482 if !self.activated.is_active(&args.skill) {
1483 return Err(ToolError::Execution(format!(
1484 "skill '{}' is not active",
1485 args.skill
1486 )));
1487 }
1488 if !args.path.starts_with("references/") {
1489 return Err(ToolError::InvalidArguments(
1490 "skill reference path must be under references/".into(),
1491 ));
1492 }
1493 let skill = self
1494 .registry
1495 .get(&args.skill)
1496 .ok_or_else(|| ToolError::Execution(format!("skill '{}' not found", args.skill)))?;
1497 let content = skill
1498 .load_reference(&args.path)
1499 .await
1500 .map_err(|e| ToolError::Execution(e.to_string()))?;
1501 if content.len() > self.store.max_reference_bytes {
1502 return Err(ToolError::Execution(format!(
1503 "skill reference exceeds size limit ({} bytes)",
1504 self.store.max_reference_bytes
1505 )));
1506 }
1507 Ok(ToolOutput::text(content)
1508 .with_memory_policy(ToolMemoryPolicy::Protected)
1509 .into())
1510 }
1511}
1512
1513fn format_skill_content(skill: &Skill) -> String {
1516 let mut out = String::new();
1517 out.push_str(&format!("<skill_content name=\"{}\">\n", skill.name()));
1518 out.push_str(skill.body());
1519 if skill.base_dir().is_some() {
1520 out.push_str("\n\nRelative paths in this skill are relative to the skill directory.");
1524 }
1525 if !skill.resources().is_empty() {
1526 out.push_str("\n\n<skill_resources>");
1527 for resource in skill.resources() {
1528 out.push_str(&format!("\n <file>{}</file>", resource.display()));
1529 }
1530 out.push_str("\n</skill_resources>");
1531 }
1532 out.push_str("\n</skill_content>");
1533 out
1534}
1535
1536#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1553#[non_exhaustive]
1554pub enum SkillError {
1555 #[error("invalid frontmatter: {0}")]
1558 InvalidFrontmatter(String),
1559 #[error("invalid skill name: {0}")]
1561 InvalidName(String),
1562 #[error("invalid skill description: {0}")]
1564 InvalidDescription(String),
1565 #[error("skill name '{name}' does not match directory name '{dir}'")]
1568 NameMismatch {
1569 name: String,
1571 dir: String,
1573 },
1574 #[error("skill not found: {0}")]
1577 NotFound(String),
1578 #[error("invalid skill body: {0}")]
1581 InvalidBody(String),
1582 #[error("io error: {0}")]
1585 Io(String),
1586}
1587
1588impl From<std::io::Error> for SkillError {
1589 fn from(err: std::io::Error) -> Self {
1590 SkillError::Io(err.to_string())
1591 }
1592}
1593
1594fn validate_name(name: &str) -> Result<(), SkillError> {
1598 if name.is_empty() {
1599 return Err(SkillError::InvalidName("name must not be empty".into()));
1600 }
1601 if name.chars().count() > 64 {
1602 return Err(SkillError::InvalidName("name exceeds 64 characters".into()));
1603 }
1604 if !name
1605 .chars()
1606 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1607 {
1608 return Err(SkillError::InvalidName(
1609 "name may only contain lowercase letters, digits, and hyphens".into(),
1610 ));
1611 }
1612 if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
1613 return Err(SkillError::InvalidName(
1614 "name is not kebab-case: must not start or end with a hyphen, and must not contain consecutive hyphens".into(),
1615 ));
1616 }
1617 Ok(())
1618}
1619
1620fn validate_description(description: &str) -> Result<(), SkillError> {
1622 if description.is_empty() {
1623 return Err(SkillError::InvalidDescription(
1624 "description must not be empty".into(),
1625 ));
1626 }
1627 if description.chars().count() > 1024 {
1628 return Err(SkillError::InvalidDescription(
1629 "description exceeds 1024 characters".into(),
1630 ));
1631 }
1632 Ok(())
1633}
1634
1635struct Frontmatter {
1638 name: Option<String>,
1639 description: Option<String>,
1640 license: Option<String>,
1641 compatibility: Option<String>,
1642 metadata: Vec<(String, String)>,
1643 allowed_tools: Vec<AllowedTool>,
1644}
1645
1646#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1649enum Block {
1650 None,
1652 Metadata,
1655 AllowedTools,
1658}
1659
1660fn parse_frontmatter(content: &str) -> Result<(Frontmatter, String), SkillError> {
1665 let content = content.strip_prefix('\u{feff}').unwrap_or(content);
1668 let content = content.trim_start_matches(['\n', '\r']);
1669
1670 let rest = content.strip_prefix("---").ok_or_else(|| {
1671 SkillError::InvalidFrontmatter("missing frontmatter start delimiter ---".into())
1672 })?;
1673 let (first_line, mut rest) = match rest.split_once('\n') {
1674 Some((line, tail)) => (line, tail),
1675 None => (rest, ""),
1676 };
1677 if !first_line.trim().is_empty() {
1678 return Err(SkillError::InvalidFrontmatter(
1679 "start delimiter --- must be followed by a newline".into(),
1680 ));
1681 }
1682
1683 let mut fm = Frontmatter {
1684 name: None,
1685 description: None,
1686 license: None,
1687 compatibility: None,
1688 metadata: Vec::new(),
1689 allowed_tools: Vec::new(),
1690 };
1691 let mut block = Block::None;
1692
1693 loop {
1694 let (line, tail) = match rest.split_once('\n') {
1695 Some((line, tail)) => (line, tail),
1696 None => (rest, ""),
1697 };
1698 if line.trim_end().trim() == "---" {
1699 let body = tail.strip_prefix('\n').unwrap_or(tail);
1702 return Ok((fm, body.to_string()));
1703 }
1704 if tail.is_empty() {
1705 return Err(SkillError::InvalidFrontmatter(
1707 "missing frontmatter end delimiter ---".into(),
1708 ));
1709 }
1710
1711 let trimmed = line.trim_end_matches('\r').trim();
1712 if trimmed.is_empty() || trimmed.starts_with('#') {
1713 } else if line.starts_with(' ') || line.starts_with('\t') {
1716 if let Some(item) = trimmed.strip_prefix("- ") {
1718 let item = item.trim();
1719 if block == Block::Metadata {
1720 return Err(SkillError::InvalidFrontmatter(
1721 "metadata does not support nested lists".into(),
1722 ));
1723 }
1724 fm.allowed_tools.push(parse_allowed_tool(item)?);
1725 } else if block == Block::Metadata {
1726 let (key, value) = split_kv(trimmed)?;
1727 fm.metadata
1728 .push((key.to_string(), stringify(value.unwrap_or_default())));
1729 } else {
1730 return Err(SkillError::InvalidFrontmatter(format!(
1731 "unsupported nested structure: {trimmed}"
1732 )));
1733 }
1734 } else {
1735 block = Block::None;
1737 let (key, value) = split_kv(trimmed)?;
1738 let value = value.map(stringify);
1741 match key {
1742 "name" => fm.name = Some(value.unwrap_or_default().to_string()),
1743 "description" => fm.description = Some(value.unwrap_or_default().to_string()),
1744 "license" => fm.license = Some(value.unwrap_or_default().to_string()),
1745 "compatibility" => {
1746 let v = value.unwrap_or_default().to_string();
1747 if v.chars().count() > 500 {
1748 return Err(SkillError::InvalidFrontmatter(
1749 "compatibility exceeds 500 characters".into(),
1750 ));
1751 }
1752 fm.compatibility = Some(v);
1753 }
1754 "allowed-tools" => {
1755 block = Block::AllowedTools;
1756 if let Some(v) = value {
1757 let v = v.trim();
1760 if v.starts_with('[') {
1761 let inner = v
1762 .strip_prefix('[')
1763 .and_then(|s| s.strip_suffix(']'))
1764 .ok_or_else(|| {
1765 SkillError::InvalidFrontmatter(format!(
1766 "allowed-tools flow list has unbalanced brackets: {v}"
1767 ))
1768 })?;
1769 for item in inner.split(',') {
1770 fm.allowed_tools.push(parse_allowed_tool(item.trim())?);
1771 }
1772 } else {
1773 for item in v.split_whitespace() {
1774 fm.allowed_tools.push(parse_allowed_tool(item)?);
1775 }
1776 }
1777 }
1778 }
1779 "metadata" => {
1780 block = Block::Metadata;
1781 if value.is_some() {
1782 return Err(SkillError::InvalidFrontmatter(
1783 "metadata value must be a key-value block (inline form is not supported)".into(),
1784 ));
1785 }
1786 }
1787 _ => {
1788 fm.metadata
1791 .push((key.to_string(), value.unwrap_or_default()));
1792 }
1793 }
1794 }
1795 rest = tail;
1796 }
1797}
1798
1799fn split_kv(line: &str) -> Result<(&str, Option<&str>), SkillError> {
1802 let Some((key, value)) = line.split_once(':') else {
1803 return Err(SkillError::InvalidFrontmatter(format!(
1804 "frontmatter line missing colon: {line}"
1805 )));
1806 };
1807 let key = key.trim();
1808 if key.is_empty() {
1809 return Err(SkillError::InvalidFrontmatter(
1810 "frontmatter line missing field name".into(),
1811 ));
1812 }
1813 if !key
1814 .chars()
1815 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1816 {
1817 return Err(SkillError::InvalidFrontmatter(format!(
1818 "invalid field name: {key}"
1819 )));
1820 }
1821 let value = value.trim();
1822 Ok((key, if value.is_empty() { None } else { Some(value) }))
1823}
1824
1825fn stringify(value: &str) -> String {
1828 let value = value.trim();
1829 let stripped = if (value.starts_with('"') && value.ends_with('"') && value.len() >= 2)
1830 || (value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2)
1831 {
1832 &value[1..value.len() - 1]
1833 } else {
1834 value
1835 };
1836 stripped.to_string()
1837}
1838
1839fn parse_allowed_tool(item: &str) -> Result<AllowedTool, SkillError> {
1841 if let Some(open) = item.find('(') {
1842 if !item.ends_with(')') || item[open + 1..].contains('(') {
1843 return Err(SkillError::InvalidFrontmatter(format!(
1844 "invalid allowed-tools entry: {item}"
1845 )));
1846 }
1847 let name = item[..open].trim();
1848 if name.is_empty() {
1849 return Err(SkillError::InvalidFrontmatter(format!(
1850 "allowed-tools entry missing tool name: {item}"
1851 )));
1852 }
1853 let scope = item[open + 1..item.len() - 1].trim();
1854 Ok(AllowedTool {
1855 name: name.to_string(),
1856 scope: (!scope.is_empty()).then(|| scope.to_string()),
1857 })
1858 } else if item.contains(')') {
1859 Err(SkillError::InvalidFrontmatter(format!(
1862 "invalid allowed-tools entry: {item}"
1863 )))
1864 } else if item.contains(['[', ']', ',']) {
1865 Err(SkillError::InvalidFrontmatter(format!(
1869 "invalid allowed-tools entry: {item}"
1870 )))
1871 } else if item.is_empty() {
1872 Err(SkillError::InvalidFrontmatter(
1873 "empty allowed-tools entry".into(),
1874 ))
1875 } else {
1876 Ok(AllowedTool {
1877 name: item.to_string(),
1878 scope: None,
1879 })
1880 }
1881}
1882
1883async fn collect_resources(base: &Path) -> Vec<PathBuf> {
1887 let mut resources = Vec::new();
1888 for dir in ["references", "scripts", "assets"] {
1889 walk_dir(base.join(dir), base, &mut resources).await;
1890 }
1891 resources.sort();
1892 resources
1893}
1894
1895async fn walk_dir(dir: PathBuf, base: &Path, out: &mut Vec<PathBuf>) {
1896 let mut entries = match tokio::fs::read_dir(&dir).await {
1897 Ok(e) => e,
1898 Err(_) => return, };
1900 loop {
1901 match entries.next_entry().await {
1902 Ok(Some(entry)) => {
1903 let path = entry.path();
1904 let is_dir = match entry.file_type().await {
1905 Ok(ft) => ft.is_dir(),
1906 Err(_) => false,
1907 };
1908 if is_dir {
1909 Box::pin(walk_dir(path, base, out)).await;
1910 } else if let Ok(rel) = path.strip_prefix(base) {
1911 out.push(rel.to_path_buf());
1912 }
1913 }
1914 Ok(None) => break,
1915 Err(_) => return,
1916 }
1917 }
1918}
1919
1920#[cfg(test)]
1921mod tests {
1922 use std::path::{Path, PathBuf};
1928 use std::sync::Arc;
1929
1930 use super::{
1931 AllowedTool, LoadSkillReferenceTool, LoadSkillTool, Skill, SkillActivationState,
1932 SkillError, SkillLayer, SkillMode, SkillRegistry, SkillResourceStore,
1933 };
1934 use crate::tool::Tool;
1935 use std::collections::HashSet;
1936
1937 fn temp_dir(tag: &str) -> TempDir {
1942 TempDir::new(tag)
1943 }
1944
1945 struct TempDir(PathBuf);
1948
1949 impl TempDir {
1950 fn new(tag: &str) -> Self {
1951 let dir =
1952 std::env::temp_dir().join(format!("molo-skill-test-{}-{tag}", std::process::id()));
1953 let _ = std::fs::remove_dir_all(&dir);
1954 std::fs::create_dir_all(&dir).unwrap();
1955 TempDir(dir)
1956 }
1957 }
1958
1959 impl std::ops::Deref for TempDir {
1960 type Target = PathBuf;
1961 fn deref(&self) -> &PathBuf {
1962 &self.0
1963 }
1964 }
1965
1966 impl Drop for TempDir {
1967 fn drop(&mut self) {
1968 let _ = std::fs::remove_dir_all(&self.0);
1969 }
1970 }
1971
1972 fn write_skill(dir: &Path, name: &str, description: &str, body: &str) -> PathBuf {
1975 let skill_dir = dir.join(name);
1976 std::fs::create_dir_all(&skill_dir).unwrap();
1977 std::fs::write(
1978 skill_dir.join("SKILL.md"),
1979 format!("---\nname: {name}\ndescription: {description}\n---\n{body}"),
1980 )
1981 .unwrap();
1982 skill_dir
1983 }
1984
1985 fn minimal(name: &str) -> Skill {
1986 Skill::parse(&format!(
1987 "---\nname: {name}\ndescription: description\n---\nbody"
1988 ))
1989 .unwrap()
1990 }
1991
1992 #[test]
1995 fn parse_minimal() {
1996 let skill = Skill::parse("---\nname: greet\ndescription: Say hello\n---\nHello!").unwrap();
1997 assert_eq!(skill.name(), "greet");
1998 assert_eq!(skill.description(), "Say hello");
1999 assert_eq!(skill.body(), "Hello!");
2000 assert!(skill.license().is_none());
2001 assert!(skill.metadata().is_empty());
2002 assert!(skill.allowed_tools().is_empty());
2003 assert!(skill.resources().is_empty());
2004 }
2005
2006 #[test]
2007 fn parse_full_fields() {
2008 let content = r#"---
2009name: code-review
2010description: Review code changes
2011license: MIT
2012compatibility: rust-1.80+
2013metadata:
2014 author: team
2015 public: true
2016allowed-tools:
2017 - Bash(git:*)
2018 - Python
2019user-invocable: true
2020---
2021Review steps.
2022"#;
2023 let skill = Skill::parse(content).unwrap();
2024 assert_eq!(skill.name(), "code-review");
2025 assert_eq!(skill.license(), Some("MIT"));
2026 assert_eq!(skill.compatibility(), Some("rust-1.80+"));
2027 assert_eq!(
2030 skill.metadata(),
2031 &[
2032 ("author".to_string(), "team".to_string()),
2033 ("public".to_string(), "true".to_string()),
2034 ("user-invocable".to_string(), "true".to_string()),
2035 ]
2036 );
2037 assert_eq!(
2039 skill.allowed_tools(),
2040 &[
2041 AllowedTool {
2042 name: "Bash".into(),
2043 scope: Some("git:*".into())
2044 },
2045 AllowedTool {
2046 name: "Python".into(),
2047 scope: None
2048 },
2049 ]
2050 );
2051 }
2052
2053 #[test]
2054 fn parse_allowed_tools_string_form() {
2055 let skill = Skill::parse(
2056 "---\nname: a\ndescription: description\nallowed-tools: Bash(git:*) Python\n---\nbody",
2057 )
2058 .unwrap();
2059 assert_eq!(skill.allowed_tools().len(), 2);
2060 assert_eq!(skill.allowed_tools()[0].name, "Bash");
2061 assert_eq!(skill.allowed_tools()[0].scope.as_deref(), Some("git:*"));
2062 assert_eq!(skill.allowed_tools()[1].name, "Python");
2063 }
2064
2065 #[test]
2066 fn parse_allowed_tools_flow_list_form() {
2067 let skill = Skill::parse(
2069 "---\nname: a\ndescription: description\nallowed-tools: [Bash, Python]\n---\nbody",
2070 )
2071 .unwrap();
2072 assert_eq!(skill.allowed_tools().len(), 2);
2073 assert_eq!(skill.allowed_tools()[0].name, "Bash");
2074 assert_eq!(skill.allowed_tools()[1].name, "Python");
2075 }
2076
2077 #[test]
2078 fn parse_allowed_tools_flow_list_unbalanced_brackets_rejected() {
2079 let err =
2082 Skill::parse("---\nname: a\ndescription: description\nallowed-tools: [Bash\n---\nbody")
2083 .unwrap_err();
2084 assert!(err.to_string().contains("unbalanced brackets"));
2085 }
2086
2087 #[test]
2088 fn frontmatter_underscore_keys_tolerated_into_metadata() {
2089 let skill =
2092 Skill::parse("---\nname: a\ndescription: description\nuser_invocable: true\n---\nbody")
2093 .unwrap();
2094 assert_eq!(
2095 skill.metadata(),
2096 &[("user_invocable".to_string(), "true".to_string())]
2097 );
2098 }
2099
2100 #[test]
2101 fn parse_body_with_blank_line_after_delimiter() {
2102 let skill = Skill::parse(
2105 "---\nname: a\ndescription: description\n---\n\nfirst body line\n\nsecond body line",
2106 )
2107 .unwrap();
2108 assert_eq!(skill.body(), "first body line\n\nsecond body line");
2109 }
2110
2111 #[test]
2112 fn parse_empty_body_allowed() {
2113 let skill = Skill::parse("---\nname: a\ndescription: description\n---").unwrap();
2115 assert_eq!(skill.body(), "");
2116 }
2117
2118 #[test]
2119 fn parse_bom_and_leading_blank_lines_tolerated() {
2120 let content = "\u{feff}\n\n---\nname: a\ndescription: description\n---\nbody";
2121 let skill = Skill::parse(content).unwrap();
2122 assert_eq!(skill.name(), "a");
2123 }
2124
2125 #[test]
2126 fn parse_quoted_values_stripped() {
2127 let skill =
2128 Skill::parse("---\nname: a\ndescription: \"quoted description\"\n---\n").unwrap();
2129 assert_eq!(skill.description(), "quoted description");
2130 }
2131
2132 #[test]
2133 fn parse_comments_and_blank_lines_ignored() {
2134 let content = "---\n# this is a comment\n\nname: a\ndescription: description\n---\nbody";
2135 let skill = Skill::parse(content).unwrap();
2136 assert_eq!(skill.name(), "a");
2137 }
2138
2139 #[test]
2140 fn parse_duplicate_field_last_wins() {
2141 let content = "---\nname: a\ndescription: first\ndescription: second\n---\n";
2142 let skill = Skill::parse(content).unwrap();
2143 assert_eq!(skill.description(), "second");
2144 }
2145
2146 #[test]
2150 fn parse_missing_frontmatter() {
2151 let err = Skill::parse("plain text, no frontmatter").unwrap_err();
2152 assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
2153 }
2154
2155 #[test]
2156 fn parse_missing_end_delimiter() {
2157 let err =
2158 Skill::parse("---\nname: a\ndescription: description\nbody without end delimiter")
2159 .unwrap_err();
2160 assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
2161 }
2162
2163 #[test]
2164 fn parse_missing_name() {
2165 let err = Skill::parse("---\ndescription: description\n---\n").unwrap_err();
2166 assert!(matches!(err, SkillError::InvalidName(_)));
2167 }
2168
2169 #[test]
2170 fn parse_invalid_names() {
2171 for bad in [
2174 "Bad-name",
2175 "bad_name",
2176 "bad name",
2177 &"a".repeat(65),
2178 "-bad",
2179 "bad-",
2180 "ba--d",
2181 ] {
2182 let content = format!("---\nname: {bad}\ndescription: description\n---\n");
2183 assert!(
2184 matches!(Skill::parse(&content), Err(SkillError::InvalidName(_))),
2185 "name should be rejected: {bad}"
2186 );
2187 }
2188 }
2189
2190 #[test]
2191 fn parse_invalid_descriptions() {
2192 let missing = Skill::parse("---\nname: a\n---\n").unwrap_err();
2193 assert!(matches!(missing, SkillError::InvalidDescription(_)));
2194
2195 let long = Skill::parse(&format!(
2196 "---\nname: a\ndescription: {}\n---\n",
2197 "x".repeat(1025)
2198 ))
2199 .unwrap_err();
2200 assert!(matches!(long, SkillError::InvalidDescription(_)));
2201 }
2202
2203 #[test]
2204 fn parse_compatibility_too_long() {
2205 let err = Skill::parse(&format!(
2206 "---\nname: a\ndescription: description\ncompatibility: {}\n---\n",
2207 "x".repeat(501)
2208 ))
2209 .unwrap_err();
2210 assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
2211 }
2212
2213 #[test]
2214 fn parse_metadata_nested_rejected() {
2215 let content = "---\nname: a\ndescription: description\nmetadata:\n tags:\n - x\n---\n";
2217 let err = Skill::parse(content).unwrap_err();
2218 assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
2219 }
2220
2221 #[test]
2222 fn parse_metadata_inline_value_rejected() {
2223 let err = Skill::parse("---\nname: a\ndescription: description\nmetadata: foo\n---\n")
2224 .unwrap_err();
2225 assert!(matches!(err, SkillError::InvalidFrontmatter(_)));
2226 }
2227
2228 #[test]
2229 fn parse_unknown_field_empty_value() {
2230 let skill =
2233 Skill::parse("---\nname: a\ndescription: description\nuser-invocable:\n---\n").unwrap();
2234 assert_eq!(
2235 skill.metadata(),
2236 &[("user-invocable".to_string(), String::new())]
2237 );
2238 }
2239
2240 #[test]
2241 fn parse_invalid_allowed_tool_entries() {
2242 for bad in ["Bash(git:*", "Bash)git:*", "()", "(x)"] {
2244 let content =
2245 format!("---\nname: a\ndescription: description\nallowed-tools: {bad}\n---\n");
2246 assert!(
2247 matches!(
2248 Skill::parse(&content),
2249 Err(SkillError::InvalidFrontmatter(_))
2250 ),
2251 "entry should be rejected: {bad}"
2252 );
2253 }
2254 }
2255
2256 #[tokio::test]
2259 async fn from_dir_ok_with_resources() {
2260 let dir = temp_dir("from-dir-ok");
2261 let skill_dir = write_skill(&dir, "code-review", "Review code", "Step one");
2262 std::fs::create_dir_all(skill_dir.join("references/nested")).unwrap();
2265 std::fs::write(skill_dir.join("references/style.md"), "# style").unwrap();
2266 std::fs::write(skill_dir.join("references/nested/check.md"), "# checklist").unwrap();
2267 std::fs::create_dir_all(skill_dir.join("scripts")).unwrap();
2268 std::fs::write(skill_dir.join("scripts/run.sh"), "#!/bin/sh").unwrap();
2269 std::fs::write(skill_dir.join("README.md"), "not a resource").unwrap();
2270
2271 let skill = Skill::from_dir(&skill_dir).await.unwrap();
2272 assert_eq!(skill.name(), "code-review");
2273 assert_eq!(skill.body(), "Step one");
2274 assert_eq!(
2276 skill.resources(),
2277 &[
2278 PathBuf::from("references/nested/check.md"),
2279 PathBuf::from("references/style.md"),
2280 PathBuf::from("scripts/run.sh"),
2281 ]
2282 );
2283 }
2284
2285 #[tokio::test]
2286 async fn from_dir_name_mismatch() {
2287 let dir = temp_dir("from-dir-mismatch");
2288 let skill_dir = dir.join("wrong-dir");
2290 std::fs::create_dir_all(&skill_dir).unwrap();
2291 std::fs::write(
2292 skill_dir.join("SKILL.md"),
2293 "---\nname: right-name\ndescription: description\n---\nbody",
2294 )
2295 .unwrap();
2296
2297 let err = Skill::from_dir(&skill_dir).await.unwrap_err();
2298 assert!(matches!(
2299 err,
2300 SkillError::NameMismatch { name, dir: _ } if name == "right-name"
2301 ));
2302 }
2303
2304 #[tokio::test]
2305 async fn from_dir_missing_skill_md() {
2306 let dir = temp_dir("from-dir-missing");
2307 let empty = dir.join("empty-skill");
2308 std::fs::create_dir_all(&empty).unwrap();
2309
2310 let err = Skill::from_dir(&empty).await.unwrap_err();
2311 assert!(matches!(err, SkillError::NotFound(_)));
2312 }
2313
2314 #[tokio::test]
2315 async fn load_reference_ok() {
2316 let dir = temp_dir("load-ref");
2317 let skill_dir = write_skill(&dir, "a", "description", "body");
2318 std::fs::create_dir_all(skill_dir.join("references")).unwrap();
2319 std::fs::write(skill_dir.join("references/style.md"), "style content").unwrap();
2320 let skill = Skill::from_dir(&skill_dir).await.unwrap();
2321
2322 assert_eq!(
2323 skill.load_reference("references/style.md").await.unwrap(),
2324 "style content"
2325 );
2326 }
2327
2328 #[tokio::test]
2329 async fn load_reference_missing_or_invalid() {
2330 let dir = temp_dir("load-ref-missing");
2331 let skill_dir = write_skill(&dir, "a", "description", "body");
2332 let skill = Skill::from_dir(&skill_dir).await.unwrap();
2333
2334 let err = skill.load_reference("nope.md").await.unwrap_err();
2336 assert!(matches!(err, SkillError::NotFound(_)));
2337 let err = skill.load_reference("../SKILL.md").await.unwrap_err();
2339 assert!(matches!(err, SkillError::NotFound(_)));
2340 let err = skill.load_reference("/etc/passwd").await.unwrap_err();
2342 assert!(matches!(err, SkillError::NotFound(_)));
2343
2344 let parsed = Skill::parse("---\nname: a\ndescription: description\n---\nbody").unwrap();
2346 let err = parsed.load_reference("x.md").await.unwrap_err();
2347 assert!(matches!(err, SkillError::NotFound(_)));
2348 }
2349
2350 #[cfg(unix)]
2353 #[tokio::test]
2354 async fn load_reference_rejects_symlink_escape() {
2355 let dir = temp_dir("load-ref-symlink");
2356 let skill_dir = write_skill(&dir, "a", "description", "body");
2357 std::fs::create_dir_all(skill_dir.join("references")).unwrap();
2358 let secret = dir.join("secret.txt");
2360 std::fs::write(&secret, "secret content").unwrap();
2361 std::os::unix::fs::symlink(&secret, skill_dir.join("references/leak")).unwrap();
2362 let skill = Skill::from_dir(&skill_dir).await.unwrap();
2363
2364 let err = skill.load_reference("references/leak").await.unwrap_err();
2365 assert!(
2366 matches!(err, SkillError::NotFound(_)),
2367 "symlink escape must be rejected, got: {err:?}"
2368 );
2369 }
2370
2371 #[cfg(unix)]
2374 #[tokio::test]
2375 async fn load_reference_allows_internal_symlink() {
2376 let dir = temp_dir("load-ref-symlink-in");
2377 let skill_dir = write_skill(&dir, "a", "description", "body");
2378 std::fs::create_dir_all(skill_dir.join("references")).unwrap();
2379 std::fs::write(skill_dir.join("references/real.md"), "real content").unwrap();
2380 std::os::unix::fs::symlink("real.md", skill_dir.join("references/alias.md")).unwrap();
2381 let skill = Skill::from_dir(&skill_dir).await.unwrap();
2382
2383 assert_eq!(
2384 skill.load_reference("references/alias.md").await.unwrap(),
2385 "real content"
2386 );
2387 }
2388
2389 #[cfg(unix)]
2392 #[tokio::test]
2393 async fn from_dir_rejects_symlinked_skill_md() {
2394 let dir = temp_dir("from-dir-symlink");
2395 let skill_dir = dir.join("a");
2396 std::fs::create_dir_all(&skill_dir).unwrap();
2397 let secret = dir.join("secret.md");
2398 std::fs::write(
2399 &secret,
2400 "---\nname: a\ndescription: description\n---\nsecret body",
2401 )
2402 .unwrap();
2403 std::os::unix::fs::symlink(&secret, skill_dir.join("SKILL.md")).unwrap();
2404
2405 let err = Skill::from_dir(&skill_dir).await.unwrap_err();
2406 assert!(
2407 matches!(err, SkillError::NotFound(_)),
2408 "SKILL.md symlink escape must be rejected, got: {err:?}"
2409 );
2410 }
2411
2412 #[test]
2415 fn registry_add_get_remove() {
2416 let registry = SkillRegistry::new();
2417 assert!(registry.get("a").is_none());
2418
2419 registry.add(minimal("a"));
2420 assert_eq!(registry.get("a").unwrap().name(), "a");
2421 assert!(registry.remove("a"));
2422 assert!(registry.get("a").is_none());
2423 assert!(!registry.remove("a"));
2425 }
2426
2427 #[test]
2428 fn registry_add_duplicate_replaces_in_place() {
2429 let registry = SkillRegistry::new();
2430 registry
2431 .add(minimal("a"))
2432 .add(minimal("b"))
2433 .add(minimal("c"));
2434
2435 let v2 = Skill::parse("---\nname: b\ndescription: new description\n---\nnew body").unwrap();
2438 registry.add(v2);
2439 let names: Vec<String> = registry
2440 .skills()
2441 .iter()
2442 .map(|s| s.name().to_string())
2443 .collect();
2444 assert_eq!(names, vec!["a", "b", "c"]);
2445 assert_eq!(registry.get("b").unwrap().body(), "new body");
2446 }
2447
2448 #[test]
2449 fn registry_from_iter_and_extend_keep_add_semantics() {
2450 let mut registry: SkillRegistry = [minimal("a"), minimal("b"), minimal("a")]
2451 .into_iter()
2452 .collect();
2453 let names: Vec<String> = registry
2454 .skills()
2455 .iter()
2456 .map(|skill| skill.name().to_string())
2457 .collect();
2458 assert_eq!(names, vec!["a", "b"]);
2459
2460 registry.extend([minimal("c")]);
2461 assert_eq!(
2462 registry
2463 .skills()
2464 .iter()
2465 .map(|skill| skill.name().to_string())
2466 .collect::<Vec<_>>(),
2467 vec!["a", "b", "c"]
2468 );
2469 }
2470
2471 #[test]
2472 fn registry_menu_format() {
2473 let registry = SkillRegistry::new();
2474 registry.add(minimal("a")).add(minimal("b"));
2475 assert_eq!(registry.menu(), "- a: description\n- b: description");
2476 assert_eq!(SkillRegistry::new().menu(), "");
2478 }
2479
2480 #[tokio::test]
2481 async fn registry_from_dir_skips_bad_skills() {
2482 let dir = temp_dir("registry-from-dir");
2483 write_skill(&dir, "good-one", "good skill", "body");
2484 let bad = dir.join("bad-one");
2486 std::fs::create_dir_all(&bad).unwrap();
2487 std::fs::write(
2488 bad.join("SKILL.md"),
2489 "---\nname: other-name\ndescription: description\n---\nbody",
2490 )
2491 .unwrap();
2492 std::fs::create_dir_all(dir.join("empty-dir")).unwrap();
2494 std::fs::write(dir.join("notes.md"), "not a skill").unwrap();
2496
2497 let registry = SkillRegistry::from_dir(&dir).await.unwrap();
2498 let names: Vec<String> = registry
2499 .skills()
2500 .iter()
2501 .map(|s| s.name().to_string())
2502 .collect();
2503 assert_eq!(names, vec!["good-one"]);
2504 assert!(registry.get("bad-one").is_none());
2505 }
2506
2507 #[tokio::test]
2508 async fn from_dirs_merges_with_later_override() {
2509 let dir = temp_dir("from-dirs-merge");
2510 let user = dir.join("user");
2511 let project = dir.join("project");
2512 std::fs::create_dir_all(&user).unwrap();
2513 std::fs::create_dir_all(&project).unwrap();
2514 write_skill(&user, "greet", "user version", "user body");
2516 write_skill(&user, "user-only", "user only", "body");
2517 write_skill(&project, "greet", "project version", "project body");
2521 write_skill(&project, "project-only", "project only", "body");
2522
2523 let registry = SkillRegistry::from_dirs(&[user, project]).await;
2524 let names: Vec<String> = registry
2525 .skills()
2526 .iter()
2527 .map(|s| s.name().to_string())
2528 .collect();
2529 assert_eq!(names, vec!["greet", "user-only", "project-only"]);
2532 assert_eq!(registry.get("greet").unwrap().body(), "project body");
2533 assert_eq!(registry.get("user-only").unwrap().body(), "body");
2534 }
2535
2536 #[tokio::test]
2537 async fn from_dirs_skips_missing_sources() {
2538 let dir = temp_dir("from-dirs-missing");
2539 let exists = dir.join("exists");
2540 std::fs::create_dir_all(&exists).unwrap();
2541 write_skill(&exists, "a", "description", "body");
2542
2543 let registry =
2546 SkillRegistry::from_dirs(&[dir.join("missing-a"), exists, dir.join("missing-b")]).await;
2547 assert_eq!(registry.skills().len(), 1);
2548
2549 let empty = SkillRegistry::from_dirs(&[dir.join("missing-a"), dir.join("missing-b")]).await;
2551 assert!(empty.skills().is_empty());
2552 }
2553
2554 #[tokio::test]
2555 async fn from_dirs_empty_list() {
2556 let registry = SkillRegistry::from_dirs::<&str>(&[]).await;
2557 assert!(registry.skills().is_empty());
2558 }
2559
2560 #[tokio::test]
2561 async fn registry_from_dir_root_io_error() {
2562 let missing = temp_dir("registry-root").join("does-not-exist");
2563 let err = SkillRegistry::from_dir(&missing).await.unwrap_err();
2564 assert!(matches!(err, SkillError::Io(_)));
2565 }
2566
2567 #[test]
2568 fn registry_hot_swap_add_remove() {
2569 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2572 let handle = Arc::clone(®istry);
2573
2574 handle.add(minimal("a"));
2575 assert!(registry.get("a").is_some());
2576 handle.remove("a");
2577 assert!(registry.get("a").is_none());
2578 }
2579
2580 #[test]
2581 fn allowed_tool_permits_rules() {
2582 let bash_git = AllowedTool {
2583 name: "Bash".into(),
2584 scope: Some("git:*".into()),
2585 };
2586 assert!(bash_git.permits("Bash", "git:diff --stat"));
2588 assert!(bash_git.permits("Bash", "git:log"));
2589 assert!(!bash_git.permits("Bash", "rm -rf /"));
2590 assert!(!bash_git.permits("Python", "git:log"));
2591
2592 let exact = AllowedTool {
2594 name: "Bash".into(),
2595 scope: Some("git:status".into()),
2596 };
2597 assert!(exact.permits("Bash", "git:status"));
2598 assert!(!exact.permits("Bash", "git:log"));
2599
2600 let python = AllowedTool {
2602 name: "Python".into(),
2603 scope: None,
2604 };
2605 assert!(python.permits("Python", "print('hello')"));
2606 assert!(!python.permits("Bash", "echo hi"));
2607 }
2608
2609 async fn call_load_skill(
2612 tool: &LoadSkillTool,
2613 arguments: serde_json::Value,
2614 state: &crate::SharedState,
2615 ) -> Result<String, crate::tool::ToolError> {
2616 let run = crate::RunContext::new("load-skill-test");
2617 let result = tool
2618 .call(
2619 arguments,
2620 crate::ToolContext::new(&run, state, "call-load-skill", "load_skill"),
2621 )
2622 .await?;
2623 Ok(result.to_string())
2624 }
2625
2626 #[tokio::test]
2627 async fn load_skill_returns_wrapped_body() {
2628 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2629 registry.add(minimal("a"));
2630 let tool = LoadSkillTool::new(Arc::clone(®istry), None);
2631 let state = crate::SharedState::new();
2632
2633 let result = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2634 .await
2635 .unwrap();
2636 assert_eq!(result, "<skill_content name=\"a\">\nbody\n</skill_content>");
2639 }
2640
2641 #[tokio::test]
2642 async fn load_skill_deduplicates_activations() {
2643 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2644 registry.add(minimal("a"));
2645 let tool = LoadSkillTool::new(Arc::clone(®istry), None);
2646 let state = crate::SharedState::new();
2647
2648 let first = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2651 .await
2652 .unwrap();
2653 assert!(first.contains("body"));
2654 let second = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2657 .await
2658 .unwrap();
2659 assert!(second.contains("already active"));
2660 assert!(!second.contains("body"));
2661 let fresh = LoadSkillTool::new(Arc::clone(®istry), None);
2663 let again = call_load_skill(&fresh, serde_json::json!({ "name": "a" }), &state)
2664 .await
2665 .unwrap();
2666 assert!(again.contains("body"));
2667 }
2668
2669 #[tokio::test]
2670 async fn load_skill_schema_enum_lists_enabled_skills() {
2671 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2672 registry
2673 .add(minimal("a"))
2674 .add(minimal("b"))
2675 .add(minimal("c"));
2676 let enabled: Arc<HashSet<String>> =
2677 Arc::new(["a".to_string(), "b".to_string()].into_iter().collect());
2678 let tool = LoadSkillTool::new(registry, Some(enabled));
2679
2680 let schema = tool.schema();
2681 let names = schema.parameters["properties"]["name"]["enum"]
2682 .as_array()
2683 .expect("name should be an enum")
2684 .iter()
2685 .map(|v| v.as_str().unwrap())
2686 .collect::<Vec<_>>();
2687 assert_eq!(names, vec!["a", "b"]);
2690 }
2691
2692 #[tokio::test]
2693 async fn load_skill_content_lists_resources() {
2694 let dir = temp_dir("load-skill-resources");
2695 let skill_dir = write_skill(&dir, "a", "description", "body");
2696 std::fs::create_dir_all(skill_dir.join("references")).unwrap();
2697 std::fs::write(skill_dir.join("references/style.md"), "# style").unwrap();
2698 let skill = Skill::from_dir(&skill_dir).await.unwrap();
2699
2700 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2701 registry.add(skill);
2702 let tool = LoadSkillTool::new(registry, None);
2703 let state = crate::SharedState::new();
2704
2705 let result = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2706 .await
2707 .unwrap();
2708 assert!(result.contains("<skill_content name=\"a\">"));
2709 assert!(
2712 result.contains("Relative paths in this skill are relative to the skill directory.")
2713 );
2714 assert!(!result.contains(&skill_dir.display().to_string()));
2715 assert!(result.contains("<skill_resources>"));
2716 assert!(result.contains("<file>references/style.md</file>"));
2717 assert!(result.ends_with("</skill_content>"));
2718 }
2719
2720 #[tokio::test]
2721 async fn load_skill_not_found() {
2722 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2723 let tool = LoadSkillTool::new(registry, None);
2724 let state = crate::SharedState::new();
2725
2726 let err = call_load_skill(&tool, serde_json::json!({ "name": "ghost" }), &state)
2727 .await
2728 .unwrap_err();
2729 assert!(err.to_string().contains("not found"));
2730 }
2731
2732 #[tokio::test]
2733 async fn load_skill_not_enabled() {
2734 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2735 registry.add(minimal("a")).add(minimal("b"));
2736 let enabled: Arc<std::collections::HashSet<String>> =
2737 Arc::new(["a".to_string()].into_iter().collect());
2738 let tool = LoadSkillTool::new(registry, Some(enabled));
2739 let state = crate::SharedState::new();
2740
2741 let err = call_load_skill(&tool, serde_json::json!({ "name": "b" }), &state)
2744 .await
2745 .unwrap_err();
2746 assert!(err.to_string().contains("not enabled"));
2747 let ok = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2749 .await
2750 .unwrap();
2751 assert!(ok.contains("body"));
2752 }
2753
2754 #[tokio::test]
2755 async fn load_skill_missing_name_argument() {
2756 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2757 let tool = LoadSkillTool::new(registry, None);
2758 let state = crate::SharedState::new();
2759 let err = call_load_skill(&tool, serde_json::json!({}), &state)
2760 .await
2761 .unwrap_err();
2762 assert!(matches!(err, crate::tool::ToolError::InvalidArguments(_)));
2763 }
2764
2765 #[test]
2766 fn skill_layer_progressive_assembles_menu_and_loader() {
2767 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2768 registry.add(minimal("a")).add(minimal("b"));
2769 let layer = SkillLayer::new(Arc::clone(®istry)).with_enabled_skills(&["a"]);
2770
2771 let assembly = layer.assemble();
2772 assert!(assembly.prompt_fragment.contains("- a: description"));
2773 assert!(!assembly.prompt_fragment.contains("- b:"));
2774 assert!(assembly.load_skill_tool.is_some());
2775 assert_eq!(assembly.manifest.visible_skills, vec!["a"]);
2776 assert_eq!(layer.load_skill_source().display_name, "load_skill");
2777 }
2778
2779 #[test]
2780 fn skill_layer_inline_embeds_bodies_without_loader() {
2781 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2782 registry.add(minimal("a"));
2783 let layer = SkillLayer::new(registry).with_mode(SkillMode::Inline);
2784
2785 let assembly = layer.assemble();
2786 assert!(assembly.prompt_fragment.contains("[Skill a]\nbody"));
2787 assert!(assembly.load_skill_tool.is_none());
2788 }
2789
2790 #[tokio::test]
2791 async fn skill_layer_shared_activation_deduplicates_loader_and_menu() {
2792 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2793 registry.add(minimal("a"));
2794 let layer = SkillLayer::new(Arc::clone(®istry));
2795 let tool = layer.assemble().load_skill_tool.unwrap();
2796 let state = crate::SharedState::new();
2797
2798 let body = call_load_skill(&tool, serde_json::json!({ "name": "a" }), &state)
2799 .await
2800 .unwrap();
2801 assert!(body.contains("body"));
2802 assert!(!layer.assemble().prompt_fragment.contains("- a:"));
2803 assert!(layer.activation_state().is_active("a"));
2804 }
2805
2806 async fn call_reference_tool(
2807 tool: &LoadSkillReferenceTool,
2808 arguments: serde_json::Value,
2809 ) -> Result<String, crate::tool::ToolError> {
2810 let run = crate::RunContext::new("load-skill-reference-test");
2811 let state = crate::SharedState::new();
2812 let result = tool
2813 .call(
2814 arguments,
2815 crate::ToolContext::new(&run, &state, "call-ref", "load_skill_reference"),
2816 )
2817 .await?;
2818 Ok(result.to_string())
2819 }
2820
2821 #[tokio::test]
2822 async fn load_skill_reference_requires_active_skill_and_references_path() {
2823 let dir = temp_dir("load-skill-reference");
2824 let skill_dir = write_skill(&dir, "a", "description", "body");
2825 std::fs::create_dir_all(skill_dir.join("references")).unwrap();
2826 std::fs::write(skill_dir.join("references/style.md"), "style").unwrap();
2827 let skill = Skill::from_dir(&skill_dir).await.unwrap();
2828
2829 let registry: Arc<SkillRegistry> = Arc::new(SkillRegistry::new());
2830 registry.add(skill);
2831 let activation = SkillActivationState::new();
2832 let tool = LoadSkillReferenceTool::new(
2833 Arc::clone(®istry),
2834 None,
2835 activation.clone(),
2836 SkillResourceStore::default(),
2837 );
2838
2839 let inactive = call_reference_tool(
2840 &tool,
2841 serde_json::json!({ "skill": "a", "path": "references/style.md" }),
2842 )
2843 .await
2844 .unwrap_err();
2845 assert!(inactive.to_string().contains("not active"));
2846
2847 activation.mark_loaded("a");
2848 let invalid = call_reference_tool(
2849 &tool,
2850 serde_json::json!({ "skill": "a", "path": "scripts/run.sh" }),
2851 )
2852 .await
2853 .unwrap_err();
2854 assert!(matches!(
2855 invalid,
2856 crate::tool::ToolError::InvalidArguments(_)
2857 ));
2858
2859 let content = call_reference_tool(
2860 &tool,
2861 serde_json::json!({ "skill": "a", "path": "references/style.md" }),
2862 )
2863 .await
2864 .unwrap();
2865 assert_eq!(content, "style");
2866 }
2867}