use super::parser::{self, AgentConfig, SkillConfig, SkillReference};
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Serialize)]
pub struct AgentGroup {
pub name: String,
pub identical: bool,
pub items: Vec<AgentConfig>,
}
#[derive(Serialize)]
pub struct SkillGroup {
pub name: String,
pub identical: bool,
pub items: Vec<SkillConfig>,
}
fn home_dir() -> Option<PathBuf> {
dirs::home_dir()
}
fn claude_home() -> Option<PathBuf> {
home_dir().map(|h| h.join(".claude"))
}
fn decode_dir_name(encoded: &str) -> PathBuf {
let stripped = match encoded.strip_prefix('-') {
Some(s) => s,
None => return PathBuf::from(encoded),
};
let parts: Vec<&str> = stripped.split('-').collect();
if parts.is_empty() {
return PathBuf::from(encoded);
}
fn solve(parts: &[&str], idx: usize, current: &Path) -> Option<PathBuf> {
if idx >= parts.len() {
return if current.exists() { Some(current.to_path_buf()) } else { None };
}
for end in (idx + 1..=parts.len()).rev() {
let segment = parts[idx..end].join("-");
let candidate = current.join(&segment);
if candidate.exists() {
if end == parts.len() {
return Some(candidate);
}
if let Some(result) = solve(parts, end, &candidate) {
return Some(result);
}
}
}
let candidate = current.join(parts[idx]);
solve(parts, idx + 1, &candidate)
}
let root = PathBuf::from("/");
solve(&parts, 0, &root).unwrap_or_else(|| {
PathBuf::from(format!("/{}", stripped.replace('-', "/")))
})
}
fn scan_agents_dir(dir: &Path, scope: &str, project_name: Option<&str>) -> Vec<AgentConfig> {
let mut agents = Vec::new();
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return agents,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
if let Ok(content) = fs::read_to_string(&path) {
if let Some(agent) = parser::parse_agent(
&content,
&path.to_string_lossy(),
scope,
project_name,
) {
agents.push(agent);
}
}
} else if path.is_dir() {
let agent_file = path.join("AGENTS.md");
if agent_file.is_file() {
if let Ok(content) = fs::read_to_string(&agent_file) {
if let Some(agent) = parser::parse_agent(
&content,
&agent_file.to_string_lossy(),
scope,
project_name,
) {
agents.push(agent);
}
}
}
}
}
agents
}
fn scan_skill_references(skill_dir: &Path) -> Vec<SkillReference> {
let mut refs = Vec::new();
for (subdir, category) in &[("references", "reference"), ("rules", "rule")] {
let dir = skill_dir.join(subdir);
if dir.is_dir() {
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
if let Ok(content) = fs::read_to_string(&path) {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
refs.push(SkillReference {
name,
path: path.to_string_lossy().to_string(),
content,
category: category.to_string(),
});
}
}
}
}
}
}
refs.sort_by(|a, b| a.name.cmp(&b.name));
refs
}
fn scan_skills_dir(dir: &Path, scope: &str, project_name: Option<&str>) -> Vec<SkillConfig> {
let mut skills = Vec::new();
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return skills,
};
for entry in entries.flatten() {
let skill_dir = entry.path();
if skill_dir.is_dir() {
let skill_file = skill_dir.join("SKILL.md");
if skill_file.is_file() {
if let Ok(content) = fs::read_to_string(&skill_file) {
if let Some(mut skill) = parser::parse_skill(
&content,
&skill_file.to_string_lossy(),
scope,
project_name,
) {
skill.references = scan_skill_references(&skill_dir);
skills.push(skill);
}
}
}
}
}
skills
}
pub fn discover_agents() -> Vec<AgentConfig> {
let mut all = Vec::new();
let Some(claude) = claude_home() else {
return all;
};
let global_agents = claude.join("agents");
all.extend(scan_agents_dir(&global_agents, "global", None));
let config = crate::config::Config::load().unwrap_or_default();
let home = home_dir().unwrap_or_default();
for dir_cfg in &config.paths.claude_dirs {
let expanded = if let Some(stripped) = dir_cfg.path.strip_prefix("~/") {
home.join(stripped)
} else {
PathBuf::from(&dir_cfg.path)
};
let entries = match fs::read_dir(&expanded) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let project_dir = entry.path();
if !project_dir.is_dir() {
continue;
}
let dir_name = project_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
let real_path = decode_dir_name(dir_name);
let project_name = real_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(dir_name);
let mut seen_paths: HashSet<PathBuf> = HashSet::new();
for agents_dir in &[
real_path.join(".claude").join("agents"),
real_path.join(".agents").join("agents"),
] {
let canonical = agents_dir.canonicalize().unwrap_or_else(|_| agents_dir.clone());
if seen_paths.insert(canonical) {
all.extend(scan_agents_dir(agents_dir, project_name, Some(project_name)));
}
}
}
}
let plugins_dir = claude.join("plugins");
if plugins_dir.is_dir() {
if let Ok(entries) = fs::read_dir(&plugins_dir) {
for entry in entries.flatten() {
let plugin_dir = entry.path();
if plugin_dir.is_dir() {
let plugin_name = plugin_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let agents_dir = plugin_dir.join("agents");
all.extend(scan_agents_dir(&agents_dir, &format!("plugin:{plugin_name}"), None));
}
}
}
}
let mut seen: HashSet<PathBuf> = HashSet::new();
all.retain(|item| {
let canonical = fs::canonicalize(&item.file_path)
.unwrap_or_else(|_| PathBuf::from(&item.file_path));
seen.insert(canonical)
});
all.sort_by(|a, b| a.name.cmp(&b.name));
all
}
pub fn discover_skills() -> Vec<SkillConfig> {
let mut all = Vec::new();
let Some(claude) = claude_home() else {
return all;
};
let global_skills = claude.join("skills");
all.extend(scan_skills_dir(&global_skills, "global", None));
let config = crate::config::Config::load().unwrap_or_default();
let home = home_dir().unwrap_or_default();
for dir_cfg in &config.paths.claude_dirs {
let expanded = if let Some(stripped) = dir_cfg.path.strip_prefix("~/") {
home.join(stripped)
} else {
PathBuf::from(&dir_cfg.path)
};
let entries = match fs::read_dir(&expanded) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let project_dir = entry.path();
if !project_dir.is_dir() {
continue;
}
let dir_name = project_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
let real_path = decode_dir_name(dir_name);
let project_name = real_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(dir_name);
let mut seen_paths: HashSet<PathBuf> = HashSet::new();
for skills_dir in &[
real_path.join(".claude").join("skills"),
real_path.join(".agents").join("skills"),
] {
let canonical = skills_dir.canonicalize().unwrap_or_else(|_| skills_dir.clone());
if seen_paths.insert(canonical) {
all.extend(scan_skills_dir(skills_dir, project_name, Some(project_name)));
}
}
}
}
let plugins_dir = claude.join("plugins");
if plugins_dir.is_dir() {
if let Ok(entries) = fs::read_dir(&plugins_dir) {
for entry in entries.flatten() {
let plugin_dir = entry.path();
if plugin_dir.is_dir() {
let plugin_name = plugin_dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let skills_dir = plugin_dir.join("skills");
all.extend(scan_skills_dir(&skills_dir, &format!("plugin:{plugin_name}"), None));
}
}
}
}
let mut seen: HashSet<PathBuf> = HashSet::new();
all.retain(|item| {
let canonical = fs::canonicalize(&item.file_path)
.unwrap_or_else(|_| PathBuf::from(&item.file_path));
seen.insert(canonical)
});
all.sort_by(|a, b| a.name.cmp(&b.name));
all
}
pub fn group_agents(agents: Vec<AgentConfig>) -> Vec<AgentGroup> {
let mut groups: Vec<AgentGroup> = Vec::new();
for agent in agents {
if let Some(g) = groups.last_mut().filter(|g| g.name == agent.name) {
g.items.push(agent);
} else {
groups.push(AgentGroup { name: agent.name.clone(), identical: true, items: vec![agent] });
}
}
for g in &mut groups {
g.identical = g.items.windows(2).all(|w| w[0].body == w[1].body);
}
groups
}
pub fn group_skills(skills: Vec<SkillConfig>) -> Vec<SkillGroup> {
let mut groups: Vec<SkillGroup> = Vec::new();
for skill in skills {
if let Some(g) = groups.last_mut().filter(|g| g.name == skill.name) {
g.items.push(skill);
} else {
groups.push(SkillGroup { name: skill.name.clone(), identical: true, items: vec![skill] });
}
}
for g in &mut groups {
g.identical = g.items.windows(2).all(|w| w[0].body == w[1].body);
}
groups
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decode_dir_name_no_prefix() {
let decoded = decode_dir_name("my-project");
assert_eq!(decoded, PathBuf::from("my-project"));
}
#[test]
fn test_decode_dir_name_fallback() {
let decoded = decode_dir_name("-nonexistent-path-here");
assert_eq!(decoded, PathBuf::from("/nonexistent/path/here"));
}
}