Skip to main content

skilllite_agent/skills/
mod.rs

1//! Skills invocation: wraps sandbox run/exec for the agent layer.
2//!
3//! Since Agent is in the same process as Sandbox, we call the sandbox
4//! executor directly (no IPC needed). Ported from Python `ToolCallHandler`.
5//!
6//! Phase 2.5 additions:
7//!   - Security scanning before skill execution (L3)
8//!   - Multi-script skill support (skill_name__script_name)
9//!   - Argparse schema inference for Python scripts
10//!   - .skilllite.lock dependency resolution
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use skilllite_core::skill::metadata::SkillMetadata;
16
17use super::types::ToolDefinition;
18
19use loader::{load_evolved_skills, load_single_skill, sanitize_tool_name};
20
21mod executor;
22pub mod infer_entry;
23mod loader;
24pub(crate) mod security;
25pub(crate) mod usage_stats;
26
27pub use executor::execute_skill;
28pub use security::{read_lock_file, write_lock_file, LockFile};
29
30/// A loaded skill ready for invocation.
31#[derive(Debug, Clone)]
32pub struct LoadedSkill {
33    pub name: String,
34    pub skill_dir: PathBuf,
35    pub metadata: SkillMetadata,
36    pub tool_definitions: Vec<ToolDefinition>,
37    /// Multi-script tool mapping: tool_name → script_path (e.g. "scripts/init_skill.py")
38    pub multi_script_entries: HashMap<String, String>,
39}
40
41/// Load skills from directories, parse SKILL.md, generate tool definitions.
42/// Also loads evolved skills from `_evolved/` subdirectories (EVO-4),
43/// skipping archived ones based on `.meta.json`.
44/// Skills are project-level only: evolution writes to workspace/.skills/_evolved/.
45pub fn load_skills(skill_dirs: &[String]) -> Vec<LoadedSkill> {
46    let mut skills = Vec::new();
47
48    for dir_path in skill_dirs {
49        let path = Path::new(dir_path);
50        if !path.exists() || !path.is_dir() {
51            tracing::debug!("Skill directory not found: {}", dir_path);
52            continue;
53        }
54
55        // Check if this directory itself is a skill (has SKILL.md)
56        if path.join("SKILL.md").exists() {
57            if let Some(skill) = load_single_skill(path) {
58                skills.push(skill);
59            }
60        } else {
61            // Scan subdirectories for skills
62            if let Ok(entries) = skilllite_fs::read_dir(path) {
63                for (entry_path, is_dir) in entries {
64                    if is_dir && entry_path.join("SKILL.md").exists() {
65                        if let Some(skill) = load_single_skill(&entry_path) {
66                            skills.push(skill);
67                        }
68                    }
69                }
70            }
71        }
72
73        // EVO-4: load evolved skills from _evolved/ subdirectory
74        let evolved_dir = path.join("_evolved");
75        if evolved_dir.exists() && evolved_dir.is_dir() {
76            let evolved = load_evolved_skills(&evolved_dir);
77            tracing::debug!(
78                "Loaded {} evolved skills from {}",
79                evolved.len(),
80                evolved_dir.display()
81            );
82            skills.extend(evolved);
83        }
84    }
85
86    skills
87}
88
89/// Load evolved skills from `_evolved/` directory, filtering out archived ones.
90/// Find a loaded skill by tool name.
91///
92/// Supports fuzzy matching: normalizes both the query and registered names
93/// so that `frontend-design` matches `frontend_design` and vice versa.
94/// This is needed because LLMs sometimes use the original skill name (with hyphens)
95/// instead of the sanitized tool name (with underscores).
96pub fn find_skill_by_tool_name<'a>(
97    skills: &'a [LoadedSkill],
98    tool_name: &str,
99) -> Option<&'a LoadedSkill> {
100    // Exact match first (fast path)
101    if let Some(skill) = skills.iter().find(|s| {
102        s.tool_definitions
103            .iter()
104            .any(|td| td.function.name == tool_name)
105    }) {
106        return Some(skill);
107    }
108
109    // Normalized match: replace hyphens with underscores and compare
110    let normalized = sanitize_tool_name(tool_name);
111    skills.iter().find(|s| {
112        s.tool_definitions
113            .iter()
114            .any(|td| td.function.name == normalized)
115    })
116}
117
118/// Find a loaded skill by its original name (not tool definition name).
119///
120/// This is useful for finding reference-only skills that have no tool definitions
121/// but are still loaded and available for documentation injection.
122/// Matches both exact name and normalized name (hyphens ↔ underscores).
123pub fn find_skill_by_name<'a>(skills: &'a [LoadedSkill], name: &str) -> Option<&'a LoadedSkill> {
124    // Exact match
125    if let Some(skill) = skills.iter().find(|s| s.name == name) {
126        return Some(skill);
127    }
128    // Normalized: frontend_design matches frontend-design
129    let with_hyphens = name.replace('_', "-");
130    let with_underscores = name.replace('-', "_");
131    skills
132        .iter()
133        .find(|s| s.name == with_hyphens || s.name == with_underscores)
134}