use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::Result;
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct InstalledSkill {
pub name: String,
pub skill_type: SkillType,
pub tool: InstalledTool,
pub path: PathBuf,
pub bundle: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InstalledTool {
Claude,
OpenCode,
Cursor,
Codex,
}
impl InstalledTool {
pub fn as_str(&self) -> &'static str {
match self {
InstalledTool::Claude => "claude",
InstalledTool::OpenCode => "opencode",
InstalledTool::Cursor => "cursor",
InstalledTool::Codex => "codex",
}
}
pub fn display_name(&self) -> &'static str {
match self {
InstalledTool::Claude => "Claude",
InstalledTool::OpenCode => "OpenCode",
InstalledTool::Cursor => "Cursor",
InstalledTool::Codex => "Codex",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SkillType {
Skill,
Agent,
Command,
Rule,
}
impl SkillType {
pub fn plural(&self) -> &'static str {
match self {
SkillType::Skill => "skills",
SkillType::Agent => "agents",
SkillType::Command => "commands",
SkillType::Rule => "rules",
}
}
}
pub fn discover_installed(base: &Path) -> Result<Vec<InstalledSkill>> {
let mut skills = Vec::new();
skills.extend(discover_claude(base)?);
skills.extend(discover_opencode(base)?);
skills.extend(discover_cursor(base)?);
skills.extend(discover_codex(base)?);
Ok(skills)
}
fn discover_claude(base: &Path) -> Result<Vec<InstalledSkill>> {
let mut skills = Vec::new();
let claude_dir = base.join(".claude");
if !claude_dir.exists() {
return Ok(skills);
}
let commands_dir = claude_dir.join("commands");
if commands_dir.exists() {
for entry in WalkDir::new(&commands_dir)
.max_depth(2)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
{
let path = entry.path().to_path_buf();
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let bundle = path.parent().and_then(|p| {
if p != commands_dir {
p.file_name().and_then(|n| n.to_str()).map(String::from)
} else {
None
}
});
if !name.is_empty() {
skills.push(InstalledSkill {
name,
skill_type: SkillType::Command,
tool: InstalledTool::Claude,
path,
bundle,
});
}
}
}
let agents_dir = claude_dir.join("agents");
if agents_dir.exists() {
for entry in WalkDir::new(&agents_dir)
.max_depth(2)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter(|e| e.path().extension().map(|ext| ext == "md").unwrap_or(false))
{
let path = entry.path().to_path_buf();
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let bundle = path.parent().and_then(|p| {
if p != agents_dir {
p.file_name().and_then(|n| n.to_str()).map(String::from)
} else {
None
}
});
if !name.is_empty() {
skills.push(InstalledSkill {
name,
skill_type: SkillType::Agent,
tool: InstalledTool::Claude,
path,
bundle,
});
}
}
}
let skills_dir = claude_dir.join("skills");
if skills_dir.exists() {
for entry in std::fs::read_dir(&skills_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name: name.clone(),
skill_type: SkillType::Skill,
tool: InstalledTool::Claude,
path: skill_file,
bundle: Some(name),
});
}
}
}
}
}
let rules_dir = claude_dir.join("rules");
if rules_dir.exists() {
for entry in std::fs::read_dir(&rules_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let rule_file = path.join("RULE.md");
if rule_file.exists() {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name: name.clone(),
skill_type: SkillType::Rule,
tool: InstalledTool::Claude,
path: rule_file,
bundle: Some(name),
});
}
}
}
}
}
Ok(skills)
}
fn discover_opencode(base: &Path) -> Result<Vec<InstalledSkill>> {
let mut skills = Vec::new();
let opencode_dir = base.join(".opencode");
if !opencode_dir.exists() {
return Ok(skills);
}
let skill_dir = opencode_dir.join("skills");
if skill_dir.exists() {
for entry in std::fs::read_dir(&skill_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name: name.clone(),
skill_type: SkillType::Skill,
tool: InstalledTool::OpenCode,
path: skill_file,
bundle: Some(name),
});
}
}
}
}
}
let agent_dir = opencode_dir.join("agents");
if agent_dir.exists() {
for entry in std::fs::read_dir(&agent_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name,
skill_type: SkillType::Agent,
tool: InstalledTool::OpenCode,
path,
bundle: None,
});
}
}
}
}
let command_dir = opencode_dir.join("commands");
if command_dir.exists() {
for entry in std::fs::read_dir(&command_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name,
skill_type: SkillType::Command,
tool: InstalledTool::OpenCode,
path,
bundle: None,
});
}
}
}
}
let rule_dir = opencode_dir.join("rules");
if rule_dir.exists() {
for entry in std::fs::read_dir(&rule_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let rule_file = path.join("RULE.md");
if rule_file.exists() {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name: name.clone(),
skill_type: SkillType::Rule,
tool: InstalledTool::OpenCode,
path: rule_file,
bundle: Some(name),
});
}
}
}
}
}
Ok(skills)
}
fn discover_cursor(base: &Path) -> Result<Vec<InstalledSkill>> {
let mut skills = Vec::new();
let cursor_dir = base.join(".cursor");
if !cursor_dir.exists() {
return Ok(skills);
}
let skills_dir = cursor_dir.join("skills");
if skills_dir.exists() {
for entry in std::fs::read_dir(&skills_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name: name.clone(),
skill_type: SkillType::Skill,
tool: InstalledTool::Cursor,
path: skill_file,
bundle: Some(name),
});
}
}
}
}
}
let agents_dir = cursor_dir.join("agents");
if agents_dir.exists() {
for entry in std::fs::read_dir(&agents_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name,
skill_type: SkillType::Agent,
tool: InstalledTool::Cursor,
path,
bundle: None,
});
}
}
}
}
let commands_dir = cursor_dir.join("commands");
if commands_dir.exists() {
for entry in std::fs::read_dir(&commands_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name,
skill_type: SkillType::Command,
tool: InstalledTool::Cursor,
path,
bundle: None,
});
}
}
}
}
let rules_dir = cursor_dir.join("rules");
if rules_dir.exists() {
for entry in std::fs::read_dir(&rules_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let rule_file = path.join("RULE.md");
if rule_file.exists() {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name: name.clone(),
skill_type: SkillType::Rule,
tool: InstalledTool::Cursor,
path: rule_file,
bundle: Some(name),
});
}
}
}
}
}
Ok(skills)
}
fn discover_codex(base: &Path) -> Result<Vec<InstalledSkill>> {
let mut skills = Vec::new();
let codex_dir = base.join(".codex");
if !codex_dir.exists() {
return Ok(skills);
}
let skills_dir = codex_dir.join("skills");
if skills_dir.exists() {
for entry in std::fs::read_dir(&skills_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name: name.clone(),
skill_type: SkillType::Skill,
tool: InstalledTool::Codex,
path: skill_file,
bundle: Some(name),
});
}
}
}
}
}
let agents_dir = codex_dir.join("agents");
if agents_dir.exists() {
for entry in std::fs::read_dir(&agents_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name,
skill_type: SkillType::Agent,
tool: InstalledTool::Codex,
path,
bundle: None,
});
}
}
}
}
let commands_dir = codex_dir.join("commands");
if commands_dir.exists() {
for entry in std::fs::read_dir(&commands_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map(|e| e == "md").unwrap_or(false) {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name,
skill_type: SkillType::Command,
tool: InstalledTool::Codex,
path,
bundle: None,
});
}
}
}
}
let rules_dir = codex_dir.join("rules");
if rules_dir.exists() {
for entry in std::fs::read_dir(&rules_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let rule_file = path.join("RULE.md");
if rule_file.exists() {
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if !name.is_empty() {
skills.push(InstalledSkill {
name: name.clone(),
skill_type: SkillType::Rule,
tool: InstalledTool::Codex,
path: rule_file,
bundle: Some(name),
});
}
}
}
}
}
Ok(skills)
}
pub fn group_by_tool(
skills: &[InstalledSkill],
) -> HashMap<InstalledTool, HashMap<SkillType, Vec<&InstalledSkill>>> {
let mut result: HashMap<InstalledTool, HashMap<SkillType, Vec<&InstalledSkill>>> =
HashMap::new();
for skill in skills {
result
.entry(skill.tool)
.or_default()
.entry(skill.skill_type)
.or_default()
.push(skill);
}
result
}
pub fn filter_by_tool(skills: Vec<InstalledSkill>, tool: &str) -> Vec<InstalledSkill> {
let tool_lower = tool.to_lowercase();
skills
.into_iter()
.filter(|s| s.tool.as_str() == tool_lower)
.collect()
}
impl InstalledSkill {
pub fn unique_id(&self) -> String {
if let Some(ref bundle) = self.bundle {
format!("{}/{}", bundle, self.name)
} else {
self.name.clone()
}
}
}
pub fn group_same_skills(skills: &[InstalledSkill]) -> HashMap<String, Vec<&InstalledSkill>> {
let mut result: HashMap<String, Vec<&InstalledSkill>> = HashMap::new();
for skill in skills {
result.entry(skill.unique_id()).or_default().push(skill);
}
result
}
pub fn remove_skill(skill: &InstalledSkill) -> Result<()> {
if skill.skill_type == SkillType::Skill || skill.skill_type == SkillType::Rule {
if let Some(parent) = skill.path.parent() {
if parent.is_dir() {
std::fs::remove_dir_all(parent)?;
return Ok(());
}
}
}
std::fs::remove_file(&skill.path)?;
let mut current = skill.path.parent();
while let Some(parent) = current {
if let Some(name) = parent.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
break;
}
}
if std::fs::read_dir(parent)?.next().is_none() {
std::fs::remove_dir(parent)?;
current = parent.parent();
} else {
break;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_discover_empty_dir() {
let dir = tempdir().unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert!(skills.is_empty());
}
#[test]
fn test_discover_claude_commands() {
let dir = tempdir().unwrap();
let commands_dir = dir.path().join(".claude/commands");
fs::create_dir_all(&commands_dir).unwrap();
fs::write(commands_dir.join("test.md"), "# Test command").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "test");
assert_eq!(skills[0].skill_type, SkillType::Command);
assert_eq!(skills[0].tool, InstalledTool::Claude);
}
#[test]
fn test_discover_claude_commands_with_bundle() {
let dir = tempdir().unwrap();
let bundle_dir = dir.path().join(".claude/commands/mybundle");
fs::create_dir_all(&bundle_dir).unwrap();
fs::write(bundle_dir.join("test.md"), "# Test command").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "test");
assert_eq!(skills[0].bundle, Some("mybundle".to_string()));
}
#[test]
fn test_discover_opencode_skills() {
let dir = tempdir().unwrap();
let skill_dir = dir.path().join(".opencode/skills/myskill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "# My skill").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "myskill");
assert_eq!(skills[0].skill_type, SkillType::Skill);
assert_eq!(skills[0].tool, InstalledTool::OpenCode);
}
#[test]
fn test_discover_cursor_rules() {
let dir = tempdir().unwrap();
let rule_dir = dir.path().join(".cursor/rules/test");
fs::create_dir_all(&rule_dir).unwrap();
fs::write(rule_dir.join("RULE.md"), "# Test rule").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "test");
assert_eq!(skills[0].skill_type, SkillType::Rule);
assert_eq!(skills[0].tool, InstalledTool::Cursor);
}
#[test]
fn test_discover_cursor_agents() {
let dir = tempdir().unwrap();
let agents_dir = dir.path().join(".cursor/agents");
fs::create_dir_all(&agents_dir).unwrap();
fs::write(agents_dir.join("my-agent.md"), "# My Agent").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-agent");
assert_eq!(skills[0].skill_type, SkillType::Agent);
assert_eq!(skills[0].tool, InstalledTool::Cursor);
}
#[test]
fn test_discover_cursor_commands() {
let dir = tempdir().unwrap();
let commands_dir = dir.path().join(".cursor/commands");
fs::create_dir_all(&commands_dir).unwrap();
fs::write(commands_dir.join("my-command.md"), "# My Command").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-command");
assert_eq!(skills[0].skill_type, SkillType::Command);
assert_eq!(skills[0].tool, InstalledTool::Cursor);
}
#[test]
fn test_discover_claude_skills() {
let dir = tempdir().unwrap();
let skill_dir = dir.path().join(".claude/skills/my-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "# My Skill").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-skill");
assert_eq!(skills[0].skill_type, SkillType::Skill);
assert_eq!(skills[0].tool, InstalledTool::Claude);
assert_eq!(skills[0].bundle, Some("my-skill".to_string()));
}
#[test]
fn test_discover_claude_rules() {
let dir = tempdir().unwrap();
let rule_dir = dir.path().join(".claude/rules/my-rule");
fs::create_dir_all(&rule_dir).unwrap();
fs::write(rule_dir.join("RULE.md"), "# My Rule").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-rule");
assert_eq!(skills[0].skill_type, SkillType::Rule);
assert_eq!(skills[0].tool, InstalledTool::Claude);
assert_eq!(skills[0].bundle, Some("my-rule".to_string()));
}
#[test]
fn test_filter_by_tool() {
let skills = vec![
InstalledSkill {
name: "test1".to_string(),
skill_type: SkillType::Command,
tool: InstalledTool::Claude,
path: PathBuf::from("/test1"),
bundle: None,
},
InstalledSkill {
name: "test2".to_string(),
skill_type: SkillType::Command,
tool: InstalledTool::OpenCode,
path: PathBuf::from("/test2"),
bundle: None,
},
];
let filtered = filter_by_tool(skills, "claude");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name, "test1");
}
#[test]
fn test_discover_codex_skills() {
let dir = tempdir().unwrap();
let skill_dir = dir.path().join(".codex/skills/my-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "# My Skill").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-skill");
assert_eq!(skills[0].skill_type, SkillType::Skill);
assert_eq!(skills[0].tool, InstalledTool::Codex);
}
#[test]
fn test_discover_codex_agents() {
let dir = tempdir().unwrap();
let agents_dir = dir.path().join(".codex/agents");
fs::create_dir_all(&agents_dir).unwrap();
fs::write(agents_dir.join("my-agent.md"), "# My Agent").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-agent");
assert_eq!(skills[0].skill_type, SkillType::Agent);
assert_eq!(skills[0].tool, InstalledTool::Codex);
}
#[test]
fn test_discover_codex_commands() {
let dir = tempdir().unwrap();
let commands_dir = dir.path().join(".codex/commands");
fs::create_dir_all(&commands_dir).unwrap();
fs::write(commands_dir.join("my-command.md"), "# My Command").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-command");
assert_eq!(skills[0].skill_type, SkillType::Command);
assert_eq!(skills[0].tool, InstalledTool::Codex);
}
#[test]
fn test_discover_codex_rules() {
let dir = tempdir().unwrap();
let rule_dir = dir.path().join(".codex/rules/my-rule");
fs::create_dir_all(&rule_dir).unwrap();
fs::write(rule_dir.join("RULE.md"), "# My Rule").unwrap();
let skills = discover_installed(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-rule");
assert_eq!(skills[0].skill_type, SkillType::Rule);
assert_eq!(skills[0].tool, InstalledTool::Codex);
}
}