use std::path::Path;
use super::discovery::{self, SkillMeta};
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let (m, n) = (a.len(), b.len());
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for (i, row) in dp.iter_mut().enumerate() {
row[0] = i;
}
for (j, val) in dp[0].iter_mut().enumerate() {
*val = j;
}
for i in 1..=m {
for j in 1..=n {
dp[i][j] = if a[i - 1] == b[j - 1] {
dp[i - 1][j - 1]
} else {
1 + dp[i - 1][j].min(dp[i][j - 1]).min(dp[i - 1][j - 1])
};
}
}
dp[m][n]
}
#[derive(Debug, Clone)]
pub struct SkillRegistry {
skills: Vec<SkillMeta>,
}
impl SkillRegistry {
pub fn new() -> Self {
Self { skills: Vec::new() }
}
pub fn discover(working_dir: &Path) -> Self {
let skills = discovery::discover_all(working_dir);
Self { skills }
}
pub fn count(&self) -> usize {
self.skills.len()
}
pub fn all(&self) -> &[SkillMeta] {
&self.skills
}
pub fn find(&self, name: &str) -> Option<&SkillMeta> {
self.skills.iter().find(|s| s.name == name)
}
pub fn find_fuzzy(&self, query: &str) -> Option<&SkillMeta> {
let q = query.to_lowercase();
if let Some(skill) = self.skills.iter().find(|s| s.name == q) {
return Some(skill);
}
let substr_matches: Vec<_> = self
.skills
.iter()
.filter(|s| s.name.contains(&q) || s.description.to_lowercase().contains(&q))
.collect();
if substr_matches.len() == 1 {
return Some(substr_matches[0]);
}
const MAX_EDIT_DIST: usize = 3;
let mut best_dist = MAX_EDIT_DIST + 1;
let mut best: Option<&SkillMeta> = None;
let mut ambiguous = false;
for skill in &self.skills {
let dist = levenshtein(&q, &skill.name.to_lowercase());
if dist < best_dist {
best_dist = dist;
best = Some(skill);
ambiguous = false;
} else if dist == best_dist {
ambiguous = true;
}
}
if !ambiguous && best_dist <= MAX_EDIT_DIST {
return best;
}
None
}
pub fn system_prompt_metadata(&self) -> Option<String> {
if self.skills.is_empty() {
return None;
}
let mut lines = vec!["## Available Skills\n".to_string()];
lines.push("The following skills are available. Use the `skill` tool to invoke them when relevant.\n".to_string());
for skill in &self.skills {
lines.push(format!(
"- **{}** ({}): {}",
skill.name, skill.source, skill.description,
));
}
Some(lines.join("\n"))
}
pub fn invoke(&self, name: &str) -> anyhow::Result<SkillInvocation> {
let skill = self
.find(name)
.or_else(|| self.find_fuzzy(name))
.ok_or_else(|| {
anyhow::anyhow!(
"Skill '{name}' not found. Available: {}",
self.skills
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
self.invoke_skill(skill)
}
pub fn invoke_with_index(
&self,
name: &str,
tool_index: &crate::tools::tool_index::ToolIndex,
) -> anyhow::Result<SkillInvocation> {
if let Some(skill) = self.find(name).or_else(|| self.find_fuzzy(name)) {
return self.invoke_skill(skill);
}
let results =
tool_index.search_by_source(name, crate::tools::tool_index::ToolSource::Skill, 1);
if let Some(top) = results.first()
&& let Some(skill) = self.find(&top.id)
{
tracing::debug!(
query = %name,
matched = %skill.name,
"Skill resolved via BM25 semantic fallback"
);
return self.invoke_skill(skill);
}
anyhow::bail!(
"Skill '{name}' not found. Available: {}",
self.skills
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
}
fn invoke_skill(&self, skill: &SkillMeta) -> anyhow::Result<SkillInvocation> {
let body = discovery::load_skill_body(&skill.path)?;
let resources = discovery::list_skill_resources(&skill.path);
let resource_names: Vec<String> = resources
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.collect();
Ok(SkillInvocation {
name: skill.name.clone(),
instructions: body,
skill_dir: skill
.path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf(),
available_resources: resource_names,
})
}
}
impl Default for SkillRegistry {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub struct SkillInvocation {
pub name: String,
pub instructions: String,
pub skill_dir: std::path::PathBuf,
pub available_resources: Vec<String>,
}
impl SkillInvocation {
pub fn to_context_string(&self) -> String {
let mut result = format!("<skill name=\"{}\">\n{}\n", self.name, self.instructions,);
if !self.available_resources.is_empty() {
result.push_str("\n## Available Resources\n\n");
result.push_str("The following files are available in the skill directory. ");
result.push_str("Use `file_read` or `bash` to access them as needed:\n\n");
for res in &self.available_resources {
let path = self.skill_dir.join(res);
result.push_str(&format!("- `{}`\n", path.display()));
}
}
result.push_str("</skill>");
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn create_test_skill(dir: &Path, name: &str, description: &str) {
let skill_dir = dir.join(".claude").join("skills").join(name);
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\n\n# Instructions\nDo the {name} thing."),
).unwrap();
}
#[test]
fn test_registry_discover() {
let dir = tempfile::tempdir().unwrap();
create_test_skill(dir.path(), "lint-code", "Run linting on code files");
create_test_skill(
dir.path(),
"deploy-app",
"Deploy the application to staging",
);
let reg = SkillRegistry::discover(dir.path());
let project_count = reg
.all()
.iter()
.filter(|s| s.source == super::discovery::SkillSource::Project)
.count();
assert_eq!(project_count, 2);
}
#[test]
fn test_registry_find_exact() {
let dir = tempfile::tempdir().unwrap();
create_test_skill(dir.path(), "lint-code", "Run linting");
let reg = SkillRegistry::discover(dir.path());
assert!(reg.find("lint-code").is_some());
assert!(reg.find("nonexistent").is_none());
}
#[test]
fn test_registry_find_fuzzy() {
let dir = tempfile::tempdir().unwrap();
create_test_skill(dir.path(), "xyzlint-tool", "Run linting on xyz files");
create_test_skill(dir.path(), "xyzdeploy-app", "Deploy the xyz application");
let reg = SkillRegistry::discover(dir.path());
assert_eq!(reg.find_fuzzy("xyzlint").unwrap().name, "xyzlint-tool");
assert_eq!(reg.find_fuzzy("xyzdeploy").unwrap().name, "xyzdeploy-app");
}
#[test]
fn test_registry_invoke() {
let dir = tempfile::tempdir().unwrap();
create_test_skill(dir.path(), "lint-code", "Run linting");
let reg = SkillRegistry::discover(dir.path());
let invocation = reg.invoke("lint-code").unwrap();
assert_eq!(invocation.name, "lint-code");
assert!(invocation.instructions.contains("Do the lint-code thing"));
}
#[test]
fn test_registry_invoke_not_found() {
let reg = SkillRegistry::new();
assert!(reg.invoke("missing").is_err());
}
#[test]
fn test_system_prompt_metadata() {
let dir = tempfile::tempdir().unwrap();
create_test_skill(dir.path(), "lint-code", "Run linting on code");
let reg = SkillRegistry::discover(dir.path());
let metadata = reg.system_prompt_metadata().unwrap();
assert!(metadata.contains("lint-code"));
assert!(metadata.contains("Run linting on code"));
assert!(metadata.contains("Available Skills"));
}
#[test]
fn test_system_prompt_metadata_empty() {
let reg = SkillRegistry::new();
assert!(reg.system_prompt_metadata().is_none());
}
#[test]
fn test_invocation_with_resources() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join(".claude").join("skills").join("my-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: my-skill\ndescription: Test\n---\n# Body",
)
.unwrap();
fs::write(skill_dir.join("REFERENCE.md"), "# Ref").unwrap();
fs::write(skill_dir.join("helper.py"), "print('hi')").unwrap();
let reg = SkillRegistry::discover(dir.path());
let inv = reg.invoke("my-skill").unwrap();
assert_eq!(inv.available_resources.len(), 2);
let ctx = inv.to_context_string();
assert!(ctx.contains("<skill name=\"my-skill\">"));
assert!(ctx.contains("Available Resources"));
assert!(ctx.contains("REFERENCE.md"));
assert!(ctx.contains("helper.py"));
assert!(ctx.contains("</skill>"));
}
}