1mod builtin;
27mod registry;
28pub mod validator;
29
30pub use builtin::builtin_skills;
31pub use registry::SkillRegistry;
32pub use validator::{
33 DefaultSkillValidator, SkillValidationError, SkillValidator, ValidationErrorKind,
34};
35
36use serde::{de, Deserialize, Deserializer, Serialize};
37use std::collections::HashSet;
38use std::path::Path;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
47#[serde(rename_all = "lowercase")]
48pub enum SkillKind {
49 #[default]
50 Instruction,
51 Persona,
52 Tool,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub struct ToolPermission {
62 pub tool: String,
63 pub pattern: String,
64}
65
66impl ToolPermission {
67 pub fn parse(s: &str) -> Option<Self> {
73 let s = s.trim();
74
75 let open = s.find('(')?;
77 let close = s.rfind(')')?;
78
79 if close <= open {
80 return None;
81 }
82
83 let tool = s[..open].trim().to_string();
84 let pattern = s[open + 1..close].trim().to_string();
85
86 Some(ToolPermission { tool, pattern })
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Skill {
95 #[serde(default)]
97 pub name: String,
98
99 #[serde(default)]
101 pub description: String,
102
103 #[serde(
105 default,
106 rename = "allowed-tools",
107 deserialize_with = "deserialize_allowed_tools"
108 )]
109 pub allowed_tools: Option<String>,
110
111 #[serde(default, rename = "disable-model-invocation")]
113 pub disable_model_invocation: bool,
114
115 #[serde(default)]
117 pub kind: SkillKind,
118
119 #[serde(skip)]
121 pub content: String,
122
123 #[serde(default)]
125 pub tags: Vec<String>,
126
127 #[serde(default)]
129 pub version: Option<String>,
130}
131
132impl Skill {
133 pub fn parse(content: &str) -> Option<Self> {
146 let parts: Vec<&str> = content.splitn(3, "---").collect();
148
149 if parts.len() < 3 {
150 return None;
151 }
152
153 let frontmatter = parts[1].trim();
154 let body = parts[2].trim();
155
156 let mut skill: Skill = serde_yaml::from_str(frontmatter).ok()?;
158 skill.content = body.to_string();
159
160 Some(skill)
161 }
162
163 pub fn from_file(path: impl AsRef<Path>) -> anyhow::Result<Self> {
165 let content = std::fs::read_to_string(path.as_ref())?;
166 let mut skill =
167 Self::parse(&content).ok_or_else(|| anyhow::anyhow!("Failed to parse skill file"))?;
168
169 if skill.name.is_empty() {
171 if let Some(stem) = path.as_ref().file_stem() {
172 skill.name = stem.to_string_lossy().to_string();
173 }
174 }
175
176 Ok(skill)
177 }
178
179 pub fn parse_allowed_tools(&self) -> HashSet<ToolPermission> {
184 let mut permissions = HashSet::new();
185
186 let Some(allowed) = &self.allowed_tools else {
187 return permissions;
188 };
189
190 let parts: Vec<&str> = if allowed.contains(',') {
195 allowed.split(',').collect()
196 } else if ToolPermission::parse(allowed).is_some() {
197 vec![allowed.as_str()]
198 } else {
199 let parts: Vec<&str> = allowed.split_whitespace().collect();
200 if parts.len() > 1 {
201 tracing::warn!(
202 skill = %self.name,
203 allowed_tools = %allowed,
204 "Legacy whitespace-separated allowed-tools is deprecated; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
205 );
206 }
207 parts
208 };
209 for part in parts {
210 let part = part.trim();
211 if part.is_empty() {
212 continue;
213 }
214 if let Some(perm) = ToolPermission::parse(part) {
215 permissions.insert(perm);
216 } else {
217 permissions.insert(ToolPermission {
218 tool: part.to_string(),
219 pattern: "*".to_string(),
220 });
221 }
222 }
223
224 permissions
225 }
226
227 pub fn uses_legacy_allowed_tools_syntax(&self) -> bool {
229 let Some(allowed) = &self.allowed_tools else {
230 return false;
231 };
232 !allowed.contains(',')
233 && ToolPermission::parse(allowed).is_none()
234 && allowed.split_whitespace().count() > 1
235 }
236
237 pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
239 let permissions = self.parse_allowed_tools();
240
241 if permissions.is_empty() {
242 return false;
243 }
244
245 permissions
247 .iter()
248 .any(|perm| perm.tool.eq_ignore_ascii_case(tool_name) && perm.pattern == "*")
249 }
250
251 pub fn to_system_prompt(&self) -> String {
253 format!(
254 "# Skill: {}\n\n{}\n\n{}",
255 self.name, self.description, self.content
256 )
257 }
258}
259
260fn deserialize_allowed_tools<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
261where
262 D: Deserializer<'de>,
263{
264 let value = Option::<serde_yaml::Value>::deserialize(deserializer)?;
265 match value {
266 None | Some(serde_yaml::Value::Null) => Ok(None),
267 Some(serde_yaml::Value::String(s)) => Ok(Some(s)),
268 Some(serde_yaml::Value::Sequence(items)) => {
269 let mut tools = Vec::new();
270 for item in items {
271 match item {
272 serde_yaml::Value::String(s) => tools.push(s),
273 other => {
274 return Err(de::Error::custom(format!(
275 "allowed-tools list entries must be strings, got {other:?}"
276 )));
277 }
278 }
279 }
280 Ok(Some(tools.join(", ")))
281 }
282 Some(other) => Err(de::Error::custom(format!(
283 "allowed-tools must be a string or a list of strings, got {other:?}"
284 ))),
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn test_parse_skill() {
294 let content = r#"---
295name: test-skill
296description: A test skill
297allowed-tools: "read(*), grep(*)"
298kind: instruction
299---
300# Instructions
301
302You are a test assistant.
303"#;
304
305 let skill = Skill::parse(content).unwrap();
306 assert_eq!(skill.name, "test-skill");
307 assert_eq!(skill.description, "A test skill");
308 assert_eq!(skill.kind, SkillKind::Instruction);
309 assert!(skill.content.contains("You are a test assistant"));
310 }
311
312 #[test]
313 fn test_parse_tool_permission() {
314 let perm = ToolPermission::parse("Bash(gh issue view:*)").unwrap();
315 assert_eq!(perm.tool, "Bash");
316 assert_eq!(perm.pattern, "gh issue view:*");
317
318 let perm = ToolPermission::parse("read(*)").unwrap();
319 assert_eq!(perm.tool, "read");
320 assert_eq!(perm.pattern, "*");
321 }
322
323 #[test]
324 fn test_parse_allowed_tools() {
325 let skill = Skill {
326 name: "test".to_string(),
327 description: "test".to_string(),
328 allowed_tools: Some("read(*), grep(*), Bash(gh:*)".to_string()),
329 disable_model_invocation: false,
330 kind: SkillKind::Instruction,
331 content: String::new(),
332 tags: Vec::new(),
333 version: None,
334 };
335
336 let permissions = skill.parse_allowed_tools();
337 assert_eq!(permissions.len(), 3);
338 }
339
340 #[test]
341 fn test_parse_legacy_whitespace_allowed_tools() {
342 let skill = Skill {
343 name: "test".to_string(),
344 description: "test".to_string(),
345 allowed_tools: Some("Read Write Edit Bash".to_string()),
346 disable_model_invocation: false,
347 kind: SkillKind::Instruction,
348 content: String::new(),
349 tags: Vec::new(),
350 version: None,
351 };
352
353 let permissions = skill.parse_allowed_tools();
354 assert_eq!(permissions.len(), 4);
355 assert!(skill.uses_legacy_allowed_tools_syntax());
356 assert!(permissions
357 .iter()
358 .any(|perm| perm.tool == "Bash" && perm.pattern == "*"));
359 }
360
361 #[test]
362 fn test_parse_single_allowed_tool_with_spaces() {
363 let skill = Skill {
364 name: "test".to_string(),
365 description: "test".to_string(),
366 allowed_tools: Some("Bash(uv run skills analyze-ci:*)".to_string()),
367 disable_model_invocation: false,
368 kind: SkillKind::Instruction,
369 content: String::new(),
370 tags: Vec::new(),
371 version: None,
372 };
373
374 let permissions = skill.parse_allowed_tools();
375 assert_eq!(permissions.len(), 1);
376 assert!(permissions
377 .iter()
378 .any(|perm| { perm.tool == "Bash" && perm.pattern == "uv run skills analyze-ci:*" }));
379 assert!(!skill.uses_legacy_allowed_tools_syntax());
380 }
381
382 #[test]
383 fn test_parse_allowed_tools_yaml_list() {
384 let content = r#"---
385name: test-skill
386description: A test skill
387allowed-tools:
388 - Read
389 - Write
390 - Bash(uv run skills analyze-ci:*)
391---
392# Instructions
393"#;
394
395 let skill = Skill::parse(content).unwrap();
396 assert_eq!(
397 skill.allowed_tools.as_deref(),
398 Some("Read, Write, Bash(uv run skills analyze-ci:*)")
399 );
400 let permissions = skill.parse_allowed_tools();
401 assert_eq!(permissions.len(), 3);
402 assert!(permissions
403 .iter()
404 .any(|perm| perm.tool == "Read" && perm.pattern == "*"));
405 }
406
407 #[test]
408 fn test_is_tool_allowed() {
409 let skill = Skill {
410 name: "test".to_string(),
411 description: "test".to_string(),
412 allowed_tools: Some("read(*), grep(*)".to_string()),
413 disable_model_invocation: false,
414 kind: SkillKind::Instruction,
415 content: String::new(),
416 tags: Vec::new(),
417 version: None,
418 };
419
420 assert!(skill.is_tool_allowed("read"));
421 assert!(skill.is_tool_allowed("grep"));
422 assert!(!skill.is_tool_allowed("write"));
423 }
424
425 #[test]
426 fn test_omitted_allowed_tools_does_not_allow_tools() {
427 let skill = Skill {
428 name: "test".to_string(),
429 description: "test".to_string(),
430 allowed_tools: None,
431 disable_model_invocation: false,
432 kind: SkillKind::Instruction,
433 content: String::new(),
434 tags: Vec::new(),
435 version: None,
436 };
437
438 assert!(!skill.is_tool_allowed("read"));
439 }
440}