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 = std::fs::read_to_string(path.as_ref())?;
167 let mut skill =
168 Self::parse(&content).ok_or_else(|| anyhow::anyhow!("Failed to parse skill file"))?;
169
170 if skill.name.is_empty() {
172 if let Some(stem) = path.as_ref().file_stem() {
173 skill.name = stem.to_string_lossy().to_string();
174 }
175 }
176
177 Ok(skill)
178 }
179
180 pub fn parse_allowed_tools(&self) -> HashSet<ToolPermission> {
185 let mut permissions = HashSet::new();
186
187 let Some(allowed) = &self.allowed_tools else {
188 return permissions;
189 };
190
191 let parts: Vec<&str> = if allowed.contains(',') {
196 allowed.split(',').collect()
197 } else if ToolPermission::parse(allowed).is_some() {
198 vec![allowed.as_str()]
199 } else {
200 let parts: Vec<&str> = allowed.split_whitespace().collect();
201 if parts.len() > 1 {
202 tracing::warn!(
203 skill = %self.name,
204 allowed_tools = %allowed,
205 "Legacy whitespace-separated allowed-tools is deprecated; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
206 );
207 }
208 parts
209 };
210 for part in parts {
211 let part = part.trim();
212 if part.is_empty() {
213 continue;
214 }
215 if let Some(perm) = ToolPermission::parse(part) {
216 permissions.insert(perm);
217 } else {
218 permissions.insert(ToolPermission {
219 tool: part.to_string(),
220 pattern: "*".to_string(),
221 });
222 }
223 }
224
225 permissions
226 }
227
228 pub fn uses_legacy_allowed_tools_syntax(&self) -> bool {
230 let Some(allowed) = &self.allowed_tools else {
231 return false;
232 };
233 !allowed.contains(',')
234 && ToolPermission::parse(allowed).is_none()
235 && allowed.split_whitespace().count() > 1
236 }
237
238 pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
240 let permissions = self.parse_allowed_tools();
241
242 if permissions.is_empty() {
243 return false;
244 }
245
246 permissions.iter().any(|perm| {
248 (perm.tool == "*" || perm.tool.eq_ignore_ascii_case(tool_name)) && perm.pattern == "*"
249 })
250 }
251
252 pub fn to_system_prompt(&self) -> String {
254 format!(
255 "# Skill: {}\n\n{}\n\n{}",
256 self.name, self.description, self.content
257 )
258 }
259}
260
261fn deserialize_allowed_tools<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
262where
263 D: Deserializer<'de>,
264{
265 let value = Option::<serde_yaml::Value>::deserialize(deserializer)?;
266 match value {
267 None | Some(serde_yaml::Value::Null) => Ok(None),
268 Some(serde_yaml::Value::String(s)) => Ok(Some(s)),
269 Some(serde_yaml::Value::Sequence(items)) => {
270 let mut tools = Vec::new();
271 for item in items {
272 match item {
273 serde_yaml::Value::String(s) => tools.push(s),
274 other => {
275 return Err(de::Error::custom(format!(
276 "allowed-tools list entries must be strings, got {other:?}"
277 )));
278 }
279 }
280 }
281 Ok(Some(tools.join(", ")))
282 }
283 Some(other) => Err(de::Error::custom(format!(
284 "allowed-tools must be a string or a list of strings, got {other:?}"
285 ))),
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn test_parse_skill() {
295 let content = r#"---
296name: test-skill
297description: A test skill
298allowed-tools: "read(*), grep(*)"
299kind: instruction
300---
301# Instructions
302
303You are a test assistant.
304"#;
305
306 let skill = Skill::parse(content).unwrap();
307 assert_eq!(skill.name, "test-skill");
308 assert_eq!(skill.description, "A test skill");
309 assert_eq!(skill.kind, SkillKind::Instruction);
310 assert!(skill.content.contains("You are a test assistant"));
311 }
312
313 #[test]
314 fn test_parse_tool_permission() {
315 let perm = ToolPermission::parse("Bash(gh issue view:*)").unwrap();
316 assert_eq!(perm.tool, "Bash");
317 assert_eq!(perm.pattern, "gh issue view:*");
318
319 let perm = ToolPermission::parse("read(*)").unwrap();
320 assert_eq!(perm.tool, "read");
321 assert_eq!(perm.pattern, "*");
322 }
323
324 #[test]
325 fn test_parse_allowed_tools() {
326 let skill = Skill {
327 name: "test".to_string(),
328 description: "test".to_string(),
329 allowed_tools: Some("read(*), grep(*), Bash(gh:*)".to_string()),
330 disable_model_invocation: false,
331 kind: SkillKind::Instruction,
332 content: String::new(),
333 tags: Vec::new(),
334 version: None,
335 };
336
337 let permissions = skill.parse_allowed_tools();
338 assert_eq!(permissions.len(), 3);
339 }
340
341 #[test]
342 fn test_parse_legacy_whitespace_allowed_tools() {
343 let skill = Skill {
344 name: "test".to_string(),
345 description: "test".to_string(),
346 allowed_tools: Some("Read Write Edit Bash".to_string()),
347 disable_model_invocation: false,
348 kind: SkillKind::Instruction,
349 content: String::new(),
350 tags: Vec::new(),
351 version: None,
352 };
353
354 let permissions = skill.parse_allowed_tools();
355 assert_eq!(permissions.len(), 4);
356 assert!(skill.uses_legacy_allowed_tools_syntax());
357 assert!(permissions
358 .iter()
359 .any(|perm| perm.tool == "Bash" && perm.pattern == "*"));
360 }
361
362 #[test]
363 fn test_parse_single_allowed_tool_with_spaces() {
364 let skill = Skill {
365 name: "test".to_string(),
366 description: "test".to_string(),
367 allowed_tools: Some("Bash(uv run skills analyze-ci:*)".to_string()),
368 disable_model_invocation: false,
369 kind: SkillKind::Instruction,
370 content: String::new(),
371 tags: Vec::new(),
372 version: None,
373 };
374
375 let permissions = skill.parse_allowed_tools();
376 assert_eq!(permissions.len(), 1);
377 assert!(permissions
378 .iter()
379 .any(|perm| { perm.tool == "Bash" && perm.pattern == "uv run skills analyze-ci:*" }));
380 assert!(!skill.uses_legacy_allowed_tools_syntax());
381 }
382
383 #[test]
384 fn test_parse_allowed_tools_yaml_list() {
385 let content = r#"---
386name: test-skill
387description: A test skill
388allowed-tools:
389 - Read
390 - Write
391 - Bash(uv run skills analyze-ci:*)
392---
393# Instructions
394"#;
395
396 let skill = Skill::parse(content).unwrap();
397 assert_eq!(
398 skill.allowed_tools.as_deref(),
399 Some("Read, Write, Bash(uv run skills analyze-ci:*)")
400 );
401 let permissions = skill.parse_allowed_tools();
402 assert_eq!(permissions.len(), 3);
403 assert!(permissions
404 .iter()
405 .any(|perm| perm.tool == "Read" && perm.pattern == "*"));
406 }
407
408 #[test]
409 fn test_is_tool_allowed() {
410 let skill = Skill {
411 name: "test".to_string(),
412 description: "test".to_string(),
413 allowed_tools: Some("read(*), grep(*)".to_string()),
414 disable_model_invocation: false,
415 kind: SkillKind::Instruction,
416 content: String::new(),
417 tags: Vec::new(),
418 version: None,
419 };
420
421 assert!(skill.is_tool_allowed("read"));
422 assert!(skill.is_tool_allowed("grep"));
423 assert!(!skill.is_tool_allowed("write"));
424 }
425
426 #[test]
427 fn test_wildcard_allowed_tools_allows_any_tool() {
428 let skill = Skill {
429 name: "test".to_string(),
430 description: "test".to_string(),
431 allowed_tools: Some("*".to_string()),
432 disable_model_invocation: false,
433 kind: SkillKind::Instruction,
434 content: String::new(),
435 tags: Vec::new(),
436 version: None,
437 };
438
439 assert!(skill.is_tool_allowed("read"));
440 assert!(skill.is_tool_allowed("bash"));
441 assert!(skill.is_tool_allowed("parallel_task"));
442 }
443
444 #[test]
445 fn test_omitted_allowed_tools_does_not_allow_tools() {
446 let skill = Skill {
447 name: "test".to_string(),
448 description: "test".to_string(),
449 allowed_tools: None,
450 disable_model_invocation: false,
451 kind: SkillKind::Instruction,
452 content: String::new(),
453 tags: Vec::new(),
454 version: None,
455 };
456
457 assert!(!skill.is_tool_allowed("read"));
458 }
459}