use std::path::PathBuf;
use bitrouter_mcp::capabilities::skills::SkillsQuery;
use bitrouter_mcp::error::ToolError;
use bitrouter_skills::frontmatter::discover_all_skills;
pub struct InstalledSkills {
root: PathBuf,
}
impl InstalledSkills {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
}
#[async_trait::async_trait]
impl SkillsQuery for InstalledSkills {
async fn search(&self, query: &str) -> Result<serde_json::Value, ToolError> {
let root = self.root.clone();
let needle = query.to_lowercase();
let skills = tokio::task::spawn_blocking(move || {
discover_all_skills(&root)
.into_iter()
.filter(|(_, fm)| {
fm.name.to_lowercase().contains(&needle)
|| fm.description.to_lowercase().contains(&needle)
})
.map(|(path, fm)| {
serde_json::json!({
"name": fm.name,
"description": fm.description,
"path": path.display().to_string(),
})
})
.collect::<Vec<_>>()
})
.await
.map_err(|e| ToolError::new(format!("skills discovery task failed: {e}")))?;
Ok(serde_json::json!({ "skills": skills }))
}
async fn get(&self, name: &str) -> Result<serde_json::Value, ToolError> {
let root = self.root.clone();
let name = name.to_string();
tokio::task::spawn_blocking(move || {
let (path, fm) = discover_all_skills(&root)
.into_iter()
.find(|(_, fm)| fm.name == name)
.ok_or_else(|| ToolError::new(format!("no installed skill named '{name}'")))?;
let content = std::fs::read_to_string(&path)
.map_err(|e| ToolError::new(format!("reading {}: {e}", path.display())))?;
Ok(serde_json::json!({
"name": fm.name,
"description": fm.description,
"metadata": fm.metadata,
"path": path.display().to_string(),
"body": skill_body(&content),
}))
})
.await
.map_err(|e| ToolError::new(format!("skills fetch task failed: {e}")))?
}
}
fn skill_body(content: &str) -> String {
let mut lines = content.lines();
if lines.next().map(|l| l.trim_end_matches('\r')) != Some("---") {
return content.to_string();
}
let mut closed = false;
let mut body = String::new();
for line in lines {
if !closed {
if line.trim_end_matches('\r') == "---" {
closed = true;
}
continue;
}
body.push_str(line);
body.push('\n');
}
if closed {
body.trim_start_matches(['\n', '\r']).to_string()
} else {
content.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn install_skill(root: &std::path::Path, name: &str, description: &str, body: &str) {
let dir = root.join(".claude").join("skills").join(name);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}"),
)
.expect("write skill");
}
#[tokio::test]
async fn search_matches_name_and_description() {
let dir = tempfile::tempdir().expect("tempdir");
install_skill(dir.path(), "refactor-rust", "Rustacean refactors", "# Body");
install_skill(dir.path(), "write-docs", "Author documentation", "# Body");
let skills = InstalledSkills::new(dir.path().to_path_buf());
let by_name = skills.search("refactor").await.expect("search");
let hits = by_name["skills"].as_array().expect("array");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0]["name"], "refactor-rust");
let by_desc = skills.search("DOCUMENTATION").await.expect("search");
assert_eq!(by_desc["skills"].as_array().expect("array").len(), 1);
}
#[tokio::test]
async fn get_returns_frontmatter_and_body() {
let dir = tempfile::tempdir().expect("tempdir");
install_skill(
dir.path(),
"alpha",
"Does alpha",
"# Alpha\n\nRun the thing.",
);
let skills = InstalledSkills::new(dir.path().to_path_buf());
let got = skills.get("alpha").await.expect("get");
assert_eq!(got["name"], "alpha");
assert_eq!(got["description"], "Does alpha");
let body = got["body"].as_str().expect("body");
assert!(
body.starts_with("# Alpha"),
"body without frontmatter: {body:?}"
);
assert!(
!body.contains("description:"),
"frontmatter stripped: {body:?}"
);
}
#[tokio::test]
async fn get_missing_skill_errors() {
let dir = tempfile::tempdir().expect("tempdir");
let skills = InstalledSkills::new(dir.path().to_path_buf());
let err = skills.get("nope").await.expect_err("not found");
assert!(err.0.contains("nope"), "names the missing skill: {}", err.0);
}
}