use std::collections::BTreeSet;
use bitrouter_mcp::actions::skills::{SkillDetail, SkillRow, SkillsQuery, SkillsReport};
use bitrouter_mcp::error::ToolError;
use crate::skills::format::{DiscoveredSkill, discover_all_skills, skill_body};
use crate::skills::root::SkillsRoot;
pub struct InstalledSkills {
roots: Vec<SkillsRoot>,
}
impl InstalledSkills {
pub fn new(roots: Vec<SkillsRoot>) -> Self {
Self { roots }
}
pub fn report(&self) -> SkillsReport {
let mut skills = Vec::new();
let mut seen: BTreeSet<std::path::PathBuf> = BTreeSet::new();
for root in &self.roots {
for found in discover_all_skills(&root.discovery_root()) {
if !seen.insert(found.skill_md.clone()) {
continue;
}
skills.push(row(&found));
}
}
skills.sort_by(|a, b| (&a.name, &a.dir).cmp(&(&b.name, &b.dir)));
SkillsReport { skills }
}
async fn report_off_thread(&self) -> Result<SkillsReport, ToolError> {
let roots = self.roots.clone();
tokio::task::spawn_blocking(move || InstalledSkills { roots }.report())
.await
.map_err(|e| ToolError::new(format!("skills discovery task failed: {e}")))
}
}
fn row(found: &DiscoveredSkill) -> SkillRow {
let problem = found.problem();
SkillRow {
name: found.name(),
description: found.description().to_string(),
dir: found.dir.display().to_string(),
skill_md: found.skill_md.display().to_string(),
valid: problem.is_none(),
problem,
}
}
#[async_trait::async_trait]
impl SkillsQuery for InstalledSkills {
async fn list(&self) -> Result<SkillsReport, ToolError> {
self.report_off_thread().await
}
async fn get(&self, name: &str) -> Result<SkillDetail, ToolError> {
let report = self.report_off_thread().await?;
let skill = report
.skills
.into_iter()
.find(|s| s.name == name)
.ok_or_else(|| ToolError::new(format!("no installed skill named '{name}'")))?;
let path = std::path::PathBuf::from(&skill.skill_md);
let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
.await
.map_err(|e| ToolError::new(format!("skills fetch task failed: {e}")))?
.map_err(|e| ToolError::new(format!("reading {}: {e}", skill.skill_md)))?;
let metadata = crate::skills::format::parse_frontmatter(&content)
.ok()
.map(|fm| {
fm.metadata
.into_iter()
.collect::<serde_json::Map<String, serde_json::Value>>()
})
.unwrap_or_default();
Ok(SkillDetail {
body: skill_body(&content).to_string(),
skill,
metadata,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use bitrouter_mcp::capabilities::skill_catalog::SkillCatalog as _;
fn install(root: &std::path::Path, rel: &str, dir: &str, body: &str) -> std::path::PathBuf {
let mut skill_dir = root.to_path_buf();
for segment in rel.split('/').filter(|segment| !segment.is_empty()) {
skill_dir.push(segment);
}
skill_dir.push(dir);
std::fs::create_dir_all(&skill_dir).expect("mkdir");
std::fs::write(skill_dir.join("SKILL.md"), body).expect("write");
skill_dir
}
fn valid(name: &str, description: &str) -> String {
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n\nDo it.\n")
}
fn project(root: &std::path::Path) -> InstalledSkills {
InstalledSkills::new(
SkillsRoot::cli_scope(false, root.to_path_buf()).expect("project scope"),
)
}
#[test]
fn every_conventional_layout_is_one_listing() {
let dir = tempfile::tempdir().expect("tempdir");
install(
dir.path(),
".claude/skills",
"installed",
&valid("installed", "d"),
);
install(dir.path(), "skills", "bundled", &valid("bundled", "d"));
let names: Vec<_> = project(dir.path())
.report()
.skills
.into_iter()
.map(|s| s.name)
.collect();
assert_eq!(names, vec!["bundled".to_string(), "installed".to_string()]);
}
#[test]
fn a_broken_skill_is_listed_and_marked() {
let dir = tempfile::tempdir().expect("tempdir");
install(dir.path(), ".claude/skills", "ok", &valid("ok", "d"));
install(
dir.path(),
".claude/skills",
"broken",
"---\nname: broken\n---\n",
);
let skills = project(dir.path()).report().skills;
assert_eq!(skills.len(), 2);
let broken = skills.iter().find(|s| s.name == "broken").expect("listed");
assert!(!broken.valid);
assert!(broken.problem.is_some());
let ok = skills.iter().find(|s| s.name == "ok").expect("listed");
assert!(ok.valid && ok.problem.is_none());
}
#[test]
fn a_row_carries_both_the_directory_and_the_skill_md() {
let dir = tempfile::tempdir().expect("tempdir");
let installed = install(dir.path(), ".claude/skills", "alpha", &valid("alpha", "d"));
let row = project(dir.path()).report().skills.remove(0);
assert_eq!(row.dir, installed.display().to_string());
assert_eq!(
row.skill_md,
installed.join("SKILL.md").display().to_string()
);
}
#[tokio::test]
async fn get_returns_the_body_without_the_frontmatter() {
let dir = tempfile::tempdir().expect("tempdir");
install(
dir.path(),
".claude/skills",
"alpha",
&valid("alpha", "Does alpha"),
);
let detail = project(dir.path()).get("alpha").await.expect("get");
assert_eq!(detail.skill.name, "alpha");
assert_eq!(detail.skill.description, "Does alpha");
assert!(detail.body.starts_with("# alpha"), "{:?}", detail.body);
assert!(!detail.body.contains("description:"), "{:?}", detail.body);
}
#[tokio::test]
async fn get_names_the_missing_skill() {
let dir = tempfile::tempdir().expect("tempdir");
let err = project(dir.path()).get("nope").await.expect_err("absent");
assert!(err.0.contains("nope"), "{}", err.0);
}
#[tokio::test]
async fn one_disk_three_surfaces() {
let project_dir = tempfile::tempdir().expect("project");
let home_dir = tempfile::tempdir().expect("home");
let project = project_dir.path();
install(
project,
".claude/skills",
"broken",
"---\nname: broken\n---\n",
);
install(
project,
"skills",
"bundled",
&valid("bundled", "From ./skills"),
);
install(
home_dir.path(),
".claude/skills",
"worldwide",
&valid("worldwide", "From the global root"),
);
let global = SkillsRoot::Global {
home: home_dir.path().to_path_buf(),
};
let project_root = SkillsRoot::Project {
project_root: project.to_path_buf(),
};
let cli_project = InstalledSkills::new(vec![project_root.clone()]).report();
let cli_global = InstalledSkills::new(vec![global.clone()]).report();
let mcp_roots = vec![project_root, global];
let tool = SkillsQuery::list(&InstalledSkills::new(mcp_roots.clone()))
.await
.expect("skills_search");
for row in cli_project.skills.iter().chain(cli_global.skills.iter()) {
assert!(
tool.skills.contains(row),
"`{}` differs between the CLI and skills_search:\n cli: {:?}\n mcp: {:?}",
row.name,
row,
tool.skills.iter().find(|s| s.name == row.name),
);
}
assert_eq!(
tool.skills.len(),
cli_project.skills.len() + cli_global.skills.len(),
"the tool's scope is exactly the CLI's two scopes together"
);
assert!(
cli_project
.skills
.iter()
.all(|s| s.dir.starts_with(&project.display().to_string())),
"a project-scoped listing must not reach outside the project root: {:?}",
cli_project.skills
);
assert!(cli_project.skills.iter().all(|s| s.name != "worldwide"));
let by_name = |name: &str| {
tool.skills
.iter()
.find(|s| s.name == name)
.unwrap_or_else(|| panic!("`{name}` missing from skills_search: {:?}", tool.skills))
.clone()
};
assert!(by_name("bundled").valid);
assert!(by_name("worldwide").valid);
let broken = by_name("broken");
assert!(!broken.valid);
assert!(broken.problem.is_some(), "an invalid skill says why");
let published = crate::skills_catalog::InstalledSkillCatalog::new(mcp_roots)
.list()
.await
.expect("skills/list");
let mut names: Vec<&str> = published
.skills
.iter()
.filter_map(|s| s.frontmatter.get("name").and_then(|n| n.as_str()))
.collect();
names.sort_unstable();
assert_eq!(names, vec!["bundled", "worldwide"], "{published:?}");
assert!(
published
.skills
.iter()
.any(|s| s.uri.starts_with("skill://global/")),
"the global root gets its own organizational prefix: {published:?}"
);
for entry in &published.skills {
let resources = entry
.resources
.entries()
.expect("a filesystem skill is never dynamic");
assert!(resources.iter().any(|r| r.uri == entry.uri));
assert!(resources.iter().all(|r| r.size > 0));
}
}
#[cfg(unix)]
#[test]
fn a_symlinked_skill_directory_is_not_listed() {
let outside = tempfile::tempdir().expect("tempdir");
install(outside.path(), "", "escape", &valid("escape", "d"));
let dir = tempfile::tempdir().expect("tempdir");
let skills = dir.path().join(".claude").join("skills");
std::fs::create_dir_all(&skills).expect("mkdir");
std::os::unix::fs::symlink(outside.path().join("escape"), skills.join("escape"))
.expect("symlink");
assert!(
project(dir.path()).report().skills.is_empty(),
"a symlink beneath the root must not be traversed"
);
}
}