Skip to main content

ai_agent/skills/
loader.rs

1//! Skill loader - loads skills from SKILL.md files
2//!
3//! Loads external skills from directories containing SKILL.md files.
4//! Supports conditional skills with paths frontmatter for dynamic activation.
5
6use 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/// Effort level for skills
15#[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/// Execution context for skills
49#[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
73/// Hooks settings for skills — uses HashMap format matching register_skill_hooks.
74/// Re-exported from the hooks module for use in skill frontmatter parsing.
75pub use crate::utils::hooks::register_skill_hooks::HooksSettings;
76pub use crate::utils::hooks::register_skill_hooks::HookMatcher;
77
78/// Skill metadata parsed from SKILL.md frontmatter
79#[derive(Debug, Clone)]
80pub struct SkillMetadata {
81    pub name: String,
82    pub description: String,
83    /// Display name parsed from frontmatter `name` field (TS: displayName)
84    pub display_name: Option<String>,
85    /// Version parsed from frontmatter `version` field
86    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    /// Conditional paths - skill is activated when these paths are touched
93    pub paths: Option<Vec<String>>,
94    /// Hooks for this skill
95    pub hooks: Option<HooksSettings>,
96    /// Effort level
97    pub effort: Option<EffortValue>,
98    /// Model to use for this skill
99    pub model: Option<String>,
100    /// Execution context (inline or fork)
101    pub context: Option<SkillContext>,
102    /// Agent to use for this skill
103    pub agent: Option<String>,
104    /// Shell for embedded command execution (bash or powershell)
105    pub shell: Option<String>,
106}
107
108/// Loaded skill with its metadata and content
109#[derive(Debug, Clone)]
110pub struct LoadedSkill {
111    pub metadata: SkillMetadata,
112    pub content: String,
113    pub base_dir: String,
114}
115
116/// Parse simple frontmatter (key: value format)
117fn 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
145/// Substitute `${CLAUDE_SKILL_DIR}` and `${CLAUDE_SESSION_ID}` environment
146/// variables in skill content strings.
147///
148/// Matches TypeScript behavior at loadSkillsDir.ts lines 362-368 where
149/// `getPromptForCommand` replaces `${CLAUDE_SKILL_DIR}` with the skill's
150/// base directory and `${CLAUDE_SESSION_ID}` with the current session ID.
151pub fn substitute_env_vars_in_skill(content: &str, base_dir: &str) -> String {
152    let session_id = crate::bootstrap::state::get_session_id();
153    // Normalize backslashes to forward slashes on Windows so shell commands
154    // don't treat them as escapes.
155    #[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
165/// Estimate token count for a skill based on frontmatter only
166/// (name, description, when_to_use) since full content is only loaded on invocation.
167///
168/// Matches TypeScript `estimateSkillFrontmatterTokens` at loadSkillsDir.ts lines 97-105.
169pub 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
182/// Load a skill from a directory containing SKILL.md
183pub fn parse_hooks_from_frontmatter(content: &str) -> Option<HooksSettings> {
184    let trimmed = content.trim();
185
186    // Extract frontmatter block between --- delimiters
187    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    // Parse the entire frontmatter as YAML to get complex structures
194    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    // Extract the 'hooks' field as a serde_yaml::Value
203    let hooks_value = yaml_value.get("hooks")?;
204
205    // Convert serde_yaml::Value to serde_json::Value for deserialization
206    // into HooksSettings (which uses serde_json::Value in HookMatcher.hooks)
207    let hooks_json = yaml_to_json(hooks_value.clone())?;
208
209    // Deserialize into HooksSettings
210    // The HooksSettings uses #[serde(flatten)] with HashMap<String, Vec<HookMatcher>>
211    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
226/// Convert a serde_yaml::Value to serde_json::Value
227fn 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            // Handle tagged YAML values by extracting the value
264            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    // Parse hooks from YAML frontmatter block
323    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
355/// Load all skills from a skills directory (skill-name/SKILL.md format)
356pub 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            // Skip skill directories that are gitignored
371            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
388/// Check if a path matches any of the given glob patterns
389/// Supports patterns like "*.rs", "src/**/*.ts", "**/test*.py"
390fn 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
399/// Simple glob matching function
400/// Supports: * (any characters), ** (any path segments), ? (single character)
401fn glob_match(pattern: &str, path: &str) -> bool {
402    // Convert glob to regex and match
403    let regex_pattern = glob_to_regex(pattern);
404    if let Ok(re) = regex::Regex::new(&regex_pattern) {
405        re.is_match(path)
406    } else {
407        false
408    }
409}
410
411/// Convert glob pattern to regex pattern
412fn 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                    // ** matches zero or more path segments (any characters including /)
424                    regex.push_str("(.*/)?");
425                } else {
426                    prev_was_doublestar = false;
427                    // * matches any characters except /
428                    regex.push_str("[^/]*");
429                }
430            }
431            '/' if prev_was_doublestar => {
432                // After **, the slash is already included in the (.*/)? pattern,
433                // so we skip it here
434                prev_was_doublestar = false;
435            }
436            '?' => regex.push('.'),
437            '[' => {
438                // Character class - pass through until ]
439                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
460/// Discover skill directories that match the given file paths
461/// This implements discoverSkillDirsForPaths from TypeScript
462pub 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            // Load the skill to check its paths
480            if let Ok(skill) = load_skill_from_dir(&path) {
481                if let Some(skill_paths) = &skill.metadata.paths {
482                    // Check if any touched path matches the skill's paths
483                    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
497/// Activate conditional skills for given file paths
498/// Returns skills that should be active based on the touched files
499/// This implements activateConditionalSkillsForPaths from TypeScript
500pub 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                    // Check if any touched path matches the skill's paths
520                    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
534/// Get all conditional skills (skills with paths frontmatter)
535pub 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/// Source of a loaded skill.
561#[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/// Unified skill entry from any source.
581#[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
592/// Resolve the user skills directory (~/.ai/skills).
593/// Returns None if the home directory cannot be determined.
594pub fn get_user_skills_dir() -> Option<PathBuf> {
595    dirs::home_dir().map(|h| h.join(".ai").join("skills"))
596}
597
598/// Resolve the project skills directory (<cwd>/.ai/skills).
599pub fn get_project_skills_dir(cwd: &str) -> PathBuf {
600    Path::new(cwd).join(".ai").join("skills")
601}
602
603/// Load skills from all sources: bundled, user (~/.ai/skills), project (<cwd>/.ai/skills).
604///
605/// Skills are deduplicated by name. Later sources override earlier ones:
606/// Project > User > Bundled.
607///
608/// Returns a Vec of UnifiedSkill sorted by source priority (project first).
609pub fn load_all_skills(cwd: &str) -> Result<Vec<UnifiedSkill>, AgentError> {
610    let mut skill_map: HashMap<String, UnifiedSkill> = HashMap::new();
611
612    // 1. Load bundled skills
613    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    // 2. Load user skills (~/.ai/skills)
630    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    // 3. Load project skills (<cwd>/.ai/skills)
650    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    // Sort: project first, then user, then bundled (alphabetical within)
671    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// ---------------------------------------------------------------------------
689// Memoized (LRU-cached) skill loading
690// ---------------------------------------------------------------------------
691// Matches TS `memoize(getSkillDirCommands)` from loadSkillsDir.ts.
692//
693// `AgentError` does not derive Clone, so the cached variants return
694// `Result<..., String>` instead.  The original unmemoized functions remain
695// public and return `Result<..., AgentError>`.
696
697/// Key for `load_skills_from_dir` memoization.
698/// Bundles the two string arguments into a single cache key.
699#[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/// Memoized version of `load_all_skills`, keyed by cwd.
707/// Matches TS `memoize(getSkillDirCommands)` from loadSkillsDir.ts
708#[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, // Max 50 cached cwd entries
716    )
717});
718
719/// Memoized version of `load_all_skills(cwd)`.
720/// Caches results per working directory to avoid re-scanning the filesystem
721/// on every turn.
722#[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/// Memoized version of `load_skills_from_dir`.
728/// Keyed by (base_path, cwd) tuple to avoid redundant filesystem scans.
729#[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, // Max 50 cached entries
744    )
745});
746
747/// Memoized version of `load_skills_from_dir(base_path, cwd)`.
748/// Caches results per directory path to avoid re-scanning the filesystem.
749#[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
761// ============================================================================
762// MCP skill builders registration
763//
764// Registers the two loadSkillsDir functions that MCP skill discovery needs.
765// This write-once registry breaks the circular dependency between MCP skill
766// discovery and the skill loader.
767// ============================================================================
768
769fn 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            // Substitute CLAUDE_SKILL_DIR
801            if !base_dir.is_empty() {
802                let skill_dir = base_dir.replace('\\', "/");
803                content = content.replace("${CLAUDE_SKILL_DIR}", &skill_dir);
804            }
805
806            // Substitute CLAUDE_SESSION_ID
807            content = content.replace(
808                "${CLAUDE_SESSION_ID}",
809                &std::env::var("AI_SESSION_ID").unwrap_or_default(),
810            );
811
812            // Substitute arguments
813            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            // Prepend base dir info
824            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
843/// Register MCP skill builders at module init.
844/// This static is initialized when first accessed, which occurs at startup
845/// when the loader module is imported.
846static 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        // May be None if home dir not available in test env
906        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        // With empty cwd and no skills registered, should return empty
920        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        // Create a project skill
931        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        // Should have Stop and PreToolUse events
993        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        // Empty hooks map should return None
1027        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        // Initialize a git repo
1106        std::process::Command::new("git")
1107            .args(["init"])
1108            .current_dir(repo_root)
1109            .output()
1110            .expect("git init failed");
1111
1112        // Create .gitignore that ignores "ignored-skill"
1113        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        // Create skills directory
1119        let skills_dir = repo_root.join(".ai").join("skills");
1120        std::fs::create_dir_all(&skills_dir).unwrap();
1121
1122        // Create a normal skill (should be loaded)
1123        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        // Create a gitignored skill (should be skipped)
1134        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        // Load skills - pass repo_root as cwd for git check-ignore context
1145        let skills =
1146            load_skills_from_dir(&skills_dir, repo_root).expect("failed to load skills");
1147
1148        // Should have exactly 1 skill (the normal one), not the ignored one
1149        assert_eq!(skills.len(), 1);
1150        assert_eq!(skills[0].metadata.name, "normal-skill");
1151    }
1152
1153    // -----------------------------------------------------------------------
1154    // Memoization tests
1155    // -----------------------------------------------------------------------
1156
1157    #[test]
1158    fn test_load_all_skills_memoization() {
1159        use std::io::Write;
1160
1161        // Clear shared cache to isolate this test from others
1162        LOAD_ALL_SKILLS_MEMO.clear();
1163
1164        let temp = tempfile::tempdir().unwrap();
1165        let cwd = temp.path().to_string_lossy().to_string();
1166
1167        // Create a project skill
1168        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        // First call - populates the cache
1178        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        // Second call with the same cwd - should hit the cache
1183        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        // Results should be identical
1188        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        // Clear shared cache to isolate this test from others
1196        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        // Create a skill inside the directory
1203        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        // First call
1216        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        // Second call with same args - should hit cache
1221        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        // Clear shared cache
1231        LOAD_ALL_SKILLS_MEMO.clear();
1232
1233        // Create more than 50 temp directories to exercise LRU eviction.
1234        // Each directory gets a unique skill name so results are distinguishable.
1235        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        // Load all 55 directories through the cached function (max 50 cached).
1251        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        // The cache can hold at most 50 entries; the first 5 should have been evicted.
1261        // Call one of the earliest entries - it will re-compute (cache miss, not an error).
1262        let first_cwd = &cwd_vec[0];
1263        let _ = load_all_skills_cached(first_cwd);
1264
1265        // Call a middle entry that should still be cached (index 30 is within
1266        // the 50-entry window after eviction of entries 0..5).
1267        let middle_cwd = &cwd_vec[30];
1268        let skills = load_all_skills_cached(middle_cwd).unwrap();
1269        // Should find skill-30
1270        assert!(skills.iter().any(|s| s.name == "skill-30"));
1271
1272        // The static cache size should be <= 50 (it may be slightly less because
1273        // the first entry was re-loaded, bumping out another).
1274        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        // Hash equality
1301        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        // Create two temp directories with different skills
1315        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        // Test ${CLAUDE_SKILL_DIR} substitution
1344        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        // Test ${CLAUDE_SESSION_ID} substitution
1349        let content = "Session: ${CLAUDE_SESSION_ID}";
1350        let result = substitute_env_vars_in_skill(&content, "/some/dir");
1351        // Session ID is generated, so we just check that the placeholder was replaced
1352        assert!(!result.contains("${CLAUDE_SESSION_ID}"));
1353        assert!(result.starts_with("Session: "));
1354
1355        // Test both substitutions together
1356        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        // "my-skill A skill that does something useful When you need help"
1385        // should be a positive number of tokens
1386        assert!(tokens > 0);
1387
1388        // Empty metadata should return 0 tokens
1389        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}