1use crate::AgentError;
7use crate::utils::git::gitignore::is_path_gitignored;
8use crate::utils::memoize::memoize_with_lru;
9use once_cell::sync::Lazy;
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum EffortValue {
18 Minimum,
19 Low,
20 Medium,
21 High,
22 Maximum,
23}
24
25impl EffortValue {
26 pub fn as_str(&self) -> &str {
27 match self {
28 EffortValue::Minimum => "minimum",
29 EffortValue::Low => "low",
30 EffortValue::Medium => "medium",
31 EffortValue::High => "high",
32 EffortValue::Maximum => "maximum",
33 }
34 }
35
36 pub fn from_str(s: &str) -> Option<Self> {
37 match s.to_lowercase().as_str() {
38 "minimum" => Some(EffortValue::Minimum),
39 "low" => Some(EffortValue::Low),
40 "medium" => Some(EffortValue::Medium),
41 "high" => Some(EffortValue::High),
42 "maximum" => Some(EffortValue::Maximum),
43 _ => None,
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum SkillContext {
52 Inline,
53 Fork,
54}
55
56impl SkillContext {
57 pub fn as_str(&self) -> &str {
58 match self {
59 SkillContext::Inline => "inline",
60 SkillContext::Fork => "fork",
61 }
62 }
63
64 pub fn from_str(s: &str) -> Option<Self> {
65 match s.to_lowercase().as_str() {
66 "inline" => Some(SkillContext::Inline),
67 "fork" => Some(SkillContext::Fork),
68 _ => None,
69 }
70 }
71}
72
73pub use crate::utils::hooks::register_skill_hooks::HooksSettings;
76pub use crate::utils::hooks::register_skill_hooks::HookMatcher;
77
78#[derive(Debug, Clone)]
80pub struct SkillMetadata {
81 pub name: String,
82 pub description: String,
83 pub display_name: Option<String>,
85 pub version: Option<String>,
87 pub allowed_tools: Option<Vec<String>>,
88 pub argument_hint: Option<String>,
89 pub arg_names: Option<Vec<String>>,
90 pub when_to_use: Option<String>,
91 pub user_invocable: Option<bool>,
92 pub paths: Option<Vec<String>>,
94 pub hooks: Option<HooksSettings>,
96 pub effort: Option<EffortValue>,
98 pub model: Option<String>,
100 pub context: Option<SkillContext>,
102 pub agent: Option<String>,
104 pub shell: Option<String>,
106}
107
108#[derive(Debug, Clone)]
110pub struct LoadedSkill {
111 pub metadata: SkillMetadata,
112 pub content: String,
113 pub base_dir: String,
114}
115
116fn parse_frontmatter(content: &str) -> (HashMap<String, String>, String) {
118 let mut fields = HashMap::new();
119 let trimmed = content.trim();
120
121 if !trimmed.starts_with("---") {
122 return (fields, content.to_string());
123 }
124
125 if let Some(end_pos) = trimmed[3..].find("---") {
126 let frontmatter = &trimmed[3..end_pos + 3];
127 for line in frontmatter.lines() {
128 let line = line.trim();
129 if line.is_empty() || line.starts_with('#') {
130 continue;
131 }
132 if let Some(colon_pos) = line.find(':') {
133 let key = line[..colon_pos].trim().to_string();
134 let value = line[colon_pos + 1..].trim().to_string();
135 fields.insert(key, value);
136 }
137 }
138 let body = trimmed[end_pos + 6..].trim_start().to_string();
139 return (fields, body);
140 }
141
142 (fields, content.to_string())
143}
144
145pub fn substitute_env_vars_in_skill(content: &str, base_dir: &str) -> String {
152 let session_id = crate::bootstrap::state::get_session_id();
153 #[cfg(windows)]
156 let normalised_base_dir = base_dir.replace('\\', "/");
157 #[cfg(not(windows))]
158 let normalised_base_dir = base_dir.to_string();
159
160 content
161 .replace("${CLAUDE_SKILL_DIR}", &normalised_base_dir)
162 .replace("${CLAUDE_SESSION_ID}", &session_id)
163}
164
165pub fn estimate_skill_frontmatter_tokens(metadata: &SkillMetadata) -> usize {
170 let parts: Vec<&str> = vec![
171 Some(metadata.name.as_str()),
172 Some(metadata.description.as_str()),
173 metadata.when_to_use.as_deref(),
174 ]
175 .into_iter()
176 .flatten()
177 .collect();
178 let frontmatter_text = parts.join(" ");
179 crate::services::token_estimation::rough_token_count_estimation(&frontmatter_text, 4.0)
180}
181
182pub fn parse_hooks_from_frontmatter(content: &str) -> Option<HooksSettings> {
184 let trimmed = content.trim();
185
186 if !trimmed.starts_with("---") {
188 return None;
189 }
190 let frontmatter_end = trimmed[3..].find("---")?;
191 let frontmatter = &trimmed[3..frontmatter_end + 3];
192
193 let yaml_value: serde_yaml::Value = match serde_yaml::from_str(frontmatter) {
195 Ok(v) => v,
196 Err(e) => {
197 log::debug!("Failed to parse SKILL.md frontmatter as YAML: {}", e);
198 return None;
199 }
200 };
201
202 let hooks_value = yaml_value.get("hooks")?;
204
205 let hooks_json = yaml_to_json(hooks_value.clone())?;
208
209 let hooks: HooksSettings = match serde_json::from_value(hooks_json) {
212 Ok(h) => h,
213 Err(e) => {
214 log::debug!("Failed to deserialize hooks from YAML: {}", e);
215 return None;
216 }
217 };
218
219 if hooks.events.is_empty() {
220 return None;
221 }
222
223 Some(hooks)
224}
225
226fn yaml_to_json(value: serde_yaml::Value) -> Option<serde_json::Value> {
228 match value {
229 serde_yaml::Value::Null => Some(serde_json::Value::Null),
230 serde_yaml::Value::Bool(b) => Some(serde_json::Value::Bool(b)),
231 serde_yaml::Value::Number(n) => {
232 if let Some(v) = n.as_i64() {
233 Some(serde_json::Value::Number(v.into()))
234 } else if let Some(v) = n.as_u64() {
235 Some(serde_json::Value::Number(v.into()))
236 } else if let Some(v) = n.as_f64() {
237 serde_json::Number::from_f64(v).map(serde_json::Value::Number)
238 } else {
239 None
240 }
241 }
242 serde_yaml::Value::String(s) => Some(serde_json::Value::String(s)),
243 serde_yaml::Value::Sequence(seq) => {
244 let arr = seq.into_iter().filter_map(|v| yaml_to_json(v)).collect();
245 Some(serde_json::Value::Array(arr))
246 }
247 serde_yaml::Value::Mapping(map) => {
248 let obj = map
249 .into_iter()
250 .filter_map(|(k, v)| {
251 let key = match &k {
252 serde_yaml::Value::String(s) => s.clone(),
253 serde_yaml::Value::Number(n) => n.to_string(),
254 serde_yaml::Value::Bool(b) => b.to_string(),
255 _ => return None,
256 };
257 yaml_to_json(v).map(|val| (key, val))
258 })
259 .collect();
260 Some(serde_json::Value::Object(obj))
261 }
262 serde_yaml::Value::Tagged(ref tagged) => {
263 yaml_to_json(tagged.value.clone())
265 }
266 }
267}
268pub fn load_skill_from_dir(dir_path: &Path) -> Result<LoadedSkill, AgentError> {
269 let skill_file = dir_path.join("SKILL.md");
270 if !skill_file.exists() {
271 return Err(AgentError::Skill(format!(
272 "SKILL.md not found in {}",
273 dir_path.display()
274 )));
275 }
276
277 let content = fs::read_to_string(&skill_file).map_err(|e| AgentError::Io(e))?;
278
279 let (fields, body) = parse_frontmatter(&content);
280
281 let name = dir_path
282 .file_name()
283 .and_then(|n| n.to_str())
284 .unwrap_or("unknown")
285 .to_string();
286
287 let display_name = fields.get("name").cloned();
288 let version = fields.get("version").cloned();
289
290 let description = fields.get("description").cloned().unwrap_or_default();
291
292 let allowed_tools = fields
293 .get("allowed-tools")
294 .map(|s| s.split(',').map(|x| x.trim().to_string()).collect());
295
296 let argument_hint = fields.get("argument-hint").cloned();
297 let when_to_use = fields.get("when_to_use").cloned();
298 let user_invocable = fields.get("user-invocable").and_then(|v| match v.as_str() {
299 "true" | "1" => Some(true),
300 "false" | "0" => Some(false),
301 _ => None,
302 });
303
304 let arg_names = fields
305 .get("arg-names")
306 .map(|s| s.split(',').map(|x| x.trim().to_string()).collect());
307
308 let paths = fields
309 .get("paths")
310 .map(|s| s.split(',').map(|x| x.trim().to_string()).collect());
311
312 let effort = fields.get("effort").and_then(|s| EffortValue::from_str(s));
313
314 let context = fields
315 .get("context")
316 .and_then(|s| SkillContext::from_str(s));
317
318 let model = fields.get("model").cloned();
319 let agent = fields.get("agent").cloned();
320 let shell = fields.get("shell").cloned();
321
322 let hooks = if fields.contains_key("hooks") {
324 parse_hooks_from_frontmatter(&content)
325 } else {
326 None
327 };
328
329 let metadata = SkillMetadata {
330 name,
331 description,
332 display_name,
333 version,
334 allowed_tools,
335 argument_hint,
336 arg_names,
337 when_to_use,
338 user_invocable,
339 paths,
340 hooks,
341 effort,
342 model,
343 context,
344 agent,
345 shell,
346 };
347
348 Ok(LoadedSkill {
349 metadata,
350 content: body,
351 base_dir: dir_path.to_string_lossy().to_string(),
352 })
353}
354
355pub fn load_skills_from_dir(base_path: &Path, cwd: &Path) -> Result<Vec<LoadedSkill>, AgentError> {
357 if !base_path.exists() {
358 return Ok(Vec::new());
359 }
360
361 let mut skills = Vec::new();
362
363 let entries = fs::read_dir(base_path).map_err(|e| AgentError::Io(e))?;
364
365 for entry in entries {
366 let entry = entry.map_err(|e| AgentError::Io(e))?;
367 let path = entry.path();
368
369 if path.is_dir() {
370 if is_path_gitignored(&path, cwd) {
372 log::debug!(
373 "[skills] Skipped gitignored skill dir: {}",
374 path.display()
375 );
376 continue;
377 }
378
379 if let Ok(skill) = load_skill_from_dir(&path) {
380 skills.push(skill);
381 }
382 }
383 }
384
385 Ok(skills)
386}
387
388fn path_matches_patterns(path: &str, patterns: &[String]) -> bool {
391 for pattern in patterns {
392 if glob_match(pattern, path) {
393 return true;
394 }
395 }
396 false
397}
398
399fn glob_match(pattern: &str, path: &str) -> bool {
402 let regex_pattern = glob_to_regex(pattern);
404 if let Ok(re) = regex::Regex::new(®ex_pattern) {
405 re.is_match(path)
406 } else {
407 false
408 }
409}
410
411fn glob_to_regex(pattern: &str) -> String {
413 let mut regex = String::from("^");
414 let mut chars = pattern.chars().peekable();
415 let mut prev_was_doublestar = false;
416
417 while let Some(c) = chars.next() {
418 match c {
419 '*' => {
420 if chars.peek() == Some(&'*') {
421 chars.next();
422 prev_was_doublestar = true;
423 regex.push_str("(.*/)?");
425 } else {
426 prev_was_doublestar = false;
427 regex.push_str("[^/]*");
429 }
430 }
431 '/' if prev_was_doublestar => {
432 prev_was_doublestar = false;
435 }
436 '?' => regex.push('.'),
437 '[' => {
438 regex.push(c);
440 while let Some(&next) = chars.peek() {
441 regex.push(next);
442 chars.next();
443 if next == ']' {
444 break;
445 }
446 }
447 }
448 '.' | '+' | '^' | '$' | '(' | ')' | '|' | '\\' => {
449 regex.push('\\');
450 regex.push(c);
451 }
452 _ => regex.push(c),
453 }
454 }
455
456 regex.push('$');
457 regex
458}
459
460pub fn discover_skill_dirs_for_paths(
463 skills_dir: &Path,
464 touched_paths: &[String],
465) -> Result<Vec<PathBuf>, AgentError> {
466 if !skills_dir.exists() {
467 return Ok(Vec::new());
468 }
469
470 let mut matching_dirs = Vec::new();
471
472 let entries = fs::read_dir(skills_dir).map_err(|e| AgentError::Io(e))?;
473
474 for entry in entries {
475 let entry = entry.map_err(|e| AgentError::Io(e))?;
476 let path = entry.path();
477
478 if path.is_dir() {
479 if let Ok(skill) = load_skill_from_dir(&path) {
481 if let Some(skill_paths) = &skill.metadata.paths {
482 for touched in touched_paths {
484 if path_matches_patterns(touched, skill_paths) {
485 matching_dirs.push(path.clone());
486 break;
487 }
488 }
489 }
490 }
491 }
492 }
493
494 Ok(matching_dirs)
495}
496
497pub fn activate_conditional_skills_for_paths(
501 skills_dir: &Path,
502 touched_paths: &[String],
503) -> Result<Vec<LoadedSkill>, AgentError> {
504 if !skills_dir.exists() || touched_paths.is_empty() {
505 return Ok(Vec::new());
506 }
507
508 let mut active_skills = Vec::new();
509
510 let entries = fs::read_dir(skills_dir).map_err(|e| AgentError::Io(e))?;
511
512 for entry in entries {
513 let entry = entry.map_err(|e| AgentError::Io(e))?;
514 let path = entry.path();
515
516 if path.is_dir() {
517 if let Ok(skill) = load_skill_from_dir(&path) {
518 if let Some(skill_paths) = &skill.metadata.paths {
519 for touched in touched_paths {
521 if path_matches_patterns(touched, skill_paths) {
522 active_skills.push(skill);
523 break;
524 }
525 }
526 }
527 }
528 }
529 }
530
531 Ok(active_skills)
532}
533
534pub fn get_conditional_skills(skills_dir: &Path) -> Result<Vec<LoadedSkill>, AgentError> {
536 if !skills_dir.exists() {
537 return Ok(Vec::new());
538 }
539
540 let mut conditional_skills = Vec::new();
541
542 let entries = fs::read_dir(skills_dir).map_err(|e| AgentError::Io(e))?;
543
544 for entry in entries {
545 let entry = entry.map_err(|e| AgentError::Io(e))?;
546 let path = entry.path();
547
548 if path.is_dir() {
549 if let Ok(skill) = load_skill_from_dir(&path) {
550 if skill.metadata.paths.is_some() {
551 conditional_skills.push(skill);
552 }
553 }
554 }
555 }
556
557 Ok(conditional_skills)
558}
559
560#[derive(Debug, Clone, PartialEq)]
562pub enum SkillSource {
563 Bundled,
564 User,
565 Project,
566 Plugin,
567}
568
569impl std::fmt::Display for SkillSource {
570 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
571 match self {
572 SkillSource::Bundled => write!(f, "bundled"),
573 SkillSource::User => write!(f, "user"),
574 SkillSource::Project => write!(f, "project"),
575 SkillSource::Plugin => write!(f, "plugin"),
576 }
577 }
578}
579
580#[derive(Debug, Clone)]
582pub struct UnifiedSkill {
583 pub name: String,
584 pub description: String,
585 pub source: SkillSource,
586 pub content: String,
587 pub paths: Option<Vec<String>>,
588 pub user_invocable: Option<bool>,
589 pub hooks: Option<HooksSettings>,
590}
591
592pub fn get_user_skills_dir() -> Option<PathBuf> {
595 dirs::home_dir().map(|h| h.join(".ai").join("skills"))
596}
597
598pub fn get_project_skills_dir(cwd: &str) -> PathBuf {
600 Path::new(cwd).join(".ai").join("skills")
601}
602
603pub fn load_all_skills(cwd: &str) -> Result<Vec<UnifiedSkill>, AgentError> {
610 let mut skill_map: HashMap<String, UnifiedSkill> = HashMap::new();
611
612 let bundled_skills = crate::skills::bundled_skills::get_bundled_skills();
614 for bs in bundled_skills {
615 skill_map.insert(
616 bs.name.clone(),
617 UnifiedSkill {
618 name: bs.name,
619 description: bs.description,
620 source: SkillSource::Bundled,
621 content: String::new(),
622 paths: None,
623 user_invocable: Some(bs.user_invocable),
624 hooks: None,
625 },
626 );
627 }
628
629 if let Some(user_dir) = get_user_skills_dir() {
631 if let Ok(user_skills) = load_skills_from_dir(&user_dir, Path::new(cwd)) {
632 for us in user_skills {
633 skill_map.insert(
634 us.metadata.name.clone(),
635 UnifiedSkill {
636 name: us.metadata.name,
637 description: us.metadata.description,
638 source: SkillSource::User,
639 content: us.content,
640 paths: us.metadata.paths,
641 user_invocable: us.metadata.user_invocable,
642 hooks: us.metadata.hooks,
643 },
644 );
645 }
646 }
647 }
648
649 let project_dir = get_project_skills_dir(cwd);
651 if let Ok(project_skills) = load_skills_from_dir(&project_dir, Path::new(cwd)) {
652 for ps in project_skills {
653 skill_map.insert(
654 ps.metadata.name.clone(),
655 UnifiedSkill {
656 name: ps.metadata.name,
657 description: ps.metadata.description,
658 source: SkillSource::Project,
659 content: ps.content,
660 paths: ps.metadata.paths,
661 user_invocable: ps.metadata.user_invocable,
662 hooks: ps.metadata.hooks,
663 },
664 );
665 }
666 }
667
668 let mut all_skills: Vec<UnifiedSkill> = skill_map.into_values().collect();
669
670 all_skills.sort_by(|a, b| {
672 let source_order = |s: &SkillSource| -> u8 {
673 match s {
674 SkillSource::Project => 0,
675 SkillSource::User => 1,
676 SkillSource::Bundled => 2,
677 SkillSource::Plugin => 3,
678 }
679 };
680 source_order(&a.source)
681 .cmp(&source_order(&b.source))
682 .then_with(|| a.name.cmp(&b.name))
683 });
684
685 Ok(all_skills)
686}
687
688#[derive(Debug, Clone, Hash, Eq, PartialEq)]
700#[allow(dead_code)]
701pub struct SkillsDirKey {
702 pub base_path: String,
703 pub cwd: String,
704}
705
706#[allow(dead_code)]
709static LOAD_ALL_SKILLS_MEMO: Lazy<
710 crate::utils::memoize::LruMemoized<String, String, Result<Vec<UnifiedSkill>, String>>,
711> = Lazy::new(|| {
712 memoize_with_lru(
713 |cwd: String| load_all_skills(&cwd).map_err(|e| e.to_string()),
714 |cwd: &String| cwd.clone(),
715 50, )
717});
718
719#[allow(dead_code)]
723pub fn load_all_skills_cached(cwd: &str) -> Result<Vec<UnifiedSkill>, String> {
724 LOAD_ALL_SKILLS_MEMO.call(cwd.to_string())
725}
726
727#[allow(dead_code)]
730static LOAD_SKILLS_FROM_DIR_MEMO: Lazy<
731 crate::utils::memoize::LruMemoized<
732 SkillsDirKey,
733 SkillsDirKey,
734 Result<Vec<LoadedSkill>, String>,
735 >,
736> = Lazy::new(|| {
737 memoize_with_lru(
738 |key: SkillsDirKey| {
739 load_skills_from_dir(Path::new(&key.base_path), Path::new(&key.cwd))
740 .map_err(|e| e.to_string())
741 },
742 |key: &SkillsDirKey| key.clone(),
743 50, )
745});
746
747#[allow(dead_code)]
750pub fn load_skills_from_dir_cached(
751 base_path: &str,
752 cwd: &str,
753) -> Result<Vec<LoadedSkill>, String> {
754 let key = SkillsDirKey {
755 base_path: base_path.to_string(),
756 cwd: cwd.to_string(),
757 };
758 LOAD_SKILLS_FROM_DIR_MEMO.call(key)
759}
760
761fn create_skill_command_for_mcp(
770 params: &crate::skills::mcp_skill_builders::LoadedSkillCommandParams,
771) -> crate::skills::bundled_skills::BundledSkillDefinition {
772 use crate::skills::bundled_skills::{BundledSkillDefinition, ContentBlock, SkillContext};
773 use crate::AgentError;
774
775 let markdown_content = params.markdown_content.clone();
776 let base_dir = params.base_dir.clone();
777 let argument_names = params.argument_names.clone();
778
779 crate::skills::bundled_skills::BundledSkillDefinition {
780 name: params.skill_name.clone(),
781 description: params.description.clone(),
782 aliases: params
783 .display_name
784 .as_ref()
785 .map(|d| vec![d.clone()]),
786 when_to_use: params.when_to_use.clone(),
787 argument_hint: params.argument_hint.clone(),
788 allowed_tools: params.allowed_tools.clone(),
789 model: params.model.clone(),
790 disable_model_invocation: Some(params.disable_model_invocation),
791 user_invocable: Some(params.user_invocable),
792 is_enabled: None,
793 hooks: None,
794 context: None,
795 agent: None,
796 files: None,
797 get_prompt_for_command: std::sync::Arc::new(move |args: &str, _ctx: &SkillContext| {
798 let mut content = markdown_content.clone();
799
800 if !base_dir.is_empty() {
802 let skill_dir = base_dir.replace('\\', "/");
803 content = content.replace("${CLAUDE_SKILL_DIR}", &skill_dir);
804 }
805
806 content = content.replace(
808 "${CLAUDE_SESSION_ID}",
809 &std::env::var("AI_SESSION_ID").unwrap_or_default(),
810 );
811
812 if let Some(ref arg_names) = argument_names {
814 for (i, name) in arg_names.iter().enumerate() {
815 let placeholder = format!("${}", name);
816 let args_vec: Vec<&str> = args.split_whitespace().collect();
817 if let Some(val) = args_vec.get(i) {
818 content = content.replace(&placeholder, val);
819 }
820 }
821 }
822
823 let final_content = if !base_dir.is_empty() {
825 format!("Base directory for this skill: {}\n\n{}", base_dir, content)
826 } else {
827 content
828 };
829
830 Ok(vec![ContentBlock::Text {
831 text: final_content,
832 }])
833 }),
834 }
835}
836
837fn parse_skill_frontmatter_fields_for_mcp(
838 content: &str,
839) -> crate::skills::mcp_skill_builders::SkillFrontmatterFields {
840 crate::skills::mcp_skill_builders::default_parse_skill_frontmatter_fields(content)
841}
842
843static MCP_SKILL_BUILDERS_INIT: once_cell::sync::OnceCell<()> = once_cell::sync::OnceCell::new();
847
848fn init_mcp_skill_builders() {
849 let _ = MCP_SKILL_BUILDERS_INIT.get_or_init(|| {
850 use crate::skills::mcp_skill_builders::{register_mcp_skill_builders, LoadedSkillCommandParams, SkillFrontmatterFields};
851
852 let create_fn: Box<dyn Fn(&LoadedSkillCommandParams) -> crate::skills::bundled_skills::BundledSkillDefinition + Send + Sync> =
853 Box::new(create_skill_command_for_mcp);
854 let parse_fn: Box<dyn Fn(&str) -> SkillFrontmatterFields + Send + Sync> =
855 Box::new(parse_skill_frontmatter_fields_for_mcp);
856
857 register_mcp_skill_builders(create_fn, parse_fn);
858 });
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864 use std::hash::Hasher;
865 use std::io::Write;
866
867 #[test]
868 fn test_glob_match_simple() {
869 assert!(glob_match("*.rs", "main.rs"));
870 assert!(glob_match("*.rs", "lib.rs"));
871 assert!(!glob_match("*.rs", "main.py"));
872 }
873
874 #[test]
875 fn test_glob_match_double_star() {
876 assert!(glob_match("src/**/*.ts", "src/foo.ts"));
877 assert!(glob_match("src/**/*.ts", "src/bar/baz.ts"));
878 assert!(!glob_match("src/**/*.ts", "tests/foo.ts"));
879 }
880
881 #[test]
882 fn test_glob_match_question() {
883 assert!(glob_match("file?.txt", "file1.txt"));
884 assert!(glob_match("file?.txt", "filea.txt"));
885 assert!(!glob_match("file?.txt", "file12.txt"));
886 }
887
888 #[test]
889 fn test_effort_value() {
890 assert_eq!(EffortValue::as_str(&EffortValue::High), "high");
891 assert_eq!(EffortValue::from_str("medium"), Some(EffortValue::Medium));
892 assert_eq!(EffortValue::from_str("invalid"), None);
893 }
894
895 #[test]
896 fn test_skill_context() {
897 assert_eq!(SkillContext::as_str(&SkillContext::Fork), "fork");
898 assert_eq!(SkillContext::from_str("inline"), Some(SkillContext::Inline));
899 assert_eq!(SkillContext::from_str("invalid"), None);
900 }
901
902 #[test]
903 fn test_get_user_skills_dir() {
904 let dir = get_user_skills_dir();
905 if let Some(d) = dir {
907 assert!(d.to_string_lossy().ends_with(".ai/skills"));
908 }
909 }
910
911 #[test]
912 fn test_get_project_skills_dir() {
913 let dir = get_project_skills_dir("/my/project");
914 assert_eq!(dir, PathBuf::from("/my/project/.ai/skills"));
915 }
916
917 #[test]
918 fn test_load_all_skills_no_skills() {
919 let result = load_all_skills("/tmp/nonexistent_dir_12345");
921 assert!(result.is_ok());
922 }
923
924 #[test]
925 fn test_load_all_skills_from_temp_dir() {
926 use std::io::Write;
927 let temp = tempfile::tempdir().unwrap();
928 let cwd = temp.path().to_string_lossy().to_string();
929
930 let skill_dir = temp.path().join(".ai").join("skills").join("test-skill");
932 std::fs::create_dir_all(&skill_dir).unwrap();
933 let mut skill_file = std::fs::File::create(skill_dir.join("SKILL.md")).unwrap();
934 writeln!(skill_file, "---").unwrap();
935 writeln!(skill_file, "description: Test skill from project").unwrap();
936 writeln!(skill_file, "---").unwrap();
937 writeln!(skill_file, "Test skill content").unwrap();
938
939 let result = load_all_skills(&cwd).unwrap();
940 let test_skill = result.iter().find(|s| s.name == "test-skill");
941 assert!(test_skill.is_some());
942 assert_eq!(test_skill.unwrap().source, SkillSource::Project);
943 }
944
945 #[test]
946 fn test_skill_source_display() {
947 assert_eq!(format!("{}", SkillSource::Bundled), "bundled");
948 assert_eq!(format!("{}", SkillSource::User), "user");
949 assert_eq!(format!("{}", SkillSource::Project), "project");
950 assert_eq!(format!("{}", SkillSource::Plugin), "plugin");
951 }
952
953 #[test]
954 fn test_unified_skill_creation() {
955 let skill = UnifiedSkill {
956 name: "test".to_string(),
957 description: "A test skill".to_string(),
958 source: SkillSource::Project,
959 content: "content".to_string(),
960 paths: Some(vec!["*.rs".to_string()]),
961 user_invocable: Some(true),
962 hooks: None,
963 };
964 assert_eq!(skill.name, "test");
965 assert!(skill.user_invocable.unwrap());
966 }
967
968 #[test]
969 fn test_parse_hooks_from_frontmatter_valid() {
970 let content = r#"---
971name: test-skill
972description: A test skill with hooks
973hooks:
974 Stop:
975 - matcher: ""
976 hooks:
977 - type: command
978 command: "echo skill-stop"
979 PreToolUse:
980 - matcher: "Bash"
981 hooks:
982 - type: command
983 command: "echo pre-bash"
984 timeout: 10
985---
986Skill content here
987"#;
988 let hooks = parse_hooks_from_frontmatter(content);
989 assert!(hooks.is_some());
990 let hooks = hooks.unwrap();
991
992 assert!(hooks.events.contains_key("Stop"));
994 assert!(hooks.events.contains_key("PreToolUse"));
995 assert!(!hooks.events.is_empty());
996 }
997
998 #[test]
999 fn test_parse_hooks_from_frontmatter_no_hooks() {
1000 let content = r#"---
1001name: test-skill
1002description: A test skill without hooks
1003---
1004Skill content here
1005"#;
1006 let hooks = parse_hooks_from_frontmatter(content);
1007 assert!(hooks.is_none());
1008 }
1009
1010 #[test]
1011 fn test_parse_hooks_from_frontmatter_no_frontmatter() {
1012 let content = "Just plain text content";
1013 let hooks = parse_hooks_from_frontmatter(content);
1014 assert!(hooks.is_none());
1015 }
1016
1017 #[test]
1018 fn test_parse_hooks_from_frontmatter_empty_hooks() {
1019 let content = r#"---
1020name: test-skill
1021hooks: {}
1022---
1023Content
1024"#;
1025 let hooks = parse_hooks_from_frontmatter(content);
1026 assert!(hooks.is_none());
1028 }
1029
1030 #[test]
1031 fn test_yaml_to_json_basic_types() {
1032 let yaml_str = r#"
1033null_val: null
1034bool_val: true
1035int_val: 42
1036str_val: hello
1037list_val:
1038 - a
1039 - b
1040map_val:
1041 key: value
1042"#;
1043 let yaml_val: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
1044 let json = yaml_to_json(yaml_val).unwrap();
1045
1046 assert_eq!(json["null_val"], serde_json::Value::Null);
1047 assert_eq!(json["bool_val"], true);
1048 assert_eq!(json["int_val"], 42);
1049 assert_eq!(json["str_val"], "hello");
1050 assert!(json["list_val"].is_array());
1051 assert_eq!(json["list_val"][0], "a");
1052 assert_eq!(json["map_val"]["key"], "value");
1053 }
1054
1055 #[test]
1056 fn test_load_skill_with_hooks() {
1057 use std::io::Write;
1058 let temp = tempfile::tempdir().unwrap();
1059 let skill_dir = temp.path().join("hook-skill");
1060 std::fs::create_dir_all(&skill_dir).unwrap();
1061
1062 let mut skill_file = std::fs::File::create(skill_dir.join("SKILL.md")).unwrap();
1063 writeln!(skill_file, "---").unwrap();
1064 writeln!(skill_file, "description: Skill with hooks").unwrap();
1065 writeln!(skill_file, "hooks:").unwrap();
1066 writeln!(skill_file, " Stop:").unwrap();
1067 writeln!(skill_file, " - matcher: \"\"").unwrap();
1068 writeln!(skill_file, " hooks:").unwrap();
1069 writeln!(skill_file, " - type: command").unwrap();
1070 writeln!(skill_file, " command: echo done").unwrap();
1071 writeln!(skill_file, "---").unwrap();
1072 writeln!(skill_file, "Skill body").unwrap();
1073
1074 let skill = load_skill_from_dir(&skill_dir).unwrap();
1075 assert_eq!(skill.metadata.name, "hook-skill");
1076 assert!(skill.metadata.hooks.is_some());
1077 let hooks = skill.metadata.hooks.unwrap();
1078 assert!(hooks.events.contains_key("Stop"));
1079 }
1080
1081 #[test]
1082 fn test_load_skill_without_hooks() {
1083 use std::io::Write;
1084 let temp = tempfile::tempdir().unwrap();
1085 let skill_dir = temp.path().join("no-hook-skill");
1086 std::fs::create_dir_all(&skill_dir).unwrap();
1087
1088 let mut skill_file = std::fs::File::create(skill_dir.join("SKILL.md")).unwrap();
1089 writeln!(skill_file, "---").unwrap();
1090 writeln!(skill_file, "description: Skill without hooks").unwrap();
1091 writeln!(skill_file, "---").unwrap();
1092 writeln!(skill_file, "Skill body").unwrap();
1093
1094 let skill = load_skill_from_dir(&skill_dir).unwrap();
1095 assert!(skill.metadata.hooks.is_none());
1096 }
1097
1098 #[test]
1099 fn test_load_skills_from_dir_skips_gitignored() {
1100 use std::io::Write;
1101
1102 let temp = tempfile::tempdir().unwrap();
1103 let repo_root = temp.path();
1104
1105 std::process::Command::new("git")
1107 .args(["init"])
1108 .current_dir(repo_root)
1109 .output()
1110 .expect("git init failed");
1111
1112 let gitignore_path = repo_root.join(".gitignore");
1114 let mut gitignore_file = std::fs::File::create(&gitignore_path).unwrap();
1115 writeln!(gitignore_file, "ignored-skill/").unwrap();
1116 drop(gitignore_file);
1117
1118 let skills_dir = repo_root.join(".ai").join("skills");
1120 std::fs::create_dir_all(&skills_dir).unwrap();
1121
1122 let normal_skill_dir = skills_dir.join("normal-skill");
1124 std::fs::create_dir_all(&normal_skill_dir).unwrap();
1125 let mut normal_skill_file =
1126 std::fs::File::create(normal_skill_dir.join("SKILL.md")).unwrap();
1127 writeln!(normal_skill_file, "---").unwrap();
1128 writeln!(normal_skill_file, "description: Normal skill").unwrap();
1129 writeln!(normal_skill_file, "---").unwrap();
1130 writeln!(normal_skill_file, "Normal skill content").unwrap();
1131 drop(normal_skill_file);
1132
1133 let ignored_skill_dir = skills_dir.join("ignored-skill");
1135 std::fs::create_dir_all(&ignored_skill_dir).unwrap();
1136 let mut ignored_skill_file =
1137 std::fs::File::create(ignored_skill_dir.join("SKILL.md")).unwrap();
1138 writeln!(ignored_skill_file, "---").unwrap();
1139 writeln!(ignored_skill_file, "description: Ignored skill").unwrap();
1140 writeln!(ignored_skill_file, "---").unwrap();
1141 writeln!(ignored_skill_file, "Ignored skill content").unwrap();
1142 drop(ignored_skill_file);
1143
1144 let skills =
1146 load_skills_from_dir(&skills_dir, repo_root).expect("failed to load skills");
1147
1148 assert_eq!(skills.len(), 1);
1150 assert_eq!(skills[0].metadata.name, "normal-skill");
1151 }
1152
1153 #[test]
1158 fn test_load_all_skills_memoization() {
1159 use std::io::Write;
1160
1161 LOAD_ALL_SKILLS_MEMO.clear();
1163
1164 let temp = tempfile::tempdir().unwrap();
1165 let cwd = temp.path().to_string_lossy().to_string();
1166
1167 let skill_dir = temp.path().join(".ai").join("skills").join("memo-test");
1169 std::fs::create_dir_all(&skill_dir).unwrap();
1170 let mut skill_file = std::fs::File::create(skill_dir.join("SKILL.md")).unwrap();
1171 writeln!(skill_file, "---").unwrap();
1172 writeln!(skill_file, "description: Memo test skill").unwrap();
1173 writeln!(skill_file, "---").unwrap();
1174 writeln!(skill_file, "Body").unwrap();
1175 drop(skill_file);
1176
1177 let skills1 = load_all_skills_cached(&cwd).unwrap();
1179 let has_skill1 = skills1.iter().any(|s| s.name == "memo-test");
1180 assert!(has_skill1);
1181
1182 let skills2 = load_all_skills_cached(&cwd).unwrap();
1184 let has_skill2 = skills2.iter().any(|s| s.name == "memo-test");
1185 assert!(has_skill2);
1186
1187 assert_eq!(skills1.len(), skills2.len());
1189 }
1190
1191 #[test]
1192 fn test_load_skills_from_dir_memoization() {
1193 use std::io::Write;
1194
1195 LOAD_SKILLS_FROM_DIR_MEMO.clear();
1197
1198 let temp = tempfile::tempdir().unwrap();
1199 let base_dir = temp.path().join(".ai").join("skills");
1200 std::fs::create_dir_all(&base_dir).unwrap();
1201
1202 let skill_dir = base_dir.join("cached-skill");
1204 std::fs::create_dir_all(&skill_dir).unwrap();
1205 let mut sf = std::fs::File::create(skill_dir.join("SKILL.md")).unwrap();
1206 writeln!(sf, "---").unwrap();
1207 writeln!(sf, "description: Cached skill").unwrap();
1208 writeln!(sf, "---").unwrap();
1209 writeln!(sf, "Body").unwrap();
1210 drop(sf);
1211
1212 let base_str = base_dir.to_string_lossy().to_string();
1213 let cwd_str = temp.path().to_string_lossy().to_string();
1214
1215 let skills1 = load_skills_from_dir_cached(&base_str, &cwd_str).unwrap();
1217 assert_eq!(skills1.len(), 1);
1218 assert_eq!(skills1[0].metadata.name, "cached-skill");
1219
1220 let skills2 = load_skills_from_dir_cached(&base_str, &cwd_str).unwrap();
1222 assert_eq!(skills2.len(), 1);
1223 assert_eq!(skills2[0].metadata.name, "cached-skill");
1224 }
1225
1226 #[test]
1227 fn test_lru_memoization_eviction() {
1228 use std::io::Write;
1229
1230 LOAD_ALL_SKILLS_MEMO.clear();
1232
1233 let temps: Vec<tempfile::TempDir> = (0..55)
1236 .map(|i| {
1237 let temp = tempfile::tempdir().unwrap();
1238 let skill_dir = temp.path().join(".ai").join("skills").join(format!("skill-{i}"));
1239 std::fs::create_dir_all(&skill_dir).unwrap();
1240 let mut sf = std::fs::File::create(skill_dir.join("SKILL.md")).unwrap();
1241 writeln!(sf, "---").unwrap();
1242 writeln!(sf, "description: Skill {i}").unwrap();
1243 writeln!(sf, "---").unwrap();
1244 writeln!(sf, "Body {i}").unwrap();
1245 drop(sf);
1246 temp
1247 })
1248 .collect();
1249
1250 let cwd_vec: Vec<String> = temps
1252 .iter()
1253 .map(|t| t.path().to_string_lossy().to_string())
1254 .collect();
1255
1256 for cwd in &cwd_vec {
1257 let _ = load_all_skills_cached(cwd);
1258 }
1259
1260 let first_cwd = &cwd_vec[0];
1263 let _ = load_all_skills_cached(first_cwd);
1264
1265 let middle_cwd = &cwd_vec[30];
1268 let skills = load_all_skills_cached(middle_cwd).unwrap();
1269 assert!(skills.iter().any(|s| s.name == "skill-30"));
1271
1272 assert!(
1275 LOAD_ALL_SKILLS_MEMO.size() <= 50,
1276 "Cache size {} exceeds max 50",
1277 LOAD_ALL_SKILLS_MEMO.size()
1278 );
1279 }
1280
1281 #[test]
1282 fn test_skills_dir_key_equality() {
1283 use std::collections::hash_map::DefaultHasher;
1284 use std::hash::{Hash, Hasher};
1285
1286 let k1 = SkillsDirKey {
1287 base_path: "/a".to_string(),
1288 cwd: "/b".to_string(),
1289 };
1290 let k2 = SkillsDirKey {
1291 base_path: "/a".to_string(),
1292 cwd: "/b".to_string(),
1293 };
1294 let k3 = SkillsDirKey {
1295 base_path: "/c".to_string(),
1296 cwd: "/d".to_string(),
1297 };
1298 assert_eq!(k1, k2);
1299 assert_ne!(k1, k3);
1300 let mut h1 = DefaultHasher::new();
1302 let mut h2 = DefaultHasher::new();
1303 k1.hash(&mut h1);
1304 k2.hash(&mut h2);
1305 assert_eq!(h1.finish(), h2.finish());
1306 }
1307
1308 #[test]
1309 fn test_memoization_different_keys_return_different_results() {
1310 use std::io::Write;
1311
1312 LOAD_ALL_SKILLS_MEMO.clear();
1313
1314 let temp_a = tempfile::tempdir().unwrap();
1316 let temp_b = tempfile::tempdir().unwrap();
1317
1318 for (temp, name) in [(&temp_a, "skill-a"), (&temp_b, "skill-b")] {
1319 let skill_dir = temp.path().join(".ai").join("skills").join(name);
1320 std::fs::create_dir_all(&skill_dir).unwrap();
1321 let mut sf = std::fs::File::create(skill_dir.join("SKILL.md")).unwrap();
1322 writeln!(sf, "---").unwrap();
1323 writeln!(sf, "description: {name}").unwrap();
1324 writeln!(sf, "---").unwrap();
1325 writeln!(sf, "Body").unwrap();
1326 drop(sf);
1327 }
1328
1329 let cwd_a = temp_a.path().to_string_lossy().to_string();
1330 let cwd_b = temp_b.path().to_string_lossy().to_string();
1331
1332 let skills_a = load_all_skills_cached(&cwd_a).unwrap();
1333 let skills_b = load_all_skills_cached(&cwd_b).unwrap();
1334
1335 assert!(skills_a.iter().any(|s| s.name == "skill-a"));
1336 assert!(!skills_a.iter().any(|s| s.name == "skill-b"));
1337 assert!(skills_b.iter().any(|s| s.name == "skill-b"));
1338 assert!(!skills_b.iter().any(|s| s.name == "skill-a"));
1339 }
1340
1341 #[test]
1342 fn test_substitute_env_vars_in_skill() {
1343 let content = "Script in ${CLAUDE_SKILL_DIR}/bin/run.sh";
1345 let result = substitute_env_vars_in_skill(&content, "/home/user/.ai/skills/my-skill");
1346 assert_eq!(result, "Script in /home/user/.ai/skills/my-skill/bin/run.sh");
1347
1348 let content = "Session: ${CLAUDE_SESSION_ID}";
1350 let result = substitute_env_vars_in_skill(&content, "/some/dir");
1351 assert!(!result.contains("${CLAUDE_SESSION_ID}"));
1353 assert!(result.starts_with("Session: "));
1354
1355 let content = "Dir: ${CLAUDE_SKILL_DIR}, Session: ${CLAUDE_SESSION_ID}";
1357 let result = substitute_env_vars_in_skill(&content, "/skills/test");
1358 assert!(!result.contains("${CLAUDE_SKILL_DIR}"));
1359 assert!(!result.contains("${CLAUDE_SESSION_ID}"));
1360 assert!(result.contains("Dir: /skills/test"));
1361 }
1362
1363 #[test]
1364 fn test_estimate_skill_frontmatter_tokens() {
1365 let metadata = SkillMetadata {
1366 name: "my-skill".to_string(),
1367 description: "A skill that does something useful".to_string(),
1368 display_name: None,
1369 version: None,
1370 allowed_tools: None,
1371 argument_hint: None,
1372 arg_names: None,
1373 when_to_use: Some("When you need help".to_string()),
1374 user_invocable: None,
1375 paths: None,
1376 hooks: None,
1377 effort: None,
1378 model: None,
1379 context: None,
1380 agent: None,
1381 shell: None,
1382 };
1383 let tokens = estimate_skill_frontmatter_tokens(&metadata);
1384 assert!(tokens > 0);
1387
1388 let empty = SkillMetadata {
1390 name: "".to_string(),
1391 description: "".to_string(),
1392 display_name: None,
1393 version: None,
1394 allowed_tools: None,
1395 argument_hint: None,
1396 arg_names: None,
1397 when_to_use: None,
1398 user_invocable: None,
1399 paths: None,
1400 hooks: None,
1401 effort: None,
1402 model: None,
1403 context: None,
1404 agent: None,
1405 shell: None,
1406 };
1407 let empty_tokens = estimate_skill_frontmatter_tokens(&empty);
1408 assert_eq!(empty_tokens, 0);
1409 }
1410
1411 #[test]
1412 fn test_load_skill_parses_version_and_display_name() {
1413 use std::io::Write;
1414
1415 let temp = tempfile::tempdir().unwrap();
1416 let skill_dir = temp.path().join("versioned-skill");
1417 std::fs::create_dir_all(&skill_dir).unwrap();
1418
1419 let mut skill_file = std::fs::File::create(skill_dir.join("SKILL.md")).unwrap();
1420 writeln!(skill_file, "---").unwrap();
1421 writeln!(skill_file, "name: My Display Name").unwrap();
1422 writeln!(skill_file, "version: 2.1.0").unwrap();
1423 writeln!(skill_file, "description: A versioned skill").unwrap();
1424 writeln!(skill_file, "---").unwrap();
1425 writeln!(skill_file, "Skill body content").unwrap();
1426 drop(skill_file);
1427
1428 let skill = load_skill_from_dir(&skill_dir).unwrap();
1429 assert_eq!(skill.metadata.name, "versioned-skill");
1430 assert_eq!(skill.metadata.display_name.as_deref(), Some("My Display Name"));
1431 assert_eq!(skill.metadata.version.as_deref(), Some("2.1.0"));
1432 assert_eq!(skill.metadata.description, "A versioned skill");
1433 }
1434}