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}