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